第18章 HDR与色调映射:高动态范围渲染

说到HDR,我得先坦白一件事。我刚开始做渲染的时候,觉得这东西就是个「让画面亮一点」的滤镜。直到有一次做夜景项目,路灯的光晕怎么调都不对——要么亮部过曝成一片白,要么暗部黑成一团。折腾了两周,我才真正意识到:没有HDR,你根本没法模拟真实世界的光照。

说白了,HDR就是让渲染结果不再被限制在0到1这个区间里。真实世界中,太阳的亮度可能是100000 cd/m²,而室内阴影可能只有0.1 cd/m²。你想想看,用0到1去表示这个范围,怎么可能?

18.1 为什么需要HDR?

传统的LDR渲染,颜色值被钳制在[0, 1]之间。超过1的直接截断,低于0的也截断。这会导致什么问题?

  • 高光细节丢失:强光源周围变成一片纯白
  • 无法做后期特效:Bloom泛光需要超过1的亮度值才能产生光晕
  • 物理不准确:光照计算在非线性空间中进行,结果偏差很大

我在项目中遇到过最典型的例子:一个金属材质的反射高光,在LDR下看起来就是一块白斑。切换到HDR后,高光中心亮度达到3.5,边缘逐渐衰减到0.8,这才有了真实的金属质感。

核心概念:HDR渲染管线中,帧缓冲使用浮点纹理(如GL_RGBA16F或GL_RGBA32F),颜色值可以远大于1.0。最终显示到屏幕前,再通过色调映射(Tone Mapping)将HDR值压缩到LDR范围。

18.2 HDR帧缓冲的搭建

嗯,这里要注意。HDR的第一步,就是让你的渲染目标能存下浮点数。代码其实不复杂:

// 创建HDR帧缓冲
GLuint hdrFBO;
glGenFramebuffers(1, &hdrFBO);
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);

// 创建HDR颜色纹理
GLuint colorBuffer;
glGenTextures(1, &colorBuffer);
glBindTexture(GL_TEXTURE_2D, colorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, width, height, 0, GL_RGBA, GL_FLOAT, NULL);
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);

// 创建深度缓冲
GLuint depthBuffer;
glGenRenderbuffers(1, &depthBuffer);
glBindRenderbuffer(GL_RENDERBUFFER, depthBuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, width, height);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthBuffer);

// 检查完整性
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
    // 处理错误

glBindFramebuffer(GL_FRAMEBUFFER, 0);

我个人习惯用GL_RGBA16F,精度够用,带宽开销也合理。GL_RGBA32F当然更好,但移动端性能会吃紧。

18.3 色调映射:把大象塞进冰箱

色调映射,说白了就是把HDR的宽动态范围压缩到LDR显示器的窄范围。这个过程不是简单的线性缩放——你想想看,人眼对暗部变化敏感,对亮部变化迟钝。所以色调映射函数通常是非线性的。

18.3.1 Reinhard色调映射

Reinhard是最经典的算法,简单高效。它的核心思想是:亮度越高,压缩越狠。

// Reinhard色调映射 - 片段着色器
#version 300 es
precision mediump float;

in vec2 vTexCoord;
uniform sampler2D uHDRTexture;

out vec4 fragColor;

vec3 ReinhardToneMapping(vec3 color) {
    return color / (color + vec3(1.0));
}

void main() {
    vec3 hdrColor = texture(uHDRTexture, vTexCoord).rgb;
    vec3 mapped = ReinhardToneMapping(hdrColor);
    // 伽马校正
    mapped = pow(mapped, vec3(1.0 / 2.2));
    fragColor = vec4(mapped, 1.0);
}

这个公式很简单:color / (color + 1)。当color很小时,结果≈color;当color很大时,结果趋近于1。我曾经在一个室内场景中测试过,Reinhard对中等亮度的场景效果很好,但遇到极亮的光源(比如太阳直射),画面会显得有点「灰」。

小技巧:Reinhard可以加一个曝光参数。把公式改成 color / (color + exposure),通过调整exposure来控制整体亮度。我一般把exposure初始值设为1.0,然后让美术同学在0.5到2.0之间微调。

18.3.2 ACES色调映射

ACES(Academy Color Encoding System)是好莱坞搞出来的标准。它比Reinhard复杂,但效果更好——色彩更饱和,对比度更自然,高光过渡更平滑。

// ACES色调映射(近似版本)
vec3 ACESFilm(vec3 x) {
    float a = 2.51;
    float b = 0.03;
    float c = 2.43;
    float d = 0.59;
    float e = 0.14;
    return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}

void main() {
    vec3 hdrColor = texture(uHDRTexture, vTexCoord).rgb;
    vec3 mapped = ACESFilm(hdrColor);
    mapped = pow(mapped, vec3(1.0 / 2.2));
    fragColor = vec4(mapped, 1.0);
}

为什么ACES更好?你看它的曲线:暗部抬升不多,保持对比度;中间调线性,色彩自然;高光部分平滑滚降,不会突然截断。我在做汽车渲染项目时,ACES对车漆高光的处理明显优于Reinhard——那种金属漆的「珍珠感」终于出来了。

算法 优点 缺点 适用场景
Reinhard 简单、性能好 高光偏灰、色彩略淡 移动端、性能敏感场景
ACES 色彩好、对比自然 计算稍重 PC端、高质量渲染
Filmic 类似ACES、更轻量 精度略低 游戏引擎常用

18.4 Bloom泛光效果

Bloom,说白了就是让亮的地方「发光」。你想想看,真实世界中看路灯,眼睛会看到一圈光晕——这是因为光线在眼球内部散射。Bloom就是模拟这个效果。

Bloom的原理其实就三步:

  1. 提取亮部:从HDR图像中提取亮度超过阈值的部分
  2. 模糊:对亮部图像做高斯模糊(通常多次迭代)
  3. 叠加:将模糊后的亮部叠加回原图

关键点:Bloom必须在HDR空间中进行。如果先做了色调映射再提取亮部,超过1.0的信息已经被压缩了,提取出来的亮部会不准确。

18.4.1 提取亮部

// 提取亮部 - 片段着色器
#version 300 es
precision mediump float;

in vec2 vTexCoord;
uniform sampler2D uHDRTexture;
uniform float uThreshold;  // 阈值,通常1.0左右

out vec4 fragColor;

void main() {
    vec3 color = texture(uHDRTexture, vTexCoord).rgb;
    float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));
    float amount = max(luminance - uThreshold, 0.0);
    vec3 bright = color * amount / max(luminance, 0.001);
    fragColor = vec4(bright, 1.0);
}

这里有个细节:不是简单地判断亮度>阈值就保留。我用的是「软阈值」——亮度超过阈值越多,保留的比例越大。这样过渡更自然,不会出现硬边。

18.4.2 高斯模糊

模糊这一步,我建议用两次一维高斯模糊(先水平再垂直),而不是一次二维模糊。性能能差出好几倍。

// 水平高斯模糊 - 片段着色器
#version 300 es
precision mediump float;

in vec2 vTexCoord;
uniform sampler2D uTexture;
uniform float uBlurSize;  // 模糊半径

out vec4 fragColor;

void main() {
    vec2 texelSize = 1.0 / vec2(textureSize(uTexture, 0));
    vec3 result = vec3(0.0);
    float totalWeight = 0.0;
    
    // 5x1高斯核(可扩展到更大)
    for (int i = -2; i <= 2; i++) {
        float weight = exp(-float(i * i) / (2.0 * uBlurSize * uBlurSize));
        vec2 offset = vec2(float(i) * texelSize.x, 0.0);
        result += texture(uTexture, vTexCoord + offset).rgb * weight;
        totalWeight += weight;
    }
    
    fragColor = vec4(result / totalWeight, 1.0);
}

垂直方向同理,只是偏移方向改成y轴。我一般做4到6次迭代(每次缩小纹理尺寸),这样模糊范围大,性能也好。

18.4.3 叠加回原图

// 最终合成 - 片段着色器
#version 300 es
precision mediump float;

in vec2 vTexCoord;
uniform sampler2D uHDRTexture;
uniform sampler2D uBloomTexture;
uniform float uBloomIntensity;  // 泛光强度,通常0.5-1.0

out vec4 fragColor;

void main() {
    vec3 hdrColor = texture(uHDRTexture, vTexCoord).rgb;
    vec3 bloomColor = texture(uBloomTexture, vTexCoord).rgb;
    vec3 finalColor = hdrColor + bloomColor * uBloomIntensity;
    // 最后再做色调映射
    finalColor = ACESFilm(finalColor);
    finalColor = pow(finalColor, vec3(1.0 / 2.2));
    fragColor = vec4(finalColor, 1.0);
}

避坑指南:我曾经犯过一个错误——先做色调映射再做Bloom。结果Bloom出来的光晕是灰白色的,因为高光信息已经被压缩了。记住:Bloom必须在HDR空间做,色调映射是最后一步。

18.5 完整管线流程

下面这张图展示了HDR渲染的完整流程。我把它画出来,方便你理解各个阶段的数据流向。

HDR渲染管线流程图 场景渲染 HDR帧缓冲 (RGBA16F) 提取亮部 亮度 > 阈值 软阈值提取 高斯模糊 水平+垂直 多次迭代 HDR合成 原图 + Bloom 乘以强度系数 色调映射 ACES / Reinhard 伽马校正 屏幕输出 LDR (0-1) 原图数据 (HDR)

流程其实不复杂:场景渲染到HDR帧缓冲 → 提取亮部 → 模糊 → 合成 → 色调映射 → 输出。每一步都有它的道理,少一步效果就出不来。

18.6 性能优化建议

移动端做HDR+Bloom,性能是个大问题。我踩过不少坑,给你几个建议:

  • 纹理精度:移动端用GL_RGBA16F就够了,别用32F。带宽差一倍。
  • 模糊次数:4次迭代通常够用。每次缩小一半纹理尺寸,最后一张可能只有原图的1/16。
  • 阈值调整:阈值设得太低,整个画面都在发光;设得太高,只有最亮的地方有光晕。我一般从1.0开始调。
  • 合并pass:如果可能,把色调映射和伽马校正合并到一个shader里,减少一次纹理采样。

我的经验:在华为P40上测试,全分辨率HDR+Bloom(4次模糊)大约耗时3ms。如果降到一半分辨率,可以控制在1.5ms以内。对于60fps的目标,这个开销是可以接受的。

好了,HDR和Bloom的内容就到这里。记住一句话:HDR是基础,Bloom是锦上添花。先把HDR管线搭稳了,再考虑特效。否则你连正确的颜色都拿不到,谈何泛光?


公众号:蓝海资料掘金营,微信deep3321