1.2 启动流程与主循环
1.1 把引擎分成四层,说清了「问题该往哪一层挖」。这一节顺着最常被问到的一条路径往下走:从引擎启动到每帧调用组件的 update,中间到底经过了哪些函数。
理解了这条链,你就能回答几个具体问题:为什么 start 只会被调用一次?为什么 lateUpdate 一定晚于所有 update?为什么 game.pause() 之后画面还在动?这些都不是文档里的孤立条目,而是同一段主循环代码的直接后果。本节的每个结论都对应 cocos/cocos-engine 仓库 tag 3.8.8 里的一段真实代码。
1.2.1 谁在驱动第一帧
启动的起点不在引擎里,而在平台适配层的帧同步器。Web 平台上是 pal/pacer/pacer-web.ts(tag 3.8.8)里的 Pacer 类:它内部用 requestAnimationFrame,每到一个绘制时机就调用一次 onTick 回调。原生和微信小游戏各有一份对应实现(pacer-native.ts / pacer-minigame.ts),对外接口一致——这又是一次「平台差异被锁在一层里」。
onTick 挂到哪里?在 cocos/game/game.ts(tag 3.8.8)里:
// cocos/game/game.ts, tag 3.8.8(节选)
private initPacer (): void {
const frameRate = querySettings(SettingsCategory.SCREEN, 'frameRate') ?? 60;
this._pacer = new Pacer();
this._pacer.onTick = this._updateCallback.bind(this);
this.frameRate = frameRate;
}
于是每帧的调用链是:
Pacer.onTick (rAF)
└─> Game._updateCallback()
└─> director.tick(this._calculateDT(false))
_updateCallback 里还有个细节:首帧要先跑启动画面、再 director.loadScene(launchScene, ...),加载完才 director.startAnimation() 进入正常循环。所以「游戏开始」不是引擎初始化完就发生的,而是启动场景加载完那一刻。这也解释了为什么首帧的逻辑常比后续帧慢——它夹着场景构建。
1.2.2 Director.tick 的七个阶段(L0)
真正的「一帧」定义在 cocos/game/director.ts(tag 3.8.8)的 Director.tick(dt) 里。把它逐行读一遍,整条主循环就清楚了:
// cocos/game/director.ts, tag 3.8.8(节选,保留结构,去掉无关分支)
public tick (dt: number): void {
if (!this._invalid) {
this.emit(DirectorEvent.BEGIN_FRAME);
if (!EDITOR_NOT_IN_PREVIEW) {
input._frameDispatchEvents();
}
// Update
if (!this._paused) {
this.emit(DirectorEvent.BEFORE_UPDATE);
this._compScheduler.startPhase(); // start(仅一次)
this._compScheduler.updatePhase(dt); // 组件 update
for (let i = 0; i < this._systems.length; ++i) {
this._systems[i].update(dt); // 各系统 update
}
this._compScheduler.lateUpdatePhase(dt); // 组件 lateUpdate
this.emit(DirectorEvent.AFTER_UPDATE);
CCObject._deferredDestroy(); // 延迟销毁
for (let i = 0; i < this._systems.length; ++i) {
this._systems[i].postUpdate(dt); // 各系统 postUpdate
}
}
this.emit(DirectorEvent.BEFORE_DRAW);
uiRendererManager.updateAllDirtyRenderers();
this._root!.frameMove(dt); // 渲染
this.emit(DirectorEvent.AFTER_DRAW);
Node.resetHasChangedFlags();
Node.clearNodeArray();
scalableContainerManager.update(dt);
this.emit(DirectorEvent.END_FRAME);
this._totalFrames++;
}
}
把这段拆成阶段表,就是本节最该记住的东西:
| 顺序 | 动作 | 关键点 |
|---|---|---|
| 1 | BEGIN_FRAME | 帧起点 |
| 2 | input._frameDispatchEvents() | 本帧输入事件在这统一派发 |
| 3 | BEFORE_UPDATE | 逻辑阶段开始 |
| 4 | startPhase() | 调 start,只对新增组件调一次 |
| 5 | updatePhase(dt) | 调所有组件 update |
| 6 | systems[i].update(dt) | 各系统更新(物理、动画等) |
| 7 | lateUpdatePhase(dt) | 调所有组件 lateUpdate |
| 8 | AFTER_UPDATE | 逻辑阶段结束 |
| 9 | _deferredDestroy() | 真正销毁上一帧标记的节点 |
| 10 | systems[i].postUpdate(dt) | 各系统收尾 |
| 11 | root.frameMove(dt) | 渲染 |
| 12 | END_FRAME | 帧结束,帧计数 +1 |
两个立刻能用的结论:
update与lateUpdate之间夹着所有系统的update。所以物理刚体位置在update里读到的是上一帧的结果,要拿本帧最终位置得用lateUpdate。_totalFrames++在if (!this._paused)之外。暂停后逻辑不再跑,但帧计数继续涨——这解释了为什么暂停时director.getTotalFrames()仍在变。
1.2.3 渲染阶段:Root.frameMove(L0)
阶段 11 的 this._root.frameMove(dt) 才是出图的地方。它在 cocos/root.ts(tag 3.8.8)里,结构是三个子步骤:
// cocos/root.ts, tag 3.8.8(结构摘要)
public frameMove (deltaTime: number): void {
this._frameTime = deltaTime;
++this._frameCount;
// ... FPS 统计 ...
this._frameMoveBegin(); // 清理上一帧的批次、相机列表
this._frameMoveProcess(); // 提取相机、更新批次、更新场景
this._frameMoveEnd(); // 排序相机、执行管线渲染、present
}
_frameMoveProcess() 里会对每个窗口提取渲染相机,然后调用 this._batcher.update() 与场景的 update();_frameMoveEnd() 里则按 camera.priority 排序相机,发出 EVENT_BEFORE_RENDER / EVENT_AFTER_RENDER,最后 this._device.present() 把画面送到屏幕。这就是第 2 章要展开的渲染管线的入口——记住它挂在 tick 的第 11 步,物理与逻辑都算完了才轮到它。
1.2.4 组件回调到底怎么被调起来
阶段 4/5/7 调的是 ComponentScheduler 的三个方法,实现在 cocos/scene-graph/component-scheduler.ts(tag 3.8.8)。它的内部按执行顺序(executionOrder)把组件分成三组:优先级为负、为零、为正,调用时先负后零再正:
// cocos/scene-graph/component-scheduler.ts, tag 3.8.8(摘要)
public startPhase (): void {
this._updating = true;
this.startInvoker.invoke();
this._startForNewComps(); // 本帧新激活节点的 start
}
public updatePhase (dt: number): void {
this.updateInvoker.invoke(dt);
}
public lateUpdatePhase (dt: number): void {
this.lateUpdateInvoker.invoke(dt);
this._updating = false;
this._startForNewComps(); // update 期间新激活的组件,下帧再 start
}
这解释了两件事:为什么 executionOrder 能控制回调顺序(三组分开调),以及为什么运行中动态 addComponent 的组件不会当帧立即 start(_startForNewComps 把新组件推到下一帧)。生产环境的 invokeUpdate 是 JIT 生成的(c.update(dt)),比逐次 hasOwnProperty 判断快得多——这也是引擎为热路径做的小优化。
1.2.5 用纯 TS 复刻主循环(L1)
现在把上面的顺序用纯 TypeScript 写一遍,不依赖 cc,可以直接单测。这个 FrameLoop 就是 Director.tick 的骨架:
// frame-loop.ts —— 纯逻辑,无 cc 依赖
export class FrameLoop {
private readonly startFns: Array<() => void> = [];
private readonly updateFns: Array<(dt: number) => void> = [];
private readonly systemFns: Array<(dt: number) => void> = [];
private readonly lateFns: Array<(dt: number) => void> = [];
private startDone = false;
private paused = false;
private frames = 0;
private time = 0;
onStart(fn: () => void): void { this.startFns.push(fn); }
onUpdate(fn: (dt: number) => void): void { this.updateFns.push(fn); }
addSystem(fn: (dt: number) => void): void { this.systemFns.push(fn); }
onLateUpdate(fn: (dt: number) => void): void { this.lateFns.push(fn); }
pause(): void { this.paused = true; }
resume(): void { this.paused = false; }
get totalFrames(): number { return this.frames; }
get totalTime(): number { return this.time; }
// 对齐 Director.tick:start → update → systems → lateUpdate
tick(dt: number): void {
if (!this.paused) {
if (!this.startDone) {
this.startDone = true;
for (const fn of this.startFns) fn();
}
for (const fn of this.updateFns) fn(dt);
for (const fn of this.systemFns) fn(dt);
for (const fn of this.lateFns) fn(dt);
this.time += dt;
}
this.frames += 1; // 注意:与引擎一致,暂停也计数
}
}
配套 vitest 断言,把顺序和边界都钉死:
// frame-loop.test.ts
import { describe, expect, it } from 'vitest';
import { FrameLoop } from './frame-loop';
describe('FrameLoop.tick', () => {
it('按 start → update → system → lateUpdate 的顺序执行', () => {
const loop = new FrameLoop();
const order: string[] = [];
loop.onStart(() => order.push('start'));
loop.onUpdate(() => order.push('update'));
loop.addSystem(() => order.push('system'));
loop.onLateUpdate(() => order.push('late'));
loop.tick(1 / 60);
expect(order).toEqual(['start', 'update', 'system', 'late']);
});
it('start 只执行一次,update 每帧执行', () => {
const loop = new FrameLoop();
let starts = 0;
let updates = 0;
loop.onStart(() => { starts += 1; });
loop.onUpdate(() => { updates += 1; });
loop.tick(0.016);
loop.tick(0.016);
expect(starts).toBe(1);
expect(updates).toBe(2);
});
it('暂停时跳过逻辑阶段,但帧计数仍递增', () => {
const loop = new FrameLoop();
let updates = 0;
loop.onUpdate(() => { updates += 1; });
loop.pause();
loop.tick(0.016);
expect(updates).toBe(0);
expect(loop.totalFrames).toBe(1);
});
it('未暂停时累加经过时间', () => {
const loop = new FrameLoop();
loop.tick(0.5);
loop.tick(0.25);
expect(loop.totalTime).toBeCloseTo(0.75, 6);
});
});
这四条断言不是随手写的:第三条正是「暂停后帧计数仍涨」那个引擎行为的回归测试。把引擎的怪癖写成测试,下次它变了你会第一时间知道。
1.2.6 在组件里监听帧事件(L2)
组件脚本可以监听 Director 上的帧事件,在 update 之外插入自己的逻辑。事件常量定义在 Director 类上,要用类名引用而不是实例:
import { _decorator, Component, Director, director, log } from 'cc';
const { ccclass } = _decorator;
@ccclass('FrameProbe')
export class FrameProbe extends Component {
private _frame = 0;
onEnable(): void {
director.on(Director.EVENT_BEFORE_UPDATE, this._onBeforeUpdate, this);
director.on(Director.EVENT_AFTER_UPDATE, this._onAfterUpdate, this);
}
onDisable(): void {
director.off(Director.EVENT_BEFORE_UPDATE, this._onBeforeUpdate, this);
director.off(Director.EVENT_AFTER_UPDATE, this._onAfterUpdate, this);
}
private _onBeforeUpdate(): void {
log('before update, frame =', director.getTotalFrames());
}
private _onAfterUpdate(): void {
this._frame += 1;
}
update(_dt: number): void {
// 本组件的 update 位于 BEFORE_UPDATE 与 AFTER_UPDATE 之间
}
}
两个必须注意的点:
- 一定要成对
on/off。Director是全局单例,只on不off会让组件被销毁后回调还在跑,这是最常见的泄漏。 - 别在
EVENT_BEFORE_UPDATE里改会被update读的数据,顺序上它先于所有组件的update;反过来EVENT_AFTER_UPDATE才是「所有逻辑都算完」的时机。
1.2.7 工程决策:什么时候该用帧事件
大多数时候你不该用 Director 的帧事件——组件自带 update / lateUpdate 就够了,而且后者参与 executionOrder 排序,更可控。帧事件的合理场景只有三个:
| 场景 | 用哪个事件 | 理由 |
|---|---|---|
| 需要「所有组件都更新完」后统一处理 | EVENT_AFTER_UPDATE | 早于它拿不到本帧最终数据 |
| 需要在渲染前最后一次改数据 | EVENT_BEFORE_DRAW | 卡在 frameMove 之前 |
| 需要全局性能采样 / 帧计时 | EVENT_BEGIN_FRAME + END_FRAME | 包住整帧,测的是真实帧耗时 |
代价要清楚:帧事件是全局的,回调里做重活会拖慢所有帧;而且它不受 executionOrder 控制,多个监听者之间没有确定顺序。能用组件回调解决的,不要上升到帧事件。
下一节我们把视角从「时间」换到「空间」:cc.d.ts 里到底导出了什么、怎么快速查一个 API 存不存在、怎么从类型声明反推引擎的能力边界。
阅读导航:上一节:1.1 引擎分层与源码结构 · 下一节:1.3 从 cc.d.ts 读引擎 API 版图 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。