色调映射
需要预先学习:
1. 从 LDR 到 HDR
1.1 真实场景的动态范围
传统渲染的帧缓冲用 8bit 无符号整数存储颜色,取值范围 ,称为 LDR(Low Dynamic Range,低动态范围)。但真实场景的亮度范围远不止如此:阳光直射下的亮度是阴影处的几万倍(动态范围约 )。把这样的场景塞进 ,结果只有两种:
- 把曝光调到阴影可见,太阳就变成一片纯白;
- 把曝光调到太阳可见,阴影就变成一片纯黑。
无论哪种,亮部或暗部的细节都被截断(clamp)了。
1.2 HDR 渲染:浮点帧缓冲
HDR(High Dynamic Range,高动态范围)渲染的做法:中间结果用浮点纹理存储,亮度超过 1.0 的值不被截断,等最后再压缩到显示范围。创建浮点帧缓冲:
// 创建 HDR 帧缓冲:颜色附件用半浮点纹理
unsigned int hdrFBO;
glGenFramebuffers(1, &hdrFBO);
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
unsigned int colorBuffer;
glGenTextures(1, &colorBuffer);
glBindTexture(GL_TEXTURE_2D, colorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, SCR_WIDTH, SCR_HEIGHT,
0, GL_RGBA, GL_FLOAT, nullptr); // 16 位浮点
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D, colorBuffer, 0);
场景先渲染进这个浮点帧缓冲,光源强度可以给到远大于 1 的值(如 vec3(10.0)),中间结果保留完整的亮度信息。常用的浮点格式:
| 格式 | 精度 | 用途 |
|---|---|---|
GL_RGBA16F |
半浮点(16bit/分量) | 常规 HDR 渲染,精度足够 |
GL_RGBA32F |
单精度浮点 | 需要高精度的计算(如 IBL 预计算) |
GL_R11F_G11F_B10F |
11/11/10bit 浮点 | 带宽省一半,无 alpha,用于颜色缓冲 |
注:浮点纹理的另一个好处是没有 sRGB 编码问题——线性空间的值直接存储(伽马校正 提到过这一点)。
2. 色调映射
2.1 概念与流程
色调映射(tone mapping)是把 HDR 的亮度范围压缩到 、同时尽量保留对比度与细节的映射。它与伽马校正的区别要分清:
| 概念 | 解决什么 | 本质 |
|---|---|---|
| 伽马校正 | 显示器非线性 | 颜色空间编码(sRGB 与线性的转换) |
| 色调映射 | 动态范围过大 | 亮度压缩(HDR 到 LDR 的映射) |
两者的关系是一条流水线:HDR 场景 → 色调映射 → 伽马编码 → 屏幕。色调映射的输出仍然在线性空间,之后才做伽马编码(见 伽马校正 的"正确流程")。
2.2 直接 clamp 为什么不行
最简单的"映射"是 min(color, 1.0),但效果很差:
- 高光被截断成纯白,亮部内部的明暗层次全部丢失;
- 截断处形成生硬的色彩突变,观感"过曝"。
色调映射的目标是平滑压缩:高光逐渐"卷起"(roll-off)而不是一刀切,同时保住暗部的层次。
3. 常见色调映射算子
3.1 Reinhard
Reinhard 映射是最经典的全局算子,一条公式:
性质:值越大压缩越狠, 时 (渐近收敛,永不截断);亮度为 1 的值映射到 0.5。缺点是整体偏灰,暗部被提亮,对比度下降。
3.2 曝光 + Reinhard
真实相机有曝光(exposure)参数。先乘曝光系数再映射,等价于调快门:
曝光小于 1 压低整体亮度(暗部细节浮现),大于 1 提亮。Reinhard 类算子对 RGB 三分量分别作用会导致色相偏移(高光泛白),更稳妥的做法是先转亮度(luminance)压缩、再按比例还原颜色,或直接用 ACES。
3.3 ACES
ACES(Academy Color Encoding System)是电影工业的色彩标准,其色调映射曲线经过大量胶片特性拟合:暗部保持对比度、高光有平滑的卷起、色彩饱和度的变化自然。相比 Reinhard,ACES 的高光衰减更柔和,色彩表现更好,是当前游戏引擎的主流默认(Unreal、Unity 均内置)。
3.4 算子对比
| 算子 | 公式(亮度域) | 特点 |
|---|---|---|
| 直接 clamp | 实现最简单,高光截断丢失细节 | |
| Reinhard | 永不截断,但整体偏灰 | |
| 曝光 + Reinhard | 曝光可调,暗部可控 | |
| ACES | 拟合曲线(分段) | 电影感,高光卷起自然,色彩保真 |
| 局部算子 | 按邻域自适应 | 保留局部对比度,实时开销大(较少用) |
4. 实现
4.1 后处理流程
色调映射作为后处理(post-processing)实现:渲染场景到浮点帧缓冲,再绘制一个全屏四边形,片段着色器采样 HDR 纹理并输出映射结果(帧缓冲 的渲染到纹理部分)。
// 色调映射 shader:HDR 纹理 -> LDR
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D hdrBuffer;
uniform float exposure;
// ACES 近似拟合(Narkowicz 2015 版,工业常用)
vec3 aces(vec3 x)
{
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((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
void main()
{
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
// 方式一:曝光 + Reinhard(逐分量,高光会轻微泛白)
vec3 reinhard = vec3(1.0) - exp(-hdrColor * exposure);
// 方式二:ACES 拟合(推荐)
vec3 mapped = aces(hdrColor * exposure);
FragColor = vec4(mapped, 1.0);
}
注:示例里用了 的 Reinhard 变体(电影行业常用形式),它的形状与 类似但暗部保留更好。映射输出在线性空间,写入 sRGB 帧缓冲时由硬件自动编码(伽马校正 的方案二);如果帧缓冲不是 sRGB,记得在 shader 里手动
pow(mapped, vec3(1.0 / 2.2))。
4.2 曝光控制
曝光值可以手动调,也可以根据画面自动计算:对 HDR 纹理做 mipmap 降采样,读取最顶层 mip 的平均亮度,用 逼近人眼的自动适应效果。这是"自动曝光"(auto-exposure)的基础,Bloom 的阈值选择也依赖 HDR 纹理中超过 1.0 的亮度信息。



