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 |
|---|---|---|---|---|
| 10 | 0 | 0 | 'a' | 10 |
| 20 | 1 | 1 | 'b' | 20 |
| 30 | 2 | 2 | 'c' | 30 |
删除 ID 10 之后:
| 稀疏 ID | #sparse[ID] | #dense 下标 | #dense 值 | #denseIds |
|---|---|---|---|---|
| 10 | -1 | 0 | 'c' | 30 |
| 20 | 1 | 1 | 'b' | 20 |
| 30 | 0 |
被删位置 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 动态数组与开放寻址哈希 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。