案例 26.4自制 Bloom:预过滤、下采样金字塔、上采样合成高阶
目标:从零实现一个与 URP 内置 Bloom 思路一致的泛光:只让足够亮的像素发光,光晕大而柔和、不闪烁,参数可由 Volume 控制。
原理:Bloom 模拟的是镜头和人眼里的光散射——极亮的光源周围出现一圈逐渐衰减的光晕。实现分四步:
- 预过滤(Prefilter):从相机颜色中提取亮部,同时降到 1/2 分辨率。
- 下采样金字塔:反复减半,得到 1/4、1/8 …… 的一串越来越模糊的图。
- 上采样合并:从最小的一级开始往回放大,每级把“放大后的低一级”与“本级”按 scatter 混合。不同尺度的模糊叠在一起,形成中心亮、拖尾长的光晕。
- 合成:在线性 HDR 空间里把泛光乘以强度和颜色,加回原图。
软阈值(Soft Knee)
最简单的阈值是 (b 为像素亮度,取 RGB 最大值),但它在 b = T 处有一个折角:亮度在阈值附近轻微变化(比如物体移动、高光闪烁)时,泛光会突然出现或消失。软阈值在 [T − K, T + K] 区间内用一段二次曲线把 0 平滑地接到直线上:
在 b = T + K 处,二次曲线 s 的值与斜率都恰好等于直线 b − T 的值与斜率(都等于 K 与 1),所以拼接处是光滑的。URP 内置 Bloom 用的是同一个公式(参数里的 Threshold Knee)。我们的 Volume 用“相对阈值的比例”来表示膝宽:K = T × softKnee。
half4 FragPrefilter(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; float2 t = _BlitTexture_TexelSize.xy; // 4 次双线性 = 4×4 纹素的盒式平均,顺便完成 2 倍降采样 half3 c = (Tap(uv + t * float2(-1, -1)) + Tap(uv + t * float2(1, -1)) + Tap(uv + t * float2(-1, 1)) + Tap(uv + t * float2(1, 1))) * 0.25; c = min(c, 65000.0); // 防止 Inf 一路扩散 // 软阈值(二次曲线过渡的“膝”):亮度 b 在 [T-K, T+K] 之间平滑过渡 half threshold = _BloomParams.x, knee = _BloomParams.y; half b = Max3(c.r, c.g, c.b); half soft = clamp(b - threshold + knee, 0.0, 2.0 * knee); soft = soft * soft / (4.0 * knee + 1e-4); half contribution = max(b - threshold, soft) / max(b, 1e-4); return half4(c * contribution, 1.0); }
vec4 FragPrefilter() { vec2 uv = texcoord; vec2 t = _BlitTexture_TexelSize.xy; // 4 次双线性 = 4×4 纹素的盒式平均,顺便完成 2 倍降采样 vec3 c = (Tap(uv + t * vec2(-1, -1)) + Tap(uv + t * vec2(1, -1)) + Tap(uv + t * vec2(-1, 1)) + Tap(uv + t * vec2(1, 1))) * 0.25; c = min(c, 65000.0); // 防止 Inf 一路扩散 // 软阈值(二次曲线过渡的“膝”):亮度 b 在 [T-K, T+K] 之间平滑过渡 float threshold = _BloomParams.x, knee = _BloomParams.y; float b = Max3(c.r, c.g, c.b); float soft = clamp(b - threshold + knee, 0.0, 2.0 * knee); soft = soft * soft / (4.0 * knee + 1e-4); float contribution = max(b - threshold, soft) / max(b, 1e-4); return vec4(c * contribution, 1.0); }
为什么 Bloom 必须放在色调映射之前、在 HDR 颜色上做?泛光模拟的是光的散射,散出去的能量与光源的真实亮度成正比。一盏亮度为 20 的灯和一块亮度为 1.2 的白墙,在色调映射之后都会接近显示器的白色,但前者应该产生大得多的光晕。如果在 LDR(色调映射之后)做 Bloom,这两者已经无法区分,只能对“接近白色的一切”一视同仁地发光,画面会显得发灰、发糊。所以本章的 Bloom 使用 BeforeRenderingPostProcessing 事件,并且金字塔的 RT 沿用相机的 HDR 格式。
预过滤先用 4 次双线性采样做 4×4 盒式平均(同时完成降采样),再 min(c, 65000):半精度浮点的上限约为 65504,场景里偶尔出现的 Inf/NaN(比如除零的高光)如果不在第一步截断,会随着金字塔扩散成一大块黑色或白色。
抗闪烁的 13 次采样下采样
简单的 2×2 平均在降采样时会“跳过”一些像素。一个只有一两个像素大小的极亮高光,随着相机微动,可能这一帧落在采样点上、下一帧没落上,于是泛光一闪一闪。Jimenez 在 2014 年 SIGGRAPH 的《Next Generation Post Processing in Call of Duty: Advanced Warfare》中给出的 13 次采样下采样,用 5 个互相重叠的 2×2 方块加权,覆盖了 6×6 的源纹素,明显减轻了这种闪烁:
// 13 次采样下采样(Jimenez 2014, Next Generation Post Processing in Call of Duty): // 中心 4 点权重 0.5,四个角上的 2×2 小方块各 0.125,能明显抑制高亮像素的闪烁 half4 FragDown(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; float2 t = _BlitTexture_TexelSize.xy; half3 A = Tap(uv + t * float2(-2, -2)), B = Tap(uv + t * float2(0, -2)), C = Tap(uv + t * float2(2, -2)); half3 D = Tap(uv + t * float2(-1, -1)), E = Tap(uv + t * float2(1, -1)); half3 F = Tap(uv + t * float2(-2, 0)), G = Tap(uv), H = Tap(uv + t * float2(2, 0)); half3 I = Tap(uv + t * float2(-1, 1)), J = Tap(uv + t * float2(1, 1)); half3 K = Tap(uv + t * float2(-2, 2)), L = Tap(uv + t * float2(0, 2)), M = Tap(uv + t * float2(2, 2)); half3 c = (D + E + I + J) * (0.5 / 4.0); c += (A + B + G + F) * (0.125 / 4.0); c += (B + C + H + G) * (0.125 / 4.0); c += (F + G + L + K) * (0.125 / 4.0); c += (G + H + M + L) * (0.125 / 4.0); return half4(c, 1.0); }
// 13 次采样下采样(Jimenez 2014, Next Generation Post Processing in Call of Duty): // 中心 4 点权重 0.5,四个角上的 2×2 小方块各 0.125,能明显抑制高亮像素的闪烁 vec4 FragDown() { vec2 uv = texcoord; vec2 t = _BlitTexture_TexelSize.xy; vec3 A = Tap(uv + t * vec2(-2, -2)), B = Tap(uv + t * vec2(0, -2)), C = Tap(uv + t * vec2(2, -2)); vec3 D = Tap(uv + t * vec2(-1, -1)), E = Tap(uv + t * vec2(1, -1)); vec3 F = Tap(uv + t * vec2(-2, 0)), G = Tap(uv), H = Tap(uv + t * vec2(2, 0)); vec3 I = Tap(uv + t * vec2(-1, 1)), J = Tap(uv + t * vec2(1, 1)); vec3 K = Tap(uv + t * vec2(-2, 2)), L = Tap(uv + t * vec2(0, 2)), M = Tap(uv + t * vec2(2, 2)); vec3 c = (D + E + I + J) * (0.5 / 4.0); c += (A + B + G + F) * (0.125 / 4.0); c += (B + C + H + G) * (0.125 / 4.0); c += (F + G + L + K) * (0.125 / 4.0); c += (G + H + M + L) * (0.125 / 4.0); return vec4(c, 1.0); }
上采样与 scatter
上采样时,低一级用 3×3 帐篷滤波放大(权重 1-2-1,比直接双线性放大更平滑,没有块状感),再与本级的下采样结果按 scatter 插值。scatter 越大,低频(大尺度)成分占比越高,光晕越大越散;越小,光晕越集中在光源附近:
// 上采样:低一层用 3×3 帐篷滤波放大,再与本层按 scatter 插值 half4 FragUp(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; float2 t = _BlitTexture_TexelSize.xy; #define LOW(o) SAMPLE_TEXTURE2D_X(_BloomLowMip, sampler_LinearClamp, uv + t * (o)).rgb half3 low = LOW(float2(-1, -1)) + LOW(float2(1, -1)) + LOW(float2(-1, 1)) + LOW(float2(1, 1)); low += (LOW(float2(0, -1)) + LOW(float2(-1, 0)) + LOW(float2(1, 0)) + LOW(float2(0, 1))) * 2.0; low += LOW(float2(0, 0)) * 4.0; low *= 1.0 / 16.0; #undef LOW half3 high = Tap(uv); return half4(lerp(high, low, _BloomParams.z), 1.0); }
// 上采样:低一层用 3×3 帐篷滤波放大,再与本层按 scatter 插值 vec4 FragUp() { vec2 uv = texcoord; vec2 t = _BlitTexture_TexelSize.xy; #define LOW(o) texture(_BloomLowMip, uv + t * (o)).rgb vec3 low = LOW(vec2(-1, -1)) + LOW(vec2(1, -1)) + LOW(vec2(-1, 1)) + LOW(vec2(1, 1)); low += (LOW(vec2(0, -1)) + LOW(vec2(-1, 0)) + LOW(vec2(1, 0)) + LOW(vec2(0, 1))) * 2.0; low += LOW(vec2(0, 0)) * 4.0; low *= 1.0 / 16.0; #undef LOW vec3 high = Tap(uv); return vec4(mix(high, low, _BloomParams.z), 1.0); }
注意 _BloomLowMip 是用 cmd.SetGlobalTexture 逐级设置的全局纹理——又一次“逐次参数”的应用。C# 部分负责分配金字塔与组织调用顺序:
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { // 金字塔从半分辨率开始,一直缩到短边只剩几个像素或达到最大层数 var desc = ColorDesc(ref renderingData, 2); int minSide = Mathf.Min(desc.width, desc.height); m_Levels = Mathf.Clamp(Mathf.FloorToInt(Mathf.Log(minSide, 2f)) - 1, 1, m_MaxIterations); for (int i = 0; i < m_Levels; i++) { Alloc(ref m_Down[i], desc, "_Ch26BloomDown" + i); if (i < m_Levels - 1) Alloc(ref m_Up[i], desc, "_Ch26BloomUp" + i); desc.width = Mathf.Max(1, desc.width / 2); desc.height = Mathf.Max(1, desc.height / 2); } Alloc(ref m_Temp, ColorDesc(ref renderingData), "_Ch26BloomTemp"); }
protected override void Render(CommandBuffer cmd, ref RenderingData renderingData) { // 1) 亮部提取 + 第一次降采样(全分辨率 → 1/2) Blitter.BlitCameraTexture(cmd, cameraColor, m_Down[0], material, PassPrefilter); // 2) 下采样金字塔:1/2 → 1/4 → 1/8 …(13 次采样的抗闪烁下采样核) for (int i = 1; i < m_Levels; i++) Blitter.BlitCameraTexture(cmd, m_Down[i - 1], m_Down[i], material, PassDown); // 3) 上采样:从最小层开始,每级 = lerp(本级下采样结果, 上一级的放大结果, scatter) for (int i = m_Levels - 2; i >= 0; i--) { RTHandle low = (i == m_Levels - 2) ? m_Down[i + 1] : m_Up[i + 1]; cmd.SetGlobalTexture(s_LowMip, low); // 每次迭代不同 → 用全局纹理逐次记录 Blitter.BlitCameraTexture(cmd, m_Down[i], m_Up[i], material, PassUp); } // 4) 合成:相机颜色 + 泛光 × 强度 × 颜色 RTHandle bloom = m_Debug == BloomDebugView.Prefilter ? m_Down[0] : (m_Levels > 1 ? m_Up[0] : m_Down[0]); cmd.SetGlobalTexture(s_BloomTex, bloom); Blitter.BlitCameraTexture(cmd, cameraColor, m_Temp, material, PassComposite); Blitter.BlitCameraTexture(cmd, m_Temp, cameraColor); }
half4 FragComposite(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv); half3 bloom = SAMPLE_TEXTURE2D_X(_BloomTex, sampler_LinearClamp, uv).rgb * _BloomTint.rgb * _BloomParams.w; if (_BloomDebugView > 0.5) return half4(bloom, 1.0); // 调试:只看泛光 color.rgb += bloom; // 线性 HDR 空间里直接相加 return color; }
[BookShot("ch26-bloom", W = 480, H = 320, Panels = 4, Hdr = true)] // Hdr:浮点相机目标,亮度可远大于 1 static void BloomShot(Shot s) { Stage(s, glow: 10f); // 自发光强度 10:远超阈值 s.Background(new Color(0.02f, 0.025f, 0.04f)); // 暗背景更能看出泛光 s.SunDir(50, -30, 0.8f); var prof = s.Post(); // 四格都加同样的 ACES 色调映射(URP 自带),只有 Bloom 不同 prof.Add<Tonemapping>().mode.Override(TonemappingMode.ACES); if (s.Panel == 0) return; s.Feature<MyBloomFeature>(); var v = prof.Add<MyBloomVolume>(); v.threshold.Override(1f); v.softKnee.Override(0.5f); v.intensity.Override(1f); v.scatter.Override(0.7f); v.debugView.Override(s.Panel == 1 ? BloomDebugView.Prefilter : s.Panel == 2 ? BloomDebugView.BloomOnly : BloomDebugView.None); }

这组图使用浮点(HDR)相机缓冲,霓虹灯条与发光球的自发光强度为 10,远高于阈值 1;四格都加了 URP 自带的 ACES 色调映射。可以看到预过滤只留下了真正“过亮”的部分:被阳光照亮的橙色纽结、蓝色方块亮度大多在 1 以下,只在软膝区间里贡献了很弱的一点。光源越亮,散射出去的能量越多,光晕也越大——这正是在 HDR 上做 Bloom 的意义。
与 URP 内置 Bloom 的对比。为了对齐参数要注意:URP 的 threshold 是按 Gamma 空间理解的,内部做了 Mathf.GammaToLinearSpace;它的 scatter 也被重映射到 0.05~0.95(见 PostProcessPass.cs 中设置 Bloom 参数的代码)。阈值取 1 时两种理解恰好相同;URP 一侧的 scatter 取 0.72(重映射后约为 0.70),与我们的 0.7 对齐,阈值、强度都为 1:

扩展方向:URP 内置 Bloom 还有 Dirt(镜头污渍贴图,用泛光强度调制一张贴图后叠加)、高质量模式(预过滤用 13 次采样)和 RGBM 编码(不支持浮点 RT 的平台上用 8 位纹理存 HDR)。理解了金字塔结构后,这些都是在某一步上的增量修改。