Unity屏幕空间物体描边与幽灵遮挡后处理实现 📅 发布时间:2026/9/4 7:33:37 👁 浏览次数: 做游戏时“角色被墙挡住但还想让玩家看到轮廓”“任务目标藏在障碍物后面需要高亮提示”这类需求非常常见。实现方式也有好几种模型边缘光、顶点膨胀描边、屏幕后处理描边。今天这篇文章聚焦屏幕空间着色器路线用 Unity 后处理把“物体轮廓”和“幽灵遮挡效果”一起做出来。看完你可以直接把它接到自己的项目里按 Layer 控制哪些物体需要高亮哪些物体被遮挡后显示半透明幽灵轮廓。这个方案不依赖模型顶点数不要求额外建模只需要在主相机上挂一个后处理脚本再配两个 Shader 就能工作。对于需要批量标记多个目标、团队副本指引、战术指示器、解谜高亮这类场景它比单独给每个物体写材质要省事得多。本文会包含这样几块内容屏幕空间物体轮廓的检测原理幽灵遮挡效果为什么需要二次深度渲染目标物体深度写入 Shader 的实现后处理边缘检测与遮挡叠加 Shader 的实现C# 控制脚本与 Layer 配置方式参数调优、URP 迁移和性能注意事项先说明本文示例以 Unity Built-in Render Pipeline 为主。URP 项目也可以迁移最后会单独讲迁移思路。1. 核心能力速览能力项说明技术路线Unity 后处理 屏幕空间边缘检测 二次深度目标渲染适用管线Built-in Render PipelineURP 需要改成 Scriptable Renderer Feature核心功能可见物体边缘描边、被遮挡物体透视幽灵轮廓目标控制Layer/Tag/指定 Renderer 列表均可显存压力较低只增加 1 张半分辨率或全分辨率临时 RT对模型要求无额外要求不需要顶点色或第二套 UV启动方式场景挂脚本 材质引用运行时自动生效批量扩展支持任意数量目标物体统一由 Layer 控制适合场景任务指引、队友透视、道具高亮、解谜提示、技能目标标记从项目上看最推荐的做法是“边缘检测 目标掩码分离”。也就是说屏幕上的轮廓线通过深度/法线差异计算出来而哪些像素属于“需要透视的目标”则由一张额外的目标深度图来判断。2. 物体轮廓与幽灵遮挡效果的原理拆解2.1 屏幕空间轮廓从哪里来屏幕空间描边和后处理描边最常用的方案有几种拉普拉斯边缘检测Sobel 边缘检测深度差 法线差判断单纯对颜色做边缘检测会把贴图纹理和光照变化也识别成边缘视觉效果很脏。更稳定的是结合深度纹理和法线纹理来判断相邻像素如果深度差距大或者法线方向差距大就认为这里存在几何边缘。Unity 里开启相机的深度法线模式可以在 Shader 中通过_CameraDepthNormalsTexture拿到一张同时包含视角空间深度和视角空间法线的纹理。然后使用类似 Roberts 或 Sobel 的卷积核在屏幕空间采样 4 到 8 个邻域点通过比较深度差和法线差就能得到屏幕上的轮廓线。这是比较经典的后处理描边思路计算量远小于在模型空间重新渲染一遍轮廓层也适合做全屏角色描边。2.2 幽灵遮挡效果的难点在哪如果只是“给物体描边”那用深度法线边缘检测就可以收工。难点在于“幽灵遮挡效果”当目标物体被墙体挡住时屏幕上的像素来自墙壁。后处理不知道这个像素原本应该属于哪个物体因此边缘检测只能画出墙壁的边缘画不出墙后面目标物体的轮廓。要让遮挡状态下的轮廓也显示必须额外渲染一张“目标物体的屏幕空间信息图”。这张图上只有目标物体其他物体不参与渲染。然后在后处理阶段比对场景深度和目标深度如果当前像素的场景深度比目标深度更靠近相机就认为目标物体在该像素位置被遮挡了。此时把这些边界像素显示成半透明、发光、或者带 ghost 效果的颜色就是“幽灵遮挡效果”。换句话说完整流程分三步主相机正常渲染场景。单独渲染目标物体到一张低分辨率深度图图中只存储目标物体到相机的距离。后处理阶段采样场景深度、场景法线、目标深度经过边缘检测和遮挡判断后输出描边与幽灵效果。3. 环境准备与工程配置建议使用 Unity 2021.3 LTS 或 2022.3 LTS 测试以下配置都按 Built-in Render Pipeline 准备。3.1 基础工程要求在 Unity 中创建 3D 工程后确认几下基础设置渲染管线为 Built-inProject Settings 的 Graphics Settings 中没设置 URP Asset。主相机开启Allow HDR、MSAA按项目承受力选择如果只要观察轮廓可以先关闭。项目使用线性空间即可。3.2 相机深度纹理由于我们要在 Shader 中读取主相机的深度和法线需要让相机生成深度法线纹理。在 C# 脚本里可以用如下方式强制开启Camera.main.depthTextureMode DepthTextureMode.DepthNormals;如果你在 Unity 编辑器中查看相机的Depth Texture选项它会显示Depth Normals。这一步务必检查否则屏幕后处理材质拿到_CameraDepthNormalsTexture后会是黑色或者全白轮廓出不来。3.3 场景结构建议建议建一层空物体OutlineSystem然后按下列结构管理场景场景根节点 ├── Directional Light ├── Main Camera (挂 OutlineEffect.cs) ├── Environment (普通场景物体默认层) └── Targets ├── Target_Cube (层设为 GhostTarget) ├── Target_Sphere (层设为 GhostTarget) └── Wall (默认层遮挡目标)Targets 下所有需要透视和描边的物体统一放到GhostTargetLayer。后面代码会把该 Layer 的所有物体写入目标深度图。4. 编写目标物体深度渲染 Shader思路是使用 Camera.RenderWithShader 或额外摄像机的cullingMask只渲染目标层但更轻量的做法是用CommandBuffer或者额外的Camera渲染一遍目标物体。为了减少额外相机维护成本示例采用最简单直观的方案创建一个独立相机它的cullingMask只包含GhostTarget层并输出到一张 RenderTexture。为了让普通物体的 Shader 不干扰深度写入我们直接覆盖渲染 Shader输出“目标深度编码颜色”。4.1 深度编码思路我们需要在 Shader 中输出目标物体某个像素到相机的距离或者直接输出“当前像素是否属于目标物体”的 Mask。如果只做 Mask幽灵效果只能是纯色轮廓但遇到遮挡物离得很近时仍然要区分目标物体的远近。输出“深度值”更通用。写一张 RGBA 纹理A 通道保存目标物体标记B 通道保存非线性或线性深度。后处理综合判断时需要知道“这个像素处有没有目标”然后比较场景深度和目标深度。4.2 GhostDepthShader.shader新建一个 Shader 文件命名为GhostDepthShader.shader。它的作用是正常按顶点位置输出但颜色输出为目标深度编码。Shader Hidden/GhostDepthShader { SubShader { Tags { RenderTypeOpaque } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; float3 worldPos : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; return o; } fixed4 frag (v2f i) : SV_Target { float depth distance(_WorldSpaceCameraPos.xyz, i.worldPos); depth saturate(depth / 50.0); return fixed4(depth, depth, depth, 1.0); } ENDCG } } }这个 Shader 并不是真正渲染到主相机屏幕而是被独立相机用RenderWithShader执行。它把“目标物体到相机的距离除以 50 米后压缩到 0 到 1”写入颜色。50 是最大显示距离你可以按场景调大。也可以用观察空间深度代替距离只需要在 vert 里编码COMPUTE_EYEDEPTH(o.pos.z)。但用距离值更直观因为后面判断遮挡时直接比“谁离相机更近”即可。4.3 创建独立深度相机不直接改主相机新建一个子物体GhostDepthCamera挂 Camera 组件。设置如下Clear Flags: Solid Color Background: Black Culling Mask: GhostTarget Target Texture: 后续创建 RenderTexture 后拖入然后在 C# 脚本中每个 LateUpdate 或 OnPreCull 阶段设置该相机的RenderWithShader并手动调用一次渲染。5. 编写屏幕空间后处理 Shader后处理 Shader 分为两个 PassPass 0利用场景深度 法线纹理检测边缘Pass 1采样目标深度纹理判断遮挡混合幽灵颜色也可以直接合成一个 Pass先算边缘再用目标深度图做二次判断。下面给出完整实现。5.1 ScreenSpaceOutline.shaderShader Hidden/ScreenSpaceOutline { Properties { _OutlineColor (Outline Color, Color) (1,1,1,1) _GhostColor (Ghost Color, Color) (0.2,0.8,1,0.5) _EdgeThreshold (Edge Threshold, Range(0.1, 2)) 0.5 } SubShader { ZTest Always ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; sampler2D _CameraDepthNormalsTexture; sampler2D _GhostDepthTex; float4 _MainTex_TexelSize; fixed4 _OutlineColor; fixed4 _GhostColor; float _EdgeThreshold; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } float3 DecodeViewNormal(float2 uv) { float4 enc tex2D(_CameraDepthNormalsTexture, uv); return DecodeViewNormalStereo(enc); } float DecodeViewDepth(float2 uv) { float4 enc tex2D(_CameraDepthNormalsTexture, uv); float depth; DecodeDepthNormal(enc, depth, float4(0,0,0,0)); return depth; } bool IsEdge(float2 uv) { float2 texel _MainTex_TexelSize.xy; float2 offsets[4] { float2(-texel.x, 0), float2(texel.x, 0), float2(0, -texel.y), float2(0, texel.y) }; float depthCenter DecodeViewDepth(uv); float3 normalCenter DecodeViewNormal(uv); float depthSum 0; float3 normalSum 0; for (int i 0; i 4; i) { float2 sampleUV uv offsets[i]; float d DecodeViewDepth(sampleUV); float3 n DecodeViewNormal(sampleUV); depthSum abs(d - depthCenter); normalSum abs(n - normalCenter); } float depthDiff depthSum / 4.0; float normalDiff (normalSum.x normalSum.y normalSum.z) / 3.0; return (depthDiff normalDiff) _EdgeThreshold; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); float ghostMask tex2D(_GhostDepthTex, i.uv).a; float targetDepth tex2D(_GhostDepthTex, i.uv).b; float sceneDepth DecodeViewDepth(i.uv); bool isEdge IsEdge(i.uv); float occlusionFactor (sceneDepth targetDepth - 0.05) ? 1 : 0; float finalEdge isEdge ? 1 : 0; if (ghostMask 0.5 finalEdge 0.5) { if (occlusionFactor 0.5) { fixed4 ghost _GhostColor; ghost.a 1.0; return lerp(col, ghost, 0.85); } else { return lerp(col, _OutlineColor, 0.8); } } return col; } ENDCG } } }这段代码的关键逻辑在 frag 中读取目标深度纹理_GhostDepthTex。用_CameraDepthNormalsTexture解码场景深度和法线。通过 IsEdge 判断当前像素是否处于屏幕空间边缘。如果是目标物体区域再比较场景深度和目标深度。场景深度小于目标深度说明目标被挡住输出幽灵颜色否则输出普通描边色。5.2 这段代码的边界判断方式上述代码每帧会对全屏每个像素做一遍深度法线采样和边缘判断。实际工作中可以直接用固定的 Roberts 算子或 Sobel 算子代替四个方向比较也可以增加采样点让边缘更连续。写 Demo 时四个方向已经够用。如果要更稳定建议把 IsEdge 改成 Sobel 卷积核计算并从深度与法线两个通道分别提取梯度然后合并。法线差异用向量距离计算深度差异直接用线性深度差。6. C# 控制脚本实现6.1 OutlineEffect.cs在主相机上挂一个OutlineEffect.cs把材质、目标深度渲染相机关联起来。using UnityEngine; [RequireComponent(typeof(Camera))] public class OutlineEffect : MonoBehaviour { public Material outlineMaterial; public Camera ghostDepthCamera; [Range(0.1f, 2f)] public float edgeThreshold 0.5f; public Color outlineColor Color.white; public Color ghostColor new Color(0.2f, 0.8f, 1f, 0.6f); private RenderTexture ghostDepthRT; private void OnEnable() { var cam GetComponentCamera(); cam.depthTextureMode | DepthTextureMode.DepthNormals; } private void OnDisable() { if (ghostDepthCamera ! null) { ghostDepthCamera.targetTexture null; } if (ghostDepthRT ! null) { ghostDepthRT.Release(); ghostDepthRT null; } } private void LateUpdate() { if (ghostDepthCamera null) { return; } if (ghostDepthRT null) { int w Screen.width / 2; int h Screen.height / 2; ghostDepthRT new RenderTexture(w, h, 16, RenderTextureFormat.ARGB32); ghostDepthRT.name GhostDepthRT; } ghostDepthCamera.targetTexture ghostDepthRT; ghostDepthCamera.enabled false; ghostDepthCamera.RenderWithShader(Shader.Find(Hidden/GhostDepthShader), null); UpdateMaterialProperties(); } private void UpdateMaterialProperties() { if (outlineMaterial null) { return; } outlineMaterial.SetTexture(_GhostDepthTex, ghostDepthRT); outlineMaterial.SetColor(_OutlineColor, outlineColor); outlineMaterial.SetColor(_GhostColor, ghostColor); outlineMaterial.SetFloat(_EdgeThreshold, edgeThreshold); } private void OnRenderImage(RenderTexture source, RenderTexture destination) { if (outlineMaterial null) { Graphics.Blit(source, destination); return; } Graphics.Blit(source, destination, outlineMaterial); } }6.2 脚本说明OnEnable 里让主相机生成深度法线纹理。LateUpdate 里做了三件事创建半分辨率 RenderTexture让一个不参与主相机渲染的独立相机用覆盖 Shader 只渲染目标物体层把材质参数同步到后处理材质OnRenderImage 最终把后处理材质应用到整屏。6.3 目标层配置目标相机GhostDepthCamera的 Culling Mask 只勾选GhostTarget。也就是说控制器里的目标角色、道具、机关都放到这一层。后处理只对这些物体输出轮廓和遮挡效果。7. 功能测试与效果验证7.1 验证可见物体轮廓摆放一个不带遮挡的 Cube 在相机正前方将 Cube 的 Layer 设置为 GhostTarget。运行场景后从相机看到的 Cube 边缘会出现_OutlineColor描边。描边稳定不闪烁。旋转相机Cube 边缘轮廓连续。如果描边没有出现优先检查主相机是否挂上了 OutlineEffect 脚本主相机 DepthTextureMode 是否是 DepthNormals后处理材质是否引用了 ScreenSpaceOutline shader目标相机 Culling Mask 是否正确7.2 验证幽灵遮挡效果在目标 Cube 前面放一面墙层设置为 Default。运行后在主相机视角中Cube 被墙完全挡住的区域边界部分应显示半透明的 GhostColor形成类似 X 光透视效果。由于边缘检测只保留边界没有边界的位置不会整片填充。这样做的好处是避免整面墙被大面积半透明颜色盖住画面更像“轮廓透视”。如果希望整颗目标物体都显示半透明而不是只有边缘可以把后处理里的finalEdge判断去掉直接对 ghostMask 区域做混合。这样就是“穿墙透视全体高亮”适合某些显示需求但视觉上会比较抢眼。7.3 目标深度纹理精确性检查为了调试目标深度图是否正常可以在 LateUpdate 里实时把 ghostDepthRT 显示到屏幕if (showDebugRT) { Graphics.Blit(ghostDepthRT, (RenderTexture)null); }不过这样会覆盖主相机输出。更方便的方法是在 Game 视图旁边开一个 RawImage UI把 ghostDepthRT 动态赋给它的 texturepublic UnityEngine.UI.RawImage debugImage; private void LateUpdate() { if (debugImage ! null ghostDepthRT ! null) { debugImage.texture ghostDepthRT; } }如果 RawImage 里只有目标物体的白色轮廓说明深度写入成功。如果整屏黑色目标物体没有进入目标深度图。7.4 参数调优观察参数作用推荐起始值edgeThreshold控制边缘敏感度0.3 到 0.8ghostColor 的 alpha控制幽灵效果混合强度0.5 到 0.7outlineColor描边颜色按团队配色决定GhostDepthRT 分辨率半分辨率即可过高会增加带宽Screen / 2最大显示距离GhostDepthShader 中 50.0按场景视距调整edgeThreshold太小会导致贴图明暗变化也被误识别为轮廓太大则会导致轮廓断裂需要根据具体场景不断测试。8. 接口 API 化与批量目标控制如果后面要做系统集成建议把这套效果整理成对外接口。例如 RTS 中玩家选中多个单位需要同时为它们生成轮廓或者副本里给多个 BOSS 目标做透视提示。8.1 开放接口OutlineEffect.cs可以加挂公有的开启/关闭接口public void SetTargetLayer(LayerMask targetLayer) { if (ghostDepthCamera ! null) { ghostDepthCamera.cullingMask targetLayer; } } public void SetEnabled(bool enable) { outlineMaterial.enabled enable; }要动态变更目标物体也可以不用 Layer而是把目标 Renderer 列表传入深度相机public void AddTarget(Renderer targetRenderer) { int layer LayerMask.NameToLayer(GhostTarget); targetRenderer.gameObject.layer layer; } public void RemoveTarget(Renderer targetRenderer) { int defaultLayer LayerMask.NameToLayer(Default); targetRenderer.gameObject.layer defaultLayer; }这种方式适合运行时切换选中目标。缺点是动态改 Layer需要维护好原先的 Layer 值移除目标时还原。8.2 排除个别物体如果不想整层统一更稳妥的方案是额外使用一份目标物体列表在独立深度相机渲染时临时切换每个物体的 Layer 并还原。代码大概是Using (new LayerScope(targetRenderer, ghostLayerId)) { ghostDepthCamera.RenderWithShader(...); }LayerScope 写一个临时改 Layer 并在 Dispose 时还原的辅助类即可。不过这个做法依赖于渲染同步时机后续可以进一步整理成 CommandBuffer 方案避免频繁改 Layer。8.3 与帧循环集成因为后处理采用 OnRenderImage目标深度渲染发生在 LateUpdate顺序大概是主相机渲染场景 - LateUpdate 生成目标深度图 - OnRenderImage 叠加轮廓。这个顺序在多数情况下没有问题。如果追求更严格的帧内同步可以使用 Camera.onPreRender 或 CommandBuffer。在 Built-in 管线中最简单稳当的还是 LateUpdate OnRenderImage。如果单位拾取或目标切换频繁只需要在 LateUpdate 之前更新 Layer 即可。9. 资源占用与性能观察9.1 主要开销这套效果的开销来自三部分主相机生成深度法线纹理独立相机再次渲染目标物体全屏后处理对每个像素的深度、法线、颜色采样第一部分不可避免只要做屏幕空间深度/法线效果就需要。第二部分只渲染 GhostTarget 层如果目标数量少、模型简单开销很低。第三部分才是最大的性能影响因素。所以适合做“目标数量少但需要长时间显示”的效果。如果场景中有几百个角色都需要透视那就应该改成 CommandBuffer 绘制深度的方案而不是单独相机逐帧渲染。9.2 如何降低开销推荐这几个方法把 GhostDepthRT 设为半分辨率Ghost 轮廓会稍微有锯齿但性能可以多出不少余量。后处理 Shader 只保留一个 Pass减少中间 RT 读写。用_MainTex_TexelSize控制采样步长避免在每帧做多层模糊。如果不需要满屏检测可以把边缘检测的像素范围限制到包围盒区域但工程复杂度会上升。9.3 Frame Debugger 观察方法打开 Window - Analysis - Frame Debugger点击 Enable然后逐条查看 Draw CallGhostDepthCamera 会生成一批目标物体的绘制。主相机最后会多一个 Post Process Pass。如果发现 GhostTarget 层物体绘制两次检查是否给 GhostDepthCamera 也开启了自动渲染而没有被禁用。10. 常见问题与排查方法问题现象可能原因排查方式解决方案轮廓完全没有显示主相机未开启 DepthNormals检查 OutlineEffect 中 depthTextureMode在 OnEnable 设置DepthTextureMode.DepthNormals后处理材质全黑目标深度 RT 未传入材质在 LateUpdate 后检查_GhostDepthTex是否有值在 OnRenderImage 前重新设置材质纹理轮廓噪点很多edgeThreshold 太小调大阈值将几个采样方向的深度差改成线性深度做差阈轮廓断裂目标模型法线变化平缓深度差不够增大法线差分权重改用多方向边缘检测或增加一次膨胀目标被挡住时没有 ghost 效果场景深度和目标深度比较条件错误打印场景深度和目标深度检查遮挡判断的符号目标物体在墙后大面积变亮ghostMask 全区域参与混合在 frag 中加 finalEdge 限制只对边缘像素做混合URP 下没有效果URP 不支持 OnRenderImage查看 URP 资产配置改用 Renderer Feature Blit独立深度相机渲染了场景其他物体Culling Mask 配置不对检查目标相机只勾选 GhostTarget 层移动端性能不足全屏多采样次数太多降低目标 RT 分辨率将 IsEdge 改成采 8 点并降半率对于移动端深度法线全屏采样压力明显建议打包真机测试。若压力过高可以优先缩小 GhostDepthRT 或改成隔帧更新。11. URP 迁移与扩展方向11.1 OnRenderImage 的限制URP 移除了 OnRenderImage不能再像 Built-in 一样直接挂脚本完成全屏后处理。迁移时需要把后处理写到 ScriptableRendererFeature 或 RenderPass 中。基本思路创建ScreenSpaceOutlineRenderFeature在Execute中获取 camera depth texture使用 CommandBuffer 执行一次Blit把源 RT 传到临时 RT再用材质 Blit 到目标 RT目标深度 RT 可以在自定义 Pass 中通过一个自定义相机渲染11.2 Renderer Feature 核心结构URP 项目里新建一个OutlineRenderFeature.cspublic class OutlineRenderFeature : ScriptableRendererFeature { public Material outlineMaterial; public LayerMask targetLayer; public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { renderer.EnqueuePass(new OutlineRenderPass(outlineMaterial, targetLayer)); } }真正的逻辑在ScriptableRenderPass.Execute中。核心点有两个从renderingData.cameraData.camera获取目标深度 RT。使用OutlineRenderPass在当前相机渲染完成后对cameraColorTarget执行 Blit。不同 URP 版本的 API 有差异例如ScriptableRenderer.cameraColorTarget在部分版本已标记为 obsolete需要按版本换成RenderTargetIdentifier。11.3 进一步扩展方向描边渐变消除让越靠近屏幕边缘的轮廓透明度越低。多层目标优先级把目标分成队友、敌人、道具三层分别输出不同颜色。与 UI 指示器组合当目标物完全不在屏幕内时只显示屏幕边缘箭头。改成 CommandBuffer 方案去掉独立相机用CommandBuffer.DrawRenderer绘制目标深度避免反复开关相机。12. 效果验收检测清单部署完成后建议按下面清单快速验证[ ] 主相机能看到目标物体的正常轮廓描边[ ] 目标物体完全被墙遮挡时边缘出现半透明 Ghost 色[ ] 无目标物体区域保持原画面不闪轮廓[ ] 多个目标物体同时存在时都能正常显示[ ] 动态修改目标 Layer 能实时生效[ ] 关闭 OutlineEffect 后渲染恢复正常[ ] GhostDepthRT 分辨率调整后性能符合预期[ ] 移动端目标帧率下无严重发热或崩溃关于资源的合规使用同样需要注意这套描边和透视效果本身不涉及版权文件但项目中的角色模型、贴图、动作素材需要确保均在授权范围内使用。如果做多人对战或协同功能涉及玩家对象、队伍可见性时一定要处理好信息边界避免把这种透视能力扩展到不合规的作弊场景。功能本身是技术手段能用在任务指引也能被滥用工程落地时需要在产品规则层面对齐边界。如果后续要继续优化最值得投入的方向不是继续堆后处理采样点而是把整套流程迁移到 ScriptableRenderPipeline 的 RenderPass 体系里。这样画面质量、目标独立渲染、以及不同平台的多相机管理都会更加可控。先用当前方案验证效果等项目需求稳定后再做管线迁移和性能压测是比较务实的推进顺序。