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 我的实战建议
做了这么多年游戏,我总结了一套自己的套路:
- 逻辑更新用固定步长,尤其是物理、网络、AI 这些对时序敏感的部分。
- 渲染用可变帧率,配合插值让画面平滑。插值其实很简单:在两次逻辑更新之间,根据 alpha 值对位置做线性插值。
- 帧率上限一定要加,防止某些机器跑出几千帧把显卡烧了。我见过一个案例,笔记本没开垂直同步,游戏跑到 800fps,风扇直接起飞。
- deltaTime 的上下限是保命符,不加的话,卡顿恢复时游戏会「瞬移」。
避坑指南:我曾经在一个手游项目里,用可变步长做物理,结果在低端安卓机上物理帧率掉到 20fps,角色跳跃高度直接减半。后来改成固定步长 + 物理帧率独立于渲染帧率,问题才解决。记住:物理引擎不是帧率无关的,它需要稳定的时间输入。
最后说一句:没有银弹。固定步长和可变步长各有优劣,关键看你的游戏类型。但如果你拿不准,选固定步长——它更稳,坑更少。等你踩过几次坑,自然就知道什么时候该用哪种了。