WebGPU 顶点缓冲区:从存储缓冲到索引绘制

原文标题:WebGPU Vertex Buffers
原文作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-vertex-buffers.html

本文是对 WebGPU 系列教程中“顶点缓冲区(Vertex Buffers)”一文的深度解读与编译,而非逐字全盘翻译。文中保留了核心 WGSL/JavaScript 代码,并在关键处加入中文说明,帮助理解 WebGPU 如何将顶点数据从存储缓冲区迁移到传统顶点缓冲区,并进一步实现实例化、归一化属性与索引绘制。


WebGPU Vertex Buffers

1. 为什么需要顶点缓冲区?

在上一篇关于存储缓冲区(Storage Buffers)的文章中,我们通过内建变量 vertex_index 在着色器中索引顶点数据。虽然这种方式逐渐流行,但传统方法仍然是将顶点数据放入顶点缓冲区,通过**顶点属性(Vertex Attributes)**提供给顶点着色器。

顶点缓冲区本质上和普通 WebGPU 缓冲区一样,但区别在于:我们不在着色器中直接访问它,而是告诉 WebGPU 缓冲区中的数据布局,由 GPU 固定功能自动提取并填充到着色器入口。


WebGPU Vertex Buffers

2. 从存储缓冲区切换到顶点缓冲区

2.1 着色器修改

首先将 Vertex 结构体中的 position 字段加上 @location(0) 属性,表示从顶点缓冲区的位置 0 读取数据:

struct OtherStruct {
  scale: vec2f,
};

struct Vertex {
  @location(0) position: vec2f,   // 关键变化:增加 @location(0)
};

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

@group(0) @binding(0) `var<storage, read>` ourStructs: `array<OurStruct>`;
@group(0) @binding(1) `var<storage, read>` otherStructs: `array<OtherStruct>`;
// 移除:@group(0) @binding(2) `var<storage, read>` pos: `array<Vertex>`;

@vertex fn vs(
  vert: Vertex,   // 不再使用 @builtin(vertex_index)
  @builtin(instance_index) instanceIndex: u32
) -> VSOutput {
  let otherStruct = otherStructs[instanceIndex];
  let ourStruct = ourStructs[instanceIndex];

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

核心变化:

  • 移除了 @group(0) @binding(2) 的存储缓冲区 pos。
  • 将 @builtin(vertex_index) 替换为直接接收 Vertex 结构体。

2.2 渲染管线描述顶点缓冲区

在创建渲染管线时,需要描述缓冲区布局(buffers 数组):

const pipeline = device.createRenderPipeline({
  label: 'vertex buffer pipeline',
  layout: 'auto',
  vertex: {
    module,
    buffers: [
      {
        arrayStride: 2 * 4, // 2 个 float32,共 8 字节
        attributes: [
          {shaderLocation: 0, offset: 0, format: 'float32x2'}, // position
        ],
      },
    ],
  },
  fragment: {
    module,
    targets: [{ format: presentationFormat }],
  },
});
  • arrayStride:从一个顶点到下一个顶点之间的字节数(步长)。
  • attributes:定义每个顶点属性的位置、偏移和格式。
  • shaderLocation: 0 对应着色器中的 @location(0)。
  • format: 'float32x2' 表示读取两个 32 位浮点数。

2.3 创建顶点缓冲区并设置到渲染通道

缓冲区使用方式从 STORAGE 改为 VERTEX:

const vertexBuffer = device.createBuffer({
  label: 'vertex buffer vertices',
  size: vertexData.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(vertexBuffer, 0, vertexData);

绘制时通过 setVertexBuffer(0, vertexBuffer) 绑定:

pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);

这里的索引 0 对应管线描述中 buffers 数组的第 0 项。


Instancing with Vertex Buffers

3. 实例化(Instancing)与多缓冲区

除了逐顶点属性,属性还可以逐实例(per-instance)推进。这正好替代了之前用 instance_index 索引存储缓冲区的做法。

3.1 着色器改为纯顶点属性

struct Vertex {
  @location(0) position: vec2f,
  @location(1) color: vec4f,
  @location(2) offset: vec2f,
  @location(3) scale: vec2f,
};

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

@vertex fn vs(
  vert: Vertex,
) -> VSOutput {
  var vsOut: VSOutput;
  vsOut.position = vec4f(
      vert.position * vert.scale + vert.offset, 0.0, 1.0);
  vsOut.color = vert.color;
  return vsOut;
}

3.2 管线描述多缓冲区与实例步进模式

vertex: {
  module,
  buffers: [
    {
      arrayStride: 2 * 4, // position
      attributes: [
        {shaderLocation: 0, offset: 0, format: 'float32x2'},
      ],
    },
    {
      arrayStride: 6 * 4, // color + offset,共 6 个 float32
      stepMode: 'instance',   // 逐实例推进
      attributes: [
        {shaderLocation: 1, offset:  0, format: 'float32x4'}, // color
        {shaderLocation: 2, offset: 16, format: 'float32x2'}, // offset
      ],
    },
    {
      arrayStride: 2 * 4, // scale
      stepMode: 'instance',
      attributes: [
        {shaderLocation: 3, offset: 0, format: 'float32x2'}, // scale
      ],
    },
  ],
},

stepMode: 'instance' 表示该属性每个实例才前进一步,而默认的 'vertex' 则是每个顶点前进一步。

3.3 绑定多个顶点缓冲区

pass.setVertexBuffer(0, vertexBuffer);
pass.setVertexBuffer(1, staticVertexBuffer);
pass.setVertexBuffer(2, changingVertexBuffer);

这样完全移除了存储缓冲区和 bind group 的需求。


Index Buffers

4. 交错属性与默认值

我们可以在一个缓冲区中交错存储多个属性。例如,为每个顶点添加颜色 perVertexColor:

struct Vertex {
  @location(0) position: vec2f,
  @location(1) color: vec4f,
  @location(2) offset: vec2f,
  @location(3) scale: vec2f,
  @location(4) perVertexColor: vec3f,
};

对应的缓冲区布局(position 2 个 float + perVertexColor 3 个 float):

{
  arrayStride: 5 * 4, // 5 个 float32 = 20 字节
  attributes: [
    {shaderLocation: 0, offset: 0, format: 'float32x2'},  // position
    {shaderLocation: 4, offset: 8, format: 'float32x3'},  // perVertexColor
  ],
},

有趣的是:着色器中的 perVertexColor 声明为 vec3f,但可以改为 vec4f,无需修改 JavaScript 数据。这是因为 GPU 会自动将缺失的第四个分量填充为默认值 1(即 (0,0,0,1))。

@location(4) perVertexColor: vec4f,

这样 vert.perVertexColor 可直接作为 vec4f 使用,第四个分量自动为 1。


Index Buffers

5. 使用归一化属性节省空间

颜色不需要 32 位浮点精度。使用 8 位无符号整数并让 GPU 自动归一化到 [0,1] 能显著减少带宽。

5.1 修改数据布局

原来 perVertexColor 使用 3 个 float32,共 12 字节;改为 unorm8x4 后仅需 4 字节(RGB 各 1 字节 + 1 字节填充或 alpha)。实例颜色也从 float32x4 改为 unorm8x4。

const staticUnitSize =
  4 +         // color 为 4 字节(unorm8x4)
  2 * 4;      // offset 仍为 2 个 float32

写入时使用 Uint8Array 视图:

const staticVertexValuesU8 = new Uint8Array(staticVertexBufferSize);
const staticVertexValuesF32 = new Float32Array(staticVertexValuesU8.buffer);

// 设置颜色(0~255)
staticVertexValuesU8.set(
  [rand() * 255, rand() * 255, rand() * 255, 255],
  staticOffsetU8 + kColorOffset);

// 设置偏移(float32,注意偏移单位)
staticVertexValuesF32.set(
  [rand(-0.9, 0.9), rand(-0.9, 0.9)],
  staticOffsetF32 + kOffsetOffset);

5.2 更新管线属性格式

{
  arrayStride: 2 * 4 + 4, // position(8字节) + color(4字节)
  attributes: [
    {shaderLocation: 0, offset: 0, format: 'float32x2'}, // position
    {shaderLocation: 4, offset: 8, format: 'unorm8x4'},  // perVertexColor
  ],
},
{
  arrayStride: 4 + 2 * 4, // color(4字节) + offset(8字节)
  stepMode: 'instance',
  attributes: [
    {shaderLocation: 1, offset: 0, format: 'unorm8x4'},  // color
    {shaderLocation: 2, offset: 4, format: 'float32x2'}, // offset
  ],
},

这样每个顶点的颜色数据从 12 字节降至 4 字节,实例颜色从 16 字节降至 4 字节,总带宽节省约 40–50%。


6. 索引缓冲区(Index Buffers)

索引缓冲区描述顶点处理顺序。原来的圆形生成每个细分 6 个顶点(有重复),现在只生成 4 个唯一顶点,通过索引重用:

const numVertices = (numSubdivisions + 1) * 2; // 仅 4 个顶点/细分

const indexData = new Uint32Array(numSubdivisions * 6);
let ndx = 0;
// 索引序列:0,1,2, 2,1,3, ...
for (let i = 0; i < numSubdivisions; ++i) {
  const ndxOffset = i * 2;
  // 第一个三角形
  indexData[ndx++] = ndxOffset;
  indexData[ndx++] = ndxOffset + 1;
  indexData[ndx++] = ndxOffset + 2;
  // 第二个三角形
  indexData[ndx++] = ndxOffset + 2;
  indexData[ndx++] = ndxOffset + 1;
  indexData[ndx++] = ndxOffset + 3;
}

创建索引缓冲区 (usage: INDEX):

const indexBuffer = device.createBuffer({
  label: 'index buffer',
  size: indexData.byteLength,
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(indexBuffer, 0, indexData);

绘制时设置索引缓冲区并调用 drawIndexed:

pass.setIndexBuffer(indexBuffer, 'uint32');
pass.drawIndexed(numVertices, kNumObjects);

这减少了 33% 的顶点内存占用,并可能提升顶点着色器缓存命中率。


7. 核心优化要点提炼

  1. 使用顶点缓冲区替代存储缓冲区,结合实例化减少 CPU→GPU 数据传输
    顶点缓冲区允许 GPU 固定功能部件直接装配顶点属性,避免在着色器中通过 vertex_index 和 instance_index 手动索引存储缓冲区。配合 stepMode: 'instance' 可以将每个实例的共性数据单独存放,避免为每个顶点重复拷贝同一实例数据,从而减少显存带宽和 CPU 端处理。

  2. 优先使用归一化整数格式(如 unorm8x4)存储颜色等低精度数据
    颜色、法线等归一化向量不需要 32 位浮点精度。改用 unorm8x4 / snorm8x4 等格式,可在保持 [0,1] 或 [-1,1] 范围的同时,将属性占用的带宽和显存空间降低 50% 甚至更多。示例中每个顶点颜色从 12 字节降至 4 字节,实例颜色从 16 字节降至 4 字节。

  3. 使用索引缓冲区复用重复顶点,减少冗余处理
    对于网格等含有大量共享顶点的几何体,索引缓冲区可以显著减少顶点缓冲区大小,并让 GPU 有机会复用顶点着色器的计算结果(通过缓存)。示例中圆形每个细分的顶点数从 6 个减少到 4 个,节省约 33% 的顶点存储和潜在的顶点着色器计算量。


8. GLSL/WebGL 与 WGSL/WebGPU 术语对照检查

虽然本文原文使用 WGSL/WebGPU(而非 GLSL/WebGL),但底层图形概念相通。以下为关键术语的中文翻译与适用性校验:

英文原文简体中文推荐译法备注
Vertex Buffer顶点缓冲区WebGL 中同为 Vertex Buffer
Storage Buffer存储缓冲区WebGPU 特有,类似 GLSL 中的 SSBO
Bind Group绑定组WebGPU 的资源绑定概念,对应 WebGL 的 uniform/attribute 绑定
Render Pipeline渲染管线WebGL 中对应 WebGLProgram + 状态集合
Vertex Shader顶点着色器通用
Fragment Shader片元着色器WebGPU 官方中文也称“片段着色器”,本文采用“片元”以区分
Attribute属性 / 顶点属性注意:WGSL 中 attribute 指输入参数,与 WebGL 的 vertex attrib 概念一致
Location位置 / 定位@location 对应 GLSL 中的 layout(location = N)
Builtin内建变量如 @builtin(position)、@builtin(vertex_index)
Stride步长指相邻顶点/实例数据之间的字节距离
Offset偏移量属性在缓冲区中的起始字节偏移
Interleaved交错多个属性在缓冲区中相邻存储
Normalized归一化如 unorm8x4 将整数 0255 归一化为 0.01.0
Instance / Instancing实例 / 实例化stepMode: 'instance'
Index Buffer索引缓冲区与 WebGL 的 ELEMENT_ARRAY_BUFFER 对应
Draw Call绘制调用draw / drawIndexed
GPUBufferUsageGPU 缓冲区用途标志保留英文,不翻译
Render Pass渲染通道WebGPU 概念,类似 WebGL 的 framebuffer 绑定过程

校验说明:本文内容涉及 WGSL 的显式 @location、@builtin 等语法,与 GLSL 的 layout(location=...) 思路一致,但 WebGPU 的顶点缓冲区描述在渲染管线中完成,而 WebGL 通常在 gl.vertexAttribPointer 中动态设置。翻译时保留了“顶点缓冲区”“索引缓冲区”“实例化”等业界通用术语,同时避免将 WebGPU 特有的“渲染通道”“绑定组”混译为 WebGL 术语。


9. 小结

本文系统梳理了 WebGPU 顶点缓冲区的核心用法:从简单替换存储缓冲区,到多缓冲区实例化,再到归一化属性与索引缓冲区优化。理解这些机制有助于在 WebGPU 中构建高效、低带宽的渲染管线。

下一步可阅读教程中的 纹理(Textures) 章节。