1.3 第一个可跑循环:requestAnimationFrame 与固定步长
1.2 节把「一帧是一次采样」「dt 单位是秒」「世界坐标 y 向上」三件事定死了。这一节把它们接起来,写出 mini2d 的第一个能跑的主循环——一个会在浏览器里画出向右移动方块的循环,而且它的核心逻辑能在 Node 里被假时钟复现成单测。
这是《方块冲刺》的第一行游戏代码,也是后面所有章节的骨架:第 4 章会把这个循环里的时间记账抽成独立的 Clock,第 5 章会把输入接进来,第 7 章会把碰撞塞进 step。但它们的形状,在这里就定了。
本节的目标很具体:写一个
Loop类,它每帧把真实经过的时间攒起来,按固定步长调用step(dt);再用FakeTime假时钟证明「不管帧率怎么抖,总步数只由真实时间决定」;最后对照 Cocos Creator 3.8 的director与Component.update。
1.3.1 requestAnimationFrame:浏览器给的节拍器
最朴素的想法是 setInterval(fn, 16)。别这么做,原因有三:
setInterval与屏幕刷新不同步。 它可能在一帧里触发两次、在另一帧里一次都不触发,画面上就是撕裂与抖动。- 后台标签页里
setInterval会被限流甚至暂停,恢复时一口气补发一串回调。 - 它给不出精确的时间戳,你得自己去读时钟。
requestAnimationFrame(rAF)是浏览器专门为动画准备的:它由显示器的垂直同步驱动,每次屏幕刷新前调用一次你的回调,并传入一个高精度的单调时间戳。60Hz 屏上它约 16.7ms 一次,120Hz 屏上约 8.3ms 一次——帧率自适应,正是我们想要的。
最小形态长这样:
function frame(now: number): void {
// now 是 DOMHighResTimeStamp,单位毫秒,单调递增
update(now);
draw();
requestAnimationFrame(frame); // 排下一帧
}
requestAnimationFrame(frame); // 排第一帧
注意 rAF 不会自动循环,每次回调末尾必须再排一次。也正因为它由 vsync 驱动,帧间隔是可变的——这正是 1.2.1 讲的「每帧时间不固定」。
1.3.2 可变步长的三个麻烦
如果直接把每帧的 dt 喂给物理,会遇到三个问题:
| 麻烦 | 现象 | 根因 |
|---|---|---|
| 物理不稳定 | 高速物体穿墙、弹簧爆炸 | 积分步长太大,误差随 dt² 放大 |
| 手感不一致 | 同一操作在不同设备上结果不同 | 逻辑推进量随帧率变化 |
| 不可复现 | 同一份输入重放结果对不上 | 每帧切分方式不同,碰撞时刻就不同 |
第 4.1 节用「乘 dt」解决了「速度与帧率无关」,但那只是让速度对;物理对帧率依然敏感。一个 120fps 的玩家,每秒走 120 个积分步;一个 30fps 的玩家,每秒只有 30 个积分步。步长不同,误差不同,最终轨迹就分叉。
要同时拿到「物理稳定」和「画面顺滑」,需要把两件事拆开:逻辑按固定步长走,画面按实际帧率画。 这就是固定步长累加器。
1.3.3 固定步长累加器
思路只有一句话:把真实经过的时间攒进一个桶(accumulator),桶里每攒够一个固定步长,就执行一次 step。
// src/core/loop.ts
export interface TimeSource {
now(): number; // 单调递增的毫秒时间戳
}
export const realTime: TimeSource = {
now: () => performance.now(),
};
export type StepFn = (dt: number) => void;
export interface LoopOptions {
fixedStep?: number; // 固定步长,单位秒
maxSubSteps?: number; // 单帧最多补几步
}
export class Loop {
readonly fixedStep: number;
readonly maxSubSteps: number;
private readonly time: TimeSource;
private readonly step: StepFn;
private accumulator = 0; // 秒
private lastMs: number | null = null;
private running = false;
private handle = 0;
constructor(step: StepFn, time: TimeSource = realTime, options: LoopOptions = {}) {
this.step = step;
this.time = time;
this.fixedStep = options.fixedStep ?? 1 / 60;
this.maxSubSteps = options.maxSubSteps ?? 5;
}
/** 用时间源推进一次:测试与手动驱动调用。返回本帧实际执行的步数。 */
advance(): number {
return this.tick(this.time.now());
}
private tick(nowMs: number): number {
if (this.lastMs === null) {
this.lastMs = nowMs; // 第一帧只对齐基准,不步进
return 0;
}
const rawDt = (nowMs - this.lastMs) / 1000;
this.lastMs = nowMs;
// 钳制单帧上限,防止切标签页回来后的巨量 dt 灌爆物理
const dt = Math.min(rawDt, this.fixedStep * this.maxSubSteps);
this.accumulator += dt;
let steps = 0;
while (this.accumulator >= this.fixedStep && steps < this.maxSubSteps) {
this.step(this.fixedStep);
this.accumulator -= this.fixedStep;
steps += 1;
}
// 已经补到上限还有剩余,说明真的追不上了,丢掉积压
if (steps === this.maxSubSteps) this.accumulator = 0;
return steps;
}
start(onFrame?: () => void): void {
if (this.running) return;
this.running = true;
const frame = (now: number): void => {
if (!this.running) return;
this.tick(now); // rAF 自带高精度时间戳,直接用它
onFrame?.();
this.handle = requestAnimationFrame(frame);
};
this.handle = requestAnimationFrame(frame);
}
stop(): void {
this.running = false;
if (this.handle) cancelAnimationFrame(this.handle);
this.handle = 0;
}
}
几个关键设计:
advance()与 rAF 解耦。start()只是薄薄一层宿主胶水;测试直接调advance(),由注入的TimeSource提供时间,根本不需要浏览器。这是全书「把不确定输入换成可注入对象」思路的第一处落地。fixedStep默认 1/60 秒。 60Hz 下正好一帧一步;120Hz 下每帧攒半格,隔帧走一步。maxSubSteps防死亡螺旋。 如果一帧卡了 5 秒,桶里会攒下 5 秒;若不加限制,while会连续跑 300 步,这 300 步又让这一帧更长,下一帧攒得更多……CPU 永远追不上。封顶之后,超出的时间被丢弃,游戏时间变慢但不会卡死。
1.3.4 用假时钟在 Node 里复现
advance() 只依赖一个 TimeSource,所以测试里换掉它就行。假时钟 FakeTime 放进 src/core/clock.ts(第 4 章会扩展它):
// src/core/clock.ts
import type { TimeSource } from './loop';
export class FakeTime implements TimeSource {
private t: number;
constructor(startMs = 0) {
this.t = startMs;
}
now(): number {
return this.t;
}
advance(ms: number): void {
this.t += ms;
}
}
现在把「帧率抖动」这件事变成可断言的测试:
import { describe, expect, it } from 'vitest';
import { Loop } from './loop';
import { FakeTime } from './clock';
function runSteps(deltas: number[], options = { maxSubSteps: 10 }): number {
const time = new FakeTime();
let steps = 0;
const loop = new Loop(() => { steps += 1; }, time, options);
loop.advance(); // 基准帧,不步进
for (const ms of deltas) {
time.advance(ms);
loop.advance();
}
return steps;
}
describe('固定步长循环', () => {
it('稳定 60fps 下,一秒约 60 步', () => {
const steps = runSteps(Array.from({ length: 60 }, () => 1000 / 60));
// 理论 60 步;浮点累加可能差 1 步,属正常
expect(steps).toBeGreaterThanOrEqual(59);
expect(steps).toBeLessThanOrEqual(60);
});
it('帧率在抖动,但总时长不变,步数几乎不变', () => {
const uniform = runSteps(Array.from({ length: 60 }, () => 1000 / 60));
const jitter = runSteps([110, 90, 130, 80, 120, 95, 105, 120, 65, 85]);
expect(jitter).toBe(60); // 1 秒 / (1/60 秒)
expect(Math.abs(jitter - uniform)).toBeLessThanOrEqual(1);
});
it('一帧卡 5 秒,也只补 maxSubSteps 步', () => {
const time = new FakeTime();
let steps = 0;
const loop = new Loop(() => { steps += 1; }, time, { maxSubSteps: 5 });
loop.advance(); // 基准帧
time.advance(5000); // 卡了 5 秒
expect(loop.advance()).toBe(5);
expect(steps).toBe(5);
});
});
三条断言各锁住一个性质:稳定帧率下步数与时间成正比、帧率抖动不改变总步数、大卡顿被 maxSubSteps 封顶。第三条尤其重要——它是「死亡螺旋」的唯一防线。
那个「浮点累加可能差 1 步」的注释,是第 4.2 节的伏笔:Clock 会用整数微秒累加来消掉这个误差,让「60 帧正好 60 步」变成精确成立。
1.3.5 接上 Game:第一个会动的方块
把 Loop 包进 Game,再加一个只会向右跑的方块,就有了《方块冲刺》的雏形:
// src/game.ts
import { Loop } from './core/loop';
export interface Player {
x: number;
y: number;
vx: number; // 像素/秒
}
export class Game {
readonly loop: Loop;
readonly player: Player = { x: 0, y: 0, vx: 120 };
constructor() {
// 注意:传给 step 的是固定步长,不是原始 dt
this.loop = new Loop((dt) => this.step(dt));
}
private step(dt: number): void {
const p = this.player;
p.x += p.vx * dt; // 速度 × 固定步长
if (p.x > 800) p.x = 0; // 跑出屏幕就回卷,方便肉眼观察
}
}
浏览器入口 src/index.ts 把 Loop 和 Canvas 连起来:
import { Game } from './game';
const canvas = document.getElementById('stage') as HTMLCanvasElement;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('拿不到 2D 上下文');
const game = new Game();
game.loop.start(() => {
// onFrame:每帧渲染一次,画的是当前逻辑状态
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#4ea1ff';
ctx.fillRect(game.player.x, canvas.height / 2 - 16, 32, 32);
});
跑 npm run dev,你会看到一个方块匀速向右滑动。它现在还很丑——没有贴图、没有背景、没有物理——但时间这条线已经通了:rAF 给时间戳,Loop 把它切成固定步长,Game.step 按固定步长推进,渲染按真实帧率画。
1.3.6 Cocos Creator 3.8 对照:director 与 Component.update
刚才手写的那一整套「rAF + 累加器 + 步进 + 渲染」,在 Cocos 里由引擎替你完成。你只需要写一个组件。
import { _decorator, Component, director, game } from 'cc';
const { ccclass } = _decorator;
@ccclass('PlayerMove')
export class PlayerMove extends Component {
private speed = 120; // 像素/秒
// 引擎每帧回调,dt 是上一帧增量,单位:秒
update(dt: number): void {
const p = this.node.position;
this.node.setPosition(p.x + this.speed * dt, p.y, p.z);
}
onLoad(): void {
// 设定目标帧率;引擎内部据此驱动主循环
game.frameRate = 60;
console.log('director 已运行帧数:', director.getTotalFrames());
}
}
对应关系逐条说清:
- 主循环 =
director。director是 Cocos 的调度中枢,引擎每帧调用director.tick(dt)推进整帧;cc.d.ts里mainLoop(now)已标@deprecated,新代码不要用。 update(dt)= 你的step(dt)。 引擎把本帧增量算好(秒),按顺序回调所有启用组件的update。你在mini2d里手写的Loop.advance,就是引擎内部那段累加器。game.frameRate = 60= 我们的目标帧率。 它是个可读可写的属性(get frameRate(): string | number),底层同样受 vsync 约束,设成 60 在 120Hz 屏上也不会跑满 120。- 钩子 =
Director.EVENT_AFTER_UPDATE。 想在「所有update跑完、渲染之前」插逻辑,用director.on(Director.EVENT_AFTER_UPDATE, cb)——这正对应mini2d里onFrame回调的位置。
| 用途 | mini2d | Cocos Creator 3.8 |
|---|---|---|
| 驱动主循环 | Loop.start()(内部 rAF) | director.tick(dt)(引擎内部) |
| 每帧逻辑 | Game.step(dt) | Component.update(dt) |
| 帧末对齐表现 | Loop.start(onFrame) 的回调 | Component.lateUpdate(dt) |
| 取上一帧增量 | Loop 内部算 | game.deltaTime(秒) |
| 设定目标帧率 | Loop 的 fixedStep | game.frameRate |
| 帧前 / 帧后钩子 | 手动在 advance 前后插 | Director.EVENT_BEFORE_UPDATE / EVENT_AFTER_UPDATE |
有一处重要差异要说清:mini2d 的 step 拿到的是固定步长,而 Cocos 的 update(dt) 拿到的是可变 dt。也就是说,Cocos 把「帧率无关」交给了你的代码(必须自己乘 dt),而把「物理固定步长」交给了内置的物理模块(RigidBody 内部才做固定步长)。这正是第 4.2 节要展开的取舍:可变步长灵活但不确定,固定步长确定但要处理插值。 mini2d 选了后者,因为它要在第 16 章做确定性回放。
本节所有 Cocos 代码均为 L2 片段,只保证与
cc.d.ts(Cocos Creator 3.8.8)类型一致,不参与mini2d的 vitest 测试。若要真跑,需要 Cocos Creator 3.8 编辑器。
到此,第 1 章把地基打完了:环境就绪(1.1)、心智模型定死(1.2)、主循环跑通(1.3)。方块已经会匀速向右滑,但它还只是一个色块。下一章开始解决「画什么、怎么画」——先讲 Canvas2D 的绘制与坐标变换,再讲精灵、纹理与图集切分,最后讲相机、视口与分辨率适配。你会看到 1.2 节那套「屏幕坐标 vs 世界坐标」的约定,如何在真实渲染里被一次次用到。
阅读导航:上一节:1.2 游戏的心智模型:帧、时间、坐标系 · 下一节:2.1 Canvas2D 绘制与坐标变换 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。