《Cocos 游戏开发高级编程》1.1 引擎分层与源码结构

把 Cocos Creator 3.8.8 拆成引擎核心、渲染后端、平台适配与 JSB 绑定四层,将 cocos/cocos-engine 的源码目录逐层映射到各自职责,给出用 sys 与构建配置判断当前运行层的可验证方法,并说明遇到问题时该往哪一层下潜排查。

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 上手指按下屏幕,事件大致这样流动(括号里是源码归属):

  1. 浏览器触发 touchstart,由平台适配层的输入模块接收(pal/input/),归一化成引擎内部事件。
  2. 事件被派发进引擎核心层的场景树,命中测试交给节点(cocos/scene-graph/)。
  3. 你的组件监听 Node.EventType.TOUCH_START,在回调里改数据(cocos/2d/framework/)。
  4. 下一帧渲染时,渲染后端把改动翻译成 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 启动流程与主循环 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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