《Cocos 游戏开发入门》1.3 第一个可跑循环:requestAnimationFrame 与固定步长

本节写出 mini2d 的第一个主循环:从 requestAnimationFrame 的可变 dt,到固定步长累加器与 maxSubSteps 防死亡螺旋,再用 FakeTime 假时钟在 Node 里把整条循环复现成单测,最后对照 Cocos Creator 3.8 的 director 主循环与 Component.update。

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)。别这么做,原因有三:

  1. setInterval 与屏幕刷新不同步。 它可能在一帧里触发两次、在另一帧里一次都不触发,画面上就是撕裂与抖动。
  2. 后台标签页里 setInterval 会被限流甚至暂停,恢复时一口气补发一串回调。
  3. 它给不出精确的时间戳,你得自己去读时钟。

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 回调的位置。
用途mini2dCocos 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 的 fixedStepgame.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 绘制与坐标变换 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画