Unity URP 14指定物体描边:模板缓冲与Renderer Feature实战
1. 项目概述为什么URP 14的描边需要“指定”在Unity的通用渲染管线URP中实现描边效果本身并不是什么新鲜事网上随便一搜就能找到一堆基于后处理Post-processing的全屏描边方案。但“指定物体描边”这个需求恰恰戳中了这类通用方案的痛点。想象一下你正在开发一款潜行游戏主角需要与环境互动只有可互动的物体才需要高亮描边来提示玩家或者你在做一个AR应用希望扫描到的特定商品模型被清晰地勾勒出来而不是整个屏幕的边缘都在发光。这时候全屏描边就显得非常“笨拙”且不专业。URP 14对应Unity 2022.3 LTS及之后的版本在渲染架构上做了不少优化为我们实现更精细的渲染控制提供了更好的基础。传统的全屏描边其原理通常是利用相机的深度图Depth Texture和法线图Normals Texture通过索贝尔Sobel等边缘检测算子找出画面中深度或法线变化剧烈的地方然后叠加颜色。这种方法无法区分“这个边缘是哪个物体的”更无法做到“只让A物体有描边B物体没有”。因此“指定物体描边”的核心思路就从后处理转移到了物体本身的渲染流程上。我们需要一种机制让指定的物体在渲染时额外多渲染一次“轮廓”而其他物体则不受影响。这听起来简单但在URP的基于可编程渲染管线SRP的框架下我们需要深入理解它的渲染队列Render Queue、覆盖材质Override Material以及如何与URP的Renderer Features渲染器特性协同工作。这正是本次分享要拆解的核心。2. 核心方案选型深度理解渲染层与模板缓冲要实现指定物体描边业界主流有几种技术路径每种都有其适用场景和代价。在URP 14的语境下我们需要做出最合适的选择。2.1 方案对比多Pass渲染 vs. 模板缓冲 vs. 渲染到纹理多Pass渲染传统Shader方式原理在同一个Shader中编写两个Pass。第一个PassOutline Pass用正面剔除Cull Front的方式将模型沿法线方向膨胀顶点外扩渲染成纯色描边色。第二个Pass正常渲染Pass再正常渲染模型本身。URP下的困境URP的Lit Shader Graph默认是单Pass的且URP为了高性能不鼓励甚至在某些渲染路径下不支持自定义的多Pass Shader。强行使用需要自己编写HLSL Shader并处理复杂的光照和阴影接收对开发者要求高且难以与URP的光照体系完美融合。结论在URP中这不是首选方案维护成本高灵活性一般。模板缓冲Stencil Buffer方案原理利用模板缓冲这个屏幕空间的掩码通道。首先正常渲染所有物体。然后在第二个渲染阶段只渲染那些我们标记了的“需要描边的物体”。在渲染它们时先检查模板值如果通过则将它们的像素模板值写入一个特定值。接着在第三个阶段再次渲染这些物体但使用一个外扩的轮廓Shader并且设置模板测试条件为“不等于刚才写入的特定值”这样轮廓就只会画在物体原本像素之外的区域。优点逻辑清晰性能可控只额外渲染了指定物体两次描边精度高且能很好地处理物体交叉时的遮挡关系。缺点需要理解模板缓冲的工作原理配置稍显繁琐。结论在URP中这是最推荐、最主流的实现“指定物体”描边的方式兼具效果和性能。渲染到纹理Render Texture后处理原理用另一个摄像机或渲染层只渲染需要描边的物体到一个单独的Render Texture上然后对这个RT进行边缘检测生成描边图最后叠加到主画面上。优点描边效果可以非常自由比如做发光、模糊等。缺点性能开销大多一次完整的场景渲染处理物体与场景的深度混合更复杂容易产生锯齿或闪烁。结论适合需要非常风格化、非实时性描边的场景如卡通渲染的粗线条外描边对于实时交互的“高亮选中”类需求性价比不高。2.2 我们的选择基于模板缓冲的URP Renderer Feature方案综合来看模板缓冲方案与URP的架构契合度最高。URP 14提供了强大的ScriptableRendererFeature和ScriptableRenderPassAPI允许我们向渲染管线中插入自定义的渲染阶段。我们可以这样设计流程标记阶段在URP渲染管线的Opaque不透明物体渲染之后插入一个RenderPass。这个Pass只渲染那些被标记为“可描边”的物体并将它们的像素模板值设置为一个特定值例如1。描边阶段紧接着插入第二个RenderPass。这个Pass再次渲染那些“可描边”的物体但使用一个特殊的“轮廓Shader”。在这个Shader中我们设置模板测试条件为“不等于1”并且将顶点沿法线外扩。这样这个Pass就只会在物体原始像素之外的区域即模板值不为1的地方绘制出轮廓。透明与后处理之后管线继续正常的透明物体渲染和后处理流程。这个方案完全在URP的框架内运作无需修改标准Shader只需为需要描边的物体赋予一个特殊的“标记材质”并编写一个简单的“轮廓材质”即可。可控性、性能和效果都能得到很好的平衡。注意模板缓冲是一个8位的缓冲区每个像素可以存储0-255的值。我们需要合理规划模板值的用途避免与其他系统如UI遮罩、某些特效冲突。通常使用较高的值如128来作为我们的描边标记。3. 实操构建从零搭建URP描边渲染器特性理论清晰后我们开始动手。整个过程分为三大部分创建模板标记材质、创建轮廓绘制材质、编写C#脚本定义Renderer Feature。3.1 第一步创建模板标记材质这个材质的目的不是被看见而是“做记号”。它会在标记阶段将需要描边的物体的像素位置在模板缓冲中“盖个章”。在Project窗口右键Create - Material命名为StencilMask。选中这个材质在Inspector面板中找到Surface Type选择Opaque。因为标记不需要透明。最关键的一步展开Advanced Options找到Stencil区块。Ref参考值设置为1。这是我们约定的标记值。Comp比较函数设置为Always。意味着总是通过模板测试。Pass通过操作设置为Replace。意味着当片段通过所有测试后将模板缓冲中该像素的值替换为Ref值即1。其他选项Read Mask, Write Mask保持默认即可。为了最小化性能开销我们可以将材质的Shader从Universal Render Pipeline/Lit切换为最简单的Universal Render Pipeline/Unlit。因为标记阶段不需要任何光照计算。你甚至可以创建一个完全空的自定义Shader只输出一个固定颜色如黑色并处理模板但使用Unlit Shader更方便。现在任何使用StencilMask材质的物体在渲染时都会将其覆盖的像素的模板值设置为1。3.2 第二步创建轮廓绘制材质这个材质负责画出我们看到的描边。它需要在标记阶段之后运行并且只画在模板值不等于1的地方。创建另一个材质命名为Outline。我们需要一个自定义Shader。在Project窗口右键Create - Shader - Universal Render Pipeline - Unlit Shader命名为OutlineShader。双击用Shader Graph或代码编辑器打开。如果使用Shader Graph添加一个Sample Texture 2D节点连接到Base Color用于可能的纹理采样非必须。添加一个Color节点连接到Base Color作为描边颜色例如亮黄色或红色。核心顶点外扩。添加一个Position节点空间选Object一个Normal Vector节点空间选Object。将法线向量乘以一个Float属性如_OutlineThickness再与物体空间位置相加然后通过Transform节点转换到Clip空间输出到Position。核心模板测试。在Graph Inspector的Graph Settings中找到Stencil。设置Ref为0Comp为Not EqualPass为Keep。这表示“仅当模板值不等于0时渲染”注意我们之前标记的是1不等于0所以会渲染而背景和其他物体模板值是0所以不渲染。这里Ref设为0是为了做比较Comp为Not Equal是关键。如果使用HLSL代码更直接Shader Custom/Outline { Properties { _OutlineColor(Outline Color, Color) (1,0,0,1) _OutlineThickness(Outline Thickness, Range(0, 0.1)) 0.03 } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline QueueGeometry1 } // Queue在Geometry之后 Pass { Name Outline Cull Front // 正面剔除只渲染背面外扩的部分 ZWrite Off // 通常关闭深度写入避免描边影响后续物体深度测试 // Stencil配置只渲染模板值不等于1的区域 Stencil { Ref 1 Comp NotEqual Pass Keep } 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 positionCS : SV_POSITION; }; float _OutlineThickness; float4 _OutlineColor; Varyings vert(Attributes IN) { Varyings OUT; VertexPositionInputs vertexInput GetVertexPositionInputs(IN.positionOS.xyz IN.normalOS * _OutlineThickness); OUT.positionCS vertexInput.positionCS; return OUT; } half4 frag(Varyings IN) : SV_Target { return _OutlineColor; } ENDHLSL } } }将创建好的OutlineShader赋给Outline材质。在材质Inspector中你就可以调整_OutlineColor和_OutlineThickness参数了。3.3 第三步编写C#脚本实现Renderer Feature这是连接URP管线的桥梁。我们需要创建一个ScriptableRendererFeature并在其中安排两个ScriptableRenderPass。创建C#脚本OutlineRendererFeature.cs。using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class OutlineRendererFeature : ScriptableRendererFeature { // 定义两个Pass class OutlineMaskPass : ScriptableRenderPass { ... } class OutlineRenderPass : ScriptableRenderPass { ... } // 可配置的参数 [System.Serializable] public class Settings { public Material maskMaterial; // 拖入StencilMask材质 public Material outlineMaterial; // 拖入Outline材质 public LayerMask outlineLayer -1; // 指定哪些层的物体可以被描边 } public Settings settings new Settings(); private OutlineMaskPass m_OutlineMaskPass; private OutlineRenderPass m_OutlineRenderPass; public override void Create() { if (settings.maskMaterial null || settings.outlineMaterial null) { Debug.LogWarning(OutlineRendererFeature: Materials are not assigned.); return; } m_OutlineMaskPass new OutlineMaskPass(settings.maskMaterial, settings.outlineLayer); m_OutlineMaskPass.renderPassEvent RenderPassEvent.AfterRenderingOpaques; // 在不透明物体渲染后 m_OutlineRenderPass new OutlineRenderPass(settings.outlineMaterial, settings.outlineLayer); m_OutlineRenderPass.renderPassEvent RenderPassEvent.AfterRenderingOpaques 1; // 在MaskPass之后 } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (renderingData.cameraData.cameraType ! CameraType.Game) return; // 仅对游戏摄像机生效 renderer.EnqueuePass(m_OutlineMaskPass); renderer.EnqueuePass(m_OutlineRenderPass); } }实现OutlineMaskPass。这个Pass的任务是使用maskMaterial渲染指定层的物体写入模板值。class OutlineMaskPass : ScriptableRenderPass { private Material m_MaskMaterial; private LayerMask m_OutlineLayer; private FilteringSettings m_FilteringSettings; private ProfilingSampler m_ProfilingSampler new ProfilingSampler(OutlineMaskPass); public OutlineMaskPass(Material maskMaterial, LayerMask outlineLayer) { m_MaskMaterial maskMaterial; m_OutlineLayer outlineLayer; m_FilteringSettings new FilteringSettings(RenderQueueRange.opaque, m_OutlineLayer); renderPassEvent RenderPassEvent.AfterRenderingOpaques; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (m_MaskMaterial null) return; CommandBuffer cmd CommandBufferPool.Get(); using (new ProfilingScope(cmd, m_ProfilingSampler)) { // 设置渲染目标为相机目标并清除模板缓冲区不我们不需要清除只需覆盖我们需要的部分。 var drawSettings CreateDrawingSettings(new ShaderTagId(UniversalForward), ref renderingData, renderingData.cameraData.defaultOpaqueSortFlags); drawSettings.overrideMaterial m_MaskMaterial; // 关键覆盖材质 drawSettings.overrideMaterialPassIndex 0; context.DrawRenderers(renderingData.cullResults, ref drawSettings, ref m_FilteringSettings); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }实现OutlineRenderPass。这个Pass使用outlineMaterial渲染同一批物体绘制轮廓。class OutlineRenderPass : ScriptableRenderPass { private Material m_OutlineMaterial; private LayerMask m_OutlineLayer; private FilteringSettings m_FilteringSettings; private ProfilingSampler m_ProfilingSampler new ProfilingSampler(OutlineRenderPass); public OutlineRenderPass(Material outlineMaterial, LayerMask outlineLayer) { m_OutlineMaterial outlineMaterial; m_OutlineLayer outlineLayer; m_FilteringSettings new FilteringSettings(RenderQueueRange.opaque, m_OutlineLayer); renderPassEvent RenderPassEvent.AfterRenderingOpaques 1; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (m_OutlineMaterial null) return; CommandBuffer cmd CommandBufferPool.Get(); using (new ProfilingScope(cmd, m_ProfilingSampler)) { var drawSettings CreateDrawingSettings(new ShaderTagId(UniversalForward), ref renderingData, renderingData.cameraData.defaultOpaqueSortFlags); drawSettings.overrideMaterial m_OutlineMaterial; // 关键覆盖轮廓材质 drawSettings.overrideMaterialPassIndex 0; context.DrawRenderers(renderingData.cullResults, ref drawSettings, ref m_FilteringSettings); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }配置到URP Renderer Asset在Project中找到你的URP Asset通常名为UniversalRP-HighQuality等对应的Renderer Asset在Renderer List里。选中这个Renderer Asset在Inspector底部点击Add Renderer Feature选择OutlineRendererFeature。将之前创建的StencilMask和Outline材质分别拖入对应的Slot。在Outline Layer处选择一个层比如我们新建一个名为Outline的层。3.4 第四步在场景中使用为需要描边的物体例如一个Cube在Inspector顶部将其Layer修改为Outline或你在Renderer Feature中指定的层。运行游戏你应该能看到这个Cube被清晰地描边了而其他物体则没有。实操心得overrideMaterial是这里的神来之笔。它允许我们在不修改物体原有材质的情况下强制用我们指定的材质StencilMask/Outline来渲染它们。这意味着你的场景物体可以使用任何复杂的标准Lit材质而描边系统完全无侵入。4. 效果优化与深度问题处理基础功能完成后我们会立刻遇到几个典型的视觉效果问题。别担心这都是可解的。4.1 描边粗细不一致与顶点法线你可能会发现描边在模型的某些部位如尖锐拐角显得很粗而在平坦区域又很细。这是因为我们简单地将顶点沿顶点法线外扩。在立方体上每个面的顶点法线方向是垂直该面向外的所以外扩均匀。但在平滑着色Smooth Shading的球体或复杂模型上顶点法线是平均后的结果方向并不总是垂直于表面导致外扩不均匀。解决方案在轮廓Shader中使用顶点位置差分来近似计算外扩方向而不是直接用法线。一种更稳定的方法是在物体空间Object Space下将顶点坐标归一化后作为外扩方向。或者对于高质量需求可以要求美术在建模时提供一套用于轮廓膨胀的“轮廓法线”通常存储在顶点颜色或第二套UV中。对于大多数“高亮选中”场景使用物体空间顶点位置归一化向量已经能获得显著改善。// 在顶点着色器中物体空间 float3 outlineOffset normalize(IN.positionOS.xyz) * _OutlineThickness; // 或者使用视空间法线但需要转换4.2 描边与背景的深度冲突Z-Fighting由于轮廓Pass关闭了深度写入ZWrite Off并且是在不透明物体之后渲染当描边与背景或其他物体距离很近时可能会产生闪烁Z-Fighting。这是因为深度测试ZTest的结果在像素级别不稳定。解决方案轻微深度偏移在轮廓Shader的Pass中添加Offset 0, -1指令。这会让轮廓的深度值稍微偏向相机一点确保它绘制在其他物体“前面”。但偏移量要小避免造成不合理的遮挡。调整渲染队列确保轮廓材质的渲染队列Queue设置在不透明队列之后如Geometry10。在我们的实现中通过RenderPassEvent控制了两个Pass的执行顺序已经保证了描边在不透明物体之后绘制这本身就有助于缓解问题。谨慎使用ZTest可以尝试将轮廓Pass的深度测试函数改为ZTest Always或ZTest LEqual默认是LEqual。Always会覆盖一切但可能穿帮LEqual是标准操作。通常结合Offset使用效果最佳。4.3 性能考量与批处理破坏我们的方案为每个需要描边的物体增加了两个额外的渲染调用Mask Pass和Outline Pass。如果场景中有成百上千个需要描边的物体Draw Call会显著增加。优化策略按需启用不要将所有可交互物体都永久置于Outline层。通过脚本动态改变物体的Layer。例如当鼠标悬停或角色靠近时再将物体切换到Outline层。使用替换材质列表RendererFeature中的overrideMaterial会破坏动态合批Dynamic Batching和SRP Batcher。对于使用相同材质的多个物体SRP Batcher本可以合并。一个优化思路是不覆盖材质而是让需要描边的物体本身就使用一个特殊的材质变体这个变体内部包含了模板标记和轮廓渲染的Pass。但这需要修改物体的基础材质侵入性较强。对于性能压力大的项目这可能是一个权衡点。控制轮廓复杂度轮廓Shader本身应尽可能简单Unlit、无复杂光照计算。避免在轮廓Pass中进行纹理采样或复杂计算。5. 进阶扩展与实战技巧掌握了基础实现后我们可以让这个系统变得更加强大和灵活。5.1 动态描边颜色与闪烁效果描边颜色不需要是静态的。我们可以在脚本中动态修改Outline材质的属性。创建一个管理脚本OutlineController.cs挂载在需要动态控制的物体上。using UnityEngine; public class OutlineController : MonoBehaviour { private Material outlineMaterialInstance; // 材质实例 public Color pulseColorA Color.yellow; public Color pulseColorB Color.red; public float pulseSpeed 2.0f; void Start() { // 获取Renderer Feature中Outline材质的实例避免修改共享材质 var renderer GetComponentRenderer(); // 注意这里需要根据你的架构获取材质实例。 // 更通用的做法是通过一个全局管理器访问OutlineRendererFeature修改其settings.outlineMaterial的属性。 // 或者为每个物体动态创建并分配一个轮廓材质实例。 if (renderer ! null) { // 假设我们通过其他方式为这个物体单独设置了轮廓材质 outlineMaterialInstance renderer.material; // 这可能不是轮廓材质 } } void Update() { if (outlineMaterialInstance ! null) { // 动态计算颜色 float t Mathf.PingPong(Time.time * pulseSpeed, 1.0f); Color dynamicColor Color.Lerp(pulseColorA, pulseColorB, t); outlineMaterialInstance.SetColor(_OutlineColor, dynamicColor); } } }注意直接修改Renderer Feature中引用的共享材质会影响所有描边物体。更专业的做法是在运行时为每个需要独立控制的描边物体动态创建材质实例Material.Instantiate并通过某种方式如字典将这些实例与物体关联在OutlineRenderPass中应用对应的实例。这增加了复杂度但提供了最大灵活性。5.2 处理透明物体与粒子系统的描边目前的方案基于RenderQueueRange.opaque只处理不透明物体。透明物体如玻璃、粒子的渲染顺序和混合方式不同直接套用会出问题。透明物体需要将FilteringSettings中的RenderQueueRange改为RenderQueueRange.transparent并在RenderPassEvent中安排在透明渲染阶段之后如AfterRenderingTransparents。同时轮廓Shader需要处理Alpha混合。粒子系统粒子通常使用Alpha Blended材质。为其描边挑战更大因为每个粒子是独立的四边形沿法线外扩可能效果奇怪。一种妥协方案是将粒子系统的渲染器Material替换为一个专门为描边定制的粒子材质该材质内部实现轮廓效果。这通常需要美术配合制作专门的粒子特效。5.3 在编辑器下的可视化与调试为了方便调试我们可以在OutlineRendererFeature的Settings中增加调试选项。public class Settings { // ... 原有设置 ... public bool debugMask false; public bool debugOutline false; }在OutlineMaskPass和OutlineRenderPass的Execute方法中可以根据这些调试布尔值使用CommandBuffer设置不同的渲染目标如BuiltinRenderTextureType.CameraTargetvs 一个临时的RenderTexture或者通过Handles或Debug.DrawRay在Scene视图绘制辅助线来观察模板缓冲的内容或轮廓渲染的范围。6. 常见问题排查与性能调优实录在实际项目集成中你肯定会遇到一些坑。以下是我踩过并总结出来的问题清单。6.1 描边不显示或显示不全检查清单Layer匹配吗确认物体的Layer是否与OutlineRendererFeature中设置的Outline Layer一致。材质球赋值了吗在Renderer Feature的Inspector面板确认Mask Material和Outline Material是否拖拽赋值。Renderer Asset关联了吗确认你的场景主摄像机使用的URP Asset其Renderer List中包含了你修改过的那个Renderer Asset并且该Renderer Asset上已添加了OutlineRendererFeature。Shader编译错误吗检查Console窗口是否有关于OutlineShader的编译错误。一个红色的错误会导致整个Pass失效。模板值冲突吗检查场景中是否有其他系统如UI Mask、其他后处理也在使用模板缓冲并使用了相同的Ref值1。尝试换一个不常用的值如128或255。物体被遮挡了吗轮廓Pass的ZTest设置可能导致其被后续的透明物体或天空盒遮挡。尝试调整RenderPassEvent到更靠后的位置或调整ZTest/Offset。6.2 描边闪烁Flickering主要原因深度测试Z-Fighting。轮廓与背景或其他物体深度值过于接近。解决步骤在轮廓Shader的Pass中添加Offset -1, -1先尝试较小的值。确保轮廓Pass的渲染队列在不透明物体之后我们通过RenderPassEvent已经保证。如果物体本身是复杂的凹面体内部也可能产生轮廓自相交闪烁。这时可能需要考虑在轮廓Pass中启用Cull Front剔除正面的同时结合Offset或者使用更复杂的轮廓生成算法如基于屏幕空间的后处理但这就不是“指定物体”了。6.3 性能突然下降诊断使用Unity的Frame Debugger或Profiler的Rendering模块查看Draw Call数量。如果描边物体很多你会看到大量的Draw Mesh调用它们可能没有被合批。优化行动减少描边物体数量这是最有效的优化。确保描边是“选中时”或“靠近时”才触发而不是常驻状态。合并静态物体如果多个静态物体需要常驻描边考虑将它们合并成一个大的Mesh这样只需要一次Draw Call。评估SRP Batcher影响如果项目严重依赖SRP Batcher那么overrideMaterial会破坏它。你需要评估破坏SRP Batcher带来的性能损失与描边功能的价值做出权衡。对于移动平台这可能是个重要考量。6.4 在Build后失效可能原因Shader没有正确被打包。解决方案在Project Settings - Graphics - Shader Stripping中确保没有过度剥离。或者将OutlineShader及其依赖的Shader Variant加入一个Always Included Shaders列表在Project Settings - Graphics - Always Included Shaders中。检查Build后材质球引用的Shader是否丢失。实现一个稳定、高效、美观的指定物体描边系统是深入理解URP渲染管线的一个绝佳实践。它涉及了渲染队列、模板测试、Renderer Feature、Shader编写等多个核心概念。当你按照上述步骤完成并调优后获得的不仅仅是一个功能更是一套应对类似“选择性渲染”需求的通用解决方案框架。无论是角色高亮、敌人预警框还是可交互物体提示这套思路都可以灵活变通成为你项目工具箱里的一件利器。