4. 游戏循环与时间控制:固定时间步长 vs 可变时间步长,FPS 控制与 deltaTime 计算

聊到游戏循环,很多新手会把它想得很玄乎。其实说白了,它就是游戏的心脏——一个永不停歇的 while 循环。每次循环,我们处理输入、更新逻辑、渲染画面。嗯,就这么简单。

但问题来了:这个循环跑多快?怎么保证在不同机器上表现一致?我当年刚入行时,就被这个问题狠狠坑过一次。

4.1 游戏循环的基本结构

先看一个最朴素的循环骨架:

while (gameIsRunning) {
    processInput();   // 处理键盘、鼠标事件
    update();         // 更新游戏逻辑(物理、AI、碰撞检测)
    render();         // 绘制画面
}

这个结构本身没问题。但如果你直接这么写,在 60Hz 的显示器上跑得飞起,换到 144Hz 的机器上,游戏速度直接翻倍。为什么?因为每次循环都执行一次 update,循环越快,逻辑更新就越频繁。

注意:千万别把游戏逻辑和渲染帧率绑定在一起。我曾经见过一个项目,物理引擎在 30fps 下正常,跑到 60fps 时角色直接穿墙——因为每帧的位移量没做时间补偿。

4.2 可变时间步长(Variable Time Step)

最简单的解决方案:计算两帧之间的时间差,也就是 deltaTime。然后所有跟时间相关的逻辑都乘以这个值。

float deltaTime = 0.0f;
float lastFrameTime = 0.0f;

while (gameIsRunning) {
    float currentTime = getCurrentTime();
    deltaTime = currentTime - lastFrameTime;
    lastFrameTime = currentTime;

    // 限制 deltaTime 防止卡顿时逻辑爆炸
    if (deltaTime > 0.05f) deltaTime = 0.05f;

    processInput();
    update(deltaTime);   // 传入时间差
    render();
}

// update 内部
void update(float dt) {
    player.position += player.velocity * dt;  // 速度乘以时间
    // 物理引擎也依赖 dt
}

这样做的好处很明显:游戏在任何帧率下表现一致。60fps 时每帧 16ms,120fps 时每帧 8ms,但累积位移量是一样的。

但有个坑:物理引擎对可变时间步长很敏感。如果 dt 忽大忽小,物理模拟会不稳定。我做过一个赛车游戏,用可变步长跑物理,结果车子在帧率波动时像在冰面上打滑。后来我查了三天才找到原因——物理积分需要固定步长。

4.3 固定时间步长(Fixed Time Step)

固定步长的思路是:不管渲染帧率怎么变,逻辑更新都按固定的时间间隔执行。比如每 16.67ms(60fps)更新一次物理。

const float FIXED_DT = 1.0f / 60.0f;  // 固定 60fps 逻辑更新
float accumulator = 0.0f;
float deltaTime = 0.0f;
float lastFrameTime = 0.0f;

while (gameIsRunning) {
    float currentTime = getCurrentTime();
    deltaTime = currentTime - lastFrameTime;
    lastFrameTime = currentTime;

    // 限制最大步长,防止卡顿时陷入死循环
    if (deltaTime > 0.25f) deltaTime = 0.25f;

    accumulator += deltaTime;

    while (accumulator >= FIXED_DT) {
        update(FIXED_DT);   // 固定步长更新
        accumulator -= FIXED_DT;
    }

    // 可选:插值渲染,让画面更平滑
    float alpha = accumulator / FIXED_DT;
    render(alpha);
}

核心思想:逻辑更新用固定步长保证稳定性,渲染帧率可以自由浮动。如果机器性能好,渲染帧率可以很高;如果卡顿,逻辑更新会追赶,但不会跳过物理帧。

我个人习惯用固定步长做物理和网络同步。尤其是网络游戏,如果客户端和服务器的步长不一致,角色位置会来回拉扯。固定步长能保证所有端的逻辑帧对齐。

4.4 FPS 控制与帧率限制

有时候我们需要主动限制帧率。比如手机游戏跑 120fps 太费电,或者某些老旧显示器只支持 60Hz。怎么做?

const int TARGET_FPS = 60;
const float FRAME_TIME = 1.0f / TARGET_FPS;

while (gameIsRunning) {
    float frameStart = getCurrentTime();

    processInput();
    update(deltaTime);
    render();

    float frameEnd = getCurrentTime();
    float elapsed = frameEnd - frameStart;

    // 如果这一帧跑得太快,就睡一会儿
    if (elapsed < FRAME_TIME) {
        sleep(FRAME_TIME - elapsed);
    }
}

这里有个细节:sleep 的精度在不同操作系统上不一样。Windows 的 Sleep 精度约 1ms,Linux 的 nanosleep 更准。如果你做的是竞技游戏,建议用自旋等待(忙等)来保证精度,但代价是 CPU 占用高。

小技巧:我一般在开发阶段不限制帧率,方便调试。发布版本才加上帧率限制。另外,给玩家一个「垂直同步」选项,让玩家自己选择是追求流畅度还是省电。

4.5 deltaTime 计算的陷阱

deltaTime 看似简单,但有几个地方容易翻车:

  • 首帧问题:第一帧的 deltaTime 可能非常大(从 0 开始计时)。我习惯初始化 lastFrameTime 为当前时间,或者直接跳过第一帧的更新。
  • 暂停恢复:游戏暂停后恢复,deltaTime 会跳变。记得在暂停时重置 lastFrameTime。
  • 调试器断点:如果你在 IDE 里打了断点,回来时 deltaTime 可能已经几十秒了。一定要加上限限制。
// 一个更健壮的 deltaTime 计算
float calculateDeltaTime() {
    static float lastTime = getCurrentTime();
    float currentTime = getCurrentTime();
    float dt = currentTime - lastTime;
    lastTime = currentTime;

    // 上限 100ms,防止卡顿或调试中断
    if (dt > 0.1f) dt = 0.1f;
    // 下限 0,防止时间倒流(虽然理论上不会)
    if (dt < 0.0f) dt = 0.0f;

    return dt;
}

4.6 两种步长的对比

特性 可变时间步长 固定时间步长
实现难度 简单 中等
物理稳定性 差(dt 波动影响积分) 好(恒定步长)
渲染平滑度 好(每帧都渲染) 需要插值才能平滑
CPU 占用 帧率越高占用越高 逻辑更新固定,渲染可限制
适用场景 简单 2D 游戏、UI 动画 物理模拟、网络同步、竞技游戏

你想想看,如果做《愤怒的小鸟》这种物理核心的游戏,用可变步长会怎样?弹弓的蓄力、小鸟的抛物线,每一步都依赖精确的物理积分。dt 一波动,小鸟可能飞到天上去。所以这类游戏必须用固定步长。

反过来,一个卡牌游戏,UI 动画用可变步长完全没问题。甚至你还可以让动画在低帧率下自动减速,看起来更优雅。

4.7 我的实战建议

做了这么多年游戏,我总结了一套自己的套路:

  1. 逻辑更新用固定步长,尤其是物理、网络、AI 这些对时序敏感的部分。
  2. 渲染用可变帧率,配合插值让画面平滑。插值其实很简单:在两次逻辑更新之间,根据 alpha 值对位置做线性插值。
  3. 帧率上限一定要加,防止某些机器跑出几千帧把显卡烧了。我见过一个案例,笔记本没开垂直同步,游戏跑到 800fps,风扇直接起飞。
  4. deltaTime 的上下限是保命符,不加的话,卡顿恢复时游戏会「瞬移」。

避坑指南:我曾经在一个手游项目里,用可变步长做物理,结果在低端安卓机上物理帧率掉到 20fps,角色跳跃高度直接减半。后来改成固定步长 + 物理帧率独立于渲染帧率,问题才解决。记住:物理引擎不是帧率无关的,它需要稳定的时间输入。

最后说一句:没有银弹。固定步长和可变步长各有优劣,关键看你的游戏类型。但如果你拿不准,选固定步长——它更稳,坑更少。等你踩过几次坑,自然就知道什么时候该用哪种了。