14.3 帧图的编译与执行
前两节把帧图拆成了两半:14.1 的 IR 负责「依赖与顺序」,14.2 划清了 WebGPU 的约束边界。这一节把两半缝起来,做成一条完整的 编译 → 执行 流水线。思路很直白:把帧图编译成一份执行计划(一份纯数据的列表),执行器拿到计划后照单录命令,不再做任何依赖判断。编译和执行被彻底分开——编译可以在 CPU 侧跑、可以缓存、可以提前一帧算好,执行只认计划。
编译:从帧图到执行计划
编译做三件事:拓扑排序定 Pass 执行序;给每个资源分配槽位(WebGPU 无别名,每资源独占一槽);汇总总显存。计划里每个 Pass 带上它读写的资源,执行器据此决定「谁当渲染目标、谁当采样源」。
// file: src/render/framegraph/compile.ts
// tier: L1
// verify: npx vitest run src/render/framegraph/compile.test.ts
//
// helio 渲染卷 14.3:把帧图「编译」成可执行的执行计划。
// 编译做三件事:
// 1) 拓扑排序,得到一个满足全部真依赖的 Pass 执行序(可能重排声明序);
// 2) 为每个资源分配槽位。WebGPU 没有别名 API,故每资源独占一个槽位;
// 3) 汇总总显存,并把每个 Pass 的读写资源原样带上,交给执行器。
// 编译是纯 CPU 的,执行器(浏览器侧)只按计划走,不再做任何依赖判断。
import type { FrameGraph, PassId, ResourceId } from './framegraph.ts';
export interface CompiledResource {
readonly id: ResourceId;
readonly kind: 'texture';
readonly bytes: number;
/** 独占槽位号(WebGPU 无别名,槽位 = 资源数)。 */
readonly slot: number;
readonly firstUse: number;
readonly lastUse: number;
}
export interface CompiledPass {
/** 执行序号,从 0 开始,严格等于拓扑序位置。 */
readonly index: number;
readonly name: PassId;
readonly reads: readonly ResourceId[];
readonly writes: readonly ResourceId[];
}
export interface ExecPlan {
readonly passes: readonly CompiledPass[];
readonly resources: readonly CompiledResource[];
readonly totalBytes: number;
}
/**
* 编译帧图。`bytesOf` 给出每个资源的字节数(例如 65×65×4 = 16900)。
* 资源槽位按拓扑序里「首次使用」的先后分配,保证计划是确定性的。
*/
export function compile(graph: FrameGraph, bytesOf: (r: ResourceId) => number): ExecPlan {
const order = graph.topologicalOrder();
const lifetimes = graph.lifetimes();
const byName = new Map(graph.passes.map((p) => [p.name, p]));
const passes: CompiledPass[] = order.map((name, index) => {
const node = byName.get(name)!;
return { index, name, reads: node.reads, writes: node.writes };
});
const sorted = [...lifetimes].sort((a, b) => a.firstUse - b.firstUse || a.resource.localeCompare(b.resource));
let totalBytes = 0;
const resources: CompiledResource[] = sorted.map((l, slot) => {
const bytes = bytesOf(l.resource);
totalBytes += bytes;
return { id: l.resource, kind: 'texture', bytes, slot, firstUse: l.firstUse, lastUse: l.lastUse };
});
return { passes, resources, totalBytes };
}
编译器有 3 条单测:把乱序声明的 C, A, B 重排成 A, B, C;每资源独占槽位、总显存等于全部之和;每个 Pass 带上读写资源且写者序号小于读者。npx vitest run src/render/framegraph/compile.test.ts。
对一条「声明序 C, A, B」的三 Pass 帧图,编译产出的计划长这样——注意 passes 已经按执行序排好,index 就是执行序号:
ExecPlan
passes:
0 A reads=[] writes=[t1]
1 B reads=[t1] writes=[t2]
2 C reads=[t2] writes=[out]
resources:
0 t1 slot=0 firstUse=0 lastUse=1
1 t2 slot=1 firstUse=1 lastUse=2
2 out slot=2 firstUse=2 lastUse=2
totalBytes: 50700
整条流水线被一条线切成两半,左边只在 CPU 上跑、右边只认数据:
编译期(CPU,纯数据变换,可缓存 / 可提前一帧算好)
声明 → 依赖推导 → 拓扑排序 → 资源分配 → ExecPlan
执行期(渲染线程,不做任何依赖判断)
for pass of plan.passes:
target = textures[pass.writes[0]]
source = textures[pass.reads[0]] ?? dummy
record(target, source, MODE[pass.name])
可运行产物:编译加执行端到端
执行器在浏览器侧:拿到执行计划后,逐 Pass 走——把该 Pass 的写资源当渲染目标、读资源当采样源,录一个 beginRenderPass。下面的脚本故意把帧图乱序声明成 C, A, B(C 依赖 B 的产物 t2,B 依赖 A 的产物 t1),看编译器能不能把它排对,再执行、回读、与 CPU 参考比对:
// file: src/render/framegraph/compile-execute-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/framegraph/compile-execute-l2.ts
//
// helio 渲染卷 14.3:编译并执行一条帧图。声明序故意写成 C, A, B,编译器把它
// 重排成 A → B → C,执行器只按编译后的执行计划走:对每个 Pass,把它的写资源当
// 渲染目标、读资源当采样源,录一个 beginRenderPass。最后回读 out 并与 CPU 参考
// 比对。需在浏览器中经本地 http 服务运行,命令行不可复现。
import { compile } from './compile.ts';
import { FrameGraph } from './framegraph.ts';
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result { readonly checks: readonly L2Check[]; readonly data: Record<string, unknown> }
const W = 65;
const H = 65;
const BPR = 512;
const PX = W * H * 4;
const MODE: Record<string, number> = { A: 0, B: 1, C: 2 };
const q8 = (v: number): number => Math.round(Math.min(1, Math.max(0, v)) * 255);
const dq = (b: number): number => b / 255;
/** CPU 参考:与 14.2 同一条链路(A 生成 → B 提亮 → C 改通道)。 */
function cpuFinal(): [number, number, number] {
const a = [0.5, 0.5, 0.25].map(q8);
const b = a.map((v) => q8(Math.min(1, dq(v) * 1.5)));
return [b[0]! / 255 * 0.5, b[1]! / 255, b[2]! / 255 * 0.5].map(q8) as [number, number, number];
}
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) });
};
// 1) 声明帧图:顺序是 C, A, B(C 依赖 B 的产物 t2,B 依赖 A 的产物 t1)。
const graph = new FrameGraph()
.addPass({ name: 'C', reads: ['t2'], writes: ['out'] })
.addPass({ name: 'A', reads: [], writes: ['t1'] })
.addPass({ name: 'B', reads: ['t1'], writes: ['t2'] });
// 2) 编译:得到执行序与资源分配。
const plan = compile(graph, () => PX);
const order = plan.passes.map((p) => p.name).join(',');
data.plan = { order, declaredOrder: 'C,A,B', resources: plan.resources.length, totalBytes: plan.totalBytes };
push('编译重排为 A,B,C', order === 'A,B,C', order);
push('三张瞬态资源独占三槽位', plan.resources.length === 3, plan.resources.map((r) => `${r.id}#${r.slot}`).join(' '));
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('requestAdapter() returned null');
const device = await adapter.requestDevice();
const wgsl = await (await fetch('/shaders/framegraph-chain.wgsl')).text();
const module = device.createShaderModule({ code: wgsl });
const info = await module.getCompilationInfo();
const errors = info.messages.filter((m) => m.type === 'error').map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
push('wgsl 0 error', errors.length === 0, errors.length);
const fmt: GPUTextureFormat = 'rgba8unorm';
const mkTex = (): GPUTexture => device.createTexture({
size: [W, H], format: fmt,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC,
});
const textures = new Map<string, GPUTexture>();
for (const r of plan.resources) textures.set(r.id, mkTex());
const dummy = device.createTexture({ size: [1, 1], format: fmt, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
device.queue.writeTexture({ texture: dummy }, new Uint8Array([0, 0, 0, 255]), { bytesPerRow: 4 }, [1, 1]);
const sampler = device.createSampler({ magFilter: 'nearest', minFilter: 'nearest' });
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format: fmt }] },
primitive: { topology: 'triangle-list' },
});
// 3) 执行器:只按计划走,逐 Pass 录命令。
const enc = device.createCommandEncoder();
for (const p of plan.passes) {
const bytes = new ArrayBuffer(32);
new Uint32Array(bytes, 0, 1)[0] = MODE[p.name]!;
const f = new Float32Array(bytes);
f[4] = W; f[5] = H;
const ub = device.createBuffer({ size: 32, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(ub, 0, bytes);
const srcTex = p.reads.length > 0 ? textures.get(p.reads[0]!)! : dummy;
const bg = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: ub } },
{ binding: 1, resource: srcTex.createView() },
{ binding: 2, resource: sampler },
],
});
const target = textures.get(p.writes[0]!)!;
const pass = enc.beginRenderPass({
colorAttachments: [{ view: target.createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
});
pass.setPipeline(pipeline); pass.setBindGroup(0, bg); pass.draw(3); pass.end();
}
const rb = device.createBuffer({ size: BPR * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
enc.copyTextureToBuffer({ texture: textures.get('out')! }, { buffer: rb, bytesPerRow: BPR }, [W, H]);
device.queue.submit([enc.finish()]);
await rb.mapAsync(GPUMapMode.READ);
const px = new Uint8Array(rb.getMappedRange().slice(0));
rb.unmap();
const off = 32 * BPR + 32 * 4;
const gpu = Array.from(px.slice(off, off + 4));
const cpu = cpuFinal();
const d = Math.max(...[0, 1, 2].map((i) => Math.abs(gpu[i]! - cpu[i]!)));
data.final = { cpu, gpu: gpu.slice(0, 3), diff: d };
push('执行计划产物 CPU≈GPU(±1)', d <= 1, `cpu=${cpu} gpu=${gpu.slice(0, 3)} diff=${d}`);
device.destroy();
return { checks, data };
}
执行器复用的是 14.2 的 shaders/framegraph-chain.wgsl。在 headless Chromium 里跑,本机实测:
PASS 编译重排为 A,B,C A,B,C
PASS 三张瞬态资源独占三槽位 t1#0 t2#1 out#2
PASS wgsl 0 error 0
PASS 执行计划产物 CPU≈GPU(±1) cpu=96,192,48 gpu=96,192,48 diff=0
关键在头两行:声明序是 C, A, B,编译输出的执行序是 A, B, C——编译器只凭数据流依赖把顺序摆正了,人写 Pass 时完全不用管先后。执行器从头到尾没有读过任何依赖信息,它只是遍历 plan.passes 这份列表;依赖判断全部发生在编译期。最后一行 cpu=96,192,48 gpu=96,192,48 diff=0 说明按计划跑出来的像素与 CPU 参考逐字节一致——乱序声明没有影响最终结果。总显存 50700 字节(3 × 16900),三张纹理各占一槽,与 14.2 的 WebGPU 式规划一致。
把编译和执行分开,带来的不只是「代码好看」。编译是纯数据变换:同样的帧图声明,编译一次就能缓存,下一帧只要依赖没变就直接复用计划,省掉每帧重排的开销。执行器不依赖任何帧图逻辑:它只认 ExecPlan 这份数据,所以可以扔到渲染线程去跑,甚至可以把计划序列化后跨线程传递。编译可以提前:本帧的 GPU 还在执行上一帧时,CPU 就能把下一帧的计划算好——这正是第 17 章「多线程渲染」要用到的性质。相比之下,如果顺序判断和执行录命令揉在一起,这三件事都做不了。
(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/framegraph/compile-execute-l2.ts 需在浏览器中运行,命令行不可复现。)
小结
帧图到这里就闭环了:声明读写 → 推导依赖 → 拓扑排序 → 分配资源 → 出执行计划 → 执行器照单录制。整卷后面所有需要多个 Pass 协作的效果——后处理链、阴影、UI 合成——都会挂进这张图,而不是手写顺序。
三节合起来看,帧图的骨架就是三个纯函数式的阶段:依赖推导(14.1,声明进、边出)、资源规划(14.2,生命周期进、槽位出)、编译(14.3,图进、执行计划出)。它们都不碰 GPU、都能单测、都能缓存;真正碰 GPU 的只有最后一小段「照计划录命令」。这种「把聪明留在 CPU、把执行留成哑巴」的分层,是本卷后面所有复杂特性的共同底子。编译与执行彻底分离,前者是纯 CPU、可缓存、可提前算,后者只认数据。第 15 章起,这套帧图会被真正用起来——从全屏 Pass 链开始,把后处理的每一步挂进图里。
阅读导航:上一节:14.2 WebGPU 约束下的帧图 · 下一节:15.1 全屏 Pass 链 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。