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 后,将其放回已映射缓冲池。如果任何时候需要一个映射缓冲区且没有可用的,就创建一个新的并加入缓冲池。
核心优化要点
-
优先使用
writeBuffer/writeTexture进行直接数据上传
对于一次性或非频繁更新的数据,直接使用device.queue.writeBuffer或device.queue.writeTexture是最简单且开销较低的方式。它们不在命令编码器内部执行,适合初始化数据。 -
注意
copyBufferToTexture/copyTextureToBuffer的bytesPerRow必须为 256 的倍数
这是 WebGPU 规范中的硬性要求,不满足会导致校验错误。在上传纹理数据时,应提前对齐每一行的字节数,避免运行时错误或性能下降。 -
使用
mappedAtCreation和映射缓冲区池优化数据初始化与读取mappedAtCreation: true允许在缓冲区创建时同步写入数据,避免额外的映射等待。- 对于频繁的异步映射操作(如读取 GPU 计算结果),维护一个已映射缓冲区池可以消除
mapAsync的等待延迟,提高吞吐量。
专业术语检查
本文涉及 WebGPU 术语,未出现 GLSL 相关内容(WebGPU 使用 WGSL)。以下为关键术语的翻译对照及检查结果:
| 英文术语 | 建议中文译法 | 备注 |
|---|---|---|
| buffer | 缓冲区 | 与 WebGL 一致 |
| texture | 纹理 | 与 WebGL 一致 |
| mipLevel | mip 级别 / 多级渐远级别 | 保留 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 | 映射 | 异步过程 |
| mapAsync | mapAsync | 保留英文 API 名 |
| GPUMapMode | GPUMapMode | 保留英文 API 名 |
| mappedAtCreation | 创建时映射 | 标志名保留英文 |
| aspect | 纹理方面 | 深度/模板纹理中区分深度部分和模板部分 |
| compressed texture | 压缩纹理 | 如 BC1 格式 |
| block width/height | 块宽度/块高度 | 压缩纹理相关 |
以上术语均按 WebGPU 官方规范及社区惯例翻译,代码中的 API 名称、枚举值等保持英文原样,符合技术文章惯例。
脚注
[1] 例外情况是设置 mappedAtCreation: true,参见 mappedAtCreation。
