别找 vkCmdPipelineBarrier 了:WebGPU 如何在多 Pass 间安全共享原子数据
如果你有 Vulkan 或 Direct3D 12 的开发背景,在刚接触 WebGPU 时,面对多 Pass 之间的资源同步,你可能会本能地去寻找类似 vkCmdPipelineBarrier 或 ResourceBarrier 这样的 API。
然而你会发现,WebGPU 的 API 里根本没有“显存屏障(Memory Barrier)”这个显式调用的概念。
这并不意味着 WebGPU 不需要同步。相反,WebGPU 作为现代图形 API,有着极为严格的资源屏障机制,只不过它将这一复杂的机制隐式化了。本文将深入探讨 WebGPU 如何通过“使用范围(Usage Scopes)”自动处理显存屏障,并展示如何在不同的 Pass(例如 Compute Pass 与 Render Pass)之间安全地共享和同步原子状态数据(Atomic Data)。
为什么 WebGPU 不需要显式屏障?
在 Vulkan 中,你需要手动指定源管线阶段(SrcStage)、目的管线阶段(DstStage)以及内存访问类型(AccessMask)。一旦指错,就会导致神秘的 GPU 挂起或画面闪烁(Data Hazard)。
WebGPU 为了保障 Web 端的安全性和可移植性,引入了**使用范围(Usage Scopes)和资源状态追踪(Resource State Tracking)**机制:
- Pass 边界即是屏障:WebGPU 的
GPUCommandEncoder在记录命令时,以 Pass(Compute Pass 或 Render Pass)为基本单元。 - 状态冲突检测:在一个 Pass 内部,一个资源(如
GPUBuffer)不能同时处于冲突的状态。例如,你不能在同一个 Render Pass 中既把一个 Buffer 当作STORAGE(可写)绑定,又当作INDEX(可读)绑定。 - 隐式屏障注入:当你在同一个 Command Encoder 中先后开启 Pass A 和 Pass B,且两个 Pass 都使用了同一个 Buffer 时,WebGPU 的浏览器底层实现(如 Dawn 或 wgpu)会检测到这个资源的使用转换(Transition)。在提交到 GPU 执行时,底层会自动在 Pass A 和 Pass B 之间插入对应的硬件级显存屏障(如 Vulkan 的
VkBufferMemoryBarrier或 D3D12 的ResourceBarrier)。
核心场景:Compute Pass 写入原子计数器,Render Pass 读取
我们通过一个经典的场景来演示这一机制:在 Compute Shader 中使用原子操作(Atomics)统计某种状态,并在随后的 Render Shader 中读取该原子计数器进行着色或顶点定位。
1. WGSL 声明与 Buffer 创建
首先,我们在 WGSL 中定义原子数据结构。注意,WGSL 要求原子类型(atomic<u32> 或 atomic<i32>)必须包裹在结构体中,且只能存储在具有 read_write 访问权限的 storage 变量中。
// WGSL 结构体定义
struct AtomicCounter {
value: atomic<u32>,
}
@group(0) @binding(0)
var<storage, read_write> counter: AtomicCounter;
在 JS/TS 端创建该 Buffer 时,必须赋予其 STORAGE 用途。如果后续还需要在 CPU 端读取,或者作为 Uniform 读取,需要复合相应的 usage:
const atomicBuffer = device.createBuffer({
size: 4, // 一个 u32 占 4 字节
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
// 初始化值为 0
new Uint32Array(atomicBuffer.getMappedRange())[0] = 0;
atomicBuffer.unmap();
2. Pass 1:Compute Pass 执行原子自增
在 Compute Pass 中,多个工作组(Workgroups)会并发写入这个 Buffer。由于我们在着色器中使用了 atomicAdd,GPU 底层会保证硬件级的原子性,但我们仍然需要关注 Pass 间的可见性。
const commandEncoder = device.createCommandEncoder();
// 开启 Compute Pass
const computePass = commandEncoder.beginComputePass();
computePass.setPipeline(computePipeline);
computePass.setBindGroup(0, computeBindGroup); // 绑定了 atomicBuffer
computePass.dispatchWorkgroups(64);
computePass.end(); // 结束 Compute Pass
关键点:当 computePass.end() 被调用时,WebGPU 知道 atomicBuffer 在这个 Pass 中的生命周期结束了。此时,该 Buffer 处于“已被 Compute Shader 写入”的状态。
3. Pass 2:Render Pass 读取原子数据
接下来,我们在同一个 Command Encoder 中开启 Render Pass,并将同一个 Buffer 作为只读数据传入(例如,用来控制绘制实例的数量,或者作为 Storage Buffer 进行只读查询)。
// Render Shader 中的声明
struct RenderData {
counter_value: u32, // 注意:在只读模式下,可以直接声明为普通 u32,不需要 atomic<u32>
}
@group(0) @binding(0)
var<storage, read> renderCounter: RenderData;
// 开启 Render Pass
const renderPass = commandEncoder.beginRenderPass(renderPassDescriptor);
renderPass.setPipeline(renderPipeline);
renderPass.setBindGroup(0, renderBindGroup); // 同样绑定 atomicBuffer,但布局为 read-only storage
// ... 执行绘制
renderPass.end();
// 提交队列
device.queue.submit([commandEncoder.finish()]);
为什么这是绝对安全的?
当底层的 WebGPU 驱动解析 commandEncoder.finish() 时,它会发现:
atomicBuffer在ComputePass中被作为read-write storage(写操作)使用。- 随后在
RenderPass中被作为read storage(读操作)使用。 - 这构成了一个典型的 写后读(RAW - Read After Write) 依赖。
为了防止 Render Pass 读到未完成写入的脏数据,WebGPU 底层会在 ComputePass 结束和 RenderPass 开始之间,自动插入一个 GPU 内存屏障(Memory Barrier)和执行屏障。该屏障会刷空(Flush)L1/L2 缓存中关于该 Buffer 的写入,并确保 Compute Pass 的所有着色器执行完毕后,Render Pass 才会开始读取。
避坑指南:多 Pass 共享原子的常见错误
尽管 WebGPU 帮我们处理了最棘手的屏障注入,但在实际开发中,依然有几个高频踩坑点会导致同步失败或 Validation Error。
错误一:混淆工作组屏障(Workgroup Barrier)与显存屏障
在编写 WGSL 时,你可能会看到 workgroupBarrier() 或 storageBarrier()。
- 它们的作用域极其有限:它们只能同步同一个工作组(Workgroup)内部的不同线程(Invocations)。
- 不能跨 Pass 同步:你无法通过在 WGSL 中调用
storageBarrier()来同步两个不同的 Dispatch 或者是不同的 Pass。跨 Pass 的数据安全必须完全依赖上述的 Pass 边界转换。
错误二:在同一个 Pass 中进行不安全的读写
如果你尝试在同一个 Pass 中,既把 Buffer 绑为可写,又绑为可读,WebGPU 验证层会直接报错:
// 错误示范:在同一个 Render Pass 中冲突绑定
const renderPass = commandEncoder.beginRenderPass({...});
renderPass.setBindGroup(0, writeBindGroup); // 内部包含 atomicBuffer (read_write)
renderPass.setBindGroup(1, readBindGroup); // 内部也包含 atomicBuffer (read)
WebGPU 严格遵守**单写多读(Single Writer or Multiple Readers)**原则。在任何一个 Pass 内部,一个资源要么只能被写(且不能有其他未同步的读),要么只能被多个地方只读。如果非要在同一个 Pass 内进行复杂的读写交替,必须全部声明为 read_write,并在 Shader 内部使用 atomic 函数,或者拆分成多个 Pass 执行。
错误三:CPU 异步读取(MapAsync)的过早调用
如果你想在 CPU 端获取这个原子计数器的值,你需要使用 buffer.mapAsync()。一个常见的错误是提交了 Command Buffer 后,立刻去 map,而没有考虑 GPU 还在执行中。
// 正确的 CPU 读取流程
device.queue.submit([commandEncoder.finish()]);
// 必须等待 mapAsync 承兑的 Promise
await atomicBuffer.mapAsync(GPUMapMode.READ);
const copyArray = new Uint32Array(atomicBuffer.getDerivedValue().slice());
atomicBuffer.unmap();
console.log("当前原子计数器的值:", copyArray[0]);
WebGPU 的队列提交是异步的,mapAsync 会在 GPU 侧的所有前置命令(包括写入该 Buffer 的 Pass)彻底执行完毕、屏障释放后,才会真正触发 Promise 的 resolve,从而保证了 CPU 端读到的是最新的安全数据。
总结
WebGPU 采用的隐式资源状态追踪极大地简化了现代 GPU 开发的门槛。要在不同的 Pass 之间安全地共享原子状态数据,你只需要做到两点:
- 合理划分 Pass:将“写入原子”与“读取原子”拆分到不同的 Pass 中。
- 正确声明 BindGroupLayout:在写入 Pass 的布局中声明为
read_write,在读取 Pass 的布局中声明为read(或 Uniform、Vertex 等)。
只要做到了这两点,WebGPU 就会在幕后为你打理好一切底层的显存屏障,让你的原子操作在保障极致性能的同时,绝对不发生数据竞争。