《游戏引擎实现原理》8.2 文档模型与脏标记

一份可编辑的场景文档长什么样?本节用扁平节点表存节点树,区分运行期句柄 id 与稳定标识 uid,把「改了一个节点,哪些东西要重算」交给脏标记来回答:任何改动沿父链向上标脏到根,刷新时按祖先先于后代的顺序处理。每次改动还追加一条变更记录,作为第 9 章撤销栈的接缝。

8.2 文档模型与脏标记

8.1 定下了「编辑器与运行时共用一份模型、各持一份实例」。这一节把那份模型具体化:它是一份场景文档,一棵节点树。真正要解决的问题只有一个——改了一个节点,哪些东西需要重算?

天真的做法是每次改动就把整棵树重算一遍:重算所有节点的世界变换、重建所有索引、刷新所有面板。对《微光岛》这种几百个节点的关卡,一次全量重算也许只要几毫秒,看不出问题。可编辑器是每帧都在改的:拖动 gizmo 时每秒产生几十次改动,全量重算会迅速吃满一帧的预算,界面开始卡。工业界的答案是脏标记:只标记真正受影响的部分,其余一律不动。

场景文档:扁平节点表

文档用 Map<id, DocNode> 存树,而不是嵌套的 children: DocNode[]。每个节点显式记 parent(父的 id)与 children(子的 id 数组)。扁平化的好处是随机访问是 O(1):给一个 id 立刻拿到节点,改它的 position,再沿 parent 链向上走。嵌套结构要访问一个深层节点得从头遍历,正是编辑器最频繁的操作。

节点上有两个 id,职责完全不同,别混:

  • id 是运行期句柄:整数、会话内唯一、删除后可被复用,只在内存里用,用来做 Map 的键。
  • uid 是稳定标识:跨会话、跨重排不变,写盘时用它互相引用(见 8.3)。重排一个节点的位置,id 可能变,uid 绝不变。

脏标记的传播

markDirty 是整节的核心。它的逻辑只有一行循环:从被改的节点出发,沿 parent 一路走到根,把沿途每个节点都加进脏集合。

为什么祖先也要标脏?因为 position 是局部平移。一个节点的世界变换等于「自己的局部变换 × 父链上所有祖先的变换」。改了 Hand 的局部坐标,Hand 的世界变换要重算,它的父 Player 的缓存也失效了(虽然 Player 自身的局部坐标没变,但它的子树世界变换缓存得重建)。所以脏必须向上传播到根,而不是只标自己。

注意标脏是向上的,不是向下:改父节点不需要标它的孩子——孩子的世界变换在下次刷新时自然会被重算。向上传播保证「要重算的东西」是超集,向下传播会漏掉祖先缓存失效这一层。

刷新:祖先先于后代

脏集合是一个 Set,但刷新时顺序很重要。重算世界变换必须保证父节点先于子节点——子节点的世界变换依赖父节点已经算好的结果。flushDirty 把脏节点按深度排序(depth 沿父链数层数),深度小的在前,同深度按 id 稳定排序,然后清空脏集合。

这个顺序在单节点改动时看不出来(只有一条链),但在批量改动时是必需的:如果同一帧里既改了根又改了叶子,按深度排序才能保证一次遍历就把所有缓存算对,不用反复迭代到收敛。

撤销的接缝

脏标记解决「重算什么」,还剩「怎么撤销」。编辑器必须支持 Ctrl+Z,而撤销需要一个记录:改了什么、改前是什么。这里不实现命令栈(那是第 9 章),只留接缝:每次改动都往 #changes 追加一条 ChangeRecord,带 kind、节点 id、before 与 after。

接缝的关键是粒度。removeNode 删除整棵子树时,只记一条 remove 记录,before 里存整棵子树的快照——这样撤销时能一次性把整棵树还原,而不是一条条逆序加回来。transform 则记 before / after 两份坐标。第 9 章的命令栈只需读这个记录流,把它折叠成可撤销的命令,完全不用知道文档内部怎么改的。

可运行产物:脏传播与变更记录的真跑

上面全是纯逻辑,能在 Node 里真跑。下面 src/editor/scene-document.ts 与它的测试 src/editor/scene-document.test.ts 都是 L1,命令 npx vitest run src/editor/scene-document.test.ts。

// file: src/editor/scene-document.ts
// tier: L1
// verify: npx vitest run src/editor/scene-document.test.ts
//
// helio 编辑器:场景文档与脏标记。文档用扁平节点表存一棵树,每个节点只记
// parent 与 children 的 id。任何改动把节点及其全部祖先标脏,脏集合按
// 「祖先先于后代」刷新;每次改动追加一条变更记录,供第 9 章命令栈做撤销。

export interface DocNode {
  /** 运行期句柄:整数、会话内唯一、可复用,只在内存里用。 */
  id: number;
  /** 稳定标识:跨会话、跨重排不变,写盘时用它互相引用(见 8.3)。 */
  uid: string;
  name: string;
  parent: number | null;
  children: number[];
  position: [number, number, number];
}

export type ChangeKind = 'add' | 'remove' | 'reparent' | 'transform';
export interface ChangeRecord { kind: ChangeKind; id: number; before: unknown; after: unknown }

export class SceneDocument {
  readonly #nodes = new Map<number, DocNode>();
  readonly #dirty = new Set<number>();
  readonly #changes: ChangeRecord[] = [];
  #nextId = 1;
  #uidSeq = 0;
  readonly #root: number;

  constructor(rootName = 'Level') { this.#root = this.#create(rootName, null, [0, 0, 0]); }

  get root(): number { return this.#root; }
  node(id: number): DocNode {
    const n = this.#nodes.get(id);
    if (!n) throw new Error(`No such node: ${id}`);
    return n;
  }
  has(id: number): boolean { return this.#nodes.has(id); }
  all(): DocNode[] { return [...this.#nodes.values()]; }
  isDirty(id: number): boolean { return this.#dirty.has(id); }
  dirtyCount(): number { return this.#dirty.size; }

  #create(name: string, parent: number | null, position: [number, number, number]): number {
    const id = this.#nextId++;
    this.#nodes.set(id, { id, uid: `n${this.#uidSeq++}`, name, parent, children: [], position: [...position] });
    return id;
  }

  createNode(name: string, parent: number, position: [number, number, number]): number {
    const id = this.#create(name, parent, position);
    this.node(parent).children.push(id);
    this.markDirty(id);
    this.#changes.push({ kind: 'add', id, before: null, after: name });
    return id;
  }

  /** 反序列化入口:用给定 uid 建节点,保持写盘时的稳定标识(见 8.3)。 */
  restoreNode(uid: string, name: string, parent: number, position: [number, number, number]): number {
    const id = this.#create(name, parent, position);
    this.node(id).uid = uid;
    const m = /^n(\d+)$/.exec(uid);
    if (m) this.#uidSeq = Math.max(this.#uidSeq, Number(m[1]) + 1);
    this.node(parent).children.push(id);
    return id;
  }

  setPosition(id: number, position: [number, number, number]): void {
    const n = this.node(id);
    const before = [...n.position];
    n.position = [...position];
    this.markDirty(id);
    this.#changes.push({ kind: 'transform', id, before, after: [...position] });
  }

  /** 把 id 移到 newParent 下;新旧两条祖先链都标脏。 */
  reparent(id: number, newParent: number): void {
    if (id === this.#root) throw new Error('Cannot reparent the root');
    const n = this.node(id);
    if (n.parent === newParent) return;
    if (this.#isAncestor(id, newParent)) throw new Error('Reparent would create a cycle');
    const old = n.parent!;
    this.node(old).children = this.node(old).children.filter((c) => c !== id);
    this.node(newParent).children.push(id);
    n.parent = newParent;
    this.markDirty(id);
    this.markDirty(old);
    this.#changes.push({ kind: 'reparent', id, before: old, after: newParent });
  }

  /** 删除节点及其整棵子树;整棵子树只记一条变更记录。 */
  removeNode(id: number): void {
    if (id === this.#root) throw new Error('Cannot remove the root');
    const snapshot = this.#subtree(id);
    const parent = this.node(id).parent;
    this.#unlink(id);
    if (parent !== null) this.markDirty(parent);
    this.#changes.push({ kind: 'remove', id, before: snapshot, after: null });
  }

  #subtree(id: number): DocNode[] {
    const n = this.node(id);
    return [n, ...n.children.flatMap((c) => this.#subtree(c))];
  }

  #unlink(id: number): void {
    const n = this.node(id);
    for (const c of [...n.children]) this.#unlink(c);
    if (n.parent !== null) this.node(n.parent).children = this.node(n.parent).children.filter((c) => c !== id);
    this.#nodes.delete(id);
    this.#dirty.delete(id);
  }

  #isAncestor(maybeAncestor: number, of: number): boolean {
    for (let cur: number | null = of; cur !== null; cur = this.node(cur).parent) {
      if (cur === maybeAncestor) return true;
    }
    return false;
  }

  /** 把节点及其所有祖先标脏。 */
  markDirty(id: number): void {
    for (let cur: number | null = id; cur !== null; cur = this.node(cur).parent) this.#dirty.add(cur);
  }

  depth(id: number): number {
    let d = 0;
    for (let cur = this.node(id).parent; cur !== null; cur = this.node(cur).parent) d++;
    return d;
  }

  /** 取出脏节点并按「祖先先于后代」排序(同深度按 id),随后清空。 */
  flushDirty(): number[] {
    const list = [...this.#dirty].sort((a, b) => this.depth(a) - this.depth(b) || a - b);
    this.#dirty.clear();
    return list;
  }

  /** 取出并清空变更记录(撤销接缝:第 9 章命令栈消费它)。 */
  drainChanges(): ChangeRecord[] { return this.#changes.splice(0, this.#changes.length); }
}
// file: src/editor/scene-document.test.ts
// tier: L1
// verify: npx vitest run src/editor/scene-document.test.ts
import { describe, expect, it } from 'vitest';
import { SceneDocument } from './scene-document.ts';

/** root → Player → Hand,外加 root → Torch 兄弟;清掉建树期的脏与记录。 */
function tree(): SceneDocument {
  const doc = new SceneDocument('微光岛');
  const player = doc.createNode('Player', doc.root, [0, 0, 0]);
  doc.createNode('Hand', player, [1, 0, 0]);
  doc.createNode('Torch', doc.root, [5, 0, 0]);
  doc.flushDirty();
  doc.drainChanges();
  return doc;
}

describe('节点树', () => {
  it('createNode 挂到父 children 末尾,reparent 迁移新旧父', () => {
    const doc = new SceneDocument();
    const a = doc.createNode('A', doc.root, [0, 0, 0]);
    const b = doc.createNode('B', doc.root, [0, 0, 0]);
    expect(doc.node(doc.root).children).toEqual([a, b]);
    doc.reparent(b, a);
    expect(doc.node(doc.root).children).toEqual([a]);
    expect(doc.node(a).children).toEqual([b]);
    expect(doc.node(b).parent).toBe(a);
  });

  it('reparent 成环直接抛错', () => {
    const doc = new SceneDocument();
    const a = doc.createNode('A', doc.root, [0, 0, 0]);
    const b = doc.createNode('B', a, [0, 0, 0]);
    expect(() => doc.reparent(a, b)).toThrow(/cycle/);
  });

  it('removeNode 删掉整棵子树', () => {
    const doc = tree();
    const player = doc.node(doc.root).children[0];
    doc.removeNode(player);
    expect(doc.has(player)).toBe(false);
    expect(doc.all().map((n) => n.name)).not.toContain('Hand');
  });
});

describe('脏标记传播', () => {
  it('改叶子把叶子和所有祖先标脏,不碰兄弟', () => {
    const doc = tree();
    const player = doc.node(doc.root).children[0];
    const hand = doc.node(player).children[0];
    const torch = doc.node(doc.root).children[1];
    doc.setPosition(hand, [2, 0, 0]);
    expect([doc.isDirty(hand), doc.isDirty(player), doc.isDirty(doc.root)]).toEqual([true, true, true]);
    expect(doc.isDirty(torch)).toBe(false);
  });

  it('reparent 把新旧两条祖先链都标脏;flushDirty 祖先先于后代', () => {
    const doc = tree();
    const player = doc.node(doc.root).children[0];
    const hand = doc.node(player).children[0];
    doc.reparent(hand, doc.root); // Hand 从 Player 下移到 root
    expect(doc.isDirty(player)).toBe(true);
    const order = doc.flushDirty();
    expect(order.indexOf(doc.root)).toBeLessThan(order.indexOf(hand));
    expect(doc.dirtyCount()).toBe(0);
  });
});

describe('撤销接缝', () => {
  it('transform 记录 before / after,drainChanges 清空', () => {
    const doc = tree();
    const player = doc.node(doc.root).children[0];
    doc.setPosition(player, [7, 1, 2]);
    const [rec] = doc.drainChanges();
    expect(rec).toMatchObject({ kind: 'transform', id: player });
    expect(rec.before).toEqual([0, 0, 0]);
    expect(rec.after).toEqual([7, 1, 2]);
    expect(doc.drainChanges()).toHaveLength(0);
  });

  it('remove 记录整棵子树快照供撤销还原', () => {
    const doc = tree();
    const player = doc.node(doc.root).children[0];
    doc.removeNode(player);
    const [rec] = doc.drainChanges();
    expect(rec.kind).toBe('remove');
    expect((rec.before as unknown[]).length).toBe(2); // Player + Hand
  });

  it('uid 跨重排不变', () => {
    const doc = new SceneDocument();
    const a = doc.createNode('A', doc.root, [0, 0, 0]);
    const b = doc.createNode('B', doc.root, [0, 0, 0]);
    const uidB = doc.node(b).uid;
    doc.reparent(b, a);
    expect(doc.node(b).uid).toBe(uidB);
  });
});

本机实跑:Test Files 1 passed、Tests 8 passed;tsc --noEmit 零错误。最值得看的是「脏标记传播」那两条:改 Hand 后 Hand、Player、root 都为脏而兄弟 Torch 不脏;reparent 后新旧两条祖先链都进脏集合,且 flushDirty 返回的序列里根一定排在叶子之前。这正是「只重算受影响部分」的机器可验证证据。

小结

  • 场景文档用扁平节点表:id 是运行期句柄、uid 是稳定标识,随机访问 O(1)。
  • 改动沿父链向上标脏到根,因为世界变换依赖整条祖先链,祖先的缓存会失效。
  • 刷新按深度排序,保证父先于子,一次遍历就能把缓存算对。
  • 每次改动追加一条 ChangeRecord 作为撤销接缝;remove 记整棵子树快照,粒度是一次操作一条记录。

下一节把这份文档写成文件:稳定 ID 怎么落盘、文件格式怎么带版本、旧存档怎么升上来。

阅读导航:上一节:8.1 编辑器与运行时的关系 · 下一节:8.3 序列化落盘与场景格式 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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