此示例需要 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把下标计算换成 @builtin(global_invocation_id) gid,并用 gid.x 代替 wg.x * 64u + lid,结果应该完全一样。
- 2把 WG 改成 256,重新跑 benchmark,看工作组数量和耗时怎么变化。
- 3把公式换成你自己的函数(比如 x * x),柱状图会跟着改变。
还没有输出。console.log 的内容会显示在这里。
运行中