《游戏引擎实现原理》8.3 序列化落盘与场景格式

关卡怎么存盘才既好读又耐改?本节把 8.2 的场景文档写成《微光岛》的 JSON 关卡格式:节点之间用稳定 uid 互相引用而不是数组下标,文件头带 format 与 version,加载时按版本迁移、遇到更高的版本直接拒绝,并用一组用例真跑往返与迁移。

8.3 序列化落盘与场景格式

8.2 建好了内存里的场景文档,但编辑器关掉就没了。这一节解决落盘:把文档写成文件、再读回来,且要满足三条硬要求——可读(能进 Git、能逐行 review)、稳定(改一个字段只产生一行 diff)、可迁移(旧存档在新版引擎里能打开)。

站内已经讲过存档的通用设计,本节不重推,只做《微光岛》这一份具体格式的实现与真跑。

这三条要求里,「可读」与「稳定」是给人看的,「可迁移」是给未来的自己看的。前两条做得不好,策划会抱怨 diff 看不懂;第三条做得不好,半年前发的版本打不开今天的关卡,只能手写脚本救数据。

主题站内既有内容本章这一节做什么
存档序列化Defold 存档与序列化不重推通用存档设计,做关卡专用的可读 JSON 格式
引用与循环17.2 引用、句柄与循环用稳定 uid 互相引用,避免下标漂移
版本迁移17.3 版本化与迁移把关卡格式的 v1→v2 迁移写成可跑的函数

文件格式:可逐行 diff

helio 的关卡文件是 JSON,后缀 .glimmer.json。选 JSON 而不是二进制,是因为关卡的改动必须能被 review:策划改了一个火把的位置,diff 里应该只多出一行,而不是「二进制文件已更改」。代价是体积大、解析慢,但关卡不是热点数据,读盘只在切场景时发生一次,完全能接受。

一个最小的关卡文件长这样:根节点 n0,它下面挂着 Player(n1)与 Torch(n2)。

{
  "format": "glimmer-isle.level",
  "version": 2,
  "root": "n0",
  "nodes": [
    { "uid": "n0", "name": "Level",  "parent": null, "position": [0, 0, 0] },
    { "uid": "n1", "name": "Player", "parent": "n0", "position": [1, 2, 3] },
    { "uid": "n2", "name": "Torch",  "parent": "n0", "position": [5, 0, 0] }
  ]
}

format 是格式标识(防止把别的 JSON 当成关卡喂进来),version 是格式版本(不是引擎版本),root 是根节点的 uid。节点表按 uid 排序输出,所以两次保存同样的关卡,字节完全一致。

把格式定成「对象数组 + 字符串引用」而不是「嵌套对象」,也是刻意的。嵌套写法的 nodes 会随着场景树深度层层缩进,diff 里一改深节点整块缩进都动;扁平写法每个节点只占一行,父子的关系体现在 parent 字符串上,改哪一行一目了然。代价是文件里看不出树的形状,但这正是编辑器该做的事——文件只需要可读,树形由工具在界面里呈现。

稳定 ID:为什么不用数组下标

最省事的写法是「用节点在 nodes 数组里的下标当父子引用」:第 3 个节点的父是第 1 个节点,写 parentIndex: 0。这在小文件里没问题,但一旦重排或插入就崩:在数组开头插一个新节点,后面所有下标平移一位,所有 parentIndex 全部失效。一次插入会污染整个文件的 diff,而语义上其实只加了一个节点。

所以 helio 用稳定 uid:每个节点创建时分配一个永不复用的标识(n0、n1……),父子引用写 uid 而不是下标。重排节点时 uid 跟着节点走,parent 字段指向的 uid 不变,diff 里只有被真正移动的那一行变。uid 由 8.2 文档里的 #uidSeq 分配,restoreNode 加载时把序号恢复到已用最大值之后,保证新加的节点不会撞上旧 uid。

「永不复用」这四个字是这条设计里最容易被省掉、又最不能省的部分。如果删除节点后把它的 uid 回收给下一个新节点,那么「删除 A、新建 B」和「把 A 改名为 B」会产出同一个 uid,撤销栈里两条操作就撞在了一起——删掉 A 再撤销,可能凭空冒出一个 B。多留几个空号没有任何代价,而回收 uid 会换来极难定位的撤销错乱。

同样的道理也适用于运行时那句柄 id:它可以回收,因为它只活在一次会话内,从不写盘、从不跨会话比较。两个 id 的复用策略不同,正是因为它们的生命周期不同——一个只在本进程里存在,一个要跟着文件活很多年。

版本字段与迁移

version 字段是为了迁移。格式一定会变:v1 用 parentIndex、v2 改成 uid,未来可能再加组件、加层、加锁定标记。加载逻辑必须能回答「这个文件是哪个版本、怎么升到当前版本」。

迁移的写法有两种流派:一种在读取时惰性升级(读到旧字段就顺手转一下),一种在加载入口一次性升到当前版本、之后全程按新格式处理。helio 选后者。惰性升级会把「这里是旧格式」的判断散落到几十处读取点,每加一个版本就多一层分支;一次性迁移只有一个入口、一条迁移链,读起来是一串 v1→v2→v3,容易验证也容易测试。代价是迁移函数必须写全、不能漏环——所以 migrateLevel 遇到没有对应迁移步骤的版本会直接抛错,而不是留一个半新半旧的对象。

parseLevel 的顺序是:先校验 format,再读 version;若 version > LEVEL_VERSION(文件比引擎还新)直接抛错——绝不猜、绝不静默降级,因为新版本可能有当前代码读不懂的字段,硬读会丢数据。若 version < LEVEL_VERSION,走 migrateLevel 逐级升级。v1→v2 的迁移把每个节点的 parentIndex(-1 表示根)换成 uid。

加载:装回文档树

loadLevel 把 LevelFile 装回 SceneDocument。有一个坑:文件里父节点不一定排在子节点之前。serializeLevel 按 uid 排序输出,但手写的文件、或者别的工具生成的顺序都可能颠倒。所以加载不能一趟扫完,而要反复扫描:每轮把「父已经装好」的节点装进去,直到某一轮没有进展为止。如果扫完还有剩余节点,说明存在孤立节点或循环引用,直接抛错而不是留一棵残缺的树。

装回时用 restoreNode 而不是 createNode,是关键的一步。createNode 会分配新的 uid,那样往返一次 uid 就变了,稳定 ID 的意义荡然无存;restoreNode 接受文件里的 uid 并把它写回节点,同时把文档的 uid 计数器顶到已用最大值之后,保证后续新建节点不会撞号。loadLevel 返回的文档再 serializeLevel 一次,字节应与输入完全一致——这正是往返测试要钉住的性质。

可运行产物:往返、迁移与拒绝的真跑

下面 src/editor/scene-format.ts 与测试 src/editor/scene-format.test.ts 都是 L1,命令 npx vitest run src/editor/scene-format.test.ts。

// file: src/editor/scene-format.ts
// tier: L1
// verify: npx vitest run src/editor/scene-format.test.ts
//
// helio 编辑器:《微光岛》关卡文件格式与版本迁移。
// 节点之间用稳定 uid 互相引用,绝不用数组下标;文件头带 format 与 version。
// 加载时按 version 升级,遇到比当前更高的版本直接拒绝,绝不猜、不静默降级。

import { SceneDocument } from './scene-document.ts';

export const LEVEL_FORMAT = 'glimmer-isle.level';
export const LEVEL_VERSION = 2;

export interface LevelNode {
  uid: string;
  name: string;
  parent: string | null;
  position: [number, number, number];
}

export interface LevelFile {
  format: string;
  version: number;
  root: string;
  nodes: LevelNode[];
}

/** v1:早期关卡用数组下标当父子引用,没有 uid。 */
export interface LevelFileV1 {
  format: string;
  version: number;
  nodes: { name: string; parentIndex: number; position: [number, number, number] }[];
}

/** 把文档写成关卡文件:uid 稳定,节点按 uid 数字后缀排序,产出可逐行 diff。 */
export function serializeLevel(doc: SceneDocument): LevelFile {
  const nodes = doc.all().map((n): LevelNode => ({
    uid: n.uid,
    name: n.name,
    parent: n.parent === null ? null : doc.node(n.parent).uid,
    position: [...n.position],
  }));
  nodes.sort((a, b) => Number(a.uid.slice(1)) - Number(b.uid.slice(1)));
  return { format: LEVEL_FORMAT, version: LEVEL_VERSION, root: doc.node(doc.root).uid, nodes };
}

/** 校验文件头并按需迁移;更高的版本、错误的 format 一律抛错。 */
export function parseLevel(raw: unknown): LevelFile {
  const f = raw as LevelFile | LevelFileV1;
  if (f?.format !== LEVEL_FORMAT) throw new Error(`Not a ${LEVEL_FORMAT} file`);
  if (typeof f.version !== 'number') throw new Error('Level file missing version');
  if (f.version > LEVEL_VERSION) throw new Error(`Level version ${f.version} is newer than ${LEVEL_VERSION}`);
  return f.version === LEVEL_VERSION ? (f as LevelFile) : migrateLevel(f as LevelFileV1);
}

/** v1 → v2:把 parentIndex 换成稳定 uid。 */
export function migrateLevel(v1: LevelFileV1): LevelFile {
  if (v1.version !== 1) throw new Error(`No migration from v${v1.version}`);
  const uidOf = v1.nodes.map((_, i) => `n${i}`);
  const nodes: LevelNode[] = v1.nodes.map((n, i) => ({
    uid: uidOf[i]!,
    name: n.name,
    parent: n.parentIndex < 0 ? null : uidOf[n.parentIndex]!,
    position: [...n.position],
  }));
  return { format: LEVEL_FORMAT, version: LEVEL_VERSION, root: uidOf[0]!, nodes };
}

/** 把关卡文件装回一棵文档树,uid 原样保留。父节点可能排在子节点之后。 */
export function loadLevel(f: LevelFile): SceneDocument {
  const doc = new SceneDocument(f.nodes.find((n) => n.uid === f.root)?.name ?? 'Level');
  const idOf = new Map<string, number>([[f.root, doc.root]]);
  const pending = f.nodes.filter((n) => n.uid !== f.root);
  let progress = true;
  while (pending.length > 0 && progress) {
    progress = false;
    for (let i = pending.length - 1; i >= 0; i--) {
      const n = pending[i]!;
      const parentId = n.parent === null ? doc.root : idOf.get(n.parent);
      if (parentId === undefined) continue;
      idOf.set(n.uid, doc.restoreNode(n.uid, n.name, parentId, n.position));
      pending.splice(i, 1);
      progress = true;
    }
  }
  if (pending.length > 0) throw new Error('Level has orphan or cyclic nodes');
  return doc;
}
// file: src/editor/scene-format.test.ts
// tier: L1
// verify: npx vitest run src/editor/scene-format.test.ts
import { describe, expect, it } from 'vitest';
import { SceneDocument } from './scene-document.ts';
import { LEVEL_FORMAT, LEVEL_VERSION, loadLevel, parseLevel, serializeLevel, type LevelFileV1 } from './scene-format.ts';

function sampleDoc(): SceneDocument {
  const doc = new SceneDocument('微光岛');
  const player = doc.createNode('Player', doc.root, [1, 2, 3]);
  doc.createNode('Hand', player, [0.5, 0, 0]);
  doc.createNode('Torch', doc.root, [5, 0, 0]);
  return doc;
}

describe('往返一致', () => {
  it('serialize → loadLevel → serialize 完全一致', () => {
    const a = serializeLevel(sampleDoc());
    const b = serializeLevel(loadLevel(a));
    expect(b).toEqual(a);
    expect(a.version).toBe(LEVEL_VERSION);
    expect(a.format).toBe(LEVEL_FORMAT);
  });

  it('uid 跨重排稳定:移动节点后写盘,uid 不变只改 parent', () => {
    const doc = sampleDoc();
    const hand = doc.all().find((n) => n.name === 'Hand')!;
    const root = doc.root;
    doc.reparent(hand.id, root);
    const file = serializeLevel(doc);
    const handNode = file.nodes.find((n) => n.name === 'Hand')!;
    expect(handNode.uid).toBe(hand.uid); // uid 没变
    expect(handNode.parent).toBe(doc.node(root).uid); // parent 变了
  });
});

describe('版本与格式', () => {
  it('v1 迁移到 v2:parentIndex 换成 uid', () => {
    const v1: LevelFileV1 = {
      format: LEVEL_FORMAT,
      version: 1,
      nodes: [
        { name: 'Level', parentIndex: -1, position: [0, 0, 0] },
        { name: 'Player', parentIndex: 0, position: [1, 0, 0] },
        { name: 'Hand', parentIndex: 1, position: [0.5, 0, 0] },
      ],
    };
    const f = parseLevel(v1);
    expect(f.version).toBe(LEVEL_VERSION);
    expect(f.root).toBe('n0');
    expect(f.nodes.find((n) => n.name === 'Hand')!.parent).toBe('n1');
    // 迁移后能装回文档树
    const doc = loadLevel(f);
    expect(doc.all().map((n) => n.name).sort()).toEqual(['Hand', 'Level', 'Player']);
  });

  it('比当前更高的版本直接拒绝', () => {
    expect(() => parseLevel({ format: LEVEL_FORMAT, version: 99, root: 'n0', nodes: [] }))
      .toThrow(/newer/);
  });

  it('错误 format 直接拒绝', () => {
    expect(() => parseLevel({ format: 'godot.tscn', version: 2, root: 'n0', nodes: [] }))
      .toThrow(/Not a/);
  });

  it('父子链断裂(孤立节点)抛错', () => {
    const orphan = {
      format: LEVEL_FORMAT,
      version: LEVEL_VERSION,
      root: 'n0',
      nodes: [
        { uid: 'n0', name: 'Level', parent: null, position: [0, 0, 0] as [number, number, number] },
        { uid: 'n9', name: 'Ghost', parent: 'nX', position: [0, 0, 0] as [number, number, number] },
      ],
    };
    expect(() => loadLevel(orphan)).toThrow(/orphan|cyclic/);
  });
});

本机实跑:Test Files 1 passed、Tests 6 passed;tsc --noEmit 零错误。四条关键断言:serialize → loadLevel → serialize 完全相等(往返无损);重排节点后 uid 不变、只有 parent 变(稳定 ID 生效);v1 的 parentIndex 正确换成 uid(迁移可用);version: 99 与错误 format 都被拒绝(不静默降级)。

小结

  • 关卡文件是可读 JSON,按 uid 排序输出,保证同样的关卡字节一致、diff 最小。
  • 父子引用用稳定 uid 而不是数组下标,插入 / 重排不会污染整份 diff。
  • 文件头带 format 与 version;更高的版本直接拒绝,更低的版本逐级迁移。
  • 加载要处理「父排在子之后」的顺序问题,反复扫描到收敛,孤立节点抛错。

编辑器的数据层到这里就完整了:8.1 的关系、8.2 的文档与脏标记、8.3 的落盘。下一章在它之上搭命令栈,把 8.2 留下的变更记录接成可撤销的操作。

阅读导航:上一节:8.2 文档模型与脏标记 · 下一节:9.1 命令模式与可撤销操作 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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