WebGPU 数据拷贝

原文作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-copying-data.html


在之前的文章中,我们大多使用 writeBuffer 向缓冲区写入数据,用 writeTexture 向纹理写入数据。实际上,向缓冲区或纹理写入数据有多种方式。

writeBuffer

writeBuffer 将 JavaScript 中的 TypedArray 或 ArrayBuffer 的数据复制到缓冲区。这是最直接的数据写入方式。

writeBuffer 的调用格式如下:

device.queue.writeBuffer(
  destBuffer,  // 要写入的缓冲区
  destOffset,  // 目标缓冲区中开始写入的偏移量(字节)
  srcData,     // 类型化数组或 ArrayBuffer
  srcOffset?,  // srcData 中开始复制的偏移量,单位是 **元素个数**
  size?,       // 要从 srcData 复制的 **元素个数**
)

如果不传 srcOffset,则默认为 0;如果不传 size,则默认为 srcData 的大小。

重要提示:srcOffset 和 size 的单位是 srcData 的 元素个数。

换句话说:

device.queue.writeBuffer(
  someBuffer,
  someOffset,
  someFloat32Array,
  6,
  7,
)

上面的代码将从第 6 个 float32 开始复制,复制 7 个 float32 数据。 换句话说,就是从 someFloat32Array 所对应的 ArrayBuffer 视图的字节偏移 24 开始,复制 28 个字节。

writeTexture

writeTexture 将 JavaScript 中的 TypedArray 或 ArrayBuffer 的数据复制到纹理。

writeTexture 的函数签名如下:

device.queue.writeTexture(
  // 目标纹理的详细信息
  { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" },

  // 源数据
  srcData,

  // 源数据的布局信息
  { offset: 0, bytesPerRow, rowsPerImage },

  // 大小:
  [ width, height, depthOrArrayLayers ] 或 { width, height, depthOrArrayLayers }
)

需要注意的几点:

  • texture 必须具有 GPUTextureUsage.COPY_DST 用途标志。
  • mipLevel、origin 和 aspect 都有默认值,因此通常不必指定。
  • bytesPerRow:表示从当前 块行 前进到下一个块行需要多少字节。 如果你要复制超过 1 个 块行,此参数几乎总是必需的(而实际使用中几乎总是超过 1 个块行)。
  • rowsPerImage:表示从一张图像的开头前进到下一张图像的开头需要多少 块行。 如果你要复制超过 1 个层(即 depthOrArrayLayers > 1),则必须提供此值。

可以将复制过程想象成以下伪代码:

   // 伪代码
   const [x, y, z] = origin ?? [0, 0, 0];
   const [blockWidth, blockHeight, bytesPerBlock] =
      getBlockInfoForTextureFormat(texture.format);

   const blocksAcross = width / blockWidth;
   const blocksDown = height / blockHeight;
   const bytesPerBlockRow = blocksAcross * bytesPerBlock;

   for (layer = 0; layer < depthOrArrayLayers; layer) {
      for (row = 0; row < blocksDown; ++row) {
        const start = offset + (layer * rowsPerImage + row) * bytesPerRow;
        copyRowToTexture(
            texture,               // 要复制到的纹理
            x, y + row, z + layer, // 纹理中的目标位置
            srcDataAsBytes + start,
            bytesPerBlockRow);
      }
   }

块行(block row)

纹理按块组织。对于大多数“普通”纹理,块宽度和块高度均为 1。对于压缩纹理则不同。例如,格式 bc1-rgba-unorm 的块宽度为 4,块高度为 4。这意味着如果宽度设为 8,高度设为 12,则只会复制 6 个块:第一行 2 个,第二行 2 个,第三行 2 个。

对于压缩纹理,大小和原点必须与块大小对齐。

重要提示:在 WebGPU 中,凡是接收大小(定义为 GPUExtent3D)的地方,都可以是包含 1 到 3 个数字的数组,也可以是包含 1 到 3 个属性的对象。height 和 depthOrArrayLayers 默认值为 1,因此:

  • [2] 表示大小 width = 2, height = 1, depthOrArrayLayers = 1
  • [2, 3] 表示大小 width = 2, height = 3, depthOrArrayLayers = 1
  • [2, 3, 4] 表示大小 width = 2, height = 3, depthOrArrayLayers = 4
  • { width: 2 } 表示大小 width = 2, height = 1, depthOrArrayLayers = 1
  • { width: 2, height: 3 } 表示大小 width = 2, height = 3, depthOrArrayLayers = 1
  • { width: 2, height: 3, depthOrArrayLayers: 4 } 表示大小 width = 2, height = 3, depthOrArrayLayers = 4

同样,凡是出现原点(默认为 GPUOrigin3D)的地方,都可以是包含 3 个数字的数组,也可以是包含 x、y、z 属性的对象。所有组件的默认值均为 0,因此:

  • [5] 表示原点 x = 5, y = 0, z = 0
  • [5, 6] 表示原点 x = 5, y = 6, z = 0
  • [5, 6, 7] 表示原点 x = 5, y = 6, z = 7
  • { x: 5 } 表示原点 x = 5, y = 0, z = 0
  • { x: 5, y: 6 } 表示原点 x = 5, y = 6, z = 0
  • { x: 5, y: 6, z: 7 } 表示原点 x = 5, y = 6, z = 7
  • aspect 实际上只在向深度-模板格式复制数据时才重要。一次只能复制一个方面:要么仅复制深度(depth-only),要么仅复制模板(stencil-only)。

小知识:纹理对象本身具有 width、height 和 depthOrArrayLayer 属性,这意味着它本身就是一个合法的 GPUExtent3D。换句话说,对于下面这个纹理:

const texture = device.createTexture({
  format: 'r8unorm',
  size: [2, 4],
  usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_ATTACHMENT,
});

以下所有写法都是有效的:

// 复制 2x4 像素的数据到纹理
const bytesPerRow = 2;
device.queue.writeTexture({ texture }, data, { bytesPerRow }, [2, 4]);
device.queue.writeTexture({ texture }, data, { bytesPerRow }, [texture.width, texture.height]);
device.queue.writeTexture({ texture }, data, { bytesPerRow }, {width: 2, height: 4});
device.queue.writeTexture({ texture }, data, { bytesPerRow }, {width: texture.width, height: texture.height});
device.queue.writeTexture({ texture }, data, { bytesPerRow }, texture); // !!!

最后一种写法能成立是因为纹理对象拥有 width、height 和 depthOrArrayLayers 属性。我们没有使用过这种风格,因为它不那么直观,但它是合法的。

copyBufferToBuffer

copyBufferToBuffer,顾名思义,将一个缓冲区的数据复制到另一个缓冲区。

函数签名:

encoder.copyBufferToBuffer(
  source,       // 源缓冲区
  sourceOffset, // 源缓冲区中的起始偏移量(字节)
  dest,         // 目标缓冲区
  destOffset,   // 目标缓冲区中的起始偏移量(字节)
  size,         // 要复制的字节数
)
  • source 必须具有 GPUBufferUsage.COPY_SRC 用途标志。
  • dest 必须具有 GPUBufferUsage.COPY_DST 用途标志。
  • size 必须是 4 的倍数。

copyBufferToTexture

copyBufferToTexture,顾名思义,将缓冲区数据复制到纹理。

函数签名:

encoder.copyBufferToTexture(
  // 源缓冲区的详细信息
  { buffer, offset: 0, bytesPerRow, rowsPerImage },

  // 目标纹理的详细信息
  { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" },

  // 大小:
  [ width, height, depthOrArrayLayers ] 或 { width, height, depthOrArrayLayers }
)

此函数的参数与 writeTexture 几乎完全相同。最大的区别在于:bytesPerRow 必须是 256 的倍数!

  • texture 必须具有 GPUTextureUsage.COPY_DST 用途标志。
  • buffer 必须具有 GPUBufferUsage.COPY_SRC 用途标志。

copyTextureToBuffer

copyTextureToBuffer,顾名思义,将纹理数据复制到缓冲区。

函数签名:

encoder.copyTextureToBuffer(
  // 源纹理的详细信息
  { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" },

  // 目标缓冲区的详细信息
  { buffer, offset: 0, bytesPerRow, rowsPerImage },

  // 大小:
  [ width, height, depthOrArrayLayers ] 或 { width, height, depthOrArrayLayers }
)

此函数的参数与 copyBufferToTexture 类似,只是纹理(现在是源)和缓冲区(现在是目标)互换了。与 copyBufferToTexture 一样,bytesPerRow 必须是 256 的倍数!

  • texture 必须具有 GPUTextureUsage.COPY_SRC 用途标志。
  • buffer 必须具有 GPUBufferUsage.COPY_DST 用途标志。

copyTextureToTexture

copyTextureToTexture 将一个纹理的一部分复制到另一个纹理。

两个纹理的格式必须相同,或者只能相差 '-srgb' 后缀。

函数签名:

encoder.copyTextureToTexture(
  // 源纹理的详细信息
  src: { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" },

  // 目标纹理的详细信息
  dst: { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" },

  // 大小:
  [ width, height, depthOrArrayLayers ] 或 { width, height, depthOrArrayLayers }
)
  • src.texture 必须具有 GPUTextureUsage.COPY_SRC 用途标志。
  • dst.texture 必须具有 GPUTextureUsage.COPY_DST 用途标志。
  • width 必须是块宽度的倍数。
  • height 必须是块高度的倍数。
  • src.origin[0] 或 .x 必须是块宽度的倍数。
  • src.origin[1] 或 .y 必须是块高度的倍数。
  • dst.origin[0] 或 .x 必须是块宽度的倍数。
  • dst.origin[1] 或 .y 必须是块高度的倍数。

着色器

着色器可以读写存储缓冲区(storage buffer)、存储纹理(storage texture),还可以间接渲染到纹理。这些都是向缓冲区和纹理写入数据的方式。换句话说,你可以编写着色器来生成和/或复制、传输数据。

映射缓冲区(Mapping Buffers)

你可以映射一个缓冲区。映射缓冲区意味着让它可以从 JavaScript 读取或写入。

至少在 WebGPU 的第一版中,可映射缓冲区有严格的限制:可映射缓冲区只能用作临时数据的中转站,用于向其他缓冲区/纹理复制数据或从其他缓冲区/纹理读取数据。可映射缓冲区不能用作任何其他类型的缓冲区(如 uniform 缓冲区、顶点缓冲区、索引缓冲区、存储缓冲区等)[1]。

你可以使用两种用途标志组合来创建可映射缓冲区:

  • GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST

    这种缓冲区可以使用上面的复制命令从其他缓冲区或纹理复制数据,然后映射它,以便在 JavaScript 中读取这些值。

  • GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC

    这种缓冲区可以在 JavaScript 中映射,然后从 JavaScript 写入数据,最后解映射,再使用上面的复制命令将其内容复制到另一个缓冲区或纹理。

映射缓冲区的过程是异步的。你需要调用 buffer.mapAsync(mode, offset = 0, size?),其中 offset 和 size 的单位是字节。如果不指定 size,则表示整个缓冲区。mode 必须是 GPUMapMode.READ 或 GPUMapMode.WRITE,并且必须与创建缓冲区时传入的 MAP_ 用途标志匹配。

mapAsync 返回一个 Promise。当 Promise resolve 后,缓冲区就处于可映射状态。然后你可以通过调用 buffer.getMappedRange(offset = 0, size?) 来查看缓冲区的全部或一部分,其中 offset 是映射区域的字节偏移。getMappedRange 返回一个 ArrayBuffer,因此通常需要用它构建类型化数组来实际使用。

以下是一个映射缓冲区的示例:

const buffer = device.createBuffer({
  size: 1024,
  usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});

// 映射整个缓冲区
await buffer.mapAsync(GPUMapMode.READ);

// 将整个缓冲区作为 32 位浮点数组获取
const f32 = new Float32Array(buffer.getMappedRange())

...

buffer.unmap();

注意:一旦映射,缓冲区在调用 unmap 之前不能被 WebGPU 使用。调用 unmap 的瞬间,缓冲区从 JavaScript 中“消失”。换句话说,看上面的例子:

const f32 = new Float32Array(buffer.getMappedRange())

f32[0] = 123;
console.log(f32[0]); // 输出 123

buffer.unmap();

console.log(f32[0]); // 输出 undefined

我们已经在第一篇文章中见过映射缓冲区用于读取的示例:我们将一些数字翻倍后放入存储缓冲区,然后将结果复制到可映射缓冲区并映射以读取结果。

另一个示例是计算着色器基础一文,我们将各种 @builtin 计算着色器的值输出到存储缓冲区,然后将结果复制到可映射缓冲区并映射读取结果。

mappedAtCreation

mappedAtCreation: true 是创建缓冲区时可以添加的一个标志。在这种情况下,缓冲区不需要 GPUBufferUsage.COPY_DST 或 GPUBufferUsage.MAP_WRITE 用途标志。

这是一个特殊标志,仅用于在创建缓冲区时向其中写入数据。创建缓冲区时添加 mappedAtCreation: true,缓冲区在创建后即处于已映射写入状态。示例:

 const buffer = device.createBuffer({
   size: 16,
   usage: GPUBufferUsage.UNIFORM,
   mappedAtCreation: true,
 });
 const arrayBuffer = buffer.getMappedRange(0, buffer.size);
 const f32 = new Float32Array(arrayBuffer);
 f32.set([1, 2, 3, 4]);
 buffer.unmap();

或者更简洁地:

 const buffer = device.createBuffer({
   size: 16,
   usage: GPUBufferUsage.UNIFORM,
   mappedAtCreation: true,
 });
 new Float32Array(buffer.getMappedRange(0, buffer.size)).set([1, 2, 3, 4]);
 buffer.unmap();

请注意,使用 mappedAtCreation: true 创建的缓冲区不会自动设置任何其他用途标志。它只是为了在创建缓冲区时方便地写入数据。它在创建时处于映射状态,一旦你调用一次 unmap,它就像普通缓冲区一样,只能用于你指定的用途。换句话说,如果你以后想向它复制数据,需要 GPUBufferUsage.COPY_DST;如果你想以后映射它,需要 GPUBufferUsage.MAP_READ 或 GPUBufferUsage.MAP_WRITE。

高效使用可映射缓冲区

上面我们看到映射缓冲区是异步的。这意味着从调用 mapAsync 请求映射缓冲区,到映射完成可以调用 getMappedRange 之间存在不确定的时间间隔。

一种常见的解决方法是一直保持一组缓冲区处于已映射状态。由于它们已经映射,因此可以立即可用。一旦使用完并调用 unmap,并且一旦提交了使用该缓冲区的命令,就再次请求映射它。当它的 Promise resolve 后,将其放回已映射缓冲池。如果任何时候需要一个映射缓冲区且没有可用的,就创建一个新的并加入缓冲池。


核心优化要点

  1. 优先使用 writeBuffer / writeTexture 进行直接数据上传
    对于一次性或非频繁更新的数据,直接使用 device.queue.writeBuffer 或 device.queue.writeTexture 是最简单且开销较低的方式。它们不在命令编码器内部执行,适合初始化数据。

  2. 注意 copyBufferToTexture / copyTextureToBuffer 的 bytesPerRow 必须为 256 的倍数
    这是 WebGPU 规范中的硬性要求,不满足会导致校验错误。在上传纹理数据时,应提前对齐每一行的字节数,避免运行时错误或性能下降。

  3. 使用 mappedAtCreation 和映射缓冲区池优化数据初始化与读取

    • mappedAtCreation: true 允许在缓冲区创建时同步写入数据,避免额外的映射等待。
    • 对于频繁的异步映射操作(如读取 GPU 计算结果),维护一个已映射缓冲区池可以消除 mapAsync 的等待延迟,提高吞吐量。

专业术语检查

本文涉及 WebGPU 术语,未出现 GLSL 相关内容(WebGPU 使用 WGSL)。以下为关键术语的翻译对照及检查结果:

英文术语建议中文译法备注
buffer缓冲区与 WebGL 一致
texture纹理与 WebGL 一致
mipLevelmip 级别 / 多级渐远级别保留 mip 原文更常见
block row块行压缩纹理中的概念
bytesPerRow每行字节数注意单位是字节
rowsPerImage每图像行数注意单位是块行数
depthOrArrayLayers深度或数组层与 GPUExtent3D 一致
usage flags用途标志如 GPUBufferUsage.COPY_SRC
storage buffer存储缓冲区对应 GPUBufferUsage.STORAGE
uniform buffer统一缓冲区对应 GPUBufferUsage.UNIFORM
vertex buffer顶点缓冲区对应 GPUBufferUsage.VERTEX
index buffer索引缓冲区对应 GPUBufferUsage.INDEX
mapping映射异步过程
mapAsyncmapAsync保留英文 API 名
GPUMapModeGPUMapMode保留英文 API 名
mappedAtCreation创建时映射标志名保留英文
aspect纹理方面深度/模板纹理中区分深度部分和模板部分
compressed texture压缩纹理如 BC1 格式
block width/height块宽度/块高度压缩纹理相关

以上术语均按 WebGPU 官方规范及社区惯例翻译,代码中的 API 名称、枚举值等保持英文原样,符合技术文章惯例。


脚注
[1] 例外情况是设置 mappedAtCreation: true,参见 mappedAtCreation。