在URP RendererFeature中实现自定义Depth-Aware Bloom

解读

国内一线/二线游戏公司近两年在URP管线下的面试里,“RendererFeature + 自定义后处理” 几乎成了区分“只会调包”与“能改管线”的必考题。Depth-Aware Bloom 又是其中最具代表性的综合题:

  1. 既考察你对 URP RendererFeature/RendererPass 生命周期 的掌握;
  2. 又验证你能否把 深度图采样、分层降采样、阈值/滤波/混合 这些经典后处理算法移植到 Tile-Based 移动架构上,并解决 Z-Buffer 非线性、MSAA、Alpha 透明物体 等坑;
  3. 最后还要给出 性能预算(Android 中高端 1080p ≤ 1 ms,iOS A14 ≤ 0.8 ms)与 热更新兼容性(Shader 变体、宏开关、资源包拆分)的落地思路。
    面试官通常会让你 “白板” 画 Pass 流程图 并口头报出关键 Shader 代码,时间 8~10 分钟,答得越深,后续追问越狠。

知识点

  1. URP RendererFeature 四件套:Create() → AddRenderPasses() → ScriptableRenderPass 配置 → Enqueue 顺序与 Event 注入点。
  2. 深度图获取:ConfigureInput(ScriptableRenderPassInput.Depth) 要求 DepthTexture 开启;移动端需处理 24bit vs 32bit、Reverse-Z、HSR(Hidden Surface Removal)差异。
  3. 分层降采样金字塔:DownSample → Horizontal/Vertical Kawase/Gaussian → UpSample Blend;双线性 + 4 Tap 优化 可减少 50% 带宽。
  4. 深度权重函数:线性化后计算 w = saturate((d – dNear) / (dFar – dNear)) 再反向插值 lerp(1, 0, w^power)power≈2~4 可抑制远景过曝;需防止除零与 Early-Z 冲突。
  5. Shader 变体控制:使用 multi_compile_local _ DEPTH_AWARE_ON 让美术在 Volume 组件里实时开关,避免全量变体爆炸。
  6. RT 生命周期管理:GetTemporaryRT + ReleaseTemporaryRT,Android 上必须显式设置 enableRandomWrite = false 否则会在部分 Mali GPU 上触发内部 Resolve,导致 0.5 ms 回读延迟。
  7. MSAA & Alpha 透明:Bloom 输入应取自 AfterRenderingTransparents 之后、BeforePostProcess 之前,且需 Copy Color 到非 MSAA RT;否则高亮边缘会出现“双影”。
  8. 性能预算
    – 降采样层级 ≤ 4,RT 格式 R11G11B10_F
    – 每 Pass 采样次数 ≤ 9;
    – 使用 URP ShaderLab HLSL 而非 PostProcessing V2 的 FrameGraph,方便后续接自定义 FrameGraph Native Render Pass(Unity 2022.3+)。
  9. 热更新:把 Pass 代码放 Assembly Definition Reference 到 HotFix.dll,Shader 放 AssetBundle;通过 IPostProcessComponent 接口动态挂载,避免整包更新。
  10. 真机验证:Snapdragon Profiler 查看 Texture Read/Write Bytes;iOS Xcode Capture 检查 GPU Bandwidth;若超过 200 MB/s,需降阶到 540p 降采样。

答案

(面试现场用 4 步讲清,关键句必须背下来)

步骤 1:RendererFeature 骨架

public class DepthAwareBloomFeature : ScriptableRendererFeature
{
    [System.Serializable] public class Settings
    {
        public RenderPassEvent Event = RenderPassEvent.AfterRenderingTransparents;
        public float Threshold = 0.9f;
        public float Intensity = 1f;
        public float DepthPower = 3f;
        public int MaxIterations = 4;
    }
    public Settings settings = new Settings();
    DepthAwareBloomPass m_Pass;

    public override void Create() => m_Pass = new DepthAwareBloomPass(settings);
    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (renderingData.cameraData.postProcessEnabled)
            renderer.EnqueuePass(m_Pass);
    }
}

步骤 2:ScriptableRenderPass 核心

class DepthAwareBloomPass : ScriptableRenderPass
{
    Settings m_Set;
    RTHandle m_TempColor, m_Down1, m_Down2, m_Down3, m_Up1, m_Up2;
    ProfilingSampler m_Prof = new ProfilingSampler("DepthAwareBloom");

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        var desc = renderingData.cameraData.cameraTargetDescriptor;
        desc.msaaSamples = 1;
        desc.depthBufferBits = 0;
        desc.colorFormat = RenderTextureFormat.R11G11B10;
        RenderingUtils.ReAllocateIfNeeded(ref m_TempColor, desc, FilterMode.Bilinear);
        // 同理再分配 Down/Up RT,降采样 2 倍递减
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        CommandBuffer cmd = CommandBufferPool.Get();
        using (new ProfilingScope(cmd, m_Prof))
        {
            var source = renderingData.cameraData.renderer.cameraColorTargetHandle;
            // 1. Copy & Threshold
            Blitter.BlitCameraTexture(cmd, source, m_TempColor, m_Set.Threshold, 0);
            // 2. DownSample 并带深度权重
            DownSampleWithDepth(cmd, m_TempColor, m_Down1, 1);
            DownSampleWithDepth(cmd, m_Down1,   m_Down2, 2);
            // 3. UpSample 混合
            UpSampleBlend(cmd, m_Down2, m_Down1, m_Up1);
            UpSampleBlend(cmd, m_Up1,   m_TempColor, m_Up2);
            // 4. 最终叠加
            Blitter.BlitCameraTexture(cmd, m_Up2, source, m_Set.Intensity, 1);
        }
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }
}

步骤 3:Shader 深度权重片段

half4 FragDownSample(Varyings input) : SV_Target
{
    half4 col = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, input.texcoord);
    float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_PointClamp, input.texcoord);
    d = LinearEyeDepth(d, _ZBufferParams);
    half depthWeight = saturate(pow(saturate((d - _DepthNear) / (_DepthFar - _DepthNear)), _DepthPower));
    col.rgb *= lerp(1, 0, depthWeight);   // 远景抑制
    return col;
}

步骤 4:性能与兼容性兜底
– 在 AddRenderPasses 里判断 SystemInfo.graphicsDeviceTypeVulkan/Metal 才启用,OpenGL ES 2.0 直接跳过;
– 用 XRSettings.isDeviceActive 区分 VR 双眼,降采样层级再减 1;
– 真机帧率低于 50 fps 时,通过 VolumeComponent 动态把 MaxIterations 降到 2,保证 Chinese mid-range Android(骁龙 7 Gen 1)流畅。

拓展思考

  1. 如果项目 后处理栈已接入 Unity FrameGraph(2023 LTS),如何把上述 Pass 改写成 NativeRenderPass 并合并到 UniversalRendererData.assetm_RendererFeaturesList,从而省掉一次 RT Aliasing?
  2. 当相机开启 TAA 时,深度图在 jitter 状态下会出现 Z-fighting 闪烁,你会把深度权重计算提前到 AfterOpaqueTexture 阶段,还是给深度图做一次 TAA Resolve?两种方案在 带宽与历史缓存 上如何取舍?
  3. 华为海思 Mali-G710 芯片上实测发现 DownSample Pass 带宽异常高 30%,怀疑是 PLS(Pixel Local Storage) 未命中,你会如何用 RenderDoc + Mali Offline Compiler 定位并优化?
  4. 如果策划要求 Bloom 颜色受环境体积光(Volumetric Fog)影响,你需要把 Depth-Aware BloomVolumetric LightingRT 复用,请给出 FrameGraph 资源别名 的注册顺序与 GPU Sync 点 控制策略。
  5. 为了支持 热更新框架(HybridCLR),如何把整个 DepthAwareBloomPass 编译进 热更 DLL,同时保证 Shader Variant CollectionAssetBundle 里不丢失 DEPTH_AWARE_ON 关键字?请给出 ScriptableBuildPipelineFilterBuildCallback 代码片段。