游戏中的数学 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");

当天实测
$ 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 输入顺序不变 |
| 高速穿越薄墙 | 连续碰撞 | 不靠全局升频掩盖 |
| 累计时间超上限 | 执行明确过载策略 | 不无限追帧 |
下一章
固定步长移动体已经能复算位置,但还不知道离几何边界多远。下一章从点到线段最近点和有符号距离开始,为离散接触、射线和连续碰撞提供同一套几何输入。