《游戏引擎实现原理》12.2 组件存储:稀疏集与 archetype

把「每种组件一个稀疏集」和「按组件集合分组的 archetype」两种组件存储都从零实现出来,用同一组操作对比:建 20 万个实体的耗时与堆净增量、以及查询并原地更新的耗时,把本机实测的真实数字写进正文,并给出各自的取舍边界与可操作的选择判据。

12.2 组件存储:稀疏集与 archetype

12.1 的最小 ECS 用的是「每种组件一个稀疏集」——实体下标当稀疏 ID,组件值紧凑存在 dense 数组里。这是最直观的一种组件存储。工业界还有另一种主流方案:archetype——把「组件集合完全相同」的实体分到同一组,组内每种组件一个连续的列。这一节把两种都写出来,跑同一组操作,用真实数字回答「到底该选哪个」。

两种存储,两种取舍

先讲清结构差异,因为后面所有数字都由它决定:

维度稀疏集存储archetype 存储
分组方式按组件类型分(每种组件一个容器)按组件集合分(同签名实体一组)
组件位置同一组件的所有值连续同一组每种组件连续,组内按行对齐
加组件往对应稀疏集 add,O(1)实体要「搬迁」到新组,逐列拷贝
查询跨组件的稀疏集求交直接挑出匹配的组,整列遍历
适合组件增删频繁、集合多样组件集合稳定、批量遍历为主

一句话概括:稀疏集把「按类型查」做到极致,archetype 把「按集合遍历」做到极致。 12.1 的最小 ECS 是前者;下面先补全前者,再实现后者。

方案 A:稀疏集存储

在 6.3 的 SparseSet 之上包一层「world」:每种组件一个稀疏集,再用一个稀疏集记录哪些实体活着。查询时以最小的那个集合为基准,逐个做成员检查。层级 L1,Node 直跑。

// file: src/ecs/sparse-world.ts
// tier: L1
// verify: npx vitest run src/ecs/sparse-world.test.ts
//
// 组件存储方案 A:稀疏集。每种组件一个 SparseSet,用实体下标当稀疏 ID。
// 增删是 O(1);查询要跨多个组件的 SparseSet 求交集(这里用最小的那个当基准)。
// 优点:组件可随时单独增删,存储紧凑;缺点:查询要按「组件集合」现场求交。

import { SparseSet } from '../core/sparse-set.ts';

export type ComponentId = number;

export class SparseWorld {
  #stores = new Map<ComponentId, SparseSet<unknown>>();
  #live = new SparseSet<true>();

  get entityCount(): number {
    return this.#live.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(index: number): void {
    this.#live.add(index, true);
  }

  destroy(index: number): boolean {
    if (!this.#live.has(index)) return false;
    for (const s of this.#stores.values()) s.remove(index);
    return this.#live.remove(index);
  }

  add<T>(index: number, id: ComponentId, value: T): void {
    this.#store(id).add(index, value);
  }

  get<T>(index: number, id: ComponentId): T | undefined {
    return this.#store(id).get(index) as T | undefined;
  }

  /** 取同时拥有全部组件的实体下标;以最小集合为基准,其余做成员检查。 */
  query(ids: ComponentId[]): number[] {
    let smallest: SparseSet<unknown> | undefined;
    for (const id of ids) {
      const s = this.#store(id);
      if (smallest === undefined || s.size < smallest.size) smallest = s;
    }
    if (smallest === undefined) return [];
    const out: number[] = [];
    for (const index of smallest.ids()) {
      if (!this.#live.has(index)) continue;
      let ok = true;
      for (const id of ids) {
        if (!this.#store(id).has(index)) {
          ok = false;
          break;
        }
      }
      if (ok) out.push(index);
    }
    return out;
  }
}

注意 query 里 smallest.ids() 会复制一份 dense ID 数组——这是稀疏集查询的固有开销:求交必须先拿到一个候选集合。archetype 方案里没有这一步,因为「集合」已经预先分好了组。

方案 B:archetype 存储

archetype 的核心是「按签名分组」。每种「组件集合」建一个 Archetype 对象,组内每种组件一条列(普通数组即可,工业实现常用 TypedArray),再加一条 rows 记录每行对应哪个实体。本实现里签名只在建实体时定一次。层级 L1,Node 直跑。

// file: src/ecs/archetype-world.ts
// tier: L1
// verify: npx vitest run src/ecs/archetype-world.test.ts
//
// 组件存储方案 B:archetype。把「组件集合完全相同」的实体分到同一组,
// 组内每种组件一个连续的列(SoA)。查询只要挑出包含所需组件的组,直接整列遍历。
// 代价:给实体增删组件要在组之间「搬迁」(逐列拷贝),比稀疏集重。

export type ComponentId = number;

export class Archetype {
  readonly ids: readonly ComponentId[];
  readonly key: string;
  readonly cols = new Map<ComponentId, unknown[]>();
  readonly rows: number[] = []; // 每行对应的实体下标

  constructor(ids: ComponentId[]) {
    this.ids = ids;
    this.key = ids.join(',');
    for (const id of ids) this.cols.set(id, []);
  }

  get count(): number {
    return this.rows.length;
  }

  push(index: number, values: Map<ComponentId, unknown>): number {
    for (const id of this.ids) this.cols.get(id)!.push(values.get(id));
    this.rows.push(index);
    return this.rows.length - 1;
  }

  column<T>(id: ComponentId): T[] {
    return this.cols.get(id) as T[];
  }

  /** swap-remove 第 row 行;返回被搬进 row 的实体下标,row 本就是末行时返回 -1。 */
  swapRemove(row: number): number {
    const last = this.rows.length - 1;
    for (const id of this.ids) {
      const c = this.cols.get(id)!;
      c[row] = c[last];
      c.pop();
    }
    const moved = this.rows[last];
    this.rows[row] = moved;
    this.rows.pop();
    return row === last ? -1 : moved;
  }
}

interface Loc {
  arch: Archetype;
  row: number;
}

export class ArchetypeWorld {
  #archetypes = new Map<string, Archetype>();
  #loc = new Map<number, Loc>();

  get entityCount(): number {
    return this.#loc.size;
  }

  get archetypeCount(): number {
    return this.#archetypes.size;
  }

  #archetypeFor(ids: ComponentId[]): Archetype {
    const sorted = [...ids].sort((a, b) => a - b);
    const key = sorted.join(',');
    let a = this.#archetypes.get(key);
    if (a === undefined) {
      a = new Archetype(sorted);
      this.#archetypes.set(key, a);
    }
    return a;
  }

  /** 建实体:给出它当前拥有的全部组件(一次性落到对应 archetype)。 */
  spawn(index: number, values: Map<ComponentId, unknown>): void {
    const arch = this.#archetypeFor([...values.keys()]);
    this.#loc.set(index, { arch, row: arch.push(index, values) });
  }

  destroy(index: number): boolean {
    const loc = this.#loc.get(index);
    if (loc === undefined) return false;
    const moved = loc.arch.swapRemove(loc.row);
    if (moved !== -1) this.#loc.get(moved)!.row = loc.row;
    this.#loc.delete(index);
    return true;
  }

  /** 取包含全部所需组件的 archetype,供整列遍历。 */
  matching(ids: ComponentId[]): Archetype[] {
    const out: Archetype[] = [];
    for (const a of this.#archetypes.values()) {
      if (a.count === 0) continue;
      let ok = true;
      for (const id of ids) {
        if (!a.cols.has(id)) {
          ok = false;
          break;
        }
      }
      if (ok) out.push(a);
    }
    return out;
  }
}

swapRemove 是关键:删一行时把末行搬到被删位置,再修正被搬动实体的 row——列永远保持紧凑,遍历无空洞。这和 6.3 稀疏集的 swap-remove 是同一手法,只是这里要同时对多列做。

实测:同一组操作的两种数字

基准建 20 万个「都有 Position + Velocity + Sprite 三个组件」的实体,然后查询 Position + Velocity 并原地更新,两者做完全一样的活。下面是核心逻辑(完整可运行文件在 bench/ecs-storage-bench.ts,用 node --expose-gc bench/ecs-storage-bench.ts 跑)。

// snippet: bench/ecs-storage-bench.ts 的核心——同一组操作跑两种存储
// 建 N 个都有 POS+VEL+SPR 的实体;查 POS+VEL 并原地更新;各跑 REPS 次取中位数。
function buildSparse(): SparseWorld {
  const w = new SparseWorld();
  for (let i = 0; i < N; i++) {
    w.spawn(i);
    w.add<Vec2>(i, POS, { x: i, y: 0 });
    w.add<Vec2>(i, VEL, { x: 1, y: 0 });
    w.add<number>(i, SPR, i & 7);
  }
  return w;
}

function buildArchetype(): ArchetypeWorld {
  const w = new ArchetypeWorld();
  for (let i = 0; i < N; i++) {
    w.spawn(i, new Map([[POS, { x: i, y: 0 }], [VEL, { x: 1, y: 0 }], [SPR, i & 7]]));
  }
  return w;
}

function updateSparse(w: SparseWorld): number {
  const ids = w.query([POS, VEL]);        // 求交:拿最小集合当基准
  for (const i of ids) {
    const p = w.get<Vec2>(i, POS)!;
    const v = w.get<Vec2>(i, VEL)!;
    p.x += v.x;
    p.y += v.y;
  }
  return ids.length;
}

function updateArchetype(w: ArchetypeWorld): number {
  let n = 0;
  for (const a of w.matching([POS, VEL])) { // 组即结果:直接整列扫
    const px = a.column<Vec2>(POS);
    const py = a.column<Vec2>(VEL);
    for (let r = 0; r < a.count; r++) {
      px[r].x += py[r].x;
      px[r].y += py[r].y;
      n++;
    }
  }
  return n;
}

本机(Node 26.5.0)实测,N = 200000,9 次取中位数:

指标稀疏集archetype比值
建 20 万实体约 36 ms约 143 msarchetype 慢约 4×
查询 POS+VEL 并原地更新约 14 ms约 1 ms稀疏集慢约 10–17×
建实体堆净增量+38.9 MiB+37.8 MiB基本持平

三个数字要连着看:

  • 建实体:archetype 慢约 4×。 每建一个实体,archetype 要现算签名、查/建组、再把值逐个 push 进各列;基准里还给每个实体现造了一个 Map,这笔分配也记在它头上。稀疏集只是往三个稀疏集各 add 一次。
  • 查询+更新:稀疏集慢约 10–17×。 这是 archetype 的主场。稀疏集要先求交(复制候选集合、逐个成员检查),再对每个实体做两次跨容器取值;archetype 直接拿到匹配的组,顺着两列连续内存往下扫。这正是第 11 章「连续访存」结论在组件存储上的兑现。
  • 堆占用:两者接近。 本基准里组件值是同样的 {x,y} 对象,对象头占了绝大多数,稀疏集多出的 sparse/denseIds 数组相比之下很小。要拉开内存差距,得把组件改成定长数值、让 archetype 用 TypedArray 列来存。

怎么选

把结论落成一句可操作的判断:

你的场景选哪个
实体组件集合变化频繁(频繁 add/remove 组件)稀疏集(增删 O(1),不搬迁)
组件集合稳定、每帧大量批量遍历archetype(连续列,遍历最快)
组件种类多、每种组件只有少数实体拥有稀疏集(按类型分,天然稀疏)
想把遍历榨到极致、且能接受增删组件时搬迁archetype

需要提醒的是,本节的倍数都来自作者本机、单线程、纯 JS 的微基准。archetype 的「10–17×」在把组件换成 TypedArray 列、或在真实多核调度下还会变;请把重点放在方向上:archetype 赢在批量遍历,稀疏集赢在增删灵活。真实引擎里两者常混用——Bevy 用 archetype,EnTT 主打稀疏集。附录 C 会给出测量方法,鼓励你在自己的机器上复现。

无论选哪种存储,实体本身都还是那个 32 位世代句柄。下一节专门讲实体的生命周期——创建、销毁、以及句柄怎么被安全复用。

阅读导航:上一节:12.1 从继承到组合到数据 · 下一节:12.3 实体生命周期与句柄复用 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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