《游戏引擎实现原理》6.3 稀疏集、句柄回收与 ECS 的接缝

稀疏集用 sparse 数组把稀疏 ID 映射到 dense 下标、dense 数组紧凑存值,换来 O(1) 增删查和一段连续内存上的遍历。本节实现可编译的 SparseSet,用 swap-remove 做句柄回收,并断言删除之后 dense 依然无空洞、遍历次数恒等于元素个数。

6.3 稀疏集、句柄回收与 ECS 的接缝

6.2 的 SlotMap 已经能防住悬垂句柄,但它的存储层是「按槽位索引排的数组」,中间可能有大量空洞——entries() 必须逐个跳过空槽。对每帧都要全量遍历的组件存储来说,这是不可接受的。这一节实现稀疏集:用 O(1) 的代价把存储压成一段连续的 dense 数组,让遍历既无分支又缓存友好,同时用 swap-remove 把「句柄回收」和「紧凑存储」这两件本来矛盾的事缝合起来。

本节与三篇 ECS 文章的分工

站内有三篇内容分别从骨架、理论、项目落地三个角度讲过 ECS。本节不重讲它们的主线,只切出「存储层怎么做到既紧凑又 O(1)」这一块。

既有文章它负责什么本节只做什么
/game-engine-ecs-architecture-deep-dive/ (811 行)用版本化 ID + 稀疏集串起整个 ECS 引擎骨架把稀疏集的存储层单独拎出来,给可编译实现与可断言的行为
/game-engine-architecture-ecs/ECS 的理论模型、组件/系统/实体的组织不解释理论,只讲 dense/sparse 两个数组的映射
/game-ecs-architecture/游戏项目里 ECS 的落地与选型不讨论选型,直接给出 SparseSet<T> 的 TS 版
/defold-ecs-architecture/子系统视角下引擎如何组织实体提供从零实现的 Node 直跑代码,而非子系统说明

稀疏集的三个数组

稀疏集的核心是「用空间换时间,再用紧凑换缓存」:

  • #sparse:下标是稀疏 ID(比如实体句柄的槽位号,可以很稀疏),值是该 ID 在 dense 中的下标;不存在时记为 -1。
  • #dense:真正存放的值,永远紧凑、无空洞。
  • #denseIds:与 #dense 平行,记录每个 dense 位置对应的 ID,用于反向校验和 swap-remove 时回填 #sparse。

查一个 ID 是两次数组访问:d = sparse[id],再 dense[d]——O(1)。遍历只走 #dense,与 ID 有多稀疏无关。

has 里那句 #denseIds[d] === id 不是多余的:#sparse 里可能残留过期下标,只有和 #denseIds 交叉验证过,才能确认这个 ID 真的存在。

把三个数组画出来,add(10,'a'); add(20,'b'); add(30,'c') 之后是这样:

稀疏 ID#sparse[ID]#dense 下标#dense 值#denseIds
1000'a'10
2011'b'20
3022'c'30

删除 ID 10 之后:

稀疏 ID#sparse[ID]#dense 下标#dense 值#denseIds
10-10'c'30
2011'b'20
300

被删位置 0 填上了原来的末尾元素 'c',#dense 仍然是连续两格,没有空洞。代价是 ID 10 与 ID 30 在遍历顺序里换了位置——这正是 swap-remove 用「顺序」换「紧凑」的地方。

可运行产物:SparseSet 实现

下面是完整可编译的 src/core/sparse-set.ts。删除用 swap-remove:把 dense 末尾的元素搬到被删位置,再 pop 掉末尾——一步就把空洞填上,dense 永远保持 packed。层级 L1,Node 直跑。

// file: src/core/sparse-set.ts
// tier: L1
// verify: npx vitest run src/core/sparse-set.test.ts
//
// 稀疏集(sparse set):sparse 数组把「稀疏 ID」映射到 dense 下标,
// dense 数组紧凑存值。O(1) 增删查 + 紧凑遍历(dense 连续,缓存友好)。
// 删除用 swap-remove:把 dense 末尾元素搬到被删位置,保持 dense 无空洞。

export class SparseSet<T> {
  #sparse: number[] = []; // sparse[id] = dense 下标,-1 表示不存在
  #dense: T[] = [];
  #denseIds: number[] = [];

  get size(): number {
    return this.#dense.length;
  }

  has(id: number): boolean {
    const d = this.#sparse[id];
    return (
      d !== undefined &&
      d >= 0 &&
      d < this.#dense.length &&
      this.#denseIds[d] === id
    );
  }

  get(id: number): T | undefined {
    return this.has(id) ? this.#dense[this.#sparse[id]] : undefined;
  }

  /** 插入:id 已存在则返回 false(不覆盖)。 */
  add(id: number, value: T): boolean {
    if (this.has(id)) return false;
    this.#sparse[id] = this.#dense.length;
    this.#denseIds.push(id);
    this.#dense.push(value);
    return true;
  }

  /** 删除:swap-remove,O(1),dense 始终保持紧凑。 */
  remove(id: number): boolean {
    if (!this.has(id)) return false;
    const d = this.#sparse[id];
    const last = this.#dense.length - 1;
    const lastId = this.#denseIds[last];
    this.#dense[d] = this.#dense[last];
    this.#denseIds[d] = lastId;
    this.#sparse[lastId] = d;
    this.#dense.pop();
    this.#denseIds.pop();
    this.#sparse[id] = -1;
    return true;
  }

  /** 紧凑遍历:直接按 dense 顺序走,无空洞、无分支。 */
  *values(): Generator<T> {
    for (let i = 0; i < this.#dense.length; i++) yield this.#dense[i];
  }

  ids(): number[] {
    return [...this.#denseIds];
  }

  /** 暴露内部 dense 数组本身,用于断言「连续、无空洞」。 */
  denseView(): readonly T[] {
    return this.#dense;
  }
}

测试:O(1) 增删查与 dense 紧凑遍历

缓存友好性在纯 JS 里没法直接量内存地址,但它有一个可断言的结构等价物:dense.length === size,且数组里没有 undefined 空洞。只要这两条成立,遍历就是走一段连续内存。下面的用例把这一点、以及 swap-remove 的行为都钉死。

// file: src/core/sparse-set.test.ts
// tier: L1
// verify: npx vitest run src/core/sparse-set.test.ts

import { describe, expect, it } from 'vitest';
import { SparseSet } from './sparse-set.ts';

describe('SparseSet 基本操作', () => {
  it('增删查 O(1) 语义正确', () => {
    const s = new SparseSet<string>();
    expect(s.add(3, 'three')).toBe(true);
    expect(s.add(1000, 'grand')).toBe(true);
    expect(s.add(3, 'dup')).toBe(false); // 重复 id 不覆盖
    expect(s.size).toBe(2);
    expect(s.get(3)).toBe('three');
    expect(s.get(1000)).toBe('grand');
    expect(s.get(999)).toBeUndefined();
    expect(s.has(3)).toBe(true);
    expect(s.remove(3)).toBe(true);
    expect(s.remove(3)).toBe(false); // 二次删除无效
    expect(s.has(3)).toBe(false);
    expect(s.size).toBe(1);
  });

  it('稀疏 id 不撑大 dense(大 id 也只用 1 个槽)', () => {
    const s = new SparseSet<number>();
    s.add(1_000_000, 42);
    expect(s.size).toBe(1);
    expect(s.denseView().length).toBe(1); // dense 只有 1 个元素
  });
});

describe('dense 紧凑性(缓存友好)', () => {
  it('删除后 dense 无空洞:length === size,且无 undefined', () => {
    const s = new SparseSet<number>();
    for (let i = 0; i < 100; i++) s.add(i, i * 10);
    // 删掉一半(偶数 id)
    for (let i = 0; i < 100; i += 2) s.remove(i);
    expect(s.size).toBe(50);
    const dense = s.denseView();
    expect(dense.length).toBe(50); // 与 size 严格相等 => 无空洞
    expect(dense.includes(undefined as never)).toBe(false);
  });

  it('swap-remove:被删位置填入 dense 末尾元素', () => {
    const s = new SparseSet<string>();
    s.add(10, 'a');
    s.add(20, 'b');
    s.add(30, 'c');
    // dense 顺序 = ['a','b','c'],删 'a'(下标 0)
    s.remove(10);
    // 末尾 'c' 被搬到下标 0
    expect([...s.values()]).toEqual(['c', 'b']);
    expect(s.ids()).toEqual([30, 20]);
  });

  it('遍历次数恒等于 size(紧凑遍历,无分支跳过)', () => {
    const s = new SparseSet<number>();
    for (let i = 0; i < 1000; i++) s.add(i * 7, i);
    for (let i = 0; i < 500; i++) s.remove(i * 7);
    let visited = 0;
    for (const _ of s.values()) visited++;
    expect(visited).toBe(s.size);
    expect(visited).toBe(500);
  });

  it('内部 dense 是真正的 packed 数组(无 hole)', () => {
    const s = new SparseSet<number>();
    for (let i = 0; i < 64; i++) s.add(i, i);
    for (let i = 0; i < 64; i += 3) s.remove(i);
    const dense = s.denseView();
    // packed 数组:Object.keys 的数量等于 length(hole 不会产生 key)
    expect(Object.keys(dense).length).toBe(dense.length);
  });
});

本机实测:npx vitest run src/core/sparse-set.test.ts 全部通过。第一个用例里 add(1_000_000, 42) 之后 dense.length 仍是 1——稀疏 ID 不会撑大存储,这正是稀疏集相对「数组下标即 ID」的优势。

为什么不用 Map

一个自然的疑问是:Map<number, T> 也是 O(1) 增删查,为什么还要自己写稀疏集?答案是遍历。

  • Map 的迭代器要遍历整个哈希表,包括删除后留下的空桶;元素越多、删得越勤,遍历要跳过的空位越多。
  • Map 的键值对散列存放,相邻键在内存里并不相邻,遍历时的访问模式接近「随机跳」,对 CPU 缓存不友好。
  • 稀疏集的 #dense 是一段连续数组,遍历就是顺序读内存,硬件预取器能提前把下一块拉进缓存。

换句话说,Map 优化的是「按键查找」,稀疏集优化的是「全量遍历」。ECS 的系统每帧都要遍历所有带某个组件的实体,遍历是热路径,所以选择稀疏集。反过来,如果只需要「按 ID 查单个值」而不需要遍历(比如资源表),Map 完全够用,不必上稀疏集。

稀疏集在 helio 里的位置

稀疏集本身还不是 ECS,它只是组件存储的底层容器。完整的 ECS 要在它之上再叠一层:

  • 实体:一个世代句柄(6.2 的 Handle)。
  • 组件存储:每种组件一个 SparseSet<T>,句柄的槽位号当稀疏 ID。
  • 查询:把若干组件的 SparseSet 求交集,得到同时拥有这些组件的实体。

这一层的分工,站内那三篇 ECS 文章已经讲得很透,本卷只在第 12、13 章把「组件存储」和「查询」从零接起来。本节要交付的只有一件事:一个 dense 连续、O(1) 增删查、能用 swap-remove 回收的 SparseSet<T>。

句柄回收:swap-remove 与世代句柄的接缝

SlotMap 和 SparseSet 解决的是同一个问题的两面:

  • SlotMap 管身份:句柄带上世代,槽位复用时旧句柄立刻失效。
  • SparseSet 管存储:dense 紧凑,遍历无空洞。

它们的接缝就是「回收」这个动作。SlotMap.remove 把槽位推回 #free 供复用;SparseSet.remove 用 swap-remove 把末尾搬到洞上。两者都不会真的把内存还给系统——回收的语义是「标记为空闲、允许复用」,而不是 free。这也是为什么对象池、freelist、slot map 都会复用地址,从而必须配一套世代机制来防悬垂。

把两者叠起来,就是 helio 内核后面组件存储的形态:句柄是「索引 + 世代」的 32 位整数,索引指向 dense 中的槽位,世代保证旧句柄取不到值。6.1 的复现用例在这里彻底闭环——「删除 → 复用 → 旧句柄访问」的答案从「读到蝙蝠」变成了「取不到值」。

一个需要留意的点:swap-remove 会打乱 dense 的顺序,所以「第 i 个插入的元素」在遍历时未必排在第 i 位。依赖遍历顺序的代码(比如按插入序渲染)必须自己带一个序号,而不能依赖 dense 下标——这是稀疏集为紧凑性付出的代价。

需要强调的是,顺序被打乱在 ECS 里通常可以接受:系统遍历组件时关心的是「有哪些实体」,而不是「它们在第几位」。真正需要稳定顺序的地方(比如渲染队列的绘制顺序)应该在更上层用排序键显式处理,而不是依赖存储层的遍历顺序。把「顺序」这个隐含假设从存储层剥掉,正是稀疏集能同时做到 O(1) 删除和紧凑遍历的前提。

三种容器的取舍并排看更清楚:

容器增/删/查遍历适用场景
数组 + 裸索引O(1)有空洞,需逐个跳过不推荐(6.1 的失效源)
Map<number, T>O(1) 均摊哈希表,随机跳只查不遍历(资源表)
SparseSet<T>O(1)dense 连续,无空洞组件存储(每帧遍历)

这张表也是本节相对站内三篇 ECS 文章的定位:它们讲「用什么」,本节把「为什么是稀疏集」用可跑的代码钉实。

阅读导航:上一节:6.2 slot map 与世代句柄 · 下一节:7.1 动态数组与开放寻址哈希 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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