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

实验结果
$ 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 输入顺序不变 |
| 高速穿越薄墙 | 连续碰撞 | 不靠全局升频掩盖 |
| 累计时间超上限 | 执行明确过载策略 | 不无限追帧 |
几何接口
固定步长移动体已经能够复算位置。下一章以该权威位置为查询输入,通过最近点与有符号距离描述几何边界,并为离散接触、射线和连续碰撞提供统一基础。