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 / vec4f | 3/4 分量浮点向量 | WGSL 向量类型。 |
mat4x4f | 4x4 浮点矩阵 | 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 内置数学函数,分别限制范围和取小数部分。 |
rgba8unorm | RGBA 8 位无符号归一化格式 | 纹理或渲染目标的像素格式。 |
这些术语在 WebGPU 开发中频繁出现,正确理解有助于编写高效且兼容的代码。
结语
本文通过实现亮度、对比度和 HSL 三个典型图像调整操作,展示了 WebGPU 后处理的灵活性与可扩展性。将调整逻辑封装在后处理着色器中,结合 uniform buffer 动态传参,可以轻松构建功能丰富的图像编辑工具。后续文章将引入 1D/3D 查找表(LUT)实现更复杂的色彩变换。
注意:原文脚注中提到,HDR 场景下亮度值可以超过 1;后处理分离的优势在于跨场景复用;
@align(16)的限制目前已有提案计划在较新设备上放宽。
