《游戏引擎实现原理》17.2 引用、句柄与循环的处理

朴素 JSON.stringify 遇到父子互指的图会抛 TypeError,报错原文是「Converting circular structure to JSON」。本节给出 helio 内核里带 id 表的对象图序列化器:把对象引用替换成整数 id,循环变成两条指向同一 id 的边,两趟重建后引用同一性保持不变;句柄部分复用第 6 章的世代句柄。

17.2 引用、句柄与循环的处理

17.1 的两个编码器都明确拒绝 ref / array 字段,因为一旦字段指向另一个对象,麻烦就来了:同一个对象可能被两处引用,可能互相指来指去指成一个环。场景树天然是后者——父节点的 children 指向子节点,子节点的 parent 又指回父节点。这一节先让朴素做法把错误犯出来,再给一个带 id 表的序列化器把它解决掉,最后说明句柄为什么是「能存进存档的引用」。

朴素序列化遇环:真实的报错

最省事的写法是直接 JSON.stringify。造一棵最小的父子互指图,本机实跑 node src/core/circular-demo.ts,先看它到底抛什么。

// file: src/core/circular-demo.ts
// tier: L1
// verify: node src/core/circular-demo.ts
//
// 朴素 JSON.stringify 遇环的真实报错文本。

type SceneNode = {
  name: string;
  parent: SceneNode | null;
  children: SceneNode[];
};

const island: SceneNode = { name: 'island', parent: null, children: [] };
const rock: SceneNode = { name: 'rock', parent: island, children: [] };
island.children = [rock];

try {
  JSON.stringify(island);
} catch (e) {
  const err = e as Error;
  console.log('name    :', err.name);
  console.log('message :', err.message.split('\n')[0]);
  console.log('--- full ---');
  console.log(err.message);
}

输出如下,报错类型是 TypeError,message 的第一行是 Converting circular structure to JSON,后面几行还贴心地画出了环的路径:

name    : TypeError
message : Converting circular structure to JSON
--- full ---
Converting circular structure to JSON
    --> starting at object with constructor 'Object'
    |     property 'children' -> object with constructor 'Array'
    |     index 0 -> object with constructor 'Object'
    --- property 'parent' closes the circle

这段原文值得记住,它是你在真实项目里排查存档崩溃时最常撞见的一行。报错本身没错——JSON.stringify 无法把一个无限递归的结构展开成有限文本,只能拒绝。问题不在 JSON.stringify,在对象引用本身不适合直接落盘。

靠 replacer 去重:不抛错了,但形状丢了

很多人第一反应是给 JSON.stringify 传一个 replacer,用一个 WeakSet 记住「见过的对象」,第二次遇到就写个占位符。它确实不再抛错了,但代价是引用关系被压扁成字符串。本机实跑 node src/core/replacer-demo.ts:

// file: src/core/replacer-demo.ts
// tier: L1
// verify: node src/core/replacer-demo.ts
//
// 用 JSON.stringify 的 replacer + WeakSet 去重:不抛错了,但形状丢了。

type SceneNode = { name: string; parent: SceneNode | null; children: SceneNode[] };

const island: SceneNode = { name: 'island', parent: null, children: [] };
const rock: SceneNode = { name: 'rock', parent: island, children: [] };
island.children = [rock];

const seen = new WeakSet<object>();
const json = JSON.stringify(island, (key, value) => {
  if (typeof value === 'object' && value !== null) {
    if (seen.has(value)) return '[circular]';
    seen.add(value);
  }
  return value;
});
console.log(json);
{"name":"island","parent":null,"children":[{"name":"rock","parent":"[circular]","children":[]}]}

rock.parent 变成了字符串 "[circular]"。这份 JSON 能写盘、不抛错,但读回来时你无法知道那个占位符原本指向谁——父子的双向关系退化成了一根单向的 children 边。对一棵场景树来说,这等于丢了一半的结构。结论:去重只能让序列化「跑得通」,不能让对象图「还原得回」。要还原,就得让每个引用带上目标对象的稳定编号。

带 id 表的对象图序列化器

解法只有一句话:不要序列化「引用」,序列化「引用的编号」。给每个对象分配一个整数 id,遇到 ref 字段就写目标对象的 id,遇到 array 就写一组 id。于是环变成「rock.parent = 0」和「island.children = [1]」两条普通边,都能落盘。

// file: src/core/graph-serializer.ts
// tier: L1
// verify: npx vitest run src/core/graph-serializer.test.ts
//
// helio 内核:带 id 表的对象图序列化器。
// 引用被替换成整数 id,循环引用因此变成两次「指向同一 id」,可正常写盘。

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

export type GraphNode = {
  id: number;
  type: string;
  fields: Record<string, unknown>;
};

export type GraphDocument = {
  root: number;
  nodes: GraphNode[];
};

/**
 * 把以 root 为起点的对象图写成「节点表 + id 引用」。
 * 每个对象只在 nodes 里出现一次,第二次遇到就复用它的 id。
 */
export function serializeGraph(
  reg: TypeRegistry,
  typeName: string,
  root: Record<string, unknown>,
): GraphDocument {
  const ids = new Map<object, number>();
  const nodes: GraphNode[] = [];

  const visit = (obj: Record<string, unknown>, tName: string): number => {
    const seen = ids.get(obj);
    if (seen !== undefined) return seen;
    const desc = reg.require(tName);
    const id = nodes.length;
    ids.set(obj, id);
    const fields: Record<string, unknown> = {};
    nodes.push({ id, type: desc.name, fields }); // 先占位,再递归,环才走得通
    for (const f of desc.fields) {
      const v = obj[f.name];
      if (v === undefined) continue;
      if (f.kind === 'ref') {
        fields[f.name] = v === null ? null : visit(v as Record<string, unknown>, f.type!);
      } else if (f.kind === 'array') {
        fields[f.name] = (v as Record<string, unknown>[]).map((x) =>
          x === null ? null : visit(x, f.type!),
        );
      } else {
        fields[f.name] = v;
      }
    }
    return id;
  };

  const rootId = visit(root, typeName);
  return { root: rootId, nodes };
}

/** 两趟重建:先建所有空壳,再按 id 连引用——循环引用靠对象同一性自然复原。 */
export function deserializeGraph(
  reg: TypeRegistry,
  doc: GraphDocument,
): Record<string, unknown> {
  const shells: Record<string, unknown>[] = doc.nodes.map((n) => {
    const desc = reg.require(n.type);
    const obj: Record<string, unknown> = {};
    for (const f of desc.fields) {
      if (f.kind === 'ref') obj[f.name] = null;
      else if (f.kind === 'array') obj[f.name] = [];
      else obj[f.name] = defaultValue(f);
    }
    return obj;
  });

  for (const n of doc.nodes) {
    const desc = reg.require(n.type);
    const obj = shells[n.id];
    for (const f of desc.fields) {
      const raw = n.fields[f.name];
      if (raw === undefined) continue;
      if (f.kind === 'ref') {
        obj[f.name] = raw === null ? null : shells[raw as number];
      } else if (f.kind === 'array') {
        obj[f.name] = (raw as number[]).map((id) => shells[id]);
      } else {
        obj[f.name] = raw;
      }
    }
  }
  return shells[doc.root];
}

两个关键点。第一,visit 里先把节点压进 nodes 占位,再递归处理字段——否则递归回到自己时 ids 里还没有它,会无限递归下去。第二,deserializeGraph 分两趟:先为每个节点建一个空壳并按 id 摆好,第二趟才把 id 翻译成对象引用。两趟的意义在于,连引用时目标空壳一定已经存在,环就自然闭合了。

可运行产物:父子互指的图怎么往返

把上面的 SceneNode 注册进描述符,跑一遍序列化再反序列化,打印节点数和文档结构。本机实跑 node src/core/graph-demo.ts:

// file: src/core/graph-demo.ts
// tier: L1
// verify: node src/core/graph-demo.ts
//
// 父子互指的对象图:带 id 表的序列化器怎么把它写下来、再原样读回。

import { TypeRegistry } from './registry.ts';
import { deserializeGraph, serializeGraph } from './graph-serializer.ts';

type SceneNode = { name: string; parent: SceneNode | null; children: SceneNode[] };

const reg = new TypeRegistry();
reg.register({
  name: 'SceneNode',
  version: 1,
  fields: [
    { name: 'name', kind: 'string' },
    { name: 'parent', kind: 'ref', type: 'SceneNode' },
    { name: 'children', kind: 'array', type: 'SceneNode' },
  ],
});

const island: SceneNode = { name: 'island', parent: null, children: [] };
const rock: SceneNode = { name: 'rock', parent: island, children: [] };
island.children = [rock];

const doc = serializeGraph(reg, 'SceneNode', island as unknown as Record<string, unknown>);
console.log('nodes   :', doc.nodes.length);
console.log('doc     :', JSON.stringify(doc));

const back = deserializeGraph(reg, doc) as unknown as SceneNode;
console.log('identity:', back.children[0].parent === back);
console.log('name    :', back.children[0].name);
nodes   : 2
doc     : {"root":0,"nodes":[{"id":0,"type":"SceneNode","fields":{"name":"island","parent":null,"children":[1]}},{"id":1,"type":"SceneNode","fields":{"name":"rock","parent":0,"children":[]}}]}
identity: true
name    : rock

island 是 id 0,rock 是 id 1;rock.parent 写成 0,island.children 写成 [1]。环被拆成了两条普通的整数边。反序列化之后 back.children[0].parent === back 为 true——不只是字段值相等,而是引用指向了同一个对象,父子关系真的复原了。这份用例也写进了 vitest(npx vitest run src/core/graph-serializer.test.ts,全部通过)。

句柄:能存进存档的引用

对象图序列化解决的是「一次序列化里」的引用。但引擎里还有另一种引用——跨存档、跨帧、甚至跨网络的那一种,它指向的是引擎运行时容器里的一个对象,而不是同一份 JSON 里的另一个节点。这种引用不能用 id 表,得用第 6 章的世代句柄(6.2 slot map 与世代句柄 )。

原因很直接:句柄是纯整数,kind: 'handle' 的字段直接编码成 4 字节 Uint32,加载后拿它去 SlotMap.get 重新解析即可。而对象引用一旦写进文件,下次读回来就指向一个不存在的对象。

// snippet: 句柄随存档往返,实体销毁后旧句柄解析成 undefined
const entities = new SlotMap<{ label: string }>();
const chest = entities.insert({ label: 'chest' });

// 句柄是纯整数,序列化后仍是整数,加载后可以重新解析。
const saved = JSON.parse(JSON.stringify({ owner: chest })) as { owner: number };
entities.get(saved.owner)?.label; // 'chest'

// 实体销毁后,旧句柄的世代对不上,解析成 undefined(防悬垂)。
entities.remove(chest);
entities.get(chest); // undefined

于是「引用」在引擎里分成两种,各有各的存法:同一次序列化内的对象关系用 id 表;跨存档的运行时对象用句柄。前者保的是对象图的形状,后者保的是「指向哪个活着的实体」——而后者能顺带把「实体已经没了」这件事变成一个可检测的 undefined,而不是一个指向幽灵对象的悬垂引用。

常见坑

  • 忘了先占位再递归。若在递归字段之后才把对象登记进 id 表,环会在登记前再次进入 visit,直接爆栈。
  • 反序列化只走一趟。一趟建对象、边建边连引用,遇到「A 引用 B、B 引用 A」时 B 还不存在,只能连出半个图。必须两趟。
  • 拿 id 当稳定标识。id 是一次序列化内的临时编号,同一个对象换一次序列化顺序就可能换 id,别把它当业务主键。
  • 数组里混进 null 没处理。children 允许有空位时,null 要原样写成 null,不能当成「未定义」跳过,否则数组下标会错位。
  • 把句柄和对象图 id 混用。句柄属于运行时容器(会随增删改变有效性),id 属于本次文档(只在一份 JSON 内有意义),两者不可互换。

有了对象图和句柄,任意复杂的存档结构都能落盘了。但还有最后一个问题:格式会变。今天写的 hp 字段,明天可能改名叫 health、类型也可能变。这是 17.3 版本化与迁移 要处理的。

阅读导航:上一节:17.1 反射驱动的序列化:JSON 与二进制 · 下一节:17.3 版本化与迁移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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