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 序列化落盘与场景格式 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。