我先把结论放在前面:遮挡高亮这个需求,在URP里如果只在“场景逻辑”层面想,比如用射线检测墙后面有没有目标,再决定要不要显示,那多半会陷入没完没了的调参地狱。我自己在项目里试过好几套,最后稳定的方案还是渲染层面解决——Stencil标记加RenderFeature后处理。这篇文章把这套方案的原理、代码、坑位一次讲清楚,适合正在用URP开发带有战术反馈、寻路提示、收集品指引等功能的同学参考。
先简单交代这个需求的典型场景:第三人称视角下,你要让玩家隔着墙壁也能看到某个重要目标的位置,但又不能让它整个透出来,否则视觉上会跟半透明幽灵一样。需求是“半隐半现,只描轮廓”。听起来不难,但真正做的时候会发现一个矛盾——如果直接把目标设为半透明材质,那么墙壁也会被透视,目标跟背景混在一起;如果只是靠UI箭头指引,又没有“空间感”。所以核心诉求是:既要保留深度关系,又要在目标被遮挡时给出轮廓提示。
1. 遮挡高亮的本质:你需要的不是“透视”,而是“标记可见性”
1.1 先搞清楚“可见”和“被挡住”在渲染里到底是什么
在Unity里,一个物体是否显示、怎么显示,本质上由渲染状态决定,而不是由“物理遮挡”这个逻辑决定。你想判断“这个敌人是否在玩家视野中”,可以靠射线、视锥体、遮挡查询,但这些方案都有一个通病:它们只是“近似判断”。射线只能判断某个点,视锥体只能判断大致区域,而真正的美术效果需要逐像素级别的深度信息。
而渲染管线恰恰在最后阶段会生成一张深度缓冲(Depth Buffer),每个像素记录了离相机最近的物体深度。如果一个目标物体被墙挡住,那么它在屏幕上的像素位置,深度值会被墙覆盖。我们要做的就是利用这张深度图,把“本来应该渲染但是被别的物体挡住的像素”找出来,并且只给这一部分描边或高亮。
这就引出了整个方案的第一块基石:不要在C#脚本里做逐帧循环射线检测来决定显示哪个UI,而是把“深度关系”作为后处理的输入,让显卡帮我们做逐像素判断。
1.2 为什么直接调RenderQueue顺序解决不了问题
有些人第一反应是:把高亮物体的材质改成透明,把渲染队列调到墙壁之后,再用深度写入做区分。我在初版方案里试过类似思路,结果发现透明物体在URP里受排序和RenderQueue影响很大,而且半透明物体默认不写深度,你很难稳定拿到“被遮挡的轮廓”。就算强行开启ZWrite,也经常出现和自己同屏排序的穿插闪面。
透明混排方案还有另一个麻烦:假设目标物体本身是正常的不透明渲染,它的颜色和深度已经写入了。你要把它的轮廓“额外画一层”,必须让额外那一层知道“我现在画的是目标身上被遮挡的部分”,这就需要标记机制。
此时Stencil入场:在渲染目标之前,我先给目标物体的像素写一个标记值(比如1),然后所有墙面、其它障碍物照常渲染。由于墙壁的深度值离相机更近,深度测试会把墙壁写入最终的深度缓冲,同时这也就意味着墙上并不存在目标的标记值。而后处理阶段读取像素的Stencil值,就能精确区分出:哪些像素仍然是目标标记值=1,说明这个像素此时露在外面;哪些像素墙壁已经覆盖,说明目标被挡了。
2. 方案选型评估:为什么是Stencil + RenderFeature,而不是其它组合
这一节把我在早期踩过的几条路拉出来对比,不是为了否定它们,而是帮你理解这套组合为什么值得投入。
先声明:遮挡高亮方案不是唯一解,甚至不是“绝对最优解”,但它在URP下的性价比和稳定性非常突出。
2.1 把各家方案放在同一个需求下对比
需求是:目标被任意物体遮挡后,轮廓高亮;没被遮挡时,可以正常显示或半透明高亮;不允许使用额外相机再叠加屏幕纹理;性能不能崩。
第一个方案:额外相机渲染目标到RenderTexture,再叠加到全屏。
这个方案可行,但是很耗。多一个相机就多一次完整场景遍历,对于移动端和密集场景是笔不小的开销。而且叠加到屏幕时还要处理分辨率、HDR、透明度混合顺序,排查问题时的变量很多。我做了一个prototype就放弃了,因为场景里如果同时有5个高亮目标,每一帧都要多渲染一次全场景,GPU时间肉眼可见地往上跳。
第二个方案:遮挡查询(OcclusionQuery)加UI指示。
这个方案开销低,但你只能得到一个二值结果:“被挡了”还是“没被挡”。它没法告诉你目标轮廓的哪一段被挡住了,也无法做到“露出一半时只描遮挡的一半”这种相对精细的效果。更麻烦的是,挂点的选取直接决定结果,你挂在敌人的脚底和头顶,结果是不同的。
第三个方案:纯Shader用深度比较。
比如在目标物体的Shader里计算该像素和主深度纹理的差别,然后让美术调参。这种方法需要每个物体的材质都接入这套逻辑,而且物体多了以后,Shader变体组合会膨胀,维护成本很高。
Stencil + RenderFeature的方案则把渲染战线拉长成三个可复用的阶段:
- 目标物体先正常或不透明渲染,同时写入Stencil标记;
- 普通场景照常渲染,深度和颜色互不干扰;
- 屏幕后处理阶段,读取Stencil值,做边缘检测和着色。
值得说的是,这套方案和后处理结合得非常好,你不用改目标物体的原有材质,只要在它身上单独叠一层高亮Shader,或者用材质的Stencil参数做区分即可。真正的描边逻辑全部集中在后处理Pass里,美术改一个参数就能控制整批物体的效果。
2.2 URP里为什么要专门写RenderFeature
如果你以前用的是内置渲染管线,可能会习惯性想到OnRenderImage或OnRenderObject,但在URP下这些老接口大部分已经失效。URP用ScriptableRendererFeature和ScriptableRenderPass来取代之前的渲染回调,目的是把渲染流程变成可配置、可插入的节点。
这个转变的代价是学习成本,但收益也很直接:你可以把“写Stencil标记”和“后处理描边”分别对应到两个RenderFeature,然后在URP Renderer配置里拖动它们的执行顺序,让美术和TA都能看到流程。
用RenderFeature也能确保它只在本项目的URP资产中被引用,不影响别的场景或别的渲染管线。未来如果升级管线版本,所有逻辑都集中在pass代码中,排查范围非常可控。
3. 核心实现:从RenderFeature到三个Pass的完整搭建
下面进入正题。我会按自己项目中的实际路径来写,不是单纯的伪代码集合。你需要先有一个可用的URP项目,版本建议用熟悉的10.x以上版本,我这里以URP 12(Unity 2021 LTS)为例,更高版本API基本兼容。
3.1 先建好控制高亮状态的RenderFeature
在Assets里创建StencilHighlightFeature.cs:
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class StencilHighlightFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public RenderPassEvent injectionPoint = RenderPassEvent.AfterRenderingOpaques; public Material highlightMaterial; public Material edgeDetectMaterial; public string stencilTag = "StencilHighlight"; public bool showWireframe; } public Settings settings = new Settings(); private StencilMarkPass _markPass; private StencilEdgePass _edgePass; public override void Create() { _markPass = new StencilMarkPass(settings, RenderPassEvent.AfterRenderingOpaques); _edgePass = new StencilEdgePass(settings); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (!renderingData.cameraData.isSceneViewCamera) { renderer.EnqueuePass(_markPass); renderer.EnqueuePass(_edgePass); } } }这里我做了两个Pass:一个用于给物体写入Stencil标记,一个用于后处理读取并描边。把它们分开的好处是后续出问题时可以单独关掉其中一个Pass来定位。
3.2 标记Pass:把目标物体的像素“盖个章”
接下来写StencilMarkPass,它的职责是在目标物体渲染之后,把它们的Stencil值写入到当前相机使用的颜色缓冲和模板缓冲中。这里有一个关键细节:这个Pass不做颜色混合,因为我们只是想给某个物体对应的屏幕区域做标记,如果混合了颜色,就会干扰后续渲染顺序。
public class StencilMarkPass : ScriptableRenderPass { private Settings _settings; private ProfilingSampler _profilingSampler; public StencilMarkPass(Settings settings, RenderPassEvent evt) { _settings = settings; renderPassEvent = evt; _profilingSampler = new ProfilingSampler("StencilMark"); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, _profilingSampler)) { CoreUtils.SetRenderTarget(cmd, renderingData.cameraData.renderer.cameraColorTargetHandle, renderingData.cameraData.renderer.cameraDepthTargetHandle); // 这里调用一个共用的绘制方法:找出所有带 StencilHighlight 标签的 Renderer var highlightRenderers = HighlightManager.Instance?.GetHighlightRenderers(); if (highlightRenderers != null) { foreach (var renderer in highlightRenderers) { var mpb = new MaterialPropertyBlock(); mpb.SetFloat("_StencilRef", _settings.stencilRef); cmd.DrawRenderer(renderer, _settings.highlightMaterial, 0, 0); } } } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }我建议你在项目里用一个HighlightManager来管理当前所有需要高亮的Renderer,这样既能恢复状态,又能方便美术在编辑器里直观地勾选哪些物体是高亮目标。如果是小型项目,也可以直接在Inspector里手动拖引用,但那样后期维护会有点乱。
标记用的材质就是普通的URP Shader,但它的Pass里要开启Stencil写入:
Pass { Name "StencilMark" Cull Off ZWrite On ZTest LEqual Stencil { Ref [_StencilRef] Comp Always Pass Replace } HLSLPROGRAM #pragma vertex vert #pragma fragment frag // 基础顶点片段着色器 ENDHLSL }注意这个Pass不要做任何颜色输出,可以让Fragment返回半透明白色,并把Blend因子设为0,最终像素颜色不变。只用Stencil做标记。
3.3 利用深度缓冲区分可见与遮挡
标记Pass完成之后,场景中其它不透明物体继续渲染,这时候深度缓冲已经被墙面等物体覆盖。而后处理Pass要做的是:再次绘制一个全屏三角形,在片段着色器里读取当前像素的Stencil值,如果值等于目标标记值,说明“该像素属于目标物体且此时它位于所有不透明物体的最前方”;如果值不等于目标标记值,说明“该像素处本来有目标物体,但被更近的物体遮挡”。
这个思路看着简单,但需要你在渲染顺序上保证一点:标记Pass必须在所有不透明渲染之前把所有高亮物体的像素都过一遍。如果目标物体被普通场景物体挡住,标记值时会被深度测试淘汰吗?
实际上,我建议标记Pass的ZTest设为LEqual,并且把ZWrite设置为关闭或开启视情况而定。标准做法是让标记Pass使用和普通不透明渲染相同的深度测试,但通过关闭颜色写入,来纯写Stencil。这时即便目标在墙后面,只要它在渲染顺序中先于墙执行,也会把墙后面的那些像素标记成目标,然后在墙体渲染时,墙体本身并不写Stencil,所以模板缓冲里保留的仍然是目标像素标记。深度缓冲被墙覆盖后,后处理阶段只要不依赖深度,而依赖Stencil,就等于拿到了“墙后面那部分轮廓”的位置。
3.4 边缘检测后处理Pass:只高亮“被挡住的那一圈”
边缘高亮其实就是后处理的边缘检测。常用做法有两种:Sobel算子和Roberts算子。考虑到性能,我用了Sobel的简化版,只对Stencil值做4邻域比较。如果一个像素自身不是边缘,但它的上下左右中至少有一个方向Stencil值不同,就认定它是边缘。
// 后处理Pass着色器核心代码 float4 Frag(Varyings input) : SV_Target { float2 uv = input.uv; uint center = GetStencil(uv); uint up = GetStencil(uv + float2(0, _TexelSize.y)); uint down = GetStencil(uv + float2(0, -_TexelSize.y)); uint left = GetStencil(uv + float2(-_TexelSize.x, 0)); uint right = GetStencil(uv + float2(_TexelSize.x, 0)); bool isEdge = (center != up) || (center != down) || (center != left) || (center != right); float edgeFactor = isEdge ? 1.0 : 0.0; return _EdgeColor * edgeFactor; }但这里有个细节要是你直接读当前相机DepthTexture,会得到墙的深度,而不是目标物体的深度。所以不要把Stencil信息合并到深度纹理里,在URP环境下,Stencil是跟着DepthTexture的,所以我们要在RenderPass里先将当前DepthRT的Stencil拿出来,或者使用带有Stencil的临时RT。
我这边的做法是,在后处理Pass执行前,先Blit一份颜色缓冲,然后在新的RT上使用Shader绘制“边缘”,再Blit回屏幕。这里的关键是保证目标RT和模板缓冲都处于有效状态,否则你在Fragment里拿到的Stencil永远是0。
4. 边缘Pass的Shader代码与参数调优
下面给出我在项目里实际用的Shader草稿,方便你直接改,这段代码做了三件事:读模板值、算边缘、混合颜色。我把计算边缘的逻辑保留成可调的三档强度,方便在不同分辨率和物体大小下快速调参。
Shader "URP/StencilHighlightEdge" { Properties { _EdgeColor ("Edge Color", Color) = (1, 0.8, 0, 1) _EdgeWidth ("Edge Width", Range(1, 5)) = 1 _StencilRef ("Stencil Ref", Int) = 1 _HighlightMode ("0: Occluded Only, 1: Full Outline", Float) = 0 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } Pass { ZWrite Off Cull Off Blend SrcAlpha OneMinusSrcAlpha HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; TEXTURE2D_X(_CameraOpaqueTexture); SAMPLER(sampler_CameraOpaqueTexture); float4 _EdgeColor; float _EdgeWidth; int _StencilRef; float _HighlightMode; Varyings vert(Attributes input) { Varyings output; output.positionCS = TransformObjectToHClip(input.positionOS.xyz); output.uv = input.uv; return output; } uint ReadStencil(int2 pixelPos) { // 这里假设在RenderPass中已经将Stencil拷贝到StencilTexture return _StencilTexture[pixelPos]; } float4 frag(Varyings input) : SV_Target { float2 screenPos = input.positionCS.xy; int2 coord = int2(screenPos.x, screenPos.y); int2 offset = int2(_EdgeWidth, _EdgeWidth); uint c = ReadStencil(coord); uint up = ReadStencil(coord + int2(0, offset.y)); uint down = ReadStencil(coord - int2(0, offset.y)); uint left = ReadStencil(coord - int2(offset.x, 0)); uint right= ReadStencil(coord + int2(offset.x, 0)); float edge = 0; // 经典Sobel权重 edge += abs((float)c - (float)up) * 0.6; edge += abs((float)c - (float)down) * 0.6; edge += abs((float)c - (float)left) * 0.4; edge += abs((float)c - (float)right) * 0.4; float mask = _HighlightMode > 0.5 ? ((c == _StencilRef) ? 1.0 : 0.0) : edge; float3 srcColor = SAMPLE_TEXTURE2D_X(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, input.uv).rgb; float3 finalColor = lerp(srcColor, _EdgeColor.rgb, saturate(mask * _EdgeColor.a)); return float4(finalColor, saturate(mask * _EdgeColor.a)); } ENDHLSL } } }说实话这段Shader必须在RenderPass中配合一个名为_StencilTexture的纹理,我在实际工程里的做法是在C#里先用GetTemporaryRT建一张带有模板缓冲的RT,然后把当前相机的深度模板拷贝进去。StencilTexture在Shader中无法直接引用Unity的内置DepthTexture,必须在Pass中通过cmd.SetGlobalTexture手动注入。
如果你不想纠结这张纹理,也可以用替代方案:在后处理前,从相机深度模板读出来,转成一张R8的Stencil Mask纹理,再传给Shader。这需要一次像素格式转换,开销相对可控,但会多一个DrawCall。
5. 踩坑记录与性能优化建议
这部分才是真正能帮你省时间的。下面每一条都是我在实际项目中调试过的。
5.1 为什么做了半天,后处理屏幕全黑?
最常见的原因是从透明物体或后处理排序导致的Stencil缓冲丢失。URP默认的屏幕后处理会重新设置RenderTarget,如果你直接使用原相机RT而它没有附带模板缓冲,那么后处理读Stencil永远是0。检查方法很简单:在RenderPass执行前和Blit之后,分别用一个Fragment把Stencil值输出为灰度看看,如果全黑,说明RT设置不对。
5.2 边缘在物体露出的部分也显示,导致画面很脏
如果整个目标都清晰可见时还有一圈描边,这不是Bug,而是需求定义问题。你可以通过_HighlightMode参数控制是只对遮挡边缘高亮,还是对整物体轮廓高亮。如果只想在遮挡时显示边缘,那么边缘检测后还要再跟深度比较一次,确保当前像素确实被其它更近的物体覆盖。做法是在后处理Pass里采样相机深度纹理,如果目标物体的深度与当前深度值之差大于某个阈值,才判定为“被遮挡”。
5.3 移动端性能:边缘检测的采样开销
Sobel边缘检测本身要采样周围4个点,如果再做全屏,开销会被放大。我的经验是:
- 先在低分辨率RT上做边缘检测,再上采到原分辨率;
- 高亮物体数量不多时,可以只在目标物体BoundingBox范围内做局部后处理,而不是全屏;
- 用
RenderPassEvent.BeforeRenderingPostProcessing注入边缘Pass,避免和Bloom等后处理抢占带宽。
另外一个容易被忽略的点:如果场景中大量物体带有极高细节纹理,后处理的边缘检测会把这些纹理上的高频细节也当成边缘,干扰遮挡边缘。因此,边缘检测要基于Stencil值而不是颜色值或深度值,这也是为什么我用Stencil做标记,而不是依赖深度图做差分。
5.4 HighlightManager 管理的高亮对象销毁后Stencil残留
如果高亮对象被销毁或禁用,但Stencil标记没有及时清除,那么这批像素仍会残留描边。解决方案是提供一个ClearAll()方法,在切换场景时把Stencil值重置为0。这个细节真的会导致线上Bug,我当时排查了很久才发现是场景切换时累积的模板缓存没清。
5.5 URP版本升级后API变化
Unity 2022以上的URP版本中,ScriptableRenderer.cameraColorTarget已经过时,改成了cameraColorTargetHandle,我的示例代码已经用了新版本写法。如果你还在用老版本,编译会报错,把句柄换成原来的属性名即可。另外,从URP 14开始,CoreUtils.SetRenderTarget对模板缓冲的处理也发生了变化,要注意目标格式中必须包含模板位。
6. 扩展:让这套遮挡高亮用得更“高级”
做完基本的遮挡边缘高亮之后,其实还可以继续在这个框架上叠加不少玩法式效果,不需要大改。
敌人血条与身份标识。把Stencil标记值扩展为多个,例如1号标记代表敌人,2号标记代表队友。后处理Pass里用不同颜色和脉冲速度对不同的Stencil值做差异化显示。这样玩家在墙外一眼就能分清敌我。
交互物提示。可拾取道具、任务里程碑同样可以被标记。美术只需要在对应材质上开一个Keyword,就能让这些物体在屏幕外时显示为柔和的轮廓。
相机镜头内的“半遮挡”渐变。如果目标只露出一半,你可以通过比较目标自身深度和场景深度,计算遮挡百分比,再让边缘颜色的透明度随遮挡比例渐变。这样比单纯的二值描边要自然很多。
多相机支持。如果你的项目有主相机和UI相机,需要注意RenderFeature的执行条件。我建议在AddRenderPasses中判断renderingData.cameraData.cameraType,只对CameraType.Game相机执行。否则Scene视图预览时会干扰编辑器操作。
Mask纹理控制显示区域。某些情况下,玩家并不希望屏幕边缘所有角度都出现提示。可以额外传入一张屏幕空间Mask纹理,让靠近屏幕中心或屏幕边缘的区域屏蔽高亮提示,提升视觉品质。
7. 最后再说一点心得体会
这套Stencil + RenderFeature的方案真正稳定的原因,在于它把问题分成了“标记”和“消费标记”两个独立阶段,避开了以往很多Shader里为了单次渲染做大量分支判断的局面。它的主要成本集中在一次后处理全屏Pass,以及标记Pass的额外渲染遍数上。实测在移动端大概额外占用1.0ms到1.5ms(1080P,边缘检测低分辨率),这个数字完全在接受范围内。
从项目维护角度讲,我也比较推荐把Stencil基础标记位和一整套后处理逻辑沉淀成通用工具,后续无论做拾取物提醒、怪物轮廓还是机关指引,都能直接复用。只要在ShderGraph或手写Shader里预留一个Stencil参数,然后在HighlightManager里注册一下,整个流程就能转起来。
如果你正在URP项目里折腾类似的遮挡高亮,建议先把“纯Stencil标记+后处理边缘”这条路走通,再去碰复杂的分辨率适配和局部后处理。这套基底打牢之后,无论接什么新需求,方向上都不会走偏。