游戏循环与时间管理
概述
任何游戏都有一个看不见的"心脏"——游戏循环(Game Loop)。它不断重复"处理输入 → 更新逻辑 → 渲染画面"三个步骤,让静止的代码活起来。而时间管理则是游戏循环中最关键也最容易出错的部分:同一段移动逻辑,在 60Hz 和 144Hz 的屏幕上运行,速度会截然不同。本文将从最原始的循环开始,逐步推导出稳健的时间管理方案。
游戏循环基础
为什么需要游戏循环
想象一个简单的需求:让一个小球以每帧 5 像素的速度向右移动。直觉实现可能是:
function update() {
ball.x += 5 // 每帧移动 5 像素
draw()
}这个实现有一个致命问题:"每帧移动 5 像素"的速度依赖于帧率。60Hz 屏幕上小球每秒移动 300 像素,144Hz 屏幕上则移动 720 像素——同一段代码,速度差了 2.4 倍。
游戏循环解决的核心问题就是:将"游戏逻辑"与"运行时间"解耦,让游戏行为只取决于真实流逝的时间,与帧率无关。
循环的三种形态
1. 变步长循环(Variable Timestep)
每帧都使用真实时间差(Delta Time)来更新逻辑,是目前最主流的方案:
let lastTime = performance.now()
function gameLoop(now) {
const deltaTime = (now - lastTime) / 1000 // 秒
lastTime = now
processInput()
update(deltaTime) // 逻辑更新,传入真实时间差
render()
requestAnimationFrame(gameLoop)
}
requestAnimationFrame(gameLoop)优点:逻辑简单,帧率自适应,无多余计算。 缺点:时间步长不固定,物理模拟可能出现不稳定(详见下文 Fixed Timestep 部分)。
2. 固定步长循环(Fixed Timestep)
逻辑以固定时间步长(如 1/60 秒)执行,渲染仍按实际帧率进行:
const FIXED_STEP = 1 / 60 // 每步 1/60 秒
let accumulator = 0
let lastTime = performance.now()
function gameLoop(now) {
const frameTime = (now - lastTime) / 1000
lastTime = now
accumulator += frameTime
// 固定步长:攒够一步就执行一步
while (accumulator >= FIXED_STEP) {
update(FIXED_STEP)
accumulator -= FIXED_STEP
}
render()
requestAnimationFrame(gameLoop)
}优点:物理模拟稳定可复现,多人游戏便于帧同步。 缺点:单帧内可能执行多次 update,逻辑复杂时单帧耗时增加。
3. 固定帧率循环(Fixed FPS)
强制每帧间隔固定(如 16.7ms),通过 sleep 等待:
const FRAME_DURATION = 1000 / 60 // ms
function gameLoop() {
const start = performance.now()
processInput()
update(1 / 60)
render()
const elapsed = performance.now() - start
const waitTime = Math.max(0, FRAME_DURATION - elapsed)
setTimeout(gameLoop, waitTime) // 等待剩余时间
}缺点:setTimeout 精度差(约 4ms 抖动),且阻塞等待浪费 CPU。如今已被 requestAnimationFrame + Delta Time 取代,仅在帧率锁定场景(如模拟器)使用。
Delta Time 详解
什么是 Delta Time
Delta Time(简称 dt)指上一帧到当前帧之间真实流逝的时间。它的引入让所有"速度"都有了时间的量纲:
| 物理量 | 无 dt 的写法 | 有 dt 的写法 |
|---|---|---|
| 位置 | x += 5 | x += 5 * dt |
| 速度 | v += 0.2 | v += 0.2 * dt |
| 冷却 | cd -= 1 | cd -= dt |
当 dt = 1/60 时两种写法等价,但 dt 方案在任意帧率下都表现一致。
浏览器中的时间源
// ❌ 不可靠:Date.now() 受系统时钟调整影响
let t1 = Date.now()
setTimeout(() => console.log(Date.now() - t1), 16)
// ✅ 推荐:performance.now() 单调递增,精度达微秒级
const start = performance.now()
setTimeout(() => console.log(performance.now() - start), 16)requestAnimationFrame 回调的第一个参数 timestamp 本身就是高精度时间,直接用它计算 dt 即可。
防止 dt 过大
当页面切到后台再切回时,dt 会变得极大(可能几秒),导致角色瞬移、物理爆炸。需要设置 dt 上限:
const MAX_DT = 1 / 10 // 单帧最多按 0.1 秒计算
function gameLoop(now) {
let dt = (now - lastTime) / 1000
lastTime = now
dt = Math.min(dt, MAX_DT) // 钳制,防止后台切回时爆炸
update(dt)
render()
requestAnimationFrame(gameLoop)
}Fixed Timestep 深入
为什么需要固定步长
考虑重力模拟:每帧 v += g * dt; y += v * dt。当帧率不稳定时,dt 的波动会让物体轨迹产生微小偏差。在多数游戏中这无伤大雅,但在物理引擎、多人帧同步、确定性回放中,同样的输入必须产生完全一致的轨迹——这就必须用固定步长。
累积器模式(Accumulator)
固定步长的经典实现是累积器:把真实流逝的时间"攒"起来,每攒够一个步长就执行一次固定步长的更新:
const STEP = 1 / 60
let acc = 0
let last = performance.now()
function loop(now) {
acc += (now - last) / 1000
last = now
// 防螺旋死亡:一帧最多补偿 5 步,防止 update 卡死循环
let steps = 0
while (acc >= STEP && steps < 5) {
update(STEP)
acc -= STEP
steps++
}
// 仍然积压过多时丢弃剩余时间,避免"死亡螺旋"
if (acc > STEP) acc = 0
render()
requestAnimationFrame(loop)
}"死亡螺旋":如果单次 update 耗时超过步长,累积器会越积越多,导致 update 次数无限增长、游戏越来越卡。解决方案就是上面代码中的步数上限 + 时间丢弃。
插值渲染(Interpolation)
固定步长下逻辑更新频率与渲染频率不同步,可能产生"抖动"。标准解法是渲染插值:保存逻辑更新的前一状态和当前状态,根据累积器剩余比例插值渲染:
let prevState, currState
function render() {
const alpha = acc / STEP // 0 ~ 1,步长间的插值比例
const x = currState.x * alpha + prevState.x * (1 - alpha)
// 使用插值后的 x 渲染,画面平滑无抖动
}requestAnimationFrame 最佳实践
为什么用它
- 垂直同步:与屏幕刷新率同步,避免画面撕裂
- 节能:页面不可见时自动暂停,节省 CPU/GPU
- 高精度:回调参数提供高精度时间戳
常见陷阱
陷阱 1:回调里又创建了一个循环
// ❌ 错误:setTimeout 与 rAF 混用,时间基准错乱
function loop() {
setTimeout(() => {
const dt = (performance.now() - last) / 1000
update(dt)
requestAnimationFrame(loop) // 一个 rAF 一个 setTimeout,节奏混乱
}, 16)
}陷阱 2:dt 首次计算为 0
第一次回调时 lastTime 未初始化,dt 巨大。统一在循环外初始化 lastTime = performance.now()。
陷阱 3:同时使用多个时间源
所有 dt 计算必须使用同一个时间基准(统一 requestAnimationFrame 的 timestamp),不要混用 performance.now() 与 Date.now()。
综合示例:帧率无关的移动
将前面所有知识点综合成一个完整示例(按住左/右方向键移动,无论帧率如何,位移保持一致):
本章小结
- 游戏循环 = 处理输入 → 更新逻辑 → 渲染画面的无限循环
- 变步长(dt):简单通用,适合大部分逻辑
- 固定步长(accumulator):物理稳定、可复现,适合物理/多人/回放
- dt 上限钳制是必须的防御性代码,防止后台切回导致瞬移
requestAnimationFrame是浏览器的正确选择,时间源统一用其回调参数- 固定步长配合插值渲染可消除画面抖动
下一篇文章将讲解游戏开发最重要的数学工具——向量、矩阵与碰撞检测。