Bloom
目录+
原理
Bloom 做的事,是把画面里超过某个亮度的能量向周围像素扩散,让高光看起来更刺眼、像在发光。真实镜头里这是光学散射,实时渲染没法算,于是拿低分辨率模糊去近似:在缩小的图上做一次半径不大的模糊,等效于在全分辨率上套了一个很大的卷积核,采样次数却少得多。
整条管线就四步:
- 过滤亮部——只留下够亮的部分,其余压掉;
- 降采样——一路缩小成一座金字塔,每级顺带模糊;
- 升采样——从最小一级往回放,逐级把模糊的下层叠回清晰的上层,光晕就是这么摊开的;
- 合成——把最终的 bloom 纹理按强度加回原场景。
实现
过滤亮部

先用 clampMax 把个别爆亮的单点像素压下去——不然它在低分辨率下会闪(firefly)。再用一条 threshold + knee 的二次软阈值曲线做提亮,而不是硬切,避免高光边缘出现明暗断层
color = min(clampMax, sample(src, uv)) // 压 firefly
b = max(color.r, color.g, color.b) // 亮度
soft = clamp(b - threshold + knee, 0, 2*knee)
soft = soft*soft / (4*knee + eps) // 阈值附近平滑过渡
mul = max(b - threshold, soft) / max(b, eps)
return color * mul // 亮的留下、暗的趋 0
逐级降采样
模糊之前先把提亮的结果缩成一座金字塔。起点按 downSampler(half/quarter/…)先缩一次,之后每往下一级宽高再各减半;级数由屏幕尺寸 log2 推出、被 bloomIterations 钳住。之所以要缩,是因为在小图上做半径不大的模糊,等效于在大图上套一个很大的核——越往下每级像素只有上一级的 1/4,便宜得很。
每一级都备两张同尺寸的 RT:MipDown 接降采样链的结果,MipUp 留给之后的升采样。全用 Bilinear + Clamp,为的是后面靠双线性一次采到 2×2、且边缘不越界:
for (int i = 0; i < m_MipCount; i++)
{
RenderingUtils.ReAllocateIfNeeded(ref m_BloomMipUp[i], desc, FilterMode.Bilinear, TextureWrapMode.Clamp,
name: "_BloomMipUp" + i);
RenderingUtils.ReAllocateIfNeeded(ref m_BloomMipDown[i], desc, FilterMode.Bilinear, TextureWrapMode.Clamp,
name: "_BloomMipDown" + i);
desc.width = Mathf.Max(1, desc.width >> 1);
desc.height = Mathf.Max(1, desc.height >> 1);
}
模糊操作
模糊核有两种常见做法,我这份实现都写了:一种是可分离高斯(横竖各一趟),一种是Dual Kawase(降/升各一趟,模糊藏在缩放里)。前者质量稳,后者 pass 少、移动端友好。
基于高斯核的模糊
高斯模糊原理
二维高斯核可以拆成两个一维高斯的外积:
所以一次二维模糊,等于先沿横向做一维高斯、再把结果沿竖向做一维高斯。半径 时,直接算二维核要 次采样,拆开后只要 ,这就是「可分离」省在哪。代价是拆成了两趟 blit(横一趟、竖一趟)。
一维权重就是离散高斯,越靠中心权重越大、两侧对称衰减,全部加起来为 1(能量守恒,模糊前后总亮度不变)。这份实现横向用 9 tap、竖向用 bilinear 优化过的 5 tap(把采样点卡在像素之间,一次采样等效两个像素,5 次采到 9 tap 的效果)。
应用
横向和竖向各占一个 pass。窍门在于横向那趟的输出目标是更小一级的 RT——降采样顺手就在这趟里做了;竖向那趟在同尺寸上收尾。所以「降一级 + 一次完整二维模糊」加起来只花两趟:
var lastDown = m_BloomMipDown[0];
for (int i = 1; i < m_MipCount; i++)
{
Blitter.BlitCameraTexture(cmd, lastDown, m_BloomMipUp[i], material, kBlurH); // 横向 → MipUp[i](临时)
Blitter.BlitCameraTexture(cmd, m_BloomMipUp[i], m_BloomMipDown[i], material, kBlurV); // 纵向 → MipDown[i]
lastDown = m_BloomMipDown[i];
}
上采样
降采样得到的是一座越往下越模糊的金字塔,光晕要靠升采样一路叠回来:从最小一级往上走,每级把「更模糊的下层」升采样后和「较清晰的本级」做一次 lerp,权重就是 Scatter。Scatter 越大,低频光晕占比越高、铺得越开越柔。
for (int i = m_MipCount - 2; i >= 0; i--)
{
// 第一次的下层来自降采样链末端,之后来自上一轮升采样结果
var lowMip = (i == m_MipCount - 2) ? m_BloomMipDown[i + 1] : m_BloomMipUp[i + 1];
cmd.SetGlobalTexture(s_SourceTexLowMip_ID, lowMip);
Blitter.BlitCameraTexture(cmd, m_BloomMipDown[i], m_BloomMipUp[i], material, kUpsample);
}
shader 里就是一句 lerp,_SourceTexLowMip 采样时会自动 bilinear 放大到本级尺寸:
highMip = sample(_BlitTexture, uv) // 本级,较清晰
lowMip = sample(_SourceTexLowMip, uv) // 下层,模糊,自动升采样
return lerp(highMip, lowMip, Scatter)
一路升到顶,MipUp[0] 就是最终的 bloom 纹理。
Dual Kawase 模糊
Dual Kawase 原理
高斯每级要横竖两趟,Dual Kawase 把模糊直接融进缩放里——降采样一趟、升采样一趟,靠特制的采样核在改变分辨率的同时完成模糊。相同的光晕半径下,pass 数和采样数都比逐级高斯少,是移动端主流。
- 降采样核 5 抽样:中心权重 4,加上下左右四个对角半像素,除以 8;
- 升采样核 8 抽样:一圈帐篷(tent)权重,正交点权 1、对角点权 2,除以 12。
半像素偏移(halfpixel = texelSize * 0.5)同样是在借双线性凑邻域平均。
Dual Kawase 应用
不再需要每级内部的横竖两趟,降和升各是一条单向链:
// 降采样链:逐级 5-tap 缩小
for (int i = 1; i < m_MipCount; i++)
Blitter.BlitCameraTexture(cmd, m_BloomMipDown[i - 1], m_BloomMipDown[i], material, kDown);
// 升采样链:从最小级 8-tap 逐级放大
var last = m_BloomMipDown[m_MipCount - 1];
for (int i = m_MipCount - 2; i >= 0; i--)
{
Blitter.BlitCameraTexture(cmd, last, m_BloomMipUp[i], material, kUp);
last = m_BloomMipUp[i];
}
对应的两个核:
// FragDown:中心 ×4 + 四对角半像素,/ 8
sum = sample(uv) * 4
sum += sample(uv ± halfpixel) + sample(uv ± (halfpixel.x, -halfpixel.y))
return sum / 8
// FragUp:八方向帐篷权重,正交 1、对角 2,/ 12
sum = Σ sample(uv + 各方向偏移) * w
return sum / 12
升到顶后 MipUp[0] 同样就是 bloom 纹理,交给下面的合成。

合成
两种模糊最后走同一个合成:把 bloom 纹理按 intensity 加回原场景。因为源和目标是同一张相机颜色纹理,不能自读自写,所以先写到一张全分辨率中转 RT,再拷回去:
cmd.SetGlobalFloat(s_BloomIntensity_ID, setting.intensity);
cmd.SetGlobalTexture(s_SourceTexLowMip_ID, bloomResult);
Blitter.BlitCameraTexture(cmd, source, m_CompositeRT, material, kComposite); // scene + bloom*intensity
Blitter.BlitCameraTexture(cmd, m_CompositeRT, source); // 拷回相机颜色
scene = sample(_BlitTexture, uv)
bloom = sample(_SourceTexLowMip, uv)
return scene + bloom * _BloomIntensity
HDR 编解码
每趟采样前 DecodeHDR、输出前 EncodeHDR,主要是伺候 Gamma 色彩空间(线性 ↔ γ 的一次开方/平方)。线性空间下基本是恒等,读代码时可以先忽略,抓模糊逻辑本身。
Bloom 要在 tonemapping 之前、HDR 范围里做完。否则溢出的高光被 LDR 提前截断,光晕会发闷,高对比边缘还容易出脏边。
优化
每个 pass 都是全屏读写、ALU 却很少,瓶颈几乎永远在带宽。开销大致就是:
所有手段都是在压这三个因子之一。
减少像素数——降分辨率。 Bloom 本就是低频光晕,从 1/2、1/4 起步肉眼看不出差别,像素数直接掉到 1/4、1/16。金字塔越往下每级只有上一级的 1/4,越深越便宜;要砍就砍靠近全分辨率的那几级才省得多。代码里 downSampler + bloomIterations 就是这两个旋钮。
减少字节数——压 RT 格式。 Bloom 只要 HDR 颜色、不要 alpha,用 R11G11B10_UFloat(4 字节)代替 ARGBHalf(8 字节),带宽直接减半,精度对模糊结果完全够。
减少 pass 数——合并。
- Prefilter 合进第一次降采样(这份代码已经这么做);
- 用 Dual Kawase 代替逐级高斯:降/升各一趟就把模糊和缩放合并了,相同半径下 pass 和采样都更少,移动端主流;
- 极致做法上 Compute ShaderCompute ShaderCompute Shader 让我们能直接把通用计算任务交给 GPU 并行处理,而不局限于传统的顶点/片元着色。要写好它,先得理解 GPU 是如何把成千上万个线程分配到硬件上执行的。 GPU 的并行执行层级 GPU 的并行能力来自一套分级的线程调度体系。从最底层的执行单元到开发者组织的线程集合,NVIDIA 和 AMD 有各自的术语,但结构基本对应。 执行单元:SP / CUDA Core SP(Streaming Processor)是 GPU 最基本的执行单元,在 NVIDIA 中也叫 CUDA Core,相当于 CPU 里的算术/逻辑单元。所有算术、逻辑、数据搬运指令最终都在 SP 上执行,每个 SP 通常一个时钟周期处理一个线程的一条指令。 GPU 之所以能高并发,正是因为它拥有成百上千个 SP 同时工作——市场上宣传的"几千核心"指的就是 SP(CUDA Core)的数量。AMD 架构中对应的执行单元通常称为 SIMD 单元,实现细节略有不同。 调度单元:SM / Compute Unit SM(Streaming Multiprocessor)是比 SP 更高,用 groupshared(LDS)缓存邻域,一个 dispatch 里做完多级降采样(如 AMD SPD)。
借双线性白嫖采样。 硬件双线性是免费的,把采样点放到 4 个像素正中,一次采样拿到 2×2 的平均。上面高斯核的 *2 步长、Kawase 核的 halfpixel 偏移都是在干这件事。
移动端(TBDR)额外注意。 每换一次 RenderTarget 就是一轮 tile 与主存的回写/加载,切 RT 比桌面端更贵。所以移动端优先选 pass 最少的 Dual Kawase,ping-pong 的 RT 尺寸/格式保持一致避免反复分配,用不到的 RT 标 DontCare 省掉 store/load。
质量侧:Karis average。 个别极亮像素在低分辨率下会闪。降采样时按亮度倒数加权平均能压住单点极亮值,既消闪烁也让 bloom 更稳。这份实现是在 Prefilter 用 clampMax 直接钳,思路一致但更简单。
各方案对比
| 方案 | 每级 pass | 采样 | 适用 |
|---|---|---|---|
| 可分离高斯 | 2(横+竖) | 质量基准,采样随半径线性增长 | 桌面、要精确核形 |
| Kawase 迭代 | 每轮 1,需多轮 ping-pong | 4 tap/轮,靠加大 offset 扩半径 | 移动端传统方案 |
| Dual Kawase | 降/升各 1 | 5 tap 降 + 8 tap 升,模糊与缩放合并 | 移动端首选 |
| Compute + LDS | 单 dispatch 可多级 | 邻域共享,省显存带宽 | 支持 CS、大半径 |