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)本质上都是选择合适色调的双色调。


Using a texture

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 个核心优化要点

  1. 将颜色映射逻辑从着色器转移到纹理(LUT)
    通过把颜色调整算法编码为 1D 纹理,可以在不修改着色器代码的情况下,仅通过更换纹理实现任意数量的效果(如双色调、渐变映射、色调分离等)。这大大提高了代码的复用性和可维护性,同时减少了着色器中的分支和数学运算。

  2. 正确处理纹理采样坐标以避免边缘混叠
    线性纹理采样在边缘像素会重复边缘颜色,导致渐变不完整。通过将亮度值映射到 [0.5/width, 1 - 0.5/width] 区间,确保采样点始终位于像素中心之间,充分利用纹理的线性插值能力,得到平滑且准确的映射。

  3. 利用浏览器 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)完全相同
向量类型vec3fvec3三维浮点向量
纹理格式rgba8unormGL_RGBA88 位无符号归一化 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 代码,但涉及的专业术语均正确无误。