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 帧图的编译与执行 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。