游戏主循环:为什么必须用 delta time
每帧固定移动 5 像素,在 60Hz 屏幕上快一倍、在 144Hz 上快两倍半。把时间差乘进去,物理才与刷新率解耦;再加一个上限,切标签页回来才不会瞬移。
同一份「每帧移动 5 像素」的代码,在 60Hz 屏幕上速度正常,在 144Hz 上快两倍半,在掉帧的机器上慢得走不动。帧率不是常量,不能当作时间用。
错误写法与正确写法
// 错:速度随刷新率变
function frame() {
player.x += 5;
}
// 对:速度与时间成正比
let last = performance.now();
function frame(now: number) {
const dt = (now - last) / 1000; // 秒
last = now;
player.x += 300 * dt; // 每秒 300 像素
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
单位改成「每秒多少」之后,速度就与帧率无关了。
撞车与穿透
dt 变大时,一步可能跨过整堵墙。每秒 300 像素、一帧 100ms,一步就是 30 像素 —— 薄墙直接穿过去。
三种处理,按代价排序:
| 做法 | 代价 |
|---|---|
| 把 dt 上限压到 1/30 秒 | 掉帧时游戏变慢,但不会穿墙 |
| 网格或 AABB 连续检测 | 需要改碰撞算法 |
| 固定步长物理 + 插值渲染 | 最稳,也最复杂 |
小游戏用第一种就够:
const dt = Math.min((now - last) / 1000, 1 / 30);
上限不是可选项。 切走标签页再回来,某些浏览器会给出几秒的 dt,没有上限就是角色瞬移出屏幕。
固定步长:要吃「确定性」时的选择
如果回放、联机同步、或者物理稳定性要求每次运行结果一致,就不能用可变 dt:
const STEP = 1 / 60;
let acc = 0;
function frame(now: number) {
acc += Math.min((now - last) / 1000, 0.25);
last = now;
while (acc >= STEP) {
update(STEP); // 物理只看到固定的 STEP
acc -= STEP;
}
render(acc / STEP); // 渲染插值,消除台阶感
}
物理永远以 1/60 秒推进,渲染用剩余的 acc 做插值。这是「确定性」与「平滑」同时拿到的唯一办法。
计时别用 Date.now
Date.now() 的精度可能被降到毫秒甚至更粗,且会因系统时间校准而跳变。用 performance.now():
- 单调递增,不受系统时间调整影响
- 亚毫秒精度
游戏计时、动画、性能测量都该用它。
标签页不可见时要停
浏览器在标签页隐藏时会把 rAF 降到几乎没有。回来时那一刻的 dt 极大。除了上限,更稳的做法是监听可见性:
document.addEventListener('visibilitychange', () => {
if (document.hidden) pause();
else last = performance.now(); // 重新对齐,丢掉隐藏期间的时间
});
恢复时重置 last,比依赖 dt 上限更彻底。
帧是渲染的节拍,不是时间的单位。所有与时间有关的东西都必须乘 dt,且 dt 必须有上限。

评论
…