此示例需要 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 个邻居。直接读纹理,相邻线程会把同一个纹素读很多遍。更好的做法是:
- 工作组里的 256 个线程一起把 (16+2·4)² = 576 个纹素(图块加上四周 4 像素的“光环”)搬进
var<workgroup>共享内存; workgroupBarrier()等所有线程搬完;- 每个线程只从快速的共享内存读邻居。
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把 blur 的 sigma 调到 4,再把 R 改大(比如 6),看共享内存的光环需要多大。
- 2把三个入口函数的参数换成 @builtin(global_invocation_id) gid,用 gid.xy 作为像素坐标(loadTile 仍需要 workgroup_id)。
- 3写一个新的 grade 效果,比如把 luma 量化成 4 级做海报化,加到 filter 列表里。
还没有输出。console.log 的内容会显示在这里。
运行中