《游戏引擎实现原理》12.1 从继承到组合到数据

把「让物体动起来」用继承、组合、数据三种方式各写一遍,对照它们各自的代价;再在 6.2 的世代句柄之上从零搭出一套最小 ECS,用可编译的 MiniEcs 落地实体、组件与查询三件套,并用 vitest 断言「销毁实体后旧句柄立即失效」这一关键行为。

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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径