需要预先学习:


1. 从 LDR 到 HDR


1.1 真实场景的动态范围

传统渲染的帧缓冲用 8bit 无符号整数存储颜色,取值范围 [0,1][0, 1],称为 LDR(Low Dynamic Range,低动态范围)。但真实场景的亮度范围远不止如此:阳光直射下的亮度是阴影处的几万倍(动态范围约 10510^5)。把这样的场景塞进 [0,1][0, 1],结果只有两种:

  • 把曝光调到阴影可见,太阳就变成一片纯白;
  • 把曝光调到太阳可见,阴影就变成一片纯黑。

无论哪种,亮部或暗部的细节都被截断(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 的亮度范围压缩到 [0,1][0, 1]、同时尽量保留对比度与细节的映射。它与伽马校正的区别要分清:

概念 解决什么 本质
伽马校正 显示器非线性 颜色空间编码(sRGB 与线性的转换)
色调映射 动态范围过大 亮度压缩(HDR 到 LDR 的映射)

两者的关系是一条流水线:HDR 场景 → 色调映射 → 伽马编码 → 屏幕。色调映射的输出仍然在线性空间,之后才做伽马编码(见 伽马校正 的"正确流程")。


2.2 直接 clamp 为什么不行

最简单的"映射"是 min(color, 1.0),但效果很差:

  • 高光被截断成纯白,亮部内部的明暗层次全部丢失;
  • 截断处形成生硬的色彩突变,观感"过曝"。

色调映射的目标是平滑压缩:高光逐渐"卷起"(roll-off)而不是一刀切,同时保住暗部的层次。


3. 常见色调映射算子


3.1 Reinhard

Reinhard 映射是最经典的全局算子,一条公式:

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

性质:值越大压缩越狠,Lin→∞L_{in} \to \infty 时 Lout→1L_{out} \to 1(渐近收敛,永不截断);亮度为 1 的值映射到 0.5。缺点是整体偏灰,暗部被提亮,对比度下降。


3.2 曝光 + Reinhard

真实相机有曝光(exposure)参数。先乘曝光系数再映射,等价于调快门:

Lout=exposure⋅Lin1+exposure⋅LinL_{out} = \frac{\text{exposure} \cdot L_{in}}{1 + \text{exposure} \cdot L_{in}}

曝光小于 1 压低整体亮度(暗部细节浮现),大于 1 提亮。Reinhard 类算子对 RGB 三分量分别作用会导致色相偏移(高光泛白),更稳妥的做法是先转亮度(luminance)压缩、再按比例还原颜色,或直接用 ACES。


3.3 ACES

ACES(Academy Color Encoding System)是电影工业的色彩标准,其色调映射曲线经过大量胶片特性拟合:暗部保持对比度、高光有平滑的卷起、色彩饱和度的变化自然。相比 Reinhard,ACES 的高光衰减更柔和,色彩表现更好,是当前游戏引擎的主流默认(Unreal、Unity 均内置)。


3.4 算子对比

算子 公式(亮度域) 特点
直接 clamp min⁡(L,1)\min(L, 1) 实现最简单,高光截断丢失细节
Reinhard L1+L\frac{L}{1 + L} 永不截断,但整体偏灰
曝光 + Reinhard eL1+eL\frac{eL}{1 + eL} 曝光可调,暗部可控
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);
}

注:示例里用了 1−e−x1 - e^{-x} 的 Reinhard 变体(电影行业常用形式),它的形状与 x1+x\frac{x}{1+x} 类似但暗部保留更好。映射输出在线性空间,写入 sRGB 帧缓冲时由硬件自动编码(伽马校正 的方案二);如果帧缓冲不是 sRGB,记得在 shader 里手动 pow(mapped, vec3(1.0 / 2.2))。


4.2 曝光控制

曝光值可以手动调,也可以根据画面自动计算:对 HDR 纹理做 mipmap 降采样,读取最顶层 mip 的平均亮度,用 exposure=1avgLuminance\text{exposure} = \frac{1}{\text{avgLuminance}} 逼近人眼的自动适应效果。这是"自动曝光"(auto-exposure)的基础,Bloom 的阈值选择也依赖 HDR 纹理中超过 1.0 的亮度信息。