12.3 实体生命周期与句柄复用
12.1 的 MiniEcs 里,实体的生死由 spawn() 和 destroy() 管。看起来只是两句调用,背后却要同时照顾三件事:句柄一旦销毁就必须立刻失效(否则旧引用会读到新实体)、槽位要能复用(否则数组无限增长)、复用又不能复活旧句柄(否则前面两条互相打架)。这一节把这三件事收进一个实体注册表,直接复用 6.2 的世代句柄,并把它跑出数字。
实体注册表:把 SlotMap 包一层
EntityRegistry 就是 6.2 SlotMap 的一层薄封装:句柄的分配、校验、回收全交给 SlotMap,注册表只负责再挂一点实体自己的信息(这里是一个自增的 serial),以及一个「延迟销毁」的队列。层级 L1,Node 直跑。
// file: src/ecs/entity-registry.ts
// tier: L1
// verify: npx vitest run src/ecs/entity-registry.test.ts
//
// 实体生命周期管理:直接复用第 6 章的世代句柄 SlotMap。
// 句柄 = 「槽位索引 + 世代」,销毁时世代自增,旧句柄立刻失效;
// 新实体复用同一槽位时拿到的是新世代,因此不会和旧句柄混淆。
// 另附「延迟销毁」命令缓冲:本帧内先标记,帧末统一 flush。
import { SlotMap, handleGeneration, handleIndex, type Handle } from '../core/slotmap.ts';
/** 实体上挂的最小身份信息(真正的组件在组件存储里)。 */
interface EntityTag {
serial: number;
}
export class EntityRegistry {
#slots = new SlotMap<EntityTag>();
#pending: Handle[] = [];
#serial = 0;
get count(): number {
return this.#slots.size;
}
get pendingCount(): number {
return this.#pending.length;
}
spawn(): Handle {
return this.#slots.insert({ serial: this.#serial++ });
}
alive(h: Handle): boolean {
return this.#slots.has(h);
}
/** 句柄的槽位号——组件存储用它当稀疏 ID。 */
slotOf(h: Handle): number {
return handleIndex(h);
}
/** 句柄的世代号——同一槽位的第几任住户。 */
generationOf(h: Handle): number {
return handleGeneration(h);
}
serialOf(h: Handle): number {
return this.#slots.expect(h).serial;
}
/** 立即销毁:槽位进空闲栈,世代自增。 */
destroy(h: Handle): boolean {
return this.#slots.remove(h);
}
/** 延迟销毁:本帧内只入队,帧末由 flush 统一执行。 */
queueDestroy(h: Handle): void {
this.#pending.push(h);
}
/** 执行所有排队的销毁;对同一句柄重复排队只会销毁一次(第二次已失效)。 */
flush(): number {
let done = 0;
for (const h of this.#pending) {
if (this.#slots.remove(h)) done++;
}
this.#pending.length = 0;
return done;
}
handles(): Handle[] {
return [...this.#slots.entries()].map(([h]) => h);
}
}
这里没有任何「世代」的运算——makeHandle、世代自增、失效校验全在 SlotMap 里(6.2 已实现)。注册表要做的是在正确的时机调用 insert / remove,把生命周期这件事收口到一个地方,别的模块只能通过句柄和 alive 来感知实体是否还在。
测试:销毁后旧句柄立即失效
生命周期对不对,全靠断言。下面这组用例把「销毁即失效」「复用换世代」「延迟销毁」「复用不涨数组」都钉死。
// file: src/ecs/entity-registry.test.ts
// tier: L1
// verify: npx vitest run src/ecs/entity-registry.test.ts
import { describe, expect, it } from 'vitest';
import { EntityRegistry } from './entity-registry.ts';
describe('EntityRegistry 生命周期', () => {
it('spawn 后 alive,destroy 后旧句柄立即失效', () => {
const reg = new EntityRegistry();
const e = reg.spawn();
expect(reg.alive(e)).toBe(true);
expect(reg.destroy(e)).toBe(true);
expect(reg.alive(e)).toBe(false);
expect(reg.count).toBe(0);
});
it('槽位复用:新实体沿用旧槽位号,但世代不同', () => {
const reg = new EntityRegistry();
const old = reg.spawn();
reg.destroy(old);
const fresh = reg.spawn();
expect(reg.slotOf(fresh)).toBe(reg.slotOf(old)); // 同一槽位
expect(reg.generationOf(fresh)).toBe(reg.generationOf(old) + 1); // 世代 +1
expect(reg.alive(old)).toBe(false);
expect(reg.alive(fresh)).toBe(true);
expect(() => reg.serialOf(old)).toThrow(/Stale handle/); // 旧句柄取不到身份
});
it('延迟销毁:flush 之前实体仍活着', () => {
const reg = new EntityRegistry();
const a = reg.spawn();
const b = reg.spawn();
reg.queueDestroy(a);
expect(reg.pendingCount).toBe(1);
expect(reg.alive(a)).toBe(true); // 尚未真正销毁
expect(reg.flush()).toBe(1);
expect(reg.alive(a)).toBe(false);
expect(reg.alive(b)).toBe(true);
expect(reg.pendingCount).toBe(0);
});
it('同一句柄重复排队只销毁一次', () => {
const reg = new EntityRegistry();
const e = reg.spawn();
reg.queueDestroy(e);
reg.queueDestroy(e);
expect(reg.flush()).toBe(1); // 第二次已失效,不计入
});
it('churn 之后槽位数组不无限增长(复用生效)', () => {
const reg = new EntityRegistry();
let peak = 0;
for (let i = 0; i < 1000; i++) {
const e = reg.spawn();
if (reg.count > peak) peak = reg.count;
reg.destroy(e);
}
expect(peak).toBe(1); // 永远只有一个活跃实体,槽位被复用
});
it('特征化:世代 12 位在复用第 4096 次回绕到 0,与 NULL_HANDLE 撞车', () => {
// 记录既有行为(非理想行为):同一槽位复用 4095 次后世代归 0,
// 生成的句柄等于 NULL_HANDLE(0),SlotMap.remove 会把它当空句柄而拒绝释放。
const reg = new EntityRegistry();
let nullAt = -1;
for (let i = 0; i < 5000; i++) {
const e = reg.spawn();
if (e === 0 && nullAt < 0) nullAt = i;
reg.destroy(e);
}
expect(nullAt).toBe(4095);
});
});
本机实测:npx vitest run src/ecs/entity-registry.test.ts 六个用例全部通过。第二个用例是核心——slotOf(fresh) === slotOf(old)(同一槽位)和 generationOf(fresh) === generationOf(old) + 1(世代 +1)同时成立,于是「槽位复用」和「旧句柄失效」这两件本来矛盾的事被一个世代计数器缝在了一起。
实测:复用把槽位上限压到 O(并发数)
复用到底值不值?跑一组对照:始终维持 1024 个实体存活,做 100 万次「销毁一个 + 新建一个」。一边是 EntityRegistry(槽位进空闲栈复用),一边是「单调递增、永不复用」的下标数组。
// file: bench/entity-lifecycle-bench.ts
// tier: B
// 运行:node --expose-gc bench/entity-lifecycle-bench.ts
// 对照「句柄复用」与「单调递增不复用」在 churn 场景下的代价。
// 场景:始终维持 K 个实体存活,做 N 次「销毁一个 + 新建一个」。
// A 复用:EntityRegistry(世代句柄,槽位进空闲栈复用)
// B 不复用:每次新建都往数组尾部 push 一个新下标,销毁只留墓碑
// 量:N 次 churn 耗时、用过的最大槽位下标(≈ 槽位数组长度)。
import { EntityRegistry } from '../src/ecs/entity-registry.ts';
import type { Handle } from '../src/core/slotmap.ts';
const K = 1024;
const N = 1_000_000;
let sink = 0;
function churnReuse(): { ms: number; maxIndex: number } {
const reg = new EntityRegistry();
const ring: Handle[] = [];
for (let i = 0; i < K; i++) ring.push(reg.spawn());
let maxIndex = 0;
for (const h of ring) maxIndex = Math.max(maxIndex, reg.slotOf(h));
const t0 = performance.now();
for (let i = 0; i < N; i++) {
const slot = i % K;
reg.destroy(ring[slot]!);
const h = reg.spawn(); // 复用刚释放的槽位
ring[slot] = h;
maxIndex = Math.max(maxIndex, reg.slotOf(h));
}
return { ms: performance.now() - t0, maxIndex };
}
function churnMonotonic(): { ms: number; maxIndex: number } {
const ring: number[] = [];
const slots: number[] = [];
for (let i = 0; i < K; i++) {
const idx = slots.length;
slots.push(1);
ring.push(idx);
}
const t0 = performance.now();
for (let i = 0; i < N; i++) {
const slot = i % K;
const idx = slots.length; // 单调递增,永不复用
slots.push(1);
sink += ring[slot]! & 0;
ring[slot] = idx;
}
return { ms: performance.now() - t0, maxIndex: slots.length - 1 };
}
function median(xs: number[]): number {
const s = [...xs].sort((a, b) => a - b);
const mid = s.length >> 1;
return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2;
}
const REPS = 9;
const reuseT: number[] = [];
const reuseMax: number[] = [];
const mono: { ms: number; maxIndex: number }[] = [];
for (let r = 0; r < REPS; r++) {
const a = churnReuse();
reuseT.push(a.ms);
reuseMax.push(a.maxIndex);
mono.push(churnMonotonic());
}
console.log(`K = ${K}, N = ${N}`);
console.log(`[reuse] ${median(reuseT).toFixed(2)} ms, 最大槽位下标 = ${Math.max(...reuseMax)}`);
console.log(`[monotonic] ${median(mono.map((m) => m.ms)).toFixed(2)} ms, 最大槽位下标 = ${mono[0]!.maxIndex}`);
console.log(`sink = ${sink}`);
本机(Node 26.5.0)实测,K = 1024,N = 1000000,9 次取中位数:
| 方案 | 耗时 | 用过的最大槽位下标 |
|---|---|---|
复用(EntityRegistry) | 约 16 ms | 1023 |
| 不复用(单调递增) | 约 5 ms | 1001023 |
这组数字把权衡摆得很清楚:
- 复用慢约 3×。多出来的时间全花在世代记账上:每次
spawn要压/弹空闲栈、每次remove要把世代自增。这不是浪费——正是这笔开销买来了「旧句柄立即失效」。 - 复用把槽位上限从 O(总生成数) 压到 O(峰值并发数)。不复用方案用掉了 100 万个下标,复用方案始终只在 1024 个下标里打转。放到《微光岛》里,就是「一天内生成又销毁几十万个可收集物」不会把实体数组撑爆。
方向性结论:句柄复用用一点点 CPU 换来了有界的存储,这对长跑的游戏是必须的。附录 C 有完整原始数据。
一个真实的边界:世代回绕撞上 NULL_HANDLE
上面第二个测试用例里藏着一条特征化断言:同一槽位被复用第 4096 次时,句柄会变回 0。原因是 6.2 把世代压进了 12 位(0xfff),从 1 数到 4095 之后回绕到 0——而 makeHandle(index, 0) 在 index 也为 0 时正好等于 NULL_HANDLE。
后果不是「读到错误对象」,而是这个槽位再也释放不掉:SlotMap.remove 遇到 NULL_HANDLE 会短路返回,槽位永远卡在活跃状态。本机复现:单槽位反复 spawn/destroy,第一次得到空句柄发生在第 4095 次(0 基)复用,与特征化用例一致。
这不是本节新引入的 bug,而是 12 位世代的固有边界——6.2 正文里也提过「工程上通过把世代位加宽或干脆用 64 位句柄来回避」。真实的引擎通常这样做:
- 把世代位加宽(比如索引 24 位 + 世代 8 位,或干脆用两个 32 位字段),把回绕周期拉长到实际跑不到的量级。
- 让世代跳过 0:自增时若结果是 0 就再加一,保证「世代 0」永远只属于
NULL_HANDLE。 - 接受它:若单个槽位的复用次数在游戏生命周期内远不到 4096 次(绝大多数项目都是如此),这个边界根本触发不了。
本节把它写出来,是因为这类「数值位宽的边界」正是内核代码最容易埋雷的地方:它平时不发作,一旦发作又极难排查(现象是「某个实体莫名删不掉」)。把它变成一个可断言、可观测的用例,比等它在线上偶发划算得多。
为什么生命周期要单独管
把实体的生死收进注册表,还有一个工程上的理由:销毁不能想删就删。游戏逻辑常常在遍历一批实体的过程中决定「这个该销毁了」,但如果你在遍历中真的把它删掉,正遍历的数组就被就地改了——轻则漏掉元素,重则越界。所以成熟的引擎都用延迟销毁:遍历时只把句柄压进队列(queueDestroy),等这一帧的更新全部跑完,再在帧末统一 flush。本节的 EntityRegistry 就把这个队列内置了,flush 对同一句柄重复排队也只销毁一次。
至此,ECS 的三件套——实体(句柄)、组件(存储)、查询——里,实体和组件都落地了。查询还停在 12.1 那个「遍历所有实体逐个检查组件」的朴素版本,它每帧都要全量扫一遍,是下一章要重点解决的地方。
阅读导航:上一节:12.2 组件存储:稀疏集与 archetype · 下一节:13.1 查询匹配与缓存命中 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。