《游戏引擎实现原理》17.1 反射驱动的序列化:JSON 与二进制

第 16 章的类型描述符在这一节接上序列化器:同一张字段表既能写出可读 JSON,也能写出紧凑二进制。给出 helio 内核里可编译的 TypeRegistry、json-serde 与 binary-serde 三个完整文件与 vitest 用例,并实测同一个 Player 对象 JSON 93 字节、二进制 37 字节。

17.1 反射驱动的序列化:JSON 与二进制

第 16 章做了一件吃力但值钱的事:把「一个类型有哪些字段、每个字段是什么种类」从代码里抽出来,变成一张可被程序读取的描述符表。描述符本身不是目的——它的第一个大客户就是序列化。这一节把那张表接上编码器:同一份字段表,既能驱动可读的 JSON,也能驱动紧凑的二进制。

本章与站内既有内容的分工

站内已经有两篇讲存档的文章,它们和本节的分工必须说清楚,否则你会觉得内容重复。

既有文章它负责什么本节只做什么
游戏存档与序列化:存档数据结构、版本迁移、校验与云存档存档系统的玩法侧设计:存什么、JSON/Binary/Protobuf 怎么选、校验与云同步由类型描述符驱动的编码器实现,以及两种格式的真实字节数
Defold 游戏存档与序列化:数据建模、加密校验与版本迁移引擎子系统视角:sys.save / sys.load 与表结构约定从零手写 JSON 与二进制两套编码,不依赖任何引擎 API

一句话:既有文章回答「存档系统长什么样、该选哪种格式」,本节回答「这个序列化器本身怎么由类型信息驱动着跑起来」。格式选型的取舍请去看上面两篇;这里只写实现和实测。

描述符驱动的编码:同一张字段表,两种格式

编码器只需要字段的种类,不需要字段的具体值。于是 FieldDescriptor 的 kind 就成了一个调度键:JSON 走一条分支,二进制走另一条。下表是本节支持的六种值种类各自的落盘形式。

kindJSON 表示二进制表示定长字节
f32numberFloat32(小端)4
i32numberInt32(小端)4
boolbooleanUint8(0 / 1)1
stringstringUint16 长度 + UTF-8 字节2 + n
handlenumberUint32(小端)4
ref / array交给对象图序列化器(17.2)同左变长

handle 之所以能直接编码成 4 字节整数,是因为第 6 章就把句柄设计成了纯整数(6.2 slot map 与世代句柄 )。这也正是那句「句柄可以存进存档」的兑现——引用不能存,句柄能存。

可运行产物:注册表与两个序列化器

三个文件构成这条链路。第一个是序列化用的 schema 注册表:第 16 章的 16.2 类型注册与属性描述符 管的是编辑器视角——字段在紧凑记录里的字节偏移、读写访问器;而序列化需要的是另一套信息——字段的线上种类(string / ref / array 这些编辑器未必关心的)以及一个 schema 版本号。两者描述同一批类型,但用途不同,所以这里单独定义 registry.ts,它是序列化这条链的入口。

// file: src/core/registry.ts
// tier: L1
// verify: npx vitest run src/core/serializer.test.ts
//
// helio 内核:类型注册表。第 16 章的反射底座,序列化器的最小驱动。

/** 字段的运行时种类,序列化器据此选择编码方式。 */
export type FieldKind = 'f32' | 'i32' | 'bool' | 'string' | 'handle' | 'ref' | 'array';

/** 单个字段:名字 + 种类(ref / array 另带目标类型名)。 */
export type FieldDescriptor = {
  readonly name: string;
  readonly kind: FieldKind;
  readonly type?: string;
};

/** 一个类型:名字 + schema 版本 + 字段表。 */
export type TypeDescriptor = {
  readonly name: string;
  readonly version: number;
  readonly fields: readonly FieldDescriptor[];
};

/** 名字到描述符的映射。显式注册,无装饰器、无元数据。 */
export class TypeRegistry {
  #types = new Map<string, TypeDescriptor>();

  register(desc: TypeDescriptor): void {
    if (this.#types.has(desc.name)) throw new Error(`Type already registered: ${desc.name}`);
    this.#types.set(desc.name, desc);
  }

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

  /** 取描述符;不存在直接抛错——序列化遇到未注册类型属于硬错误。 */
  require(name: string): TypeDescriptor {
    const d = this.#types.get(name);
    if (!d) throw new Error(`Type not registered: ${name}`);
    return d;
  }

  names(): string[] {
    return [...this.#types.keys()];
  }
}

JSON 编码器最短:遍历描述符的字段,把值原样塞进以字段名做 key 的对象,再加两个元字段 $type 与 $v($v 是 schema 版本,17.3 的迁移全靠它)。

// file: src/core/json-serde.ts
// tier: L1
// verify: npx vitest run src/core/serializer.test.ts
//
// helio 内核:JSON 序列化。字段名直接当 key,可读优先。

import type { FieldDescriptor, TypeRegistry } from './registry.ts';

export const JSON_TYPE_KEY = '$type';
export const JSON_VERSION_KEY = '$v';

/** 未写入的字段按种类给零值,保证反序列化后字段齐全。 */
export function defaultValue(f: FieldDescriptor): unknown {
  switch (f.kind) {
    case 'bool':
      return false;
    case 'string':
      return '';
    case 'f32':
    case 'i32':
    case 'handle':
      return 0;
    default:
      return undefined;
  }
}

export function serializeToJson(
  reg: TypeRegistry,
  typeName: string,
  obj: Record<string, unknown>,
): Record<string, unknown> {
  const desc = reg.require(typeName);
  const out: Record<string, unknown> = {
    [JSON_TYPE_KEY]: desc.name,
    [JSON_VERSION_KEY]: desc.version,
  };
  for (const f of desc.fields) {
    const v = obj[f.name];
    if (v === undefined) continue;
    if (f.kind === 'ref' || f.kind === 'array') {
      throw new Error(`Field ${f.name}: ${f.kind} needs the graph serializer`);
    }
    out[f.name] = v;
  }
  return out;
}

export function deserializeFromJson(
  reg: TypeRegistry,
  json: Record<string, unknown>,
): Record<string, unknown> {
  const name = json[JSON_TYPE_KEY];
  if (typeof name !== 'string') throw new Error('Missing $type in JSON payload');
  const desc = reg.require(name);
  const obj: Record<string, unknown> = {};
  for (const f of desc.fields) obj[f.name] = json[f.name] ?? defaultValue(f);
  return obj;
}

二进制编码器手工操作 DataView:BinaryWriter 往一段会按需翻倍的 ArrayBuffer 里按字段种类写定长或变长字段,BinaryReader 按完全相同的顺序读回来。顺序即协议,所以读写两个 switch 必须一一对应。

// file: src/core/binary-serde.ts
// tier: L1
// verify: npx vitest run src/core/serializer.test.ts
//
// helio 内核:二进制序列化。手工写字段:f32/i32/handle 各 4 字节,bool 1 字节,string 变长。

import type { FieldDescriptor, TypeRegistry } from './registry.ts';
import { defaultValue } from './json-serde.ts';

export const BINARY_FORMAT_VERSION = 1;

class BinaryWriter {
  #buf = new ArrayBuffer(64);
  #view = new DataView(this.#buf);
  #bytes = new Uint8Array(this.#buf);
  #pos = 0;

  #ensure(n: number): void {
    if (this.#pos + n <= this.#buf.byteLength) return;
    let cap = this.#buf.byteLength;
    while (cap < this.#pos + n) cap *= 2;
    const next = new ArrayBuffer(cap);
    new Uint8Array(next).set(this.#bytes);
    this.#buf = next;
    this.#view = new DataView(next);
    this.#bytes = new Uint8Array(next);
  }

  u8(v: number): void { this.#ensure(1); this.#view.setUint8(this.#pos, v); this.#pos += 1; }
  u16(v: number): void { this.#ensure(2); this.#view.setUint16(this.#pos, v, true); this.#pos += 2; }
  u32(v: number): void { this.#ensure(4); this.#view.setUint32(this.#pos, v >>> 0, true); this.#pos += 4; }
  i32(v: number): void { this.#ensure(4); this.#view.setInt32(this.#pos, v | 0, true); this.#pos += 4; }
  f32(v: number): void { this.#ensure(4); this.#view.setFloat32(this.#pos, v, true); this.#pos += 4; }

  str(s: string): void {
    const b = new TextEncoder().encode(s);
    this.u16(b.length);
    this.#ensure(b.length);
    this.#bytes.set(b, this.#pos);
    this.#pos += b.length;
  }

  finish(): Uint8Array { return this.#bytes.slice(0, this.#pos); }
}

class BinaryReader {
  #view: DataView;
  #bytes: Uint8Array;
  #pos = 0;

  constructor(bytes: Uint8Array) {
    this.#bytes = bytes;
    this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  }

  u8(): number { const v = this.#view.getUint8(this.#pos); this.#pos += 1; return v; }
  u16(): number { const v = this.#view.getUint16(this.#pos, true); this.#pos += 2; return v; }
  u32(): number { const v = this.#view.getUint32(this.#pos, true); this.#pos += 4; return v; }
  i32(): number { const v = this.#view.getInt32(this.#pos, true); this.#pos += 4; return v; }
  f32(): number { const v = this.#view.getFloat32(this.#pos, true); this.#pos += 4; return v; }

  str(): string {
    const n = this.u16();
    const s = new TextDecoder().decode(this.#bytes.subarray(this.#pos, this.#pos + n));
    this.#pos += n;
    return s;
  }
}

function writeField(w: BinaryWriter, f: FieldDescriptor, v: unknown): void {
  switch (f.kind) {
    case 'f32': w.f32(v as number); break;
    case 'i32': w.i32(v as number); break;
    case 'handle': w.u32(v as number); break;
    case 'bool': w.u8(v ? 1 : 0); break;
    case 'string': w.str(v as string); break;
    default: throw new Error(`Field ${f.name}: ${f.kind} needs the graph serializer`);
  }
}

function readField(r: BinaryReader, f: FieldDescriptor): unknown {
  switch (f.kind) {
    case 'f32': return r.f32();
    case 'i32': return r.i32();
    case 'handle': return r.u32();
    case 'bool': return r.u8() !== 0;
    case 'string': return r.str();
    default: throw new Error(`Field ${f.name}: ${f.kind} needs the graph serializer`);
  }
}

export function serializeToBinary(
  reg: TypeRegistry,
  typeName: string,
  obj: Record<string, unknown>,
): Uint8Array {
  const desc = reg.require(typeName);
  const w = new BinaryWriter();
  w.u8(BINARY_FORMAT_VERSION);
  w.str(desc.name);
  w.u16(desc.version);
  for (const f of desc.fields) writeField(w, f, obj[f.name] ?? defaultValue(f));
  return w.finish();
}

export type BinaryDecodeResult = { schemaVersion: number; data: Record<string, unknown> };

export function deserializeFromBinary(
  reg: TypeRegistry,
  bytes: Uint8Array,
): BinaryDecodeResult {
  const r = new BinaryReader(bytes);
  const fmt = r.u8();
  if (fmt !== BINARY_FORMAT_VERSION) throw new Error(`Unsupported binary format version: ${fmt}`);
  const name = r.str();
  const schemaVersion = r.u16();
  const desc = reg.require(name);
  const data: Record<string, unknown> = {};
  for (const f of desc.fields) data[f.name] = readField(r, f);
  return { schemaVersion, data };
}

实测:同一对象,JSON 93 字节、二进制 37 字节

注册一个 Player 类型,造一个具体对象,两种格式各写一遍,把字节数打出来。本机实跑 node src/core/serialize-size-demo.ts:

JSON : {"$type":"Player","$v":1,"x":12.5,"y":0,"z":-3.25,"health":100,"name":"Glimmer","alive":true}
JSON bytes : 93
BIN  bytes : 37
BIN  hex   : 01 06 00 50 6c 61 79 65 72 01 00 00 00 48 41 00 00 00 00 00 00 50 c0 64 00 00 00 07 00 47 6c 69 6d 6d 65 72 01
ratio      : 2.51x
round-trip : {"x":12.5,"y":0,"z":-3.25,"health":100,"name":"Glimmer","alive":true}

二进制那 37 个字节的构成可以逐段对上,每个字段都是定长的,只有字符串变长:

区段内容字节数
格式版本011
类型名06 00 + Player(6 字节)8
schema 版本01 002
x / y / z三个 Float32,12.5 / 0 / -3.2512
healthInt32 = 1004
name07 00 + Glimmer(7 字节)9
aliveUint8 = 11
合计37

同一个对象,JSON 93 字节、二进制 37 字节,比例 2.51 倍。这个倍数是本机相对量级,会随字段名长度和数值位数变化——字段名越长、数值越接近整数、对象越少,JSON 相对越大。真正稳定的结论是方向:二进制把「字段名」和「分隔符」这两类开销彻底省掉,只留值本身。JSON 里 "health": 这 9 个字符的键名,在二进制里只对应 0 字节。

常见坑

  • 读写顺序必须严格对应。二进制没有字段名,读错一个字段的位置,后面全错位。所以 writeField 与 readField 的 switch 要放在同一屏里对照着改。
  • 忘了给小端参数。DataView 的 setUint16 / setFloat32 默认大端,跨端读回会得到乱码;本实现统一传 true 走小端。
  • 字符串长度用 u16 是上限 65535 字节。名字、标签这类短文本够用;要存长文本得换成 u32 长度,否则静默截断。
  • f32 存不下所有 double。12.5 能精确表示,0.1 不能;需要精确的金额、坐标种子请用 i32 定点(见 10.2 定点数 )。
  • 二进制里没写字段名,也就没有「自描述」。解码时必须先拿到 $type 对应的描述符——这就是为什么二进制格式把类型名和 schema 版本写进了头部。

JSON 与二进制解决了「值怎么落盘」,但两种编码器都明确拒绝 ref / array:一旦字段指向另一个对象,就会出现同一个对象被写两遍、甚至互相指来指去写不完的问题。这是 17.2 引用、句柄与循环的处理 要解决的。

阅读导航:上一节:目录 · 下一节:17.2 引用、句柄与循环的处理 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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