Unity URP屏幕空间描边效果实战:从深度法线采样到性能优化全解析

📅 2026/7/15 12:02:54
Unity URP屏幕空间描边效果实战:从深度法线采样到性能优化全解析
1. 项目概述从传统管线到URP的“阵痛”与机遇最近在把一个老项目的角色描边效果从Unity内置渲染管线Built-in迁移到通用渲染管线URP上整个过程可以说是一波三折踩的坑比写的代码行数还多。如果你也正面临类似的迁移任务或者想在URP里从头实现一个稳定、高效的描边效果这篇实战总结或许能帮你省下大量折腾的时间。描边效果无论是用于角色高亮、交互提示还是风格化渲染都是游戏里非常高频的需求。在Built-in管线里我们可能已经习惯了用Camera.RenderWithShader或者Command Buffer配合一个简单的后处理Shader来实现感觉一切都很“顺滑”。但到了URP你会发现整个渲染架构、API接口甚至Shader的编写方式都发生了翻天覆地的变化之前那套经验很多都不管用了甚至直接“失灵”。这次迁移的核心目标很明确把一个基于屏幕空间、利用深度和法线信息计算边缘的后处理描边效果从Built-in管线完整地搬到URP中并且要保证性能开销可控、效果稳定。听起来简单不就是换个API、改改Shader语法吗但实际操作起来从渲染数据的获取、Pass的编写、到与URP渲染流程的对接每一步都有“惊喜”。比如URP里没有了_CameraDepthNormalsTexture这张“万能”的RT我们该如何获取屏幕空间的法线信息URP的RenderObjects特性Feature和Built-in的Command Buffer用法差异巨大怎么把我们的描边绘制正确地插入到渲染流程的特定阶段Shader里那些以Unity开头的内置变量和函数在URP的Shader Graph或者HLSL代码里又该怎么替换这些问题官方文档往往不会事无巨细地告诉你尤其是在处理一些特定效果时。网络上零散的教程可能只解决了其中一环但如何把它们串成一个稳定可用的完整方案就需要自己一步步去试错和排查了。接下来我会把整个迁移过程中的核心思路、关键技术点、具体的实现步骤以及那些让我调试到深夜的“坑”和解决方案毫无保留地拆解出来。无论你是Shader新手还是有一定图形学基础的程序都能从中找到可以直接复用的代码片段和避坑指南。2. 核心思路解析两种主流描边方案的URP适配在动手写代码之前我们必须先厘清在URP中实现描边效果的几种主流路径并做出适合自己项目需求的技术选型。在Built-in管线中屏幕空间后处理描边非常流行因为它不关心物体本身的复杂度只依赖于深度和法线图实现起来相对统一。但在URP中我们需要根据URP的渲染流程特点重新评估。2.1 方案一基于Render Objects Feature的物体空间描边这是URP官方推荐并内置支持的一种方式。其核心思路是利用URP的Render Objects渲染特性Render Feature将需要描边的物体用特定的渲染设置覆盖材质、Stencil状态等再渲染一遍。实现原理通常采用“背面膨胀”法。首先在Render Objects中设置一个过滤条件如Layer或Shader Pass名选中需要描边的物体。然后使用一个只渲染背面、且将顶点沿法线方向向外挤出vertex.xyz normal * _OutlineWidth的Shader将这个“膨胀”后的背面渲染到屏幕上。因为挤出的背面会比原物体大一圈且通常渲染为纯色如黑色或发光色从而形成了描边效果。URP适配要点创建Render Objects Feature在URP Asset的Renderer Data中添加一个Render ObjectsFeature。配置过滤与渲染状态在Feature中通过Filters设置选择要渲染的物体如指定Layer。在Override Material中指定我们编写的描边材质。最关键的是要设置Stencil状态确保描边不会覆盖物体自身通常先渲染原物体写入Stencil再渲染描边部分只在不等于该Stencil值的区域绘制。编写描边Shader需要创建一个URP Lit或Unlit Shader Graph或者手写HLSL Shader。在Shader中主要操作在顶点着色器里完成法线方向的顶点挤出。优缺点分析优点实现直观与物体模型和法线直接相关描边宽度稳定不受摄像机距离和物体在屏幕中大小的影响因为是物体空间偏移。性能开销与需要描边的物体数量及顶点数直接相关对于场景中少量重要物体如主角、可交互物品非常合适。缺点对于复杂形状或法线不连续的模型挤出可能导致描边断裂或变形。无法实现基于深度不连续物体与背景交界的“屏幕空间”描边效果。当需要描边的物体很多时Draw Call会显著增加。2.2 方案二基于全屏后处理的屏幕空间描边这正是我本次迁移所采用的方案也是传统Built-in管线中更通用的方法。它不关心具体是哪个物体而是对整屏的深度和法线信息进行分析找到那些变化剧烈深度差大或法线夹角大的像素将其判定为边缘并绘制描边。实现原理在URP中我们需要通过一个Renderer Feature来插入一个全屏后处理Pass。在这个Pass的Shader中采样URP提供的_CameraDepthTexture和_CameraNormalsTexture注意URP中默认不生成法线纹理需在URP Asset中开启。然后对当前像素周围一定范围内的像素如使用Sobel算子进行采样计算深度和法线的差异。如果差异超过某个阈值则将该像素输出为描边颜色。URP适配要点开启必要的纹理在URP Asset的Renderer设置中确保Depth Texture和Opaque Texture如果需要颜色信息选项为On。对于法线纹理可能需要通过脚本或自定义Renderer Feature来主动创建。创建全屏后处理Feature这不是内置的Render Objects而是需要自己编写一个继承自ScriptableRendererFeature的类并在其中添加一个继承自ScriptableRenderPass的Pass。这个Pass需要配置渲染事件如AfterRenderingOpaques并实现Execute方法在其中调用BlitterAPI或绘制一个全屏四边形来执行我们的后处理Shader。编写后处理Shader使用HLSL编写一个屏幕空间Shader。关键点在于正确声明和采样URP的全局纹理如TEXTURE2D(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture);并使用LinearEyeDepth函数还原深度值。法线纹理的采样和解码也需要遵循URP的规范通常编码在两个通道中。优缺点分析优点效果统一能捕捉到所有物体的边缘包括物体与物体之间、物体与背景之间的边缘。实现一次全场适用不增加额外Draw Call只有一个全屏Pass。对于需要为大量物体或整个场景添加轮廓感的风格化渲染非常有效。缺点描边宽度是屏幕空间的像素宽度物体离摄像机越远描边在视觉上会越细。性能开销取决于后处理Shader的复杂度采样次数和屏幕分辨率。对深度/法线纹理的精度和抗锯齿处理比较敏感容易产生锯齿或噪点。我的选择与理由我选择了方案二屏幕空间后处理。原因在于我们的老项目效果本身就是基于屏幕空间的希望保持视觉一致性。更重要的是项目场景中需要高亮提示的物体动态变化且数量可能较多使用物体空间方案会导致Draw Call波动而屏幕空间方案的开销是恒定的一次全屏处理更易于性能预算管理。接下来的内容将围绕这个方案展开。3. 关键步骤拆解构建URP全屏后处理描边确定了屏幕空间方案后我们开始具体实施。这个过程可以分为三个核心环节配置URP资源、创建Renderer Feature、编写后处理Shader。每一个环节都有需要特别注意的细节。3.1 第一步URP资源与管线配置这是所有工作的基础配置错误会导致后续步骤根本无法获取到需要的数据。创建或修改URP Asset如果你的项目还没有URP Asset通过Create - Rendering - URP Asset (with Universal Renderer)创建一个。确保你场景中使用的Graphics Settings里指定了该URP Asset。开启深度与法线纹理深度纹理在URP Asset的Renderer列表中找到你使用的Renderer Data通常是UniversalRenderer。在其Inspector中Depth Texture选项默认可能是Off务必将其设为On。这是获取_CameraDepthTexture的前提。法线纹理URP默认不会创建全屏法线纹理(_CameraNormalsTexture)。这是一个大坑你需要通过代码手动开启。通常我们在自定义的Renderer Feature的AddRenderPasses方法中通过配置ConfigureInput来向URP管线请求法线纹理public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (m_OutlinePass ! null) { // 请求深度和法线作为输入 m_OutlinePass.ConfigureInput(ScriptableRenderPassInput.Normal); // 请求法线 // 深度纹理在URP中默认可用但通常也需要显式声明依赖 renderer.EnqueuePass(m_OutlinePass); } }此外还需要确保物体的Shader能够将法线信息写入到屏幕空间法线纹理中。对于URP Lit着色器这通常是自动的。但对于自定义Shader可能需要确保其输出正确的法线信息。抗锯齿MSAA与深度纹理如果你的项目开启了MSAA需要注意_CameraDepthTexture在MSAA下的行为。URP的深度纹理在MSAA开启时默认可能是解析Resolve后的非MSAA纹理。如果你的后处理需要每个样本的深度可能需要更复杂的处理。对于大多数描边效果使用解析后的深度纹理已经足够但边缘可能会有一些模糊。这是一个高级调优点初期可以暂时使用默认行为。3.2 第二步创建自定义Renderer Feature与Render Pass这是连接URP管线和我们的后处理Shader的桥梁。创建Renderer Feature脚本在项目中创建C#脚本例如OutlineRendererFeature.cs继承自ScriptableRendererFeature。这个类负责管理ScriptableRenderPass的生命周期。using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class OutlineRendererFeature : ScriptableRendererFeature { class OutlineRenderPass : ScriptableRenderPass { ... } // 内嵌的Pass类 private OutlineRenderPass m_OutlinePass; public Material outlineMaterial; // 暴露给Inspector用于指定后处理材质 public override void Create() { m_OutlinePass new OutlineRenderPass(outlineMaterial); // 配置渲染事件例如在不透明物体渲染之后、天空盒之前 m_OutlinePass.renderPassEvent RenderPassEvent.AfterRenderingOpaques; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (outlineMaterial null) { Debug.LogWarning(Outline material is not assigned.); return; } // 请求法线纹理作为输入 m_OutlinePass.ConfigureInput(ScriptableRenderPassInput.Normal); renderer.EnqueuePass(m_OutlinePass); } }实现OutlineRenderPass这是核心在Execute方法中执行渲染命令。class OutlineRenderPass : ScriptableRenderPass { private Material m_Material; private RTHandle m_CameraColorTarget; // 用于存储摄像机颜色目标 public OutlineRenderPass(Material material) { m_Material material; } // 这个方法在每帧渲染前被调用用于更新资源 public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { // 获取当前摄像机的颜色目标句柄 m_CameraColorTarget renderingData.cameraData.renderer.cameraColorTargetHandle; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (m_Material null) return; CommandBuffer cmd CommandBufferPool.Get(Outline Post Process); // 使用Blitter类进行全屏绘制这是URP推荐的方式 // Blitter.BlitCameraTexture内部会处理源和目标纹理以及视口/裁剪 Blitter.BlitCameraTexture(cmd, m_CameraColorTarget, m_CameraColorTarget, m_Material, 0); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }关键点使用Blitter.BlitCameraTexture替代传统的cmd.Blit它能更好地兼容URP的RTHandle系统和XR渲染。参数0表示使用材质球中的第0个Pass。添加到渲染器将创建好的OutlineRendererFeature脚本拖拽到你的URP Renderer Data的Renderer Features列表中并在Inspector中为其指定我们接下来要创建的后处理材质球。3.3 第三步编写屏幕空间后处理ShaderHLSL这是效果的核心算法所在。我们将创建一个Unlit Shader因为描边计算不涉及光照。Shader基本结构创建一个新的Shader文件选择Universal Render Pipeline/Unlit模板进行修改或者从头手写。Shader Hidden/OutlinePostProcess { Properties { _OutlineColor (Outline Color, Color) (0,0,0,1) _OutlineThreshold (Outline Threshold, Range(0, 1)) 0.1 _DepthSensitivity (Depth Sensitivity, Float) 1.0 _NormalSensitivity (Normal Sensitivity, Float) 1.0 } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline} LOD 100 ZTest Always ZWrite Off Cull Off // 后处理标准设置 Pass { Name OutlinePass HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag // 包含URP核心库 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareNormalsTexture.hlsl // 定义属性和变量 TEXTURE2D(_CameraColorTexture); SAMPLER(sampler_CameraColorTexture); float4 _OutlineColor; float _OutlineThreshold; float _DepthSensitivity; float _NormalSensitivity; struct Attributes { ... }; struct Varyings { ... }; Varyings Vert (Attributes input) { ... } // 标准全屏三角形顶点着色器 float4 Frag (Varyings input) : SV_Target { // 核心边缘检测逻辑将在这里实现 } ENDHLSL } } }深度与法线采样与解码深度使用SampleSceneDepth函数来自DeclareDepthTexture.hlsl来采样并获取线性眼空间深度。float depth SampleSceneDepth(input.uv); float linearDepth LinearEyeDepth(depth, _ZBufferParams);法线使用SampleSceneNormals函数来自DeclareNormalsTexture.hlsl来采样屏幕空间法线。URP中的法线通常已经解码为世界空间或视图空间取决于设置我们需要确认其坐标系。float3 normal SampleSceneNormals(input.uv); // 通常需要归一化因为插值可能导致长度变化 normal normalize(normal);边缘检测算法实现Sobel算子这是描边效果的精髓。我们不对颜色进行Sobel而是对深度和法线进行。float2 texelSize 1.0 / _ScreenParams.xy; // 获取一个像素的UV大小 // 定义Sobel算子使用的偏移 float2 offsets[9] { ... }; // 定义周围9个像素的UV偏移 float depthEdge 0; float normalEdge 0; // 中心点采样 float centerDepth LinearEyeDepth(SampleSceneDepth(input.uv), _ZBufferParams); float3 centerNormal normalize(SampleSceneNormals(input.uv)); // 使用简化或完整的Sobel卷积计算深度和法线的梯度 for (int i 0; i 9; i) { float2 sampleUV input.uv offsets[i] * texelSize; float sampleDepth LinearEyeDepth(SampleSceneDepth(sampleUV), _ZBufferParams); float3 sampleNormal normalize(SampleSceneNormals(sampleUV)); // 深度差异使用绝对值或平方差 depthEdge abs(sampleDepth - centerDepth); // 法线差异使用点积1 - dot 表示差异度 normalEdge 1.0 - dot(centerNormal, sampleNormal); } // 平均或加权求和 depthEdge / 9.0; normalEdge / 9.0; // 综合深度和法线边缘强度 float edgeStrength saturate(depthEdge * _DepthSensitivity normalEdge * _NormalSensitivity);为了性能有时会采用简化的4方向或Roberts交叉算子减少采样次数。最终颜色混合根据边缘强度将原图颜色与描边颜色混合。float4 originalColor SAMPLE_TEXTURE2D(_CameraColorTexture, sampler_CameraColorTexture, input.uv); float outlineMask step(_OutlineThreshold, edgeStrength); // 阈值化大于阈值则为1边缘 float4 finalColor lerp(originalColor, _OutlineColor, outlineMask); return finalColor;更高级的做法可以使用smoothstep实现柔和的边缘过渡或者让边缘强度影响描边颜色的透明度。4. 实战避坑指南那些让我调试到深夜的问题理论很美好但实践起来到处都是陷阱。下面是我在迁移过程中遇到的最具代表性的几个问题及其解决方案。4.1 坑一_CameraNormalsTexture为空白或法线数据错误问题现象描边效果完全没有出现或者出现奇怪的、不随物体旋转变化的条纹。检查Frame Debugger发现法线纹理是全黑、全粉或者看起来不对劲。排查与解决确认纹理是否被创建在Frame Debugger中查看执行你的后处理Pass之前是否有名为_CameraNormalsTexture的渲染纹理。如果没有说明URP没有生成它。检查Renderer Feature的输入请求确保在你的OutlineRenderPass的创建或AddRenderPasses方法中正确调用了ConfigureInput(ScriptableRenderPassInput.Normal)。这是告诉URP“我这个Pass需要法线纹理请为我准备好。”检查物体的渲染状态屏幕空间法线纹理是由不透明物体的渲染结果生成的。确保你的场景中有使用URP Lit Shader或正确输出世界/视图空间法线的自定义Shader的不透明物体。透明物体通常不会写入法线纹理。验证法线坐标系在Shader中采样到法线后尝试直接将其作为颜色输出return float4(normal * 0.5 0.5, 1.0)查看是否是正确的世界空间法线通常球体的法线图看起来是彩色的球。如果颜色不对可能是坐标系问题。URP的SampleSceneNormals默认返回的是视图空间View Space法线。确保你的边缘检测计算是在同一空间下进行的通常视图空间即可。如果你在Built-in里用的是世界空间这里就需要调整。4.2 坑二深度值异常导致的描边错乱问题现象描边出现在不该出现的地方比如天空盒或纯色背景上出现了边缘或者近处的物体描边断裂。排查与解决理解深度纹理格式URP的深度纹理可能不是线性的。使用LinearEyeDepth或Linear01Depth函数对采样的深度进行解码是必须的。直接使用原始的SampleSceneDepth值进行计算会导致完全错误的结果。处理天空盒和背景天空盒的深度值通常是1远平面或一个非常大的值。在计算深度差时如果背景和前景物体深度差巨大会被误判为边缘。一个常见的技巧是在计算深度差异前加入一个最大深度差的限制或者当某个采样点的深度接近远平面时忽略其贡献。if (sampleDepth 0.999) // 接近远平面 { // 跳过或赋予很小的权重 continue; }抗锯齿MSAA/TAA的影响当开启MSAA时深度纹理可能是经过Resolve的边缘计算可能会变模糊或出现重影。如果对描边锐度要求高可以考虑在URP Asset中关闭MSAA或使用TAA并测试其对效果的影响。另一种方案是尝试采样原始的MSAA深度缓冲但这需要更高级的渲染编程知识。4.3 坑三性能开销过大问题现象游戏帧率明显下降尤其是在高分辨率下。优化策略降低采样次数将9x9的Sobel采样简化为4方向采样上、下、左、右甚至使用Roberts交叉算子只需2x2的4个点。虽然质量略有下降但性能提升显著。降分辨率渲染在后处理Pass中将渲染目标设置为一个比屏幕分辨率更小的RT例如一半分辨率在这个低分辨率Buffer上进行边缘检测计算最后再上采样回屏幕分辨率。因为描边本身是低频信息对分辨率不敏感这样做能大幅减少像素着色器的执行数量。// 在RenderPass的OnCameraSetup或Execute中 RenderTextureDescriptor descriptor renderingData.cameraData.cameraTargetDescriptor; descriptor.width / 2; // 宽高减半 descriptor.height / 2; RenderingUtils.ReAllocateIfNeeded(ref m_TemporaryColorTexture, descriptor, FilterMode.Bilinear); // 先Blit到m_TemporaryColorTexture进行处理再Blit回原目标精确控制执行条件在AddRenderPasses方法中根据条件决定是否加入该Pass。例如只在特定摄像机如主摄像机、或当有物体需要高亮时才开启。public override void AddRenderPasses(...) { if (!ShouldRenderOutline()) return; // 自定义条件判断 // ... 加入Pass }4.4 坑四与UI、粒子等半透明物体的叠加问题问题现象描边效果画在了UI或者粒子特效的上面破坏了视觉层次。原因与解决这是因为你的后处理Pass的执行时机renderPassEvent可能设置在了UI渲染之后。UI和粒子通常是在AfterRenderingPostProcessing甚至更晚的事件渲染的。解决方案将你的描边Pass的renderPassEvent设置为AfterRenderingOpaques在不透明物体之后天空盒之前或者BeforeRenderingTransparents在透明物体之前。确保它在你希望影响的渲染步骤之后、但又在你不想影响的步骤之前执行。这需要你仔细规划整个渲染顺序。通常放在不透明物体之后、天空盒之前是一个比较安全的选择可以确保描边出现在场景物体上但不会影响后期才渲染的UI。5. 效果调优与进阶思路基础功能跑通后就可以开始精细调整效果并探索一些更高级的玩法。5.1 参数调优手册在Shader的Properties中暴露的几个参数是调节视觉效果的关键_OutlineColor描边颜色。除了纯色也可以尝试使用渐变纹理根据深度或屏幕位置改变颜色。_OutlineThreshold边缘检测阈值。值越小对深度/法线变化越敏感描边越“多”值越大只显示对比强烈的边缘描边越“少”。_DepthSensitivity深度边缘的敏感度。对于场景层次复杂的可以调高对于平坦场景可以调低。_NormalSensitivity法线边缘的敏感度。对于模型细节丰富的调高可以突出模型轮廓对于光滑物体可以主要依赖深度。调试技巧在Shader中可以临时将edgeStrength边缘强度直接作为灰度图输出这样可以直观地看到当前参数下哪些区域被判定为边缘便于精准调整阈值和敏感度。5.2 结合物体ID或自定义Buffer实现选择性描边全屏后处理会给所有物体描边。如果我们只想对特定物体比如敌人、可交互物品描边怎么办一个强大的方法是结合Render ObjectsFeature和自定义Buffer。渲染物体ID到一张单独的纹理创建一个新的Render ObjectsFeature使用一个极简的Shader将物体的唯一标识例如一个自定义的_ObjectID浮点数渲染到一张单独的RTHandle中。这个Feature需要在所有不透明物体渲染之前执行。在后处理Shader中采样ID纹理在你的屏幕空间描边Shader中除了采样深度和法线再采样这张ID纹理。选择性应用描边在计算outlineMask后再与一个条件判断结合。例如只有当当前像素的物体ID属于我们关心的特定ID集合时才保留描边Mask。float objectID SAMPLE_TEXTURE2D(_ObjectIDTexture, sampler_ObjectIDTexture, input.uv).r; if (objectID 0.5) // 假设ID0表示背景或不关心的物体 { outlineMask 0; }这种方法实现了屏幕空间描边的质量与物体空间描边的选择性控制虽然增加了额外的渲染Pass和纹理采样但提供了极大的灵活性。5.3 性能与质量平衡实践对于移动平台或性能敏感的项目必须做权衡低配方案使用Roberts交叉算子降半分辨率渲染关闭基于法线的边缘检测只依赖深度。中配方案使用4方向Sobel全分辨率渲染同时使用深度和法线。高配方案使用完整的9x9或更大核的Sobel算子甚至结合双边滤波来平滑深度/法线信息以减少噪点和产生更平滑、更高质量的描边可以全分辨率或动态分辨率渲染。迁移到URP的过程更像是一次对现代渲染管线理解的重塑。它迫使你放弃一些旧的“黑盒”API去更深入地理解渲染命令的调度、资源的生命周期以及Shader与管线的交互方式。踩坑固然痛苦但每解决一个问题你对URP的掌控力就增强一分。最终当那个流畅、稳定的描边效果在URP项目中重新亮起时你会觉得这一切都是值得的。希望这篇超详细的实战记录能成为你URP迁移之路上的一个可靠路标。