WebGPU 后期处理——3D 查找表 (LUT)

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

本文是对 WebGPU 图像调整系列第三篇的深度解读。上一篇文章介绍了 1D 查找表(1D‑LUT),即渐变映射;而 3D‑LUT 则是同一思想在三维空间的推广。理解本文有助于在 WebGPU 应用中高效实现颜色分级、滤镜和风格化效果,同时避免不必要的着色器复杂度。


WebGPU Post Processing - 3d lookup table (LUT)

1. 3D LUT 的基本原理

简单来说,3D 查找表就是一个颜色立方体。它把源图像中每个像素的 R、G、B 值作为三维索引,从立方体中取出一个新的颜色输出。如果用一个 256×256×256 的三维数组,可以这样理解:

const newColor = lut[origColor.red][origColor.green][origColor.blue]; // 注意原文此处误为 .bue

当然,256³ 的内存开销巨大。实际上,纹理采样总是使用 0.0 到 1.0 的归一化坐标,因此我们并不需要真正创建一个巨大的数组。例如一个 8×8×8 的立方体就足够描述许多颜色变换。

  • 恒等 LUT:立方体中每个位置的颜色与其索引一致,输入什么颜色就输出什么颜色,相当于“不做任何调整”。
  • 自定义 LUT:改变立方体内的颜色分布,就能实现色调偏移、饱和度、对比度、曝光、阴影/高光、海报化、颜色反转等效果,甚至可以将多种调整合并到一个 LUT 中。

下图展示了恒等 LUT 和琥珀色 LUT 的查找结果对比(原文示意图略)。


WebGPU Post Processing - 3d lookup table (LUT)

2. WebGPU/WGSL 实现核心代码

在 WGSL 中,我们使用 texture_3d<f32> 来表示 3D 纹理,通过 textureSample 进行采样。以下是对比 1D LUT 与 3D LUT 的采样函数:

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

fn apply3DLUT(
    color: vec3f,
    lut: `texture_3d<f32>`,
    smp: sampler) -> vec3f {
  let size = vec3f(textureDimensions(lut, 0));
  let range = (size - 1) / size;
  let uvw = 0.5 / size + color * range;
  return textureSample(lut, smp, uvw).rgb;
}

在片段着色器中,我们将原来的亮度/对比度等调整替换为 3D LUT 采样,并保留 lutAmount 来混合原始颜色和 LUT 结果:

struct Uniforms {
  lutAmount: 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;
@group(1) @binding(0) var lut: `texture_3d<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 = mix(rgb, apply3DLUT(rgb, lut, lutSampler), uni.lutAmount);
  return vec4f(rgb, color.a);
}

2.1 创建恒等 3D LUT 纹理

最简单的 3D LUT 是一个 2×2×2 的恒等 LUT。使用 writeTexture 一次性上传 8 个 RGBA 颜色:

function makeIdentityLutTexture(device) {
  const texture = device.createTexture({
    size: [2, 2, 2],
    dimension: '3d',
    format: 'rgba8unorm',
    usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
  });

  const identityLUT = new Uint8Array([
      0,   0,   0, 255,  // black
    255,   0,   0, 255,  // red
      0, 255,   0, 255,  // green
    255, 255,   0, 255,  // yellow
      0,   0, 255, 255,  // blue
    255,   0, 255, 255,  // magenta
      0, 255, 255, 255,  // cyan
    255, 255, 255, 255,  // white
  ]);

  device.queue.writeTexture(
    { texture },
    identityLUT,
    { bytesPerRow: 8, rowsPerImage: 2 },
    [2, 2, 2],
  );

  return texture;
}

2.2 从图像加载 LUT(PNG 平铺格式)

为了在 Photoshop 等图像编辑器中创建自定义 LUT,可以将 LUT 切片平铺在一张 PNG 的顶部(宽度 = size × size,高度 = size)。加载时逐层上传到 3D 纹理:

const createLUTTextureFromImage = (function() {
  const ctx = new OffscreenCanvas(1, 1).getContext('2d', { willReadFrequently: true });

  return async function createLUTTextureFromImage(device, url, lutSize) {
    const img = new Image();
    img.src = url;
    await img.decode();
    ctx.canvas.width = lutSize * lutSize;
    ctx.canvas.height = lutSize;
    ctx.drawImage(img, 0, 0);
    const imgData = ctx.getImageData(0, 0, lutSize * lutSize, lutSize);

    const texture = device.createTexture({
      size: [lutSize, lutSize, lutSize],
      dimension: '3d',
      format: 'rgba8unorm',
      usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
    });

    for (let z = 0; z < lutSize; ++z) {
      device.queue.writeTexture(
        { texture, origin: [0, 0, z] },
        imgData.data,
        { offset: z * lutSize * 4, bytesPerRow: imgData.width * 4 },
        [lutSize, lutSize],
      );
    }
    return texture;
  };
})();

文件名约定如 foo-s16.png 表示尺寸 16 的 LUT 使用线性过滤;bar-s8n.png 中的 n 表示最近邻过滤。这种约定便于自动化加载和参数解析。


WebGPU Post Processing - 3d lookup table (LUT)

3. 三个核心优化要点

优化 1:将多种颜色调整合并为一次 3D 纹理采样

传统的颜色调整可能需要多个着色器分支或多次采样,而 3D LUT 将亮度、对比度、饱和度、色调、阴影/高光、曲线、海报化等所有基于单像素输入的调整统一映射为一个 RGB 三元组查找。GPU 只需执行一次 textureSample,大幅减少着色器指令和寄存器压力,尤其适合移动端或低功耗设备。

优化 2:选择最小可行的 LUT 分辨率以平衡质量与内存

3D 纹理的数据量随分辨率按 O(n³) 增长。例如 8³ 仅需约 2 KB(8×8×8×4 字节),16³ 为 16 KB,而 64³ 则需要 1 MB 显存。在能够表达所需色彩变换的前提下,应使用最小的 LUT 尺寸。此外,线性过滤可以在较小 LUT 上产生平滑过度,而最近邻过滤则保留精确色块,适合海报化等效果。根据需求选择过滤模式也能影响视觉质量和带宽。

优化 3:将 Adobe LUT(.cube)转换为紧凑的 PNG 平铺格式

Adobe .cube 文件通常为 33×33×33(约 1 MB),不适合直接用于 Web 传输。通过工具将其转换为平铺 PNG(例如 16×16×16 的 PNG 仅约 50 KB),体积减小约 20 倍。转换时注意保留文件命名中的尺寸和过滤信息,并裁剪掉多余区域,只保留 LUT 切片部分。这既降低了网络流量,又加快了纹理上传速度。


WebGPU Post Processing - 3d lookup table (LUT)

4. 专业术语检查(WebGPU/WGSL 相关)

本文涉及的技术为 WebGPU,其着色语言是 WGSL(WebGPU Shading Language),并非 GLSL。以下术语在翻译和使用中需严格区分:

英文原词中文翻译备注
3D lookup table (LUT)三维查找表颜色分级核心数据结构
texture_3d3D 纹理WGSL 中的 3D 纹理类型,对应 WebGPU API 的 dimension: '3d'
texture_2d2D 纹理与 3D 纹理对应
textureSample纹理采样WGSL 内建函数,用于从纹理中采样
sampler采样器定义纹理过滤和寻址模式
textureDimensions纹理尺寸返回纹理的尺寸,用于归一化坐标计算
linear filtering线性过滤在纹理采样时进行双线性/三线性插值
nearest sampling最近邻采样不进行插值,直接取最近纹素
bind group绑定组WebGPU 中一组绑定资源(纹理、采样器、uniform buffer)
writeTexture写入纹理WebGPU 队列方法,用于上传纹理数据
GPUTextureUsage.TEXTURE_BINDING纹理绑定用途表示纹理可被着色器读取
GPUTextureUsage.COPY_DST复制目标用途表示纹理可作为复制操作的目标
rgba8unormRGBA8 无符号归一化纹理格式,每通道 8 位无符号整数,归一化到 [0,1]
OffscreenCanvas离屏画布用于在 worker 或主线程中处理图像数据
identity LUT恒等 LUT输入输出颜色一致的查找表
clip space裁剪空间顶点变换后的坐标空间

需要特别注意:WGSL 与 GLSL 的语法不同,例如 textureSample 对应 GLSL 的 texture,textureDimensions 对应 textureSize,且 WGSL 使用 @group/@binding 替代 GLSL 的 layout(set = ..., binding = ...)。在阅读或移植代码时应避免混淆。


WebGPU Post Processing - 3d lookup table (LUT)

5. 总结

3D LUT 是一种强大且高效的颜色变换技术,特别适合实时后期处理。WebGPU 提供了 texture_3d、writeTexture 和灵活绑定机制,使得实现 3D LUT 变得直观。通过合理选择分辨率、利用平铺 PNG 格式和恰当的采样模式,可以在保持画质的同时极大提升性能与加载效率。

本文代码均可在原文链接对应的交互示例中运行,建议结合示例深入学习。


延伸阅读:原文系列还包括 Image Adjustments 和 1D Lookup Table (LUT),建议按顺序阅读。