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 Buffer | Storage 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)。在翻译时需注意校正。
核心优化要点提炼
基于原文内容和实践,提炼出三个使用存储缓冲区的核心优化要点:
-
利用存储缓冲区的大容量与可读写特性减少资源切换
存储缓冲区默认最大 128 MiB,远大于均匀缓冲区的 64 KiB,且支持read_write模式。对于需要大量实例数据、计算着色器中间结果或动态顶点数据的场景,使用存储缓冲区可以避免频繁的上传和绑定切换,提升渲染效率。 -
通过实例化(instancing)与运行时大小数组合并绘制调用
声明array<Struct>类型的存储缓冲区,配合@builtin(instance_index),可以在单个绘制调用中渲染大量具有不同属性的对象。这显著减少了 CPU 到 GPU 的命令开销,特别适合绘制大量相似物体(如粒子、植被、建筑等)。 -
审慎选择顶点数据的传递方式
虽然通过存储缓冲区传递顶点数据在灵活性和现代 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 | 阶段间变量 | 顶点着色器输出、片元着色器输入 |
通过本文的深度解读,读者应当能够理解存储缓冲区的核心概念、与均匀缓冲区的区别,以及它们在实例化和顶点数据传递中的实际应用。原文提供了一系列可交互示例,建议结合链接中的在线编辑器进行实验,以加深理解。
