1.1 引擎分层与源码结构
从这一卷起,我们把 Cocos 引擎本身当研究对象。卷一从零手写 mini2d,是为了让你知道「一个引擎最少要有哪些零件」;卷二用 Cocos Creator 3.8 把《方块冲刺》做成能发布的产品,是为了让你会用编辑器。现在到了第三层:当性能不达预期、当某段行为和直觉不符、当你要裁掉一部分引擎时,你得知道问题出在哪一层,以及那一层的代码长什么样。
站内 graphics/ 的 45 篇讲的是图形学通用理论(管线、光照、着色器),本卷不重复那部分;这里只讲 Cocos 引擎的具体实现,每一条结论都尽量落到 cocos/cocos-engine 的源码路径或 cc.d.ts 的导出上,可核可查。本节的源码引用一律以仓库 tag 3.8.8 为准。
1.1.1 分层解决的是什么问题
一个引擎有几千个文件。如果它们平铺在一个目录里,你会遇到两个具体麻烦:
- 改一处,炸一片。渲染代码直接调用平台 API,换一个平台就得重写渲染代码。
- 问一个问题,找不到入口。「这帧为什么没画出来」要横跨输入、逻辑、渲染、平台四件事。
分层就是给这些麻烦提前定好边界:同一层内部可以自由协作,跨层只能沿着固定方向调用。Cocos 的分层不是抽象的架构图,它直接体现在仓库目录上——目录边界基本就是依赖边界。你在 pal/ 里几乎看不到渲染代码,在 cocos/rendering/ 里也几乎看不到浏览器 API,这不是巧合,是分层约束的结果。
1.1.2 四层结构与依赖方向
把 JS 侧(引擎用 TypeScript 写的部分)和原生侧分开看,得到四层:
| 层 | 职责 | 典型问题 |
|---|---|---|
| 引擎核心 | 节点树、组件生命周期、资源管理、事件、数学库 | 「组件的 update 为什么这帧没跑」 |
| 渲染后端 | 把场景数据翻译成 GPU 命令(gfx 抽象 + rendering 管线) | 「Draw Call 为什么降不下来」 |
| 平台适配 | 把浏览器 / 小游戏 / 原生的差异抹平成统一接口 | 「微信上拿不到触摸事件」 |
| JSB 绑定 | 原生侧(C++)与 JS 的桥,运行在原生包里 | 「原生崩溃了,栈里全是 native 帧」 |
依赖方向是自下而上:平台适配层不认识渲染后端,渲染后端不认识引擎核心,引擎核心也不反向依赖渲染。用文字画出来就是:
子系统 (2d / 3d / animation / physics / audio)
│ 依赖
▼
渲染后端 (cocos/rendering) 引擎核心 (cocos/core, cocos/game, cocos/scene-graph)
│ │
└──────────────┬───────────────┘
▼
平台适配 (pal/) ← JSB 绑定 (native/) 也落在这个高度
2d/、3d/、animation/、physics/、audio/ 这些是架在核心层之上、通过渲染后端出图的子系统,不属于四层本身,但目录上和核心层平级。把这张图记住,后面每次定位问题都会用到。
1.1.3 源码目录映射
以下路径取自 cocos/cocos-engine 仓库的 tag 3.8.8,是本节所有结论的核对依据:
| 源码路径(tag 3.8.8) | 所属层 | 里面有什么 |
|---|---|---|
cocos/core/ | 引擎核心 | 数学库、内存对象基类、scheduler.ts(定时器) |
cocos/scene-graph/ | 引擎核心 | node.ts、component.ts、component-scheduler.ts、node-activator.ts |
cocos/game/ | 引擎核心 | director.ts(导演)、game.ts(主循环驱动) |
cocos/asset/asset-manager/ | 引擎核心 | asset-manager.ts、bundle.ts、cache-manager.ts |
cocos/rendering/ | 渲染后端 | custom/ 下的可编程管线与执行器 |
cocos/2d/、cocos/3d/ | 子系统 | 2D 装配器(assembler)、Mesh、骨骼 |
pal/ | 平台适配 | screen-adapter/、input/、audio/、system-info/ |
native/cocos/ | JSB 绑定 | C++ 实现,JSB 接口在 native/engine/common/ |
一个能立刻用上的结论:看到 pal/ 开头的代码,就知道它在处理平台差异;看到 cocos/rendering/,就知道它在组织 GPU 命令。定位问题的第一步,就是判断症状落在哪张表的哪一行。
1.1.4 一次触摸事件如何穿过四层
抽象的分层,用一次触摸走一遍就具体了。在 H5 上手指按下屏幕,事件大致这样流动(括号里是源码归属):
- 浏览器触发
touchstart,由平台适配层的输入模块接收(pal/input/),归一化成引擎内部事件。 - 事件被派发进引擎核心层的场景树,命中测试交给节点(
cocos/scene-graph/)。 - 你的组件监听
Node.EventType.TOUCH_START,在回调里改数据(cocos/2d/framework/)。 - 下一帧渲染时,渲染后端把改动翻译成 GPU 命令(
cocos/rendering/)。
同一套代码在微信小游戏上跑,第 1 步换成小游戏平台的触摸接口(pal/input/ 下的小游戏分支),第 2–4 步一行都不用改。这就是分层最实际的收益:平台差异被锁在第 1 步里。也正因为如此,当「触摸没反应」时,先分清是第 1 步没收到,还是第 2 步没命中——两者的排查入口完全不同:前者看 pal/input/,后者看你的节点层级与碰撞区域。
1.1.5 用 sys 判断当前运行层(L2)
引擎把「我现在跑在哪」这件事收敛到了 sys 这个导出对象里。下面这个组件在启动时打印运行层信息,它只依赖 cc.d.ts 里真实存在的导出:
import { _decorator, Component, log, sys } from 'cc';
const { ccclass } = _decorator;
@ccclass('PlatformProbe')
export class PlatformProbe extends Component {
start(): void {
log('platform =', sys.platform);
log('isNative =', sys.isNative);
log('isBrowser =', sys.isBrowser);
// sys.Platform 是一个字符串枚举,成员覆盖全部目标平台
if (sys.platform === sys.Platform.WECHAT_GAME) {
log('运行在微信小游戏');
} else if (sys.platform === sys.Platform.DESKTOP_BROWSER) {
log('运行在桌面浏览器');
} else if (sys.isNative) {
log('运行在原生包(Android / iOS / macOS 之一)');
}
}
}
sys.Platform 的成员在 cc.d.ts 里能数全:UNKNOWN、EDITOR_PAGE、MOBILE_BROWSER、DESKTOP_BROWSER、WIN32、ANDROID、IOS、MACOS、OHOS、WECHAT_GAME、WECHAT_MINI_PROGRAM、XIAOMI_QUICK_GAME 等,共二十余个。这份枚举本身就是「引擎支持哪些发布目标」的权威清单——比任何二手文章都准。
除了 platform,sys 还提供 hasFeature(feature) 做能力探测,例如判断某个平台是否支持某类渲染特性。先 hasFeature 再决定走哪条分支,比直接写 if (sys.platform === ...) 更耐平台更新。这正是 1.3 要展开的用法:把 cc.d.ts 当字典查。
1.1.6 一个可单测的分层归属判断器(L1)
分层的边界可以被写成代码。下面这个纯 TS 模块根据文件路径判断它属于哪一层,不依赖 cc,可以直接单测——它把 1.1.3 那张表变成了可执行的规则:
// layers.ts —— 纯逻辑,无 cc 依赖
export type Layer = 'core' | 'render' | 'platform' | 'jsb' | 'subsystem';
const RULES: ReadonlyArray<readonly [RegExp, Layer]> = [
[/^cocos\/core\//, 'core'],
[/^cocos\/scene-graph\//, 'core'],
[/^cocos\/game\//, 'core'],
[/^cocos\/asset\//, 'core'],
[/^cocos\/rendering\//, 'render'],
[/^cocos\/(2d|3d|animation|physics|audio)\//, 'subsystem'],
[/^pal\//, 'platform'],
[/^native\//, 'jsb'],
];
export function classifyModule(path: string): Layer | null {
for (const [re, layer] of RULES) {
if (re.test(path)) return layer;
}
return null;
}
光有归属还不够,分层真正约束的是调用方向。把上面的层号排成一张顺序表,就能写一个「跨层调用是否合法」的检查器:
// 数字越小越靠底层;上层调用下层合法,反向非法
const ORDER: Record<Layer, number> = {
platform: 0,
jsb: 0,
core: 1,
render: 2,
subsystem: 3,
};
export function isLegalCall(from: Layer, to: Layer): boolean {
return ORDER[from] >= ORDER[to];
}
配套的 vitest 断言,把「目录即边界」钉死:
// layers.test.ts
import { describe, expect, it } from 'vitest';
import { classifyModule, isLegalCall } from './layers';
describe('classifyModule', () => {
it('把核心层目录归为 core', () => {
expect(classifyModule('cocos/scene-graph/component.ts')).toBe('core');
expect(classifyModule('cocos/game/director.ts')).toBe('core');
});
it('把渲染与子系统分开', () => {
expect(classifyModule('cocos/rendering/custom/executor.ts')).toBe('render');
expect(classifyModule('cocos/2d/assembler/sprite/simple.ts')).toBe('subsystem');
});
it('平台适配与 JSB 各自成层', () => {
expect(classifyModule('pal/screen-adapter/native/screen-adapter.ts')).toBe('platform');
expect(classifyModule('native/cocos/audio/AudioEngine.cpp')).toBe('jsb');
});
it('未知路径返回 null,而不是猜一个层', () => {
expect(classifyModule('scripts/game/Player.ts')).toBeNull();
});
});
describe('isLegalCall', () => {
it('上层调用下层合法', () => {
expect(isLegalCall('subsystem', 'core')).toBe(true);
expect(isLegalCall('render', 'platform')).toBe(true);
});
it('下层反向调用上层非法', () => {
expect(isLegalCall('core', 'render')).toBe(false);
expect(isLegalCall('platform', 'core')).toBe(false);
});
it('同层调用合法', () => {
expect(isLegalCall('core', 'core')).toBe(true);
});
});
最后一条归属断言是关键:判断器宁可返回 null,也不该猜。这和本卷引用源码的态度一致——核不到的东西不写。
1.1.7 构建期的分层:模块裁剪(L3)
运行时的分层之外,还有一层构建期的分层:你可以在构建时把用不到的层整个裁掉,从而减小包体。这是通过构建配置完成的。
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。下面的片段来自工程的模块裁剪配置,必须由编辑器构建面板读写,直接改文件可能被编辑器覆盖。
{
"__version__": "1.0.0",
"modules": {
"cache": {
"render": { "physics": false, "particle": false, "audio": false }
}
}
}
在编辑器里打开「项目 → 构建发布 → 高级选项 → 引擎模块裁剪」,会看到同一份配置的可勾选界面:2d、3d、physics、audio、animation、particle 等分组。勾掉一个模块,对应目录的代码就不会进入构建产物——代价是运行时调用到被裁 API 会直接报错。
裁之前要做两件事:一是确认代码里没有引用被裁模块的符号(可以配合 1.1.6 那个归属判断器做静态扫描),二是确认用到的第三方插件也没有引用。这属于第 9 章的定制话题,这里只需记住:分层的边界,也是裁剪的边界。
1.1.8 工程决策:什么时候往哪一层挖
分层知识只有在「定位问题」时才产生价值。给你一张对照表:
| 症状 | 先怀疑的层 | 下潜入口 |
|---|---|---|
| 组件回调没按预期时机跑 | 引擎核心 | 1.2 的主循环与 component-scheduler.ts |
| Draw Call 降不下来 | 渲染后端 | 第 2 章的合批机制 |
| 触摸 / 键盘在小游戏上失灵 | 平台适配 | pal/input/ |
| 原生崩溃、栈全是 native 帧 | JSB 绑定 | 第 8 章的 JSB 与原生工程 |
| 包体过大 | 构建期分层 | 第 9 章的模块裁剪 |
不要跳过核心层直接怀疑渲染。多数「渲染慢」的现场,根因是组件在 update 里做了不该做的事——那是引擎核心层的问题,只是症状从渲染表现出来了。反过来,当你在核心层怎么调都调不动时,说明瓶颈可能真的在渲染后端,这时才轮到第 2 章的工具。
下一节我们顺着这张表最上面一行往下走:从 main 出发,看一帧到底经过了哪些函数,组件回调在这条路径的哪个位置被调用。
阅读导航:上一节:目录 · 下一节:1.2 启动流程与主循环 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。