WebGPU 1D LUT:用查找表做渐变映射调色
英文原作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-1dlut.html
本文是 WebGPU 图像调整系列的第二篇,基于上一篇《图像调整》构建。上一篇实现了亮度、对比度和 HSL 调整,本篇将实现“双色调(duotone)”图像调整,并进一步引入 1D 查找表(1D Lookup Table, 1D-LUT) 纹理技术,使效果调整更加灵活高效。
1. 双色调(Duotone)效果的实现
双色调效果使用图像的亮度(luminance)在两种颜色之间进行插值:暗部接近第一种颜色,亮部接近第二种颜色。
亮度计算公式
人眼对绿色更敏感,因此标准 RGB 转亮度公式为:
luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722
在 WGSL 中可写作:
fn luminance(color: vec3f) -> f32 {
return dot(color, vec3f(0.2126, 0.7152, 0.0722));
}
其中 dot 表示向量点积。
使用 mix 实现双色调
在片段着色器中添加 applyDuotone 函数,并根据亮度值混合两种颜色:
fn applyDuotone(color: vec3f, color1: vec3f, color2: vec3f) -> vec3f {
let l = luminance(color);
return mix(color1, color2, l);
}
更新 uniform 结构体和主片段着色器:
struct Uniforms {
brightness: f32,
contrast: f32,
@align(16) duotone: f32,
@align(16) duotoneColor1: vec3f,
@align(16) duotoneColor2: vec3f,
};
@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 {
let color = textureSample(postTexture2d, postSampler, fsInput.texcoord);
var rgb = color.rgb;
rgb = adjustBrightness(rgb, uni.brightness);
rgb = adjustContrast(rgb, uni.contrast);
rgb = mix(rgb, applyDuotone(rgb, uni.duotoneColor1, uni.duotoneColor2), uni.duotone);
return vec4f(rgb, color.a);
}
对应的 JavaScript 参数更新:
const settings = {
brightness: 0,
contrast: 0,
duotone: 1,
duotoneColor1: new Float32Array([0.1, 0, 0.5]),
duotoneColor2: new Float32Array([1, 0.69, 0.4]),
};
效果如下(图片略)。许多常见效果(如棕褐色 sepia)本质上都是选择合适色调的双色调。
2. 使用纹理(1D LUT)替代硬编码混合
虽然可以用 mix 直接混合两种颜色,但更好的做法是使用 2×1 像素的纹理,借助线性过滤(linear filtering)实现任意数量的颜色渐变映射。这就是 1D 查找表 的核心思想。
纹理采样坐标计算
一个 2×1 纹理的线性采样行为如下:
如果直接使用 textureSample(lut, smp, vec2f(l, 0.5)) 采样,在 l=0.0 和 l=1.0 时会被限制到第一个和最后一个像素的颜色,整个渐变范围会丢失一部分。正确做法是将亮度映射到两个像素中心之间的区域:
fn apply1DLUT(
color: vec3f,
lut: `texture_2d<f32>`,
smp: sampler) -> vec3f {
let l = luminance(color);
let width = f32(textureDimensions(lut, 0).x);
let range = (width - 1) / width;
let u = 0.5 / width + l * range;
return textureSample(lut, smp, vec2f(u, 0.5)).rgb;
}
这样 l=0 对应第一个像素的中心,l=1 对应最后一个像素的中心,线性插值完全覆盖整个纹理。
在着色器中使用 LUT 纹理
修改 uniform 结构和绑定组,将 LUT 纹理和采样器放在独立的组中:
struct Uniforms {
brightness: f32,
contrast: f32,
gradient: f32, // 混合系数,控制 LUT 效果强度
};
@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 lut: `texture_2d<f32>`;
@group(1) @binding(1) var lutSampler: sampler;
@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);
rgb = mix(rgb, apply1DLUT(rgb, lut, lutSampler), uni.gradient);
return vec4f(rgb, color.a);
}
相应 JavaScript 创建纹理和 bind group:
const lutSampler = device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
});
const rgbToUnorm8 = (rgb) => [0, 0, 0, 1].map((v, i) => (rgb[i] ?? v) * 255 | 0);
const gradientColors = new Uint8Array([
...rgbToUnorm8([0.1, 0, 0.5]),
...rgbToUnorm8([1, 0.69, 0.4]),
]);
const lutTexture = device.createTexture({
size: [2],
format: 'rgba8unorm',
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
});
device.queue.writeTexture(
{ texture: lutTexture },
gradientColors,
{ },
[2],
);
const lutBindGroup = device.createBindGroup({
layout: postProcessPipeline.getBindGroupLayout(1),
entries: [
{ binding: 0, resource: lutTexture },
{ binding: 1, resource: lutSampler },
],
});
渲染时设置第二个 bind group:
pass.setBindGroup(0, postProcessBindGroup);
pass.setBindGroup(1, lutBindGroup);
3. 生成任意数量的 LUT 纹理
使用 LUT 纹理的优势在于:无需修改着色器,只需更换纹理即可实现完全不同的颜色映射。我们可以利用浏览器 2D Canvas 的线性渐变能力,快速生成任意数量的 LUT 纹理。
使用 OffscreenCanvas 生成渐变 LUT
定义一组渐变数据,每个渐变由多个颜色停止点构成:
const gradients = [
[
[ 0, 0, 0, 0.0],
[236, 23, 223, 0.37],
[255, 144, 0, 0.48],
[255, 255, 255, 1],
],
// ... 更多渐变定义
];
利用 OffscreenCanvas 和 createLinearGradient 生成 256×1 纹理:
const ctx = new OffscreenCanvas(256, 1).getContext('2d');
const lutBindGroups = gradients.map(stops => {
const grad = ctx.createLinearGradient(0, 0, ctx.canvas.width, 0);
for (const [r, g, b, stop] of stops) {
grad.addColorStop(stop, `rgb(${r}, ${g}, ${b})`);
}
ctx.fillStyle = grad;
ctx.fillRect(0, 0, ctx.canvas.width, 1);
const texture = createTextureFromSource(device, ctx.canvas);
return device.createBindGroup({
layout: postProcessPipeline.getBindGroupLayout(1),
entries: [
{ binding: 0, resource: texture },
{ binding: 1, resource: lutSampler },
],
});
});
通过 UI 切换不同的 LUT
在 HTML 中添加容器,并使用 CSS 渐变显示每个 LUT 的预览条:
<body>
<canvas></canvas>
<div id="ui"></div>
</body>
#ui {
position: absolute;
left: 0px;
top: 0px;
overflow: auto;
height: 100%;
}
.gradient {
margin: 1px;
width: 100px;
height: 20px;
}
JavaScript 创建可点击的渐变条:
const uiElem = document.querySelector('#ui');
gradients.forEach((stops, i) => {
const div = document.createElement('div');
div.className = 'gradient';
div.style.background = `linear-gradient(to right,
${stops.map(([r, g, b, stop]) => `rgb(${r}, ${g}, ${b}) ${stop * 100}%`).join(',')}
)`;
div.addEventListener('click', () => {
settings.lut = i;
render();
});
uiElem.append(div);
});
渲染时根据选择切换 bind group:
pass.setBindGroup(1, lutBindGroups[settings.lut]);
最终效果可参考原文中的交互示例。
3 个核心优化要点
-
将颜色映射逻辑从着色器转移到纹理(LUT)
通过把颜色调整算法编码为 1D 纹理,可以在不修改着色器代码的情况下,仅通过更换纹理实现任意数量的效果(如双色调、渐变映射、色调分离等)。这大大提高了代码的复用性和可维护性,同时减少了着色器中的分支和数学运算。 -
正确处理纹理采样坐标以避免边缘混叠
线性纹理采样在边缘像素会重复边缘颜色,导致渐变不完整。通过将亮度值映射到[0.5/width, 1 - 0.5/width]区间,确保采样点始终位于像素中心之间,充分利用纹理的线性插值能力,得到平滑且准确的映射。 -
利用浏览器 2D Canvas 动态生成 LUT 纹理
使用OffscreenCanvas和createLinearGradient可以快速创建任意分辨率(如 256×1)的 LUT 纹理,避免了手动构建原始像素数据和编写大量上传代码。这种方法还能与 CSS 渐变预览无缝结合,方便快速迭代和选择不同的映射效果。
GLSL / WebGL 专业术语检查
本文使用 WebGPU 的 WGSL 着色语言,但涉及的核心概念与 GLSL / WebGL 一脉相承。以下列出关键术语对应关系,确保理解准确:
| 概念 | WGSL (WebGPU) | GLSL (WebGL) | 说明 |
|---|---|---|---|
| 纹理类型 | texture_2d<f32> | sampler2D(配合纹理单元) | 2D 纹理 |
| 纹理采样 | textureSample(lut, smp, uv) | texture(sampler2D, uv) | 采样纹理颜色 |
| 采样器 | sampler | 通过 glTexParameter 设置过滤模式 | 控制过滤、寻址模式 |
| 纹理尺寸查询 | textureDimensions(lut, 0) | textureSize(sampler2D, 0) | 获取纹理宽度、高度 |
| 线性插值函数 | mix(a, b, t) | mix(a, b, t) | 完全相同 |
| 点积 | dot(a, b) | dot(a, b) | 完全相同 |
| 向量类型 | vec3f | vec3 | 三维浮点向量 |
| 纹理格式 | rgba8unorm | GL_RGBA8 | 8 位无符号归一化 RGBA |
| 纹理上传 | device.queue.writeTexture() | gl.texImage2D() | 向 GPU 上传纹理数据 |
| uniform 缓冲区 | var<uniform> | uniform 变量 | 全局常量数据 |
| bind group | @group(0) @binding(...) | 纹理单元 + uniform 位置 | 资源绑定模型 |
检查结果:所有术语使用准确,WGSL 代码与 WebGPU API 调用均符合规范。在 WebGL 环境中,1D LUT 通常使用 GL_TEXTURE_2D 的宽度方向模拟 1D 纹理;而 WebGPU 直接支持 texture_2d,两者本质相同。本文未出现 GLSL 代码,但涉及的专业术语均正确无误。
