/ Ch20 色调再现

第20章:色调再现 — 零基础讲义

讲义说明
本讲义基于 Steve Marschner & Peter Shirley 所著《虎书5》第5版第20章(p.569-594)。
核心问题:渲染器算出来的是一个物理上正确但显示器放不下的高动态范围(HDR)图像,你怎么把它压缩到显示器能显示的低动态范围(LDR)而不丢失细节?
本版插画采用 Guizang 材质插画风格重新绘制。


20.1 问题的诞生:深夜看电视场景

想象一个场景:深夜你关灯看电视。电视里是一部电影,有两个镜头——

  • 镜头 A:白天阳光下的沙滩,非常亮
  • 镜头 B:月光下的森林,非常暗

你的电视机只有一个亮度范围(比如 0~300 尼特)。它怎么同时显示"阳光"和"月光"?

如果它把"阳光"缩放到 300 尼特,"月光"就几乎变黑了,什么都看不见。如果它把"月光"抬升到能看见的程度,"阳光"就过曝成一团白。

核心问题色调映射(Tone Mapping)要解决的问题——把宽动态范围(HDR)压缩到窄动态范围(LDR)的同时,让亮区和暗区的细节都能看清。
深夜看电视类比
图20-1:深夜看电视——阳光沙滩与月光森林同时显示,这就是色调映射要解决的问题

20.1.1 数字层面的问题

渲染器算出来的 HDR 图像(每个像素是浮点数):
  窗外的阳光直射表面  →  亮度 = 100,000 cd/m²
  室内的桌面          →  亮度 = 100 cd/m²
  桌下的阴影          →  亮度 = 0.5 cd/m²

显示器能显示的范围(普通 SDR 显示器):
  最亮 = 300 cd/m²
  最暗 = 0.3 cd/m²
  比例 = 1000:1

问题是:100,000 和 0.5 差了 20 万倍,显示器只有 1000 倍范围。
    ↓
必须用一种"聪明的压缩"——色调映射(Tone Mapping)

20.1.2 人眼的动态范围†

自然界的亮度范围:
  星光夜空 .............. ~0.001 cd/m²
  月光下 ................ ~1 cd/m²
  室内灯光 .............. ~100 cd/m²
  阴天室外 .............. ~1,000 cd/m²
  阳光直射 .............. ~100,000 cd/m²
  太阳本体 .............. ~1,000,000,000 cd/m²
          ↑ 相差 9 个数量级(10亿倍)

人眼的能力:
  单一时刻能分辨 ≈ 4~5 个数量级(10000:1 ~ 100000:1)
  通过瞳孔调节+化学适应,整体可适应 10+ 个数量级

显示器的短板:
  SDR 显示器:~2 个数量级(1000:1)
  HDR 显示器:~3~4 个数量级(高端 > OLED)
HDR与LDR动态范围对比
图20-2:HDR vs LDR 动态范围对比——自然场景有9个数量级,显示器只能显示2~3个

20.2 三种直觉解法

如果你自己来设计"HDR→LDR 压缩",你会怎么做? 下面三种方法从简单到复杂,逐渐逼近工业标准。

解法 1:直接线性缩放(最简单但最差)

思路:把所有亮度按比例缩小到显示器范围。

Ldisplay = Lrender / Lmax
举例:原始最大值 = 100,000 → 缩小到 300
      原始 0.5    → 0.5 × 300/100000 = 0.0015 → 几乎黑色

结果:阳光部分正常了,阴影部分什么都看不见。
🤔 想一想:线性缩放为什么不行?
因为自然界亮度范围太大了(10万倍),线性缩放会把暗区压到接近0,丢失所有暗部细节。这就像你用"等比例缩图"把一座大山和一座小丘画在同一张纸上——小丘变成几乎看不见的一个点。

解法 2:取对数(经典解法)

思路:用对数把乘法关系变成加法关系,压缩大值,保留小值。

Ldisplay = log(1 + Lrender) / log(1 + Lmax)
直觉:log 的特性——大值被压得狠,小值被压得少
  100,000 → log 之后 → 11.5
  0.5     → log 之后 → 0.4
  比值从 200,000:1 缩小到 28:1

结果:亮区和暗区都能看到了,但对比度偏平(像"褪色"了)
生活类比:对数就像"用分贝量声音"。100分贝和1000分贝只差10倍,而不是100倍——大数被有意压缩了。但问题是对数太"均匀"了,该亮的地方不够亮。

解法 3:S 形曲线(工业标准)

思路:用 Sigmoid 曲线——暗部线性保留,亮部渐近压缩。

Ldisplay = Lrender / (Lrender + k)

其中 k 是"半饱和常数"

特性:暗部 → 近似线性(细节保留)
      亮部 → 渐近压缩(不会过曝成一团白)
      中间区域 → 平滑过渡

这就是游戏/电影里最常见的色调映射曲线形状

20.3 为什么不能简单缩放?

核心矛盾

场景动态范围需要怎么处理
阳光海滩极亮(100,000 cd/m²)大幅压缩
月光森林极暗(1 cd/m²)适度提升
室内桌面中等(100 cd/m²)基本保留

问题:同一个场景、同一张图像,同时包含三种区域。一个函数无法同时满足所有需求。

方案海滩森林结论
压暗(线性缩小)正常全黑暗部丢失
提亮(线性放大)过曝正常亮部丢失
S形曲线压缩但可看提亮但可看两者兼顾
一句话总结:色调映射 = 模拟人眼的自动适应能力。人眼通过瞳孔+视网膜自动调节,显示器不会自动调节,所以我们用算法来模拟。

20.4 两条路线

路线思路现状
路线 A:硬件 HDR显示器本身支持 HDR(OLED、Dolby Vision),增加硬件的动态范围正在普及,但尚未完全
路线 B:软件色调映射用算法把 HDR 压缩到 LDR 显示当前主力方法,本章主题
无论硬件 HDR 发展到什么程度,色调映射算法都不可或缺——因为内容的动态范围总是超过硬件的极限。

20.5 动态范围详解

20.5.1 LDR 和 HDR 编码格式

编码格式每通道位数动态范围典型用途
8-bit LDR (sRGB)8 bit~2.4 log₁₀普通显示器、网页图片
10-bit HDR1010 bit~3 log₁₀蓝光、HDR 电视
FP16 (OpenEXR)16 bit 浮点理论 760 个数量级渲染中间结果、电影制作
FP3232 bit 浮点天文数字科学计算、高精度渲染

20.5.2 三种度量方式

这是在解决什么问题:动态范围本质上就是"最亮除以最暗"。但不同行业用不同的尺子量——结果一样,单位不同。

度量方法公式用中文说就是例子(阳光/阴影)
比值法Lmax/Lmin最亮除以最暗200,000:1
对数法log₁₀(Lmax/Lmin)用对数尺子量,差1=差10倍5.3 log₁₀
SNR法20·log₁₀(信噪比)考虑了噪声,暗区噪声太大再暗也白搭(取决于传感器)

20.6 颜色处理

20.6.1 亮度压缩,颜色不变

这是在解决什么问题:HDR 图像有 RGB 三个通道。如果对三个通道单独压缩,颜色比例会乱掉——红色压得比绿色多,画面就偏色了。所以正确的做法是:只压缩亮度,RGB比例保持不变。

Cdisplay = (Coriginal / Loriginal) × Lcompressed

用中文说就是:原始 RGB 除以亮度(得到颜色比例),再乘以压缩后的亮度(恢复亮度但保留色相)。

20.6.2 饱和度问题

这是在解决什么问题:太严格保持 RGB 比例,亮区的颜色反而会变得"过艳"——因为人眼在亮场景下对颜色的敏感度降低。

Cdisplay = (Coriginal / Loriginal)s × Lcompressed
s 值效果适用场景
s = 1完全保持 RGB 比例可能过艳,不太自然
s = 0.6适度降低饱和度最常用的默认值
s = 0.3大幅降低饱和度模拟亮环境下的视觉

20.7 图像形成模型

20.7.1 基本模型

这是在解决什么问题:一个像素的亮度是怎么来的?我们需要一个简单模型来理解"亮度"的组成成分。

L(x,y) = r(x,y) · E(x,y)
符号含义用中文说就是范围
L(x,y)你看到的像素亮度照片上那个位置的明暗0 ~ 很大
r(x,y)反射率物体"本来"的明暗——白墙≈0.9,黑布≈0.050.005 ~ 1
E(x,y)照度照到物体上的光有多强——太阳下 vs 月光下0 ~ 极大
生活类比:一张照片的亮度 = 物体本身 × 光照。
白墙(反射率0.9)即使光照弱也比较亮;黑布(反射率0.05)即使光照强仍然暗。
懂了这一点,就懂了色调映射的核心策略:压缩照度(E),保留反射率(r)。

20.7.2 对数域:加法比乘法好处理

这是在解决什么问题:取对数之后,"乘法"变成了"加法",方便我们分别处理反射率和照度。

log(L) = log(r) + log(E)
成分变化频率物理含义处理策略
log(r)(反射率)高频(变化快)纹理、边缘、细节保留
log(E)(照度)低频(变化慢)整体明暗压缩

20.8 全局空间算子

20.8.1 对数平均亮度

这是在解决什么问题:我们需要知道场景的"平均亮度"来调节整体明暗。但普通平均会被极亮的像素(如太阳)主导,不代表人眼感知的"平均"。对数平均解决了这个问题。

v = exp( (1/N) · Σ log(δ + Lv(x,y)) )

四步拆解

  1. 给每个亮度加小常数 δ(防 log(0))
  2. 取自然对数 log()
  3. 对所有像素求平均
  4. 再取指数 exp() 恢复成亮度值
为什么不用普通平均?
  场景中有太阳 = 亮度 1,000,000
  其他都是阴影 = 亮度 10
  普通平均 ≈ 500,000 → 被太阳主导
  log 平均 ≈ 3,162 → 更反映人眼感知的平均亮度
🤔 想一想:如果场景中有一盏特别亮的灯,对色调映射有什么影响?
普通平均会被这盏灯大幅拉高,导致整体压得过暗。而log平均把极亮值的影响控制在对数域——10倍的光强差距在对数域只差1个单位。这就是为什么log平均是"感知平均"。

20.8.2 Sigmoid 压缩曲线

这是在解决什么问题:Sigmoid 曲线是在"线性保留"和"渐近压缩"之间做权衡。暗部线性增长(细节分明),亮部渐近压缩(不会过曝)。

Ldisplay = Loriginal / (Loriginal + f)
情况数学效果
L << f(暗区)Ld ≈ L/f线性,细节丰富
L >> f(亮区)Ld ≈ 1渐近饱和,不超白
L = f(中间)Ld = 0.5正好一半
生活类比:f 像是一个"门槛"。亮度低于门槛的——线性保留(你看到的细节就是真实的)。亮度高于门槛的——被压缩(越亮压得越狠,但永远不会超过显示器的最大亮度)。

20.8.3 除法算子

方法公式用中文说就是问题
全局除法Ld = Lv / L̄v每个像素除以平均亮度暗处仍然太暗
局部除法Ld = Lv / Lblur除以周围区域的平均亮度边缘有光晕(halo)

20.9 Reinhard 色调映射(逐行注释)

这是在解决什么问题:Reinhard 色调映射(2002年发表)是最经典的色调映射算法。它把前面的所有技巧整合成三步——缩放→压缩→颜色重建。

Reinhard算子完整流程
图20-3:Reinhard 色调映射完整流程——对数平均亮度→Key缩放→Sigmoid压缩→颜色重建

完整代码(每行中文注释)

// ======== Reinhard 色调映射(C++ 伪代码,每行注释) ========
// 输入:HDR 图像(浮点数组,可能包含极大值如 100,000)
// 输出:LDR 图像(浮点数组,范围约 [0, 1])

// ---- 函数1:计算对数平均亮度 ----
// 这个函数的目的:找出场景的"感知平均亮度"
float computeLogAverageLuminance(const float* hdrImage, int pixelCount)
{
    float sum = 0.0f;
    float delta = 0.0001f;    // 防止 log(0) 的小常数

    for (int i = 0; i < pixelCount; i++)
    {
        // 取 log:把宽范围压缩到可计算的范围
        sum += log(delta + hdrImage[i]);
    }

    // 平均后取指数,得到"感知上的平均亮度"
    return exp(sum / pixelCount);
}

// ---- 函数2:亮度缩放(Key 调整) ----
// 这个函数的目的:把 HDR 亮度的范围"挪"到适合压缩的位置
float scaleLuminance(float L, float L_avg, float key)
{
    // key = "场景亮度偏好"参数,通常 key = 0.18(18% 灰卡反射率)
    // key = 0.18 是摄影中的"中性灰"标准
    // key 越大 → 场景整体偏亮(像过曝照片)
    // key 越小 → 场景整体偏暗(像欠曝照片)
    //
    // 公式含义:scaled = (key / L_avg) × L
    // 平均亮度高的场景 → 缩小幅度大 → 整体压暗
    // 平均亮度低的场景 → 缩小幅度小 → 整体提亮
    return (key / L_avg) * L;
}

// ---- 函数3:Sigmoid 压缩 ----
// 这个函数的目的:把缩放后的亮度压缩到 [0, 1] 范围
float compressSigmoid(float L_scaled)
{
    // 基本 Reinhard 公式:L_d = L_scaled / (1 + L_scaled)
    //
    // 用中文说就是:亮度除以"自己+1"
    //   L_scaled很小(暗区) → 分母≈1 → L_d≈L_scaled(线性保留)
    //   L_scaled很大(亮区) → 分母≈L_scaled → L_d≈1(渐近饱和)
    //   L_scaled=1(中间)  → L_d=0.5
    return L_scaled / (1.0f + L_scaled);
}

// ---- 函数4:颜色重建(可选饱和度控制) ----
// 这个函数的目的:把压缩后的亮度重新应用到 RGB 通道
void reconstructColor(
    const float* hdrR,     // HDR 红色通道
    const float* hdrG,     // HDR 绿色通道
    const float* hdrB,     // HDR 蓝色通道
    float* ldrR,           // LDR 红色通道(输出)
    float* ldrG,           // LDR 绿色通道(输出)
    float* ldrB,           // LDR 蓝色通道(输出)
    const float* hdrLum,  // HDR 原始亮度
    const float* ldrLum,  // LDR 压缩后亮度
    int pixelCount,
    float saturation       // 饱和度控制 [0, 1],通常 = 0.5~1.0
)
{
    for (int i = 0; i < pixelCount; i++)
    {
        // 给每个通道(R/G/B)做饱和度修正
        // 公式:C_ldr = (C_hdr / L_hdr)^s × L_ldr
        //
        // (C_hdr / L_hdr) = "这个颜色比例"
        // ^s = 饱和度指数(s=1完全保持,s<1降低饱和度)
        // × L_ldr = 乘上压缩后的亮度
        //
        // s=1.0时:RGB比例完全保持(可能过艳)
        // s=0.5时:颜色被"去饱和"→ 更自然
        ldrR[i] = pow(hdrR[i] / hdrLum[i], saturation) * ldrLum[i];
        ldrG[i] = pow(hdrG[i] / hdrLum[i], saturation) * ldrLum[i];
        ldrB[i] = pow(hdrB[i] / hdrLum[i], saturation) * ldrLum[i];
    }
}

// ---- 主函数:完整的色调映射管线 ----
void reinhardToneMap(
    const float* hdrImage,   // HDR 亮度数组
    int pixelCount,
    float* ldrOutput,         // LDR 亮度输出
    float key = 0.18f,        // 亮度偏好(摄影参考:18%灰)
    float saturation = 0.6f  // 饱和度(默认 0.6 较自然)
)
{
    // Step 0:计算对数平均亮度
    float L_avg = computeLogAverageLuminance(hdrImage, pixelCount);

    // Step 1-2:对每个像素做 "缩放 + Sigmoid 压缩"
    for (int i = 0; i < pixelCount; i++)
    {
        float L = hdrImage[i];
        float L_scaled = scaleLuminance(L, L_avg, key);  // Step 1
        ldrOutput[i] = compressSigmoid(L_scaled);              // Step 2
    }
    // Step 3(颜色):在主程序中用 reconstructColor() 处理 RGB
}

Reinhard 参数速查

参数典型值含义大→小
key0.18(摄影标准)整体亮度偏好大→更亮,小→更暗
saturation0.6饱和度控制1→完全保持,0.5→适度去饱和
对数平均自动计算场景的"感知平均亮度"自动

20.10 局部算子

20.10.1 为什么要用局部?

这是在解决什么问题:全局算子用同一个压缩曲线处理整个场景。但一个场景可能同时包含极亮(太阳)和极暗(洞内),全局曲线无法同时照顾两边。

全局 Sigmoid 的问题:
  太阳亮度 = 100,000 → 被压到接近 1.0
  洞内亮度 = 0.1 → 被放大到接近 0.0(仍然太暗?)
  如果让洞内可见 → 调低 f → 太阳会过曝

解决方案:不同区域用不同的压缩曲线。
  太阳区 → 用大 f(压缩更狠)
  阴影区 → 用小 f(压缩更轻)
  这需要知道"每个像素所属的区域"→ 局部算子

20.10.2 局部除法 + 双边滤波

方法公式优点问题
高斯模糊除法Ld = Lv / Lgaussian简单快速边缘产生光晕(halo)
双边滤波除法Ld = Lv / Lbilateral边缘保持,无光晕计算慢

20.10.3 频率域方法

这是在解决什么问题:既然照度是"低频"(变化慢),反射率是"高频"(变化快),那就把图像做"频段分离"——压缩低频、保留高频。

步骤:
  ① 把图像转到"对数域"(把乘法变成加法)
  ② 用双边滤波分离 base(低频/照度)和 detail(高频/反射率)
  ③ 只压缩 base layer(照度)
  ④ 把 detail 加回去
  ⑤ 指数变换回亮度
关键洞察:频率域方法的核心优势——照度被压了,但纹理细节(反射率)完完整整保留下来。这是目前质量最高的色调映射方式。

20.11 工业级算子

ACES/Filmic 色调映射曲线
图20-4:ACES/Filmic S形曲线——模拟真实胶片的光化学响应,暗部线性、亮部柔和压缩

20.11.1 ACES(Academy Color Encoding System)

ACES 是好莱坞电影工业的标准色调映射方案。用一句话说:ACES 是一条"模拟真实胶片响应"的多项式曲线

特性说明
输入场景线性光(scene-referred)
输出显示就绪的 sRGB(display-referred)
核心一个拟合好的多项式函数
暗部处理保持对比度
亮部处理"胶片肩部"——模拟真实胶片的饱和

20.11.2 Filmic(Uncharted 2 / John Hable)

对比维度ACESFilmic
追求物理准确(电影标准)视觉好看(游戏标准)
暗部准确还原特意提亮(玩家需要看清暗处)
调参固定曲线对比度、饱和度、白点可调
复杂度较高(多项式拟合)较低(6个系数)

20.11.3 其他工业算子速览

算子来源特点
LottesLottes 2016暗部细节特别好
UchimuraUchimura 2017针对 HDR 显示器优化
Khrushchev(AgX)Blender 3.0+Blender 默认色调映射
FrostbiteEA/DICEEA 商用引擎的色调映射(未公开)
Gran TurismoPolyphony Digital赛车游戏的胶片感

20.12 公式速查与选型指南

公式速查表

概念公式用中文说就是
图像形成模型L = r × E亮度 = 反射率 × 照度
对数分解log(L) = log(r) + log(E)乘法变加法,方便分开处理
对数平均亮度L̄ = exp((1/N)Σ log(δ+Li))防止高亮像素带偏平均值
除法算子Ld = Lv / f每个像素除以参考值 f
Sigmoid 压缩Ld = Lv / (Lv + f)暗区线性、亮区压缩
饱和度修正Cd = (Cv/Lv)s × Lds控制饱和度
Reinhard 三步①L′=(key/L̄)L ②Ld=L′/(1+L′) ③Cd=(Cv/Lv)sat×Ld缩放→Sigmoid→颜色重建
对数压缩Ld = log(1+Lv)/log(1+Lmax)取对数再归一化

快速选型指南

场景推荐算子原因
移动端游戏Reinhard 全局快、简单、GPU 开销小
PC/主机游戏ACES 或 Filmic质量高、参数好调
电影/离线渲染局部算子(双边滤波)质量最优
建筑可视化Reinhard稳定、参数少
VR/AR全局算子 + 防晕动速度快
夜场景暗视觉模型 + 蓝移沉浸感

一句话记住第20章
色调映射 = 告诉显示器"你看到的亮部该多亮、暗部该多暗"——它模拟人眼的适应能力,把物理世界的宽亮度范围巧妙地塞进显示器的窄范围里。

脚注
尼特(nit):亮度单位 cd/m²,1尼特≈一根蜡烛在1平方米面积上的亮度。普通显示器约300尼特,阳光直射表面可达100,000尼特。
SDR(标准动态范围):Standard Dynamic Range,普通显示器的亮度范围,亮度值在0~300尼特之间,对比度约1000:1。
动态范围(Dynamic Range):场景中最亮和最暗的比值。常用对数表示,差1个log单位意味着差10倍。
Sigmoid 函数:S形曲线,在0附近近似线性,在正负无穷处渐近饱和。因其形状像字母S而得名。是神经网络激活函数和色调映射曲线的共同数学基础。
半饱和常数:当 Lrender = k 时输出恰好为0.5。k越小,曲线越早开始压缩(暗部保留更好);k越大,曲线越晚压缩(亮部更亮但可能过曝)。
色相/饱和度/亮度:色相(Hue)= 色彩的主色调(红/绿/蓝等);饱和度(Saturation)= 色彩的鲜艳程度(从灰到纯色);亮度(Luminance)= 色彩的明暗程度。
目录 X
章节 X