WebGPU 后期处理——3D 查找表 (LUT)
原文作者: webgpufundamentals Contributors
原始链接: https://webgpufundamentals.org/webgpu/lessons/webgpu-3dlut.html
本文是对 WebGPU 图像调整系列第三篇的深度解读。上一篇文章介绍了 1D 查找表(1D‑LUT),即渐变映射;而 3D‑LUT 则是同一思想在三维空间的推广。理解本文有助于在 WebGPU 应用中高效实现颜色分级、滤镜和风格化效果,同时避免不必要的着色器复杂度。
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 的查找结果对比(原文示意图略)。
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 表示最近邻过滤。这种约定便于自动化加载和参数解析。
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 切片部分。这既降低了网络流量,又加快了纹理上传速度。
4. 专业术语检查(WebGPU/WGSL 相关)
本文涉及的技术为 WebGPU,其着色语言是 WGSL(WebGPU Shading Language),并非 GLSL。以下术语在翻译和使用中需严格区分:
| 英文原词 | 中文翻译 | 备注 |
|---|---|---|
| 3D lookup table (LUT) | 三维查找表 | 颜色分级核心数据结构 |
| texture_3d | 3D 纹理 | WGSL 中的 3D 纹理类型,对应 WebGPU API 的 dimension: '3d' |
| texture_2d | 2D 纹理 | 与 3D 纹理对应 |
| textureSample | 纹理采样 | WGSL 内建函数,用于从纹理中采样 |
| sampler | 采样器 | 定义纹理过滤和寻址模式 |
| textureDimensions | 纹理尺寸 | 返回纹理的尺寸,用于归一化坐标计算 |
| linear filtering | 线性过滤 | 在纹理采样时进行双线性/三线性插值 |
| nearest sampling | 最近邻采样 | 不进行插值,直接取最近纹素 |
| bind group | 绑定组 | WebGPU 中一组绑定资源(纹理、采样器、uniform buffer) |
| writeTexture | 写入纹理 | WebGPU 队列方法,用于上传纹理数据 |
| GPUTextureUsage.TEXTURE_BINDING | 纹理绑定用途 | 表示纹理可被着色器读取 |
| GPUTextureUsage.COPY_DST | 复制目标用途 | 表示纹理可作为复制操作的目标 |
| rgba8unorm | RGBA8 无符号归一化 | 纹理格式,每通道 8 位无符号整数,归一化到 [0,1] |
| OffscreenCanvas | 离屏画布 | 用于在 worker 或主线程中处理图像数据 |
| identity LUT | 恒等 LUT | 输入输出颜色一致的查找表 |
| clip space | 裁剪空间 | 顶点变换后的坐标空间 |
需要特别注意:WGSL 与 GLSL 的语法不同,例如 textureSample 对应 GLSL 的 texture,textureDimensions 对应 textureSize,且 WGSL 使用 @group/@binding 替代 GLSL 的 layout(set = ..., binding = ...)。在阅读或移植代码时应避免混淆。

5. 总结
3D LUT 是一种强大且高效的颜色变换技术,特别适合实时后期处理。WebGPU 提供了 texture_3d、writeTexture 和灵活绑定机制,使得实现 3D LUT 变得直观。通过合理选择分辨率、利用平铺 PNG 格式和恰当的采样模式,可以在保持画质的同时极大提升性能与加载效率。
本文代码均可在原文链接对应的交互示例中运行,建议结合示例深入学习。
延伸阅读:原文系列还包括 Image Adjustments 和 1D Lookup Table (LUT),建议按顺序阅读。
