12.1 从继承到组合到数据
第 11 章把「数据摆在内存里的顺序」讲透了:同样的算法,连续访存和随机访存能差出一个数量级。但那只回答了「怎么摆」,没回答「摆什么」。这一章开始搭 ECS,第一件事就是决定用什么东西来组织游戏里的对象——是让对象继承一棵类树,还是用组件拼装,还是干脆不要对象、只剩一堆数据数组。三种方式都能让《微光岛》里的角色动起来,代价却完全不同。
本章与站内既有内容的分工
站内有三篇内容已经从「ECS 是什么、怎么选、架构哲学」的角度讲得很透,本章不重讲它们的主线。本章只做一件事:在 6.2 的世代句柄(generational slot map)之上,从零搭一套最小 ECS,并且每一步都真跑、真断言。
| 既有文章 | 它讲什么 | 本节只做什么 |
|---|---|---|
/game-ecs-architecture/ | 从 OOP 撞墙讲到 ECS 三段式定义、AoS/SoA 布局与三引擎对照,再落到场景图与帧循环 | 不重讲「ECS 是什么」,只把「实体 + 组件 + 查询」用可编译代码搭出来并断言行为 |
/game-engine-architecture-ecs/ | ECS 的存储模型(Sparse Set / Archetype)、System 并行调度与框架选型 | 不比框架、不推导理论,只在世代句柄之上给一套最小 ECS 的可运行实现 |
/game-engine-ecs-architecture-deep-dive/ | 用「版本化 Entity ID + 稀疏集 + ComponentPool(SoA)」串起整个 ECS 引擎骨架 | 它是一篇讲清骨架的文章;本节是可运行的分步实现 + 本机实测,把同一件事拆成「先跑通、再看数字」 |
换句话说,那三篇告诉你「ECS 长什么样、为什么这么设计」,本节给你「照着敲一遍就能跑起来」的那一版,并且把每个行为的验证也一起给你。
三种组织方式:同一件事的三种写法
先看一个最小的问题:场景里有一堆「会动的东西」,每帧要按速度把它们的位置往前推。下面用继承、组合、数据三种方式各写一遍。
// snippet: 「让所有会动的东西每帧前进」——三种组织方式
// A 继承:行为挂在类树上,位置和速度是对象的字段
class Actor {
x = 0; y = 0;
update(_dt: number): void {}
}
class MovingActor extends Actor {
vx = 0; vy = 0;
override update(dt: number): void { this.x += this.vx * dt; this.y += this.vy * dt; }
}
// B 组合:对象由可插拔的组件拼成,行为在系统里按需挑选组件
interface Transform { x: number; y: number; }
interface Mover { vx: number; vy: number; }
class GameObject {
transform: Transform = { x: 0, y: 0 };
mover?: Mover; // 没有 mover 的物体不会动
}
// C 数据:不要对象,只有一组平行的连续数组,更新就是扫一遍数组
const posX: number[] = [];
const posY: number[] = [];
const velX: number[] = [];
const velY: number[] = [];
function updateAll(dt: number): void {
for (let i = 0; i < posX.length; i++) {
posX[i] += velX[i] * dt;
posY[i] += velY[i] * dt;
}
}
三种写法各自解决了什么问题,又把什么问题留给了你,可以并排看:
| 组织方式 | 加一种新物体 | 加一种新行为 | 遍历代价 | 主要痛点 |
|---|---|---|---|---|
| 继承(A) | 新开一个子类 | 往基类加方法,全树受影响 | 对象散落堆上,逐个虚调用 | 类爆炸、菱形继承、改基类波及全部 |
| 组合(B) | 挂不同组件即可 | 写个新系统挑组件 | 要按类型过滤,缓存不友好 | 组件对象仍散落堆上,过滤有开销 |
| 数据(C) | 往数组里加一格 | 写个新循环 | 连续数组顺序扫 | 数据与「对象」解耦,改起来反直觉 |
第 11 章已经证明:C 的遍历代价最低,因为它是唯一能把「同一种数据」摆成连续内存的做法。继承和组合都能让程序跑对,但到了每帧要更新几万个物体时,散落堆上的对象会让缓存一次次落空。ECS 本质上是把 C 这条路线工程化:用「实体」代替对象身份,用「组件」代替字段,用「系统」代替方法。
可运行产物:在世代句柄上搭最小 ECS
下面就是 helio 的最小 ECS,完整可编译。它只有三个角色:实体是一个世代句柄(直接复用 6.2 的 SlotMap),组件存储是每种组件一个稀疏集(6.3 的 SparseSet),查询把拥有某组组件的实体筛出来。层级 L1,Node 直跑。
// file: src/ecs/mini-ecs.ts
// tier: L1
// verify: npx vitest run src/ecs/mini-ecs.test.ts
//
// helio 内核:在世代句柄(SlotMap)之上搭的最小 ECS。
// 实体 = 世代句柄;组件存储 = 每种组件一个 SparseSet,用句柄的槽位号当稀疏 ID。
// 不做查询缓存、不做并行——只把「实体 / 组件 / 系统」三件套接起来。
import { SlotMap, handleIndex, type Handle } from '../core/slotmap.ts';
import { SparseSet } from '../core/sparse-set.ts';
export type ComponentId = number;
/** 每个实体记录它挂了哪些组件,用于销毁时逐个清理。 */
interface EntityInfo {
comps: Set<ComponentId>;
}
export class MiniEcs {
#entities = new SlotMap<EntityInfo>();
#stores = new Map<ComponentId, SparseSet<unknown>>();
get entityCount(): number {
return this.#entities.size;
}
#store(id: ComponentId): SparseSet<unknown> {
let s = this.#stores.get(id);
if (s === undefined) {
s = new SparseSet<unknown>();
this.#stores.set(id, s);
}
return s;
}
spawn(): Handle {
return this.#entities.insert({ comps: new Set() });
}
alive(h: Handle): boolean {
return this.#entities.has(h);
}
add<T>(h: Handle, id: ComponentId, value: T): void {
const info = this.#entities.expect(h);
this.#store(id).add(handleIndex(h), value);
info.comps.add(id);
}
has(h: Handle, id: ComponentId): boolean {
if (!this.#entities.has(h)) return false;
return this.#store(id).has(handleIndex(h));
}
get<T>(h: Handle, id: ComponentId): T | undefined {
if (!this.#entities.has(h)) return undefined;
return this.#store(id).get(handleIndex(h)) as T | undefined;
}
remove(h: Handle, id: ComponentId): boolean {
const info = this.#entities.get(h);
if (info === undefined) return false;
info.comps.delete(id);
return this.#store(id).remove(handleIndex(h));
}
/** 销毁实体:先清掉它的全部组件,再回收句柄(世代自增)。 */
destroy(h: Handle): boolean {
const info = this.#entities.get(h);
if (info === undefined) return false;
for (const id of info.comps) this.#store(id).remove(handleIndex(h));
return this.#entities.remove(h);
}
/** 查询:返回同时拥有全部给定组件的实体句柄。 */
query(...ids: ComponentId[]): Handle[] {
const out: Handle[] = [];
for (const [h, info] of this.#entities.entries()) {
let ok = true;
for (const id of ids) {
if (!info.comps.has(id)) {
ok = false;
break;
}
}
if (ok) out.push(h);
}
return out;
}
}
三点值得说明:
- 实体不是对象,是句柄。
spawn()返回一个number,里面装着「槽位索引 + 世代」。你不持有实体本身,只持有它的编号——所以把句柄存进组件、写进存档都成立(6.2 已讲)。 - 组件存储用句柄的槽位号当稀疏 ID。
add时用handleIndex(h)定位稀疏集,get/has同理。组件的增删是 O(1),和 6.3 的结论一致。 - 查询是线性扫描。 这里最省事:遍历所有实体,逐个检查它有没有所需组件。第 13 章会把它换成「按最小集合求交 + 缓存」,本节先跑对。
测试:实体、组件、查询三件套
行为对不对,靠断言说话。下面这组用例把增删查、查询命中、销毁清组件、以及最关键的「槽位复用后旧句柄读不到新实体」都钉死。
// file: src/ecs/mini-ecs.test.ts
// tier: L1
// verify: npx vitest run src/ecs/mini-ecs.test.ts
import { describe, expect, it } from 'vitest';
import { MiniEcs } from './mini-ecs.ts';
const POSITION = 1;
const VELOCITY = 2;
const SPRITE = 3;
interface Vec2 {
x: number;
y: number;
}
describe('MiniEcs 基本操作', () => {
it('spawn 返回有效句柄,组件可增可查', () => {
const ecs = new MiniEcs();
const e = ecs.spawn();
expect(ecs.alive(e)).toBe(true);
ecs.add<Vec2>(e, POSITION, { x: 1, y: 2 });
expect(ecs.has(e, POSITION)).toBe(true);
expect(ecs.get<Vec2>(e, POSITION)).toEqual({ x: 1, y: 2 });
expect(ecs.has(e, VELOCITY)).toBe(false);
expect(ecs.entityCount).toBe(1);
});
it('query 返回同时拥有全部组件的实体', () => {
const ecs = new MiniEcs();
const a = ecs.spawn();
ecs.add(a, POSITION, { x: 0, y: 0 });
ecs.add(a, VELOCITY, { x: 1, y: 0 });
const b = ecs.spawn();
ecs.add(b, POSITION, { x: 0, y: 0 });
ecs.add(b, SPRITE, 7);
const movers = ecs.query(POSITION, VELOCITY);
expect(movers).toEqual([a]);
});
it('destroy 清组件并让旧句柄立即失效', () => {
const ecs = new MiniEcs();
const e = ecs.spawn();
ecs.add(e, POSITION, { x: 0, y: 0 });
expect(ecs.destroy(e)).toBe(true);
expect(ecs.alive(e)).toBe(false);
expect(ecs.get(e, POSITION)).toBeUndefined();
expect(ecs.entityCount).toBe(0);
});
it('槽位复用但旧句柄读不到新实体的组件', () => {
const ecs = new MiniEcs();
const old = ecs.spawn();
ecs.add(old, POSITION, { x: 9, y: 9 });
ecs.destroy(old);
const fresh = ecs.spawn(); // 复用同一槽位,世代自增
ecs.add(fresh, POSITION, { x: 1, y: 1 });
expect(ecs.get(old, POSITION)).toBeUndefined(); // 旧句柄失效
expect(ecs.get(fresh, POSITION)).toEqual({ x: 1, y: 1 });
});
it('remove 单组件后 query 不再命中', () => {
const ecs = new MiniEcs();
const e = ecs.spawn();
ecs.add(e, POSITION, { x: 0, y: 0 });
ecs.add(e, VELOCITY, { x: 0, y: 0 });
expect(ecs.remove(e, VELOCITY)).toBe(true);
expect(ecs.query(POSITION, VELOCITY)).toEqual([]);
expect(ecs.has(e, POSITION)).toBe(true);
});
});
本机实测:npx vitest run src/ecs/mini-ecs.test.ts 五个用例全部通过。第四个用例是 6.1「删除后旧引用还能读到东西」的 ECS 版对照——同一个场景,最小 ECS 给出的答案是 undefined。
三种方式的代价,落回到工程判断
把这三种方式和它们在 helio 里的对应物并排看:
| 关注点 | 继承(A) | 组合(B) | 数据 / ECS(C) |
|---|---|---|---|
| 身份 | 对象引用 | 对象引用 | 世代句柄(整数) |
| 数据位置 | 散落堆上 | 组件对象散落堆上 | 稀疏集 dense 数组(连续) |
| 加新类型 | 改类树 | 加组件 | 加组件存储 + 系统 |
| 遍历 | 虚调用 + 缓存落空 | 过滤 + 缓存落空 | 顺序扫连续数组 |
| 可存档 | 引用写进文件即坏 | 同左 | 句柄是整数,可存可校验 |
结论不是「继承和组合是错的」,而是:当每帧要批量处理成千上万个同类物体时,只有数据导向能把遍历压成一段连续内存。 《微光岛》里程序化生成的小岛上有成百上千个可收集物,它们的行为高度同构——正是数据导向最划算的场景。反过来,如果只有几十个彼此差异极大的对象,组合甚至继承都更省事,没必要上 ECS。
还有一个常被忽略的代价:ECS 把「对象」拆成了句柄和一堆数组之后,你不能再对单个实体调用方法。想让它做点什么,得写一个系统、在系统里查一批实体、再统一处理。这层转换是数据导向的门槛,也是它换来遍历性能的前提。第 13 章会把这层「系统怎么查、怎么调度」补完。
下一节就把本节这套「每种组件一个稀疏集」的存储,和另一种工业界常用的 archetype 存储放在一起实现、一起实测,看它们各自的数字。
阅读导航:上一节:目录 · 下一节:12.2 组件存储:稀疏集与 archetype 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。