《游戏引擎实现原理》12.3 实体生命周期与句柄复用

把实体的创建、销毁、句柄复用收进一个实体注册表,复用第 6 章的世代句柄:删除实体后旧句柄立即失效,新实体沿用同一槽位却拿到新世代。给出可编译实现、断言用例、复用与不复用的实测对比,以及世代回绕撞上 NULL_HANDLE 这个真实边界与应对办法。

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 ms1023
不复用(单调递增)约 5 ms1001023

这组数字把权衡摆得很清楚:

  • 复用慢约 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 查询匹配与缓存命中 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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