原文配图

WebGPU 后处理入门:离屏纹理与 CRT 滤镜

原文作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-post-processing.html


一、后处理的基本概念

后处理(Post Processing)指在生成“原始”图像后,对图像施加额外处理。该技术可应用于照片、视频、2D 场景或 3D 场景,类似于在 Instagram 上选择滤镜。在 WebGPU 中,常规做法是直接渲染到画布(canvas)纹理;要实现后处理,则先将场景渲染到一个独立的离屏纹理(render target),再将该纹理渲染到画布的同时应用图像处理效果。

本文以模拟 1980 年代 CRT 电视的扫描线(scanline)和 RGB 像素格(RGB cell)为例,展示 WebGPU 后处理的基本流程。


二、从直接渲染到离屏渲染

首先将原有动画渲染目标从画布改为独立纹理,然后再将该纹理绘制到画布。核心步骤包括:

  1. 创建后处理着色器:使用一个大三角形覆盖裁剪空间(clip space),并传递正确的 UV 坐标以采样纹理。
const postProcessModule = device.createShaderModule({
  code: /* wgsl */ `
    struct VSOutput {
      @builtin(position) position: vec4f,
      @location(0) texcoord: vec2f,
    };

    @vertex fn vs(
      @builtin(vertex_index) vertexIndex : u32,
    ) -> VSOutput {
      var pos = array(
        vec2f(-1.0, -1.0),
        vec2f(-1.0,  3.0),
        vec2f( 3.0, -1.0),
      );

      var vsOutput: VSOutput;
      let xy = pos[vertexIndex];
      vsOutput.position = vec4f(xy, 0.0, 1.0);
      vsOutput.texcoord = xy * vec2f(0.5, -0.5) + vec2f(0.5);
      return vsOutput;
    }

    @group(0) @binding(0) var postTexture2d: `texture_2d<f32>`;
    @group(0) @binding(1) var postSampler: sampler;

    @fragment fn fs2d(fsInput: VSOutput) -> @location(0) vec4f {
      let color = textureSample(postTexture2d, postSampler, fsInput.texcoord);
      return vec4f(color);
    }
  `,
});
  1. 创建后处理渲染管线(输出到画布):
const postProcessPipeline = device.createRenderPipeline({
  layout: 'auto',
  vertex: { module: postProcessModule },
  fragment: {
    module: postProcessModule,
    targets: [ { format: presentationFormat }],
  },
});
  1. 创建离屏纹理:当画布尺寸变化时,动态创建或调整离屏纹理的大小,并将其设置为原始渲染管线的颜色附件。
let renderTarget;
function setupPostProcess(canvasTexture) {
  if (renderTarget?.width === canvasTexture.width &&
      renderTarget?.height === canvasTexture.height) {
    return;
  }
  renderTarget?.destroy();
  renderTarget = device.createTexture({
    size: canvasTexture,
    format: 'rgba8unorm',
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
  });
  const renderTargetView = renderTarget.createView();
  renderPassDescriptor.colorAttachments[0].view = renderTargetView;
}
  1. 修改原始管线:原始渲染管线的目标格式需从 presentationFormat 改为 'rgba8unorm'。

  2. 后处理渲染函数:在每一帧中,先执行原始渲染到离屏纹理,再调用 postProcess 将离屏纹理绘制到画布。

function postProcess(encoder, srcTexture, dstTexture) {
  postProcessRenderPassDescriptor.colorAttachments[0].view = dstTexture.createView();
  const pass = encoder.beginRenderPass(postProcessRenderPassDescriptor);
  pass.setPipeline(postProcessPipeline);
  pass.setBindGroup(0, postProcessBindGroup);
  pass.draw(3);
  pass.end();
}

三、添加 CRT 扫描线效果

CRT 电视的扫描线是由于电子束逐行扫描产生的明暗条纹。利用正弦波的绝对值可以模拟这种效果:

@fragment fn fs2d(fsInput: VSOutput) -> @location(0) vec4f {
  let banding = abs(sin(fsInput.position.y * uni.bandMult));
  let effect = mix(1.0, banding, uni.effectAmount);
  let color = textureSample(postTexture2d, postSampler, fsInput.texcoord);
  return vec4f(color.rgb * effect, color.a);
}

对应的 JavaScript 需要创建 uniform 缓冲区、绑定组和 GUI 控制参数。


四、添加 RGB 像素格效果

CRT 屏幕由红、绿、蓝三色荧光点组成,可以通过对像素的 x 坐标取模 3 来分配颜色通道:

@fragment fn fs2d(fsInput: VSOutput) -> @location(0) vec4f {
  let banding = abs(sin(fsInput.position.y * uni.bandMult));

  let cellNdx = u32(fsInput.position.x * uni.cellMult) % 3;
  var cellColor = vec3f(0);
  cellColor[cellNdx] = 1;
  let cMult = cellColor + uni.cellBright;

  let effect = mix(vec3f(1), banding * cMult, uni.effectAmount);
  let color = textureSample(postTexture2d, postSampler, fsInput.texcoord);
  return vec4f(color.rgb * effect, color.a);
}

这样每个像素只保留红、绿或蓝中的一个通道,并结合扫描线形成类似 CRT 的视觉效果。


五、使用计算着色器(Compute Shader)

后处理也可以使用计算着色器实现,即将画布纹理作为存储纹理(storage texture)写入。首先需要检测设备是否支持 bgra8unorm-storage 特性,并相应调整纹理格式和用途。

计算着色器版本的核心代码如下:

@group(0) @binding(0) var postTexture2d: `texture_2d<f32>`;
@group(0) @binding(1) var postSampler: sampler;
@group(0) @binding(2) `var<uniform>` uni: Uniforms;
@group(1) @binding(0) var outTexture: `texture_storage_2d<rgba8unorm, write>`;

@compute @workgroup_size(1) fn cs(@builtin(global_invocation_id) gid: vec3u) {
  let outSize = textureDimensions(outTexture);
  let banding = abs(sin(f32(gid.y) * uni.bandMult));

  let cellNdx = u32(f32(gid.x) * uni.cellMult) % 3;
  var cellColor = vec3f(0);
  cellColor[cellNdx] = 1.0;
  let cMult = cellColor + uni.cellBright;

  let effect = mix(vec3f(1), banding * cMult, uni.effectAmount);
  let uv = (vec2f(gid.xy) + 0.5) / vec2f(outSize);
  let color = textureSampleLevel(postTexture2d, postSampler, uv, 0);
  textureStore(outTexture, gid.xy, vec4f(color.rgb * effect, color.a));
}

使用 @workgroup_size(1) 的版本虽然简单,但在多数 GPU 上性能不佳。通过将工作组大小改为 [16, 16] 并添加边界检查,可以显著提升性能:

const workgroupSize = [16, 16];
// ...
@compute @workgroup_size(${workgroupSize}) fn cs(@builtin(global_invocation_id) gid: vec3u) {
  let outSize = textureDimensions(outTexture);
  if (gid.x >= outSize.x || gid.y >= outSize.y) {
    return;
  }
  // ... 其余代码相同
}

调度时使用:

pass.dispatchWorkgroups(
  Math.ceil(dstTexture.width / workgroupSize[0]),
  Math.ceil(dstTexture.height / workgroupSize[1]),
);

然而,即便使用较大的工作组,计算着色器方案在部分 GPU 上仍慢于渲染管线方案。文章给出的对比数据显示,在某些硬件(如 Nvidia 2070 Super)上计算着色器耗时约为渲染管线的 2 倍。


六、核心优化要点

  1. 离屏渲染 + 大三角形覆盖:
    后处理的基础是将场景渲染到离屏纹理,再使用单个大三角形(而非两个三角形)覆盖裁剪空间,减少顶点开销。该三角形只需 3 个顶点,即可完成全屏纹理采样。

  2. 动态管理离屏纹理尺寸:
    仅当画布尺寸变化时重新创建离屏纹理,避免每帧重复创建/销毁纹理带来的内存和性能开销。同时正确设置纹理用途(RENDER_ATTACHMENT | TEXTURE_BINDING),确保它既能用作渲染目标又能被采样。

  3. 计算着色器 vs 渲染管线的权衡:
    后处理既可用渲染管线(fragment shader)实现,也可用计算着色器实现。计算着色器需要合理设置工作组大小(如 16x16)并处理边界情况才能获得可接受的性能。但即使优化后,在许多 GPU 上渲染管线方案仍更快,因为 GPU 对光栅化渲染到纹理的优化历史更悠久。因此,除非算法能有效利用工作组共享内存或子组(subgroups),否则推荐优先使用渲染管线。


七、GLSL/WebGL 专业术语检查

本文涉及 WebGPU,其着色语言为 WGSL(WebGPU Shading Language),而非 GLSL。虽然用户要求检查“GLSL/WebGL 专业术语”,但实际内容为 WebGPU。以下列出关键术语并给出准确翻译与解释:

英文术语中文翻译说明
Post Processing后处理在原始图像生成后施加的效果处理
Render Pass渲染通道WebGPU 中一次渲染操作的定义
Render Target渲染目标被渲染到的纹理,可为画布或离屏纹理
Texture纹理图像数据存储单元
Sampler采样器定义纹理采样方式(过滤、寻址等)
Bind Group绑定组一组资源(纹理、采样器、uniform 等)的集合
Uniform Buffer统一缓冲区存储着色器 uniform 数据的缓冲区
Storage Texture存储纹理可被着色器读写(非仅采样)的纹理
Workgroup工作组计算着色器中并行执行的线程组
Global Invocation ID全局调用 ID计算着色器中每个调用的全局索引
Clip Space裁剪空间顶点着色器输出的坐标空间,范围 [-1, 1]
UV CoordinatesUV 坐标纹理采样坐标,通常归一化到 [0, 1]
Scanline扫描线CRT 显示中逐行扫描产生的明暗条纹
RGBA8unormRGBA8 无符号归一化纹理格式,每个通道 8 位,值范围 [0, 1]

注意:原文中是 WGSL,其语法与 GLSL 有相似之处但存在重大差异。例如,WGSL 使用 @builtin(position) 而非 GLSL 的 gl_Position;使用 @group 和 @binding 而非 layout(set = ..., binding = ...)。本文所有术语均基于 WebGPU/WGSL,并无 GLSL/WebGL 内容。因此术语检查结论:文中专业术语使用准确,翻译应遵循 WebGPU 社区通用惯例。


八、总结

本文介绍了 WebGPU 后处理的基本实现:先将场景渲染到离屏纹理,再通过全屏三角形采样该纹理并应用 CRT 风格效果(扫描线 + RGB 像素格)。同时探讨了使用计算着色器的替代方案,并指出了性能权衡。后处理是构建高级图形效果(如模糊、色调映射、颜色校正等)的基础,后续文章将继续深入。

延伸阅读:

原文配图 原文配图