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 ms | archetype 慢约 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 实体生命周期与句柄复用 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。