WebGPU Storage Buffers(存储缓冲区)

原文作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-storage-buffers.html

本文是对 WebGPU 官方基础教程中《Storage Buffers》一文的深度解读。原文循序渐进地介绍了如何通过存储缓冲区(Storage Buffers)向着色器传递数据。我将提炼核心知识、保留关键代码,并指出三个核心优化要点,同时检查相关 WebGPU/WGSL 术语,帮助读者准确理解这一重要概念。


1. 存储缓冲区与均匀缓冲区的异同

在 WebGPU 中,uniform buffer(均匀缓冲区) 和 storage buffer(存储缓冲区) 都用于向 GPU 的着色器传递数据,但两者有显著区别。原文首先演示了一个简单的事实:如果我们仅仅把 JavaScript 中的 GPUBufferUsage.UNIFORM 改成 GPUBufferUsage.STORAGE,并把 WGSL 中的 var<uniform> 改为 var<storage, read>,那么之前的示例依然能正常工作。下面这段代码展示了最小改动:

const staticUniformBuffer = device.createBuffer({
  label: `static uniforms for obj: ${i}`,
  size: staticUniformBufferSize,
-  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});

const uniformBuffer = device.createBuffer({
  label: `changing uniforms for obj: ${i}`,
  size: uniformBufferSize,
-  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});

相应的 WGSL 声明:

-@group(0) @binding(0) `var<uniform>` ourStruct: OurStruct;
-@group(0) @binding(1) `var<uniform>` otherStruct: OtherStruct;
+@group(0) @binding(0) `var<storage, read>` ourStruct: OurStruct;
+@group(0) @binding(1) `var<storage, read>` otherStruct: OtherStruct;

这说明两者在基本用法上很相似,但实际能力和适用场景不同。原文总结了三点主要差异:

特性Uniform BufferStorage Buffer
默认最大尺寸64 KiB(65536 字节)128 MiB(134217728 字节)
读写权限只读可读可写(可声明 read 或 read_write)
典型用途每帧变化的少量数据(如变换矩阵、材质参数)大数据量、实例化数据、计算着色器输出

术语检查:

  • “uniform buffer” 标准译法为“均匀缓冲区”,不译为“一致缓冲区”。
  • “storage buffer” 译为“存储缓冲区”。
  • GPUBufferUsage.STORAGE 中的 STORAGE 保留英文,表示存储用途。
  • var<storage, read> 中的 read 表示只读访问,read_write 为可读写。

2. 使用存储缓冲区实现实例化绘制

存储缓冲区的一个重要优势是可以声明为 runtime-sized array(运行时大小数组),这意味着缓冲区的大小可以在运行时确定,非常适合实例化(instancing)场景。原文将之前的示例修改为仅用一次绘制调用(draw call)绘制 100 个三角形,每个三角形具有不同的颜色、偏移和缩放。

首先,将 WGSL 中的结构体绑定修改为数组:

-@group(0) @binding(0) `var<storage, read>` ourStruct: OurStruct;
-@group(0) @binding(1) `var<storage, read>` otherStruct: OtherStruct;
+@group(0) @binding(0) `var<storage, read>` ourStructs: `array<OurStruct>`;
+@group(0) @binding(1) `var<storage, read>` otherStructs: `array<OtherStruct>`;

然后在顶点着色器中引入 @builtin(instance_index),利用实例索引从数组中取出对应的数据:

struct VSOutput {
  @builtin(position) position: vec4f,
  @location(0) color: vec4f,
};

@vertex fn vs(
  @builtin(vertex_index) vertexIndex : u32,
  @builtin(instance_index) instanceIndex: u32
) -> VSOutput {
  let pos = array(
    vec2f( 0.0,  0.5),  // top center
    vec2f(-0.5, -0.5),  // bottom left
    vec2f( 0.5, -0.5)   // bottom right
  );

  let otherStruct = otherStructs[instanceIndex];
  let ourStruct = ourStructs[instanceIndex];

  var vsOut: VSOutput;
  vsOut.position = vec4f(
      pos[vertexIndex] * otherStruct.scale + ourStruct.offset, 0.0, 1.0);
  vsOut.color = ourStruct.color;
  return vsOut;
}

@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
  return vsOut.color;
}

在 JavaScript 端,只需创建足够大的存储缓冲区,一次性写入所有实例的数据,然后调用 pass.draw(3, kNumObjects) 即可完成批量绘制。关键代码:

// 更新所有实例的缩放值
objectInfos.forEach(({scale}, ndx) => {
  const offset = ndx * (changingUnitSize / 4);
  storageValues.set([scale / aspect, scale], offset + kScaleOffset);
});
device.queue.writeBuffer(changingStorageBuffer, 0, storageValues);

pass.setBindGroup(0, bindGroup);
pass.draw(3, kNumObjects);  // 每个实例调用顶点着色器 3 次

术语检查:

  • @builtin(instance_index) 译为“内建变量:实例索引”,注意不要与 vertex_index(顶点索引)混淆。
  • inter-stage variable 译为“阶段间变量”,是顶点着色器与片元着色器之间传递数据的方式。
  • draw call 通用译为“绘制调用”,也可保留英文。

3. 将顶点数据放入存储缓冲区

存储缓冲区不仅可以传递每实例数据,还可以用来存储顶点数据本身。传统上顶点数据通过 vertex buffer(顶点缓冲区) 传递,但存储缓冲区提供更大的灵活性,尤其适合计算着色器生成顶点数据的场景。原文演示了如何生成一个圆形顶点数组,通过存储缓冲区传递,并利用 vertex_index 索引。

首先定义一个 Vertex 结构体并绑定:

struct Vertex {
  position: vec2f,
};

@group(0) @binding(2) `var<storage, read>` pos: `array<Vertex>`;

顶点着色器中通过 pos[vertexIndex].position 获取顶点位置:

@vertex fn vs(
  @builtin(vertex_index) vertexIndex : u32,
  @builtin(instance_index) instanceIndex: u32
) -> VSOutput {
  let otherStruct = otherStructs[instanceIndex];
  let ourStruct = ourStructs[instanceIndex];

  var vsOut: VSOutput;
  vsOut.position = vec4f(
      pos[vertexIndex].position * otherStruct.scale + ourStruct.offset, 0.0, 1.0);
  vsOut.color = ourStruct.color;
  return vsOut;
}

JavaScript 中,使用 createCircleVertices 函数生成顶点数据,并写入存储缓冲区:

const { vertexData, numVertices } = createCircleVertices({
  radius: 0.5,
  innerRadius: 0.25,
});
const vertexStorageBuffer = device.createBuffer({
  label: 'storage buffer vertices',
  size: vertexData.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(vertexStorageBuffer, 0, vertexData);

绑定组增加第三个条目:

const bindGroup = device.createBindGroup({
  label: 'bind group for objects',
  layout: pipeline.getBindGroupLayout(0),
  entries: [
    { binding: 0, resource: staticStorageBuffer },
    { binding: 1, resource: changingStorageBuffer },
    { binding: 2, resource: vertexStorageBuffer },
  ],
});

最终绘制时使用实际的顶点数量:

pass.draw(numVertices, kNumObjects);

术语检查:

  • vertex buffer 译为“顶点缓冲区”,是 WebGPU 中专门用于传递顶点数据的机制,与存储缓冲区在性能特性上有所不同。
  • bind group 译为“绑定组”,是将多个资源(缓冲区、纹理)绑定到着色器指定槽位的对象。
  • 原文中有一处笔误:“in our WSGL”,应为 “WGSL”(WebGPU Shading Language)。在翻译时需注意校正。

核心优化要点提炼

基于原文内容和实践,提炼出三个使用存储缓冲区的核心优化要点:

  1. 利用存储缓冲区的大容量与可读写特性减少资源切换
    存储缓冲区默认最大 128 MiB,远大于均匀缓冲区的 64 KiB,且支持 read_write 模式。对于需要大量实例数据、计算着色器中间结果或动态顶点数据的场景,使用存储缓冲区可以避免频繁的上传和绑定切换,提升渲染效率。

  2. 通过实例化(instancing)与运行时大小数组合并绘制调用
    声明 array<Struct> 类型的存储缓冲区,配合 @builtin(instance_index),可以在单个绘制调用中渲染大量具有不同属性的对象。这显著减少了 CPU 到 GPU 的命令开销,特别适合绘制大量相似物体(如粒子、植被、建筑等)。

  3. 审慎选择顶点数据的传递方式
    虽然通过存储缓冲区传递顶点数据在灵活性和现代 GPU 上表现良好,但根据原文提示,在某些老旧设备上,经典顶点缓冲区(vertex buffer)的固定功能读取路径可能更快。因此,在目标设备不确定或性能敏感的场景下,建议进行基准测试,权衡存储缓冲区与顶点缓冲区的使用。


术语检查总结

英文原文中文译法备注
Storage Buffer存储缓冲区可读写,默认最大 128 MiB
Uniform Buffer均匀缓冲区只读,默认最大 64 KiB
Bind Group绑定组将资源与着色器绑定槽关联
Vertex Shader顶点着色器处理顶点数据
Fragment Shader片元着色器处理像素颜色
Draw Call绘制调用GPU 执行一次绘制命令
Instance Index实例索引内建变量 @builtin(instance_index)
Vertex Index顶点索引内建变量 @builtin(vertex_index)
Runtime-sized Array运行时大小数组长度在创建绑定时确定,常见于存储缓冲区
Inter-stage Variable阶段间变量顶点着色器输出、片元着色器输入

通过本文的深度解读,读者应当能够理解存储缓冲区的核心概念、与均匀缓冲区的区别,以及它们在实例化和顶点数据传递中的实际应用。原文提供了一系列可交互示例,建议结合链接中的在线编辑器进行实验,以加深理解。