WebGPU 后处理 - 图像调整

原文标题: WebGPU Post Processing - Image Adjustments
原作者: webgpufundamentals Contributors
原文链接: https://webgpufundamentals.org/webgpu/lessons/webgpu-image-adjustments.html


概述

本文是图像调整系列的第一篇。在之前的文章《WebGPU 后处理》中,我们介绍了后处理的基本流程:先把内容渲染到纹理,再通过另一个渲染通道将该纹理输出到画布。本文在此基础上,实现常见的图像调整功能——亮度(Brightness)、对比度(Contrast)、色相/饱和度/明度(HSL)。这些调整在后处理片段着色器中完成,通过 uniform buffer 实时传递参数。

我们将保留核心着色器与 JavaScript 代码,并在最后提炼出三个核心优化要点,同时检查 WebGPU/WGSL 相关专业术语。


基础设置:渲染带纹理的四边形

首先,我们需要一个将图像绘制到渲染目标(render target)的管线。核心着色器如下(WGSL):

struct VSOutput {
  @builtin(position) position: vec4f,
  @location(0) texcoord: vec2f,
};

struct Uniforms {
  matrix: mat4x4f,
};

@group(0) @binding(0) `var<uniform>` uni: Uniforms;
@group(0) @binding(1) var tex: `texture_2d<f32>`;
@group(0) @binding(2) var smp: sampler;

@vertex fn vs(@builtin(vertex_index) vNdx: u32) -> VSOutput {
  let positions = array(
    vec2f( 0,  0),
    vec2f( 1,  0),
    vec2f( 0,  1),
    vec2f( 0,  1),
    vec2f( 1,  0),
    vec2f( 1,  1),
  );
  let pos = positions[vNdx];
  return VSOutput(
    uni.matrix * vec4f(pos, 0, 1),
    pos,
  );
}

@fragment fn fs(fsInput: VSOutput) -> @location(0) vec4f {
  return textureSample(tex, smp, fsInput.texcoord);
}

这个着色器绘制一个单位四边形(1x1 矩形),将其位置乘以一个从 uniform buffer 传入的矩阵,以便进行缩放、平移和旋转。片段着色器简单地采样纹理。

JavaScript 管线创建要点

const pipeline = device.createRenderPipeline({
  label: 'textured unit quad',
  layout: 'auto',
  vertex: { module },
  fragment: {
    module,
    targets: [{ format: 'rgba8unorm' }],
  },
});

const imageUniformBuffer = device.createBuffer({
  size: 4 * 16,  // mat4x4
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});

const imageTexture = await createTextureFromImage(
  device,
  'resources/images/david-clode-clown-fish.jpg',
);

const imageSampler = device.createSampler({
  minFilter: 'linear',
  magFilter: 'linear',
});

后处理管线

后处理管线本身几乎与之前文章中的相同,只是增加了一个目前无用的 uniform 结构体,以便下一步直接扩展。下面是后处理着色器的初始版本:

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) + vec2f(0.5);
  return vsOutput;
}

struct Uniforms {
  unused: f32,
};

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

@fragment fn fs2d(fsInput: VSOutput) -> @location(0) vec4f {
  _ = uni; // so it's included in the bind group
  let color = textureSample(postTexture2d, postSampler, fsInput.texcoord);
  var rgb = color.rgb;
  return vec4f(rgb, color.a);
}

后处理使用一个覆盖全屏的大三角形,将渲染目标纹理输出到画布。相关 JavaScript 设置渲染目标纹理和绑定组:

let renderTarget;
let postProcessBindGroup;

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;

  postProcessBindGroup = device.createBindGroup({
    layout: postProcessPipeline.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: renderTargetView },
      { binding: 1, resource: postProcessSampler },
      { binding: 2, resource: postProcessUniformBuffer  },
    ],
  });
}

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();
}

亮度调整

亮度调整是最简单的:只需在片段着色器中将亮度值加到 RGB 颜色上。

着色器修改

fn adjustBrightness(color: vec3f, brightness: f32) -> vec3f {
  return color + brightness;
}

struct Uniforms {
  brightness: f32,
};

// ...

@fragment fn fs2d(fsInput: VSOutput) -> @location(0) vec4f {
  let color = textureSample(postTexture2d, postSampler, fsInput.texcoord);
  var rgb = color.rgb;
  rgb = adjustBrightness(rgb, uni.brightness);
  return vec4f(rgb, color.a);
}

JavaScript 更新 uniform

function postProcess(encoder, srcTexture, dstTexture) {
  device.queue.writeBuffer(
    postProcessUniformBuffer,
    0,
    new Float32Array([
      settings.brightness,
    ]),
  );
  // ...
}

const settings = {
  brightness: 0,
};

gui.add(settings, 'brightness', -1, 1);

对比度调整

对比度调整通过将颜色围绕 0.5 缩放来实现:小于 0.5 的颜色推向 0,大于 0.5 的推向 1。

着色器修改

fn adjustContrast(color: vec3f, contrast: f32) -> vec3f {
  let c = contrast + 1.0;
  return clamp(0.5 + c * (color - 0.5), vec3f(0), vec3f(1));
}

struct Uniforms {
  brightness: f32,
  contrast: f32,
};

// ...

@fragment fn fs2d(fsInput: VSOutput) -> @location(0) vec4f {
  let color = textureSample(postTexture2d, postSampler, fsInput.texcoord);
  var rgb = color.rgb;
  rgb = adjustBrightness(rgb, uni.brightness);
  rgb = adjustContrast(rgb, uni.contrast);
  return vec4f(rgb, color.a);
}

JavaScript 设置

device.queue.writeBuffer(
  postProcessUniformBuffer,
  0,
  new Float32Array([
    settings.brightness,
    settings.contrast,
  ]),
);

gui.add(settings, 'contrast', -1, 10);

注意:这些操作是顺序相关的。先应用亮度,再应用对比度,因此实际上是在对比度之前确定了 0.5 的亮度基准。


色相、饱和度、明度(HSL)调整

HSL 将颜色映射到圆柱模型:H 为角度,S 为半径(0 中心,1 边缘),L 为高度(0 黑,1 白)。实现 HSL 调整需要 RGB 与 HSL 之间的转换函数。

RGB ↔ HSL 转换代码

struct HSL {
  h: f32,
  s: f32,
  l: f32,
};

fn rgbToHsl(rgb: vec3f) -> HSL {
  let cMin = min(min(rgb.r, rgb.b), rgb.g);
  let cMax = max(max(rgb.r, rgb.b), rgb.g);
  let delta = cMax - cMin;

  let l = (cMax + cMin) / 2.0;
  if (delta == 0.0) {
    return HSL(0, 0, l);
  }

  var h = 0.0;
  if (rgb.r == cMax) {
    h = (rgb.g - rgb.b) / delta;
  } else if (rgb.g == cMax) {
    h = 2.0 + (rgb.b - rgb.r) / delta;
  } else {
    h = 4.0 + (rgb.r - rgb.g) / delta;
  }
  h = h / 6.0;
  let s = delta / (1.0 - abs(2.0 * l - 1.0));
  return HSL(h, s, l);
}

fn hslToRgb(hsl: HSL) -> vec3f {
  let c = vec3f(fract(hsl.h), clamp(vec2f(hsl.s, hsl.l), vec2f(0), vec2f(1)));
  let rgb = clamp(abs((c.x * 6.0 + vec3f(0.0, 4.0, 2.0)) % 6.0 - 3.0) - 1.0, vec3f(0), vec3f(1));
  return c.z + c.y * (rgb - 0.5) * (1.0 - abs(2.0 * c.z - 1.0));
}

应用到后处理着色器

fn adjustHSL(color: vec3f, adjust: HSL) -> vec3f {
  let hsl = rgbToHsl(color);
  let newHSL = HSL(hsl.h + adjust.h, hsl.s + adjust.s, hsl.l + adjust.l);
  return hslToRgb(newHSL);
}

struct Uniforms {
  brightness: f32,
  contrast: f32,
  @align(16) hsl: HSL,
};

// ...

@fragment fn fs2d(fsInput: VSOutput) -> @location(0) vec4f {
  let color = textureSample(postTexture2d, postSampler, fsInput.texcoord);
  var rgb = color.rgb;
  rgb = adjustHSL(rgb, uni.hsl);
  rgb = adjustBrightness(rgb, uni.brightness);
  rgb = adjustContrast(rgb, uni.contrast);
  return vec4f(rgb, color.a);
}

为什么需要 @align(16)?

在 WGSL 中,用于 uniform buffer 的结构体默认必须按 16 字节边界对齐。添加 @align(16) 使 HSL 结构体能同时适用于 uniform 和 storage buffer。如果不加,则只能用于 storage buffer。这是 WGSL 设计决策,旨在未来放宽对齐限制时保持向后兼容。

JavaScript uniform buffer 扩展

const postProcessUniformBuffer = device.createBuffer({
  size: 32,  // 增加为 32 字节
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});

device.queue.writeBuffer(
  postProcessUniformBuffer,
  0,
  new Float32Array([
    settings.brightness,
    settings.contrast,
    0,
    0,
    settings.hue,
    settings.saturation,
    settings.lightness,
  ]),
);

const settings = {
  brightness: 0,
  contrast: 0,
  hue: 0,
  saturation: 0,
  lightness: 0,
};

gui.add(settings, 'hue', -0.5, 0.5);
gui.add(settings, 'saturation', -1, 1);
gui.add(settings, 'lightness', -1, 1);

三个核心优化要点

1. 后处理与主渲染分离,提升复用性

文章明确指出,图像调整并不一定要单独一个后处理通道——可以直接在绘制图像时调整。但分离出一个独立的后处理 pass 的优势在于:它适用于任何场景,比如游戏中的全局色调调整、淡入淡出效果等。将后处理作为独立阶段,使得图像调整逻辑可以无缝接入各种渲染流程,而无需修改原始绘制代码。

2. 利用 Uniform Buffer 打包多个参数,减少绑定与上传开销

所有调整参数(亮度、对比度、HSL)被合并到同一个 uniform buffer 中,而不是为每个参数创建独立的 buffer。这样:

  • 减少了 bind group 中的资源数量,降低了绑定开销。
  • 一次 writeBuffer 调用即可更新所有参数,避免了多次小规模上传。
  • 使用 @align(16) 确保结构体在 uniform buffer 中正确对齐,保证兼容性和性能。

3. 着色器数学函数优化,确保数值稳定与性能

在颜色空间转换和调整过程中,大量使用了 WGSL 内置函数如 clamp、fract、abs、min、max 等。这些函数通常映射到 GPU 硬件指令,效率远高于手写分支。例如:

  • hslToRgb 中使用 fract(hsl.h) 自动处理超出 [0,1] 范围的色相值,避免了额外的条件判断。
  • 对比度调整使用 clamp 强制结果在 0~1 之间,防止溢出。
  • 这种函数式写法既简洁又高效,充分发挥了 GPU 的并行计算能力。

WebGPU/WGSL 专业术语检查与说明

以下是本文中出现的核心专业术语及其中文解释,确保读者准确理解:

英文术语中文翻译说明
Post Processing后处理在渲染完成后对图像进行的额外处理。
Render Pass渲染通道WebGPU 中执行绘制命令的容器。
Render Target渲染目标渲染输出的纹理,可以是画布或离屏纹理。
Bind Group绑定组一组资源(纹理、采样器、uniform buffer 等)的集合,供着色器访问。
Uniform Buffer统一缓冲区存储常量数据(如矩阵、参数)的 GPU 缓冲区,所有着色器调用共享。
Texture Sample纹理采样从纹理中读取颜色,textureSample 是 WGSL 内置函数。
Sampler采样器定义纹理采样的过滤方式(如线性、最近邻)。
@builtin(position)内置位置WGSL 顶点输出中表示裁剪空间坐标的内置变量。
@location(0)位置 0顶点属性或片段输出位置的编号。
vec3f / vec4f3/4 分量浮点向量WGSL 向量类型。
mat4x4f4x4 浮点矩阵WGSL 矩阵类型。
@group(0) @binding(0)组 0 绑定 0资源在 bind group 中的索引。
GPUTextureUsage.RENDER_ATTACHMENT渲染附件用途纹理可作为渲染通道的颜色附件。
GPUTextureUsage.TEXTURE_BINDING纹理绑定用途纹理可作为着色器采样资源。
GPUBufferUsage.UNIFORM统一缓冲区用途缓冲区可作为 uniform buffer。
GPUBufferUsage.COPY_DST复制目标用途缓冲区可作为复制操作的目标。
@align(16)16 字节对齐强制结构体成员按 16 字节边界对齐,用于 uniform buffer。
clamp / fract钳制 / 取小数WGSL 内置数学函数,分别限制范围和取小数部分。
rgba8unormRGBA 8 位无符号归一化格式纹理或渲染目标的像素格式。

这些术语在 WebGPU 开发中频繁出现,正确理解有助于编写高效且兼容的代码。


结语

本文通过实现亮度、对比度和 HSL 三个典型图像调整操作,展示了 WebGPU 后处理的灵活性与可扩展性。将调整逻辑封装在后处理着色器中,结合 uniform buffer 动态传参,可以轻松构建功能丰富的图像编辑工具。后续文章将引入 1D/3D 查找表(LUT)实现更复杂的色彩变换。

注意:原文脚注中提到,HDR 场景下亮度值可以超过 1;后处理分离的优势在于跨场景复用;@align(16) 的限制目前已有提案计划在较新设备上放宽。