WEBKT

从 WebGL 迁移到 WebGPU:如何重构多 Pass 后期处理管线以榨干 GPU 并行性能?

2 0 0 0

在 WebGL 时代,开发复杂的后期处理特效(如 Bloom、SSAO、景深、径向模糊等)通常是一件令人头疼的事。受限于 WebGL(特别是 WebGL 2.0 之前)缺乏计算着色器(Compute Shader)的支持,我们不得不依赖**“全屏 Quad + 帧缓冲区(FBO)Ping-Pong 交换”**的模式。这种模式在面对多 Pass(多通道)管线时,会产生极高的 CPU 提交开销、频繁的纹理绑定与上下文切换,以及无法避免的全局内存读写延迟。

WebGPU 的到来彻底改变了这一游戏规则。它不仅带来了原生 Compute Shader,还引入了更底层的内存控制、工作组(Workgroup)共享内存以及显式的管线状态管理。

本文将深入探讨如何将原有的 WebGL 多 Pass 后期处理管线,重构为高效、高并行的 WebGPU 架构。


一、 WebGL 遗留管线的性能痛点

在构建 WebGPU 新架构之前,我们需要精确定义 WebGL 后期处理管线的“原罪”:

[WebGL 模式]
Render Pass 1 (Downsample) -> 写回 VRAM (FBO A)
       ↓ (CPU 介入,切换 FBO 绑定,产生 Pipeline Barrier)
Render Pass 2 (Blur X)     -> 写回 VRAM (FBO B)
       ↓ (CPU 介入,切换 FBO 绑定)
Render Pass 3 (Blur Y)     -> 写回 VRAM (FBO A)
       ↓
Render Pass 4 (Composite)  -> 最终屏幕 (Backbuffer)
  1. VRAM 带宽吞吐瓶颈:每一次 Pass 都必须将中间像素数据完整地写入显存(VRAM),下一阶段再从显存读回。对于 4K 分辨率,这种来回读写会瞬间吃满显卡带宽。
  2. CPU-GPU 状态同步开销:JS 每一帧都要频繁调用 gl.bindFramebuffergl.useProgramgl.drawElements。每次切换 FBO,GPU 内部的管线都会发生隐式冲刷(Flush),导致硬件单元出现短暂饥饿。
  3. 缺乏局部缓存机制:片元着色器(Fragment Shader)无法共享相邻像素的计算结果。例如在做 9x9 高斯模糊时,相邻的像素会重复采样大量的相同纹理坐标,造成了极大的纹理采样器(Sampler)浪费。

二、 WebGPU 重构的核心架构方案

在 WebGPU 中,重构多 Pass 管线并最大化并行度的核心思路是:能用 Compute Shader 解决的,绝不用 Render Pipeline;能在一个 Pass 内用 Workgroup Shared Memory 解决的,绝不拆分到多个 Pass。

下面是重构后的整体架构图:

                           [ 原始场景渲染纹理 (Texture View) ]
                                          │
                                          ▼
                      ┌────────────────────────────────────────┐
                      │    WebGPU Compute Shader (单 Pass)     │
                      │                                        │
                      │  ┌──────────────────────────────────┐  │
                      │  │  线程组加载像素到 Workgroup Shared  │  │
                      │  │  Memory (仅一次全局显存读取)     │  │
                      │  └──────────────────────────────────┘  │
                      │                   │                    │
                      │                   ▼                    │
                      │  ┌──────────────────────────────────┐  │
                      │  │ 并在内存中并行执行多步算法(如降采样)│  │
                      │  └──────────────────────────────────┘  │
                      │                   │                    │
                      │                   ▼                    │
                      │  ┌──────────────────────────────────┐  │
                      │  │ 使用 Storage Texture 直接原位写入 │  │
                      │  └──────────────────────────────────┘  │
                      └────────────────────────────────────────┘
                                          │
                                          ▼
                             [ 最终呈现 / 写入 Canvas ]

1. 核心转变:从 RenderPass 到 ComputePass

在 WebGL 中,后期处理是一个“拉”(Pull)的过程——通过绘制一个视口大小的三角形,强迫片元着色器在每个像素位置运行。
在 WebGPU 中,后期处理是一个“推”(Push)的过程——通过 dispatchWorkgroups 启动数万个 GPGPU 线程,直接对目标纹理进行像素写入。

通过将渲染管线(GPURenderPipeline)替换为计算管线(GPUComputePipeline),我们去除了顶点装配、光栅化、混合(Blending)等硬件阶段,使 GPU 能够专职进行数学计算与数据读写。

2. 利用 Workgroup Shared Memory 消除 Ping-Pong 延迟

这是并行度提升的关键。以高斯模糊(Gaussian Blur)为例,传统做法是先做横向模糊(Pass 1),写回显存,再做纵向模糊(Pass 2)。

在 WebGPU 中,我们可以利用 var<workgroup> 声明一块工作组共享内存。

  • 每一个工作组(例如 $16 \times 16$ 个线程)一次性将自身及周边边缘所需的像素块批量加载到这块极速的 L1 缓存级内存中。
  • 接着,工作组内部进行横向和纵向滤波,期间所有的中间读写都发生在这块共享内存中,不需要进行任何 VRAM 交互。
  • 最后,仅将最终结果一次性写入到输出的 Storage Texture 中。

WGSL 代码示例(单 Pass 计算着色器内完成局部滤波):

@group(0) @binding(0) var inputTex: texture_2d<f32>;
@group(0) @binding(1) var outputTex: texture_storage_2d<rgba16float, write>;

// 定义工作组共享内存,大小通常为 (Workgroup_Size + 2 * Radius)^2
var<workgroup> sharedPixels: array<array<vec4<f32>, 24>, 24>;

@compute @workgroup_size(16, 16)
fn main(
    @builtin(local_invocation_id) LocalId: vec3<u32>,
    @builtin(global_invocation_id) GlobalId: vec3<u32>
) {
    let localX = LocalId.x + 4u; // 假设模糊半径为 4
    let localY = LocalId.y + 4u;
    let textureSize = textureDimensions(inputTex);

    // 1. 协同加载数据到工作组共享内存中
    if (GlobalId.x < textureSize.x && GlobalId.y < textureSize.y) {
        sharedPixels[localY][localX] = textureLoad(inputTex, vec2<i32>(GlobalId.xy), 0);
        
        // 加载边缘处的像素(此处省略边界处理逻辑,实际开发中需通过分支处理填充边界)
    }
    
    // 2. 引入屏障,确保工作组内所有线程均已完成数据加载
    workgroupBarrier();

    // 3. 直接在共享内存中执行横向与纵向滤波,无任何显存交互
    var blurredColor = vec4<f32>(0.0);
    for (var i: i32 = -4; i <= 4; i++) {
        let weight = vec4<f32>(getGaussianWeight(i)); // 权重计算函数
        blurredColor += sharedPixels[i32(localY)][i32(localX) + i] * weight;
    }

    // 4. 将最终计算结果直接写入 Storage 纹理
    if (GlobalId.x < textureSize.x && GlobalId.y < textureSize.y) {
        textureStore(outputTex, vec2<i32>(GlobalId.xy), blurredColor);
    }
}

三、 多 Pass 架构设计的具体落地

对于更复杂的后期管线(例如:SSAO -> Blur -> HDR Tonemapping -> FXAA),我们无法将其全部塞进单个 Compute Shader 中。此时,我们需要设计一套基于 WebGPU 显式依赖追踪 的多 Pass 调度器。

1. 彻底消灭显式 Barrier:使用单个 CommandEncoder

在 WebGL 中,每次切换 Pass 我们都要将数据强制 Flush 到 GPU。在 WebGPU 中,我们应当将所有后期处理 Pass 的指令录制到同一个 GPUCommandEncoder 中,然后一次性 submit

const commandEncoder = device.createCommandEncoder();

// Pass 1: 计算 SSAO
const ssaoPass = commandEncoder.beginComputePass();
ssaoPass.setPipeline(ssaoPipeline);
ssaoPass.setBindGroup(0, ssaoBindGroup);
ssaoPass.dispatchWorkgroups(width / 16, height / 16);
ssaoPass.end();

// Pass 2: 对 SSAO 结果进行模糊(直接复用上一步的输出作为输入)
const blurPass = commandEncoder.beginComputePass();
blurPass.setPipeline(blurPipeline);
// 此处的 blurBindGroup 将 ssao 的输出纹理绑定为只读输入
blurPass.setBindGroup(0, blurBindGroup); 
blurPass.dispatchWorkgroups(width / 16, height / 16);
blurPass.end();

// 一次性提交所有指令,由 WebGPU 驱动在底层生成最优的 Pipeline Barrier
device.queue.submit([commandEncoder.finish()]);

2. 资源别名与内存复用(Transient Attachments)

在 WebGL 中,为了实现多 Pass,我们通常会创建大量的临时 FBO,占用巨大的显存。
在 WebGPU 中,我们可以通过创建具有 GPUTextureUsage.STORAGE_BINDINGGPUTextureUsage.TEXTURE_BINDING 双重属性的临时存储纹理(Transient Textures)。

通过巧妙地对 BindGroup 进行复用,两个无依赖关系的 Pass 可以共享同一个临时显存块。例如:

  • Pass A 输出到 TempTexture_Alpha
  • Pass B(依赖 Pass A)读取 TempTexture_Alpha,输出到 TempTexture_Beta
  • Pass C(不依赖 Pass A 和 B)可以直接重新申请向 TempTexture_Alpha 写入。

由于 WebGPU 的资源绑定生命周期是由 BindGroup 控制的,通过动态更新 BindGroup 内的 textureView 指向,可以在不重新分配显存的前提下,实现显存空间的完美复用。


四、 迁移重构前后的性能对比与收益

在实际的 3D 渲染引擎重构中(以一个包含 SSAO + 2阶高斯模糊 + 颜色校正 的经典后期管线为例),从 WebGL 2.0 迁移到 WebGPU 后的性能表现提升显著:

评估维度 WebGL 2.0 (全屏 Quad + FBO Ping-Pong) WebGPU (Compute Shader + Shared Memory) 提升原理
CPU 提交耗时 (帧) ~1.5ms - 3.0ms (频繁 JS 调用阻断) < 0.1ms (单次 Command Buffer 提交) 消除无意义的 CPU 端状态机切换
显存带宽占用 高 (每次 Pass 均有完整的 VRAM 读写) 降低 60% 以上 Workgroup 共享内存替代了部分 VRAM 读写
多 Pass 切换延迟 存在明显管线空转 (FBO Switch Stall) 无缝流转 (隐式物理屏障优化) 显式底层指令流,GPU 驱动自动并行优化
高分辨率支持 (4K) 帧率呈断崖式下跌 线性平缓下降 计算单元高并发抵消了像素总量暴增的压力

五、 迁移总结与避坑指南

  1. 小心限制 Workgroup 尺寸:虽然 WebGPU 标准允许的最大工作组大小(maxComputeInvocationsPerWorkgroup)通常为 256 或更高,但在移动端设备上,建议将工作组尺寸限制在 16 x 16 (256) 或 8 x 8 (64) 内,以保证最大化的跨平台兼容性。
  2. 谨慎使用非对齐的 Storage Textures:有些移动端 GPU 对 rgba8unorm 格式的 Storage Texture 写入支持不佳,在定义管线时,推荐优先使用通用性更广、精度更高且对计算极其友好的 rgba16floatr32float 格式。
  3. 不要盲目消灭 Render Pipeline:对于最后一阶段的 Composite(将各特效混合并输出到 Canvas),使用一个简单的 GPURenderPipeline 配合 GPUFragmentStage 依然是最直接高效的选择。Compute Shader 适合复杂的矩阵计算与邻域滤波,而渲染管线在处理硬件级的混合模式和直接输出到屏幕时,依然具有不可替代的硬件加速优势。
极客显卡说 WebGPUWebGL图形学重构

评论点评