Deep Read

Bloom

原理

Bloom 做的事,是把画面里超过某个亮度的能量向周围像素扩散,让高光看起来更刺眼、像在发光。真实镜头里这是光学散射,实时渲染没法算,于是拿低分辨率模糊去近似:在缩小的图上做一次半径不大的模糊,等效于在全分辨率上套了一个很大的卷积核,采样次数却少得多。

整条管线就四步:

  1. 过滤亮部——只留下够亮的部分,其余压掉;
  2. 降采样——一路缩小成一座金字塔,每级顺带模糊;
  3. 升采样——从最小一级往回放,逐级把模糊的下层叠回清晰的上层,光晕就是这么摊开的;
  4. 合成——把最终的 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 少、移动端友好。

基于高斯核的模糊

高斯模糊原理

二维高斯核可以拆成两个一维高斯的外积:

G(x,y)=G(x)G(y)G(x,y)=G(x)\cdot G(y)

所以一次二维模糊,等于先沿横向做一维高斯、再把结果沿竖向做一维高斯。半径 rr 时,直接算二维核要 O(r2)O(r^2) 次采样,拆开后只要 O(2r)O(2r),这就是「可分离」省在哪。代价是拆成了两趟 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 纹理,交给下面的合成。 image.png

合成

两种模糊最后走同一个合成:把 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 却很少,瓶颈几乎永远在带宽。开销大致就是:

开销触碰的像素数×每像素字节数×pass 数\text{开销} \approx \text{触碰的像素数} \times \text{每像素字节数} \times \text{pass 数}

所有手段都是在压这三个因子之一。

减少像素数——降分辨率。 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-pong4 tap/轮,靠加大 offset 扩半径移动端传统方案
Dual Kawase降/升各 15 tap 降 + 8 tap 升,模糊与缩放合并移动端首选
Compute + LDS单 dispatch 可多级邻域共享,省显存带宽支持 CS、大半径