本文属于系列「游戏中的数学:从坐标到可同步运动」第 10 / 15 篇

游戏中的数学 10:固定时间步与数值积分

上一章给出的 position、velocity、acceleration 需要在统一时间协议中推进。本章采用固定步长 dt=1/hz,避免运动结果依赖设备帧率、卡顿和追帧策略。所有比较继续使用前文定义的米、秒与速度单位。抛体运行两秒,弹簧运行 10,000 Tick;评价指标分别为离散位置误差与能量漂移。

// $WORK_DIR/.tmp/game-math-lab/Program.cs · Chapter10 抛体
foreach (var hz in new[] { 30, 60, 120 })
{
    var dt = 1.0 / hz;
    var position = new Vector2(0, 0);
    var velocity = new Vector2(10, 15);

    for (var i = 0; i < hz * 2; i++)
    {
        velocity += new Vector2(0, -9.81f) * (float)dt;
        position += velocity * (float)dt;
    }

    var exact = new Vector2(
        20, (float)(15 * 2 - .5 * 9.81 * 4));
    projectile.Add(new P(
        hz, Vector2.Distance(position, exact)));
}

时间步定义

逻辑时钟持有 Tick 序号和固定 dt,运动状态持有位置与速度,外力系统提交加速度或冲量。渲染层可以插值两个已确认状态,但不反向修改 Tick。半隐式欧拉先更新速度,再使用新速度更新位置;相同初值、输入和 Tick 数产生相同结果。

帧循环通过累加器将墙钟时间转换为零个或多个 Tick。累加器仅决定当前帧推进步数,不改变单步 dt。卡顿欠账超过上限时,系统执行既定过载策略并记录丢弃或延迟;临时放大步长会改变碰撞与回放依赖的状态转移。

// 半隐式欧拉的状态转移
velocity += acceleration * dt;
position += velocity * dt;

// 显式欧拉的状态转移
position += velocity * dt;
velocity += acceleration * dt;

连续抛体与固定步长离散轨迹

步长误差

抛体始终使用同一种半隐式更新。30Hz 的两秒位置误差为 0.326992 m,60Hz 为 0.163485 m,120Hz 为 0.081733 m:步长减半,误差近似减半。提高频率会线性增加 Tick 数,并推高碰撞、脚本和网络输入处理成本,因此频率选择同时受误差与预算约束。

{
  "ticks": 10000,
  "projectile_errors": [
    { "X": 30, "Y": 0.3269920349121094 },
    { "X": 60, "Y": 0.1634845733642578 },
    { "X": 120, "Y": 0.08173274993896484 }
  ],
  "max_catchup_ticks": 5
}

能量漂移

弹簧系统用于观察积分器的长期性质。显式欧拉持续注入数值能量,60Hz 运行 10,000 Tick 后,相对能量漂移达到 8.44386477935688e11;半隐式欧拉为 0.0154113。该数据仅证明当前输入下半隐式欧拉更稳定,不表示严格能量守恒,也不能外推为所有物理系统的统一选择。

// Program.cs · SpringEnergy
static P[] SpringEnergy(int hz, int ticks, bool semi)
{
    var dt = 1.0 / hz;
    double x = 1, v = 0, k = 10, m = 1;
    var samples = new List<P>();

    for (var i = 0; i < ticks; i++)
    {
        var a = -k * x / m;
        if (semi)
        {
            v += a * dt;
            x += v * dt;
        }
        else
        {
            x += v * dt;
            v += a * dt;
        }

        if (i % 100 == 0)
            samples.Add(new P(i, .5 * m * v * v + .5 * k * x * x));
    }
    return samples.ToArray();
}

过载处理

回归测试约束三项行为:较小固定步长降低抛体误差;半隐式欧拉的弹簧能量漂移低于显式欧拉;一次 200ms 卡顿在 60Hz 下形成 12 个 Tick 欠账时,累加器执行 5 个并丢弃 7 个完整 Tick,余数为零。丢弃只是当前策略,服务器也可以选择减速、迁移或断线恢复,但策略需要进入协议。

状态推进顺序为收集输入、推进固定 Tick、发布新状态、渲染插值;累计时间越界时进入过载分支。服务器可以记录落后并迁移房间,客户端可以降低表现质量或请求重同步,但两端保持相同 dt,避免后续校正继续放大。

// Program.cs · Chapter10:比较与断言
var explicit60 = SpringEnergy(60, 10_000, false);
var semi60 = energy[1].Points.ToArray();

var explicitDrift = Math.Abs(
    explicit60[^1].Y / explicit60[0].Y - 1);
var semiDrift = Math.Abs(
    semi60[^1].Y / semi60[0].Y - 1);

Check(
    projectile[2].Y < projectile[0].Y,
    "smaller fixed step reduces projectile error");
Check(
    semiDrift < explicitDrift,
    "semi-implicit Euler bounds spring energy better");

var catchup = ConsumeFrame(.2, 1.0 / 60, 5);
Check(catchup.ExecutedTicks == 5
    && catchup.DroppedTicks == 7
    && catchup.ResidualSeconds < 1e-9,
    "catch-up cap is deterministic");

固定 Tick 下的弹簧能量漂移

实验结果

$ cd $WORK_DIR
$ dotnet run --project .tmp/game-math-lab/GameMathLab.csproj -- --out .tmp/game-math-lab/out

CH10 PASS ticks=10000 projectile_error_30=0.326992m 120=0.081733m energy_drift_explicit=844386477935.6880 semi=0.0154 catchup=5 dropped=7
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=148.7
exit_code=0
{
  "explicit_60hz_energy_drift": 844386477935.688,
  "semi_60hz_energy_drift": 0.015411278197765443,
  "max_catchup_ticks": 5,
  "catchup": {
    "ExecutedTicks": 5,
    "DroppedTicks": 7,
    "ResidualSeconds": 0
  }
}

求解器选择

60Hz 构成当前逻辑基线,而非普适频率。低速玩法可以在误差预算允许时降频;高速弹体和刚体接触更适合局部子步或连续碰撞,而非将整个世界统一提高到 120Hz。追帧上限保护单帧预算,同时要求系统在过载时明确选择减速、丢弃累计时间或断线恢复。

触发条件 首选动作 不变量
抛体误差超预算 减小局部步长 Tick 输入顺序不变
高速穿越薄墙 连续碰撞 不靠全局升频掩盖
累计时间超上限 执行明确过载策略 不无限追帧

几何接口

固定步长移动体已经能够复算位置。下一章以该权威位置为查询输入,通过最近点与有符号距离描述几何边界,并为离散接触、射线和连续碰撞提供统一基础。