WebGPU Utils 与 wgpu-matrix

原作者: webgpufundamentals Contributors
原始链接: https://webgpufundamentals.org/webgpu/lessons/webgpu-utils.html


本文要点

使用 WebGPU 非常冗长。冗长到如果使用一些辅助函数,你就可以专注于更高层次的概念,从而更容易理解代码。

举个例子,假设你在学习数学。老师教你“平均数”的含义,以及如何计算一组数字的平均数。一旦教完,他们就会继续讲其他内容,然后只说“这里你要计算平均数”。例如:

要计算标准差:

  • 计算所有数据的平均数
  • 对数据集中的每个数字,计算该数字与平均数之间的差值
  • 求出每个差值后,将其平方
  • 取平方差平均数的平方根

他们不会重新解释如何计算平均数。你已经学过了,现在他们只需引用你已学过的内容即可。

类似地,在 WebGPU 中,我们有在 WGSL 中为 uniform 创建结构体的概念。然后创建一个或多个 uniform buffer,使用 TypedArray 填充这些缓冲区的数据。这些内容在本站前 20-30 篇文章以及关于内存布局的文章中已经广泛讲过。

然而到了某个阶段,如果继续纠结于这些细节,理解代码就会变得更加困难。与其说“设置 uniform”,不如说你之前已经学过“设置 uniform”意味着“计算到各数据块的偏移量,创建类型化数组视图以便设置数据,然后在渲染前将其上传到 GPU”。

因此,不要害怕本站使用的这些库。它们几乎所有的功能都在前面的文章中详细解释过。下面提供更多细节。


本站许多示例使用了两个库。

wgpu-matrix

第一个是 wgpu-matrix。wgpu-matrix 是我们在矩阵数学文章到透视投影文章,以及光照文章中编写的相同函数的集合。

这里没有任何特殊之处。如果你想了解任何一个数学函数的工作原理,可以阅读上面列出的文章。

webgpu-utils

第二个是 webgpu-utils。

WebGPU Utils 是我们在多篇文章中编写的其他有用函数的集合。例如:

  • numMipLevels
  • loadImageBitmap
  • copySourceToTexture
  • createTextureFromSource
  • createTextureFromImage
  • generateMips

这些都是在导入纹理的文章中创建的。

它还包括:

  • copySourcesToTexture
  • createTextureFromSources
  • generateMips

来自立方体贴图文章。在那篇文章中,我们更新了 generateMips 以处理多个图层。

同时它也包含了我们在透明与混合文章中为支持 premultipliedAlpha 而添加的内容。

该库还包括:

  • createTextureFromImages

来自环境贴图文章。

makeShaderDataDefinitions 和 makeStructuredView

这两个函数在内存布局文章中简要提到过。

正如你在所有基础文章、矩阵数学文章以及光照文章中看到的那样,当我们在 WGSL 中创建一个结构体时,通常需要再创建一个 uniform buffer 或 storage buffer,并以某种方式将数据放入其中。

在光照文章中尤其明显。我们有这个结构体:

struct Uniforms {
  matrix: mat4x4f,
  color: vec4f,
  lightDirection: vec3f,
};

然后改成这样:

struct Uniforms {
  world: mat4x4f,
  worldViewProjection: mat4x4f,
  color: vec4f,
  lightDirection: vec3f,
};

接着是这样:

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
  color: vec4f,
  lightDirection: vec3f,
};

然后:

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
  world: mat4x4f,
  color: vec4f,
  lightPosition: vec3f,
};

接着:

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
  world: mat4x4f,
  color: vec4f,
  lightWorldPosition: vec3f,
  viewWorldPosition: vec3f,
};

然后:

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
  world: mat4x4f,
  color: vec4f,
  lightWorldPosition: vec3f,
  viewWorldPosition: vec3f,
  shininess: f32,
};

最后:

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
  world: mat4x4f,
  color: vec4f,
  lightWorldPosition: vec3f,
  viewWorldPosition: vec3f,
  shininess: f32,
  lightDirection: vec3f,
  innerLimit: f32,
  outerLimit: f32,
};

每次进行这些更改时,我们都必须进入设置视图的代码中编辑许多内容。为了说明我们不得不做什么,下面是演进过程。

我们从方向光照文章开始:

  // matrix + color + light direction
  const uniformBufferSize = (16 + 4 + 4) * 4;
  const uniformBuffer = device.createBuffer({
    label: 'uniforms',
    size: uniformBufferSize,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  });

  const uniformValues = new Float32Array(uniformBufferSize / 4);

  // offsets to the various uniform values in float32 indices
  const kMatrixOffset = 0;
  const kColorOffset = 16;
  const kLightDirectionOffset = 20;

  const matrixValue = uniformValues.subarray(kMatrixOffset, kMatrixOffset + 16);
  const colorValue = uniformValues.subarray(kColorOffset, kColorOffset + 4);
  const lightDirectionValue =
      uniformValues.subarray(kLightDirectionOffset, kLightDirectionOffset + 3);

然后改成这样,不断增加字段和 offset,代码越来越冗长……直至文章末尾的聚光灯文章的版本。

希望你能看到,这种冗长让人分心,偏离了文章的重点! 我们真正想说的只是“将你的 WGSL 结构体改成这样,然后在绘制前设置这些值”,但反而每个示例都要展示 40 多行代码修改。

使用 makeShaderDataDefinitions 和 makeStructuredView,上面所有的 JavaScript 代码可以简化为这 7 行:

const defs = makeShaderDataDefinitions(code);
const uni = makeStructuredView(defs.uniforms.uni);

const uniformBuffer = device.createBuffer({
  size: uni.arrayBuffer.byteLength,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});

仅此而已。在示例之间,我们只需适当地更改结构体,这两个函数就会为我们创建所有偏移量和视图。

以最后一个示例结构体为例:

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
  world: mat4x4f,
  color: vec4f,
  lightWorldPosition: vec3f,
  viewWorldPosition: vec3f,
  shininess: f32,
  lightDirection: vec3f,
  innerLimit: f32,
  outerLimit: f32,
};

@group(0) @binding(0) `var<uniform>` uni: Uniforms;

这两行代码:

const defs = makeShaderDataDefinitions(code);
const uni = makeStructuredView(defs.uniforms.uni);

为我们在 WGSL 中定义的 uniform 绑定 uni 创建一个“结构化视图”。

实际上,这些行在幕后大致生成了如下对象:

const arrayBuffer = new ArrayBuffer(256);
const uni = {
  arrayBuffer,
  set: function(data) { /* helper */ },
  views: {
    normalMatrix: new Float32Array(arrayBuffer, 0, 12),
    worldViewProjection: new Float32Array(arrayBuffer, 48, 16),
    world: new Float32Array(arrayBuffer, 112, 16),
    color: new Float32Array(arrayBuffer, 176, 4),
    lightWorldPosition: new Float32Array(arrayBuffer, 192, 3),
    viewWorldPosition: new Float32Array(arrayBuffer, 208, 3),
    shininess: new Float32Array(arrayBuffer, 220, 1),
    lightDirection: new Float32Array(arrayBuffer, 224, 3),
    innerLimit: new Float32Array(arrayBuffer, 236, 1),
    outerLimit: new Float32Array(arrayBuffer, 240, 1),
  },
};

这里没有魔法,除了 makeShaderDataDefinitions 实际上会解析 WGSL 以提取足够的数据来创建这些视图。

在提到的文章中,设置值的代码类似这样:

    const aspect = canvas.clientWidth / canvas.clientHeight;
    const projection = mat4.perspective(
        degToRad(60),
        aspect,
        1,      // zNear
        2000,   // zFar
    );

    const eye = [100, 150, 200];
    const target = [0, 35, 0];
    const up = [0, 1, 0];

    // Compute a view matrix
    const viewMatrix = mat4.lookAt(eye, target, up);

    // Combine the view and projection matrixes
    const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);

    // Compute a world matrix
    const world = mat4.rotationY(settings.rotation, worldValue);

    // Combine the viewProjection and world matrices
    mat4.multiply(viewProjectionMatrix, world, worldViewProjectionValue);

    // Inverse and transpose it into the worldInverseTranspose value
    mat3.fromMat4(mat4.transpose(mat4.inverse(world)), normalMatrixValue);

    colorValue.set([0.2, 1, 0.2, 1]);  // green
    lightWorldPositionValue.set([-10, 30, 100]);
    viewWorldPositionValue.set(eye);
    shininessValue[0] = settings.shininess;
    innerLimitValue[0] = Math.cos(settings.innerLimit);
    outerLimitValue[0] = Math.cos(settings.outerLimit);

    // Since we don't have a plane like most spotlight examples
    // let's point the spot light at the F
    {
        const mat = mat4.aim(
            lightWorldPositionValue,
            [
              target[0] + settings.aimOffsetX,
              target[1] + settings.aimOffsetY,
              0,
            ],
            up);
        // get the zAxis from the matrix
        // negate it because lookAt looks down the -Z axis
        lightDirectionValue.set(mat.slice(8, 11));
    }

    // upload the uniform values to the uniform buffer
    device.queue.writeBuffer(uniformBuffer, 0, uniformValues);

使用 uni.views 后可以改成:

    // Pull out the views using the same existing names.
    const {
      world: worldValue,
      worldViewProjection: worldViewProjectionValue,
      normalMatrix: normalMatrixValue,
      color: colorValue,
      lightWorldPosition: lightWorldPositionValue,
      lightDirection: lightDirectionValue,
      viewWorldPosition: viewWorldPositionValue,
      shininess: shininessValue,
      innerLimit: innerLimitValue,
      outerLimit: outerLimitValue,
    } = uni.views;

    // ...矩阵计算代码保持不变...

    colorValue.set([0.2, 1, 0.2, 1]);  // green
    lightWorldPositionValue.set([-10, 30, 100]);
    viewWorldPositionValue.set(eye);
    shininessValue[0] = settings.shininess;
    innerLimitValue[0] = Math.cos(settings.innerLimit);
    outerLimitValue[0] = Math.cos(settings.outerLimit);

    // ...

    device.queue.writeBuffer(uniformBuffer, 0, uni.arrayBuffer);

或者更简洁地直接使用 views:

    const { views } = uni;
    // ...
    const world = mat4.rotationY(settings.rotation, views.world);
    mat4.multiply(viewProjectionMatrix, world, views.worldViewProjection);
    mat3.fromMat4(mat4.transpose(mat4.inverse(world)), views.normalMatrix);

    views.color.set([0.2, 1, 0.2, 1]);  // green
    views.lightWorldPosition.set([-10, 30, 100]);
    views.viewWorldPosition.set(eye);
    views.shininess[0] = settings.shininess;
    views.innerLimit[0] = Math.cos(settings.innerLimit);
    views.outerLimit[0] = Math.cos(settings.outerLimit);

    // ...

    device.queue.writeBuffer(uniformBuffer, 0, uni.arrayBuffer);

甚至可以使用 set 函数进一步简化:

    const { views } = uni;
    // ...矩阵计算部分保持不变...
    uni.set({
      color: [0.2, 1, 0.2, 1],  // green
      lightWorldPosition: [-10, 30, 100],
      viewWorldPosition: eye,
      shininess: settings.shininess,
      innerLimit: settings.innerLimit,
      outerLimit: settings.outerLimit,
    });

    // ...
    device.queue.writeBuffer(uniformBuffer, 0, uni.arrayBuffer);

set 函数至少对于上述用例来说非常简单。它可以这样实现:

const arrayBuffer = new ArrayBuffer(256);
const views = {
  normalMatrix: new Float32Array(arrayBuffer, 0, 12),
  worldViewProjection: new Float32Array(arrayBuffer, 48, 16),
  world: new Float32Array(arrayBuffer, 112, 16),
  color: new Float32Array(arrayBuffer, 176, 4),
  lightWorldPosition: new Float32Array(arrayBuffer, 192, 3),
  viewWorldPosition: new Float32Array(arrayBuffer, 208, 3),
  shininess: new Float32Array(arrayBuffer, 220, 1),
  lightDirection: new Float32Array(arrayBuffer, 224, 3),
  innerLimit: new Float32Array(arrayBuffer, 236, 1),
  outerLimit: new Float32Array(arrayBuffer, 240, 1),
};
const uni = {
  arrayBuffer,
  set: function(data) {
    // over simplified
    for (const [key, value] of Object.entries(data)) {
      const view = views[key];
      if (view) {
        view.set(typeof value === 'number' ? [value] : value);
      }
    }
  },
};

实际的 set 实现稍微复杂一些,以处理嵌套结构体和数组。详细信息可查看源码:set 的代码及其调用的函数:链接。

希望上面的示例说明它并不是魔法。这些简单的函数可以让使用 WebGPU 变得远没有那么乏味,也可让解释事情变得更简单。你可以只说“设置 uniform 值”,而不是第 150 次展示计算偏移量、创建视图等繁琐细节。


顶点缓冲区与属性

另一个容易减少乏味的地方是设置顶点缓冲区和属性。常见问题是,我们需要一些数据,例如顶点位置、顶点法线、顶点纹理坐标。我们可以把它们放在不同的数组中。这很容易:

const positions = [];
const normals = [];
const texcoords = [];

for(each vertex) {
  ...
  position.push(x, y, z);
  normals.push(nx, ny, nz);
  texcoord.push(u, v);
}

但现在我们增加了复杂性:需要 3 个缓冲区和 3 组属性。

  const pipeline = device.createRenderPipeline({
    vertex: {
      module: shaderModule,
      buffers: [
        // position
        {
          arrayStride: 3 * 4, // 3 floats, 4 bytes each
          attributes: [
            {shaderLocation: 0, offset: 0, format: 'float32x3'},
          ],
        },
        // normals
        {
          arrayStride: 3 * 4, // 3 floats, 4 bytes each
          attributes: [
            {shaderLocation: 1, offset: 0, format: 'float32x3'},
          ],
        },
        // texcoords
        {
          arrayStride: 2 * 4, // 2 floats, 4 bytes each
          attributes: [
            {shaderLocation: 2, offset: 0, format: 'float32x2',},
          ],
        },
      ],
    },
    ...

  function createBuffer(device, values, usage) {
    const data = new Float32Array(values);
    const buffer = device.createBuffer({
      size: data.byteLength,
      usage,
      mappedAtCreation: true,
    });
    const dst = new data.constructor(buffer.getMappedRange());
    dst.set(data);
    buffer.unmap();
    return buffer;
  }

  const positionBuffer = createBuffer(device, positions, GPUBufferUsage.VERTEX);
  const normalBuffer = createBuffer(device, normals, GPUBufferUsage.VERTEX);
  const texcoordBuffer = createBuffer(device, texcoords, GPUBufferUsage.VERTEX);

又是乏味。😮‍💨

或者我们可以尝试将它们交错。这可能容易,也可能不容易。如果它们都是相同类型,比如所有 32 位浮点值,那么我们可以这样做:

const vertexData = [];

for (each vertex) {
  ...
  vertexData.push(
      x, y, z,
      nx, ny, nz,
      u, v);
}

但一旦我们想交错 8 位颜色等,就会再次变得乏味。并且即使我们完成上述操作,还需要设置管线。

我们可以编写一个函数,传入数据对象,它会自动交错数据、创建缓冲区,并返回管线所需的 buffers 部分:

const positions = [];
const normals = [];
const texcoords = [];

const data = {
  positions,
  normals,
  texcoords,
};

const {
  bufferLayouts,
  buffers,
  numElements
} = createBuffersAndAttributesFromArrays(device, data);

现在缓冲区已经创建好了,默认只有 1 个,数据已经交错。该缓冲区是 buffers[0]。我也返回了 bufferLayouts,即管线中名为 buffers 的部分:

  const pipeline = device.createRenderPipeline({
    vertex: {
      module: shaderModule,
      buffers: bufferLayouts
    },
    ...

而且,由于 buffers 是一个数组,我们可以像这样编写缓冲区命令:

    const pass = encoder.beginRenderPass(renderPassDescriptor);
    buffers.forEach((buffer, i) => pass.setVertexBuffer(i, buffer));
    ...

这样,无论缓冲区多或少,都不需要修改代码。

下面是一个原始 WebGPU 和使用 WebGPU Utils 的对比示例(来自 cube 示例):

原始 WebGPU (大量手动创建缓冲区、布局、绘制调用等):

WebGPU Utils 仅用少量代码完成同样功能:

  const {
    buffers: [vertexBuffer],
    bufferLayouts,
    indexBuffer,
    indexFormat,
    numElements,
  } = createBuffersAndAttributesFromArrays(
    device, {
      positions: [ ... ],
      normals: [ ... ],
      texcoords: [ ... ],
      indices: [ ... ],
    });

  const pipeline = device.createRenderPipeline({
    ...
    vertex: {
      module: shaderModule,
      buffers: bufferLayouts,
    },
    ...
  });

  ...
    passEncoder.setVertexBuffer(0, vertexBuffer);
    passEncoder.setIndexBuffer(indexBuffer, indexFormat);
    passEncoder.drawIndexed(numElements);

更复杂的例子,比如来自顶点缓冲区文章的使用 8 位颜色的示例,也变得更加简单。手动管理多个缓冲区、偏移、实例步进模式等,都可以用 createBuffersAndAttributesFromArrays 替代。


总结

这些辅助函数并不神奇,它们只是把前面文章已经详细解释过的概念封装起来。当你在示例中看到类似:

const sphereData = createBuffersAndAttributesFromArrays(
   device,
   createSphereVertices(radius),
);

希望你能明白,这个站点上有 30-40 篇文章解释了 createBuffersAndAttributesFromArrays 的含义,这些工具没有任何可怕或难以理解的地方。解释一个概念、给它命名,然后只用名字引用它,是学习中的常态。它让你更容易构建更高层次的概念。


三个核心优化要点

  1. 用 makeShaderDataDefinitions + makeStructuredView 自动生成 Uniform 缓冲区的视图和偏移量
    手动管理 uniform buffer 中每个字段的偏移量极其容易出错且冗长。通过解析 WGSL 结构体自动生成 views 和 arrayBuffer,代码从 40 多行缩减到 3 行,同时避免内存布局计算错误。

  2. 用 createBuffersAndAttributesFromArrays 自动创建并交错顶点缓冲区及属性布局
    手动创建多个顶点缓冲区(位置、法线、纹理坐标等),设置 arrayStride、offset、format,并在绘制时逐个绑定,非常繁琐。该函数接收一个数据对象,自动交错数据、创建缓冲区、返回管线所需的 bufferLayouts 和缓冲区数组,大大简化渲染管线初始化。

  3. 用高层辅助函数(set、generateMips 等)替代底层细节,专注概念表达
    这些库对 device.queue.writeBuffer、setVertexBuffer 等操作进行了合理的封装,让你可以在文章或教学中直接说“设置 uniform 值”或“生成 mipmap”,而不必每次展示完整的 offset 计算、视图创建和数据上传代码,从而将注意力集中在真正要讲解的 WebGPU 概念上。


GLSL/WebGL 专业术语检查

本文涉及的是 WebGPU,其着色语言为 WGSL(WebGPU Shading Language),并非 GLSL。注意不要混淆以下术语:

英文术语推荐中文翻译说明
WGSLWGSL(WebGPU 着色语言)WebGPU 使用的着色语言,区别于 GLSL
WebGPUWebGPU新一代 Web 图形/计算 API,不是 WebGL
uniform buffer统一缓冲区GPU 可读取的常量数据缓冲区
storage buffer存储缓冲区GPU 可读写的缓冲区
vertex buffer顶点缓冲区存储顶点属性的缓冲区
attribute顶点属性 / 属性渲染管线中每个顶点的输入字段
interleave / interleaved交错(存储)将多个属性的数据交错存放在一个缓冲区中
offset偏移量数据在缓冲区中的字节偏移
shader location着色器位置WGSL 中使用 @location 指定的属性位置
arrayStride数组步进每个顶点数据所占的字节数
stepMode步进模式'vertex' 或 'instance',控制逐顶点或逐实例读取
mip levels / mipmap多级渐远纹理层级 / Mipmap纹理金字塔层级
premultiplied alpha预乘 Alpha颜色分量预先乘以 Alpha 值
render pipeline渲染管线WebGPU 中配置图形渲染的管线对象
bind group绑定组一组绑定资源(缓冲区、纹理等)的集合
TypedArray类型化数组JavaScript 中如 Float32Array、Uint8Array 等
GPUBufferUsageGPUBufferUsageWebGPU 缓冲区用途标志
GPUBufferUsage.UNIFORM统一缓冲区用途标记缓冲区用作 uniform buffer
GPUBufferUsage.COPY_DST复制目标用途标记缓冲区可作为数据写入目标
GPUBufferUsage.VERTEX顶点缓冲区用途标记缓冲区用作顶点缓冲区
GPUBufferUsage.INDEX索引缓冲区用途标记缓冲区用作索引缓冲区

注意: 原文中提到的 mat4x4f、vec4f、vec3f、mat3x3f、f32 等是 WGSL 的类型关键字,不是 GLSL 的 mat4、vec4、vec3、mat3、float。在 WebGPU 上下文中应使用 WGSL 类型。WebGPU 与 WebGL 是两个不同的 API,不能混用术语。


以上内容即是对原文的深度解读与编译。保留的核心代码展示了从手动繁琐操作到使用辅助库简化过程的具体代码。希望这些信息和术语检查能帮助你更好地理解 WebGPU 工具库的价值。