《游戏引擎实现原理》6.2 slot map 与世代句柄

把句柄编码成「低 20 位槽位索引 + 高 12 位世代」的 32 位整数,槽位复用时世代自增,旧句柄的世代对不上就立刻失效。本节给出 helio 内核里可编译的 SlotMap 完整实现与 vitest 用例,并用「删除后再复用」的断言证明旧句柄再也取不到值。

6.2 slot map 与世代句柄

6.1 把问题钉死了:裸索引无法区分「同一个槽位的两任住户」。解法只有一句话——在句柄里再加一份「这是第几代」的信息。这一节实现 helio 内核的 SlotMap:一个用 32 位整数句柄索引的容器,句柄失效可以在访问的瞬间被检测出来,不需要任何额外的墓碑标记或延迟清理。

句柄编码:索引 20 位 + 世代 12 位

句柄就是一个 number,但它的位被切成两段。低位放槽位索引,高位放世代计数:

位区间含义容量
bit 0–19(低 20 位)槽位索引 index最多约 104 万个活跃槽位
bit 20–31(高 12 位)世代 generation每个槽位可复用 4096 代
  • makeHandle(index, gen) 把两段拼回一个数,再 >>> 0 转成无符号 32 位。
  • handleIndex(h) 用掩码取出低 20 位;handleGeneration(h) 右移 20 位再掩码。
  • 世代用 12 位循环计数(& 0xfff),即 4096 代后回绕。回绕意味着理论上存在「恰好过了 4096 代、旧句柄又对上」的极小概率,工程上通过把世代位加宽或干脆用 64 位句柄来回避;本节保持 12 位,先讲清机制。
  • NULL_HANDLE = 0 是保留的空句柄:索引与世代都是 0,永远取不到值。

一个细节:索引从 0 开始,而世代从 1 开始。这样「索引 0、世代 0」就只剩空句柄一种可能,get(0) 可以直接短路返回,不必查表。

举个具体的例子,makeHandle(42, 3) 得到的句柄是这样的:

部分二进制(低 24 位)十进制
世代 3(高 12 位)0000 0000 00113
索引 42(低 20 位)0000 0000 0000 0010 101042
拼合后的句柄0011 0000 0000 0010 10103145770

handleIndex(3145770) 掩掉高 12 位得到 42,handleGeneration(3145770) 右移 20 位得到 3,一来一回无损。槽位 42 被复用后世代变成 4,句柄数值随之变成另一个数,旧句柄的世代 3 就对不上了。本机实测:node -e 跑上面这段位运算,三个数分别是 3145770、42、3。

为什么是 20 / 12 而不是 16 / 16?因为「活跃槽位上限」比「单个槽位的复用次数」更早撞到墙。一帧内同时在世的实体通常几万到几十万,20 位(约 104 万)留了余量;而一个槽位被复用 4096 次,意味着同一位置先后住过 4096 个对象,在正常游戏节奏下已经相当极端。两段的划分本质上是一个「容量预算」的取舍,可以按游戏规模调整——需要更多实体就把索引位加宽,需要更长时间才回绕就加宽世代位。

可运行产物:SlotMap 完整实现

下面是完整可编译的 src/core/slotmap.ts。它同时是「世代句柄」和「稀疏集」的雏形:#slots 是 dense 之外的第一层存储,#generations 平行记录每个槽位的世代,#free 是一个空闲槽位栈。层级 L1,Node 直跑。

// file: src/core/slotmap.ts
// tier: L1
// verify: npx vitest run src/core/slotmap.test.ts
//
// helio 内核:世代句柄(generational handle)+ 稀疏集(sparse set)。
// 用 32 位整数索引代替指针:索引 20 位 + 世代 12 位,句柄失效可检测。

/** 句柄:高位是世代,低位是槽位索引。0 是「空句柄」。 */
export type Handle = number;

const INDEX_BITS = 20;
const INDEX_MASK = (1 << INDEX_BITS) - 1;
const GEN_MASK = 0xfff; // 12 位世代

export function makeHandle(index: number, generation: number): Handle {
  return (((generation & GEN_MASK) << INDEX_BITS) | (index & INDEX_MASK)) >>> 0;
}

export function handleIndex(h: Handle): number {
  return h & INDEX_MASK;
}

export function handleGeneration(h: Handle): number {
  return (h >>> INDEX_BITS) & GEN_MASK;
}

export const NULL_HANDLE: Handle = 0;

/** 带世代校验的稀疏集:句柄失效(dangling)在访问时立即暴露。 */
export class SlotMap<T> {
  #slots: (T | undefined)[] = [];
  #generations: number[] = [];
  #free: number[] = [];
  #live = 0;

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

  /** 分配一个新槽位,返回句柄。 */
  insert(value: T): Handle {
    let index: number;
    if (this.#free.length > 0) {
      index = this.#free.pop()!;
    } else {
      index = this.#slots.length;
      this.#slots.push(undefined);
      this.#generations.push(1);
    }
    this.#slots[index] = value;
    this.#live++;
    return makeHandle(index, this.#generations[index]);
  }

  /** 句柄有效则返回其值,否则 undefined(世代不符即失效)。 */
  get(h: Handle): T | undefined {
    if (h === NULL_HANDLE) return undefined;
    const i = handleIndex(h);
    if (i >= this.#slots.length) return undefined;
    if (this.#generations[i] !== handleGeneration(h)) return undefined;
    return this.#slots[i];
  }

  has(h: Handle): boolean {
    return this.get(h) !== undefined;
  }

  /** 校验句柄并返回其值;失效则抛错——用于「本应有效」的场景。 */
  expect(h: Handle): T {
    const v = this.get(h);
    if (v === undefined) {
      throw new Error(`Stale handle: 0x${h.toString(16)} (slot ${handleIndex(h)})`);
    }
    return v;
  }

  /** 移除并让世代自增,使所有旧句柄立即失效。 */
  remove(h: Handle): boolean {
    if (!this.has(h)) return false;
    const i = handleIndex(h);
    this.#slots[i] = undefined;
    this.#generations[i] = (this.#generations[i] + 1) & GEN_MASK;
    this.#free.push(i);
    this.#live--;
    return true;
  }

  /** 遍历所有活跃 (句柄, 值) 对,顺序按槽位索引。 */
  *entries(): Generator<[Handle, T]> {
    for (let i = 0; i < this.#slots.length; i++) {
      const v = this.#slots[i];
      if (v !== undefined) yield [makeHandle(i, this.#generations[i]), v];
    }
  }
}

remove 是全篇的关键:它先把值清空,紧接着把该槽位的世代加一,再压进 #free 备用。于是「同一个槽位被复用」和「旧句柄失效」同时成立——复用不会让旧句柄重新变有效。

测试:槽位复用但旧句柄取不到值

6.1 的裸索引复现用例,在 slot map 上必须给出相反答案。下面这个用例直接对标它:h1 被移除、h2 复用了同一槽位,但 get(h1) 必须是 undefined。

// file: src/core/slotmap.test.ts
// tier: L1
// verify: npx vitest run src/core/slotmap.test.ts

import { describe, expect, it } from 'vitest';
import {
  NULL_HANDLE,
  SlotMap,
  handleGeneration,
  handleIndex,
  makeHandle,
} from './slotmap.ts';

describe('Handle 编解码', () => {
  it('往返一致', () => {
    const h = makeHandle(12345, 7);
    expect(handleIndex(h)).toBe(12345);
    expect(handleGeneration(h)).toBe(7);
  });

  it('NULL_HANDLE 索引与世代都是 0', () => {
    expect(handleIndex(NULL_HANDLE)).toBe(0);
    expect(handleGeneration(NULL_HANDLE)).toBe(0);
  });
});

describe('SlotMap', () => {
  it('插入后可取回', () => {
    const m = new SlotMap<string>();
    const h = m.insert('alpha');
    expect(m.get(h)).toBe('alpha');
    expect(m.size).toBe(1);
  });

  it('移除后旧句柄立即失效(世代自增)', () => {
    const m = new SlotMap<string>();
    const h = m.insert('alpha');
    m.remove(h);
    expect(m.get(h)).toBeUndefined();
    expect(m.has(h)).toBe(false);
  });

  it('槽位复用但新句柄与旧句柄不等(防悬垂引用)', () => {
    const m = new SlotMap<string>();
    const h1 = m.insert('alpha');
    m.remove(h1);
    const h2 = m.insert('beta');
    expect(handleIndex(h2)).toBe(handleIndex(h1)); // 复用同一槽位
    expect(h2).not.toBe(h1); // 但世代不同
    expect(m.get(h1)).toBeUndefined(); // 旧句柄不会读到新值
    expect(m.get(h2)).toBe('beta');
  });

  it('expect 对失效句柄抛错', () => {
    const m = new SlotMap<number>();
    const h = m.insert(42);
    m.remove(h);
    expect(() => m.expect(h)).toThrow(/Stale handle/);
  });

  it('NULL_HANDLE 永远取不到值', () => {
    const m = new SlotMap<number>();
    m.insert(1);
    expect(m.get(NULL_HANDLE)).toBeUndefined();
  });

  it('entries 只遍历活跃槽位', () => {
    const m = new SlotMap<number>();
    const a = m.insert(1);
    m.insert(2);
    const c = m.insert(3);
    m.remove(c);
    const seen = [...m.entries()].map(([, v]) => v);
    expect(seen).toEqual([1, 2]);
    expect(m.has(a)).toBe(true);
  });
});

本机实测:npx vitest run src/core/slotmap.test.ts 全部通过。第三个用例正是 6.1 里「旧索引读到蝙蝠」的对照——同一个场景,slot map 给出的结果是 undefined。

常见坑

实现和使用 slot map 时,有几个反复出现的坑:

  • 把世代写进对象里。世代属于「槽位」,不属于对象。对象被移除后世代才自增,所以世代必须和槽位平行存储(#generations),不能塞进 T 里。
  • 复用时不加世代。如果 insert 复用空闲槽位却忘了让世代保持递增,旧句柄会重新变有效——这正是 6.1 的 bug 换了个壳。
  • 拿 get 的结果当长期引用。get 返回的是对象引用,一旦移除,引用就变成幽灵对象(6.1 已复现)。要长期持有就存句柄,每帧重新 get。
  • 句柄默认值乱写。NULL_HANDLE = 0 是保留值,任何「没有对象」的默认值都应该用它,而不是 -1 和 undefined 混着来。
  • 拿句柄当数组下标。槽位号要用 handleIndex(h) 解出来,句柄本身带世代位,直接拿 h 当下标会越界。

第 2 条最容易被忽略,因为它在「槽位刚好被复用」时才暴露,单测很容易漏掉。6.2 的用例专门覆盖了它。

三种访问语义:get / has / expect

同一个句柄有三种访问方式,对应三种「我有多确定它还有效」的语气:

  • get(h):不确定,可能失效。失效返回 undefined,适合每帧扫描时跳过已销毁对象。
  • has(h):只想问存在性,不取值。
  • expect(h):本应有效。失效直接抛错,用来把「逻辑上不该发生」的悬垂访问变成一次显式的崩溃——早崩早发现,而不是让它静默地读到别的对象。

expect 的报错信息里带上了句柄的十六进制值和槽位号,方便在日志里反查是哪一次分配/释放出的问题。把「失效」从一个不可观测的状态,变成一个可观测、可断言的信号,这才是世代句柄相对裸指针的全部价值。

还有一点常被忽略:句柄是纯整数,所以可以直接序列化、存进组件、写进存档。6.1 里的对象引用做不到这一点——引用一旦写进文件,下次读回来就指向不存在的东西。用句柄存「引用」是引擎能存档、能网络同步的前提。这一特性会在卷一第 17 章的序列化里继续展开。这里只需记住:句柄是「可以存起来、以后再校验」的引用,而对象引用是「存起来就坏掉」的引用。

有了 SlotMap,「删除 → 复用 → 旧句柄访问」这条最危险的路径被彻底堵死。但 entries() 的遍历是「按槽位索引扫、跳过空洞」——稀疏的槽位会让缓存不友好。6.3 用稀疏集把存储层换成紧凑的 dense 数组,让遍历重新变成一段连续内存。

阅读导航:上一节:6.1 为什么指针会失效 · 下一节:6.3 稀疏集、句柄回收与 ECS 的接缝 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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