返回知识库

渲染

颜色空间与色调映射

颜色空间与色调映射 封面
WebGPU/WebGLShaderGLSL/WGSL颜色空间HDR色调映射LUT

直觉问题:为什么显示器显示的颜色和真实世界不一样?

Q1: 为什么同一张照片在不同显示器上颜色差异很大?

Q2: 为什么游戏中太阳那么亮,但不会让整个屏幕过曝?


核心概念白话讲

颜色空间

颜色空间定义了如何表示颜色,包含两个维度:

  1. 色域(Gamut):能显示的颜色范围
  2. 传递函数(Transfer Function):亮度与数值的映射关系

常见颜色空间对比

颜色空间色域覆盖率Gamma 曲线应用场景
sRGB~35% CIE 19312.2 分段线性Web、显示器、普通游戏
Rec.709~35% CIE 19312.2 线性HDTV、Blu-ray
Rec.2020~75.8% CIE 1931PQ/HLG HDRUHDTV、HDR 视频流
DCI-P3~42% CIE 19312.6电影放映、Apple 设备

色调映射算法对比

算法优点缺点适用场景
Reinhard简单、平滑高光压缩过度,丢失细节简单场景、移动端
Filmic保留高光细节实现复杂AAA 游戏、电影感渲染
ACES电影标准、真实计算量大电影、高质量渲染
Exposure可控曝光无自动压缩摄影类应用

TIP

Rec.2020 是目前最广的色域标准,覆盖人眼可见颜色的 75.8%,但需要 HDR 显示器支持。

LDR vs HDR

LDR(Low Dynamic Range):8 位整数,范围 [0, 255],动态比 ~256:1 HDR(High Dynamic Range):16/32 位浮点数,范围 [0, +∞),动态比 >10⁶:1

问题:LDR 无法同时表现太阳的亮度和阴影的细节。

Gamma 校正

显示器是非线性的:输入电压与亮度呈幂函数关系。

Lout=VinγL_{out} = V_{in}^{\gamma}

Gamma 2.2 校正

Ccorrected=Clinear1/2.2C_{corrected} = C_{linear}^{1/2.2}

WARNING

如果不进行 Gamma 校正,中间调会过暗,暗部细节丢失。

线性渲染管线

graph LR
    A[输入资源<br/>sRGB 纹理] --> B[线性化<br/>pow(x, 2.2)]
    B --> C[线性空间计算<br/>光照/混合/后处理]
    C --> D[色调映射<br/>HDR→LDR]
    D --> E[Gamma 编码<br/>pow(x, 1/2.2)]
    E --> F[输出显示<br/>sRGB 显示器]

关键步骤

  1. 线性化:所有纹理从 sRGB 转换到线性空间
  2. 线性计算:光照、混合、后处理均在线性空间进行
  3. 色调映射:将 HDR 映射到 LDR [0, 1]
  4. Gamma 编码:转换回 sRGB 用于显示

色调映射(Tone Mapping)

色调映射将高动态范围的亮度压缩到低动态范围,同时保留细节。

常用算法对比

算法优点缺点适用场景
Reinhard简单、平滑高光压缩过度,丢失细节简单场景、移动端
Filmic保留高光细节实现复杂AAA 游戏、电影感渲染
ACES电影标准、真实计算量大电影、高质量渲染
Exposure可控曝光无自动压缩摄影类应用

LUT(Lookup Table,查找表)

LUT 将复杂的颜色变换预计算为查找表,运行时通过纹理采样快速应用。

3D LUT

  • 输入:(R, G, B) 三维坐标
  • 输出:变换后的 (R', G', B')
  • 大小:通常 32×32×32 或 64×64×64

TIP

Unity、Unreal Engine 等引擎使用 LUT 进行实时调色,无需重写 Shader。


原理与数学机制

Gamma 校正公式

sRGB 分段线性函数

Clinear={Csrgb12.92,Csrgb0.04045(Csrgb+0.0551.055)2.4,Csrgb>0.04045C_{linear} = \begin{cases} \frac{C_{srgb}}{12.92}, & C_{srgb} \leq 0.04045 \\ \left(\frac{C_{srgb} + 0.055}{1.055}\right)^{2.4}, & C_{srgb} > 0.04045 \end{cases}

逆 Gamma(用于线性化)

Csrgb={12.92Clinear,Clinear0.00313081.055Clinear1/2.40.055,Clinear>0.0031308C_{srgb} = \begin{cases} 12.92 \cdot C_{linear}, & C_{linear} \leq 0.0031308 \\ 1.055 \cdot C_{linear}^{1/2.4} - 0.055, & C_{linear} > 0.0031308 \end{cases}

Reinhard 色调映射

Lout=Lin1+LinL_{out} = \frac{L_{in}}{1 + L_{in}}

白点(White Point)控制

Lout=Lin(1+LinLwhite2)1+LinL_{out} = \frac{L_{in} \cdot (1 + \frac{L_{in}}{L_{white}^2})}{1 + L_{in}}

问题:高光被压缩到接近白色,丢失细节。

Filmic Tone Mapping

Unreal Filmic 近似

x=max(0,Lin0.004)x = \max(0, L_{in} - 0.004) Lout=x(6.2x+0.5)x(6.2x+1.7)+0.06L_{out} = \frac{x \cdot (6.2 \cdot x + 0.5)}{x \cdot (6.2 \cdot x + 1.7) + 0.06}

ACES Filmic Tone Mapping

ACES 官方曲线(简化版)

a=2.51a = 2.51 b=0.03b = 0.03 c=2.43c = 2.43 d=0.59d = 0.59 e=0.14e = 0.14 Lout=clamp(Lin(aLin+b)Lin(cLin+d)+e,0,1)L_{out} = \text{clamp}\left(\frac{L_{in} \cdot (a \cdot L_{in} + b)}{L_{in} \cdot (c \cdot L_{in} + d) + e}, 0, 1\right)

TIP

ACES 是电影工业标准,色彩准确,但计算量较大。可使用预计算 LUT 加速。

LUT 采样数学

3D LUT 索引计算

U=R255N1N+0.5NU = \frac{R}{255} \cdot \frac{N-1}{N} + \frac{0.5}{N} V=G255N1N+0.5NV = \frac{G}{255} \cdot \frac{N-1}{N} + \frac{0.5}{N} W=B255N1N+0.5NW = \frac{B}{255} \cdot \frac{N-1}{N} + \frac{0.5}{N}

其中 N 是 LUT 纹理大小(如 32、64)。

三线性插值

Cout=trilinearLerp(C000,C001,,C111,α,β,γ)C_{out} = \text{trilinearLerp}(C_{000}, C_{001}, \dots, C_{111}, \alpha, \beta, \gamma)

GLSL vs WGSL 代码对照

Gamma 校正与线性渲染管线

GLSL 版本

#version 300 es
precision highp float;

in vec2 v_texcoord;

uniform sampler2D u_color_texture;
uniform float u_exposure;

out vec4 frag_color;

vec3 linearToSRGB(vec3 linearRGB) {
  vec3 sRGBLo = linearRGB * 12.92;
  vec3 sRGBHi = pow(linearRGB, vec3(1.0 / 2.4)) * 1.055 - 0.055;
  vec3 sRGB = mix(sRGBLo, sRGBHi, step(0.0031308, linearRGB));
  return sRGB;
}

vec3 sRGBToLinear(vec3 sRGB) {
  vec3 linearLo = sRGB / 12.92;
  vec3 linearHi = pow((sRGB + 0.055) / 1.055, vec3(2.4));
  vec3 linear = mix(linearLo, linearHi, step(0.04045, sRGB));
  return linear;
}

vec3 reinhardToneMapping(vec3 hdr) {
  return hdr / (hdr + vec3(1.0));
}

vec3 filmicToneMapping(vec3 hdr) {
  vec3 x = max(vec3(0.0), hdr - vec3(0.004));
  return (x * (6.2 * x + vec3(0.5))) / (x * (6.2 * x + vec3(1.7)) + vec3(0.06));
}

vec3 acesToneMapping(vec3 hdr) {
  const float a = 2.51;
  const float b = 0.03;
  const float c = 2.43;
  const float d = 0.59;
  const float e = 0.14;
  return clamp((hdr * (a * hdr + vec3(b))) / (hdr * (c * hdr + vec3(d)) + vec3(e)), 0.0, 1.0);
}

void main() {
  vec4 color = texture(u_color_texture, v_texcoord);

  vec3 linear = sRGBToLinear(color.rgb);
  vec3 exposed = linear * exp2(u_exposure);
  vec3 tonemapped = acesToneMapping(exposed);
  vec3 srgb = linearToSRGB(tonemapped);

  frag_color = vec4(srgb, color.a);
}

WGSL 版本

struct FragmentInput {
  @location(0) texcoord: vec2<f32>,
}

struct FragmentOutput {
  @location(0) color: vec4<f32>,
}

@group(0) @binding(0)
var color_texture: texture_2d<f32>;

@group(0) @binding(1)
var color_sampler: sampler;

@group(0) @binding(2)
var<uniform> exposure: f32;

fn linearToSRGB(linearRGB: vec3<f32>) -> vec3<f32> {
  let sRGBLo = linearRGB * 12.92;
  let sRGBHi = pow(linearRGB, vec3<f32>(1.0 / 2.4)) * 1.055 - 0.055;
  let sRGB = mix(sRGBLo, sRGBHi, step(0.0031308, linearRGB));
  return sRGB;
}

fn sRGBToLinear(sRGB: vec3<f32>) -> vec3<f32> {
  let linearLo = sRGB / 12.92;
  let linearHi = pow((sRGB + 0.055) / 1.055, vec3<f32>(2.4));
  let linear = mix(linearLo, linearHi, step(0.04045, sRGB));
  return linear;
}

fn reinhardToneMapping(hdr: vec3<f32>) -> vec3<f32> {
  return hdr / (hdr + vec3<f32>(1.0));
}

fn filmicToneMapping(hdr: vec3<f32>) -> vec3<f32> {
  let x = max(vec3<f32>(0.0), hdr - vec3<f32>(0.004));
  return (x * (6.2 * x + vec3<f32>(0.5))) / (x * (6.2 * x + vec3<f32>(1.7)) + vec3<f32>(0.06));
}

fn acesToneMapping(hdr: vec3<f32>) -> vec3<f32> {
  let a: f32 = 2.51;
  let b: f32 = 0.03;
  let c: f32 = 2.43;
  let d: f32 = 0.59;
  let e: f32 = 0.14;
  return clamp((hdr * (a * hdr + vec3<f32>(b))) / (hdr * (c * hdr + vec3<f32>(d)) + vec3<f32>(e)), vec3<f32>(0.0), vec3<f32>(1.0));
}

@fragment
fn fs_main(input: FragmentInput) -> FragmentOutput {
  var output: FragmentOutput;

  let color = textureSample(color_texture, color_sampler, input.texcoord);

  let linear = sRGBToLinear(color.rgb);
  let exposed = linear * exp2(exposure);
  let tonemapped = acesToneMapping(exposed);
  let srgb = linearToSRGB(tonemapped);

  output.color = vec4<f32>(srgb, color.a);
  return output;
}

差异点

  • WGSL 的 mix() 函数参数顺序与 GLSL 相同
  • pow() 函数 WGSL 需要显式类型标注
  • WGSL 的 clamp() 使用 vec3<f32> 类型

LUT 调色实现

GLSL 版本

#version 300 es
precision highp float;

in vec2 v_texcoord;

uniform sampler2D u_color_texture;
uniform sampler3D u_lut_texture;
uniform float u_lut_intensity;

out vec4 frag_color;

vec3 applyLUT(vec3 color, sampler3D lut, float intensity) {
  float lutSize = 32.0;
  float scale = (lutSize - 1.0) / lutSize;
  float offset = 0.5 / lutSize;

  vec3 lutCoord = color * scale + offset;
  vec3 lutColor = texture(lut, lutCoord).rgb;

  return mix(color, lutColor, intensity);
}

void main() {
  vec4 color = texture(u_color_texture, v_texcoord);
  vec3 lutColor = applyLUT(color.rgb, u_lut_texture, u_lut_intensity);

  frag_color = vec4(lutColor, color.a);
}

WGSL 版本

struct FragmentInput {
  @location(0) texcoord: vec2<f32>,
}

struct FragmentOutput {
  @location(0) color: vec4<f32>,
}

@group(0) @binding(0)
var color_texture: texture_2d<f32>;

@group(0) @binding(1)
var color_sampler: sampler;

@group(0) @binding(2)
var lut_texture: texture_3d<f32>;

@group(0) @binding(3)
var lut_sampler: sampler;

@group(0) @binding(4)
var<uniform> lut_intensity: f32;

fn applyLUT(color: vec3<f32>, lut: texture_3d<f32>, sampler_type: sampler, intensity: f32) -> vec3<f32> {
  let lutSize: f32 = 32.0;
  let scale = (lutSize - 1.0) / lutSize;
  let offset = 0.5 / lutSize;

  let lutCoord = color * scale + offset;
  let lutColor = textureSample(lut, sampler_type, lutCoord).rgb;

  return mix(color, lutColor, intensity);
}

@fragment
fn fs_main(input: FragmentInput) -> FragmentOutput {
  var output: FragmentOutput;

  let color = textureSample(color_texture, color_sampler, input.texcoord);
  let lutColor = applyLUT(color.rgb, lut_texture, lut_sampler, lut_intensity);

  output.color = vec4<f32>(lutColor, color.a);
  return output;
}

差异点

  • WGSL 需要显式声明 texture_3d<f32> 类型
  • textureSample() 函数替代 texture()
  • 参数传递需要显式类型转换

常见误区与陷阱

  1. 忘记线性化纹理

    • 陷阱:直接使用 sRGB 纹理进行光照计算
    • 解决:加载纹理时标记为 SRGB 格式,或在 Shader 中手动 pow(x, 2.2)
  2. 色调映射后重复 Gamma 校正

    • 陷阱:已经转换为 sRGB 后再次 pow(x, 1/2.2)
    • 解决:明确管线阶段,避免重复校正
  3. LUT 纹理大小过小

    • 陷阱:使用 16×16×16 的 LUT 导致色彩断层
    • 解决:使用 32×32×32 或 64×64×64,或使用三线性插值
  4. Reinhard 色调映射丢失高光

    • 陷阱:高光被压缩到接近白色
    • 解决:使用 Filmic 或 ACES,或调整白点参数
  5. HDR 纹理格式错误

    • 陷阱:使用 RGBA8 存储 HDR 纹理导致精度丢失
    • 解决:使用 RGBA16FRGBA32F 浮点纹理
  6. ACSE 色调映射参数错误

    • 陷阱:使用错误的 a, b, c, d, e 参数导致颜色失真
    • 解决:使用官方标准值或预计算 LUT
  7. 混合颜色空间

    • 陷阱:部分纹理在线性空间,部分在 sRGB 空间
    • 解决:统一工作流,所有纹理在线性空间计算
  8. LUT 强度过高

    • 陷阱:lut_intensity = 1.0 导致颜色失真
    • 解决:使用 0.5-0.8 的中等强度,保留原始色彩

延伸阅读与自测

权威资料

开源实现参考

自测题

  1. 思考题: 为什么显示器需要 Gamma 曲线?如果显示器是完全线性的,渲染管线需要做什么改变?

  2. 对比题: sRGB 与 Rec.2020 在色域覆盖上的区别是什么?为什么 Rec.2020 需要 HDR?

  3. 实践题: 如何实现一个自适应曝光(Adaptive Exposure)系统,根据场景平均亮度自动调整曝光?

  4. 扩展题: 为什么 ACES 色调映射比 Reinhard 更适合电影渲染?它们的数学曲线有何本质区别?

  5. 优化题: LUT 调色在移动端如何优化?是否可以使用 2D LUT 替代 3D LUT?


参考资料获取时间: 2026-07-06,通过 web-search-prime_web_search_prime 工具检索。