第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的原理其实就三步:
- 提取亮部:从HDR图像中提取亮度超过阈值的部分
- 模糊:对亮部图像做高斯模糊(通常多次迭代)
- 叠加:将模糊后的亮部叠加回原图
关键点: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帧缓冲 → 提取亮部 → 模糊 → 合成 → 色调映射 → 输出。每一步都有它的道理,少一步效果就出不来。
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