主题
03 · 渲染帧与逻辑帧:别把一切塞进一帧
目标:理解「渲染帧」与「逻辑帧」为何要分开;实现固定步长 + 累加器;解释「快机器上游戏更快」从哪来。
前置:02 · 最小可玩循环
1. 问题:只用 dt 乘速度,还不够吗?
02 里常见写法:
ts
x += speed * dt1
这在帧率稳定时很香。但:
| 情况 | 后果 |
|---|---|
| 一帧卡到 100ms | 这一帧位移巨大,可能「穿墙」漏检碰撞 |
| 物理/判定想确定性 | 每帧步长不同,回放与手感难一致 |
| 把「生成一波敌人」绑在渲染帧上 | 高刷屏刷怪更快 |
所以专业循环常拆成:
- 逻辑帧(fixed update):固定
dt(如1/60秒)更新物理与玩法 - 渲染帧(render):按显示器节奏画;可用插值让画面更顺
2. 固定步长 + 累加器(最小实现)
ts
const STEP = 1 / 60 // 逻辑步长:秒
let acc = 0
let last = performance.now()
let x = 40
const speed = 120 // px/s
function update(fixedDt: number) {
x += speed * fixedDt
if (x > 480) x = 0
}
function render() {
ctx.fillStyle = '#0b1220'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#86efac'
ctx.fillRect(x, 300, 20, 20)
}
function frame(now: number) {
let frameDt = (now - last) / 1000
last = now
// 防止切后台回来一口吃掉过大时间(螺旋死亡)
if (frameDt > 0.25) frameDt = 0.25
acc += frameDt
while (acc >= STEP) {
update(STEP)
acc -= STEP
}
render()
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
要点:
- 累加器
acc:把真实流逝时间攒起来,够一个STEP就扣一次并update - 一帧内可能多次
update:掉帧时追赶逻辑,而不是一步跨过大位移(仍建议钳制frameDt) render每 rAF 一次:画面跟屏幕走
3. 「快机器上更快」从哪来
若有人这样写(错误示范):
ts
// 每渲染帧:敌人 y += 2(写死像素,不乘 dt,也不走固定步)
enemy.y += 21
2
2
则 144Hz 机器每秒下移约 2 * 144,60Hz 只有 2 * 60——这就是「好电脑打不过」。
正确方向二选一(阶段一够用):
- 用
速度 * 时间(dt或STEP) - 或把生成/移动放进固定
update(STEP),且位移只依赖STEP
4. 插值(先建立直觉,阶段一可不写)
acc / STEP 是「下一个逻辑帧还差多少比例」。高级做法用「上一状态 / 当前状态」插值再画,减少逻辑 60Hz、屏幕 144Hz 时的顿挫。
阶段一街机原型:固定步长 + 直接按当前状态渲染即可;插值留给 Phaser/Cocos 或以后优化。
5. 和前端事件循环的对照
| 浏览器 | 游戏循环 |
|---|---|
| 宏任务 / 微任务 | 输入事件写入「按键状态表」(见 04) |
| rAF 回调 | 本帧的 update + render |
| 长任务堵主线程 | 一帧 frameDt 飙高 → 需钳制与简化逻辑 |
游戏逻辑应保持短小可预测;重计算再考虑 Worker(本系列阶段一不做)。
验收清单
- [ ] 能解释:为什么要
while (acc >= STEP) - [ ] 代码里有对过大
frameDt的钳制 - [ ] 能指出「每帧
y += 2」为何依赖刷新率 - [ ]
update与render分成两个函数
