《游戏引擎实现原理》16.2 类型注册与属性描述符

从零实现 helio 内核的 TypeRegistry:用 defineType 按字段类型累加偏移,产出名字、类型、偏移、默认值四件套的属性描述符;注册顺序被保留,遍历稳定可复现。本节给出完整可编译实现与 vitest 用例,并真跑一次 registry.get('Vec3').properties,把真实输出贴进正文。

16.2 类型注册与属性描述符

16.1 把需求钉死了:运行时给不了声明类型、默认值和偏移,这三样必须由引擎自己提供。这一节就把这份「自己提供的类型描述」实现出来——一个 TypeDescriptor 描述符,加一个按名字查询它的 TypeRegistry。

先明确一件事:这一节是手工登记的版本,加一个类型就要手写一段描述符。它看起来笨,但它是理解机制的必经之路——你必须先知道每个描述符字段从哪来,16.3 的自动生成才有意义。

描述符里该放什么

一个属性要能被编辑器、序列化器、脚本三方面通用地读写,描述符至少要回答四个问题:

字段回答的问题用在哪
name这个字段叫什么?脚本按名访问、编辑器显示标签
type它是什么类型?编辑器选控件、序列化选编码
offset它在记录里的字节位置?紧凑布局的读写
defaultValue没赋值时是多少?反序列化补默认、编辑器「重置」

除此之外还有两个隐含但重要的性质。第一是顺序:描述符在数组里的次序就是序列化写字段的次序,必须稳定。第二是访问方式:字段不一定在紧凑记录里,也可能由一个 Map 或 getter 支撑,这时要有一条 accessor 逃生通道。

offset 从哪来?不是手填的,而是按字段类型累加出来的。defineType 拿到一串 { name, type, defaultValue },从左到右累加每种类型的字节数,就得到了每个字段的偏移和整个记录的总大小。类型尺寸表很简单:

类型字节说明
f32 / i32 / u32 / handle4单精度浮点、32 位整数、句柄
f648双精度浮点
bool1布尔
vec312三个 f32

注意这里做的是紧凑布局(packed),没有考虑对齐填充——对齐是 4.3 专门讲的话题,本节为了把偏移算得一眼可读,先用累加的方式。真实的记录布局会在此基础上按对齐规则调整,但描述符的结构不变。

可运行产物:TypeRegistry 完整实现

下面是完整可编译的 src/core/type-registry.ts。它包含四件东西:FieldType 与尺寸表、PropertyDescriptor 与 TypeDescriptor 两个描述符接口、把字段规格变成描述符的 defineType、以及注册表 TypeRegistry 本体。层级 L1,Node 直跑。

// file: src/core/type-registry.ts
// tier: L1
// verify: npx vitest run src/core/type-registry.test.ts
//
// helio 内核:显式类型注册表 + 属性描述符。
// 不用装饰器、不依赖 emitDecoratorMetadata——类型信息由代码显式登记,
// 注册顺序即遍历顺序,序列化输出因此稳定可复现。

/** 引擎认得的字段类型,尺寸以字节计。字符串不进紧凑记录,用 handle 指向 intern 表(见 7.3)。 */
export type FieldType = 'f32' | 'f64' | 'i32' | 'u32' | 'bool' | 'handle' | 'vec3';

const FIELD_SIZE: Record<FieldType, number> = {
  f32: 4,
  f64: 8,
  i32: 4,
  u32: 4,
  bool: 1,
  handle: 4,
  vec3: 12,
};

/** 单个属性的描述符:名字、类型、在记录里的字节偏移、默认值。 */
export interface PropertyDescriptor {
  readonly name: string;
  readonly type: FieldType;
  readonly offset: number;
  readonly defaultValue: unknown;
  /** 可选访问器:字段不在紧凑记录里时用它读写(如 Map 支撑的属性)。 */
  readonly accessor?: (obj: Record<string, unknown>) => unknown;
}

/** 定义字段时的输入:只需名字、类型、默认值,偏移由布局算出。 */
export interface FieldSpec {
  readonly name: string;
  readonly type: FieldType;
  readonly defaultValue: unknown;
  readonly accessor?: (obj: Record<string, unknown>) => unknown;
}

/** 一个已注册类型:名字、紧凑记录大小、属性描述符列表。 */
export interface TypeDescriptor {
  readonly name: string;
  readonly size: number;
  readonly properties: readonly PropertyDescriptor[];
}

/** 按声明顺序累加偏移,得到紧凑布局与属性描述符。 */
export function defineType(name: string, specs: readonly FieldSpec[]): TypeDescriptor {
  let offset = 0;
  const properties: PropertyDescriptor[] = [];
  for (const s of specs) {
    properties.push({
      name: s.name,
      type: s.type,
      offset,
      defaultValue: s.defaultValue,
      accessor: s.accessor,
    });
    offset += FIELD_SIZE[s.type];
  }
  return { name, size: offset, properties };
}

/** 显式类型注册表。注册顺序被保留,遍历稳定。 */
export class TypeRegistry {
  #types = new Map<string, TypeDescriptor>();
  #order: string[] = [];

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

  /** 登记一个类型;重名直接抛错——类型名是全局键,不能悄悄覆盖。 */
  register(desc: TypeDescriptor): void {
    if (this.#types.has(desc.name)) {
      throw new Error(`Duplicate type registration: ${desc.name}`);
    }
    this.#types.set(desc.name, desc);
    this.#order.push(desc.name);
  }

  registerAll(descs: readonly TypeDescriptor[]): void {
    for (const d of descs) this.register(d);
  }

  get(name: string): TypeDescriptor | undefined {
    return this.#types.get(name);
  }

  has(name: string): boolean {
    return this.#types.has(name);
  }

  /** 按注册顺序返回全部类型名——稳定,可复现。 */
  names(): readonly string[] {
    return this.#order;
  }

  /** 按注册顺序遍历类型,供序列化器等按固定次序消费。 */
  *[Symbol.iterator](): Generator<TypeDescriptor> {
    for (const n of this.#order) yield this.#types.get(n)!;
  }
}

/** 读一个属性的当前值:优先用访问器,否则按属性名直接取。 */
export function readProperty(obj: Record<string, unknown>, prop: PropertyDescriptor): unknown {
  return prop.accessor ? prop.accessor(obj) : obj[prop.name];
}

/** 全量读取一个对象在某个类型下的所有属性值,顺序即描述符顺序。 */
export function readAll(
  obj: Record<string, unknown>,
  desc: TypeDescriptor,
): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const p of desc.properties) out[p.name] = readProperty(obj, p);
  return out;
}

几处设计取舍值得点出:

  • #order 与 #types 分开存。 Map 保证查询 O(1),但它不承诺「按注册顺序」是你能依赖的契约;显式维护一个 #order 数组,遍历顺序就完全由你控制。序列化器依赖的就是这个顺序。
  • 重名抛错。 类型名是全局键。如果允许静默覆盖,一个不小心的重复注册会让某处的类型描述被另一处的顶替,而错误要等到读存档时才暴露。宁可注册时就崩。
  • defineType 而不是手写偏移。 偏移是人算容易错的量。让它从字段类型累加出来,你只声明「有哪些字段」,布局由代码保证一致。

真实输出:查一个 Vec3

光看实现不够,跑一次才算数。下面这个 demo 手工登记 Vec3、Transform、Health 三个类型,然后按名字查询、按描述符读值。

// file: src/apps/registry-demo.ts
// tier: L1
// verify: node src/apps/registry-demo.ts
//
// 手工登记三个类型,再按名字查询属性描述符——16.2 的可运行产物。

import { TypeRegistry, defineType, readAll } from '../core/type-registry.ts';

const reg = new TypeRegistry();
reg.registerAll([
  defineType('Vec3', [
    { name: 'x', type: 'f32', defaultValue: 0 },
    { name: 'y', type: 'f32', defaultValue: 0 },
    { name: 'z', type: 'f32', defaultValue: 0 },
  ]),
  defineType('Transform', [
    { name: 'position', type: 'vec3', defaultValue: [0, 0, 0] },
    { name: 'rotation', type: 'vec3', defaultValue: [0, 0, 0] },
    { name: 'scale', type: 'vec3', defaultValue: [1, 1, 1] },
  ]),
  defineType('Health', [
    { name: 'current', type: 'f32', defaultValue: 100 },
    { name: 'max', type: 'f32', defaultValue: 100 },
    { name: 'invulnerable', type: 'bool', defaultValue: false },
  ]),
]);

console.log('registered:', reg.size, '->', reg.names().join(', '));

// 按名字查询一个类型的属性描述符列表。
const vec3 = reg.get('Vec3')!;
console.log(`registry.get('Vec3').size = ${vec3.size}`);
for (const p of vec3.properties) {
  console.log(`  ${p.name}: ${p.type} @${p.offset} default=${JSON.stringify(p.defaultValue)}`);
}

// 注册顺序即遍历顺序——稳定,序列化靠它。
for (const desc of reg) console.log(`${desc.name}: size=${desc.size}`);

// 按描述符读出一个对象的全部字段值。
const hp = { current: 42, max: 100, invulnerable: true };
console.log('readAll(Health, hp) =', JSON.stringify(readAll(hp, reg.get('Health')!)));

本机 node src/apps/registry-demo.ts 的真实输出:

registered: 3 -> Vec3, Transform, Health
registry.get('Vec3').size = 12
  x: f32 @0 default=0
  y: f32 @4 default=0
  z: f32 @8 default=0
Vec3: size=12
Transform: size=36
Health: size=9
readAll(Health, hp) = {"current":42,"max":100,"invulnerable":true}

几个数字都对得上:Vec3 三个 f32 共 12 字节,偏移依次 0 / 4 / 8;Transform 三个 vec3 共 36 字节;Health 两个 f32 加一个 bool 共 9 字节。readAll 输出的键顺序是 current, max, invulnerable——和描述符顺序一致,这就是「稳定顺序」在代码里的样子。

测试:布局、顺序与访问器

上面这些结论都被单测盯住了。挑几个关键断言看:

// snippet: src/core/type-registry.test.ts 节选
const VEC3 = defineType('Vec3', [
  { name: 'x', type: 'f32', defaultValue: 0 },
  { name: 'y', type: 'f32', defaultValue: 0 },
  { name: 'z', type: 'f32', defaultValue: 0 },
]);

// 偏移按字段类型累加,不是手填的
expect(VEC3.properties.map((p) => p.offset)).toEqual([0, 4, 8]);
expect(VEC3.size).toBe(12);

// 重名注册直接抛错,且失败的那次不会污染注册表
const r = new TypeRegistry();
r.register(VEC3);
expect(() => r.register(VEC3)).toThrow(/Duplicate/);
expect(r.names()).toEqual(['Vec3']); // 仍是第一次注册的那份

本机 npx vitest run src/core/type-registry.test.ts 的结果是 7 个用例全过。这些用例覆盖了四类不变式:布局累加正确、重名抛错、注册顺序稳定、访问器优先于同名属性。最后一条容易被忽略——当字段由 getter 计算得出时,readProperty 必须走访问器而不是直接取属性名,否则读到的是另一个东西。

常见坑

实现和使用类型注册表时,几个反复出现的坑:

  • 把 offset 手写进描述符。 一旦手写,改一个字段类型就会让后面所有偏移错位,而且错得很隐蔽——编译能过、运行读到的却是隔壁字段。偏移永远由 defineType 算。
  • 用 Map 的迭代顺序当序列化顺序。 Map 确实保留插入顺序,但这是实现细节,且一旦中间有删除再插入,顺序就不再是你以为的样子。序列化顺序要显式维护成 #order。
  • 类型名大小写不一致。 'Vec3' 和 'vec3' 是两个不同的键。注册表不帮你做归一化——这是好事,但要求你在项目里统一命名规范。
  • 把默认值当成「字段的初始值」。 defaultValue 只用于「反序列化时字段缺失」和「编辑器重置」,它不会自动写进每个新对象。新对象的初始化是另一回事。
  • 忘了 accessor 这条逃生通道。 有些属性不落在紧凑记录里(比如由句柄间接指向的资源),只能靠访问器读写。描述符里留了 accessor 字段就是为它准备的。

第 1 条最危险,因为它不会立刻报错,而是让数据悄悄错位。这也是为什么本节的实现把偏移计算完全交给代码——人能少算一步,就少错一步。

手工登记的尽头

到这里,类型描述、注册表、按名查询、按描述符读值,一整套都跑通了。但你一定注意到了:每加一个组件类型,就要手写一段 defineType(...),字段名和类型还要和源码里的 interface 保持同步——两处重复,一处改另一处忘,就是 bug。

这正是 16.1 里第三条路线(构建期代码生成)要解决的问题。16.3 会写一个脚本,扫描源码里带标记的 interface,把你这一节手写的 defineType(...) 自动生成出来。手工这一遍到此为止,接下来交给机器。

阅读导航:上一节:16.1 引擎为什么需要反射 · 下一节:16.3 显式注册表 + 代码生成扫描 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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