WebGPU中的缓冲区
摘要:本文深入介绍了 WebGPU 中三种核心缓冲区资源的使用方法:统一缓冲区(Uniform Buffer)、存储缓冲区(Storage Buffer)和索引缓冲区(Index Buffer)。统一缓冲区用于存储每帧不变的变换矩阵等数据,通过绑定组(Bind Group)与着色器关联;存储缓冲区容量更大,适合处理大量数据;索引缓冲区则通过复用顶点数据优化复杂几何体的渲染性能。文章详细讲解了每种缓冲区的创建、数据写入、绑定组配置及着色器访问方式,并提供了完整的代码示例。
在Part 1: WebGPU的因承和编程范式中,提供的三角形例子中的顶点着色器只是简单地返回了顶点缓冲区中的位置。顶点着色器其实可以执行更复杂的运算,比如标量、矢量和矩阵运算等。与线性变换(平移、旋转、缩放)有关的数据通常需要存储在统一缓冲区中,本篇就专门讲解统一缓冲区、存储缓冲区和索引缓冲区这三种缓冲区资源的用法。
统一缓冲区和绑定组
线性代数变换通常需要一份在同一次绘制中,对每个顶点、每个片元都保持不变(uniform)的矩阵数据。把这类数据存放在统一缓冲区(uniform buffer)比存放在顶点缓冲区更高效,因为它不需要随顶点重复。使用统一缓冲区需要以下 5 步:
例如,我们想在 3 维空间中把物体绕
z
z
z 轴旋转 30°,对应的变换矩阵(列主序存储)为:
const uniformData = new Float32Array([
0.866, 0.5, 0.0, 0.0, // 第一列
–0.5, 0.866, 0.0, 0.0, // 第二列
0.0, 0.0, 1.0, 0.0, // 第三列
0.0, 0.0, 0.0, 1.0, // 第四列
]);
第一步:创建统一缓冲区
创建方式与顶点缓冲区类似,只是 usage 需要包含 GPUBufferUsage.UNIFORM:
const uniformBuffer = device.createBuffer({
label: "Uniform Buffer 0",
size: uniformData.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});
因为后面要向其中写入数据,所以还必须加上 GPUBufferUsage.COPY_DST,否则 writeBuffer 调用会失败。
第二步:将数据写入缓冲区
device.queue.writeBuffer(uniformBuffer, 0, uniformData);
调用writeBuffer的参数为:
| buffer | GPUBuffer | 否 | 接收数据的目标缓冲区 |
| bufferOffset | Integer | 否 | 从缓冲区的第几个字节开始写入 |
| data | ArrayBuffer、TypedArray 或 DataView | 否 | 数据源 |
| dataOffset | Integer | 是 | 从数据源的第几个字节开始读取 |
| size | Integer | 是 | 从数据源写入的字节数 |
第三步:获取绑定组布局
绑定组(bind group)的目的是把多个资源打包成一个对象,从而减少 CPU 与 GPU 之间的数据交换次数。GPUBindGroup 把若干资源(统一缓冲区、存储缓冲区、纹理、采样器等)组合在一起,每个资源都关联一个整数编号,即绑定值(binding)。
一个渲染管线可以同时使用多个绑定组,每个绑定组又都有自己的索引(@group 值)。描述某个绑定组中资源结构的对象叫绑定组布局(bind group layout),而管线创建时的 layout 字段则必须指向一个管线布局(pipeline layout,本质上是若干绑定组布局的集合)。它们的关系如下图所示:

创建渲染管线时,如果把 layout 设为字符串 "auto",WebGPU 会分析着色器代码,自动为每个 @group 生成对应的绑定组布局,并组合成管线布局。此时可以通过渲染管线的 getBindGroupLayout(index) 方法拿到某个绑定组布局:
const bindGrouplayout = renderPipeline.getBindGroupLayout(0);
getBindGroupLayout 并不局限于 "auto" 布局才能调用——即便管线布局是手动创建的,这个方法同样会返回对应索引的绑定组布局。只是当资源结构比较复杂、或者需要在多个管线之间复用同一套绑定组布局时,手动创建 GPUBindGroupLayout 和 GPUPipelineLayout 会更清晰、更可控;否则用 "auto" 更省事。
第四步:创建绑定组
// 获取绑定组布局
const bindGroupLayout = renderPipeline.getBindGroupLayout(0);
// 创建绑定组
let bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [
{
binding: 0,
resource: { buffer: uniformBuffer }
}
]
});
createBindGroup 接收的描述符包含三个字段:
- label:可选,绑定组的调试名称;
- layout:必填,描述该绑定组资源结构的绑定组布局;
- entries:必填,由"资源——绑定值"组成的数组。
当条目对应的资源是缓冲区时,resource 需要是一个对象,包含:
- buffer:承载数据的 GPUBuffer;
- offset:可选,缓冲区内的字节偏移量;
- size:可选,绑定的数据范围大小(字节)。
创建好绑定组后,需要把它关联到渲染通道编码器上:
// 关联绑定组与渲染通道编码器
renderPass.setBindGroup(0, bindGroup);
setBindGroup 接受三个参数:着色器中用来访问该绑定组的索引(对应 @group(n))、GPUBindGroup 对象,以及一个可选的动态偏移量数组(用于同一绑定组在多次绘制间复用不同偏移量的场景)。
第五步:在着色器中访问Uniform数据
在着色器里,可以用绑定组索引和绑定值访问对应资源,例如访问前面存储的旋转矩阵:
// 声明访问统一缓冲区的变量
@group(0) @binding(0) var<uniform> rotMat: mat4x4f;
@group(n) 对应 setBindGroup 调用时传入的第一个参数,@binding(n) 对应 entries 数组中资源的 binding 值。本例只使用了一个绑定组和一个统一缓冲区,因此都是 0。
然后我们就可以在顶点着色器中应用这个矩阵对顶点做线性变换:
// 声明访问统一缓冲区的变量
@group(0) @binding(0) var<uniform> rotMat: mat4x4f;
@vertex
fn vs_main(@location(0) coords: vec2f) -> @builtin(position) vec4f {
return rotMat * vec4f(coords, 0.0, 1.0);
}
完整的三角形旋转例子
import { mat4 } from "https://wgpu-matrix.org/dist/2.x/wgpu-matrix.module.js";
// 新的着色器代码
const shaderCode = `
@group(0) @binding(0) var<uniform> rotMat: mat4x4f;
struct DataStruct {
@builtin(position) pos: vec4f,
@location(0) colors: vec3f,
}
@vertex
fn vs_main(@location(0) coords: vec2f, @location(1) colors: vec3f) -> DataStruct {
var outData: DataStruct;
outData.pos = rotMat * vec4f(coords, 0.0, 1.0);
outData.colors = colors;
return outData;
}
@fragment
fn fs_main(fragData: DataStruct) -> @location(0) vec4f {
return vec4f(fragData.colors, 1.0);
}
`;
let device;
let context;
let canvasFormat;
async initWebGPU() {…}
async function runExample() {
// 初始化 WebGPU, 获得适配器和设备对象,并配置上下文
await initWebGPU();
// 顶点属性数据(坐标 + 颜色)
const vertexData = new Float32Array([
0.0, 0.5, 0.0, 1.0, 0.0, // 第一个顶点
–0.5, –0.5, 1.0, 0.0, 0.0, // 第二个顶点
0.5, –0.5, 0.0, 0.0, 1.0, // 第三个顶点
]);
// 创建顶点缓冲区
const vertexBuffer = device.createBuffer({
label: "Example vertex buffer",
size: vertexData.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
// 将数据写入缓冲区
device.queue.writeBuffer(vertexBuffer, 0, vertexData);
// 定义缓冲数据布局
const bufferLayout = {
arrayStride: 20,
attributes: [
{ format: "float32x2", offset: 0, shaderLocation: 0 },
{ format: "float32x3", offset: 8, shaderLocation: 1 }
],
};
// 这里我们使用了wgpu-matrix包,而非像开头那样手动输入矩阵
const uniformData = mat4.rotationZ(30.0 * Math.PI / 180.0);
const uniformBuffer = device.createBuffer({
label: "Uniform Buffer 0",
size: uniformData.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(uniformBuffer, 0, uniformData);
// 创建渲染管线,必须在定义绑定组布局之前,因为我们需要使用管线获得绑定组布局
// const renderPipeline = device.createRenderPipeline({ … });
// 访问绑定组布局
const bindGroupLayout = renderPipeline.getBindGroupLayout(0);
// 创建绑定组
let bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [
{
binding: 0,
resource: { buffer: uniformBuffer }
}
]
});
// 关联绑定组与渲染通道编码器
renderPass.setVertexBuffer(0, vertexBuffer);
renderPass.setBindGroup(0, bindGroup);
// 绘制顶点完成渲染
// …
}
这里的变换矩阵没有手动输入,而是通过 wgpu-matrix 库的旋转函数生成,具体用法可参考 https://wgpu-matrix.org/。借助该库,还可以很方便地把多个变换合并成一个矩阵:
const transMat = mat4.translation([–2.0, –1.0, 2.0]);
const rotMat = mat4.rotationY(45 * Math.PI / 180);
const combinedMat = mat4.mul(transMat, rotMat);
如果着色器用 combinedMat 与顶点坐标相乘,图形会先旋转再平移——因为矩阵乘法满足结合律,combinedMat * vertex 等价于 transMat * (rotMat * vertex),也就是先对顶点应用 rotMat,再对结果应用 transMat。
存储缓冲区
统一缓冲区的一个主要限制是可存储的数据量较小,可以用以下代码查询当前设备支持的上限:
// 查看统一缓冲区能绑定的最大字节数
const maxUniformSize = adapter.limits.maxUniformBufferBindingSize;
如果需要传输更多数据,一种办法是拆分成多个统一缓冲区;另一种更常见的办法是改用存储缓冲区(storage buffer),它的容量上限通常远大于统一缓冲区(可以用 adapter.limits.maxStorageBufferBindingSize 查询)。
存储缓冲区的创建和使用方式与统一缓冲区基本相同,主要有三处不同:
// 默认只读
@group(0) @binding(0) var<storage> mat: mat4x4f;
存储缓冲区也可以支持读写,此时着色器和绑定组布局都要显式声明为可读写:
@group(0) @binding(0) var<storage, read_write> mat: mat4x4f;
对应的绑定组布局条目需要写成 buffer: { type: "storage" };如果着色器里只用 var<storage>(默认只读),绑定组布局里可以用性能更优的 "read-only-storage"。
存储缓冲区的短板是访问性能通常不如统一缓冲区。如果应用需要频繁、少量地更新数据(例如每帧更新的变换矩阵),优先使用统一缓冲区;只有当数据量超出统一缓冲区限制,或者需要在计算着色器中读写大量数据时,才使用存储缓冲区。
索引渲染与索引缓冲区
对于复杂几何体,同一个顶点常常会被多个三角形共用。如果按三角形展开、原样存入顶点缓冲区,同一份顶点数据就要被重复存储多次,浪费显存和带宽。索引渲染(indexed rendering)通过额外的索引缓冲区(index buffer)解决这个问题:顶点缓冲区里每个顶点只存一份,索引缓冲区里的整数序列则告诉渲染器该按什么顺序去访问这些顶点,从而组装出三角形。例如索引序列 [0, 1, 2, 3] 和 [0, 2, 3, 1] 会用同一组顶点组装出不同的图形。
需要注意的是,索引缓冲区只供渲染器内部使用,着色器代码无法直接访问它。
使用索引缓冲区一般分四步:
下面以绘制一个立方体为例。理想情况下,一个立方体只有 8 个不重复的顶点位置,索引渲染的典型收益就是只存这 8 份数据、用索引去复用它们。但如果像下面的例子一样,每个面要显示不同的颜色,那么同一个几何位置在不同面上就对应不同的顶点属性(颜色不同),无法再共用同一条顶点记录,于是仍需按面存储、总共 24 个顶点(6 个面 × 每面 4 个顶点)。此时索引缓冲区的作用不再是“省顶点”,而是:
- 把每个面的 4 个顶点重新排列成合法的三角形带(triangle-strip)顶点顺序;
- 利用图元重启(primitive restart)在不额外增加绘制调用的前提下,把 6 个独立的面拼接进同一次 drawIndexed 调用。
// 立方体的顶点数据:位置(x,y,z) + 颜色(r,g,b),按面存储,共 24 个顶点
const vertexData = new Float32Array([
// 左侧面
–1.0, –1.0, –1.0, 0.0, 0.0, 0.8,
–1.0, 1.0, –1.0, 0.0, 0.0, 0.8,
–1.0, 1.0, 1.0, 0.0, 0.0, 0.8,
–1.0, –1.0, 1.0, 0.0, 0.0, 0.8,
// 右侧面
1.0, –1.0, –1.0, 0.8, 0.8, 0.0,
1.0, 1.0, –1.0, 0.8, 0.8, 0.0,
1.0, 1.0, 1.0, 0.8, 0.8, 0.0,
1.0, –1.0, 1.0, 0.8, 0.8, 0.0,
// 底面
–1.0, –1.0, –1.0, 0.0, 0.8, 0.0,
1.0, –1.0, –1.0, 0.0, 0.8, 0.0,
1.0, –1.0, 1.0, 0.0, 0.8, 0.0,
–1.0, –1.0, 1.0, 0.0, 0.8, 0.0,
// 顶面
–1.0, 1.0, –1.0, 0.8, 0.0, 0.8,
1.0, 1.0, –1.0, 0.8, 0.0, 0.8,
1.0, 1.0, 1.0, 0.8, 0.0, 0.8,
–1.0, 1.0, 1.0, 0.8, 0.0, 0.8,
// 后面
–1.0, –1.0, 1.0, 0.8, 0.0, 0.0,
1.0, –1.0, 1.0, 0.8, 0.0, 0.0,
1.0, 1.0, 1.0, 0.8, 0.0, 0.0,
–1.0, 1.0, 1.0, 0.8, 0.0, 0.0,
// 前面
–1.0, –1.0, –1.0, 0.0, 0.8, 0.8,
1.0, –1.0, –1.0, 0.0, 0.8, 0.8,
1.0, 1.0, –1.0, 0.0, 0.8, 0.8,
–1.0, 1.0, –1.0, 0.0, 0.8, 0.8,
]);
// 索引数据:每个面 4 个索引 + 1 个图元重启标记 0xffff
const indexData = new Uint16Array([
0, 1, 3, 2, 0xffff,
7, 6, 4, 5, 0xffff,
11, 10, 8, 9, 0xffff,
12, 13, 15, 14, 0xffff,
19, 18, 16, 17, 0xffff,
20, 21, 23, 22, 0xffff
]);
// 创建索引缓冲区
const indexBuffer = device.createBuffer({
label: "Index Buffer 0",
size: indexData.byteLength,
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(indexBuffer, 0, indexData);
renderPass.setIndexBuffer(indexBuffer, "uint16");
…
renderPass.drawIndexed(30, 3);
调用 device.createBuffer() 时描述符的 usage 需要是 GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,分别表示这是一个索引缓冲区、且允许写入数据。
setIndexBuffer(buffer, indexFormat, offset?, size?) 必须指定索引缓冲区和索引数据的格式("uint16" 或 "uint32");offset(读取起始字节偏移)和 size(读取的字节数)是可选参数。
执行索引绘制要调用 drawIndexed()(或间接绘制版本 drawIndexedIndirect()),而不是 draw() / drawIndirect()。drawIndexed 的参数依次为:
- indexCount:本次绘制读取的索引数量(必填);
- instanceCount:可选,实例(副本)数量,默认 1;
- firstIndex:可选,从索引缓冲区第几个索引开始读取,默认 0;
- baseVertex:可选,为每个读到的索引值加上的偏移量,默认 0;
- firstInstance:可选,第一个实例的编号,默认 0。
上面的例子用 30 个索引绘制了 3 个实例的立方体。
细心的读者会发现,30 比按面计算出的顶点/索引基数(6 面 × 4 顶点 = 24)要多,这是因为每绘制完一个面就插入了一个特殊的图元重启索引:0xffff。它的作用是通知渲染器结束当前图元带的组装、从下一个索引重新开始,从而避免相邻两个面之间的顶点被错误地连接成三角形——相当于让每个面独立成带。若索引格式是 "uint32",对应的重启值则是 0xffffffff。
需要特别注意:图元重启只在图元拓扑为 "line-strip" 或 "triangle-strip" 时生效,并且渲染管线的 primitive 状态必须显式声明与索引缓冲区一致的 stripIndexFormat,否则重启标记不会被正确识别。因此上面例子对应的渲染管线还需要这样配置:
const renderPipeline = device.createRenderPipeline({
// …
primitive: {
topology: "triangle-strip",
stripIndexFormat: "uint16", // 需与 setIndexBuffer 的格式一致
frontFace: "cw",
cullMode: "back",
},
});
其中 topology: "triangle-strip" 让渲染器把每 4 个索引对应的顶点(如 0, 1, 3, 2)组装成两个共边的三角形;stripIndexFormat 告诉管线索引值以 16 位还是 32 位存储,从而正确识别 0xffff / 0xffffffff 这个重启标记;frontFace 和 cullMode 则用于配置背面剔除,与图元重启本身无关,但通常会一并设置以提升渲染性能。




