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 像素」。嗯,这就是典型的「帧率敏感」代码。

避坑指南: 我曾经在一个小游戏项目里直接用帧计数驱动动画,结果在 144Hz 显示器上角色快得像闪电。从那以后,我所有涉及时间、速度、动画的逻辑,一律用 dt 做系数。

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,就跳过更新,直接渲染。

关键点: update() 里所有涉及速度、加速度、动画进度的计算,都乘以 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%。这样画面看起来就是连续平滑的,而不是一跳一跳的。

我的经验: 渲染插值对 2D 游戏效果非常明显。我曾经在一个像素风平台游戏里加了插值,测试同学说「感觉角色滑冰了一样顺」。但注意,插值只适用于渲染,不要污染逻辑状态。

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 固定时间步长的知识体系

下面这张图梳理了固定时间步长的核心逻辑和决策路径。你可以把它当作一个快速参考。

固定时间步长核心逻辑图 固定时间步长核心逻辑 游戏循环开始 计算 elapsed = current - previous clamp(elapsed, 0, 0.1) lag += elapsed lag >= dt ? 是 update(dt) 否 render(alpha) alpha = lag / dt 用于渲染插值

从这张图可以清楚看到:固定时间步长的核心是一个消费-累积循环。lag 是「时间债务」,dt 是「还款单位」。每次还清一笔债,就更新一次逻辑。还清了,就去渲染;没还清,继续还。

3.7 什么时候用实时 dt,什么时候用固定 dt?

我个人的选择标准很简单:

  • 固定 dt:任何涉及物理模拟、碰撞检测、动画状态机、网络同步的游戏。说白了,只要逻辑的「可重复性」和「确定性」重要,就用固定 dt。
  • 实时 dt:纯 UI 动画、非交互式演示、工具类应用。这些场景对帧率一致性要求不高,实时 dt 实现简单,够用就行。
一句话总结: 固定时间步长让游戏逻辑与帧率解耦。dt 是连接「真实时间」和「游戏时间」的桥梁。掌握了它,你的游戏循环才算真正「站稳了」。