WebGPU 绑定组布局(Bind Group Layouts)

原作者: webgpufundamentals Contributors
原始链接: https://webgpufundamentals.org/webgpu/lessons/webgpu-bind-group-layouts.html


How it works:

为什么需要绑定组布局?

绑定组布局(Bind Group Layouts)让 WebGPU 能够高效地将绑定组(Bind Groups)与计算管线(Compute Pipelines)和渲染管线(Render Pipelines)进行匹配。其核心思想是:把耗时的详细校验前移到创建绑定组时完成,而在真正绘制或调度计算时只做极轻量的检查。

工作原理

一个管线(如 GPUComputePipeline 或 GPURenderPipeline)使用一个 GPUPipelineLayout,其中定义了 0 个或多个 GPUBindGroupLayout。每个 GPUBindGroupLayout 被分配到特定的组索引(group index)。绑定组也是用特定的 GPUBindGroupLayout 创建的。

当调用 draw 或 dispatchWorkgroups 时,WebGPU 只需要检查当前管线的 GPUPipelineLayout 中每个组索引对应的 GPUBindGroupLayout 是否与当前通过 setBindGroup 绑定的绑定组匹配。这个检查非常简单,因为大部分详细检查已经在创建绑定组时完成了。

如果使用 layout: 'auto' 创建管线,WebGPU 会自动生成 GPUPipelineLayout 并填充 GPUBindGroupLayout。但在以下两种情况下,你应该手动创建布局:

  1. 需要不同于默认 'auto' 的布局 —— 例如使用 rgba32float 纹理时。
  2. 希望在多个管线间共享同一个绑定组 —— 自动生成的布局不能跨管线复用。

一、使用不同于 layout: 'auto' 的布局:rgba32float 纹理

默认情况下,texture_2d<f32> 绑定会被自动推断为需要可过滤(filterable)的纹理。但 rgba32float(以及所有 xxx32float 格式)默认不可过滤,除非设备支持可选特性。因此直接使用 layout: 'auto' 会导致校验错误。

问题代码(修改前)

const textureData = new Uint8Array([  // 原为 Uint8Array
  b, _, _, _, _,
  ...
].flat());

const texture = device.createTexture({
  size: [kTextureWidth, kTextureHeight],
  format: 'rgba8unorm',   // 原格式
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});

改为 rgba32float 后:

const _ = [1, 0, 0, 1]; // red
const y = [1, 1, 0, 1]; // yellow
const b = [0, 0, 1, 1]; // blue
const textureData = new Float32Array(
  [
    b,
    _,
    _,
    _,
    _,
    _,
    y,
    y,
    y,
    _,
    _,
    y,
    _,
    _,
    _,
    _,
    y,
    y,
    _,
    _,
    _,
    y,
    _,
    _,
    _,
    _,
    y,
    _,
    _,
    _,
    _,
    _,
    _,
    _,
    _,
  ].flat(),
);

const texture = device.createTexture({
  size: [kTextureWidth, kTextureHeight],
  format: "rgba32float",
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});
device.queue.writeTexture(
  { texture },
  textureData,
  { bytesPerRow: kTextureWidth * 4 * 4 }, // 注意每个浮点占4字节
  { width: kTextureWidth, height: kTextureHeight },
);

运行后浏览器报错类似于:

None of the supported sample types (UnfilterableFloat) of [Texture “yellow F on red”] match the expected sample types (Float).
While validating entries[1] as a Sampled Texture. Expected entry layout: {sampleType: TextureSampleType::Float, ...}

这说明自动布局默认期望 sampleType: "float"(可过滤),而 rgba32float 只支持 "unfilterable-float"。

解决方案:手动创建布局

首先创建 GPUBindGroupLayout,将采样器类型改为 'non-filtering',纹理采样类型改为 'unfilterable-float':

const bindGroupLayout = device.createBindGroupLayout({
  entries: [
    {
      binding: 0,
      visibility: GPUShaderStage.FRAGMENT,
      sampler: {
        type: "non-filtering", // 改为非过滤
      },
    },
    {
      binding: 1,
      visibility: GPUShaderStage.FRAGMENT,
      texture: {
        sampleType: "unfilterable-float", // 改为不可过滤浮点
        viewDimension: "2d",
        multisampled: false,
      },
    },
  ],
});

然后创建 GPUPipelineLayout 并在管线中使用:

const pipelineLayout = device.createPipelineLayout({
  bindGroupLayouts: [bindGroupLayout],
});

const pipeline = device.createRenderPipeline({
  label: "hardcoded textured quad pipeline",
  layout: pipelineLayout, // 替换原来的 layout: 'auto'
  vertex: { module },
  fragment: {
    module,
    targets: [{ format: presentationFormat }],
  },
});

现在 rgba32float 纹理就能正常工作了。

注意:如果采样器的 magFilter、minFilter 或 mipmapFilter 设置为 'linear',则仍会报错,因为非过滤采样器不允许线性过滤。


二、使用不同布局:动态偏移(Dynamic Offsets)

默认情况下,绑定组中的 uniform/storage buffer 在创建时就固定了整个缓冲区或指定的 offset+length,之后无法更改。如果希望在调用 setBindGroup 时动态改变 offset,就必须在布局中设置 hasDynamicOffset: true。

修改计算着色器

@group(0) @binding(0) `var<storage, read_write>` a: `array<f32>`;
@group(0) @binding(1) `var<storage, read_write>` b: `array<f32>`;
@group(0) @binding(2) `var<storage, read_write>` dst: `array<f32>`;

@compute @workgroup_size(1) fn computeSomething(
  @builtin(global_invocation_id) id: vec3u
) {
  let i = id.x;
  dst[i] = a[i] + b[i];
}

创建带动态偏移的布局

const bindGroupLayout = device.createBindGroupLayout({
  entries: [
    {
      binding: 0,
      visibility: GPUShaderStage.COMPUTE,
      buffer: { type: "storage", hasDynamicOffset: true },
    },
    {
      binding: 1,
      visibility: GPUShaderStage.COMPUTE,
      buffer: { type: "storage", hasDynamicOffset: true },
    },
    {
      binding: 2,
      visibility: GPUShaderStage.COMPUTE,
      buffer: { type: "storage", hasDynamicOffset: true },
    },
  ],
});

创建管线时使用该布局:

const pipelineLayout = device.createPipelineLayout({
  bindGroupLayouts: [bindGroupLayout],
});

const pipeline = device.createComputePipeline({
  label: "add elements compute pipeline",
  layout: pipelineLayout,
  compute: { module },
});

准备缓冲区

偏移量必须是 256 的倍数(取决于设备限制,见 minStorageBufferOffsetAlignment)。这里创建一个 768 字节的缓冲区,支持 0、256、512 三个偏移:

const input = new Float32Array(64 * 3);
input.set([1, 3, 5]);
input.set([11, 12, 13], 64);

const workBuffer = device.createBuffer({
  label: "work buffer",
  size: input.byteLength,
  usage:
    GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(workBuffer, 0, input);

创建绑定组并指定偏移

绑定组中每个动态缓冲区需要指定 size(否则默认整个缓冲区),并在 setBindGroup 时传入偏移数组:

const bindGroup = device.createBindGroup({
  label: "bindGroup for work buffer",
  layout: pipeline.getBindGroupLayout(0),
  entries: [
    { binding: 0, resource: { buffer: workBuffer, size: 256 } },
    { binding: 1, resource: { buffer: workBuffer, size: 256 } },
    { binding: 2, resource: { buffer: workBuffer, size: 256 } },
  ],
});

// 编码命令
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup, [0, 256, 512]); // 三个动态偏移
pass.dispatchWorkgroups(3);
pass.end();

性能提示:动态偏移比非动态偏移略慢,因为偏移范围检查推迟到了 setBindGroup 调用时。如果调用次数不多,影响可忽略;但若调用上千次,可能值得考虑创建多个固定偏移的绑定组。


三、在多个管线间共享绑定组

手动创建绑定组布局的另一个重要原因是复用绑定组。例如在渲染阴影的场景中,你可能希望全局、材质、局部等绑定组能被阴影管线和主渲染管线共用,而不必为每个管线重复创建。

这里用两个计算管线共享一个绑定组作为示例。

两个不同的计算着色器

const moduleTimes2 = device.createShaderModule({
  code: /* wgsl */ `
    @group(0) @binding(0) `var<storage, read_write>` data: `array<f32>`;
    @compute @workgroup_size(1) fn computeSomething(
      @builtin(global_invocation_id) id: vec3u
    ) {
      let i = id.x;
      data[i] = data[i] * 2.0;
    }
  `,
});

const modulePlus3 = device.createShaderModule({
  code: /* wgsl */ `
    @group(0) @binding(0) `var<storage, read_write>` data: `array<f32>`;
    @compute @workgroup_size(1) fn computeSomething(
      @builtin(global_invocation_id) id: vec3u
    ) {
      let i = id.x;
      data[i] = data[i] + 3.0;
    }
  `,
});

共享同一个布局

const bindGroupLayout = device.createBindGroupLayout({
  entries: [
    {
      binding: 0,
      visibility: GPUShaderStage.COMPUTE,
      buffer: { type: "storage", minBindingSize: 0 },
    },
  ],
});

const pipelineLayout = device.createPipelineLayout({
  bindGroupLayouts: [bindGroupLayout],
});

const pipelineTimes2 = device.createComputePipeline({
  label: "doubling compute pipeline",
  layout: pipelineLayout,
  compute: { module: moduleTimes2 },
});

const pipelinePlus3 = device.createComputePipeline({
  label: "plus 3 compute pipeline",
  layout: pipelineLayout,
  compute: { module: modulePlus3 },
});

使用同一个绑定组

const bindGroup = device.createBindGroup({
  label: "bindGroup for work buffer",
  layout: bindGroupLayout, // 注意直接使用共享布局
  entries: [{ binding: 0, resource: workBuffer }],
});

const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipelineTimes2);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(input.length);
pass.setPipeline(pipelinePlus3); // 切换管线,绑定组不变
pass.dispatchWorkgroups(input.length);
pass.end();

这样就用一个绑定组完成了两次不同计算。


绑定组布局注意事项

1. 每个条目必须声明 binding 索引

{ binding: 0, ... }

2. 每个条目必须声明可见的着色器阶段

visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.VERTEX;
// 或
visibility: GPUShaderStage.COMPUTE |
  GPUShaderStage.FRAGMENT |
  GPUShaderStage.VERTEX;

3. 默认值

  • texture 绑定的默认值:
    { sampleType: 'float', viewDimension: '2d', multisampled: false }
    
  • sampler 绑定的默认值:
    {
      type: "filtering";
    }
    

因此常见用法可以简写为:

const bindGroupLayout = device.createBindGroupLayout({
  entries: [
    { binding: 0, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
    { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
  ],
});

4. 尽可能声明 minBindingSize

当绑定的是 uniform buffer(或已知大小的 storage buffer)时,建议设置 minBindingSize,例如:

buffer: {
  type: 'uniform',
  minBindingSize: 32,  // 对应 uniform struct 的大小
}

这样 WebGPU 会在 createBindGroup 时检查缓冲区大小是否足够,而不是在每次 draw/dispatch 时检查,从而提升性能。对于声明为数组的 storage buffer,如果允许不同大小,可以不设置。


三个核心优化要点

  1. 显式声明绑定组布局以匹配纹理采样能力
    使用 rgba32float 等不可过滤纹理时,必须手动创建 GPUBindGroupLayout,将采样器设为 'non-filtering'、纹理设为 'unfilterable-float'。这避免了自动布局的默认假设,减少运行时错误,并确保管线能在目标设备上正确创建。

  2. 通过 minBindingSize 提前验证缓冲区大小
    在缓冲绑定中声明 minBindingSize,使 WebGPU 在创建绑定组时完成大小检查,而不是在每次绘制/调度时重复检查。这显著减少了 draw call 的 CPU 开销,尤其适用于大量绘制调用。

  3. 复用绑定组布局与绑定组,减少资源创建与切换成本
    手动创建 GPUBindGroupLayout 和 GPUPipelineLayout,使多个管线共享同一绑定组。这避免了为每个管线重复创建类似的绑定组,简化代码并提升内存效率。同时在设计绑定组结构时,将全局、材质、局部数据分层,可进一步提高渲染循环的清晰度和性能。


GLSL / WebGL 专业术语检查

本文涉及的技术为 WebGPU,使用的是 WGSL(WebGPU Shading Language),而非 WebGL/GLSL。但读者若来自 WebGL 背景,需注意以下术语对照:

英文术语中文推荐备注
Bind Group Layout绑定组布局WebGPU 独有概念,类似 WebGL 中的 uniform block / texture unit 组织方式,但更显式
GPUBindGroupLayoutGPU 绑定组布局对象API 接口名保留英文
GPUPipelineLayoutGPU 管线布局对象同上
GPUShaderStageGPU 着色器阶段对应 VERTEX / FRAGMENT / COMPUTE
Storage Buffer存储缓冲区类似于 GLSL 中 SSBO(Shader Storage Buffer Object)
Uniform Buffer统一缓冲区对应 WebGL 的 UBO
Sampler采样器对应 GLSL 的 sampler2D 等,但 WebGPU 中采样器是独立资源
Filterable / Unfilterable可过滤 / 不可过滤指纹理是否支持线性过滤
texture_2d<f32>2D 浮点纹理WGSL 类型,不同于 GLSL 的 sampler2D
rgba32floatRGBA 32 位浮点纹理格式对应 GLSL 中 GL_RGBA32F
Dynamic Offsets动态偏移WebGPU 特有,用于在 setBindGroup 时调整缓冲区偏移
minBindingSize最小绑定大小用于提前校验缓冲区大小

重要提醒:WebGPU 使用 WGSL,GLSL 代码不能直接用于 WebGPU。迁移时需将 uniform、in/out、sampler2D 等语法改写为 WGSL 的 @group/@binding、var<uniform>、texture_2d 等。


本文基于 webgpufundamentals.org 的同名教程深度编译,省略了部分导航与交互内容,保留了核心概念与代码示例。如需查看完整示例和交互演示,请访问原始链接。