每日编程实践: HDR Tone Mapping & Color Grading
背景与动机
每一帧游戏画面的最后一道工序,往往最不起眼,却决定了一切的观感——色调映射(Tone Mapping)。
问题的根源:显示器不是眼睛
现实世界的亮度范围极为宽广。晴天户外的直射阳光亮度约为 100,000 cd/m²,室内阴影处可能只有 0.01 cd/m²,动态范围高达 10 个数量级(100dB)。人眼通过瞳孔收缩和视杆/视锥细胞的自适应,能感知约 20 个 EV(Exposure Value)的动态范围。
然而,家用显示器的 SDR(Standard Dynamic Range)面板通常只能显示 0.1 到 300 cd/m² 的亮度,不足 3 个数量级。HDR 显示器能达到 1000–4000 nits,也仅覆盖 4 个数量级。
一个物理正确的渲染器(Path Tracer、光栅化 + 全局光照),输出的是线性光照空间的辐射值,可以包含从 0.001(深阴影)到 20+(太阳直射)甚至更高的值域。如果我们直接把这些值限制在 [0, 1] 范围内显示,高光区域全部爆掉变成一片白,暗部细节也因非线性感知而全部消失。
色调映射就是解决这个问题的桥梁——它把宽动态范围的 HDR 数据压缩映射到显示器能表达的 LDR(Low Dynamic Range)范围,同时尽量保留视觉上的真实感。
工业界使用场景
- 游戏引擎:Unreal Engine 4/5 默认使用 ACES Filmic;Unity HDRP 提供 ACES、Neutral、Custom 等选项
- 电影 VFX 流水线:ACES(Academy Color Encoding System)是好莱坞的行业标准,被 Nuke、DaVinci Resolve 广泛支持
- 实时渲染:TAA(时序抗锯齿)、Bloom、SSAO 等后处理效果都在 HDR 空间工作,色调映射是渲染管线最末端的一步
- 摄影软件:Lightroom 的 Tone Curve、Photoshop 的 Camera Raw 都是色调映射的图形化界面
- HDR 显示器适配:新一代游戏需要在 SDR/HDR 显示器之间自动切换色调映射策略
没有色调映射会怎样
1 | HDR 场景亮度范围:0.01 ~ 25.0 |
今天的目标:实现 6 种主流色调映射算法,并加上色彩分级(Color Grading),最终输出对比图。
核心原理
什么是 HDR 场景
在渲染管线中,HDR 缓冲(通常是 RGBA16F 或 RGBA32F 格式的帧缓冲)存储的是线性辐射值,不经过任何非线性变换。
线性光照空间的关键特性:
- 叠加律:两盏灯照同一点,亮度直接相加(不是在显示器空间相加)
- 物理正确:PBR 光照计算基于能量守恒,结果在线性空间才有意义
- 范围无上限:太阳直射可以是 20,室内灯可以是 5,阴影可以是 0.02
色调映射函数 T(x) 需要满足:
- 单调递增:更亮的 HDR 值映射后仍然更亮
- 渐近压缩:当
x → ∞时,T(x)趋近于 1(不是无限增长) - 线性低端:当
x很小时,T(x) ≈ x(暗部不被扭曲) - 视觉舒适:映射结果符合人眼对对比度和色彩的感知习惯
伽马编码:为什么显示前要做伽马矫正
人眼对亮度的感知是非线性的——对暗部变化更敏感,对亮部变化相对迟钝。这正好符合对数曲线。
历史上,CRT 显示器的物理特性恰好是 亮度 ∝ 电压^2.2,即显示器本身有 γ=2.2 的非线性响应。为了利用这一特性存储更多暗部信息,sRGB 标准规定图像存储时要做伽马编码(近似 output = input^(1/2.2)),显示时被显示器的物理特性”解码”。
现代 HDR 显示器和软件流水线中,这个过程被标准化为 sRGB 传递函数:
1 | 线性值 → sRGB编码值 |
关键直觉:所有色调映射都在线性空间进行,最后才做伽马编码。如果顺序搞反,颜色会严重失真。
Reinhard 色调映射
公式:
1 | T(x) = x / (x + 1) |
当 x=0.5 时,T(x)=0.33;当 x=1 时,T(x)=0.5;当 x→∞ 时,T(x)→1。
直觉:分母比分子多了一个常数 1,使得曲线在高亮度区域增速减缓。就像加了一个弹簧——越拉越难拉。
改进版(Extended Reinhard) 引入白点 W:
1 | T(x) = x × (1 + x/W²) / (1 + x) |
当 x = W 时,输出接近 1。通过调整白点,可以控制”何时高光开始压缩”。若 W 很大,整体亮度更高更通透;W 较小,画面更暗但高光更可控。
优缺点:
- ✅ 简单,无参数,实现一行代码
- ❌ 颜色会偏移(不同通道被不同幅度压缩)
- ❌ 画面往往偏灰,对比度低
- 适合:学习、原型验证、需要保持颜色准确的场景
ACES Filmic 色调映射
ACES(Academy Color Encoding System)是电影工业的颜色管理标准,由美国电影艺术与科学学院制定。Krzysztof Narkowicz 在 2016 年提出了一个廉价但效果极好的近似公式,被 Unreal Engine 采用:
公式(Narkowicz/Hill ACES 近似):
1 | T(x) = (x × (2.51x + 0.03)) / (x × (2.43x + 0.59) + 0.14) |
等价展开:分子 = ax² + bx,分母 = cx² + dx + e,这是一个有理函数(Rational Function)。
直觉:ACES 曲线有三段特征:
- 暗部(x<0.2):近似线性,保留暗部细节不失真
- 中灰(x≈0.2-0.6):略微抬高对比度,画面更有”电影感”
- 高光(x>1):快速压缩至 1,高光不爆
与 Reinhard 不同,ACES 在中灰区域有轻微的 S 形弯曲,这让画面的对比度和饱和度都更接近电影效果。在曝光参数 exposure=0.6 时,输入 1.0 的白色被映射到约 0.8,保留了高光的层次。
实现细节:
1 | Vec3 tonemapACES(Vec3 color, float exposure = 0.6f) { |
注意:必须做逐通道除法(component-wise division),不能写 num / den——除非 Vec3 定义了向量除法运算符(这是今天的一个坑)。
Uncharted 2 / Hable Filmic
由 John Hable 在 Uncharted 2 开发期间设计,2010 年的 GDC 演讲后被广泛采用。基于 Filmic 曲线设计,参数更多但控制更细腻:
Hable 算子:
1 | f(x) = (x × (A×x + C×B) + D×E) / (x × (A×x + B) + D×F) - E/F |
这不是一个任意多项式——每个参数对应曲线的一个视觉特性:
- A(Shoulder Strength):高光区肩部的弯曲程度
- B(Linear Strength):中间线性段的斜率
- C(Linear Angle):线性段的倾角
- D(Toe Strength):暗部趾部的弯曲程度
- E(Toe Numerator) 和 F(Toe Denominator):趾部的精细控制
使用时还需要用白点归一化:T(x) = f(x) / f(W),其中 W=11.2 是场景的最亮值(Hable 用了霓虹灯场景的真实数据)。
与 ACES 的主要区别:
- Uncharted 2 的中灰色稍微低一些,对比度略高
- 高光的”肩部”更硬,过渡更快
- 因此画面感觉更”硬朗”,适合写实风格游戏
Lottes Filmic
Timothy Lottes(AMD GPU 研究员)在 2016 年提出,与 Reinhard 和 ACES 相比,公式更复杂但曲线控制更精确:
1 | f(v) = v^a / (v^(a×d) × b + c) |
Lottes 曲线的特点是参数有明确的物理含义:
a:整体对比度(类似 Power 曲线的指数)d:高光区的衰减速度- 中灰映射 midIn→midOut 是硬编码的,保证了不同场景的感知亮度一致
效果上,Lottes 和 ACES 相近,但高光区更柔和,色偏更少。
对比总结
| 算法 | 暗部保留 | 中灰对比 | 高光处理 | 色偏 | 计算量 |
|---|---|---|---|---|---|
| Gamma Only | 良好 | 正常 | 爆掉 | 无 | 极低 |
| Reinhard | 良好 | 偏低 | 柔和但偏灰 | 中等 | 极低 |
| Reinhard Extended | 良好 | 偏低 | 可控白点 | 中等 | 低 |
| ACES Filmic | 优秀 | 高(电影感) | 压缩强 | 极少 | 低 |
| Uncharted 2 | 优秀 | 高(写实感) | 硬朗 | 极少 | 低 |
| Lottes | 优秀 | 中等 | 柔和 | 极少 | 中等 |
| ACES + ColorGrade | 优秀 | 高+定制 | 压缩强 | 可调 | 低 |
实现架构
整体渲染管线
1 | [生成 HDR 场景] |
每个阶段都在不同的色彩空间中操作,顺序不能搞乱。
关键数据结构
Vec3:线性颜色值
1 | struct Vec3 { |
亮度系数来自 BT.709 标准(sRGB 使用的原色):绿色通道权重最高(0.7152),因为人眼对绿色最敏感。
Image:HDR 图像缓冲
1 | struct Image { |
为什么用几何均值(log均值)而不是算术均值?
因为亮度的感知是对数的。对数空间的平均值 = 几何均值,更能代表场景的”中灰”。这也是自动曝光(Auto Exposure)的计算基础,即 Reinhard 2002 论文中的 Key Value 算法。
ToneMapper 类型别名:
1 | using ToneMapper = std::function<Vec3(Vec3)>; |
用函数对象封装每个算法,可以统一地对整张图应用:
1 | Image applyToneMap(const Image& hdr, ToneMapper tm, bool doColorGrade = false) { |
HDR 测试场景设计
为了验证各算法的差异,场景必须覆盖所有亮度区间:
| 区域 | 亮度范围 | 设计目标 |
|---|---|---|
| 背景天空 | 1.5–3.0 | 正常高光区 |
| 太阳 | 15–25 | 超亮高光(各算法的关键区别) |
| 灯光窗口 | 3–7 | 中高光 |
| 地面暗部 | 0.02–0.15 | 暗区细节保留 |
| 萤火虫粒子 | 2–10 | 随机点光源 |
这覆盖了 3 个数量级的亮度范围(0.02 到 25),足以区分各算法的行为差异。
色彩分级管线
1 | 色调映射输出(LDR线性) |
色彩分级在色调映射之后、伽马编码之前进行——此时数据已经在 [0,1] 范围内,是最稳定的操作空间。
关键代码解析
HDR 场景生成
1 | Image generateHDRScene(int width, int height) { |
关键点:太阳核心亮度 20,光晕 6,总亮度最高可达 26——这超过了 Reinhard 白点(通常设 4)的 6 倍,能很好地体现各算法的高光处理差异。
Reinhard 核心实现
1 | Vec3 tonemapReinhard(Vec3 color, float exposure = 1.f) { |
为什么逐分量处理而不是按亮度处理?
逐分量(如上):每个颜色通道独立压缩。优点是实现简单;缺点是高亮度时不同通道被不同程度压缩,导致色偏(hue shift)——一个橙色的强光源经过 Reinhard 后可能偏黄或偏红。
亮度保持(Reinhard Luma Variant):先计算 luma,用 Reinhard 压缩 luma,再按比例缩放 RGB:
1 | float luma = color.luminance(); |
优点是无色偏,但暗部饱和度会稍高。
今天的实现选择了逐分量版本,因为想展示”朴素”版本的行为,便于与 ACES 对比。
ACES 实现——为什么需要逐分量除法
1 | Vec3 tonemapACES(Vec3 color, float exposure = 0.6f) { |
早期实现曾写成 num / den,但当时 Vec3 只有 operator/(float) 没有 operator/(Vec3),导致编译错误(见踩坑章节)。
Uncharted 2 Hable 实现
1 | Vec3 hableOp(Vec3 x) { |
白点 11.2 的选取:Hable 在分析 Uncharted 2 实际场景时,发现场景最亮的元素(霓虹灯反射)约为 11.2 EV,因此用这个值作为白点,保证场景最亮处刚好映射到纯白。
Lottes Filmic 实现
1 | Vec3 tonemapLottes(Vec3 color, float exposure = 1.f) { |
midIn=0.18 是”中灰”(18% 反射率,摄影的标准灰卡),midOut=0.267 是 Lottes 选定的中灰应该映射到的显示亮度。这个约束保证了不同场景的相对亮度感知一致性。
色彩分级管线
1 | // 对比度:S形曲线,以0.5为中心 |
整合管线:
1 | Vec3 colorGrade(Vec3 color) { |
输出对比图拼接
1 | // 3列布局,每panel之间有4px分隔线 |
最终输出 PPM 格式(P6 二进制),再用 Pillow 转 PNG。选择 PPM 格式是因为它无需任何图像库——只需标准文件 I/O,适合 Skill 验证环境。
踩坑实录
Bug 1:Vec3 缺少向量除法运算符
症状:编译错误
1 | error: no match for 'operator/' (operand types are 'Vec3' and 'Vec3') |
错误假设:写 num / den(两个 Vec3 相除),以为 Vec3 已经有这个运算符了。
真实原因:Vec3 只定义了 operator/(float t) 标量除法,没有定义 operator/(const Vec3& o) 逐分量除法。C++ 不会自动生成向量运算符——你定义了什么,就只有什么。
修复方式:
1 | // 在 Vec3 中添加: |
受影响的函数:Reinhard、Reinhard Extended、hableOp(Uncharted 2)、Uncharted 2 白点归一化——共 4 处,都因为这同一个缺失的运算符报错。
教训:写向量数学类时,把所有运算符(+、-、×、/ 的标量版和向量版)一次性写完。少一个就会在意想不到的地方炸。
编译后验证:
1 | 编译前:4 个 error,0 warning |
Bug 2:亮度统计使用算术均值而不是几何均值
症状(潜在的,调试时发现):验证脚本期望场景的平均亮度代表”中灰”,但算术均值因为太阳极高亮度被拉偏,不能准确反映场景的感知亮度。
真实原因:亮度感知是对数的,算数均值容易被极端值拉偏。例如场景中 99% 的像素亮度 0.3,1% 的像素亮度 25,算术均值约 0.55,几何均值约 0.32。后者更接近人眼感知的”平均亮度”。
修复方式:
1 | float avgLuminance() const { |
教训:HDR 计算中任何”平均亮度”的计算,默认用几何均值(log 空间均值),除非特殊场景另有需要。
Bug 3:ACES 曝光参数过高导致亮度普遍偏高
症状(调试中观察):用 exposure=1.0 时,ACES 输出的均值约 0.85,接近全白,失去了高光层次。
原因:ACES 曲线本身会提升中灰对比度,加上 exposure=1 对输入没有任何缩放,场景中大量 HDR 值(>1)被一股脑地压到了 0.9+ 区间。
修复:调整为 exposure=0.6,让输入先缩小到合理范围,ACES 曲线再展示它的优势区间。
最终验证结果:
1 | [ACES Filmic] mean=0.669 std=0.272 ✅ 均值和标准差都在正常范围 |
Bug 4:PPM 写入时忘记 std::ios::binary
症状:PPM 文件写入后,在 Linux 上能正常读取,但如果在 Windows 上运行可能出现文件损坏(换行符问题)。
原因:PPM P6 格式是二进制格式,文件头是 ASCII,但像素数据是原始二进制字节。如果用文本模式打开文件,\n 可能被写成 \r\n,导致像素数据偏移。
修复:
1 | std::ofstream f(filename, std::ios::binary); // 必须 binary 模式! |
教训:任何包含二进制数据的文件,无论平台,都应该用 std::ios::binary 打开。
效果验证与数据
编译验证
1 | 编译命令:g++ main.cpp -o output -std=c++17 -O2 -Wall -Wextra |
运行数据
HDR 场景统计:
1 | 分辨率:320 × 200 |
各算法输出像素统计(均值越接近 0.5 越”自然”,标准差越高越有层次):
| 算法 | 均值 | 标准差 | 评价 |
|---|---|---|---|
| Gamma Only | 0.743 | 0.284 | 均值偏高,高光爆掉 |
| Reinhard | 0.634 | 0.210 | 均值合理,但标准差略低 |
| Reinhard Extended | 0.659 | 0.231 | 白点保护有效 |
| ACES Filmic | 0.669 | 0.272 | 标准差最高,层次最丰富 |
| Uncharted 2 | 0.632 | 0.238 | 均值最低,风格最”硬” |
| Lottes Filmic | 0.750 | 0.222 | 均值略高,亮度感强 |
| ACES + ColorGrade | 0.663 | 0.322 | 色彩分级后标准差最高 |
ACES Filmic 的标准差(0.272)最高,说明明暗层次保留得最好——这正是它成为工业标准的原因。
坐标系验证
1 | 天空区(上1/4)均值:232.7 |
文件大小
1 | tm_aces.png:2.9 KB(320×200) |
视觉对比分析
观察对比图(960×612)从左到右:
第一排:
- Gamma Only:高光区严重爆掉(太阳整片白色),地面细节尚存
- Reinhard:高光得到控制,但整体偏灰,缺乏冲击力
- Reinhard Extended:比 Reinhard 略亮,高光更有层次
第二排:
- ACES Filmic:对比度最强,太阳区域有明显的高光过渡,中灰区细节最丰富
- Uncharted 2:接近 ACES,但高光”肩部”更硬,整体更暗(曝光参数 2.0 时接近 ACES 0.6)
- Lottes Filmic:整体最亮,类似 ACES 但更”通透”
第三排(ACES + ColorGrade):
- 相较纯 ACES,暗部略偏冷蓝(分色),高光略偏暖橙(色温+0.08)
- 对比度提升后层次感最强
- 这是现代游戏引擎最接近的真实效果
总结与延伸
技术局限性
无自动曝光(Auto Exposure):真实渲染管线会根据场景平均亮度动态调整曝光,本项目使用固定曝光值。实现 AE 需要 GPU histogram 或降采样计算平均亮度,然后用滞后平均(lerp 到目标)避免闪烁。
分辨率较低(320×200):为快速迭代选用,实际验证效果。生产环境应为 1920×1080 或更高。
色彩分级是简化模型:真实的电影级色彩分级使用 3D LUT(Lookup Table,64×64×64 的三维颜色映射表),可以做任意复杂的颜色变换。本项目的分色和色温调整是一次线性近似。
ACES 曲线是近似:完整的 ACES 标准包含从场景色彩空间(ACES2065-1)到显示色彩空间的矩阵变换。Narkowicz/Hill 的公式只是 RRT(Reference Rendering Transform)的拟合近似,精度约 1%。
没有 HDR 显示器支持:输出是 sRGB SDR。真正的 HDR 显示需要输出 PQ(Perceptual Quantizer,ST 2084)或 HLG(Hybrid Log-Gamma)编码。
可优化方向
- Local Tone Mapping:Reinhard 全局版本的改进是局部自适应——根据每个像素的局部邻域亮度来决定压缩幅度,效果更接近人眼的局部自适应
- Bloom 协同:真实场景中,高亮区域应该先做 Bloom(辉光)再做色调映射,否则高光过渡太硬
- HDR 到 HDR 的映射:对于支持 HDR 显示器的设备,需要输出 0–10000 nits 范围的 PQ 信号,而不是压缩到 SDR
- 感知均匀色彩空间:在 OKLab、ICtCp 等感知均匀空间做色调映射,可以最大程度减少色偏
- GPU 实现:目前是 CPU 单线程,全分辨率图像可以用 GLSL/HLSL 的 Fragment Shader 或 Compute Shader 实现,成本接近零
与本系列的关联
这个 Skill 是渲染管线的最后一环,与前几天的实践形成闭环:
| 日期 | 项目 | 位置 |
|---|---|---|
| 03-26 | TAA(时序抗锯齿) | 色调映射之前的 AA pass |
| 03-27 | Motion Blur | 色调映射之前的运动模糊 pass |
| 03-28 | Depth of Field | 色调映射之前的景深 pass |
| 03-29 | HDR Tone Mapping | 所有后处理之后,最终输出 |
一个完整的渲染管线:
1 | G-Buffer → Lighting → SSR → SSAO → TAA → DoF → MotionBlur → Bloom → ToneMapping → Color Grade |
今天的项目填补了”最后一公里”的空白。
代码与资产
- 代码仓库:https://github.com/chiuhoukazusa/daily-coding-practice/tree/main/2026/03/03-29-hdr-tone-mapping
- 对比图:https://raw.githubusercontent.com/chiuhoukazusa/blog_img/main/2026/03/03-29-hdr-tone-mapping/tonemap_comparison.png

7种算法从左到右,上至下:Gamma裁剪、Reinhard、Reinhard Extended、ACES Filmic、Uncharted 2、Lottes Filmic、ACES + Color Grading。
每日编程实践系列 · 第 40+ 天 · 2026-03-29











