10.1 ECS 与数据驱动架构
架构这一章,我们从最容易吵起来的话题开始。做了几年引擎的人进 Cocos 项目,第一句话常常是「怎么不用 ECS」;而用 Cocos 做完两个项目的人反过来问「到底要不要上 ECS」。这两个问题的答案都不是「要」或「不要」,而是「哪一层该用」。 本节把边界划清楚:引擎自带的组件-节点模型管什么,数据驱动的纯逻辑层管什么,两者怎么接。
与站内 game/ 顶层架构文的关系:那些文章讲的是通用的实体组织方式;本节落到 Cocos 3.8 的具体实现与 cc.d.ts 能证明的行为上,给的是一个能编译、能单测的最小数据层,而不是一篇 ECS 概念综述。
10.1.1 组件-节点模型不是 ECS
先把概念对齐。ECS(Entity-Component-System)有三条硬性特征:实体是一个纯 ID、组件是只有数据没有行为的结构体、逻辑全部放在 System 里按查询批量执行。Cocos 的组件-节点模型三条都不满足:
| 特征 | 纯 ECS | Cocos 组件-节点 |
|---|---|---|
| 实体 | 一个整数 ID | 一个 Node 实例,带变换与层级 |
| 组件 | 纯数据结构体 | Component 子类,数据与行为混在一起 |
| 逻辑 | System 按查询遍历 | 组件自己的 update 回调 |
这不是 Cocos 做得不好,而是它的定位决定的。引擎源码里,组件与节点定义在两个不同的文件里:cocos/scene-graph/component.ts 定义 Component 基类,cocos/scene-graph/node.ts 定义 Node;两者的生命周期回调由 cocos/scene-graph/component-scheduler.ts 统一驱动(以上均为 cocos/cocos-engine tag 3.8.8 的路径,可在仓库里逐个核对)。关键差别在于「谁持有数据、谁驱动逻辑」:在 Cocos 里,一个 Component 实例同时是数据容器和行为主体,挂在某棵节点树上,由调度器按节点顺序调用。
为什么引擎不做成纯 ECS?三个现实约束:
- 编辑器要能序列化。 场景与预制体存的是「节点 + 挂在节点上的组件实例」,每个组件在编辑器里可被选中、改属性。纯 ECS 的裸数据数组没法在层级管理器里点选。
- 节点有层级与变换。 父子节点的世界坐标是算出来的,这套继承关系是 2D/3D 场景的核心,ECS 里没有「节点树」这个概念。
- 脚本要能被策划和美术读懂。 一个
PlayerController组件拖到玩家节点上就能跑,比「往 World 里 spawn 一个实体再挂三个组件」直观得多。
10.1.2 组件模型处理不了的那类问题
组件-节点模型在对象数量少、每个对象各不同时最舒服:玩家、相机、HUD、关卡管理器,每个都是独一份,写成一个组件天经地义。它别扭的是另一类场景——数量多、结构完全相同、每帧都要遍历更新的对象:
- 一屏几百颗子弹、几百枚金币;
- 粒子系统的每个粒子;
- 大量背景装饰元素的位移。
对这类对象,如果每颗子弹都是一个节点、挂一个 Bullet 组件、在 update 里改自己的位置,会出现两个代价:一是每个节点/组件的对象头与引用链带来的内存开销(数量一上去就很可观),二是调度器要按节点顺序逐个调用 update,调用点分散、缓存不友好。这不是 Cocos 独有的问题,Unity 的 DOTS、Godot 的服务器模式,解决的正是同一件事。
于是有了本节的结论:别把整个游戏改成 ECS,而是把「大量同构对象」抽到一个数据驱动的纯逻辑层里,只把最终结果同步给节点。
10.1.3 数据驱动层的边界
划界的原则只有一条:这段逻辑能不能只靠输入输出描述,完全不碰节点和引擎? 能,就进数据层;不能,就留在组件里。
| 放数据层(纯 TS,可单测) | 留在组件里(L2,需引擎) |
|---|---|
| 子弹的位置、速度、存活状态 | 子弹节点的创建与销毁 |
| 每帧积分与碰撞判定 | 预制体实例化、父子关系 |
| 道具计时、生成节奏 | 精灵渲染、动画播放 |
| 结算数值、对象池索引 | 音频、UI 刷新 |
这张表的右侧全是「必须由引擎做的事」——它们依赖运行时、依赖 Node,跑不了单测。左侧则是一个纯函数式的小世界,可以在 Node 里真跑。边界清不清,决定了这段逻辑能不能被测试、能不能被复现。
10.1.4 用纯 TypeScript 写一个极简数据层
下面这段属于 assets/scripts/core/,不 import cc,可以在 Node 里直接跑。它用「结构数组」(SoA,structure of arrays)的思路,把子弹的位置与速度存进独立的 Float32Array,而不是每颗子弹一个对象:
// assets/scripts/core/ecs/world.ts —— 纯 TypeScript,不 import 'cc'
export const MAX_ENTITIES = 1024;
export class World {
readonly alive = new Uint8Array(MAX_ENTITIES);
readonly posX = new Float32Array(MAX_ENTITIES);
readonly posY = new Float32Array(MAX_ENTITIES);
readonly velX = new Float32Array(MAX_ENTITIES);
readonly velY = new Float32Array(MAX_ENTITIES);
private count = 0;
spawn(x: number, y: number, vx: number, vy: number): number {
const id = this.count++;
this.alive[id] = 1;
this.posX[id] = x;
this.posY[id] = y;
this.velX[id] = vx;
this.velY[id] = vy;
return id;
}
despawn(id: number): void {
this.alive[id] = 0;
}
step(dt: number): void {
for (let id = 0; id < this.count; id++) {
if (this.alive[id] === 0) continue;
this.posX[id] += this.velX[id] * dt;
this.posY[id] += this.velY[id] * dt;
}
}
}
这段代码里没有一个 cc 符号。step 是一次典型的「批量更新」:遍历所有实体、跳过已销毁的、做一次积分。它对应 ECS 里 System 的角色,只不过我们没建实体 ID 表、没做组件查询,只用了最简单的「同构数组 + 一个循环」——这正是数据驱动在 Cocos 里最实用的形态:够用就好,不必上完整的 ECS 框架。
10.1.5 用 vitest 锁住它的行为
数据层之所以值得单独抽出来,就是因为它能被测试。给上面的 World 配两条断言:
import { describe, expect, it } from 'vitest';
import { World } from '../core/ecs/world';
describe('World', () => {
it('spawn 后实体存活,按速度积分', () => {
const world = new World();
const id = world.spawn(0, 0, 60, 0);
expect(world.alive[id]).toBe(1);
world.step(0.5);
expect(world.posX[id]).toBeCloseTo(30);
});
it('despawn 后实体不再被更新', () => {
const world = new World();
const id = world.spawn(0, 0, 60, 0);
world.despawn(id);
world.step(1);
expect(world.posX[id]).toBe(0);
});
});
这两条测试跑在 Node 里,不需要引擎、不需要浏览器,几十毫秒就出结果。一旦有人把积分写成 posX[id] = velX[id](漏乘 dt),CI 立刻变红——这就是把逻辑挤进 core/ 的回报。子弹的飞行、道具的倒计时、生成器的节奏,全都属于这一层。
10.1.6 数据层里的对象池
上面的 World 每次 spawn 都从 count 往后分配新 id,despawn 只把 alive 置 0——但 id 只增不减,count 很快会撞上 MAX_ENTITIES。跑酷游戏里子弹一局能发上千颗,单调递增的 id 撑不住,需要回收复用。
改法很直接:维护一个空闲 id 栈,despawn 时把 id 压回去,spawn 时优先弹栈:
export class World {
// 位置与速度数组、alive 同上
private count = 0;
private free: number[] = [];
spawn(x: number, y: number, vx: number, vy: number): number {
const id = this.free.length > 0 ? this.free.pop()! : this.count++;
this.alive[id] = 1;
this.posX[id] = x;
this.posY[id] = y;
this.velX[id] = vx;
this.velY[id] = vy;
return id;
}
despawn(id: number): void {
if (this.alive[id] === 0) return;
this.alive[id] = 0;
this.free.push(id);
}
}
pop()! 里的 ! 是在告诉类型系统「栈非空时取出的元素一定存在」——这正是 10.3 里 noUncheckedIndexedAccess 会要求的写法。对象池是数据层最容易顺手做对的一件事:位置与速度存在扁平数组里,回收只是复用一段下标,不像节点那样要销毁再重建。视图层的节点同样可以池化,但那是另一层的事。
10.1.7 一层薄适配接回节点
数据层算出来的位置,终究要变成屏幕上的东西。这一步交给一个组件做,它是数据层与节点树之间的唯一桥梁:
import { _decorator, Component, Node, Prefab, instantiate, Vec3 } from 'cc';
import { World } from '../core/ecs/world';
const { ccclass, property } = _decorator;
@ccclass('BulletView')
export class BulletView extends Component {
@property(Prefab)
bulletPrefab: Prefab | null = null;
private world = new World();
private views: (Node | null)[] = [];
private tmp = new Vec3();
spawnBullet(x: number, y: number, vx: number, vy: number): void {
const id = this.world.spawn(x, y, vx, vy);
const node = instantiate(this.bulletPrefab!);
this.node.addChild(node);
this.views[id] = node;
}
update(dt: number): void {
this.world.step(dt);
for (let id = 0; id < this.views.length; id++) {
const node = this.views[id];
if (!node) continue;
if (this.world.alive[id] === 0) {
node.destroy();
this.views[id] = null;
continue;
}
this.tmp.set(this.world.posX[id], this.world.posY[id], 0);
node.setPosition(this.tmp);
}
}
}
这个组件的职责被压到最小:它不持有任何玩法数据,只负责「同步」——数据层说某颗子弹在哪,它就把对应节点挪到哪;数据层说这颗子弹没了,它就销毁节点。真正的逻辑(积分、命中判定、生命周期)全在 World 里,可测。instantiate 复制预制体、addChild 挂到父节点、setPosition 改位置,这三个 API 都能在 cc.d.ts 里核到。
注意 update 里复用了同一个 tmp(Vec3)来传坐标,而不是每帧 new Vec3(...)。理由在 7.1 讲过:每帧在热路径上分配对象,等于给 GC 制造垃圾。视图层是热路径,任何分配都要掂量。
10.1.8 子弹的预制体长什么样
bulletPrefab 指向的那个预制体,是这套架构里唯一需要编辑器参与的部分。它的节点结构很简单:
以下节点层级需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现:
Bullet.prefab
└── Bullet (Node)
├── Sprite (Sprite 组件,子弹贴图)
└── Collider (BoxCollider2D 组件,命中判定区)
在编辑器里双击 Bullet.prefab 打开,你会看到层级管理器里只有一个根节点 Bullet,它下面挂着 Sprite 与 Collider 两个子节点(或组件,取决于你的搭法);属性检查器里 Bullet 根节点上没有任何脚本——因为我们刻意不让预制体带玩法脚本。预制体只描述「一颗子弹长什么样」,它「怎么飞」由 World 决定。这个分离是本节架构的关键:预制体是可变的资源,逻辑是不变的代码,两者不要互相渗透。
10.1.9 什么时候不该用数据驱动
数据驱动不是银弹。下面这几种情况,老老实实用组件就好,硬套数据层只会让代码更难读:
| 场景 | 为什么不该上数据层 |
|---|---|
| 唯一的玩家、相机、关卡管理器 | 数量是 1,遍历开销为 0,组件更直观 |
| 需要编辑器可视化编辑的物件 | 数据在组件属性里才能在属性检查器里改 |
| 状态机驱动的角色(待机/跳跃/受击) | 状态本身就是行为,拆成数据反而绕 |
| 需要引擎特性参与的(物理、动画、UI) | 这些必须由引擎驱动,数据层插不上手 |
判断信号很直接:当你想把「一个对象」写成「一行数据」时,先问它是不是只有一个。 只有一个的东西,数据驱动的所有好处(批量遍历、缓存友好、可测)都用不上,只会多一层间接。
10.1.10 工程决策
把这一节收敛成几条可执行的判断:
- 默认用组件-节点模型。它是引擎的主线,编辑器、序列化、物理、动画全都围绕它设计,逆着来代价很大。
- 只在「大量同构对象」上引入数据层。子弹、金币、粒子这类,收益最大;玩家、UI 这类,收益为零。
- 数据层放
core/,永不 importcc。这是它可测的前提,也是 10.3 会用静态检查钉死的纪律。 - 组件只做同步。视图组件不持有玩法数据,只把数据层的状态映射到节点。
- 预制体不带逻辑。预制体描述外观,代码描述行为,两者解耦。
一句话总结:Cocos 不是 ECS,但你可以在一层纯逻辑里借 ECS 的思路——只借「数据与视图分离」这一条,别借整套框架。
下一节把视角从「单个模块怎么写」抬到「整个工程怎么分」。当数据层、玩法层、UI 层、平台层都齐了,真正决定工程能不能长大的,是它们之间的依赖方向——以及当依赖乱成一团时,你怎么把它揪出来。
阅读导航:上一节:目录 · 下一节:10.2 大型项目模块化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。