4.1 资源绑定与 bind group
上一章我们学会了录命令、提交、用三重缓冲躲开「帧还没画完就被改写」的坑。但命令只是「什么时候画什么」,真正决定画出来的东西长什么样的是资源:一个相机矩阵、一张 albedo 贴图、一个决定怎么采样的 sampler。这些东西不在顶点/索引缓冲里,而是散落在管线的各个阶段,等着着色器按名字去取。
于是就有了这一章要解决的核心问题:着色器怎么知道「相机矩阵」在哪一块 buffer、「albedo」是哪张 texture? WebGPU 的答案是三层结构——着色器里写 @group(n) @binding(m) 声明槽位,CPU 侧用 BindGroupLayout 描述「第 n 组第 m 号槽该放什么类型的资源」,再用 BindGroup 把具体的 buffer/texture/sampler 塞进去。三者必须严丝合缝,任何一处对不上,管线就会创建失败或渲染出错误结果。
本章与站内既有内容的分工
渲染原理本身不在这本书里重推。下表说明本节和站内既有文章各自负责什么。
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/graphics-texture-memory/ | 纹理显存占用、格式与压缩的原理 | 在 helio RHI 上实现纹理绑定与上传路径 |
/graphics-shader-compilation-pipeline/ | 着色器从源码到 ISA 的编译流程 | 把 WGSL 的 @group/@binding 反射成绑定布局 |
/posts/graphics/ | 渲染原理与多引擎对照(45 篇) | 只做 RHI 上从零实现与真实像素结果 |
一句话:算法看 graphics,实现与产物看本卷。本节不推导任何采样或光照公式,只回答「绑定怎么搭、跑出来对不对」。
从着色器注解到 BindGroupLayout
WGSL 里的每个资源声明都自带坐标:
@group(0) @binding(0) var<uniform> u: Globals; // 第 0 组、第 0 槽:uniform buffer
@group(0) @binding(1) var tex: texture_2d<f32>; // 第 0 组、第 1 槽:采样纹理
@group(0) @binding(2) var samp: sampler; // 第 0 组、第 2 槽:采样器
CPU 侧的 BindGroupLayout 必须逐槽声明类型(buffer / texture / sampler)、用途(uniform 还是 storage)和可见阶段(vertex / fragment / compute)。传统做法是手写一份布局、再手写一份 WGSL,两边靠人肉对齐——一旦改了着色器忘了改布局,就是最难查的一类 bug:管线创建失败,报错只告诉你「某个槽类型不符」。
helio 的做法是让着色器当唯一真源:写一个反射器扫描 WGSL 的注解,自动推出布局。这样布局永远和着色器一致。下面这个纯 TS 模块就是反射器,可以在 Node 里直接跑、直接断言。
// file: src/render/binding/bind-group-layout.ts
// tier: L1
// verify: npx vitest run src/render/binding/bind-group-layout.test.ts
//
// 从 WGSL 源码的 @group/@binding 注解推导 BindGroupLayout 描述:
// 把「着色器」当作绑定契约的唯一真源,布局不再手写,两边永不漂移。
export type BindingKind =
| 'uniform' | 'storage' | 'read-only-storage'
| 'texture' | 'storage-texture' | 'sampler';
export interface BindingDecl {
readonly group: number;
readonly binding: number;
readonly name: string;
readonly kind: BindingKind;
/** 着色器里的原始类型文本,如 `texture_2d<f32>`、`Globals`、`array<f32>`。 */
readonly wgslType: string;
}
// 匹配 `@group(0) @binding(1) var<uniform> u: Globals;` 与
// `@group(0) @binding(1) var tex: texture_2d<f32>;`(无地址空间)。
const DECL_RE =
/@group\((\d+)\)\s*@binding\((\d+)\)\s*var(?:<([^>]*)>)?\s+([A-Za-z_]\w*)\s*:\s*([^;]+);/g;
/** 依据「地址空间 + 类型」判定绑定种类。 */
export function classifyBinding(varSpace: string | undefined, wgslType: string): BindingKind {
const space = (varSpace ?? '').split(',').map((s) => s.trim());
const t = wgslType.trim();
if (space[0] === 'uniform') return 'uniform';
if (space[0] === 'storage') return space[1] === 'read_write' ? 'storage' : 'read-only-storage';
if (t === 'sampler' || t.startsWith('sampler_')) return 'sampler';
if (t.startsWith('texture_storage')) return 'storage-texture';
if (t.startsWith('texture_')) return 'texture';
throw new Error(`无法判定的绑定类型: var<${varSpace ?? ''}> ${t}`);
}
/** 扫描 WGSL 源码,按出现顺序返回所有资源绑定声明。 */
export function parseBindings(wgsl: string): BindingDecl[] {
const out: BindingDecl[] = [];
for (const m of wgsl.matchAll(DECL_RE)) {
const [, g, b, space, name, type] = m;
out.push({
group: Number(g), binding: Number(b), name: name!,
kind: classifyBinding(space, type!), wgslType: type!.trim(),
});
}
return out;
}
/** 一个 bind group 的布局条目(不含后端细节的中间表示)。 */
export interface LayoutEntry {
readonly binding: number;
readonly visibility: 'vertex' | 'fragment' | 'compute';
readonly kind: BindingKind;
}
/** 把一个 group 内的声明转成布局条目,并按 binding 升序排列。 */
export function layoutForGroup(decls: readonly BindingDecl[], group: number): LayoutEntry[] {
return decls
.filter((d) => d.group === group)
.map((d) => ({ binding: d.binding, visibility: 'fragment' as const, kind: d.kind }))
.sort((a, b) => a.binding - b.binding);
}
/** 校验:同一 group 内 binding 号必须唯一。 */
export function assertNoDuplicateBindings(decls: readonly BindingDecl[]): void {
const seen = new Set<string>();
for (const d of decls) {
const key = `${d.group}:${d.binding}`;
if (seen.has(key)) throw new Error(`重复的绑定槽 @group(${d.group}) @binding(${d.binding})`);
seen.add(key);
}
}
/** 按 group 分组返回所有声明。 */
export function groupBindings(decls: readonly BindingDecl[]): Map<number, BindingDecl[]> {
const map = new Map<number, BindingDecl[]>();
for (const d of decls) {
const list = map.get(d.group) ?? [];
list.push(d);
map.set(d.group, list);
}
return map;
}
几个要点。第一,判定种类靠「地址空间 + 类型」两个线索:var<uniform> 是 uniform buffer;var<storage, read> 是只读 storage,var<storage, read_write> 是可写 storage;剩下的看类型文本——texture_2d<f32> 是采样纹理,texture_storage_2d 是存储纹理,sampler 是采样器。第二,layoutForGroup 按 binding 号排序输出,因为 WGSL 里声明的书写顺序可以任意(你可以先写 binding 2 再写 binding 0),但布局条目应当有序,便于比对与缓存。
本机实测 npx vitest run src/render/binding/bind-group-layout.test.ts:Test Files 1 passed (1)、Tests 8 passed (8)。其中一条断言验证了「书写顺序 0、2、1 的声明,布局输出被排成 0、1、2」,另一条验证了重复槽位会被 assertNoDuplicateBindings 拒绝。
可运行产物:真跑一个 uniform + texture + sampler
反射出的布局只是纸面契约,还得拿真实设备验证。下面这个 L2 模块在一个 group(0) 里同时挂上 uniform buffer、texture、sampler 三种资源,画一张贴图四边形,把 uniform 里的 tint 乘进去,再回读两个角的像素。它会在 headless Chromium(本地 http 服务 + --enable-unsafe-webgpu)里真跑。
// file: src/render/binding/webgpu-bind-group.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/binding/webgpu-bind-group.ts
//
// 真跑一个「uniform + texture + sampler」的 bind group:画一张贴图四边形、
// 把 uniform 的 tint 乘进去,回读两个角的像素验证绑定与采样都生效。
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string; }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown>; }
const WGSL = `
// file: shaders/textured-quad.wgsl
// tier: L2
struct Globals { tint: vec4<f32>, };
@group(0) @binding(0) var<uniform> u: Globals;
@group(0) @binding(1) var tex: texture_2d<f32>;
@group(0) @binding(2) var samp: sampler;
struct VSOut { @builtin(position) pos: vec4<f32>, @location(0) uv: vec2<f32>, };
@vertex fn vs(@builtin(vertex_index) i: u32) -> VSOut {
var p = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(-1.0, 1.0),
vec2<f32>(-1.0, 1.0), vec2<f32>(1.0, -1.0), vec2<f32>( 1.0, 1.0),
);
var uv = array<vec2<f32>, 6>(
vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 0.0),
vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 1.0), vec2<f32>(1.0, 0.0),
);
var o: VSOut;
o.pos = vec4<f32>(p[i], 0.0, 1.0);
o.uv = uv[i];
return o;
}
@fragment fn fs(in: VSOut) -> @location(0) vec4<f32> {
return textureSample(tex, samp, in.uv) * u.tint;
}
`;
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 W = 64, H = 64, bpr = 256;
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('requestAdapter() returned null');
const device = await adapter.requestDevice();
const format = navigator.gpu.getPreferredCanvasFormat();
const module = device.createShaderModule({ code: WGSL });
const info = await module.getCompilationInfo();
const errs = info.messages.filter((m) => m.type === 'error')
.map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
push('wgsl 编译 0 error', errs.length === 0, errs.join('; ') || 'no diagnostics');
// 显式布局:一个 uniform buffer + 一个 texture + 一个 sampler。
const layout = device.createBindGroupLayout({ entries: [
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
] });
const pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
primitive: { topology: 'triangle-list' },
});
// 2x2 贴图:左上 (100,60,40)、右下 (20,30,50),tint = (2,1,1,1)。
const texels = new Uint8Array([100, 60, 40, 255, 7, 8, 9, 255, 11, 12, 13, 255, 20, 30, 50, 255]);
const texture = device.createTexture({
size: [2, 2], format: 'rgba8unorm',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});
device.queue.writeTexture({ texture }, texels, { bytesPerRow: 8 }, [2, 2]);
const uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(uniform, 0, new Float32Array([2.0, 1.0, 1.0, 1.0]));
const sampler = device.createSampler({ magFilter: 'nearest', minFilter: 'nearest' });
const bindGroup = device.createBindGroup({ layout, entries: [
{ binding: 0, resource: { buffer: uniform } },
{ binding: 1, resource: texture.createView() },
{ binding: 2, resource: sampler },
] });
const target = device.createTexture({
size: [W, H], format,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
});
const rb = device.createBuffer({ size: bpr * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const enc = device.createCommandEncoder();
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, bindGroup);
pass.draw(6);
pass.end();
enc.copyTextureToBuffer({ texture: target }, { 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(); rb.destroy(); device.destroy();
// bgra8unorm 时把字节还原成 RGBA,保证断言与平台无关。
const swapBR = format.startsWith('bgra');
const at = (x: number, y: number): number[] => {
const b = Array.from(px.slice(y * bpr + x * 4, y * bpr + x * 4 + 4));
return swapBR ? [b[2]!, b[1]!, b[0]!, b[3]!] : b;
};
const tl = at(2, 2), br = at(W - 3, H - 3);
data.format = format; data.topLeft = tl; data.bottomRight = br;
push('左上角 = texel(0,0)*tint ≈ [200,60,40,255]',
Math.abs(tl[0]! - 200) <= 2 && Math.abs(tl[1]! - 60) <= 2 && Math.abs(tl[2]! - 40) <= 2 && tl[3] === 255,
JSON.stringify(tl));
push('右下角 = texel(1,1)*tint ≈ [40,30,50,255]',
Math.abs(br[0]! - 40) <= 2 && Math.abs(br[1]! - 30) <= 2 && Math.abs(br[2]! - 50) <= 2 && br[3] === 255,
JSON.stringify(br));
return { checks, data };
}
本机实测 node tools/run-l2.mjs src/render/binding/webgpu-bind-group.ts 的 3 条检查全部 PASS,data.format 是 bgra8unorm,topLeft = [200,60,40,255]、bottomRight = [40,30,50,255]。这两个数字值得细看:左上角对应贴图的 texel(0,0)=(100,60,40),tint=(2,1,1,1) 乘上去正好是 (200,60,40);右下角对应 texel(1,1)=(20,30,50),乘完是 (40,30,50)。同一个 bind group 里,uniform 改的是整体色调、texture 提供的是底色、sampler 决定按 uv 取哪个 texel——三者各自生效,缺一不可。如果采样器写错成 clamp-to-edge 或 uv 写反,两个角就会取到同一个 texel,断言会立刻发现。
小结
- 绑定分三层:着色器
@group/@binding声明槽位、BindGroupLayout描述槽位类型、BindGroup塞入具体资源。 - 布局可以手写,也可以从 WGSL 反射;helio 选后者,让着色器当唯一真源,布局与着色器永不漂移。
- 判定绑定种类靠「地址空间(uniform/storage)+ 类型文本(texture/sampler)」,
layoutForGroup按 binding 号排序输出。 - 真跑验证:一个 group 里同时绑 uniform + texture + sampler,回读两个角得到
[200,60,40,255]与[40,30,50,255],证明三者都参与且按 uv 取到不同 texel。
绑定的槽位对上之后,下一个问题是资源本身怎么进 GPU:一张 2x2 的小贴图随手就传上去了,但真实贴图有几百 KB 甚至几 MB,还得逐级生成 mipmap。下一节我们就走一遍上传、暂存与 mipmap 生成,并撞上 WebGPU 最著名的那个 256 字节对齐坑。
阅读导航:上一节:回到目录 · 下一节:4.2 上传、暂存与 mipmap 生成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。