Skip to content

Stage02|Engine Loop ​

Lesson012|Director.mainLoop:一帧到底怎么跑? ​

Tags: #Creator2.x #Stage02 #Director #mainLoop #Scheduler #FrameDifficulty: ⭐⭐⭐☆☆


一、本课目标 ​

本课承接 Lesson011 的启动流程,继续回答一个问题:

游戏已经启动以后,cc.director.mainLoop() 如何把每一帧推进到屏幕?

完成本课后,你应该能够:

  • 说明 cc.game、浏览器或原生平台回调、cc.director.mainLoop 之间的关系。
  • 画出一帧从开始到结束的职责分层。
  • 区分 update、lateUpdate、定时器、Action 和渲染阶段的职责。
  • 解释为什么业务代码修改状态后,不一定会立刻完成矩阵计算或 GPU 提交。
  • 判断一个卡顿发生在脚本调度、Transform、渲染还是下一帧的准备阶段。
  • 知道哪些顺序是稳定的生命周期约束,哪些顺序必须以当前 Creator 2.x 版本源码为准。

本课不要求背 Director 的源码,也不在本课深入拆解 Scheduler 和 ComponentScheduler 的内部实现。后两部分分别在 Lesson015 和 Lesson016 展开。


二、上节课回顾:游戏为什么能进入 update ​

Lesson011 建立了这条主线:

text
cc.game 启动游戏
    ↓
初始化引擎、渲染和资源环境
    ↓
cc.director 接管当前 Scene
    ↓
创建 Node 和 Component
    ↓
执行 onLoad / onEnable / start
    ↓
进入主循环
    ↓
调度满足条件的 Component.update(dt)

这一课要把最后的“进入主循环”拆开。

你写的代码可能是:

ts
update(dt: number) {
    this.node.x += 100 * dt;
}

这段代码并不会自己在后台循环执行。它需要经过:

text
平台帧回调
    ↓
cc.game 的主循环入口
    ↓
cc.director.mainLoop(dt)
    ↓
引擎调度系统
    ↓
当前组件的 update(dt)

所以,update 是“被推进出来的”,不是“自己跑起来的”。


三、Director 到底是什么 ​

可以先用一句话记住:

cc.director 是当前游戏场景的运行时编排者。

它不是一个普通的 Node,也不是某个业务组件。它负责协调多个系统:

text
当前 Scene
组件调度
定时器和 Action
场景切换
暂停状态
帧时间
场景更新和绘制

和 cc.game 的分工可以这样看:

对象主要关注范围典型职责
cc.game整个游戏进程启动、平台帧回调、暂停、恢复、结束
cc.director当前游戏场景Scene 管理、每帧推进、调度和绘制编排
Scene当前节点树保存场景层级,承载 Node 和 Component
ComponentScheduler组件生命周期onLoad、start、update、lateUpdate 等
Scheduler / Action 系统时间驱动任务定时器、周期回调、Action 等
Renderer画面输出收集渲染数据并提交绘制

这里的“编排”很重要。Director 通常不亲自实现所有功能,而是在合适的时间通知不同系统工作。


四、谁在调用 mainLoop ​

1. Web 平台:帧回调推进游戏 ​

在 Web 或小游戏环境中,可以把过程理解为:

text
浏览器 / 小游戏平台提供下一帧回调
    ↓
cc.game 的主循环回调被触发
    ↓
计算这一帧的 dt
    ↓
调用 cc.director.mainLoop(dt)

实际实现会根据平台使用 requestAnimationFrame 或平台等价机制。重要的不是记住某个函数名,而是理解:

引擎每一帧需要一个外部时钟信号,Director 再用这个信号推进场景。

2. Native 平台:原生平台提供帧驱动 ​

在 Native 环境中,帧驱动来自原生应用的主循环或渲染循环。JavaScript 层看到的业务代码可能没有变化,但底层仍然需要:

text
Native Frame Callback
    ↓
绑定层 / JS Runtime
    ↓
游戏主循环
    ↓
Director.mainLoop(dt)

这也是为什么同一段 update 代码在 Web 和 Native 上的时序大体一致,但性能、线程和调用成本可能不同。

3. mainLoop 不是无限递归 ​

常见误解是:

ts
mainLoop() {
    mainLoop();
}

实际上不是业务代码递归调用自己,而是平台每次产生一个新的帧回调:

text
第 N 帧平台回调 → mainLoop(dt)
第 N+1 帧平台回调 → mainLoop(dt)
第 N+2 帧平台回调 → mainLoop(dt)

每次调用结束后,控制权会回到平台,下一帧再被唤醒。


五、mainLoop 的教学模型 ​

为了理解一帧,可以先使用这个稳定的高层模型:

text
一帧开始
    ↓
接收输入和平台事件
    ↓
确定本帧 dt 与暂停状态
    ↓
处理定时器、Action 和调度任务
    ↓
处理组件生命周期与 update
    ↓
处理 lateUpdate
    ↓
统一处理 Transform 和场景状态
    ↓
收集渲染数据
    ↓
合批并提交 Renderer / GPU
    ↓
处理延迟销毁和本帧收尾
    ↓
等待下一帧

这张图是用于建立职责关系的教学模型,不应被当作每个 2.x 小版本的逐行源码。不同版本、平台和模块可能调整调度器、Action、物理或渲染阶段的具体先后。

更准确的学习方法是区分两层:

text
稳定约束:生命周期和引擎职责的边界
    ↓
版本实现:某个调度器在 mainLoop 中的具体调用位置

本课先掌握第一层,后续再用源码和实验验证第二层。


六、一帧里的几个关键阶段 ​

1. 帧开始:确定时间和运行状态 ​

引擎首先需要知道:

text
这一帧距离上一帧过去了多久?
游戏当前是否暂停?
是否正在切换场景?
是否需要清理或重启 Director?

可以抽象为:

ts
const dt = currentTime - lastTime;

if (gameIsPaused) {
    return;
}

director.mainLoop(dt);

真实引擎会对 dt 做限制、缩放或平台适配,但核心任务不变:为本帧提供时间输入。

2. 调度阶段:让到时间的任务工作 ​

定时器和 Action 不一定都直接写在组件的 update 中。例如:

ts
this.scheduleOnce(() => {
    this.openRewardPanel();
}, 1);

这段代码的含义不是“创建一个新的线程等待 1 秒”,而是把任务登记到引擎的时间调度系统里:

text
登记任务
    ↓
每帧累计或比较时间
    ↓
到达触发条件
    ↓
在某一帧调用回调

任务的回调最终仍然需要在引擎允许的主线程或脚本调度环境中执行。

3. Component update 阶段 ​

只有满足条件的组件才会进入 update 调度:

text
组件存在
组件实现 update
组件 enabled = true
节点 activeInHierarchy = true
对象没有进入销毁状态
游戏主循环正在运行

例如:

ts
update(dt: number) {
    this.node.x += this.speed * dt;
}

引擎要做的不是简单地遍历所有 Node,而是维护或查询满足调度条件的组件集合,然后调用它们的 update。

这就是空 update 也有成本的原因:

text
组件被识别为可更新对象
    ↓
进入调度集合
    ↓
每帧被判断和调用
    ↓
即使函数体没有业务代码,也有调度成本

4. lateUpdate 阶段 ​

lateUpdate 通常用于依赖本帧其他组件结果的收尾逻辑。例如相机跟随:

ts
lateUpdate() {
    this.cameraNode.position = this.targetNode.position;
}

这个例子表达的是一种依赖关系:

text
其他对象先完成本帧状态更新
    ↓
相机读取最终状态
    ↓
相机再完成跟随

但 lateUpdate 不是“万能的最后一步”。它后面通常仍然可能有 Transform 同步、渲染收集、GPU 提交和延迟销毁。需要把它理解成:

组件脚本更新阶段中的后置回调,而不是整个引擎帧流程的绝对终点。

5. Transform 与渲染阶段 ​

如果组件在 update 中写:

ts
update() {
    this.node.x = 100;
    this.node.y = 200;
    this.node.scale = 2;
}

更合理的理解是:

text
修改 x → 标记 Transform 脏
修改 y → 继续保持脏状态
修改 scale → 继续保持脏状态
    ↓
在后续需要使用 Transform 的阶段
    ↓
统一计算 Local / World Matrix
    ↓
渲染组件读取新的变换

这并不意味着所有情况下都绝对只计算一次。中途如果业务代码主动读取强制更新的结果,或者不同模块要求立即得到世界坐标,可能触发额外计算。工程上应该避免在循环中反复修改和读取会触发更新的属性。

6. 帧收尾:延迟销毁和平台提交 ​

destroy() 的语义是“标记对象进入销毁流程”,不是在任意一行代码中立刻释放所有内容:

text
调用 destroy()
    ↓
对象被标记为待销毁
    ↓
当前调度继续按引擎规则完成
    ↓
合适的帧阶段统一清理
    ↓
触发 onDestroy 和相关引用清理

具体清理时机要以当前 Creator 版本为准,但业务代码不应依赖“destroy 后对象还能继续正常使用”。


七、一个组件从这一帧到下一帧发生了什么 ​

假设场景中有一个计时器组件:

ts
cc.Class({
    extends: cc.Component,

    properties: {
        speed: 100,
    },

    onLoad() {
        this.elapsed = 0;
    },

    update(dt: number) {
        this.elapsed += dt;
        this.node.x += this.speed * dt;
    },

    lateUpdate() {
        // 读取本帧其他逻辑更新后的结果
    },
});

可以用下面的视角分析:

text
进入场景并满足启用条件
    ↓
组件进入可调度状态
    ↓
某一帧 mainLoop 被平台触发
    ↓
引擎把 dt 传入调度系统
    ↓
调用 update(dt)
    ↓
elapsed 和 node.x 的数据发生变化
    ↓
Transform 被标记为需要更新
    ↓
调用 lateUpdate()
    ↓
后续 Transform / Render 阶段读取新状态
    ↓
画面显示新的位置
    ↓
下一帧再次从平台回调开始

注意:node.x 变化的是 Node 的运行时数据,不是直接把某个像素写进屏幕。屏幕上的变化要等到后续渲染流程使用这些数据。


八、状态在一帧中途改变,会马上影响谁 ​

1. 在 update 中关闭自身组件 ​

ts
update() {
    this.enabled = false;
}

通常可以这样理解:

text
当前 update 已经开始
    ↓
当前函数会继续执行到引擎允许的边界
    ↓
组件从后续调度中移除
    ↓
之后的帧不再执行 update

不要把它理解为“执行到这一行,当前 JavaScript 函数立刻被外部强制中断”。

2. 在 update 中关闭节点 ​

ts
this.node.active = false;

影响范围比 enabled = false 更大:

text
当前节点 activeInHierarchy 变化
    ↓
子节点的最终激活状态受到影响
    ↓
相关组件进入 disable 状态
    ↓
后续调度和渲染不再按原状态继续

具体是从当前阶段还是下一次调度检查开始体现,要结合 Creator 版本和调度实现验证;稳定结论是它不会只影响当前脚本函数。

3. 在 update 中销毁节点 ​

ts
this.node.destroy();

更准确的分析方式是区分三件事:

text
逻辑上:对象已经不应继续使用
调度上:对象会退出后续可执行状态
内存上:引擎在合适时机统一完成清理

不要在 destroy() 后继续访问组件属性并假设它们一定有效。


九、为什么“业务代码执行完”不等于“屏幕已经更新” ​

业务代码和 GPU 之间至少隔着几层工作:

text
业务代码修改状态
    ↓
Node / Component 数据改变
    ↓
Dirty 状态传播
    ↓
Transform 和渲染数据更新
    ↓
Renderer 收集可绘制对象
    ↓
合批、排序、提交绘制命令
    ↓
GPU 执行
    ↓
屏幕显示

因此下面这段代码:

ts
this.node.x = 100;
cc.log(this.node.x);

可以立即读到运行时属性值,但这不代表:

text
World Matrix 已经计算完成
Renderer 已经提交
GPU 已经执行
屏幕已经显示

如果确实需要读取世界坐标或屏幕坐标,应使用引擎提供的坐标转换 API,并意识到某些读取操作可能触发同步计算。不要在大量循环中无意识地反复读写这类数据。


十、暂停、后台和 mainLoop ​

1. cc.game.pause() 的范围 ​

ts
cc.game.pause();

它表达的是游戏级别的暂停意图:

text
游戏主循环停止推进或进入暂停分支
    ↓
组件 update 不再按正常游戏帧继续运行
    ↓
游戏时间和动画推进受到影响

它和下面的代码不是一个层级:

ts
this.node.active = false;

后者只关闭节点树的一部分,其他场景对象和全局系统仍可能继续工作。

2. 平台切后台不等于普通节点隐藏 ​

在移动端或小游戏中,应用切后台可能带来:

  • 帧回调暂停或频率下降。
  • dt 在恢复时出现较大值。
  • 音频、网络和计时器受到平台策略影响。
  • 页面或场景仍然存在,但没有按正常频率推进。

所以涉及倒计时、网络超时和恢复逻辑时,不能只依赖“每帧一定以固定频率执行”。

3. 不要用 node.active 模拟全局暂停 ​

把根节点关闭看起来像暂停,但它不一定能暂停:

text
场景外的系统
全局 Scheduler 任务
网络回调
音频系统
原生平台任务

真正的暂停方案需要明确哪些系统跟随游戏时间、哪些系统使用真实时间。


十一、如何用实验观察 mainLoop ​

可以创建两个简单脚本,观察生命周期和每帧调用:

ts
cc.Class({
    extends: cc.Component,

    onLoad() {
        cc.log(this.node.name, 'onLoad');
    },

    onEnable() {
        cc.log(this.node.name, 'onEnable');
    },

    start() {
        cc.log(this.node.name, 'start');
    },

    update(dt: number) {
        cc.log(this.node.name, 'update', dt);
    },

    lateUpdate(dt: number) {
        cc.log(this.node.name, 'lateUpdate', dt);
    },
});

实验时记录:

text
1. onLoad、onEnable、start 是否只在首次启用时执行
2. update 和 lateUpdate 是否每帧出现
3. 关闭 component.enabled 后,哪些日志停止
4. 关闭 node.active 后,子节点的日志如何变化
5. 重新 active = true 后,哪些生命周期可能再次触发
6. 调用 cc.game.pause() 后,日志是否继续增长

不要只看日志顺序,还要记录实验条件:Creator 版本、运行平台、节点初始 active 状态和是否运行在编辑器预览中。

实验建议:不要在真实项目中每帧打印 ​

cc.log 本身也有成本,尤其是:

ts
update() {
    cc.log('every frame');
}

日志会改变调试环境的时间分布,甚至制造假性的卡顿。实验完成后应移除或限制输出:

ts
if (this.elapsed > 1) {
    cc.log('sample once per second');
    this.elapsed = 0;
}

十二、常见误区 ​

误区一:Director 就是一个“超级 Node” ​

不是。Director 管理和协调场景运行,但它不属于场景树中的普通 Node,也不通过挂载 Component 来工作。

误区二:mainLoop 只负责调用 update ​

不是。update 只是其中一个阶段。mainLoop 还要协调时间、调度、场景状态、lateUpdate、Transform、渲染和收尾工作。

误区三:平台每秒调用 60 次,所以 dt 永远是 1 / 60 ​

不是。刷新率、后台、设备负载和平台调度都会影响实际 dt。移动逻辑通常应使用:

ts
this.node.x += this.speed * dt;

而不是假定每帧固定移动一个像素:

ts
this.node.x += this.speed;

误区四:lateUpdate 是整个引擎的最后一步 ​

不是。它是脚本组件更新阶段的后置回调,后面仍可能有 Transform、渲染和帧收尾。

误区五:调用 node.x = 100 就立刻提交 GPU ​

不是。它首先修改运行时状态,后续系统在合适阶段读取状态并完成渲染。

误区六:关闭一个根节点就等价于暂停游戏 ​

不是。它只影响该节点树,不会自动暂停所有全局任务、网络、音频和原生系统。

误区七:实验中看到的日志顺序就是所有版本的源码顺序 ​

不是。日志只能说明当前场景、当前版本和当前平台下观察到的行为。需要把稳定语义和实现细节分开记录。


十三、工作中的真实案例:角色移动为什么不稳定 ​

错误写法:按帧移动 ​

ts
update() {
    this.node.x += 5;
}

如果设备一秒执行 60 帧,角色每秒移动约 300 个单位;如果设备只能执行 30 帧,角色每秒只移动约 150 个单位。

改进写法:按时间移动 ​

ts
update(dt: number) {
    this.node.x += 300 * dt;
}

这里的 300 表示每秒移动的单位数。帧率变化时,移动速度更接近稳定值。

但 dt 也不是万能的 ​

如果应用从后台恢复,某一帧的 dt 可能异常偏大:

ts
update(dt: number) {
    this.node.x += 300 * dt;
}

可能导致角色瞬间移动很远。对于角色控制、物理和网络同步等系统,通常还需要:

  • 对 dt 设置上限。
  • 使用固定时间步或积累器。
  • 对后台恢复进行单独处理。
  • 区分真实时间和游戏时间。

这些内容会在后续的时间、Scheduler 和性能课程中继续展开。


十四、综合练习 ​

有一个场景:

text
Canvas
└── GameRoot
    ├── Player
    │   └── PlayerMove
    └── FollowCamera

当前状态:

text
GameRoot.active = true
Player.active = true
PlayerMove.enabled = true
游戏没有 pause

PlayerMove.update(dt) 每帧修改 Player 的 x 坐标,FollowCamera.lateUpdate() 读取 Player 的位置。

请回答:

  1. 谁负责让 PlayerMove.update 被调用?
  2. PlayerMove 修改 x 后,为什么画面不是在这一行代码中直接改变?
  3. 为什么相机跟随逻辑可能放在 lateUpdate?
  4. 如果 GameRoot.active = false,Player 自己的 active 还是 true,PlayerMove 会继续执行吗?
  5. 如果只设置 PlayerMove.enabled = false,Player 和 FollowCamera 会发生什么?
  6. 如果调用 cc.game.pause(),它和关闭 GameRoot 的影响范围有什么不同?
  7. 如果应用切后台 5 秒后恢复,为什么不能假定下一帧的 dt 仍然是正常值?

十五、综合练习参考答案 ​

1. 谁负责调用 update ​

平台产生帧回调,cc.game 的主循环把本帧推进给 cc.director.mainLoop(dt),再由组件调度系统找到满足条件的 PlayerMove 并调用其 update(dt)。

text
平台帧回调
→ cc.game
→ cc.director.mainLoop
→ ComponentScheduler
→ PlayerMove.update(dt)

2. 为什么不是在写 x 的那一行直接改变屏幕 ​

写 x 首先修改的是 Node 的 Transform 状态,并标记相关数据需要更新。后续 Transform、渲染收集、合批和 GPU 提交阶段才会把这个状态反映到屏幕。

3. 为什么相机跟随可能放在 lateUpdate ​

相机需要读取 Player 或其他对象本帧更新后的结果。放在组件更新的后置阶段,可以减少读取到旧状态的机会。但它仍不是整个引擎帧的最后一步。

4. 父节点关闭后 PlayerMove 是否继续执行 ​

通常不会。虽然 Player.active 自身仍可能是 true,但 GameRoot.active = false 会使 Player 的 activeInHierarchy 变为 false,组件不再满足正常 update 调度条件。

5. 只关闭 PlayerMove 组件的影响 ​

PlayerMove 自身不再执行 update,但 Player 节点仍可能显示,FollowCamera 也不一定停止。enabled 是组件级别控制,不能等同于关闭整个节点树。

6. cc.game.pause() 与关闭 GameRoot 的区别 ​

GameRoot.active = false 是局部关闭节点树;cc.game.pause() 是游戏级别暂停主循环。后者影响范围更大,但具体平台事件、网络和原生任务仍需单独确认。

7. 后台恢复后的 dt 问题 ​

平台可能暂停帧回调或降低频率。恢复时,时间差可能变大,导致按 dt 移动、倒计时和插值一次跨过很长时间。因此需要限制 dt 或设计专门的恢复策略。


十六、面试题整理 ​

1. cc.game 和 cc.director 的关系是什么? ​

cc.game 负责游戏级别的启动、平台帧驱动和暂停恢复;cc.director 负责当前场景的管理和每帧推进。平台或游戏主循环最终会把每帧时间交给 director.mainLoop(dt)。

2. mainLoop 只负责调用 update 吗? ​

不是。它还要协调本帧时间、调度任务、组件生命周期、lateUpdate、Transform、渲染和帧收尾。update 只是其中一个阶段。

3. 为什么角色移动要乘以 dt? ​

因为 dt 表示本帧经过的时间。用速度乘以时间,才能让移动结果更接近“每秒移动多少”,减少不同帧率带来的速度差异。

4. lateUpdate 是不是 GPU 提交前的最后一行代码? ​

不是。它是脚本更新阶段的后置回调,后面仍可能进行 Transform 更新、渲染数据收集、合批和 GPU 提交。

5. 为什么 node.active = false 和 cc.game.pause() 不能混为一谈? ​

前者只影响节点和子树的最终激活状态;后者控制游戏主循环的推进。一个是场景树局部状态,一个是游戏级运行状态。

6. 修改 Transform 后为什么有时会产生额外计算? ​

通常修改只会标记 Dirty,后续统一计算。但如果中间主动读取需要最新世界坐标的结果,或者某个系统强制同步,就可能提前触发计算。大量交替读写会增加成本。


十七、Creator 2.4.x 版本边界与验证入口 ​

本课的职责模型面向 Creator 2.4.x。稳定结论是:平台帧驱动进入 Director,Director 协调调度、组件更新、场景和渲染;不稳定部分是各内部系统在某个小版本、某个平台上的精确逐行顺序。

需要验证时按以下顺序进行:

  1. 在 Creator 关于窗口记录完整版本号和运行平台。
  2. 用本课探针记录 update、lateUpdate、暂停、恢复和销毁日志。
  3. 在匹配版本的引擎源码中搜索 mainLoop、ComponentScheduler、Scheduler 和渲染入口。
  4. 把“公开语义”“当前实验现象”“源码实现细节”分成三列记录。

这样即使升级 2.4.x 小版本,也能重新验证,而不是依赖一张过期调用顺序图。

十八、本课总结 ​

本课最重要的不是记住一张绝对不变的源码流程图,而是形成下面这条思维链:

text
平台帧回调
    ↓
cc.game 提供游戏级运行入口
    ↓
cc.director.mainLoop(dt) 推进当前场景
    ↓
Scheduler / ComponentScheduler 处理到期任务和组件
    ↓
update / lateUpdate 修改并整理运行时状态
    ↓
Transform / Renderer 读取状态
    ↓
DrawCall / GPU / Screen
    ↓
帧收尾并等待下一次回调

最终记住四句话:

text
Director 负责把当前场景向前推进。
mainLoop 是每一帧的总编排入口。
update 是被调度出来的组件方法,不是组件自己启动的循环。
业务状态改变后,还要经过 Transform 和 Renderer 才会成为屏幕像素。

十九、下一课预告 ​

下一课进入:

text
Lesson013|Director 是什么?为什么它是引擎调度中心?

下一课会继续拆解:

text
Director 保存了什么状态
↓
Director 如何管理当前 Scene
↓
Director 如何处理场景切换
↓
Director 如何把调度、时间和渲染系统串起来

本课讲的是“一帧从哪里开始、经过哪些职责”;下一课讲的是“谁在持续管理这条链路”。