Bloom
需要预先学习:
1. Bloom 原理
Bloom(辉光)是 HDR 渲染的经典后处理效果:场景中的亮部向周围"渗光",形成柔和的光晕。看夜空中的路灯、显示器上的高光,人眼与镜头都会看到这种光晕——光线在眼球晶状体/镜头内部散射,把亮部的能量摊到了周围。
实现的思路很直接,三步:
- 提取亮部:从 HDR 画面中取出超过亮度阈值的部分;
- 模糊:把亮部图模糊成光晕;
- 合成:模糊结果叠加回原图。
1.1 为什么必须用 HDR
亮度阈值要区分"亮"与"不亮",只能在 HDR 空间做。LDR 画面里所有像素都被截断在 1.0,无法区分"亮度 1.0 的灰"和"亮度 100 的太阳"——两者提取出来一模一样。HDR 帧缓冲(色调映射)保留了超过 1.0 的亮度信息,阈值才有意义。
2. 实现步骤
2.1 1. 渲染 HDR 场景
场景渲染进浮点帧缓冲(色调映射 中的 hdrFBO),光源亮度可以超过 1.0,例如点光源给 vec3(10.0) 的强度。这是整个 Bloom 流程的输入。
2.2 2. 提取亮部(Bright Pass)
片段着色器采样 HDR 纹理,亮度超过阈值的部分原样输出,其余输出黑色:
// 亮部提取 shader
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D hdrBuffer;
uniform float threshold; // 亮度阈值,通常 1.0 附近
// 亮度:按人眼感知加权的亮度公式(Rec. 709 系数)
float luminance(vec3 color)
{
return dot(color, vec3(0.2126, 0.7152, 0.0722));
}
void main()
{
vec3 color = texture(hdrBuffer, TexCoords).rgb;
float brightness = luminance(color);
// 超过阈值的部分保留,且随亮度平滑过渡(乘系数避免硬切边)
float contribution = max(brightness - threshold, 0.0) / max(brightness, 0.0001);
FragColor = vec4(color * contribution, 1.0);
}
2.3 3. 高斯模糊(Ping-Pong 帧缓冲)
对亮部图做高斯模糊(Gaussian blur)。二维高斯核可以拆成两个一维高斯核:先横向模糊一遍,再纵向模糊一遍,效果等价但采样数从 降到 。两遍之间用两个帧缓冲来回切换(ping-pong):
// 一维高斯模糊 shader(横向/纵向由采样偏移方向控制)
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D image;
uniform bool horizontal; // true 横向模糊,false 纵向
// 9 tap 高斯权重(sigma 较大时数值)
const float weight[5] = float[](
0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216
);
void main()
{
vec2 texelSize = 1.0 / textureSize(image, 0);
vec2 offset = horizontal ? vec2(texelSize.x, 0.0)
: vec2(0.0, texelSize.y);
vec3 result = texture(image, TexCoords).rgb * weight[0];
for (int i = 1; i < 5; ++i)
{
result += texture(image, TexCoords + offset * i).rgb * weight[i];
result += texture(image, TexCoords - offset * i).rgb * weight[i];
}
FragColor = vec4(result, 1.0);
}
// ping-pong:在两个帧缓冲之间交替模糊
bool horizontal = true, firstIteration = true;
for (unsigned int i = 0; i < 10; ++i) // 迭代次数越多光晕越扩散
{
glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[horizontal]);
glUniform1i(glGetUniformLocation(blurShader, "horizontal"), horizontal);
// 第一次采样亮部图,之后采样上一次模糊的结果
glBindTexture(GL_TEXTURE_2D, firstIteration ? brightTexture
: pingpongTexture[!horizontal]);
renderQuad();
horizontal = !horizontal;
firstIteration = false;
}
2.4 4. 合成
最后把模糊后的亮部叠加回 HDR 原图,再做色调映射(色调映射):
// 合成 shader:原图 + Bloom 光晕
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D scene; // HDR 原图
uniform sampler2D bloomBlur; // 模糊后的亮部
uniform float bloomStrength; // 光晕强度
void main()
{
vec3 color = texture(scene, TexCoords).rgb;
vec3 bloom = texture(bloomBlur, TexCoords).rgb;
color += bloom * bloomStrength; // 线性空间直接相加
FragColor = vec4(color, 1.0);
}
注:合成必须在线性空间做加法(伽马校正),所以流程是"合成 → 色调映射 → 伽马编码"。如果先色调映射再叠加,HDR 信息已经丢失,光晕会失真。
3. 优化
3.1 降采样模糊
高斯模糊的分辨率越高越贵。Bloom 光晕是低频信息(大面积的柔和渐变),完全可以在低分辨率下计算:先把亮部图降到 1/2 或 1/4 分辨率再模糊,光晕更"散"且开销大减,最后升采样回原分辨率合成。降采样本身也相当于一次模糊,迭代次数可以相应减少。
3.2 mipmap 金字塔模糊
UE 的做法:把亮部图逐级降采样生成 mipmap 链,再从低 mip 逐级升采样叠加回上一级。一次生成 mipmap + 一次升采样循环,就得到平滑的大范围光晕,每级只需一次全屏绘制,比多次 ping-pong 更省。
3.3 自适应阈值
固定阈值的问题:画面整体偏暗时亮部提不出来,偏亮时又全屏光晕。自适应 Bloom 根据画面平均亮度动态调整阈值(平均亮度来自 HDR 纹理的 mipmap 顶层,见 色调映射 的曝光控制),让效果在不同场景下稳定。
| 优化 | 原理 | 收益 |
|---|---|---|
| 降采样模糊 | 低频信息低分辨率算 | 模糊开销降到 1/4 以下 |
| mipmap 金字塔 | 降采样即模糊,升采样合并 | 少一半绘制次数 |
| 自适应阈值 | 阈值跟随平均亮度 | 效果稳定,不依赖场景调参 |
| 分离核 | 二维高斯拆两个一维 | 采样数从 降到 |
Bloom 是 色调映射 流水线的自然延伸:HDR 提供了超过 1.0 的亮度信息,Bloom 把它提取出来变成光晕,色调映射再把压缩后的画面交给 伽马校正 输出。三者合起来就是现代渲染器"从物理亮度到屏幕像素"的完整链路。



