2026/8/5 23:14:01

Unity URP角色遮挡高亮:5分钟集成自定义Shader与Render Feature方案

Unity URP角色遮挡高亮:5分钟集成自定义Shader与Render Feature方案 1. 项目概述为什么角色遮挡高亮是刚需在开发第三人称或俯视角游戏时一个让无数开发者头疼的经典问题就是当你的角色走到墙后、树后或者任何大型物体后面时玩家的视线被完全挡住了。想象一下你正操控着心爱的英雄在迷宫中穿梭一个拐弯角色“消失”在了墙后你只能对着屏幕干瞪眼凭感觉乱按方向键——这种体验简直糟透了。角色遮挡高亮Outline/Highlight when Occluded效果就是为了解决这个“找不着北”的痛点而生的。它的核心逻辑很简单当角色被场景中的其他物体遮挡时自动在遮挡物的边缘通常是靠近摄像机的一侧勾勒出角色的轮廓或者让角色本身以一种高亮、半透明的方式显示出来确保玩家始终能知道“我的角色在哪”。这个效果在MOBA、ARPG、RTS等类型的游戏中几乎是标配。过去实现这个效果可能需要依赖后处理、渲染纹理或者比较复杂的Shader编写让不少初学者望而却步。但在Unity的URPUniversal Render Pipeline管线中借助其可编程的渲染管线特性我们可以用一种更清晰、更高效的方式来实现它。今天要分享的就是一个能在5分钟内快速集成到你的URP项目中的实战方案。它不依赖任何第三方插件核心就是一个自定义的Shader配合简单的C#脚本进行状态管理。我会把完整的Shader代码贴出来并逐行解释其原理让你不仅能用更能懂。无论你是Shader新手还是想优化现有方案的开发者这篇文章都能给你带来直接的帮助。2. 核心思路与方案选型为什么选择自定义Render Feature在动手写代码之前我们先来拆解一下实现这个效果有哪些常见的路子以及为什么我最终选择了“自定义Shader URP Render Feature”这个组合拳。2.1 常见实现方案对比方案一后处理轮廓描边这是比较传统的一种思路。通常需要两个Pass第一个Pass将角色渲染到一张单独的纹理中并记录其深度或ID第二个Pass后处理在全屏范围内检测角色纹理与场景深度图的差异在边缘处进行采样并绘制轮廓。这种方案的优点是效果稳定可以做出发光、模糊等丰富的轮廓效果。但缺点也很明显性能开销较大多了一次全屏采样并且当角色完全被遮挡时其信息可能已经从深度图中被覆盖导致无法正确描边。方案二动态修改材质球另一种思路是当检测到角色被遮挡时动态地将角色身上所有材质球切换成一个特定的“高亮”材质。这个高亮材质通常是透明的或者带有自发光。这种方法实现简单但问题在于材质切换可能带来Draw Call的波动并且对于Skinned Mesh Renderer带骨骼动画的角色来说切换材质可能不够平滑也可能影响合批。方案三CommandBuffer与额外摄像机使用CommandBuffer指令在渲染完不透明物体之后、透明物体之前用一个额外的摄像机只渲染角色并指定一个特殊的替换ShaderReplacement Shader将角色渲染成轮廓颜色。这个方法非常灵活但需要手动管理CommandBuffer的生命周期和渲染顺序在URP中与SRP的渲染流程集成起来稍显繁琐。2.2 我们的选择URP Render Feature 自定义ShaderURP的Render Feature渲染特性机制为我们提供了一个完美契合此需求的“插槽”。你可以把它理解为一个可编程的渲染阶段插件。我们可以在URP的渲染流程中插入一个自定义的Render Feature让它专门负责在某个时机比如在所有不透明物体渲染之后去渲染我们的角色并且使用我们自定义的、专门用于绘制遮挡高亮的Shader。这个方案的优势非常突出性能可控只有被标记为需要高亮的角色才会进入这个特殊的渲染流程不会影响场景中其他物体的渲染。效果精准我们可以完全控制这个特殊Pass的渲染状态比如深度测试、混合模式确保轮廓只出现在被遮挡的部分。与URP深度集成遵循URP的框架代码结构清晰易于管理和移植。实现简洁核心逻辑集中在Shader和一个小小的Feature脚本中不涉及复杂的全局后处理或材质动态切换。我们的实现流程图可以简单概括为一个C#脚本挂在角色上负责判断“我是否被遮挡了”。如果被遮挡这个脚本通知一个全局管理器或直接设置材质参数激活高亮状态。URP渲染流程中我们的自定义Render Feature捕获到这个状态在正确的时机用自定义Shader再画一遍角色。自定义Shader的核心技巧是使用“深度测试 Greater”或特定的混合模式让这次绘制只出现在角色被其他物体挡住的地方即深度值更大的片段。注意这里说的“5分钟”是指在你已经有一个URP项目的基础上集成这套代码并看到效果的时间。对于Shader和URP渲染流程完全陌生的朋友理解原理可能需要更长的时间但跟着步骤做实现效果绝对是可以快速达成的。3. 完整实现步骤拆解接下来我们进入实战环节。我会将整个过程拆解为三个核心部分创建高亮Shader、编写控制脚本、配置URP Render Feature。3.1 第一步编写核心高亮Shader这是整个效果的灵魂。我们在URP中创建一个Unlit Shader因为它不需要复杂的光照计算只负责输出纯色高亮。关键点在于对深度缓冲Depth Buffer的操作。// 文件名OcclusionHighlight.shader Shader Custom/OcclusionHighlight { Properties { _HighlightColor (Highlight Color, Color) (1, 0.5, 0, 1) // 默认橙色 _HighlightWidth (Highlight Width, Range(0, 0.1)) 0.02 } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline QueueGeometry1 // 确保在普通不透明物体之后渲染 } Pass { Name OcclusionHighlight Tags { LightModeUniversalForward } // 使用URP前向渲染路径 // 核心渲染状态设置 ZWrite Off // 关闭深度写入我们不希望这次绘制影响深度缓冲 ZTest Greater // 深度测试模式仅当片段的深度值**大于**深度缓冲中的值时即被挡住了才通过测试 Blend SrcAlpha OneMinusSrcAlpha // 使用Alpha混合让高亮与背景融合 HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionHCS : SV_POSITION; float3 normalWS : TEXCOORD0; }; CBUFFER_START(UnityPerMaterial) float4 _HighlightColor; float _HighlightWidth; CBUFFER_END Varyings vert(Attributes IN) { Varyings OUT; // 将模型空间顶点位置转换到齐次裁剪空间 VertexPositionInputs positionInputs GetVertexPositionInputs(IN.positionOS.xyz); OUT.positionHCS positionInputs.positionCS; // 将法线从模型空间转换到世界空间用于后续可能的轮廓膨胀 VertexNormalInputs normalInputs GetVertexNormalInputs(IN.normalOS); OUT.normalWS normalInputs.normalWS; return OUT; } half4 frag(Varyings IN) : SV_Target { // 最简单的实现直接返回高亮颜色 // _HighlightColor.a 控制了透明度可以用于淡入淡出 return _HighlightColor; } ENDHLSL } } }Shader关键点解析QueueGeometry1这个标签决定了Shader在渲染队列中的顺序。Geometry是默认的不透明物体队列。我们设为Geometry1是为了确保这个Pass在所有常规不透明物体之后渲染这样深度缓冲中已经存储了遮挡物的深度信息。ZWrite Off关闭深度写入。这是至关重要的一步我们这次渲染的目的只是为了“显示”高亮而不是要更新深度缓冲。如果打开了深度写入这个高亮绘制会覆盖掉后面物体的深度导致视觉错误。ZTest Greater这是实现“仅在遮挡处显示”的魔法指令。深度测试默认是Less深度值更小离相机更近的片段通过。我们设置为Greater意味着只有当这个片段的深度值大于深度缓冲中已有的值时即它位于某个已经画出来的物体后面这个片段才会被渲染。换句话说角色没被挡住的部分深度值小不会通过测试只有被挡住的部分深度值大才会被画上高亮色。Blend SrcAlpha OneMinusSrcAlpha启用Alpha混合。这样我们可以通过调整_HighlightColor的Alpha通道a值来控制高亮的透明度实现平滑的淡入淡出效果。实操心得_HighlightWidth属性在这个基础版本中其实没有用到。更高级的实现中我们可以在顶点着色器里沿着顶点法线方向将顶点“膨胀”一点点IN.positionOS.xyz IN.normalOS * _HighlightWidth;这样画出来的轮廓会稍微比模型大一圈形成描边效果避免高亮色与模型原色完全重叠。初学者可以先忽略实现基础功能后再尝试添加。3.2 第二步编写角色遮挡检测与控制脚本Shader准备好了我们需要一个脚本来告诉系统“什么时候该画高亮”。这个脚本挂在需要高亮效果的角色比如玩家角色上。// 文件名OcclusionHighlightController.cs using UnityEngine; using UnityEngine.Rendering; [RequireComponent(typeof(Renderer))] public class OcclusionHighlightController : MonoBehaviour { [SerializeField] private Color _highlightColor new Color(1f, 0.5f, 0f, 0.7f); // 默认半透明橙色 [SerializeField] private float _checkInterval 0.1f; // 检测间隔避免每帧检测 private Renderer _renderer; private MaterialPropertyBlock _propBlock; private Camera _mainCamera; private float _timer; private bool _isOccluded; // 一个简单的基于视锥体和射线检测的遮挡判断 public bool IsOccluded { get _isOccluded; private set { if (_isOccluded ! value) { _isOccluded value; UpdateHighlightState(); } } } void Start() { _renderer GetComponentRenderer(); _propBlock new MaterialPropertyBlock(); _mainCamera Camera.main; // 初始状态 UpdateHighlightState(); } void Update() { _timer Time.deltaTime; if (_timer _checkInterval) { _timer 0f; CheckOcclusion(); } } void CheckOcclusion() { if (_renderer null || _mainCamera null) return; // 方法1简单的视锥体与渲染器包围盒测试快速但不精确 // 如果渲染器在相机视锥体内但不可见被遮挡Unity的渲染器.isVisible可能为false。 // 但.isVisible也受裁剪影响。这里我们用一个更直接的方法。 // 方法2从相机向角色包围盒中心发射射线更常用 Vector3 center _renderer.bounds.center; Vector3 dir (center - _mainCamera.transform.position).normalized; float distance Vector3.Distance(_mainCamera.transform.position, center); // 发射射线忽略自身图层 int layerMask ~(1 gameObject.layer); // 忽略角色自己的图层 if (Physics.Raycast(_mainCamera.transform.position, dir, out RaycastHit hit, distance, layerMask)) { // 如果打到了别的物体说明被遮挡 IsOccluded true; } else { // 没打到或者打到的就是自己已被忽略说明未被遮挡 IsOccluded false; } } void UpdateHighlightState() { if (_renderer null) return; _renderer.GetPropertyBlock(_propBlock); // 我们通过MaterialPropertyBlock传递一个开关和颜色给Render Feature // 这里用一个自定义的Shader属性名例如“_IsHighlighted” // 注意这个属性不是给上面创建的OcclusionHighlight.shader用的而是给一个“中间材质”或通过Render Feature传递参数用的。 // 更常见的做法是将状态传递给一个管理器由Render Feature从管理器读取。 // 为了简化我们这里假设有一个全局静态类来管理。 HighlightManager.Instance.SetObjectState(this, _isOccluded, _highlightColor); // 或者更直接地我们可以控制一个用于高亮渲染的额外Renderer的显隐。 // 这部分逻辑需要与你的Render Feature实现方式配合。 } // 提供给Render Feature获取状态和颜色的方法 public bool GetOcclusionState() _isOccluded; public Color GetHighlightColor() _highlightColor; }脚本逻辑解析遮挡检测CheckOcclusion方法使用了从摄像机指向角色中心的射线检测。如果射线在碰到角色之前先碰到了其他物体通过layerMask排除了角色自身则判定为被遮挡。这是一种性能与精度折中的方法。对于复杂形状的角色你可以选择发射多条射线到不同的关键点如头、胸、脚进行综合判断。状态更新我们使用MaterialPropertyBlock来动态修改材质属性这是一种高效的方式避免了直接修改材质球实例导致的内存增加。但这里更关键的是将状态是否被遮挡、高亮颜色传递出去。我示例中提到了一个HighlightManager这是一个设计模式的选择。你可以实现一个简单的单例管理器让Render Feature去查询所有注册角色的状态。性能优化通过_checkInterval如0.1秒来控制检测频率避免每帧都进行射线检测这对于大量角色的场景很重要。3.3 第三步创建URP Render Feature这是连接URP渲染流程和我们自定义Shader的桥梁。我们需要创建一个继承自ScriptableRendererFeature的类。// 文件名OcclusionHighlightRenderFeature.cs using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class OcclusionHighlightRenderFeature : ScriptableRendererFeature { // 定义一个设置类方便在Inspector中调整 [System.Serializable] public class FeatureSettings { public Material highlightMaterial; // 引用我们创建的OcclusionHighlight材质球 public LayerMask occlusionLayerMask -1; // 指定哪些层级的物体会触发遮挡高亮 } public FeatureSettings settings new FeatureSettings(); // 自定义的Pass class OcclusionHighlightPass : ScriptableRenderPass { private Material _material; private LayerMask _layerMask; private ListOcclusionHighlightController _highlightObjects; private FilteringSettings _filteringSettings; // 构造函数接收参数 public OcclusionHighlightPass(Material material, LayerMask layerMask) { _material material; _layerMask layerMask; _highlightObjects new ListOcclusionHighlightController(); // 配置渲染队列和层级过滤 _filteringSettings new FilteringSettings(RenderQueueRange.opaque, layerMask); renderPassEvent RenderPassEvent.AfterRenderingOpaques; // 关键在所有不透明物体渲染完成后执行 } // 每帧执行收集需要高亮的对象 public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor) { // 这里可以从你的管理器如HighlightManager获取所有需要高亮的对象 // 为了示例我们使用FindObjectsOfType性能较差仅用于演示生产环境应用对象池或管理器 var allControllers Object.FindObjectsOfTypeOcclusionHighlightController(); _highlightObjects.Clear(); foreach (var controller in allControllers) { if (controller ! null controller.GetOcclusionState()) { _highlightObjects.Add(controller); } } } // 执行渲染命令 public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (_material null || _highlightObjects.Count 0) return; CommandBuffer cmd CommandBufferPool.Get(OcclusionHighlight); using (new ProfilingScope(cmd, new ProfilingSampler(OcclusionHighlightPass))) { var camera renderingData.cameraData.camera; foreach (var controller in _highlightObjects) { var renderer controller.GetComponentRenderer(); if (renderer null) continue; // 为每个高亮对象设置材质属性如颜色 _material.SetColor(_HighlightColor, controller.GetHighlightColor()); // 绘制该渲染器使用我们指定的高亮材质 cmd.DrawRenderer(renderer, _material, 0, 0); // submeshIndex 0, shaderPass 0 } } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } private OcclusionHighlightPass _scriptablePass; // 初始化Feature创建Pass public override void Create() { if (settings.highlightMaterial null) { Debug.LogWarning(OcclusionHighlightRenderFeature: Highlight Material is not assigned.); return; } _scriptablePass new OcclusionHighlightPass(settings.highlightMaterial, settings.occlusionLayerMask); } // 将Pass添加到渲染器中 public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (settings.highlightMaterial null) return; renderer.EnqueuePass(_scriptablePass); } }Render Feature关键点解析RenderPassEvent.AfterRenderingOpaques这是本方案的核心之一。我们将这个Pass的执行时机设置在AfterRenderingOpaques。这意味着当URP渲染完所有不透明物体场景中的墙壁、地面、箱子等之后深度缓冲已经填充了这些遮挡物的深度信息。此时再执行我们的PassShader中的ZTest Greater才能正确判断出角色哪些部分被这些“先画上去”的物体挡住了。材质传递FeatureSettings中需要你手动拖入一个材质球这个材质球就是使用我们第一步编写的OcclusionHighlight.shader创建的。OcclusionHighlightPass在Execute方法中会使用这个材质来绘制所有需要高亮的角色渲染器cmd.DrawRenderer。对象收集在Configure或Execute方法中我们需要收集当前帧所有处于“被遮挡”状态的角色对象。示例中使用了FindObjectsOfType这在真实项目中是性能瓶颈。你应该实现一个管理器如HighlightManager让角色控制器在状态变化时主动注册/注销然后Render Feature从这个管理器中获取列表。属性设置在绘制每个角色前通过_material.SetColor动态地将该角色特有的高亮颜色传递给Shader。这样不同角色可以使用不同的高亮色。3.4 第四步在URP Asset中配置并应用创建材质在Project窗口右键Create - Material命名为“Mat_OcclusionHighlight”。将其Shader选择为我们创建的Custom/OcclusionHighlight。你可以在这里调整默认的_HighlightColor。创建Render Feature在Project窗口右键Create - Rendering - URP Renderer Feature如果找不到可以创建C#脚本但Unity新版通常有菜单。将其命名为“RendererFeature_OcclusionHighlight”。或者直接使用我们上面创建的OcclusionHighlightRenderFeature.cs脚本将其拖到Renderer Asset上。配置URP Renderer Asset打开你的URP项目设置通常是一个UniversalRP-HighQuality之类的Asset。找到Renderer List点击添加Renderer Feature。选择我们创建的OcclusionHighlightRenderFeature。在Inspector面板中将第一步创建的“Mat_OcclusionHighlight”材质球拖入Highlight Material槽位。设置Occlusion Layer Mask指定哪些图层上的物体需要这个效果通常是Player、Enemy等角色层。应用到角色将OcclusionHighlightController脚本挂到你的角色预制体或GameObject上。确保该角色所在的图层包含在Render Feature的Occlusion Layer Mask中。调整脚本上的Highlight Color为你想要的颜色。完成以上步骤后运行游戏。当你的角色走到一个立方体或墙壁后面时你应该能看到角色被遮挡的部分呈现出你设置的高亮颜色。4. 效果优化与高级技巧基础功能实现后我们来看看如何让它更完美、更高效。4.1 解决“闪烁”与“边缘锯齿”问题问题描述在移动时高亮边缘可能会出现闪烁Z-fighting或锯齿。原因分析闪烁通常是因为高亮Pass绘制的片段深度值与遮挡物的深度值非常非常接近由于深度缓冲的精度限制谁在前谁在后每帧可能计算不同导致交替显示。锯齿则是由于像素级别的深度比较不连续。解决方案深度偏移Depth Bias在Shader的Pass中增加Offset -1, -1指令。这会对深度值施加一个微小的负偏移让高亮片段在深度测试中“更靠后”一点从而稳定地显示在遮挡物之后避免深度冲突。这个值需要根据场景尺度微调。Pass { ... Offset -1, -1 // 添加深度偏移 ... }平滑轮廓如果使用顶点膨胀法做轮廓可以在片段着色器中对边缘进行平滑处理。一种简单的方法是使用屏幕空间导数fwidth来计算边缘强度。或者更高级的做法是使用第二个Pass先渲染一个稍粗的轮廓再用这个主Pass渲染形成内外两层柔滑过渡。4.2 性能优化策略对象管理器如前所述务必避免在Render Feature中每帧使用FindObjectsOfType。创建一个HighlightManager单例让OcclusionHighlightController在OnEnable时注册自己在OnDisable时注销。Render Feature直接遍历管理器的活动列表。基于距离的检测在OcclusionHighlightController的CheckOcclusion中可以添加距离判断。如果角色离摄像机非常远或者根本不在视锥体内就跳过射线检测直接设置IsOccluded false。检测频率动态调整对于非玩家角色NPC可以进一步降低_checkInterval比如从0.1秒调整为0.3秒甚至0.5秒。玩家角色可以保持较高频率0.05-0.1秒以获得即时反馈。合并绘制调用Draw Call Batching如果多个角色使用相同的高亮材质和颜色理论上URP可以自动进行合批。但因为我们每帧可能动态设置颜色这会打断合批。如果所有角色高亮色相同可以在Render Feature中一次性设置好材质属性然后批量绘制所有渲染器能有效降低Draw Call。4.3 扩展实现描边Outline效果基础版本是高亮填充。如果你想要的是轮廓描边需要对Shader进行修改。核心思路是“背面膨胀渲染法”在Pass中使用Cull Front指令剔除正面只渲染模型的背面。在顶点着色器中沿着顶点法线方向将顶点位置向外或向内挤出expand一小段距离。用这个挤出的背面模型作为轮廓。你可以将这个描边Pass作为另一个独立的Pass或者与现有的高亮Pass结合使用例如先画一个放大的黑色背面作为轮廓再画高亮填充。注意调整渲染顺序和混合模式。5. 常见问题排查QA在实际集成过程中你可能会遇到以下问题Q1运行后完全没有高亮效果。检查1Render Feature是否启用在URP Renderer Asset的Inspector中确保OcclusionHighlightRenderFeature前面的复选框是勾选的。检查2材质球是否正确关联确认Render Feature设置中Highlight Material引用的材质球其Shader确实是Custom/OcclusionHighlight。检查3角色图层是否匹配确保角色对象所在的图层包含在Render Feature的Occlusion Layer Mask中。检查4遮挡检测是否生效在OcclusionHighlightController脚本中添加Debug.Log输出IsOccluded的值确认射线检测逻辑是否正确。检查射线碰撞的Layer Mask是否设置合理。检查5渲染队列顺序。确保场景中遮挡物的材质渲染队列Render Queue不大于2500属于不透明物体。如果遮挡物是半透明的我们的Pass在AfterRenderingOpaques执行可能无法正确获取其深度。Q2高亮效果一直显示即使角色没有被遮挡。原因这通常是深度测试ZTest设置没起作用或者深度信息有问题。排查首先确认Shader中ZTest设置为Greater。其次检查角色的Mesh Renderer上是否意外勾选了Allow Occlusion或者角色使用的材质是否关闭了深度写入ZWrite Off如果角色自身不写入深度那么深度缓冲中就没有它的信息Greater测试会一直通过。确保角色用于常规渲染的材质是正常写入深度的。Q3高亮效果在移动相机时剧烈闪烁。原因典型的深度冲突Z-fighting。解决在Shader的Pass中添加Offset -1, -1指令。如果问题依旧可以尝试增大偏移值如Offset -2, -2。同时检查角色和遮挡物的模型是否有共面或极度接近的面。Q4性能开销感觉很大。优化实现HighlightManager避免FindObjectsOfType。增加遮挡检测的时间间隔_checkInterval。在Render Feature的Execute方法中使用DrawingSettings和FilteringSettings配合context.DrawRenderers进行批量绘制而不是对每个对象单独调用cmd.DrawRenderer尽管在底层可能优化但显式批量更好。考虑使用异步或分帧进行遮挡检测。Q5我想要不同角色有不同的高亮颜色或宽度如何传递参数方法正如我们在OcclusionHighlightController中定义了_highlightColor并在Render Feature的Execute循环中通过_material.SetColor为每个对象单独设置。对于宽度等其他属性原理相同。在Shader中定义属性如_HighlightWidth在控制器脚本中存储对应的值然后在绘制前通过MaterialPropertyBlock或直接material.SetFloat传递给Shader。注意频繁设置材质属性可能影响合批如果属性变化不频繁这不是大问题。这个方案提供了一个坚实、可扩展的起点。它牢牢抓住了URP可编程渲染管线的优势将效果实现封装在一个清晰的模块中。从理解深度测试的妙用到掌握Render Feature的插槽机制再到处理实际开发中的性能与细节问题整个过程本身就是对现代Unity渲染流程一次很好的实战学习。希望这份详细的指南和代码能帮你彻底搞定角色遮挡高亮这个“小目标”让你游戏中的角色再也无处可藏。