跳到主要内容
3D Playground
GPU 与 WebGPU 计算第 4 / 11 节

计算着色器入门

进阶WebGPU
此示例需要 WebGPU(Chrome / Edge 113+、Safari 26+)。

不画图的着色器

计算着色器(@compute)没有顶点和像素的概念,它只是“对一百万个元素各跑一次函数”。输入输出都是存储缓冲(storage buffer):var<storage, read> 只读,var<storage, read_write> 可写。

工作组与调度

@workgroup_size(64) 表示一个工作组有 64 个线程;JS 端 dispatchWorkgroups(Math.ceil(n / 64)) 决定启动多少个工作组。100 万个元素就是 16384 个工作组。工作组大小一般取 32 或 64 的倍数,正好填满硬件的 warp。

线程编号:三种内置变量

每个线程要知道自己负责哪个元素。这个例子这样计算全局下标:

wgsl
fn main(@builtin(workgroup_id) wg : vec3u,
        @builtin(local_invocation_index) lid : u32) {
  let i = wg.x * 64u + lid;
  if (i >= P.n) { return; }
  output[i] = sin(input[i] * P.freq + P.phase) * exp(-input[i] * P.decay);
}
  • workgroup_id:第几个工作组;
  • local_invocation_index:组内第几个线程(0…63);
  • global_invocation_id:两者合起来的全局编号,即 workgroup_id * workgroup_size + local_invocation_id。

所以 wg.x * 64u + lid 与 global_invocation_id.x 完全等价。这里把它拆开写,是为了看清三者的关系(也能绕开一些软件 GPU 的兼容问题)。最后一个工作组可能有多余线程,所以要做越界检查。

把结果读回来

GPU 内存不能被 JS 直接读取。流程是:计算结果拷贝到一个带 MAP_READ 用途的暂存缓冲,await staging.mapAsync(GPUMapMode.READ) 等 GPU 完成,再用 getMappedRange() 读取,最后 unmap()。读回会强制 CPU 等 GPU,开销不小,所以真实应用尽量让数据留在 GPU 上。

同一个缓冲,算完就画

渲染循环里,计算 pass 写 output,紧接着的渲染 pass 在顶点着色器里读它画柱状图,全程不经过 CPU。WebGPU 会自动保证两个 pass 的先后顺序;在途帧数用 onSubmittedWorkDone 限制在 2 帧以内。

点 run benchmark 会对比“GPU 调度 + 读回”和 JS 循环。在软件 GPU(如测试环境里的 SwiftShader)上,GPU 可能显得比 JS 还慢;在真实显卡上,计算本身快得多,读回才是主要开销。

核心 API

@compute@workgroup_size@builtin(workgroup_id)@builtin(local_invocation_index)var<storage, read_write>device.createComputePipelinepass.dispatchWorkgroupsencoder.copyBufferToBufferbuffer.mapAsyncGPUMapMode.READ

动手试试

  1. 1把下标计算换成 @builtin(global_invocation_id) gid,并用 gid.x 代替 wg.x * 64u + lid,结果应该完全一样。
  2. 2把 WG 改成 256,重新跑 benchmark,看工作组数量和耗时怎么变化。
  3. 3把公式换成你自己的函数(比如 x * x),柱状图会跟着改变。
运行中