《游戏引擎实现原理》7.3 字符串 intern、资源 GUID 与名字表

把引擎里到处流动的名字压成整数:字符串驻留表让同名字符串只存一份、比较变成整数比较,资源 GUID 让文件路径确定性地映射成 64 位标识,名字表把组件类型、属性、系统登记成句柄。给出按字符串查 Map 与按句柄索引数组的 6.6 倍实测差距。

7.3 字符串 intern、资源 GUID 与名字表

引擎里到处是名字:组件类型叫 Transform、属性叫 position、资源叫 assets/mesh/island.gltf。这些名字在热路径上被反复比较和查找,而字符串比较是逐字符的、字符串本身还占内存。这一节做三件事:把名字驻留成整数、把路径映射成确定性 GUID、把一组名字登记进名字表,让「按名字取数据」从字符串查找变成数组索引。

字符串驻留:把名字压成一个整数

字符串驻留(interning)的核心承诺只有一句:同样的字符串在表里只存一份,返回同一个整数 id。之后比较两个名字就是比较两个 number,查找也从哈希字符串变成用 id 直接索引。StringTable 用一张 Map<string, StringId> 做去重,id 从 1 开始(0 保留为「无效」),并统计命中次数以便观察复用率:

// file: src/core/string-intern.ts
// tier: L1
// verify: npx vitest run src/core/string-intern.test.ts
//
// 字符串驻留表:同样的字符串只存一份,返回一个稳定的数字 id。
// 之后比较两个名字就变成比较两个整数(O(1)),而不是逐字符比字符串。
// 同时给出由路径确定性地推导资源 GUID 的函数。

/** 驻留字符串的数字标识。0 保留为「无效」。 */
export type StringId = number;
export const INVALID_STRING: StringId = 0;

/** FNV-1a 32 位哈希,offset basis 可换以得到独立的第二路哈希。 */
export function fnv1a32(s: string, basis = 0x811c9dc5): number {
  let h = basis;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

export class StringTable {
  #ids = new Map<string, StringId>();
  #strings: string[] = ['']; // 下标 0 留给「无效」
  #hits = 0;

  get size(): number {
    return this.#strings.length - 1;
  }

  /** 命中次数:同名字符串被重复驻留时累加(用于观察复用率)。 */
  get hits(): number {
    return this.#hits;
  }

  /** 驻留一个字符串:已存在返回旧 id,否则分配新 id。 */
  intern(s: string): StringId {
    const existing = this.#ids.get(s);
    if (existing !== undefined) {
      this.#hits++;
      return existing;
    }
    const id = this.#strings.length;
    this.#ids.set(s, id);
    this.#strings.push(s);
    return id;
  }

  /** id → 字符串。越界或 INVALID_STRING 返回 undefined。 */
  resolve(id: StringId): string | undefined {
    return id > 0 && id < this.#strings.length ? this.#strings[id] : undefined;
  }

  /** 字符串 → id,未驻留过返回 undefined(不产生副作用)。 */
  idOf(s: string): StringId | undefined {
    return this.#ids.get(s);
  }

  *entries(): Generator<[StringId, string]> {
    for (let i = 1; i < this.#strings.length; i++) yield [i, this.#strings[i]];
  }
}

/** 由资源路径确定性地推导 64 位 GUID(16 个十六进制字符)。 */
export function guidFromPath(path: string): string {
  const lo = fnv1a32(path, 0x811c9dc5);
  const hi = fnv1a32(path, 0x9e3779b1); // 独立的第二路 offset basis
  return lo.toString(16).padStart(8, '0') + hi.toString(16).padStart(8, '0');
}

有两个方法容易混淆:intern 会登记(没有就新建),idOf 只查询(没有就返回 undefined,不产生副作用)。读档或解析配置时用 intern,只想确认「这个名字登记过没有」时用 idOf。

资源 GUID:让路径变成确定性标识

资源引用不能直接存路径字符串:路径一改,所有引用它的文件都失效;跨平台大小写和分隔符也不一致。引擎的通行做法是给每个资源一个 GUID,引用只认 GUID。GUID 有两种来源——随机生成(全局唯一,但每次导入都变)和由路径确定性推导(同路径永远同 GUID,可复现)。

helio 用后者:guidFromPath 跑两路独立的 FNV-1a(换一个 offset basis 就得到不相关的第二路哈希),拼成 16 个十六进制字符,即 64 位标识。本机实测 assets/mesh/island.gltf 映射到 16ed47d340de1877,同一路径每次都得这个值。这样《微光岛》的地图文件里写的是 GUID,美术改了文件名也不会把引用打断——只要重新导入时路径推导规则一致。

名字表与句柄化查找

把上面两件事合起来,就是引擎的名字表:组件类型、属性、系统这些「按名字引用」的东西都登记进来,得到一个数字句柄。NameTable 在 StringTable 之上多加了一张「句柄 → 哈希」的平行表,这样序列化时既能存名字、也能存哈希,读档时能靠哈希把字符串快速接回同一个句柄:

// file: src/core/name-table.ts
// tier: L1
// verify: npx vitest run src/core/name-table.test.ts
//
// 名字表:引擎里所有「按名字引用」的东西(组件类型、属性、系统)都登记在这里,
// 得到一个数字句柄。比较名字变成比较句柄,序列化时既存字符串也存哈希,
// 读档时能靠哈希把字符串快速接回同一个句柄。

import { StringTable, fnv1a32, type StringId } from './string-intern.ts';

export type NameHandle = StringId;

export class NameTable {
  #table = new StringTable();
  #hashById: number[] = [0]; // 下标 0 对应 INVALID

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

  /** 登记一个名字,返回稳定句柄。 */
  intern(name: string): NameHandle {
    const id = this.#table.intern(name);
    if (this.#hashById[id] === undefined) this.#hashById[id] = fnv1a32(name);
    return id;
  }

  nameOf(id: NameHandle): string | undefined {
    return this.#table.resolve(id);
  }

  idOf(name: string): NameHandle | undefined {
    return this.#table.idOf(name);
  }

  hashOf(id: NameHandle): number {
    return this.#hashById[id] ?? 0;
  }

  /** 句柄比较:一次整数比较,与名字长度无关。 */
  equal(a: NameHandle, b: NameHandle): boolean {
    return a === b;
  }

  /** 只拿到哈希时反查句柄(读档路径)。碰撞则返回第一个匹配。 */
  idByHash(hash: number): NameHandle | undefined {
    for (let id = 1; id < this.#hashById.length; id++) {
      if (this.#hashById[id] === hash) return id;
    }
    return undefined;
  }

  *entries(): Generator<[NameHandle, string]> {
    yield* this.#table.entries();
  }
}

/** 把 helio 内核的一组内建名字登记进名字表。 */
export function registerKernelNames(names: NameTable): void {
  for (const n of [
    'Transform',
    'MeshRenderer',
    'RigidBody',
    'Script',
    'position',
    'rotation',
    'scale',
    'UpdateSystem',
    'RenderSystem',
  ]) {
    names.intern(n);
  }
}

idByHash 是读档路径的关键:存档里只写了 fnv1a32('RigidBody'),读回来时用它在名字表里反查句柄,就能接回正确的组件类型。注意它是线性扫描——因为哈希只用于「快速缩小范围」,真正权威的匹配仍然是名字本身;helio 把它放在读档这种非热路径上,热路径一律用已经拿到的句柄。

可运行产物与实测

驻留表的用例覆盖了最容易出错的几处语义——同名字符串返回同一 id、不同字符串得到不同 id、id 从 1 开始、idOf 不产生副作用。下面是 StringTable 与 guidFromPath 的完整测试,它同时说明了「名字相等即整数相等」这条承诺:

// file: src/core/string-intern.test.ts
// tier: L1
// verify: npx vitest run src/core/string-intern.test.ts

import { describe, expect, it } from 'vitest';
import { INVALID_STRING, StringTable, guidFromPath } from './string-intern.ts';

describe('StringTable', () => {
  it('同名字符串返回同一 id', () => {
    const t = new StringTable();
    const a = t.intern('health');
    const b = t.intern('health');
    expect(a).toBe(b);
    expect(t.size).toBe(1);
    expect(t.hits).toBe(1);
  });

  it('不同字符串得到不同 id 且可反查', () => {
    const t = new StringTable();
    const a = t.intern('position');
    const b = t.intern('velocity');
    expect(a).not.toBe(b);
    expect(t.resolve(a)).toBe('position');
    expect(t.resolve(b)).toBe('velocity');
  });

  it('id 从 1 开始,0 是无效', () => {
    const t = new StringTable();
    expect(t.intern('first')).toBe(1);
    expect(t.resolve(INVALID_STRING)).toBeUndefined();
    expect(t.resolve(999)).toBeUndefined();
  });

  it('idOf 不产生副作用', () => {
    const t = new StringTable();
    t.intern('mesh');
    expect(t.idOf('mesh')).toBe(1);
    expect(t.idOf('absent')).toBeUndefined();
    expect(t.size).toBe(1);
  });

  it('名字相等即整数相等(O(1) 比较)', () => {
    const t = new StringTable();
    const a = t.intern('Transform');
    const b = t.intern('Transform');
    expect(a === b).toBe(true); // 比较两个 number
  });
});

describe('guidFromPath', () => {
  it('同路径恒定、不同路径不同', () => {
    expect(guidFromPath('assets/mesh/island.gltf')).toBe(guidFromPath('assets/mesh/island.gltf'));
    expect(guidFromPath('assets/mesh/island.gltf')).not.toBe(guidFromPath('assets/mesh/rock.gltf'));
  });

  it('是 16 个十六进制字符', () => {
    expect(guidFromPath('a')).toMatch(/^[0-9a-f]{16}$/);
  });
});

npx vitest run src/core/string-intern.test.ts 与 npx vitest run src/core/name-table.test.ts 全部通过;名字表侧的用例还额外验证了 hashOf 与独立计算的 FNV-1a 一致、idByHash 能接回句柄。

那为什么不直接用 JS 的 Symbol() 或 WeakMap 当名字标识?Symbol 确实保证唯一,但它不能序列化、不能跨进程传递、也没法从整数反查名字;WeakMap 以字符串为键同样要每次哈希字符串。驻留表把「唯一性」和「可序列化」「可反查」三件事一起解决了:句柄是整数,存进存档、发过网络都行,读回来还能 resolve 出原始名字给编辑器显示。

真正值得看的是「按名字取数据」这件事的代价。本机实测 1 万个唯一名字、100 万次查询:

取数据方式100 万次耗时
每次拿字符串查 Map22.6 ms
驻留成句柄后按句柄索引数组3.4 ms

6.6 倍。差距来自两处:字符串键的哈希计算 + 桶查找,对比一个整数下标。而驻留本身的成本是一次性的——10 万个唯一名字驻留进表只用 39.2 ms,之后每次取数据都走那条 3.4 ms 的快路径。这就是为什么引擎宁可在启动时把所有名字驻留一遍,也不愿在每帧里反复拿字符串查表。

要留意的一点:哈希会碰撞,句柄不会。两个不同的名字可能算出同一个 32 位哈希,所以哈希只能当「加速索引」,不能当「唯一标识」;真正判等永远比较句柄(或名字本身)。helio 的 idByHash 因此保留线性扫描兜底,而不是假定哈希唯一。

到这里,第 7 章的三种基础标识都齐了:容器(7.1、7.2)、名字与句柄(7.3)。下一章进入游戏数学,从最基础的向量与它在 TypedArray 上的连续布局开始。

阅读导航:上一节:位集合、环形缓冲与侵入式链表 · 下一节:向量代数与 TypedArray 布局 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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