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

计算着色器做图像处理

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

图像就是二维数组

源图像上传到一张普通纹理(copyExternalImageToTexture),结果写进一张存储纹理(texture_storage_2d<rgba8unorm, write>),计算着色器用 textureStore 按像素写入。一个 16×16 的工作组负责一个 16×16 的图块,dispatchWorkgroups(64, 40) 覆盖 1024×640 的图片。程序启动时先用 copyTextureToTexture 把原图拷进输出纹理,所以管线还在编译时,右半边也不是黑的。

线程坐标

wgsl
fn pixel(wg : vec2u, lid : u32) -> vec2u {
  return wg * TILE + vec2u(lid % TILE, lid / TILE);
}

工作组位置乘以 16,加上组内坐标,等价于 global_invocation_id.xy。这里的除法和取模都是对常量 TILE。

共享内存与图块

9×9 的模糊每个像素要读 81 个邻居。直接读纹理,相邻线程会把同一个纹素读很多遍。更好的做法是:

  1. 工作组里的 256 个线程一起把 (16+2·4)² = 576 个纹素(图块加上四周 4 像素的“光环”)搬进 var<workgroup> 共享内存;
  2. workgroupBarrier() 等所有线程搬完;
  3. 每个线程只从快速的共享内存读邻居。
wgsl
var<workgroup> tile : array<vec4f, 576>;
for (var i = lid; i < SIDE * SIDE; i += TILE * TILE) { tile[i] = textureLoad(src, ...); }
workgroupBarrier();

注意:越界检查的 return 必须放在 barrier 之后。barrier 要求组内所有线程都到达,提前返回的线程会让其他线程永远等下去(或导致未定义行为)。

三种滤镜

  • blur:高斯核可分离,w(dx, dy) = w1(dx) · w1(dy),一维权重只算一次。
  • edges:Sobel 算子求水平和垂直亮度梯度,按梯度方向上色。
  • grade:曝光、色温、对比度、饱和度、暗角。只看自己一个像素,不需要共享内存。

只在需要时计算

只有参数变化时才重新调度计算,平时每帧只是把前后两张纹理按分割线合成到画布。管线用 createComputePipelineAsync 按需编译,不阻塞页面。渲染循环同样用 onSubmittedWorkDone 把在途帧数限制在 2 帧。

状态栏里的 “GPU frame” 是提交到完成的时间。在软件 GPU 上可能要上千毫秒,看起来比 JS 还慢;真实显卡上同样的滤镜通常只要零点几毫秒。

核心 API

var<workgroup>workgroupBarriertexture_storage_2dtextureLoadtextureStoredevice.queue.copyExternalImageToTextureencoder.copyTextureToTexturedevice.createComputePipelineAsyncpass.dispatchWorkgroups(x, y)

动手试试

  1. 1把 blur 的 sigma 调到 4,再把 R 改大(比如 6),看共享内存的光环需要多大。
  2. 2把三个入口函数的参数换成 @builtin(global_invocation_id) gid,用 gid.xy 作为像素坐标(loadTile 仍需要 workgroup_id)。
  3. 3写一个新的 grade 效果,比如把 luma 量化成 4 级做海报化,加到 filter 列表里。
运行中