《游戏引擎实现原理》7.2 位集合、环形缓冲与侵入式链表

实现 helio 的三个零分配容器:用 Uint32Array 把 32 个布尔压进一个字的位集合、固定容量首尾相接的环形缓冲,以及把 next/prev 长在对象自己身上的侵入式链表。给出位集合与布尔数组的堆增量、遍历耗时实测,以及环形缓冲 1000 万次 push 的吞吐。

7.2 位集合、环形缓冲与侵入式链表

7.1 的两个容器都靠「扩容」活着,它们总会在某个时刻分配内存。这一节反过来,做三个构造时就分配完毕、之后一个字节都不再申请的容器:位集合(用一位表示一个布尔状态)、环形缓冲(定容队列)、侵入式链表(节点长在对象自己身上)。它们的共同点是——热路径上没有 new,也就没有 GC。

位集合:32 个布尔压进一个字

引擎里到处是「一堆开关」:哪些实体可见、哪些子系统启用了、哪些资源加载完成。用 boolean[] 存,每个标记在 JS 堆上至少占一个数组槽;用位集合,一个 Uint32Array 元素的 32 个二进制位各管一个标记。BitSet 就是这层封装,另外提供两个位运算才能优雅做到的操作:一次数完 32 位的 popcount,和跳过成片 0 的置位遍历:

// file: src/core/bitset.ts
// tier: L1
// verify: npx vitest run src/core/bitset.test.ts
//
// 位集合:用 Uint32Array 的每一位表示一个布尔状态。1M 个标记只占 128 KB,
// 且「计数」和「找下一个置位」都用位运算一次处理 32 个,比布尔数组快得多。

export class BitSet {
  #words: Uint32Array;
  #size: number;

  constructor(size: number) {
    if (size < 0) throw new RangeError('size must be >= 0');
    this.#size = size;
    this.#words = new Uint32Array((size + 31) >>> 5);
  }

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

  /** 容量以 32 位字为单位向上取整。 */
  get wordCount(): number {
    return this.#words.length;
  }

  set(i: number): void {
    if (i < 0 || i >= this.#size) throw new RangeError(`bit ${i} out of range`);
    this.#words[i >>> 5] |= 1 << (i & 31);
  }

  clear(i: number): void {
    if (i < 0 || i >= this.#size) throw new RangeError(`bit ${i} out of range`);
    this.#words[i >>> 5] &= ~(1 << (i & 31));
  }

  toggle(i: number): void {
    this.#words[i >>> 5] ^= 1 << (i & 31);
  }

  test(i: number): boolean {
    if (i < 0 || i >= this.#size) return false;
    return (this.#words[i >>> 5] & (1 << (i & 31))) !== 0;
  }

  /** 置位总数:每个字用 popcount 位技巧一次数完 32 位。 */
  popcount(): number {
    let total = 0;
    for (let w = 0; w < this.#words.length; w++) {
      let x = this.#words[w];
      x = x - ((x >>> 1) & 0x55555555);
      x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
      x = (x + (x >>> 4)) & 0x0f0f0f0f;
      total += Math.imul(x, 0x01010101) >>> 24;
    }
    return total;
  }

  /** 只遍历置位的下标,跳过成片的 0。 */
  *setBits(): Generator<number> {
    for (let w = 0; w < this.#words.length; w++) {
      let x = this.#words[w];
      while (x !== 0) {
        const lsb = x & -x; // 最低置位
        const bit = 31 - Math.clz32(lsb);
        yield (w << 5) + bit;
        x ^= lsb; // 抹掉这一位
      }
    }
  }
}

popcount 那四行是经典的 SWAR(在字内并行计数)技巧:先把每 2 位一组相加,再 4 位、8 位,最后用一个乘法把四个字节的计数汇总到最高字节。它不循环 32 次,所以数 1M 个标记的置位数只是一次线性扫描。

本机实测 1M 个标记(每 3 个置 1 位):BitSet 的堆增量是 0.0 MB(Uint32Array 的 buffer 不走 JS 堆),而 boolean[] 是 7.6 MB——差了三个数量级。但遍历全部置位时,BitSet 用了 7.31 ms,boolean[] 只用 2.11 ms:位技巧的逐位拆解有开销。所以位集合的赢面在内存和稀疏扫描(只走置位、跳过成片 0),不在「密集全扫」这一项上。

环形缓冲:定容、零分配的队列

「最近 N 帧耗时」「待处理的事件」「网络收包缓冲」这类需求,天然适合环形缓冲:容量固定,写满就绕回头部。它的关键不是功能,而是构造时把底层数组一次分配好,之后 push / shift 都不再分配:

// file: src/core/ring-buffer.ts
// tier: L1
// verify: npx vitest run src/core/ring-buffer.test.ts
//
// 环形缓冲:固定容量、首尾相接的队列。构造时一次性分配底层数组,
// 之后 push / pop / shift 都不再分配——这是它在帧内热路径上的全部价值。

export class RingBuffer<T> {
  #buf: (T | undefined)[];
  #cap: number;
  #head = 0; // 指向最早元素
  #count = 0;

  constructor(capacity: number) {
    if (capacity <= 0) throw new RangeError('capacity must be > 0');
    this.#cap = capacity;
    this.#buf = new Array(capacity);
  }

  get capacity(): number {
    return this.#cap;
  }

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

  get isFull(): boolean {
    return this.#count === this.#cap;
  }

  get isEmpty(): boolean {
    return this.#count === 0;
  }

  /** 入队;满则返回 false(不覆盖、不分配)。 */
  push(value: T): boolean {
    if (this.#count === this.#cap) return false;
    this.#buf[(this.#head + this.#count) % this.#cap] = value;
    this.#count++;
    return true;
  }

  /** 入队;满则覆盖最旧元素并返回 true(用于「最近 N 帧」这类场景)。 */
  pushOverwrite(value: T): void {
    if (this.#count < this.#cap) {
      this.#buf[(this.#head + this.#count) % this.#cap] = value;
      this.#count++;
    } else {
      this.#buf[this.#head] = value;
      this.#head = (this.#head + 1) % this.#cap;
    }
  }

  /** 出队(先进先出)。 */
  shift(): T | undefined {
    if (this.#count === 0) return undefined;
    const v = this.#buf[this.#head];
    this.#buf[this.#head] = undefined;
    this.#head = (this.#head + 1) % this.#cap;
    this.#count--;
    return v;
  }

  /** 看队头但不移除。 */
  peek(): T | undefined {
    return this.#count === 0 ? undefined : this.#buf[this.#head];
  }

  /** 按入队顺序遍历,不回绕到预留槽。 */
  *[Symbol.iterator](): Generator<T> {
    for (let i = 0; i < this.#count; i++) {
      yield this.#buf[(this.#head + i) % this.#cap] as T;
    }
  }

  toArray(): T[] {
    return [...this];
  }
}

push 和 pushOverwrite 是两种语义:前者满了就拒绝(有界队列,保护内存),后者满了就覆盖最旧元素(滑动窗口,保留最近 N 个)。(head + count) % cap 是回绕的写法,注意取模的对象是物理下标、不是逻辑序号——这是环形缓冲最容易写错的地方。本机实测:容量 1024、连推 1000 万次(满则先 shift 再 push),总耗时 63.7 ms,全程零分配。

侵入式链表:节点长在对象身上

普通链表的每个节点是一个独立对象,next 指针把一堆对象串起来——插入时要 new 一个节点,删除时要等 GC。侵入式链表反过来:next 和 prev 直接长在对象自己身上,链表不拥有内存。在 TS 里,我们让对象用一段按索引编号的池表示,next / prev 就是两条平行的 Int32Array,于是「挂进链表」「摘出链表」都只是改两个整数,O(1) 且零分配:

// file: src/core/intrusive-list.ts
// tier: L1
// verify: npx vitest run src/core/intrusive-list.test.ts
//
// 侵入式链表:链表节点(next / prev)不额外分配,直接长在对象自己身上。
// 这里的对象是一段按索引编号的池,next / prev 是两条平行的 Int32Array,
// 所以「挂进链表」不产生任何新分配,O(1) 插入与删除。

const NIL = -1;

export class IntrusiveList {
  #next: Int32Array;
  #prev: Int32Array;
  #capacity: number;
  #head = NIL;
  #tail = NIL;
  #count = 0;

  constructor(capacity: number) {
    if (capacity <= 0) throw new RangeError('capacity must be > 0');
    this.#capacity = capacity;
    this.#next = new Int32Array(capacity).fill(NIL);
    this.#prev = new Int32Array(capacity).fill(NIL);
  }

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

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

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

  /** 越界 id 必须显式拒绝:Int32Array 越界写入是静默丢弃的。 */
  #check(id: number): void {
    if (id < 0 || id >= this.#capacity) {
      throw new RangeError(`node id ${id} out of capacity ${this.#capacity}`);
    }
  }

  /** 把节点 id 挂到链表尾部,O(1),零分配。 */
  pushBack(id: number): void {
    this.#check(id);
    this.#prev[id] = this.#tail;
    this.#next[id] = NIL;
    if (this.#tail === NIL) this.#head = id;
    else this.#next[this.#tail] = id;
    this.#tail = id;
    this.#count++;
  }

  /** 把节点 id 挂到链表头部,O(1)。 */
  pushFront(id: number): void {
    this.#check(id);
    this.#next[id] = this.#head;
    this.#prev[id] = NIL;
    if (this.#head === NIL) this.#tail = id;
    else this.#prev[this.#head] = id;
    this.#head = id;
    this.#count++;
  }

  /** 从链表中摘除节点 id,O(1),不需要遍历。 */
  remove(id: number): void {
    this.#check(id);
    const p = this.#prev[id];
    const n = this.#next[id];
    if (p === NIL) this.#head = n;
    else this.#next[p] = n;
    if (n === NIL) this.#tail = p;
    else this.#prev[n] = p;
    this.#prev[id] = NIL;
    this.#next[id] = NIL;
    this.#count--;
  }

  /** 弹出头部节点并返回其 id。 */
  popFront(): number {
    if (this.#head === NIL) return NIL;
    const id = this.#head;
    this.remove(id);
    return id;
  }

  /** 从任意节点出发向后遍历(含它自己)。id 越界或为 NIL 时不产出。 */
  *from(id: number): Generator<number> {
    if (id < 0 || id >= this.#capacity) return;
    let cur = id;
    while (cur !== NIL) {
      yield cur;
      cur = this.#next[cur];
    }
  }

  /** 顺序遍历整条链表。 */
  *[Symbol.iterator](): Generator<number> {
    yield* this.from(this.#head);
  }

  toArray(): number[] {
    return [...this];
  }
}

export { NIL };

remove(id) 是它的核心价值:只要你手里有节点的 id,摘链就是 O(1),不需要从头找前驱——因为前驱就存在 prev[id] 里。这让「从事件队列里取消一个事件」「从更新列表里临时摘掉一个实体」都变成常数时间。

实测小结与两个坑

三个容器都有 vitest 用例:位集合的 popcount / setBits 顺序、环形缓冲的回绕与覆盖语义、侵入式链表的中间摘链与续遍历,npx vitest run 全部通过。实测数据汇总如下:

容器测点本机结果
BitSet vs boolean[]1M 标记堆增量0.0 MB vs 7.6 MB
BitSet vs boolean[]遍历全部置位7.31 ms vs 2.11 ms
RingBuffer1000 万次 push63.7 ms,零分配

两个必须记住的坑,都来自「用 TypedArray 存结构」这件事:

  • TypedArray 越界写入是静默丢弃的。new Int32Array(8) 写 arr[8] 不报错也不生效,读 arr[8] 得到 undefined。写侵入式链表时,如果 id 超出容量却没人拦,next[id] 会变成 undefined,遍历循环 while (cur !== NIL) 就永远停不下来——直接吃满内存。helio 的 #check 和 from 里的边界判断,就是为了把这种「静默损坏」变成一次显式的 RangeError。
  • % 的取模对象是物理下标。环形缓冲里 (head + count) % cap 的 head + count 可能大于等于 cap,取模后才是真实下标;把逻辑序号直接当物理下标用,回绕一次就会读错位置。

这三个容器是后面 ECS(第 12、13 章)和 Job System(第 14 章)的零件:位集合存组件的存在标记,环形缓冲当事件队列与帧历史,侵入式链表管可随时摘除的更新列表。7.3 处理最后一种「基础标识」——字符串与资源名。

阅读导航:上一节:动态数组与开放寻址哈希 · 下一节:字符串 intern、资源 GUID 与名字表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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