《游戏引擎实现原理》14.3 帧图的编译与执行

帧图编译成执行计划只有三步:拓扑排序定顺序、给每个资源分配槽位、把 Pass 的读写原样带上。本节在 helio 里把这三步写成纯 CPU 的编译器,再让浏览器侧的执行器只按计划录命令,把一条乱序声明的三 Pass 帧图跑出正确像素,验证「声明序」与「执行序」彻底解耦。

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 链 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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