《游戏引擎实现原理》14.2 WebGPU 约束下的帧图

Vulkan 类 API 的帧图要替开发者插屏障、把瞬态资源别名进共享显存;WebGPU 把这两件事整个从 API 里拿掉了——同步在 pass 边界隐式完成,纹理也没有别名入口。本节在 helio 里把这个约束差异量化:同一批瞬态资源,别名规划器给出两种显存占用,再在真实设备上跑通一条三 Pass 链。

14.2 WebGPU 约束下的帧图

上一节做出的帧图 IR 是后端无关的:它只知道 Pass 读什么写什么,不知道底层是谁。但帧图的价值有多大,取决于底层 API 把多少脏活留给了上层。Vulkan / D3D12 这一类 API 把大量责任推给应用,帧图因此能替开发者做很多事;WebGPU 则把其中两件最值钱的事整个从 API 里拿掉了。这一节的差异本身就是内容。

WebGPU 与 Vulkan 类 API 的两条约束差异

差异一:屏障。 Vulkan 1.0–1.2 里,图像布局转换、缓存同步全靠应用手动插 vkCmdPipelineBarrier / vkCmdImageMemoryBarrier。一个 Pass 写完全屏纹理、下一个 Pass 拿它当采样源,中间必须插一次屏障把布局从 COLOR_ATTACHMENT 转到 SHADER_READ_ONLY,写错就是花屏或校验层报错。帧图之所以在 Vulkan 上值钱,一半是因为它能自动推导并插入这些屏障。

WebGPU 没有屏障 API。beginRenderPass / endRenderPass 的边界就是同步点,驱动在其中隐式完成布局转换与依赖同步。上层既不需要、也无法调用任何屏障函数。于是帧图在 WebGPU 上「自动插屏障」这个职责直接消失了——它要做的事少了一半。

差异二:内存别名。 Vulkan / D3D12 允许把多张生命周期不重叠的瞬态纹理别名进同一块显存堆:hdr 用完就死,它的显存立刻给下一张瞬态纹理复用,靠 VK_IMAGE_ALIASING / 别名堆实现。一个典型帧图能把瞬态显存压到 1/3 甚至更低。WebGPU 没有任何别名入口:每张 createTexture 都是一次独立分配,你无法告诉它「这两张纹理共享显存」。

两条差异合起来改变了帧图的定位:在 WebGPU 上,帧图不再是屏障与别名的自动机,而是纯粹的组织与顺序工具——它把 Pass 排好序、把资源生命周期算清楚、把执行计划交出去,仅此而已。下面把别名这条差异用数字量化出来。

从零实现:两种别名策略的显存账

给一个资源列表(字节数 + 生命周期区间),算两种规划:Vulkan 式(能别名就复用槽位)与 WebGPU 式(每资源独占)。核心是「两个资源能否共享同一块显存」= 生命周期区间不重叠。

// file: src/render/framegraph/aliasing.ts
// tier: L1
// verify: npx vitest run src/render/framegraph/aliasing.test.ts
//
// helio 渲染卷 14.2:瞬态资源的内存别名规划器。
// 同一块显存可以给「生命周期不重叠」的多个瞬态资源复用——这是 Vulkan /
// D3D12 一类 API 的帧图能省显存的关键。WebGPU 没有别名 API,每个资源独占
// 分配。本模块把两种策略都算出来,用数字说明差异。

export interface ResourceLifetime {
  readonly resource: string;
  readonly bytes: number;
  /** 首次使用所在的拓扑序下标。 */
  readonly firstUse: number;
  /** 末次使用所在的拓扑序下标。 */
  readonly lastUse: number;
}

export interface Placement {
  readonly slot: number;
  readonly bytes: number;
}

export interface AliasPlan {
  readonly slots: number;
  readonly totalBytes: number;
  readonly placements: Readonly<Record<string, Placement>>;
}

/** 两个资源能否共享同一块显存:生命周期区间不重叠即可。 */
export function canAlias(a: ResourceLifetime, b: ResourceLifetime): boolean {
  return a.lastUse < b.firstUse || b.lastUse < a.firstUse;
}

/**
 * Vulkan 式别名规划:首次适配(first-fit)。
 * 按 firstUse 升序处理;每个资源塞进第一个「已有成员都与它不重叠」的槽位,
 * 否则新开一个槽位。槽位大小 = 成员里最大的字节数。总显存 = 各槽位之和。
 */
export function planAliased(lifetimes: readonly ResourceLifetime[]): AliasPlan {
  const sorted = [...lifetimes].sort((x, y) => x.firstUse - y.firstUse || x.resource.localeCompare(y.resource));
  const slots: ResourceLifetime[][] = [];
  const placements: Record<string, Placement> = {};
  for (const r of sorted) {
    let placed = false;
    for (let s = 0; s < slots.length; s++) {
      if (slots[s]!.every((m) => canAlias(m, r))) {
        slots[s]!.push(r);
        placements[r.resource] = { slot: s, bytes: r.bytes };
        placed = true;
        break;
      }
    }
    if (!placed) {
      const s = slots.length;
      slots.push([r]);
      placements[r.resource] = { slot: s, bytes: r.bytes };
    }
  }
  const totalBytes = slots.reduce((sum, members) => sum + Math.max(...members.map((m) => m.bytes)), 0);
  return { slots: slots.length, totalBytes, placements };
}

/**
 * WebGPU 式规划:不允许别名,每个资源独占一个槽位。
 * 槽位数 = 资源数,总显存 = 所有资源字节之和。
 */
export function planNonAliased(lifetimes: readonly ResourceLifetime[]): AliasPlan {
  const placements: Record<string, Placement> = {};
  let totalBytes = 0;
  lifetimes.forEach((r, i) => {
    placements[r.resource] = { slot: i, bytes: r.bytes };
    totalBytes += r.bytes;
  });
  return { slots: lifetimes.length, totalBytes, placements };
}

可运行产物:三 Pass 链在真实设备上跑通

光有规划器不够,还得证明「不插屏障也能跑对」。下面这条三 Pass 链在真实 WebGPU 设备上跑:Pass A 从 uv 生成梯度写进 t1,Pass B 采样 t1 提亮 1.5 倍写进 t2,Pass C 采样 t2 把 R/B 通道减半写进最终纹理。三个 Pass 各自是独立的 beginRenderPass,全程没有调用任何屏障 API,相邻 Pass 之间的同步由 WebGPU 隐式完成。共用的全屏着色器:

// file: shaders/framegraph-chain.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/framegraph/webgpu-constraints-l2.ts
//
// helio 渲染卷 14.2:帧图 pass 链共用的全屏着色器。
// 一个全屏三角形,片元里按 uniform 的 mode 选择三段行为之一:
//   mode 0 = Pass A,从 uv 直接生成梯度图(不采样);
//   mode 1 = Pass B,采样上游纹理并整体提亮 1.5 倍;
//   mode 2 = Pass C,采样上游纹理并把 R/B 通道减半。
// 三段共用一条管线、一个 bind group 布局,只是换了绑定与 mode。
struct Params {
  mode: vec4<u32>, // x: pass 模式
  res: vec2<f32>,  // 渲染目标分辨率
  pad: vec2<f32>,
};
@group(0) @binding(0) var<uniform> p: Params;
@group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var src_sampler: sampler;

struct VSOut {
  @builtin(position) pos: vec4<f32>,
};

@vertex
fn vs(@builtin(vertex_index) i: u32) -> VSOut {
  var tri = array<vec2<f32>, 3>(
    vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0));
  var o: VSOut;
  o.pos = vec4<f32>(tri[i], 0.0, 1.0);
  return o;
}

@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
  // builtin position 是像素坐标(原点左上),除以分辨率得到 [0,1) 的 uv。
  let uv = vec2<f32>(in.pos.x / p.res.x, in.pos.y / p.res.y);
  if (p.mode.x == 0u) {
    return vec4<f32>(uv.x, uv.y, 0.25, 1.0);
  }
  let s = textureSample(src, src_sampler, uv);
  if (p.mode.x == 1u) {
    return vec4<f32>(min(vec3<f32>(1.0), s.rgb * 1.5), 1.0);
  }
  return vec4<f32>(s.r * 0.5, s.g, s.b * 0.5, 1.0);
}

浏览器侧的驱动脚本:录三个 pass、逐段回读中心像素、把 CPU 参考与 GPU 实测比对,顺带把别名规划器的两种结果一起报出来。

// file: src/render/framegraph/webgpu-constraints-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/framegraph/webgpu-constraints-l2.ts
//
// helio 渲染卷 14.2:在真实 WebGPU 设备上跑一条三 Pass 帧图链。
// Pass A 生成梯度 → Pass B 提亮 → Pass C 改通道,每个 Pass 是独立的
// beginRenderPass;相邻 Pass 之间的同步由 WebGPU 隐式完成,全程没有调用
// 任何屏障 API。同时把「同一批瞬态资源在 Vulkan 式别名 / WebGPU 独占」两种
// 规划下的显存占用算出来。需在浏览器中经本地 http 服务运行,命令行不可复现。
import { planAliased, planNonAliased, type ResourceLifetime } from './aliasing.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; // 奇数边长:像素 (32,32) 中心恰好落在 uv (0.5,0.5)
const H = 65;
const BPR = 512; // bytesPerRow 必须是 256 的倍数(65*4=260 会被拒绝)

const q8 = (v: number): number => Math.round(Math.min(1, Math.max(0, v)) * 255);
const dq = (b: number): number => b / 255; // 8 位纹理回读后的线性值

/** CPU 参考:逐 Pass 模拟「浮点计算 → 量化到 8 位 → 下一步读回」的链路。 */
function cpuChain(): { a: [number, number, number]; b: [number, number, number]; c: [number, number, number] } {
  const aF = [0.5, 0.5, 0.25] as [number, number, number];
  const a = aF.map(q8) as [number, number, number];
  const bF = a.map(dq).map((v) => Math.min(1, v * 1.5));
  const b = bF.map(q8) as [number, number, number];
  const cF = [b[0]! / 255 * 0.5, b[1]! / 255, b[2]! / 255 * 0.5];
  const c = cF.map(q8) as [number, number, number];
  return { a, b, c };
}

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 (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}`);
  data.wgslErrors = errors;
  push('wgsl 0 error', errors.length === 0, errors.length);

  const fmt: GPUTextureFormat = 'rgba8unorm';
  const mkTex = (extraUsage = 0): GPUTexture => device.createTexture({
    size: [W, H], format: fmt,
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC | extraUsage,
  });
  const t1 = mkTex(); // Pass A 写、Pass B 读
  const t2 = mkTex(); // Pass B 写、Pass C 读
  const out = mkTex(); // Pass C 写
  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' },
  });

  const makeUniform = (mode: number): GPUBuffer => {
    const bytes = new ArrayBuffer(32);
    new Uint32Array(bytes, 0, 1)[0] = mode;
    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);
    return ub;
  };

  // 一条命令缓冲里依次录三个 render pass:每个 pass 用不同绑定与 mode。
  const enc = device.createCommandEncoder();
  const record = (target: GPUTexture, source: GPUTexture, mode: number): void => {
    const ub = makeUniform(mode);
    const bg = device.createBindGroup({
      layout: pipeline.getBindGroupLayout(0),
      entries: [
        { binding: 0, resource: { buffer: ub } },
        { binding: 1, resource: source.createView() },
        { binding: 2, resource: sampler },
      ],
    });
    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();
  };
  record(t1, dummy, 0); // Pass A:写 t1
  record(t2, t1, 1);    // Pass B:读 t1 写 t2
  record(out, t2, 2);   // Pass C:读 t2 写 out

  const readCenter = async (tex: GPUTexture): Promise<number[]> => {
    const rb = device.createBuffer({ size: BPR * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
    const c = device.createCommandEncoder();
    c.copyTextureToBuffer({ texture: tex }, { buffer: rb, bytesPerRow: BPR }, [W, H]);
    device.queue.submit([c.finish()]);
    await rb.mapAsync(GPUMapMode.READ);
    const px = new Uint8Array(rb.getMappedRange().slice(0));
    rb.unmap();
    const off = 32 * BPR + 32 * 4;
    return Array.from(px.slice(off, off + 4));
  };

  device.queue.submit([enc.finish()]);
  const gpuA = await readCenter(t1);
  const gpuB = await readCenter(t2);
  const gpuC = await readCenter(out);
  const cpu = cpuChain();
  const diff = (g: number[], c: number[]): number => Math.max(...[0, 1, 2].map((i) => Math.abs(g[i]! - c[i]!)));
  const dA = diff(gpuA, cpu.a);
  const dB = diff(gpuB, cpu.b);
  const dC = diff(gpuC, cpu.c);
  push('Pass A CPU≈GPU(±1)', dA <= 1, `cpu=${cpu.a} gpu=${gpuA.slice(0, 3)} diff=${dA}`);
  push('Pass B CPU≈GPU(±1)', dB <= 1, `cpu=${cpu.b} gpu=${gpuB.slice(0, 3)} diff=${dB}`);
  push('Pass C CPU≈GPU(±1)', dC <= 1, `cpu=${cpu.c} gpu=${gpuC.slice(0, 3)} diff=${dC}`);

  // 瞬态资源生命周期(拓扑序下标):t1 在 Pass0 写、Pass1 读;t2 在 Pass1 写、Pass2 读。
  const px = W * H * 4;
  const lifetimes: ResourceLifetime[] = [
    { resource: 'dummy', bytes: 4, firstUse: 0, lastUse: 0 },
    { resource: 't1', bytes: px, firstUse: 0, lastUse: 1 },
    { resource: 't2', bytes: px, firstUse: 1, lastUse: 2 },
    { resource: 'out', bytes: px, firstUse: 2, lastUse: 2 },
  ];
  const aliased = planAliased(lifetimes);
  const nonAliased = planNonAliased(lifetimes);
  data.alias = { vulkanSlots: aliased.slots, vulkanBytes: aliased.totalBytes, webgpuSlots: nonAliased.slots, webgpuBytes: nonAliased.totalBytes };
  push('Vulkan 式别名省下显存', aliased.totalBytes < nonAliased.totalBytes,
    `vulkan=${aliased.totalBytes}B/${aliased.slots}slot webgpu=${nonAliased.totalBytes}B/${nonAliased.slots}slot`);

  device.destroy();
  return { checks, data };
}

在 headless Chromium(本地 http 服务、带 --enable-unsafe-webgpu)里跑,本机实测:

PASS  wgsl 0 error  0
PASS  Pass A CPU≈GPU(±1)  cpu=128,128,64 gpu=128,128,64 diff=0
PASS  Pass B CPU≈GPU(±1)  cpu=192,192,96 gpu=192,192,96 diff=0
PASS  Pass C CPU≈GPU(±1)  cpu=96,192,48 gpu=96,192,48 diff=0
PASS  Vulkan 式别名省下显存  vulkan=33800B/2slot webgpu=50704B/4slot

三个 Pass 的中心像素逐字节相等:A 输出 (128,128,64),B 提亮后 (192,192,96),C 改通道后 (96,192,48)。注意 B、C 的值是按「8 位量化后再读回」算的——B 读到的 t1 已经是量化过的 128/255,乘 1.5 得 192/255,不是解析解的 191。全程没有任何屏障调用,说明在 WebGPU 上 pass 边界的同步确实是隐式的。

右边那行是本节的核心数字:同一批 65×65 瞬态纹理,Vulkan 式别名规划只需要 2 个槽位、33800 字节;WebGPU 被迫独占,要 4 个槽位、50704 字节——多出 16904 字节(正好一张纹理 + 一个 4 字节 dummy)。这就是「不允许别名」的代价,量化到字节。

(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/framegraph/webgpu-constraints-l2.ts 需在浏览器中运行,命令行不可复现。)

小结

WebGPU 把 Vulkan 类 API 的两件脏活——手动屏障与显存别名——都拿掉了:前者由 pass 边界隐式完成,后者干脆不提供。于是 helio 的帧图在 WebGPU 上退化成一件更纯粹的工具:排序、管生命周期、出执行计划。下一节就把这三件事串成一条完整的编译 → 执行流水线。

阅读导航:上一节:14.1 Pass 依赖与资源生命周期 · 下一节:14.3 帧图的编译与执行 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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