《游戏引擎实现原理》13.2 迭代顺序、变更检测与脏标记

改动一个节点后只把它的子树标脏,updateDirty 只重算被标脏的节点,updateAll 则无条件全量重算。本节给出 helio 内核可编译的 TransformHierarchy(扁平 TypedArray + 拓扑序),并实测 4 叉树上改叶子时「全量重算 vs 脏标记」的耗时差与被重算的节点数。

13.2 迭代顺序、变更检测与脏标记

13.1 的缓存有个致命前提:世界不变。可游戏世界里每帧都有人在动。如果每次改动都让全部查询、全部变换重算,缓存和层级更新就都成了摆设。这一节解决两件事:让迭代顺序稳定可预测,让重算只发生在真正变了的那部分数据上。

稳定的迭代顺序从哪来

「顺序」看起来是个小问题,但它决定了三件事:模拟是否可复现、缓存是否命中、遍历是否缓存友好。

  • 可复现。确定性模拟要求同一份输入永远产出同一份输出。如果每帧的遍历顺序取决于哈希桶的分布或 GC 时机,两次运行的浮点累加次序就会不同,结果慢慢飘开(第 10 章讲过的确定性问题的另一半)。
  • 缓存友好。13.1 的 queryUncached 直接按 Map 的插入顺序遍历——只要不删不重加,顺序就稳定;而稀疏集的 dense 数组(6.3)给的是「紧凑且稳定」的顺序,两者都比「每次重新排序」好。
  • 拓扑正确。层级结构必须「父先于子」处理,否则子节点会用到过期的父世界变换。做法很简单:父节点总是先于子节点被创建,于是节点数组的插入顺序天然就是拓扑序,不需要每次排序。
容器迭代顺序稳定性来源
Map插入顺序只要不删除键,顺序不变
稀疏集 dense 数组紧凑插入顺序swap-remove 只挪动末尾元素
变换层级节点数组父先于子的拓扑序先建父、后建子

本节的层级把节点存在扁平 TypedArray 里,另外维护一个 order 数组记录拓扑序。更新时按 order 一次线性扫描——既保证父先于子,又是连续内存访问。

脏标记:只重算变了的那棵子树

脏标记的核心只有一句话:改动发生时,把受影响的范围标记出来;更新时只处理被标记的部分。

一个变换节点被改动,受影响的只有它自己和它的所有后代——祖先和兄弟都不受影响。所以 setLocal 之后要做的,是从这个节点出发,把整棵子树标脏。updateDirty 再按拓扑序扫一遍,遇到脏节点就重算、清脏,遇到干净节点直接跳过。

这里有个容易被忽略的优化:markDirty 遇到已经脏的节点要立即返回,不再往它的子树下探。因为「子树已脏」意味着里面每个节点都已被标脏,重复下探纯属浪费。正是这条提前返回,让「先标脏子树、再标脏父节点」这类重复操作退化成一次 O(子树) 而不是 O(子树²)。

下面是完整实现,层级 L1,Node 直跑。

// file: src/core/transform-hierarchy.ts
// tier: L1
// verify: npx vitest run src/core/transform-hierarchy.test.ts
//
// 变换层级 + 脏标记:数据用扁平 TypedArray 存(SoA),
// 节点按「父先于子」的拓扑序排列。改动一个节点只把它的子树标脏,
// updateDirty 只重算被标脏的节点;updateAll 则无条件全量重算。

export interface LocalTRS {
  tx: number;
  ty: number;
  rot: number;
  scale: number;
}

export class TransformHierarchy {
  #parent: Int32Array;
  #tx: Float64Array;
  #ty: Float64Array;
  #rot: Float64Array;
  #scale: Float64Array;
  #wx: Float64Array;
  #wy: Float64Array;
  #wrot: Float64Array;
  #wscale: Float64Array;
  #dirty: Uint8Array;
  #children: number[][];
  #order: number[] = [];
  #count = 0;
  #recomputed = 0;

  constructor(capacity: number) {
    this.#parent = new Int32Array(capacity).fill(-1);
    this.#tx = new Float64Array(capacity);
    this.#ty = new Float64Array(capacity);
    this.#rot = new Float64Array(capacity);
    this.#scale = new Float64Array(capacity).fill(1);
    this.#wx = new Float64Array(capacity);
    this.#wy = new Float64Array(capacity);
    this.#wrot = new Float64Array(capacity);
    this.#wscale = new Float64Array(capacity).fill(1);
    this.#dirty = new Uint8Array(capacity);
    this.#children = Array.from({ length: capacity }, () => []);
  }

  get count(): number {
    return this.#count;
  }

  /** 上一帧真正被重算的节点数。 */
  get recomputed(): number {
    return this.#recomputed;
  }

  /** 新增节点,parent 为 -1 表示根。返回节点 id。 */
  add(parent: number, local: LocalTRS): number {
    const i = this.#count++;
    this.#parent[i] = parent;
    this.#tx[i] = local.tx;
    this.#ty[i] = local.ty;
    this.#rot[i] = local.rot;
    this.#scale[i] = local.scale;
    if (parent >= 0) this.#children[parent].push(i);
    this.#order.push(i); // 父先建、子后建 → order 天然是拓扑序
    this.#dirty[i] = 1;
    return i;
  }

  /** 设置局部变换并把该节点及其整棵子树标脏(已脏则不再下探)。 */
  setLocal(i: number, local: LocalTRS): void {
    this.#tx[i] = local.tx;
    this.#ty[i] = local.ty;
    this.#rot[i] = local.rot;
    this.#scale[i] = local.scale;
    this.markDirty(i);
  }

  markDirty(i: number): void {
    if (this.#dirty[i] === 1) return; // 子树已脏,无需重复遍历
    this.#dirty[i] = 1;
    for (const c of this.#children[i]) this.markDirty(c);
  }

  #recompute(i: number): void {
    const p = this.#parent[i];
    if (p < 0) {
      this.#wx[i] = this.#tx[i];
      this.#wy[i] = this.#ty[i];
      this.#wrot[i] = this.#rot[i];
      this.#wscale[i] = this.#scale[i];
    } else {
      const c = Math.cos(this.#wrot[p]);
      const s = Math.sin(this.#wrot[p]);
      const ps = this.#wscale[p];
      const lx = this.#tx[i] * ps;
      const ly = this.#ty[i] * ps;
      this.#wx[i] = this.#wx[p] + lx * c - ly * s;
      this.#wy[i] = this.#wy[p] + lx * s + ly * c;
      this.#wrot[i] = this.#wrot[p] + this.#rot[i];
      this.#wscale[i] = ps * this.#scale[i];
    }
    this.#dirty[i] = 0;
    this.#recomputed++;
  }

  /** 只重算被标脏的节点,按拓扑序一次扫描。 */
  updateDirty(): void {
    this.#recomputed = 0;
    for (const i of this.#order) {
      if (this.#dirty[i] === 1) this.#recompute(i);
    }
  }

  /** 无条件全量重算。 */
  updateAll(): void {
    this.#recomputed = 0;
    for (const i of this.#order) this.#recompute(i);
  }

  world(i: number): [number, number, number, number] {
    return [this.#wx[i], this.#wy[i], this.#wrot[i], this.#wscale[i]];
  }

  isDirty(i: number): boolean {
    return this.#dirty[i] === 1;
  }

  order(): readonly number[] {
    return this.#order;
  }
}

注意 updateDirty 和 updateAll 用的是同一段重算逻辑,区别只在「要不要检查脏位」。这让「全量」成为「脏标记」的一个退化解——脏标记不是另一套代码路径,只是给全量扫描加了一个廉价的跳过判断。

可运行产物:全量重算 vs 脏标记实测

基准脚本建一棵 10000 节点的 4 叉树,跑 1000 帧。每帧改 20 个叶子节点,然后对比两种更新方式。核心循环:

// snippet: bench/dirty-flag-bench.ts(节选)
function runFull(): number {
  const h = build();
  const t0 = performance.now();
  for (let f = 0; f < FRAMES; f++) {
    h.updateAll();
  }
  sink += h.recomputed;
  return performance.now() - t0;
}

function runDirtyLeaf(leaves: number[]): { ms: number; recomputed: number } {
  const h = build();
  let seed = 99;
  const t0 = performance.now();
  let total = 0;
  for (let f = 0; f < FRAMES; f++) {
    for (let k = 0; k < CHANGES; k++) {
      seed = (seed * 1103515245 + 12345) & 0x7fffffff;
      const id = leaves[seed % leaves.length]!;
      h.setLocal(id, { tx: 1 + (f % 7) * 0.001, ty: 0, rot: 0.01, scale: 1 });
    }
    h.updateDirty();
    total += h.recomputed;
  }
  sink += total;
  return { ms: performance.now() - t0, recomputed: total };
}

本机(Node 26.5.0)实跑,取 7 次重复的中位数:

N = 10000 nodes, FRAMES = 1000, CHANGES/frame = 20, leaves = 7500
[A leaf] full   193.63 ms
[A leaf] dirty  10.93 ms
[A leaf] speedup 17.71x
[A leaf] recomputed total = 19886 (full would be 10000000)
[B root] dirty  280.26 ms, recomputed total = 10000000

改叶子的场景:全量 1000 帧重算 1000 万次,脏标记只重算了 19886 次——因为 20 个叶子都没有后代,markDirty 只标记它们自己。重算次数差了 500 倍,耗时差 17.71 倍(差额来自脏标记的遍历与判断开销)。方向的结论是稳的:改动越局部,脏标记越省。

但第二条数据同样重要:如果每帧改的是根节点,整棵树都是它的子树,脏标记要重算全部 1000 万次,还要额外付出标记遍历的代价,于是 280.26 ms 比全量还慢。这戳破了一个常见的误读——脏标记不是「一定更快」,它只在改动范围小于全量时才划算。工程上要判断的是「典型帧里改动的节点占比」:小于三成,脏标记就基本稳赢;接近全量,就该老实全量重算。

测试:脏标记只重算子树

// snippet: src/core/transform-hierarchy.test.ts(节选)
it('脏标记只重算子树:改一个叶子只重算 1 个节点', () => {
  const h = new TransformHierarchy(16);
  const root = h.add(-1, L(0, 0));
  const mid = h.add(root, L(1, 0));
  const leaf = h.add(mid, L(1, 0));
  h.updateAll(); // 清脏
  h.setLocal(leaf, L(2, 0));
  h.updateDirty();
  expect(h.recomputed).toBe(1); // 只有 leaf
});

it('改父节点会重算整棵子树', () => {
  const h = new TransformHierarchy(16);
  const root = h.add(-1, L(0, 0));
  const a = h.add(root, L(1, 0));
  const b = h.add(root, L(2, 0));
  const c = h.add(a, L(1, 0));
  h.updateAll();
  h.setLocal(root, L(5, 0));
  h.updateDirty();
  expect(h.recomputed).toBe(4); // root + a + b + c
});

npx vitest run src/core/transform-hierarchy.test.ts 全部通过(6 个用例)。用例直接断言 recomputed 的数值,把「只重算子树」这条性质变成了可回归的约束——一旦有人把 markDirty 的提前返回删掉,或用 updateAll 顶替 updateDirty,测试立刻红。

常见坑

  • 忘记给新节点标脏。add 必须把新节点标脏,否则它第一次 updateDirty 会保持世界变换为 0。
  • markDirty 不提前返回。子树反复被标脏时,重复下探会让传播从 O(子树) 退化成 O(子树²)。
  • 只标自己、不标后代。父节点变了后代却没标脏,后代就会用到过期的父世界变换——这是层级动画里最典型的「子物体跟不上」bug。
  • 在遍历中改结构。updateDirty 扫 order 时如果插入/删除节点,order 会错位;结构改动应放到帧边界之外。
  • 把脏标记当万能药。改动覆盖大半棵树时它比全量还慢;别不看数据就上。

13.3 把「谁先谁后」这件事再推进一步:系统之间不是靠遍历顺序,而是靠声明的读写依赖自动排出并行分组。

阅读导航:上一节:13.1 查询匹配与缓存命中 · 下一节:13.3 系统依赖声明与并行调度 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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