GameTech/URP 渲染实战

案例 26.4自制 Bloom:预过滤、下采样金字塔、上采样合成高阶

目标:从零实现一个与 URP 内置 Bloom 思路一致的泛光:只让足够亮的像素发光,光晕大而柔和、不闪烁,参数可由 Volume 控制。

原理:Bloom 模拟的是镜头和人眼里的光散射——极亮的光源周围出现一圈逐渐衰减的光晕。实现分四步:

  1. 预过滤(Prefilter):从相机颜色中提取亮部,同时降到 1/2 分辨率。
  2. 下采样金字塔:反复减半,得到 1/4、1/8 …… 的一串越来越模糊的图。
  3. 上采样合并:从最小的一级开始往回放大,每级把“放大后的低一级”与“本级”按 scatter 混合。不同尺度的模糊叠在一起,形成中心亮、拖尾长的光晕。
  4. 合成:在线性 HDR 空间里把泛光乘以强度和颜色,加回原图。
相机颜色 Down0 ½ Down1 D2 预过滤 13 次采样下采样 Up0 Up1 Up2 帐篷放大+ scatter 混合 合成:color + bloom × 强度 虚线:上采样时,本级的“下采样结果”作为高频部分参与混合
图 26-9Bloom 的 RT 流转:下行(蓝)逐级减半,上行(橙)逐级放大并与同级下采样结果混合,最后加回相机颜色

软阈值(Soft Knee)

最简单的阈值是 c·max(b−T,0)/b(b 为像素亮度,取 RGB 最大值),但它在 b = T 处有一个折角:亮度在阈值附近轻微变化(比如物体移动、高光闪烁)时,泛光会突然出现或消失。软阈值在 [T − K, T + K] 区间内用一段二次曲线把 0 平滑地接到直线上:

s=clamp(b−T+K,0,2K)24K,contribution=max(b−T,s)b

在 b = T + K 处,二次曲线 s 的值与斜率都恰好等于直线 b − T 的值与斜率(都等于 K 与 1),所以拼接处是光滑的。URP 内置 Bloom 用的是同一个公式(参数里的 Threshold Knee)。我们的 Volume 用“相对阈值的比例”来表示膝宽:K = T × softKnee。

Assets/Cookbook/Ch26/MyBloom.shader(节选:prefilter)
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 的源纹素,明显减轻了这种闪烁:

Assets/Cookbook/Ch26/MyBloom.shader(节选:down)
// 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 越大,低频(大尺度)成分占比越高,光晕越大越散;越小,光晕越集中在光源附近:

Assets/Cookbook/Ch26/MyBloom.shader(节选:up)
// 上采样:低一层用 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# 部分负责分配金字塔与组织调用顺序:

Assets/Cookbook/Ch26/MyBloom.cs(节选:alloc)
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");
}
Assets/Cookbook/Ch26/MyBloom.cs(节选:render)
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);
}
Assets/Cookbook/Ch26/MyBloom.shader(节选:composite)
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;
}
vec4 FragComposite()
{
    vec2 uv = texcoord;
    vec4 color = texture(_BlitTexture, uv);
    vec3 bloom = texture(_BloomTex, uv).rgb * _BloomTint.rgb * _BloomParams.w;
    if (_BloomDebugView > 0.5) return vec4(bloom, 1.0);   // 调试:只看泛光
    color.rgb += bloom;                                    // 线性 HDR 空间里直接相加
    return color;
}
Assets/Cookbook/Ch26/Editor/Ch26Shots.cs(节选:shot-bloom)
[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);
}
图 26-10从左到右:原图;预过滤结果(只剩亮部);上采样合并后的泛光;最终合成(threshold = 1、softKnee = 0.5、intensity = 1、scatter = 0.7),霓虹灯条和发光球周围出现柔和的彩色光晕

这组图使用浮点(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:

图 26-11左:本章的 MyBloom;右:URP 内置 Bloom。两者结构相同,光晕形状接近;细微差别来自预过滤和上采样核的选择

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