3、固定时间步长:游戏循环的基石,dt(delta time)的概念
聊到游戏循环,很多人第一反应就是「while(running) update(); render();」。嗯,没错,骨架是这样。但真正让这个循环稳定、可预测、不随机器性能飘忽不定的,是那个不起眼的小东西——dt(delta time)。
我个人习惯把 dt 叫作「时间步长」。说白了,就是上一帧到这一帧过去了多少秒。你想想看,如果游戏逻辑更新不关心时间,只按帧跑,那 60fps 和 30fps 下角色的移动速度会差一倍。这显然不行。
3.1 为什么需要固定时间步长?
先看一个最朴素的写法:
while (running) {
processInput();
update(); // 假设这里让角色移动 5 像素
render();
}
这段代码在 60fps 的机器上,角色每秒移动 300 像素。换到 30fps 的机器上,每秒只移动 150 像素。同样的游戏,不同的体验——这就是帧率依赖的坑。
我在项目中遇到过这种情况:测试同学拿了一台老笔记本跑我们的原型,角色走路像踩了胶水。查了半天,发现所有移动量都是硬编码的「每帧 5 像素」。嗯,这就是典型的「帧率敏感」代码。
3.2 dt 的两种计算方式
dt 的计算其实就两个关键点:记录上一帧的时间,用当前时间减去它。但具体实现上有两种主流做法。
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 实时 dt | 每帧用真实时间差 | 实现简单,直接反映实际帧间隔 | 帧率波动时 dt 不稳定,物理模拟容易出问题 |
| 固定 dt | 设定一个固定值(如 1/60 秒),循环累加追赶 | 逻辑更新稳定,物理/动画可预测 | 需要额外处理「追赶」逻辑,实现稍复杂 |
我个人更推荐固定 dt。为什么?因为游戏逻辑(尤其是物理模拟)对时间步长的一致性要求很高。你想想看,如果 dt 忽大忽小,物理引擎里的积分器会发散,碰撞检测会漏检,动画会抖动。
3.3 固定时间步长的标准实现
这里给出一个我常用的固定时间步长循环骨架。它来自 Glenn Fiedler 的经典文章《Fix Your Timestep》,我稍微做了点调整。
double previous = getCurrentTime();
double lag = 0.0;
const double dt = 1.0 / 60.0; // 固定步长 1/60 秒
while (running) {
double current = getCurrentTime();
double elapsed = current - previous;
previous = current;
lag += elapsed;
// 处理输入(可以每帧都处理,不受 dt 影响)
processInput();
// 用固定步长追赶逻辑
while (lag >= dt) {
update(dt); // 物理/逻辑更新,传入固定 dt
lag -= dt;
}
// 渲染(可以插值,让画面更平滑)
render(lag / dt);
}
这段代码的核心思想是:逻辑更新用固定步长,渲染用实时帧率。lag 变量累积了「欠下的时间」,每次用 dt 去消耗它。如果 lag 超过 dt,就多更新一次逻辑;如果 lag 不足 dt,就跳过更新,直接渲染。
position += velocity * dt。这样无论帧率怎么变,角色每秒移动的距离都是恒定的。
3.4 渲染插值:让画面更丝滑
你可能会问:固定步长下,逻辑更新频率是固定的(比如 60 次/秒),但渲染频率可能更高(比如 144fps)。那多出来的渲染帧怎么办?
答案是渲染插值。上面代码里 render(lag / dt) 中的 lag / dt 就是插值因子,范围在 [0, 1) 之间。它表示当前渲染帧相对于上一次逻辑更新的「进度」。
举个例子:假设逻辑更新在 t=0 和 t=1/60 时各更新一次。渲染在 t=0.005 秒时发生,此时 lag/dt = 0.3。渲染时可以把物体的位置在「上一次更新位置」和「下一次更新位置」之间线性插值 30%。这样画面看起来就是连续平滑的,而不是一跳一跳的。
3.5 dt 的边界情况处理
实际项目中,dt 会遇到一些极端情况。我踩过几个坑,列出来供你参考。
- 断点调试后恢复: 如果程序被断点卡住几秒,恢复后 elapsed 会非常大。lag 会瞬间累积几十秒,导致 while(lag >= dt) 循环几万次。解决方案是给 elapsed 设一个上限,比如 0.1 秒。
- 窗口最小化/切换: 类似断点,窗口被挂起后恢复,也会产生巨大的 elapsed。同样需要 clamp。
- 慢速设备: 如果设备性能太差,逻辑更新跟不上实时时间,lag 会无限增长。这时需要限制最大更新次数,或者主动降低游戏速度(慢动作模式)。
// 处理断点/挂起后的时间跳跃
double elapsed = current - previous;
if (elapsed > 0.1) elapsed = 0.1; // 最多累积 0.1 秒
lag += elapsed;
// 防止逻辑更新无限追赶
int maxUpdates = 5;
while (lag >= dt && maxUpdates-- > 0) {
update(dt);
lag -= dt;
}
3.6 固定时间步长的知识体系
下面这张图梳理了固定时间步长的核心逻辑和决策路径。你可以把它当作一个快速参考。
从这张图可以清楚看到:固定时间步长的核心是一个消费-累积循环。lag 是「时间债务」,dt 是「还款单位」。每次还清一笔债,就更新一次逻辑。还清了,就去渲染;没还清,继续还。
3.7 什么时候用实时 dt,什么时候用固定 dt?
我个人的选择标准很简单:
- 固定 dt:任何涉及物理模拟、碰撞检测、动画状态机、网络同步的游戏。说白了,只要逻辑的「可重复性」和「确定性」重要,就用固定 dt。
- 实时 dt:纯 UI 动画、非交互式演示、工具类应用。这些场景对帧率一致性要求不高,实时 dt 实现简单,够用就行。