7.2 脏标记传播与 world matrix 缓存
7.1 每帧展平都要算一遍全部 world 矩阵。但《微光岛》里绝大多数礁石一帧都不动——全量重算是纯浪费。这一节给场景图加上脏标记:改动一个节点只标脏它的子树,更新时只重算被标脏的节点;并证明「脏更新」与「全量重算」产出逐位一致的矩阵。
本节产出
src/render/scene/transform-cache.ts(L1)与src/render/scene/cache-pixels.ts(L2)。所有计数与像素差异都来自本机实跑。
脏从哪里开始传播
核心只有一条规则:改一个节点,脏它整棵子树。父节点动了,子的世界矩阵一定跟着变;但子的子节点是否要动,取决于它自己有没有独立改动——所以脏标记必须往下传到叶子。
实现上有两个关键点:
- 早停。
markDirty遇到一个已经脏的节点就立即返回,不再往它的子树里走,因为那棵子树上一轮已经标过了。于是「同一棵子树连续改两次」的第二次是 O(1)。 - 拓扑序更新。
update()按「父先于子」的下标顺序扫一遍,只对脏节点重算。父若脏,先算父,子重算时就能读到新的父世界矩阵。
这套机制和卷一 13.2《迭代与脏标记》同源:那边用脏标记跳过没变的实体查询,这边用它跳过没变的矩阵乘法,都是「只做必要的工作」。
可运行产物:脏传播到底省了多少
完整的 src/render/scene/transform-cache.ts 如下。它建一棵 16 节点的树(1 个根 + 3 个分组 + 每组 4 块礁石):
// file: src/render/scene/transform-cache.ts
// tier: L1
// verify: node src/render/scene/transform-cache.ts
//
// helio 渲染 7.2:脏标记传播 + world 矩阵缓存。
// 改动一个节点的局部变换,只把它的子树标脏;update() 只重算被标脏的节点,
// updateAll() 无条件全量重算。两者产出的 world 矩阵必须逐位一致——
// 缓存只省时间,绝不改结果。纯计算,Node 26 直跑。
import { type Mat4, identity, multiply } from '../../core/mat4.ts';
import { composeTRS, type Transform } from '../../core/transform.ts';
export class TransformCache {
#parent: Int32Array;
#local: Transform[];
#world: Mat4[];
#dirty: Uint8Array;
#children: number[][];
#count = 0;
#recomputed = 0; // 上一次 update/updateAll 真正重算的节点数
#markVisits = 0; // markDirty 访问到的节点数(含早停的那次)
constructor(capacity: number) {
this.#parent = new Int32Array(capacity).fill(-1);
this.#local = [];
this.#world = new Array(capacity);
this.#dirty = new Uint8Array(capacity);
this.#children = Array.from({ length: capacity }, () => []);
}
get count(): number { return this.#count; }
get recomputed(): number { return this.#recomputed; }
get markVisits(): number { return this.#markVisits; }
add(parent: number, local: Transform): number {
const i = this.#count++;
this.#parent[i] = parent;
this.#local[i] = local;
if (parent >= 0) this.#children[parent]!.push(i);
this.#dirty[i] = 1;
return i;
}
/** 改局部变换,并把该节点及其整棵子树标脏(遇到已脏节点立即停,不再下探)。 */
setLocal(i: number, local: Transform): void {
this.#local[i] = local;
this.#markDirty(i);
}
#markDirty(i: number): void {
this.#markVisits++;
if (this.#dirty[i] === 1) return; // 子树已经脏了,重复标记是 O(1)
this.#dirty[i] = 1;
for (const c of this.#children[i]!) this.#markDirty(c);
}
#recompute(i: number): void {
const local = composeTRS(this.#local[i]!);
const p = this.#parent[i]!;
this.#world[i] = p < 0 ? local : multiply(identity(), this.#world[p]!, local);
this.#dirty[i] = 0;
this.#recomputed++;
}
/** 只重算被标脏的节点。节点按父先于子登记,故一次正序扫描即拓扑序。 */
update(): number {
this.#recomputed = 0;
for (let i = 0; i < this.#count; i++) if (this.#dirty[i] === 1) this.#recompute(i);
return this.#recomputed;
}
/** 无条件全量重算,作为「正确性基准」。 */
updateAll(): number {
this.#recomputed = 0;
for (let i = 0; i < this.#count; i++) this.#recompute(i);
return this.#recomputed;
}
/** 重置标记统计,方便在 demo 里分段观察。 */
resetCounters(): void { this.#markVisits = 0; this.#recomputed = 0; }
world(i: number): Mat4 { return this.#world[i]!; }
isDirty(i: number): boolean { return this.#dirty[i] === 1; }
}
/** 逐位比较两个 4x4(都来自同一套 f32 运算,可用严格相等)。 */
export function mat4Equal(a: Mat4, b: Mat4): boolean {
for (let k = 0; k < 16; k++) if (a[k] !== b[k]) return false;
return true;
}
// ---- 直接运行时打印一份「脏传播到底省了多少」的账(Node 26 import.meta.main)----
if ((import.meta as { main?: boolean }).main) {
const trs = (t: [number, number, number], s = 1): Transform => ({
translation: t, rotation: identity(), scale: [s, s, s],
});
// 1 个根 + 3 个分组 + 每组 4 块礁石 = 16 个节点
const cache = new TransformCache(32);
const root = cache.add(-1, trs([0, 0, 0]));
const groups: number[] = [];
const rocks: number[][] = [];
for (let gi = 0; gi < 3; gi++) {
const g = cache.add(root, trs([gi * 4, 0, 0]));
groups.push(g);
const rs: number[] = [];
for (let r = 0; r < 4; r++) rs.push(cache.add(g, trs([r, 0, gi], 0.5)));
rocks.push(rs);
}
const full = cache.updateAll();
console.log(`节点 ${cache.count} 首次全量 updateAll 重算 ${full} 次`);
// 场景 A:动一个叶子礁石
cache.resetCounters();
cache.setLocal(rocks[2]![0]!, trs([9, 0, 0], 0.5));
const leaf = cache.update();
console.log(`改 1 块礁石 markDirty 访问 ${cache.markVisits} update 重算 ${leaf} (全量需 ${cache.count})`);
// 场景 B:动整个分组(它下面挂 4 块礁石)
cache.resetCounters();
cache.setLocal(groups[1]!, trs([100, 0, 0]));
const group = cache.update();
console.log(`改 1 个分组 markDirty 访问 ${cache.markVisits} update 重算 ${group} (全量需 ${cache.count})`);
// 场景 C:同一子树连续标记两次——第二次遇到已脏节点立即停
cache.resetCounters();
cache.setLocal(groups[1]!, trs([101, 0, 0]));
const again = cache.markVisits;
cache.setLocal(groups[1]!, trs([102, 0, 0]));
cache.update(); // 把最后一次改动刷进缓存,再和全量基准比
console.log(`同一分组连标两次 markDirty 访问 ${again} 然后 ${cache.markVisits - again}(早停)`);
// 正确性:脏更新的结果必须和全量重算逐位一致
const dirtyWorlds = Array.from({ length: cache.count }, (_, i) => cache.world(i));
cache.updateAll();
const same = dirtyWorlds.every((m, i) => mat4Equal(m, cache.world(i)));
console.log(`脏更新 == 全量重算(逐位): ${same}`);
}
node src/render/scene/transform-cache.ts(本机实测):
节点 16 首次全量 updateAll 重算 16 次
改 1 块礁石 markDirty 访问 1 update 重算 1 (全量需 16)
改 1 个分组 markDirty 访问 5 update 重算 5 (全量需 16)
同一分组连标两次 markDirty 访问 5 然后 1(早停)
脏更新 == 全量重算(逐位): true
读法:全量重算是 16 次矩阵乘法;改一块叶子礁石只重算 1 次(省 15 次);改一个挂着 4 块礁石的分组,markDirty 访问 5 个节点、update 重算 5 次(省 11 次)。最后两行是重点:同一棵子树连标两次,第二次只访问 1 个节点就早停;而脏更新的结果与全量重算逐位相同——缓存只省时间,绝不改结果。
可运行产物:脏更新的画面与全量重算逐字节相同
「逐位相同」是矩阵层面的,真正的验收是画面层面。src/render/scene/cache-pixels.ts(L2)用 7.1 的 instanced 渲染器,把「脏更新后的矩阵」和「全量重算的矩阵」各画一帧,逐像素比较:
// file: src/render/scene/cache-pixels.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/scene/cache-pixels.ts
//
// 7.2 的 L2 验收:缓存只省时间、不改结果。同一棵 16 节点场景,
// 用「脏更新后的 world 矩阵」和「全量重算的 world 矩阵」各渲染一帧,
// 逐像素比较——必须完全一致;同时改动确实让画面变了(前后两帧不同),
// 证明这条像素断言不是空转。需 headless Chromium + 本地 http 服务。
import { TransformCache } from './transform-cache.ts';
import { renderInstances, loadWgsl, type L2Check, type L2Result } from './render-instances.ts';
import { type Mat4, identity } from '../../core/mat4.ts';
import { type Transform } from '../../core/transform.ts';
const trs = (t: [number, number, number], s = 1): Transform => ({
translation: t, rotation: identity(), scale: [s, s, s],
});
function buildTree(): { cache: TransformCache; rocks: number[][] } {
const cache = new TransformCache(32);
const root = cache.add(-1, trs([0, 0, 0]));
const rocks: number[][] = [];
for (let gi = 0; gi < 3; gi++) {
const g = cache.add(root, trs([gi * 4, 0, 0]));
const rs: number[] = [];
for (let r = 0; r < 4; r++) rs.push(cache.add(g, trs([r, 0, gi], 0.5)));
rocks.push(rs);
}
return { cache, rocks };
}
const worlds = (cache: TransformCache): Mat4[] =>
Array.from({ length: cache.count }, (_, i) => cache.world(i));
export async function runL2(): Promise<L2Result> {
const checks: L2Check[] = [];
const data: Record<string, unknown> = {};
const push = (name: string, ok: boolean, detail: unknown): void => {
checks.push({ name, ok, detail: String(detail) });
};
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('requestAdapter() returned null');
const device = await adapter.requestDevice();
const wgsl = await loadWgsl('/shaders/instance-transform.wgsl');
const { cache, rocks } = buildTree();
const SIZE = 128;
// 每个节点一种颜色,保证「移动某个节点」一定改变像素(避免同色叠盖掩盖差异)
const colors = Array.from({ length: cache.count }, (_, i) => [
((i * 40) % 200 + 30) / 255, ((i * 70) % 200 + 30) / 255, ((i * 110) % 200 + 30) / 255, 1,
]);
cache.updateAll();
const pxBefore = await renderInstances(device, wgsl, worlds(cache), colors, SIZE);
// 改动一块礁石:脏更新后的矩阵
cache.setLocal(rocks[0]![0]!, trs([-20, 0, 0], 0.5));
cache.update();
const pxDirty = await renderInstances(device, wgsl, worlds(cache), colors, SIZE);
// 同样这棵场景,改用全量重算
cache.updateAll();
const pxFull = await renderInstances(device, wgsl, worlds(cache), colors, SIZE);
const diff = (a: Uint8Array, b: Uint8Array): number => {
let d = 0;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) d++;
return d;
};
const dCacheVsFull = diff(pxDirty, pxFull);
const dBeforeVsAfter = diff(pxBefore, pxDirty);
data.diffCacheVsFull = dCacheVsFull;
data.diffBeforeVsAfter = dBeforeVsAfter;
data.bytes = pxDirty.length;
push('脏更新 == 全量重算(逐字节)', dCacheVsFull === 0, `diff=${dCacheVsFull}`);
push('改动确实改变了画面(断言非空转)', dBeforeVsAfter > 0, `diff=${dBeforeVsAfter}`);
device.destroy();
return { checks, data };
}
本机实跑:
PASS 脏更新 == 全量重算(逐字节) diff=0
PASS 改动确实改变了画面(断言非空转) diff=432
第一行 diff=0:两帧 65536 字节完全相同。第二行是对照——我特意把一块礁石移到屏幕外,前后两帧差 432 字节,证明这条像素断言不是空转(若渲染根本没画出东西,两帧会一样「全 0 差异」而骗过第一行)。
小结
脏标记把「每帧重算 N 个矩阵」压成「每帧重算被改动的那几棵子树」。代价是每个节点多一个字节的脏位和一次向下传播,换来的是静态场景几乎零成本。
矩阵缓存解决了「算得少」,下一节解决「看什么、点哪里」:相机、裁剪面与拾取射线。
阅读导航:上一节:7.1 场景图与变换层级 · 下一节:7.3 相机、裁剪面与拾取射线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。