WEBGpu最佳实践之BindGroup

发布时间 2023-10-10 14:24:33作者: 千藤

介绍

在WebGPU中,资源通过GPUBindGroup结构传递给着色器,与支持它的布局定义(GPUBindGroupLayout和GPUPipelineLayout)以及着色器中绑定组的声明一起。这三者——着色器、布局和绑定组——都需要相互兼容,通常在每个阶段都需要重复信息。因此,这个API的这一方面乍一看可能感觉不必要地复杂,让人在使用时感到沮丧。

这份文档专注于解释为什么绑定组接口被设计成这样,以及如何最好地利用它。

绑定组使用概述

首先,让我们简要介绍一下绑定组的使用,以及每个阶段之间的关系。如果你熟悉WebGL,绑定组是取代通过gl.uniform*()调用设置WebGL uniforms的机制,尽管它们更接近于WebGL 2.0的Uniform Buffer Objects。在所有情况下,你都是获取应用程序提供的一些数据(缓冲区中的值、纹理或采样器),并使它们可以被即将运行的着色器访问。

为了解释这是如何工作的,让我们看一下如何将一些常见的渲染数据暴露给WebGPU着色器:摄像机值、模型变换、基础颜色纹理和采样器。

着色器和绑定组布局

首先,我们需要在WGSL着色器代码中声明这些值,可能看起来像这样:

 1 // vertexModuleA source:
 2 
 3 // Declaration of bind groups used by the vertex shader
 4 struct Camera {
 5   projection : matrix4x4f,
 6   view : matrix4x4f,
 7   position: vec3f,
 8 };
 9 @group(0) @binding(0) var<uniform> camera : Camera;
10 
11 @group(0) @binding(1) var<uniform> model : matrix4x4f;
12 
13 // The remainder of this shader doesn't affect the bind groups.
14 struct VertexOutput {
15   @builtin(position) position : vec4f,
16   @location(0) texcoord : vec2f,
17 };
18 
19 @vertex fn vertexMain(
20     @location(0) position : vec3f,
21     @location(1) texcoord : vec2f) -> VertexOutput {
22   var output : VertexOutput;
23   output.position = camera.projection * camera.view * model * vec4f(position, 1);
24   output.texcoord = texcoord;
25   return output;
26 }
27 // fragmentModuleA source:
28 
29 // Declaration of bind groups used by the fragment shader
30 @group(0) @binding(2) var baseColor : texture_2d<f32>;
31 @group(0) @binding(3) var baseColorSampler : sampler;
32 
33 // The remainder of this shader doesn't affect the bind groups.
34 @fragment fn fragmentMain(
35     @location(0) texcoord : vec2f) -> @location(0) vec4f {
36   return textureSample(baseColor, baseColorSampler, texcoord);
37 }

这里需要注意的几点:

应用程序暴露的每个值都被赋予了@group@binding数。多个绑定可以共享相同的@group,并且在给定的@group内必须具有唯一(但不一定是连续的)@binding值。对于来自应用程序提供的缓冲区的uniforms,类型可以是结构体(例如摄像机uniform)或单个值(例如模型uniform)。

在相关的应用程序代码中,需要定义一个GPUBindGroupLayout,其中包含来自着色器中特定@group的每个@binding的条目。对于上述着色器片段中的@group(0),这样的布局会如下所示:

const bindGroupLayout = gpuDevice.createBindGroupLayout({
  entries: [{
    binding: 0, // camera uniforms
    visibility: GPUShaderStage.VERTEX,
    buffer: {},
  }, {
    binding: 1, // model uniform
    visibility: GPUShaderStage.VERTEX,
    buffer: {},
  }, {
    binding: 2, // baseColor texture
    visibility: GPUShaderStage.FRAGMENT,
    texture: {},
  }, {
    binding: 3, // baseColor sampler
    visibility: GPUShaderStage.FRAGMENT,
    sampler: {},
  }]
});

需要注意的是,每个条目都有一个显式的绑定,与着色器中的@binding值相匹配。它们还说明它们可见的着色器阶段(可以是多个阶段)。最后,它指示绑定类型,比如缓冲区、纹理或采样器。这些类型还有选项,可以进一步指定不同类型的绑定,比如缓冲区类型是 'storage' 而不是 'uniform'。但是,如果默认值适用,至少必须设置一个空字典来表示类型,就像上面所示。

 

管线和管线布局

在创建渲染或计算管线时,通过GPUPipelineLayout传递绑定组布局。管线布局是管线使用的GPUBindGroupLayouts的列表,按@group索引排序。由于这些着色器仅使用一个组,我们只需要一个条目:

const pipelineLayout = gpuDevice.createPipelineLayout({
  bindGroupLayouts: [
    bindGroupLayout, // @group(0)
  ]
});

const pipelineA = gpuDevice.createRenderPipeline({
  layout: pipelineLayout,
  // Most render pipeline values omitted for simplicity.
  vertex: {
    module: vertexModuleA,
    entryPoint: 'vertexMain'
  },
  fragment: {
    module: fragmentModuleA,
    entryPoint: 'fragmentMain'
  }
});

如果着色器中的任何@group没有在管线布局中找到匹配的条目,或者着色器中的任何@binding没有在相应绑定组布局的@group中找到匹配的条目,管线的创建将以错误失败。

 

资源和绑定组

接下来,可以创建一个GPUBindGroup,它指向将要暴露给着色器的实际资源。首先确保将要暴露给着色器的资源已经使用适当的大小和用途创建好:

const cameraBuffer = gpuDevice.createBuffer({
  size: 144, // Room for two 4x4 matrices and a vec3
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.UNIFORM
});

const modelBuffer = gpuDevice.createBuffer({
  size: 64, // Room for one 4x4 matrix
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.UNIFORM
});

const baseColorTexture = gpuDevice.createTexture({
  size: { width: 256, height: 256 }
  usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
  format: 'rgba8unorm',
});

const baseColorSampler = gpuDevice.createSampler({
  magFilter: "linear",
  minFilter: "linear",
  mipmapFilter: "linear",
  addressModeU: "repeat",
  addressModeV: "repeat",
});

然后创建一个绑定组,它指向这些资源,使用在渲染管线创建过程中提供给管线布局的相同GPUBindGroupLayout

const bindGroup = gpuDevice.createBindGroup({
  layout: bindGroupLayout,
  entries: [{
    binding: 0,
    resource: { buffer: cameraBuffer },
  }, {
    binding: 1,
    resource: { buffer: modelBuffer },
  }, {
    binding: 2,
    resource: baseColorTexture.createView(),
  }, {
    binding: 3,
    resource: baseColorSampler,
  }],
});

再次注意,每个条目都有一个显式的绑定,与着色器和绑定组布局中的@binding值相匹配。如果需要,缓冲区绑定在绑定组创建时还可以指定显式的偏移和大小。从这一点开始,绑定组指向的资源不能被更改,但是资源本身的内容仍然可以更新,这些更改将在着色器中反映出来。换句话说,绑定组不会创建资源的快照。

 

设置绑定组和管线

最终,在记录命令缓冲时,应在调用渲染或计算通道的 draw*() 或 dispatch*() 方法之前更新缓冲区的数据、设置绑定组和管线:

// Place the most recent camera values in an array at the appropriate offsets.
const cameraArray = new Float32Array(36);
cameraArray.set(projectionMatrix, 0);
cameraArray.set(viewMatrix, 16);
cameraArray.set(cameraPosition, 32);

// Update the camera uniform buffer
device.queue.writeBuffer(cameraBuffer, 0, cameraArray);

// Update the model uniform buffer
device.queue.writeBuffer(modelBuffer, 0, modelMatrix);

// Record and submit the render commands for our scene.
const commandEncoder = device.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({ /* Ommitted for simplicity */ });

passEncoder.setPipeline(pipelineA);
passEncoder.setBindGroup(0, bindGroup); // @group(0)
passEncoder.draw(128);

passEncoder.end();
device.queue.submit([commandEncoder.finish()]);

setBindGroup() 必须对着色器/管线布局中存在的每个 @group 调用一次。如果未设置绑定组或给定的绑定组不使用管线布局中对应的绑定组布局,那么调用 draw()dispatchWorkgroups() 将失败。然而,假设一切兼容,着色器将使用当前设置的绑定组中的资源执行。

 

自动布局生成

如果你熟悉WebGL中的uniform流程,上述步骤可能会显得非常啰嗦。此外,你可能会注意到涉及相当多的重复工作,这增加了在一个地方更新代码但忘记在另一个地方更新的可能性,从而导致错误。

有一种机制可以简化至少部分流程,但使用时需要谨慎:layout: 'auto'

在创建渲染或计算管线时,你可以选择传入 'auto' 关键字而不是显式的GPUPipelineLayout,此时管线将根据在着色器中声明的绑定自动生成其内部管线布局。然后,在创建要与该管线一起使用的绑定组时,可以使用 getBindGroupLayout(index) 从管线中查询自动生成的布局。

const autoPipelineA = gpuDevice.createRenderPipeline({
  layout: 'auto',
  // Most render pipeline values omitted for simplicity.
  vertex: {
    module: vertexModuleA,
    entryPoint: 'vertexMain'
  },
  fragment: {
    module: fragmentModuleA,
    entryPoint: 'fragmentMain'
  }
});

const autoBindGroupA = gpuDevice.createBindGroup({
  layout: autoPipelineA.getBindGroupLayout(0), // @group(0)
  entries: [{
    binding: 0,
    resource: { buffer: cameraBuffer },
  }, {
    binding: 1,
    resource: { buffer: modelBuffer },
  }, {
    binding: 2,
    resource: baseColorTexture.createView(),
  }, {
    binding: 3,
    resource: baseColorSampler,
  }],
});

这消除了手动创建绑定组布局或管线布局的需求,这使其看起来成为一个吸引人的选项。

然而,使用自动生成的布局有一个重要的注意事项。它生成的绑定组布局(以及使用它们创建的绑定组)只能与生成该布局的管线一起使用。

为了了解为什么这是个问题,考虑这样一个情景:我们有上面着色器的一个版本,交换纹理的红色和蓝色通道。

// fragmentModuleB source
@group(0) @binding(2) var baseColor : texture_2d<f32>;
@group(0) @binding(3) var baseColorSampler : sampler;

@fragment fn fragmentMain(
    @location(0) texcoord : vec2f) -> @location(0) vec4f {
  return textureSample(baseColor, baseColorSampler, texcoord).bgra;
}

如果我们在创建使用这个着色器的管线时使用了layout: 'auto',那么我们还必须使用自动生成的布局创建一个新的绑定组,该绑定组只能与特定的管线兼容:

const autoPipelineB = gpuDevice.createRenderPipeline({
  layout: 'auto',
  // Most render pipeline values omitted for simplicity.
  vertex: {
    module: vertexModuleA,
    entryPoint: 'vertexMain'
  },
  fragment: {
    // Note that we're using the second fragment shader with the first vertex shader
    module: fragmentModuleB,
    entryPoint: 'fragmentMain'
  }
});

const autoBindGroupB = gpuDevice.createBindGroup({
  layout: autoPipelineB.getBindGroupLayout(0), // @group(0)
  entries: [{
    binding: 0,
    resource: { buffer: cameraBuffer },
  }, {
    binding: 1,
    resource: { buffer: modelBuffer },
  }, {
    binding: 2,
    resource: baseColorTexture.createView(),
  }, {
    binding: 3,
    resource: baseColorSampler,
  }],
});

正如你所看到的,这个绑定组与我们为第一个管线创建的绑定组相同,除了布局之外,所以我们只是在重复工作。这种重复工作在记录渲染通道时也是可见的:

const commandEncoder = gpuDevice.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({ /* Ommitted for simplicity */ });

passEncoder.setVertexBuffer(0, vertexBuffer);

passEncoder.setPipeline(autoPipelineA);
passEncoder.setBindGroup(0, autoBindGroupA); // @group(0)
passEncoder.draw(128);

passEncoder.setPipeline(autoPipelineB);
passEncoder.setBindGroup(0, autoBindGroupB); // @group(0)
passEncoder.draw(128);

passEncoder.end();
device.queue.submit([commandEncoder.finish()]);

现在我们调用了两次setBindGroup(),传递的数据实际上是相同的,底层实现不太可能对其进行优化。在渲染/计算通道中更改任何状态都会带来性能成本,而为了最终得到实际上相同的状态而支付这个成本是不可取的。

 

难以预测

另一个考虑因素是,当使用layout: 'auto'时,生成的绑定组布局可能并不总是符合你的预期。考虑以下计算着色器:

// computeModuleA source:
struct GlobalState {
  timeDelta : f32,
  gravity : vec3f
}
@group(0) @binding(0) var<uniform> globalState : GlobalState;

struct Particle {
  pos : vec2f,
  vel : vec2f,
}
@group(0) @binding(1) var<storage, read> particlesIn : array<Particle>;
@group(0) @binding(2) var<storage, read_write> particlesOut : array<Particle>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) GlobalInvocationID : vec3<u32>) {
  let index : u32 = GlobalInvocationID.x;

  let vPos = particlesIn[index].pos;
  let vVel = particlesIn[index].vel;

  particlesOut[index].pos = vPos + vVel;
  particlesOut[index].vel = vVel + vec3f(0, 0, -9.8);
}

通过快速浏览上面的着色器代码,你可能会认为这将是适用于该管线的正确绑定组:

 
const computePipelineA = device.createComputePipeline({
  layout: 'auto',
  compute: {
    module: computeModuleA,
    entryPoint: 'computeMain',
  }
});

const computeBindGroupA = gpuDevice.createBindGroup({
  layout: computePipelineA.getBindGroupLayout(0), // @group(0)
  entries: [{
    binding: 0,
    resource: { buffer: globalStateBuffer },
  }, {
    binding: 1,
    resource: { buffer: particleInputBuffer },
  }, {
    binding: 2,
    resource: { buffer: particleOutputBuffer },
  }],
});

但这将导致错误!为什么呢?事实证明,globalState uniform 在着色器主体中从未被静态使用,因此在管线创建过程中被自动布局创建忽略了。

在这种情况下,这很可能代表着着色器中的一个错误,该着色器可能想在更新粒子时同时使用 timeDeltagravity 变量,因此修复起来相对容易。但这种情况也可能是由于在调试时注释掉了 uniform 的使用,此时之前正常工作的绑定组突然开始失败。

使用显式的绑定组布局通过不强制你详细了解WebGPU内部着色器处理的工作原理来回避这些问题。

 

谨慎使用

由于上述考虑,有效使用layout: 'auto'的情况可能仅限于具有唯一资源需求的管线。例如,一些计算着色器或后处理渲染通道可能使用了应用程序中其他管线不需要的资源组合,因此让它自动生成布局可能是一个合理的选择。

然而,每当有多个管线需要相同的数据,或者至少需要相同结构的数据时,你应该始终首选使用显式定义的管线布局。

绑定组的重用

显式的管线布局允许在不同管线之间重复使用绑定组,这对于提高效率可能是一个巨大的优势。

回顾一下shaderModuleB的着色器代码,我们可以看到它在相同的位置使用了与shaderModuleA相同的绑定,这意味着它们都可以使用相同的绑定组布局(在这种情况下还包括管线布局):

const pipelineB = gpuDevice.createRenderPipeline({
  layout: pipelineLayout,
  // Most render pipeline values omitted for simplicity.
  vertex: {
    module: vertexModuleA,
    entryPoint: 'vertexMain'
  },
  fragment: {
    module: fragmentModuleB,
    entryPoint: 'fragmentMain'
  }
});

现在,在记录渲染命令时,我们只需要一次设置绑定组,两个管线都可以使用它。

const commandEncoder = gpuDevice.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({ /* Ommitted for simplicity */ });

passEncoder.setVertexBuffer(0, vertexBuffer);

passEncoder.setBindGroup(0, bindGroup); // @group(0)

passEncoder.setPipeline(pipelineA);
passEncoder.draw(128);

passEncoder.setPipeline(pipelineB);
passEncoder.draw(128);

passEncoder.end();
device.queue.submit([commandEncoder.finish()]);

 

绑定组子集重用

这种模式也适用于管线使用的资源仅是绑定组资源的子集的情景!例如,假设我们有我们的片段着色器的另一个变体,以纯色渲染网格,因此不需要纹理或采样器:

// fragmentModuleC source
@fragment fn fragmentMain(
    @location(0) texcoord : vec2f) -> @location(0) vec4f {
  return vec4<32f>(1.0, 0.0, 1.0, 1.0);
}

即使使用了这个着色器的管线没有使用其中的每个绑定,它仍然可以使用与之前相同的绑定组布局:

const pipelineC = gpuDevice.createRenderPipeline({
  layout: pipelineLayout,
  // Most render pipeline values omitted for simplicity.
  vertex: {
    module: vertexModuleA,
    entryPoint: 'vertexMain'
  },
  fragment: {
    module: fragmentModuleC,
    entryPoint: 'fragmentMain'
  }
});

const commandEncoder = gpuDevice.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({ /* Ommitted for simplicity */ });

passEncoder.setVertexBuffer(0, vertexBuffer);

passEncoder.setBindGroup(0, bindGroup); // @group(0)

passEncoder.setPipeline(pipelineA);
passEncoder.draw(128);

passEncoder.setPipeline(pipelineB);
passEncoder.draw(128);

passEncoder.setPipeline(pipelineC);
passEncoder.draw(128);

passEncoder.end();
device.queue.submit([commandEncoder.finish()]);

在这种情况下,pipelineC 简单地忽略了已绑定的纹理和采样器,而其他两个管线则使用了它们。值得注意的是,即使管线不使用特定的资源,驱动程序仍然会执行使其对着色器可访问的工作,因此确保绑定组中的资源是必要的,并且至少被使用了一些共享相同布局的管线是个好主意。

根据变化频率分组资源

到目前为止,我们只处理了一个绑定组,但从上述代码中可以明显看出,WebGPU 的架构是为使用多个绑定组而设计的。通过在着色器中使用多个 @group() 索引,提供的资源可以分布在多个绑定组之间,每个绑定组都需要自己的绑定组布局。尽管这需要额外的工作来创建布局和绑定组,以及额外的绑定调用,那么将绑定组拆分成这样有什么好处呢?

首先,让我们考虑一个比上面简单示例更实际的渲染模式,以及我们正在公开的绑定组资源与之的关系。我们可以期望任何场景由多个网格组成,所有这些网格都将有自己的变换矩阵。此外,每个网格都将具有材质(在这里简化为纹理),该材质可能在多个其他网格之间共享。 (例如:砖块或混凝土材质可能会在多个地方使用。) 最后,绑定组中还有一些值,如摄像机 uniform,对于场景中的所有内容都将是相同的。

使用当前的结构,即使用单一的单块绑定组,为了表示这样的场景,我们需要为每个网格复制很多绑定组信息,因为它们都需要至少一个唯一的数据。在伪代码中,它看起来像这样:

const renderableMeshes = [];

function createSceneBindGroups(meshes) {
  for (const mesh of meshes) {
    mesh.bindGroup = gpuDevice.createBindGroup({
      layout: bindGroupLayout,
      entries: [{
        binding: 0,
        resource: { buffer: cameraBuffer },
      }, {
        binding: 1,
        resource: { buffer: mesh.modelMatrixBuffer },
      }, {
        binding: 2,
        resource: mesh.material.baseColorTexture.createView(),
      }, {
        binding: 3,
        resource: mesh.material.baseColorSampler,
      }],
    });

    renderableMeshes.push(mesh);
  }
}

function renderScene(passEncoder) {
  // Assume all meshes can use the same pipeline, for simplicity
  passEncoder.setPipeline(pipelineA);

  for (mesh of renderableMeshes) {
    passEncoder.setBindGroup(0, mesh.bindGroup);
    passEncoder.setVertexBuffer(0, mesh.vertexBuffer);
    passEncoder.draw(mesh.drawCount);
  }
}

虽然这将正确绘制网格,但这并不是最有效的方法,因为有很多重复设置相同状态的操作。再次强调,即使在调用 setBindGroup() 之间,绑定组资源的子集保持不变,你也不应该指望实现/驱动程序为你进行优化。即使在一个平台上由驱动程序处理了这个问题,也可能在其他平台上没有处理。

那么解决方案是什么呢?我们可以根据它们需要更改的频率将资源分组。摄像机 uniform 在整个渲染通道中都不会更改,因此它们可以放在自己的绑定组中。材质属性会半频繁更改,但并非每个网格都需要更改,因此它们可以放在单独的绑定组中。最后,模型矩阵对于每个绘制调用都是不同的,因此它属于另一个绑定组。

这导致了一个更新的着色器,看起来像这样。请仔细注意 @group@binding 索引的变化:

// shaderModuleD source:

struct Camera {
  projection : matrix4x4f,
  view : matrix4x4f,
  position: vec3f,
};
@group(0) @binding(0) var<uniform> camera : Camera;

@group(1) @binding(0) var baseColor : texture_2d<f32>;
@group(1) @binding(1) var baseColorSampler : sampler;

@group(2) @binding(0) var<uniform> model : matrix4x4f;

// The remainder of this shader doesn't affect the bind groups.
struct VertexOutput {
  @builtin(position) position : vec4f,
  @location(0) texcoord : vec2f,
};

@vertex fn vertexMain(
    @location(0) position : vec3f,
    @location(1) texcoord : vec2f) -> VertexOutput {
  var output : VertexOutput;
  output.position = camera.projection * camera.view * model * vec4f(position, 1);
  output.texcoord = texcoord;
  return output;
}

// The remainder of this shader doesn't affect the bind groups.
@fragment fn fragmentMain(
    @location(0) texcoord : vec2f) -> @location(0) vec4f {
  return textureSample(baseColor, baseColorSampler, texcoord);
}

相应的绑定组布局和管线布局也需要更新,以适应资源的新排列:

const cameraBindGroupLayout = gpuDevice.createBindGroupLayout({
  entries: [{
    binding: 0, // camera uniforms
    visibility: GPUShaderStage.VERTEX,
    buffer: {},
  }]
});
const materialBindGroupLayout = gpuDevice.createBindGroupLayout({
  entries: [{
    binding: 0, // baseColor texture
    visibility: GPUShaderStage.FRAGMENT,
    texture: {},
  }, {
    binding: 1, // baseColor sampler
    visibility: GPUShaderStage.FRAGMENT,
    sampler: {},
  }]
});
const meshBindGroupLayout = gpuDevice.createBindGroupLayout({
  entries: [{
    binding: 0, // model uniform
    visibility: GPUShaderStage.VERTEX,
    buffer: {},
  }]
});

const pipelineDLayout = gpuDevice.createPipelineLayout({
  bindGroupLayouts: [
    cameraBindGroupLayout, // @group(0)
    materialBindGroupLayout, // @group(1)
    meshBindGroupLayout, // @group(2)
  ]
});

const pipelineD = gpuDevice.createRenderPipeline({
  layout: pipelineDLayout,
  // Most render pipeline values omitted for simplicity.
  vertex: {
    module: shaderModuleD,
    entryPoint: 'vertexMain'
  },
  fragment: {
    module: shaderModuleD,
    entryPoint: 'fragmentMain'
  }
});

最后,绑定组本身也需要根据新的分组创建。这次,我们还将以减少重复为目标进行创建。此外,我们将借此机会以一种减少在渲染循环期间需要进行的绑定组设置的方式对网格进行排序:

const cameraBindGroup;
const renderableMaterials = new Map();

function createSceneBindGroups(meshes) {
  // Create a single bind group for the camera uniforms
  cameraBindGroup = gpuDevice.createBindGroup({
    layout: cameraBindGroupLayout,
    entries: [{
      binding: 0,
      resource: { buffer: cameraBuffer },
    }],
  });

  for (const mesh of meshes) {
    // Find or create a renderableMaterials entry for the mesh's material.
    // renderableMaterials will contain the bind group and associated meshes for
    // each material.
    let renderableMaterial = renderableMaterials.get(mesh.material);
    if (!renderableMaterial) {
      const materialBindGroup = gpuDevice.createBindGroup({
        layout: materialBindGroupLayout,
        entries: [{
          binding: 0,
          resource: mesh.material.baseColorTexture.createView(),
        }, {
          binding: 1,
          resource: mesh.material.baseColorSampler,
        }],
      });
      renderableMaterial = {
        meshes: [],
        bindGroup: materialBindGroup
      };
      renderableMaterials.set(mesh.material, renderableMaterial);
    }

    // Store meshes grouped by the material that they use.
    renderableMaterial.meshes.push(mesh);

    // Create a bind group for the mesh's transform
    mesh.bindGroup = gpuDevice.createBindGroup({
      layout: pipelineLayout,
      entries: [{
        binding: 0,
        resource: { buffer: mesh.modelMatrixBuffer },
      }],
    });
  }
}

function renderScene(passEncoder) {
  // Assume all meshes can use the same pipeline, for simplicity
  passEncoder.setPipeline(pipelineA);

  // Set the camera bind group once, since it applies to all meshes
  passEncoder.setBindGroup(0, cameraBindGroup); // @group(0)

  // Loop through all the materials and set the material bind group once for each of them.
  for (const material of renderableMaterials.values()) {
    passEncoder.setBindGroup(1, material.bindGroup); // @group(1)

    // Loop through each mesh using the current material, bind it's information, and draw.
    for (const mesh of material.meshes) {
      passEncoder.setBindGroup(2, mesh.bindGroup); // @group(2)
      passEncoder.setVertexBuffer(0, mesh.vertexBuffer);
      passEncoder.draw(mesh.drawCount);
    }
  }
}

你可以看到上面的代码建立了一种状态变化的层次结构,变化最不频繁的值被设置在函数的最上面,然后逐渐进入更紧凑的循环,每个循环代表更频繁变化的值。在广泛的层面上,这是在WebGPU中执行任何渲染或计算操作时要努力实现的模式:尽量少地设置状态,并根据其变化的频率将状态拆分。

请注意,许多WebGPU实现一次可能仅支持4个绑定组(检查GPUAdapter上的limits.maxBindGroups以确定系统的实际限制,并在调用requestDevice()时传递所需的计数以提高在你自己的应用程序中的限制。)这对于大多数情况来说应该是足够的。将绑定组分离为“每帧/每材质/每绘制”状态,如上所示,是相当常见的。

组索引很重要

WebGPU API 在技术上对于绑定组的声明顺序和 setBindGroup() 调用的顺序是不关心的。在 @group(2) 放置相机绑定组,而在 @group(0) 放置模型绑定组可以正常工作。然而,底层本机API可能对于声明和设置组的顺序有性能上的偏好。因此,为了确保在各方面都获得最佳性能,你应该更喜欢在 @group(0) 中包含在 draw/dispatch 调用之间变化最不频繁的值,并且每个后续的 @group 索引都包含以逐渐更高频率变化的数据。

重用管线布局

在某些情况下,你可能会发现你有一个管线不使用绑定组,但在其他管线中可以与应用程序中的其他管线共享绑定组状态。

例如,让我们再次看一下上面没有使用纹理和采样器的着色器,现在我们将绑定组拆分开来。你可以看到如果删除未使用的 @bindings,我们会在组索引中留下一个间隙:

// shaderModuleE source:

struct Camera {
  projection : matrix4x4f,
  view : matrix4x4f,
  position: vec3f,
};
@group(0) @binding(0) var<uniform> camera : Camera;

@group(2) @binding(0) var<uniform> model : matrix4x4f;

struct VertexOutput {
  @builtin(position) position : vec4f,
  @location(0) texcoord : vec2f,
};

@vertex fn vertexMain(
    @location(0) position : vec3f,
    @location(1) texcoord : vec2f) -> VertexOutput {
  var output : VertexOutput;
  output.position = camera.projection * camera.view * model * vec4f(position, 1);
  output.texcoord = texcoord;
  return output;
}

@fragment fn fragmentMain(
    @location(0) texcoord : vec2f) -> @location(0) vec4f {
  return vec4f(1, 0, 1, 1);
}

你可能会因为保持管线布局一致而想要在那里放入未使用的绑定,但其实没有必要这样做。你仍然可以使用与其他着色器相同的管线布局,即使它包含一个在这里没有引用的绑定组布局。

const pipelineE = gpuDevice.createRenderPipeline({
  layout: pipelineDLayout, // Re-using the same pipeline from above
  // Most render pipeline values omitted for simplicity.
  vertex: {
    module: shaderModuleE,
    entryPoint: 'vertexMain'
  },
  fragment: {
    module: shaderModuleE,
    entryPoint: 'fragmentMain'
  }
});

但请注意,在绘制时,你仍然需要为管线布局中的每个绑定组布局设置一个绑定组,无论着色器是否使用它。

const commandEncoder = gpuDevice.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({ /* Ommitted for simplicity */ });

passEncoder.setVertexBuffer(0, vertexBuffer);

passEncoder.setPipeline(pipelineE);

passEncoder.setBindGroup(0, cameraBindGroup);
passEncoder.setBindGroup(1, materialBindGroup); // Required even though it's unused!
passEncoder.setBindGroup(2, meshBindGroup);

passEncoder.draw(128);

passEncoder.end();
device.queue.submit([commandEncoder.finish()]);

尽情享受,创造出令人炫目的作品吧!

绑定组管理可能需要一些时间来适应,特别是如果你已经熟悉了WebGL 1.0 uniforms使用的更简单(但效率较低)的模式。然而,一旦你掌握了它,你会发现它让你更明确地控制着色器资源何时以及如何更新,从而减少了开销,使应用程序运行更快!

简要回顾一下:尽管它看起来像是额外的工作,但请记住,除了最简单的应用程序之外,明确设置自己的绑定组布局和管线布局通常是正确的选择,layout: 'auto' 应该保留用于与应用程序的其余部分共享很少或几乎没有状态的一次性管线。尽量让尽可能多的管线重复使用相同的绑定组布局,并小心根据更新频率拆分绑定组资源。

祝你在未来的项目中好运,我迫不及待地想看到Web社区创造出什么令人惊叹的作品!