WebGPU缓冲区更新最佳实践

介绍

在WebGPU中,GPUBuffer是您将要操作的主要对象之一。它与GPUTextures一同代表了您的应用程序向GPU传递用于渲染的大部分数据。在WebGPU中,缓冲区用于顶点和索引数据、uniforms、计算和片段着色器的通用存储,以及作为纹理数据的临时存储区域。

本文档专注于找到将数据有效地输入这些缓冲区的最佳方法,而不考虑其最终用途。

缓冲区数据流

在深入探讨设置缓冲区数据的机制之前,让我们先谈谈它在底层是什么样子。

总体而言,您可以将WebGPU视为使用两种类型的内存:GPU可访问的内存和CPU可访问且能够高效复制到GPU可访问内存的内存。每当您想要从着色器(顶点、片段或计算)中访问数据时,它必须在GPU可访问内存中;每当您想要从JavaScript中访问数据时,它必须在CPU可访问内存中。缓冲区可以是GPU或CPU可访问的,但不能同时是两者,而纹理始终只能是GPU可访问的。

在某些设备上,比如手机,实际上它们可能是同一内存池。在另一些设备上,比如带有独立显卡的个人电脑,它们可能位于不同的物理板上,并且只能通过PCIe总线或类似方式进行通信。由于我们正在为Web开发,我们希望能够编写一个单一的代码路径,可以在最广泛的设备上运行。因此,WebGPU在处理这些内存配置时不像Vulkan那样区分它们。一切都被视为具有独立的CPU和GPU内存池,而由WebGPU实现负责在可能的情况下进行特定架构的优化。

这意味着进入GPU可访问内存的所有数据将大致采用相同的路径:

  1. 创建一个使用CPU可访问内存的"临时"缓冲区,该缓冲区可用于写入和复制。 (usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC)
  2. 对"临时"缓冲区进行映射以进行写入(通过mapAsync()),这使得其内存可以作为JavaScript ArrayBuffer进行写入。
  3. 将数据放入数组缓冲区。
  4. 解除对"临时"缓冲区的映射。
  5. 使用复制命令(例如copyBufferToBuffer()或copyBufferToTexture())将数据从"临时"缓冲区复制到GPU可访问的目标中。

类似的路径用于从GPU可访问内存中读取数据:

  1. 创建一个使用CPU可访问内存的"临时"缓冲区,该缓冲区可用于复制和读取。 (usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST)
  2. 使用复制命令(例如copyBufferToBuffer()或copyTextureToBuffer())将数据从GPU可访问的目标复制到"临时"缓冲区。
  3. 对"临时"缓冲区进行映射以进行读取(通过mapAsync()),这使得其内存可以作为JavaScript ArrayBuffer进行读取。
  4. 从数组缓冲区中读取数据。
  5. 解除对"临时"缓冲区的映射。

正如您将看到的,下面的一些方法通过使这些步骤成为隐式的方式来隐藏它们,但在大多数情况下,您可以假定这正是发生的事情。

当有疑虑时,使用writeBuffer()!

首先要明确的是,如果您对将数据有效输入特定缓冲区的最佳方法有任何疑问,writeBuffer()方法始终是一个安全的后备选择,几乎没有太多缺点。

writeBuffer()是GPUQueue上的一个便捷方法,它将ArrayBuffer中的值复制到GPUBuffer中,以用户代理认为最佳的方式进行。通常,这将是一条相当高效的路径,在某些情况下甚至可能是最高效的路径!(在大多数情况下,当您调用writeBuffer()时,用户代理将为您管理一个隐式的"临时"缓冲区,但在某些体系结构上,它有可能跳过该步骤。)

具体来说,如果您正在从WASM代码中使用WebGPU,那么writeBuffer()是首选路径。这是因为当您使用映射缓冲区时,WASM应用程序需要执行从WASM堆复制的额外步骤。

总的来说,使用writeBuffer()的优势有:

  1. 对于WASM应用程序来说是首选路径。
  2. 总体代码复杂度最低。
  3. 立即设置缓冲区数据。
  4. 如果数据已经在ArrayBuffer中,避免分配/复制映射ArrayBuffer。
  5. 在返回之前无需将映射缓冲区的数组内容设置为零。
  6. 允许用户代理选择上传数据到GPU的(可能是最佳的)模式。

实际上,并没有明显的不利之处。根据确切的使用模式,您可能能够编写一个更定制的缓冲区管理系统,在某种情况下获得更好的性能,但writeBuffer()是一个非常可靠的通用解决方案,用于设置缓冲区数据。

这里是使用writeBuffer()的一个示例。您可以看到代码非常简洁:

复制代码
// At some point during the app startup...
const projectionMatrixBuffer = gpuDevice.createBuffer({
  size: 16 * Float32Array.BYTES_PER_ELEMENT, // Large enough for a 4x4 matrix
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, // COPY_DST is required
});

// Whenever the projection matrix changes (ie: window is resized)...
function updateProjectionMatrixBuffer(projectionMatrix) {
  const projectionMatrixArray = projectionMatrix.getAsFloat32Array();
  gpuDevice.queue.writeBuffer(projectionMatrixBuffer, 0, projectionMatrixArray, 0, 16);
}

不会改变的缓冲区

有许多情况下,您将创建一个缓冲区,其内容在创建时需要被设置一次,然后永远不再改变。一个简单的例子是静态网格的顶点和索引缓冲区:缓冲区本身需要在创建后立即填充网格数据,之后在渲染循环中对网格进行任何更改都将使用变换矩阵或可能是在顶点着色器中进行的网格蒙皮。缓冲区内容在初始化设置后唯一更改的时间是在最终销毁时。

在这种情况下,在调用createBuffer()时使用mappedAtCreation标志是设置缓冲区数据的最佳方法之一。这将在映射状态下创建缓冲区,以便在创建后立即调用getMappedRange()。这提供了一个ArrayBuffer用于填充,之后调用unmap()并设置缓冲区数据!实际上,浏览器几乎肯定需要在调用unmap()后在后台对数组缓冲区内容进行一次复制,但通常可以确保以高效的方式完成。 (就像在writeBuffer()情况下一样,大多数情况下,用户代理会为您管理一个隐式的临时缓冲区。)

这种方法的主要优势是,如果您的缓冲区数据是动态生成的,您可以通过直接生成数据到映射的缓冲区中,至少可以节省一个CPU端的复制。

这种方法的优势有:

  1. 立即设置缓冲区数据。
  2. 不需要特定的使用标志。
  3. 数据可以直接写入映射的缓冲区,避免CPU端复制。

缺点有:

  1. 仅适用于新创建的缓冲区。
  2. 用户代理在映射之前必须将缓冲区清零。
  3. 如果数据已经在ArrayBuffer中,则需要进行另一次CPU端的复制。

以下是使用mappedAtCreation设置静态顶点数据的示例:

复制代码
// Creates a grid of vertices on the X, Y plane
function createXYPlaneVertexBuffer(width, height) {
  const vertexSize = 3 * Float32Array.BYTES_PER_ELEMENT; // Each vertex is 3 floats (X,Y,Z position)

  const vertexBuffer = gpuDevice.createBuffer({
    size: width * height * vertexSize, // Allocate enough space for all the vertices
    usage: GPUBufferUsage.VERTEX, // COPY_DST is not required!
    mappedAtCreation: true,
  });

  const vertexPositions = new Float32Array(vertexBuffer.getMappedRange()),

  // Build the vertex grid
  for (let y = 0; y < height; ++y) {
    for (let x = 0; x < width; ++x) {
      const vertexIndex = y * width + x;
      const offset = vertexIndex * 3;

      vertexPositions[offset + 0] = x;
      vertexPositions[offset + 1] = y;
      vertexPositions[offset + 2] = 0;
    }
  }

  // Commit the buffer contents to the GPU
  vertexBuffer.unmap();

  return vertexBuffer;
}

经常写入的缓冲区

如果您有经常更改的缓冲区(例如每帧一次),那么有效地更新它们略微更加复杂。不过在我们进一步讨论之前,应该注意在许多情况下,从性能的角度来看,使用writeBuffer()将是一条完全可以接受的路径!

然而,希望更明确控制其内存使用的应用程序可以使用所谓的"临时缓冲区环"。这种技术使用一个旋转的临时缓冲区集,不断地向GPU可访问缓冲区"提供"新数据。每次更新数据时,首先检查之前使用的临时缓冲区是否已映射并准备好使用,如果是,则将数据写入其中。如果不是,则创建一个新的临时缓冲区,将mappedAtCreation设置为true,以便立即填充。在数据在GPU端复制后,临时缓冲区立即再次映射,一旦映射完成,它就被放入准备使用的缓冲区队列中。如果缓冲区数据经常更新,这通常会导致一个包含2-3个临时缓冲区的循环列表。

这种方法在缓冲区管理方面是最复杂的,并且在持续内存使用方面比其他方法更多。不过,它对GPU的工作流水线有好处,并为您提供了很多控制的能力,可以针对特定情况进行调整。

优势:

  1. 限制缓冲区创建。
  2. 不等待先前使用的缓冲区映射。
  3. 临时缓冲区重用意味着初始化成本仅在每个设置中支付一次。
  4. 数据可以直接写入映射的缓冲区,避免CPU端复制。

缺点:

  1. 比其他方法更复杂。
  2. 更高的持续内存使用。
  3. 用户代理必须在第一次映射时将临时缓冲区清零。
  4. 如果数据已经在ArrayBuffer中,则需要进行另一次CPU端的复制。

以下是临时缓冲区环如何工作的示例,设置顶点数据:

复制代码
const waveGridSize = 1024;
const waveGridBufferSize = waveGridSize * waveGridSize * 3 * Float32Array.BYTES_PER_ELEMENT;
const waveGridVertexBuffer = gpuDevice.createBuffer({
  size: waveGridBufferSize,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
const waveGridStagingBuffers = [];

// Updates a grid of vertices on the X, Y plane with wave-like motion
function updateWaveGrid(time) {
  // Get a new or re-used staging buffer that's already mapped.
  let stagingBuffer;
  if (waveGridStagingBuffers.length) {
    stagingBuffer = waveGridStagingBuffers.pop();
  } else {
    stagingBuffer = gpuDevice.createBuffer({
      size: waveGridBufferSize,
      usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC,
      mappedAtCreation: true,
    });
  }

  // Fill in the vertex grid values.
  const vertexPositions = new Float32Array(stagingBuffer.getMappedRange()),
  for (let y = 0; y < height; ++y) {
    for (let x = 0; x < width; ++x) {
      const vertexIndex = y * width + x;
      const offset = vertexIndex * 3;

      vertexPositions[offset + 0] = x;
      vertexPositions[offset + 1] = y;
      vertexPositions[offset + 2] = Math.sin(time + (x + y) * 0.1);
    }
  }
  stagingBuffer.unmap();

  // Copy the staging buffer contents to the vertex buffer.
  const commandEncoder = gpuDevice.createCommandEncoder({});
  commandEncoder.copyBufferToBuffer(stagingBuffer, 0, waveGridVertexBuffer, 0, waveGridBufferSize);
  gpuDevice.queue.submit([commandEncoder.finish()]);

  // Immediately after copying, re-map the buffer. Push onto the list of staging buffers when the
  // mapping completes.
  stagingBuffer.mapAsync(GPUMapMode.WRITE).then(() => {
    waveGridStagingBuffers.push(stagingBuffer);
  });
}

数学无处不在,生成在GPU上的数据!

虽然超出了这份文档的范围,但如果我不提及一种快速将数据放入缓冲区的终极技术,我会感到遗憾:在GPU上生成它!具体而言,WebGPU的计算着色器是高效填充缓冲区的绝佳工具。这样做的巨大优势是不需要任何临时缓冲区,因此避免了复制的需要。当然,GPU端的缓冲区生成只有在您的数据可以完全通过算法计算且不适用于从文件加载的模型等情况下才真正奏效。

现实世界的例子 如果您想在现实世界中看到这些技术(以及其他一些技术)在实际工作中的效果,您应该查看我的WebGPU Metaballs演示。使用"metaballMethod"下拉菜单选择要使用的缓冲区填充方法,尽管不要期望在它们之间看到太大的性能差异(除了计算着色器方法)。您还可以查看每种技术的代码,其中有注释解释每种技术。它还详细说明了这里没有涵盖的另外两种模式,主要是因为它们在它们是最有效的路径的情况下相当罕见。

进一步阅读 如果您想更多地了解缓冲区使用的机制,我建议查阅WebGPU Explainer和WebGPU规范的相关部分。特别是规范并不是我所说的"轻松阅读",但它详细描述了WebGPU缓冲区的预期行为。

玩得开心,创造出酷炫的东西!WebGPU中用于将数据传递到GPU的各种模式可能会使这一领域感到混乱,可能有点令人生畏,但它不必如此!要记住的第一件事是,这种灵活性存在是为了为高端专业应用程序提供一种紧密控制其性能的方式。对于普通的WebGPU开发人员,您可以并且应该从使用最简单的方法开始:调用writeBuffer()来更新缓冲区,也许对于只需要设置一次的缓冲区使用mappedAtCreation。这些不是"简化的"辅助函数!它们是推荐的,高性能的路径,碰巧也是最简单的路径。只有当您发现向缓冲区写入是应用程序的瓶颈,并且您能够确定适合您的用例的替代技术时,才尝试变得更炫酷。

祝您在即将进行的任何项目中好运,我迫不及待想看到Web社区构建的令人瞩目的创意!