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

游戏中的数学 10:固定步长怎样推进运动

运动逻辑若直接吃渲染帧的 deltaTime,同一输入会因设备帧率、卡顿和追帧策略产生不同结果。本章把步长固定为 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 所有权

逻辑时钟拥有 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=112.8
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 输入顺序不变
高速穿越薄墙 连续碰撞 不靠全局升频掩盖
累计时间超上限 执行明确过载策略 不无限追帧

下一章

固定步长移动体已经能复算位置,但还不知道离几何边界多远。下一章从点到线段最近点和有符号距离开始,为离散接触、射线和连续碰撞提供同一套几何输入。