4.1 帧率无关的移动与 deltaTime
在 1.3 节我们第一次用 requestAnimationFrame 拼出了一个能跑的循环,但那个循环里的方块是「每帧移动固定像素」的。在 60Hz 显示器上它看起来正常,换到 144Hz 电竞屏就会快得离谱。这一节要解决的,是《方块冲刺》里第一个真正会咬人的问题:速度必须和时间挂钩,而不是和帧数挂钩。
本节推进《方块冲刺》的核心运动学:给
mini2d的Game.step(dt)引入正确的deltaTime语义。你会看到不乘 dt 会出什么 bug、为什么乘上就帧率无关、dt 从哪里来、要不要钳制,以及跳跃为什么是 dt 最容易被用错的地方。本章不写渲染,只关心「时间怎么流进物理」。
4.1.1 一个不乘 dt 的 bug
先看错误的写法。这是很多人写下的第一个「能跑」的版本:
// 错误:每帧加固定像素,与真实经过的时间无关
step(): void {
this.player.x += 3; // 3 像素 / 帧
this.player.vy += -30; // 每帧减 30 的速度
this.player.y += this.player.vy;
}
在 60Hz 下它每秒移动 3 × 60 = 180 像素。问题在于这里的「3 像素」隐含了一个前提:每帧恰好经过 1/60 秒。一旦帧率变化,这个前提就崩了。
| 设备 / 帧率 | 每秒移动像素 | 与 60Hz 相比 |
|---|---|---|
| 60Hz 笔记本 | 180 | 基准 |
| 144Hz 高刷屏 | 432 | 快 2.4 倍 |
| 30Hz 省电模式 | 90 | 慢一半 |
| 掉帧到 20fps | 60 | 慢 3 倍 |
同一份代码,在四台设备上跑出四种手感。更糟的是,帧率不是恒定的——后台标签页被浏览器降频、某一帧里恰好做了一次垃圾回收,都会让「这一帧」的时长突然变化,于是速度也跟着抖。
这类 bug 最阴险的地方在于:它在开发机上永远复现不了。你的机器稳定 60fps,测试同事的中端安卓机在 30fps 上下浮动,玩家说「怎么跑着跑着突然慢一下」,而你在本地怎么也看不出来。
4.1.2 为什么乘 dt 就帧率无关
修法只有一处:把「每帧多少」改成「每秒多少」,再乘上这一帧实际经过的秒数。
// 正确:速度单位是 像素/秒,乘上本帧经过的秒数
step(dt: number): void {
this.player.x += MOVE_SPEED * dt; // MOVE_SPEED = 260 px/s
this.player.vy += GRAVITY * dt; // GRAVITY = -1800 px/s²
this.player.y += this.player.vy * dt;
}
为什么这样就对了?因为位移在数学上就是速度对时间的积分:Δx = ∫ v dt ≈ v · dt。当 dt 足够小时,积分可以近似成一次乘法。无论一帧是 16.7ms 还是 6.9ms,乘出来的位移都对应真实经过的时间,累计起来就是同一条轨迹。
用 1 秒的移动验证一遍,两种帧率下总位移应该几乎相等:
| 帧率 | 单帧 dt | 每帧位移(260 px/s) | 1 秒累计 |
|---|---|---|---|
| 60fps | 0.01667s | 4.333 px | 260 px |
| 120fps | 0.00833s | 2.167 px | 260 px |
| 30fps | 0.03333s | 8.667 px | 260 px |
差别只在浮点误差里。这就是「帧率无关」的全部含义:结果由真实时间决定,与切成多少帧无关。
要注意,帧率无关不等于「绝对精确」。把 1 秒切成 30 份和切成 120 份,用一次乘法近似积分,误差量级是 O(dt²)。对跑酷这种游戏无所谓;对需要长期守恒的物理(比如轨道模拟)就要用更高阶的积分器——那是 4.2 节固定步长要缓解的问题之一。
4.1.3 dt 从哪里来
dt 不是凭空来的,它来自两个真实时间戳的差:
let last: number | null = null;
function frame(now: number): void {
if (last === null) {
last = now; // 第一帧只对齐基准,不步进
requestAnimationFrame(frame);
return;
}
const dt = (now - last) / 1000; // 毫秒差 → 秒
last = now;
game.step(dt);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
三个要点:
- 用
performance.now(),不要用Date.now()。performance.now()返回单调递增的高精度毫秒时间戳;Date.now()会被系统时钟调整(NTP 校时、用户改时间)影响,可能出现负的 dt,让物理倒着走。 - 单位换算要显式。浏览器给毫秒,物理公式要秒。少除一个 1000,重力就会大一千倍,方块瞬间飞出屏幕。
- 第一帧要特殊处理。
last还没初始化时,now - last是垃圾值。上面的写法里第一次回调先把last设好再 return,这是最容易漏的一步。
4.1.4 为什么要钳制 dt
即使 dt 来路正确,也不能无脑用。考虑这些真实场景:
- 玩家切到别的标签页,游戏被浏览器降频甚至暂停,回来时
now - last可能是 8000ms。 - 断点调试时你在
step()里停了 30 秒。 - 手机息屏再点亮,WebView 恢复。
如果直接把这 8 秒喂给物理,方块会在一次 step 里瞬移 8 秒的位移,穿过沿途所有平台,俗称「穿墙」。所以真实引擎都会钳制单帧 dt 的上限:
const MAX_DT = 1 / 15; // 单帧最多按 66ms 算
const raw = (now - last) / 1000;
const dt = Math.min(raw, MAX_DT);
钳制之后,卡顿会被「慢动作」化——游戏时间比真实时间走得慢——但物理不会爆。这正是 4.2 节固定步长累加器要解决的同一类问题,只不过那里做得更彻底:不是简单截断,而是把多余的时间攒起来慢慢补。
4.1.5 先乘后加,还是先加后乘
x += v * dt 这一行里其实藏了两个动作:先算 v * dt(乘),再加到 x(加)。但真正容易写错的是速度更新和位置更新这两行谁先谁后:
// 变体 A:先用旧速度更新位置,再更新速度(显式欧拉)
p.x += p.vx * dt;
p.vx += GRAVITY * dt;
// 变体 B:先更新速度,再用新速度更新位置(半隐式欧拉)
p.vx += GRAVITY * dt;
p.x += p.vx * dt;
两种写法在一帧内的差别只有 GRAVITY * dt²,看起来微不足道,但它是系统性的:变体 A 会让每次落地后系统累积一点点多余能量,方块在平台上轻微抖动;变体 B(半隐式欧拉)则稳定得多,几乎所有游戏引擎都用它。
mini2d 的 Game.step 用的就是变体 B——先算 vy += GRAVITY * dt,再用新的 vy 更新 y。记住这条顺序,能省掉后面第 7 章碰撞响应里一大半的「奇怪抖动」排查时间。
4.1.6 跳跃为什么是 dt 最容易被用错的地方
跳跃的坑和「是否乘 dt」相反——它的问题是乘法被用在了不该用的地方。
瞬时速度的正确写法是直接赋值:
if (jumpPressed) {
p.vy = JUMP_VELOCITY; // 720 px/s,设置的是速度,不乘 dt
p.onGround = false;
}
但如果有人想「跳得高一点,那就乘 dt」,就会写成这样:
// 错误:把位移公式错套到瞬时速度上
p.vy = JUMP_VELOCITY * dt; // dt ≈ 0.0167,跳跃高度几乎为 0
瞬时速度不是「这一帧积累的量」,它没有「每秒」以外的隐含时间。乘上 dt 等于把它缩小了 60 倍。判断规则很简单:只有持续作用的量(速度、加速度、角速度)才乘 dt;一次性事件(起跳、开火、开关切换)不乘。
还有第二个坑:起跳输入必须在同一帧被消费。如果 jump 是个「按住就一直为真」的布尔量,玩家落地后只要还按着键就会立刻再跳一次,变成连跳。mini2d 的做法是在 step 里用 onGround 守住起跳条件,同时由输入层保证 jump 是边沿触发(本帧刚按下为真):
if (this.input.jump && p.onGround) {
p.vy = JUMP_VELOCITY;
p.onGround = false;
}
// 输入层每帧结束后复位,下一帧要重新按下才算数
this.input.jump = false;
边沿触发、输入缓冲这些手感细节留到第 5 章展开,这里先记住:dt 只属于连续量。
4.1.7 一个可复现的对照实验
口说无凭,用假时钟做一次对照实验:同一段 1 秒的真实时间,分别用「每帧 3 像素」和「260 px/s × dt」推进,看方块走了多远。
// 实验一:不乘 dt(每帧固定像素)
function fixedPerFrame(frameCount: number): number {
let x = 0;
for (let i = 0; i < frameCount; i += 1) x += 3;
return x;
}
// 实验二:乘 dt(像素/秒)
function scaledByDt(frames: number[], speed: number): number {
let x = 0;
for (const dt of frames) x += speed * dt;
return x;
}
const fps60 = Array.from({ length: 60 }, () => 1 / 60);
const fps144 = Array.from({ length: 144 }, () => 1 / 144);
console.log(fixedPerFrame(60)); // 180
console.log(fixedPerFrame(144)); // 432 ← 同一秒,快 2.4 倍
console.log(scaledByDt(fps60, 260)); // 260
console.log(scaledByDt(fps144, 260)); // 260 ← 帧率无关
结果一目了然:
| 实验 | 60fps 结果 | 144fps 结果 | 是否帧率无关 |
|---|---|---|---|
| 每帧 3 像素 | 180 | 432 | 否 |
| 260 px/s × dt | 260 | 260 | 是 |
这个实验还可以换一组「忽大忽小」的帧序列,比如 [0.016, 0.040, 0.012, 0.033, ...](总和恰好 1 秒),scaledByDt 依然得到 260。它验证的正是 4.1.2 的结论:总位移只取决于真实时间总量,与怎么切分无关。
不过要小心一个常见的误解:帧率无关不等于确定性。上面实验二在 60fps 和 144fps 下结果相同,是因为它是纯粹的线性积分;一旦引入碰撞、摩擦、非线性阻尼,切分方式就会影响结果——1 秒切成 60 份和切成 144 份,碰撞发生的时刻不同,后续轨迹就可能分叉。要真正做到「同一份输入永远得到同一份结果」,需要 4.2 节的固定步长把切分方式也固定下来。这也是第 16 章确定性回放的地基。
4.1.8 Cocos Creator 3.8 对照:dt 从哪来
在 Cocos 3.8 里,你几乎不会自己算 dt。Component 的 update(dt) 由引擎每帧回调,dt 已经是以秒为单位的上一帧增量:
import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;
@ccclass('PlayerMove')
export class PlayerMove extends Component {
private speed = 260; // px/s
protected update(dt: number): void {
const pos = this.node.position;
this.node.setPosition(pos.x + this.speed * dt, pos.y, pos.z);
}
}
几个要点:
update(dt)的dt单位是秒(cc.d.ts里明确写 “the delta time in seconds”),和mini2d的step(dt)口径一致,公式可以原样搬过来。- 想让「整帧逻辑都跑完再对齐表现」用
lateUpdate(dt):相机跟随、渲染插值放这里。 - 引擎级的 dt 从
game.deltaTime(秒)拿。director.getDeltaTime()同样能拿到,但cc.d.ts标了@deprecated since v3.3.0, please use game.deltaTime instead,新代码不要用。 game.totalTime是毫秒口径,别和秒混用;要秒就自己除以 1000。
下面这张表把两边的对应关系列清楚:
| 用途 | mini2d | Cocos Creator 3.8 |
|---|---|---|
| 每帧逻辑 | Game.step(dt) | Component.update(dt) |
| 帧末对齐表现 | 手动在 step 末尾 | Component.lateUpdate(dt) |
| 取上一帧增量 | 自己 now - last | game.deltaTime(秒) |
| 取总时长 | 自己累加 | game.totalTime(毫秒) |
本节给出的 Cocos 代码是 L2 片段,只保证与
cc.d.ts(Cocos Creator 3.8.8)类型一致,不参与mini2d的 vitest 测试。
到这里,我们解决了「单帧 dt 怎么用」。但帧率本身是会变的——有的帧 16ms,有的帧 40ms。下一节把乘 dt 的直觉推广到整套固定步长模拟:用累加器把可变时间切成固定步长,再用渲染插值把画面抹平,让物理稳定与画面顺滑同时成立。
阅读导航:上一节:目录 · 下一节:4.2 固定步长物理与渲染插值 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。