尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

URP屏幕空间描边三步法:从原理到高性能实现

URP屏幕空间描边三步法:从原理到高性能实现 1. 项目概述为什么屏幕空间描边是URP项目的“点睛之笔”在Unity URP项目中描边效果是提升视觉表现力最直接、最有效的手段之一。无论是为了突出可交互物体、增强卡通渲染风格还是单纯想让模型在复杂场景中“跳”出来一个高质量的描边都能起到画龙点睛的作用。而屏幕空间描边Screen Space Outline因其实现相对独立于模型复杂度且效果稳定可控成为了众多开发者的首选方案。你可能已经尝试过基于法线和深度的边缘检测但效果总是不尽如人意要么边缘断断续续要么在物体重叠处出现奇怪的“粘连”又或者性能开销让你在移动平台上望而却步。这些痛点恰恰是区分“能用”和“专业”的关键。今天要分享的这套“三步法”是我在多个上线项目中反复打磨、验证过的URP屏幕空间描边实现方案。它不追求最花哨的算法而是聚焦于稳定性、性能与效果的平衡目标是让你用最小的代价获得最接近商业项目品质的轮廓效果。简单来说这个方案的核心路径是准备描边遮罩 - 在屏幕空间进行边缘检测与扩张 - 高质量的后处理合成。每一步都有其设计考量和避坑要点接下来我们就深入拆解。2. 核心思路与方案选型为什么是“三步走”在动手写Shader之前理清思路比盲目编码更重要。屏幕空间描边的实现流派很多我们需要先明确URP管线下的最佳实践路径。2.1 主流描边方案横向对比在Unity中实现描边主要有三大类方法基于几何体扩张、基于后处理屏幕空间以及混合方案。基于几何体扩张这是最古老的方法通常通过复制模型并沿顶点法线方向挤出正面剔除或背面渲染来实现。它的优点是边缘绝对精确与模型拓扑一致。但在URP中特别是对于复杂场景它的缺点被放大每个需要描边的物体都需要额外绘制一次甚至两次Draw Call翻倍对GPU带宽压力大对于有硬边的模型挤出会导致接缝处不连续效果生硬。在移动端或大型场景中这通常是性能瓶颈。基于后处理屏幕空间这正是我们方案的核心。它的原理是在所有的物体渲染完毕后在整个屏幕图像上进行分析通过对比相邻像素之间的差异如深度值、法线方向、物体ID来识别出物体的边缘。其最大优势是与场景复杂度解耦无论场景中有100个还是1000个物体后处理渲染的代价几乎是恒定的一次全屏Pass。这为性能优化提供了巨大空间。混合方案有些方案会结合两者例如用几何体扩张生成一个高质量的描边遮罩图Render Texture再结合后处理进行模糊或风格化。这提供了最大的灵活性但实现复杂度也最高。对于绝大多数URP项目尤其是需要考虑跨平台包括移动端的项目纯屏幕空间后处理方案在性能、效果和实现成本上取得了最佳平衡。我们的“三步法”正是基于此理念构建。2.2 我们的“三步法”设计哲学为什么是三步这三步环环相扣每一步都为了解决一个核心问题第一步准备描边遮罩Mask Preparation。这是所有屏幕空间描边的基石。问题在于用什么信息来定义“边缘”单纯用深度在平坦的表面深度变化小边缘会消失。单纯用法线在曲面光滑的物体上法线连续变化会检测出大量“伪边缘”。我们的策略是为需要描边的物体渲染一张专用的“对象ID”图。这可以通过URP的Render Objects特性在渲染不透明物体之后将特定Layer的物体以纯色如物体索引或自定义颜色渲染到一张单独的Render Texture中。这样边缘的定义就变成了“这张ID图上颜色不连续的地方”精准且可控。第二步屏幕空间边缘检测与扩张Edge Detection Dilation。拿到ID图后我们需要在屏幕空间找出颜色突变的像素——即边缘。这里采用经典的Sobel算子或简单的采样比较。但直接检测出的边缘通常是单像素的细线视觉效果很弱。因此我们需要“扩张”它。这一步通常用一个自定义的Blur如方向模糊或Dilation膨胀Pass来实现将细线变粗形成有宽度的描边。这一步的采样半径和算法选择直接决定了描边的粗细和柔和度。第三步后处理合成Post-Process Compositing。将处理好的、带有宽度的描边图与最终渲染的相机画面进行合成。这里不是简单的叠加需要考虑描边的颜色、透明度以及如何处理描边与场景的混合例如让描边能够被场景中的半透明物体或雾效正确遮挡。我们将在URP的RenderPass中完成最终合成确保渲染顺序正确并集成到URP的后处理堆栈中便于项目管理和参数调整。这个流程将复杂的描边效果分解为三个职责清晰的模块每个模块都可以独立优化和调试极大地提升了开发效率和效果的可控性。3. 第一步精准生成描边遮罩对象ID图万事开头难遮罩的质量直接决定了最终描边效果的精度。在URP中我们有多种方式生成遮罩这里推荐最稳健、性能可预期的一种。3.1 使用URP Renderer Feature创建ID渲染通道我们不能干扰主渲染流程因此需要利用URP的Renderer Feature来插入一个额外的渲染通道。首先创建一个C#脚本例如OutlineMaskFeature.cs继承自ScriptableRendererFeature。在Create方法中实例化一个自定义的ScriptableRenderPass我们称之为OutlineMaskPass。这个OutlineMaskPass的核心任务是在不透明物体渲染之后透明物体渲染之前将我们指定Layer比如我们新建一个叫“Outline”的Layer的物体渲染到一张单独的Render Texture上。关键点在于这个Pass要使用一个极其简单的、只输出纯色的Shader。为什么不用深度或法线图深度图相机本身就有一张但它是所有物体的深度混合。如果两个不同物体紧贴在一起它们的深度值可能非常接近导致边缘检测失败。法线图同样自阴影或平滑着色会导致法线连续变化产生大量内部“噪声边缘”。而专用的对象ID图每个物体或每组物体一个颜色边界绝对清晰完美定义了“物体轮廓”。3.2 编写遮罩渲染Shader创建一个Unlit Shader命名为Hidden/OutlineMask.shader。这个Shader简单到极致Shader Hidden/OutlineMask { SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } Pass { Name OutlineMask // 关闭深度写入但开启深度测试确保被遮挡的物体不会绘制到遮罩上 ZWrite Off ZTest LEqual Cull Back ColorMask RGBA // 确保输出颜色 HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionCS : SV_POSITION; }; // 定义一个材质属性用于在Renderer Feature中传递物体ID颜色 half4 _OutlineColorID; Varyings vert (Attributes input) { Varyings output; VertexPositionInputs vertexInput GetVertexPositionInputs(input.positionOS.xyz); output.positionCS vertexInput.positionCS; return output; } half4 frag (Varyings input) : SV_Target { // 直接返回预设的ID颜色 return _OutlineColorID; } ENDHLSL } } }这个Shader的frag函数直接返回_OutlineColorID。我们可以在C#的OutlineMaskPass中通过MaterialPropertyBlock为每个需要描边的物体动态设置不同的_OutlineColorID值。例如可以用物体的InstanceID转换成一个颜色值确保场景中不同物体颜色不同。注意在实际项目中如果物体非常多为每个物体分配唯一颜色可能带来管理开销。一个折中方案是为同一类物体如所有敌人、所有可拾取物品分配同一个颜色ID。这样描边会将这些物体视为一个整体但通常是可以接受的因为它们的轮廓本就是分开的。3.3 在Renderer Feature中配置与渲染在OutlineMaskPass的Execute方法中我们需要申请一张临时Render TextureRenderTextureDescriptor格式通常用RenderTextureFormat.ARGB32就够了因为只需要存储颜色ID。分辨率可以设为相机目标分辨率的一半Half以节省带宽这对描边质量影响很小但能提升性能。配置命令缓冲区CommandBuffer设置渲染目标到这张RT。使用DrawingSettings和FilteringSettings筛选出“Outline” Layer的物体使用我们上面创建的OutlineMask材质进行绘制。在执行完毕后将这张生成的ID图存储到一个全局变量如_OutlineMaskTex中供后续的屏幕空间Pass采样。实操心得一关于性能与精度。将遮罩图分辨率设为半分辨率Half Res是一个非常重要的优化技巧。因为后续的边缘检测和扩张都是图像处理操作降低输入纹理的分辨率能直接减少约75%的像素处理量宽高各一半。而描边本身是种“模糊”的效果半分辨率的输入在经过扩张后最终合成到全分辨率画面上视觉损失微乎其微尤其是在运动状态下几乎无法察觉。这在移动端项目上是必须考虑的优化点。4. 第二步屏幕空间边缘检测与智能扩张拿到干净的对象ID图后我们就要在屏幕空间里“找出”边缘并把它“加粗”。这一步是整个效果的灵魂直接决定了描边的粗细、平滑度和风格。4.1 实现边缘检测Shader我们创建一个新的Shader用于后处理。在URP中这通常通过FullScreen Pass来实现。新建一个Shader例如Hidden/OutlinePostProcess.shader。首先在Shader中定义边缘检测函数。这里采用经典的Sobel算子它通过卷积计算像素在水平和垂直方向上的梯度综合判断边缘强度。float _OutlineWidth; // 控制采样距离影响边缘检测的“灵敏度” float _OutlineThreshold; // 边缘检测阈值低于此值的梯度变化将被忽略 float SampleMask(float2 uv) { // 采样对象ID图这里我们只取颜色的亮度作为比较依据 // 因为ID是颜色直接比较RGB向量不够高效转换为亮度或取其中一个通道如R均可 return tex2D(_OutlineMaskTex, uv).r; } float GetEdgeStrength(float2 uv) { // 定义Sobel算子使用的偏移量基于_OutlineWidth调整 float2 texelSize _MainTex_TexelSize.xy * _OutlineWidth; // Sobel卷积核 float gx 0.0; gx -1.0 * SampleMask(uv float2(-texelSize.x, -texelSize.y)); gx -2.0 * SampleMask(uv float2(-texelSize.x, 0)); gx -1.0 * SampleMask(uv float2(-texelSize.x, texelSize.y)); gx 1.0 * SampleMask(uv float2(texelSize.x, -texelSize.y)); gx 2.0 * SampleMask(uv float2(texelSize.x, 0)); gx 1.0 * SampleMask(uv float2(texelSize.x, texelSize.y)); float gy 0.0; gy -1.0 * SampleMask(uv float2(-texelSize.x, -texelSize.y)); gy -2.0 * SampleMask(uv float2(0, -texelSize.y)); gy -1.0 * SampleMask(uv float2(texelSize.x, -texelSize.y)); gy 1.0 * SampleMask(uv float2(-texelSize.x, texelSize.y)); gy 2.0 * SampleMask(uv float2(0, texelSize.y)); gy 1.0 * SampleMask(uv float2(texelSize.x, texelSize.y)); float edgeStrength sqrt(gx * gx gy * gy); return saturate((edgeStrength - _OutlineThreshold) / (1.0 - _OutlineThreshold)); }GetEdgeStrength函数返回一个0到1的值表示该像素是边缘的强度。Sobel算子能较好地抵抗噪声得到相对平滑的边缘响应。4.2 实现描边扩张Dilation效果直接由Sobel检测出的边缘通常是单像素的。我们需要将其扩张。一种高效且效果不错的方法是使用方向模糊Directional Blur或者更准确地说是最大值采样Max Sampling。我们可以在边缘检测的同时或之后在一个小范围内比如3x3或5x5的核采样并取最大的边缘强度值。这相当于将亮的边缘像素向四周“扩散”。float4 frag (Varyings input) : SV_Target { float2 uv input.uv; float edge 0.0; // 简单的最大值扩张采样 float sampleWidth _OutlineWidth * 2.0; // 扩张范围可以比检测范围大 float2 texelSize _MainTex_TexelSize.xy * sampleWidth; for (int x -1; x 1; x) { for (int y -1; y 1; y) { float2 offset float2(x, y) * texelSize; edge max(edge, GetEdgeStrength(uv offset)); } } // 输出边缘强度作为描边遮罩 return float4(edge.xxx, edge); }这个嵌套循环实现了3x3的最大值采样。_OutlineWidth参数在这里控制了扩张的物理宽度。最终edge变量就是一个从0非边缘到1边缘中心渐变的遮罩边缘区域是平滑的带状而非生硬的线条。实操心得二采样优化与性能。上面的3x3循环采样在Fragment Shader中开销不小。一个高级优化技巧是使用双Pass分离滤波。即第一个Pass只做垂直方向的扩张采样第二个Pass对第一个Pass的结果做水平方向的扩张采样。这样能将一个NxN的采样核9次采样拆解为两个1xN的核336次采样在N较大时优势明显。对于移动端甚至可以简化为只做4方向上、下、左、右或2方向水平、垂直采样在效果和性能间取得平衡。4.3 关键参数调校指南这一步有几个关键参数理解它们的作用才能调出理想效果_OutlineWidth这是最直观的参数控制描边的“粗细”。但要注意它在边缘检测和扩张两个阶段可能代表不同的尺度。通常我会将其拆分为两个参数_EdgeDetectWidth控制Sobel采样距离影响边缘识别灵敏度和_DilationRadius控制扩张范围影响物理宽度。初期可以联动后期精细调整时再分开。_OutlineThreshold边缘检测阈值。值越小对颜色/亮度变化越敏感检测出的边缘越多可能包含噪声值越大只保留对比强烈的边缘。对于干净的对象ID图这个值可以设得低一些如0.05-0.1。如果发现背景中有零星噪点被误检为描边就适当调高它。抗锯齿AA处理在URP的后期处理中如果开启了TAA或FXAA可能会对描边遮罩图进行滤波导致边缘变模糊或出现重影。一个解决办法是将描边渲染的Pass放在抗锯齿之前执行。在URP的渲染器设置中可以调整Renderer Feature的RenderPassEvent将其设置为BeforeRenderingPostProcessing并确保在抗锯齿Pass之前。5. 第三步高质量后处理合成与视觉增强现在我们有了一张灰度图白色代表描边区域黑色代表非描边区域。最后一步就是将它漂亮地合成到最终画面上。5.1 基础合成与颜色控制合成Shader相对简单。我们采样原始场景颜色_MainTex和描边遮罩_OutlineMaskTex即上一步的输出。float4 frag (Varyings input) : SV_Target { float4 sceneColor tex2D(_MainTex, input.uv); float outlineMask tex2D(_OutlineMaskTex, input.uv).r; // 假设上一步输出在R通道 // 定义描边颜色和强度 float3 _OutlineColor float3(1.0, 0.2, 0.2); // 红色描边 float _OutlineIntensity 1.0; // 核心合成公式线性插值Lerp float3 finalColor lerp(sceneColor.rgb, _OutlineColor, outlineMask * _OutlineIntensity); return float4(finalColor, sceneColor.a); }这是最简单的覆盖式合成。但直接lerp会使得描边完全覆盖场景颜色看起来像贴上去的纸片缺乏立体感和融合度。5.2 高级合成技巧深度测试与场景交互一个专业的描边效果应该能和场景正确交互。例如描边不应该出现在被其他物体包括半透明物体如粒子、雾效遮挡的部分描边自身也可以有深度感。实现基于深度的描边遮挡我们需要相机的深度纹理。在URP中可以通过_CameraDepthTexture获取。// 在Shader中声明深度纹理 TEXTURE2D(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture); // 在片元着色器中 float rawDepth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, input.uv); float sceneDepth LinearEyeDepth(rawDepth, _ZBufferParams); // 转换为线性眼空间深度 // 计算当前像素的“描边深度”。一个简单方法是在渲染ID图时也同时写入深度。 // 假设我们有一张存储了描边物体深度的纹理 _OutlineDepthTex float outlineDepth tex2D(_OutlineDepthTex, input.uv).r; // 比较深度如果场景深度比描边深度更近值更小说明描边被遮挡 if (sceneDepth outlineDepth - _DepthBias) { // 加一个微小偏置避免Z-fighting outlineMask 0.0; // 丢弃被遮挡部分的描边 }这就要求我们在第一步渲染ID图时需要同时将深度信息写入另一张RT或者利用URP的深度预Pass机制。这增加了复杂度但换来了物理上正确的遮挡关系对于复杂场景至关重要。实现描边内外发光与渐变我们可以利用outlineMask的梯度值从0到1的变化来做文章。Sobel算子的结果或扩张后的遮罩边缘区域本身就是渐变的。// 假设outlineMask是平滑的渐变值 float edgeSoftness 0.3; float smoothMask smoothstep(0.0, edgeSoftness, outlineMask); // 使边缘过渡更柔和 // 内发光让描边颜色向内部衰减 float innerGlow pow(outlineMask, 2.0); // 二次方衰减内部变暗更快 float3 innerColor _OutlineColor * innerGlow; // 外发光在边缘外侧添加一层更淡、更扩散的光晕需要额外采样扩张 // ... 此处省略外发光采样代码 ... // 混合将内发光颜色加到场景色上Additive混合 float3 finalColor sceneColor.rgb innerColor * _OutlineIntensity;通过调整衰减函数pow,exp等和混合模式Additive, Screen, Overlay等可以实现各种风格的描边从硬朗的卡通边到柔和的发光边。5.3 集成到URP后处理堆栈为了管理方便我们应该将整个描边效果封装成一个URP的Volume组件。这样美术和策划可以在场景中或通过代码动态调整参数。创建一个C#脚本OutlineVolumeComponent继承自VolumeComponent并实现IPostProcessComponent接口。在其中定义所有可调参数OutlineColor,Width,Threshold,Intensity,DepthBias等。修改我们的OutlinePostProcess.shader使用UnityEngine.Rendering.Universal命名空间下的Blit方法并关联Volume中的参数。在Renderer中添加一个FullScreen Pass并在其Execute方法中检查当前是否有激活的OutlineVolumeComponent如果有则执行我们的后处理材质。这样你就可以像使用URP内置的Bloom、Color Grading一样在Volume中轻松开关和调整描边效果了。实操心得三移动端适配精要。在移动设备上带宽和填充率是宝贵资源。降低采样次数如前所述使用分离滤波或将扩张采样从5x5降到3x3。利用Bloom通道如果你的项目已经开启了Bloom辉光可以尝试将描边效果“嫁接”到Bloom上。具体做法是将描边遮罩作为发光源输出到Bloom的亮度提取通道。这样描边就会拥有Bloom的颜色和模糊效果而且省去了独立的后处理Pass。但这需要修改URP的Bloom实现侵入性较强适合深度定制。分档位设置为不同性能档位的设备准备不同的参数预设。低端机上可以减小_OutlineWidth关闭深度测试甚至只对最重要的物体如主角、任务目标启用描边。6. 常见问题排查与性能优化实战即使按照步骤实现你也可能会遇到一些“坑”。这里记录了几个最常见的问题及其解决方案。6.1 描边闪烁、抖动或边缘不连续这是屏幕空间效果最常见的问题尤其在相机或物体运动时。原因一深度/ID图分辨率不足。如果第一步的ID图分辨率太低如用了1/4分辨率在运动时物体边缘在ID图上的投影会因采样位置变化而产生像素级别的“跳跃”导致检测出的边缘位置抖动。解决方案将ID图分辨率至少提升到半分辨率Half对于主要角色或关键物体可以考虑使用全分辨率。原因二没有使用抖动Dithering或TAA。在低分辨率下颜色边界会产生明显的锯齿运动时表现为闪烁。解决方案在生成ID图的Shader中加入简单的蓝噪声抖动Interleaved Gradient Noise可以有效地将锯齿转化为不易察觉的噪声。更好的方法是依赖URP的TAA时域抗锯齿它能极大地缓解边缘闪烁问题。原因三边缘检测阈值Threshold设置不当。阈值太低噪声被放大阈值太高弱边缘丢失导致边缘“时有时无”。解决方案动态调试。在Scene视图中将描边遮罩作为Debug显示观察在运动状态下边缘信号的稳定性微调阈值直到找到平衡点。6.2 描边在物体内部错误出现原因这通常是因为对象ID分配冲突或者物体自身的材质有复杂的透明或镂空部分。如果两个不同物体被分配了相同或过于接近的ID颜色它们之间的边界就无法被检测出来而物体内部因纹理或顶点颜色变化导致ID图颜色不均反而被误检为边缘。解决方案确保ID唯一性检查ID分配逻辑确保需要独立描边的物体颜色值差异足够大例如用整数ID转RGB确保R、G、B通道值有显著差距。检查材质确保用于渲染ID遮罩的材质是纯色不透明的没有使用透明度、镂空贴图Alpha Test或顶点颜色。如果原模型材质复杂可能需要一个专门的、简化版的材质球Material用于渲染遮罩。引入法线检测作为补充在极端情况下可以结合对象ID和摄像机空间法线View Space Normal来共同判断边缘。物体内部的法线变化通常是连续的而边界处的法线是突变的。将两者检测结果相乘或取最大值可以过滤掉大部分内部错误边缘。6.3 性能分析与优化策略在Unity Profiler的Rendering区域重点关注SetPass Calls增加一个Renderer Feature是否会显著增加SetPass Calls如果会检查Filtering Settings是否过于宽泛确保只渲染必要的Layer。RenderTexture分配临时RT的创建和释放是开销。确保在OutlineMaskPass中使用RenderTexture.GetTemporary和ReleaseTemporary来管理RT生命周期避免每帧分配新内存。GPU耗时在GPU Profiler中查看我们新增的OutlineMask和OutlinePostProcess两个Pass的耗时。如果OutlinePostProcess耗时过长优先考虑降低采样次数简化扩张滤波核或降低处理分辨率。一个进阶优化是基于距离的描边衰减。对于远离相机的小物体或处于视野边缘的物体其描边贡献度很低但计算开销不变。我们可以在物体渲染ID图时根据物体到相机的距离动态调整其ID颜色的Alpha值或在后处理中根据深度图衰减描边强度让远处的描边自然淡出既能维持视觉中心的效果又能节省不必要的计算。7. 效果扩展与风格化应用掌握了基础的三步法你就可以在此基础上玩出各种花样让描边更好地为你的游戏艺术风格服务。多颜色与渐变描边不再满足于单一颜色你可以在后处理合成阶段根据边缘的强度outlineMask的值或者屏幕空间位置动态改变描边颜色。例如让描边从上到下由蓝渐变到红或者让高强度边缘显示为红色低强度边缘显示为黄色。这只需要在Shader中用一个gradient采样或者简单的颜色混合公式即可实现。风格化描边虚线、点状与水墨感修改扩张滤波的算法。传统的最大值扩张产生均匀宽度的实线。我们可以尝试虚线在采样时根据像素的屏幕坐标input.positionCS.xy加上一个噪声或使用正弦函数进行调制让连续的边缘Mask产生间断。水墨感使用更复杂的噪声纹理如Perlin噪声对扩张采样进行扰动让描边边缘产生手绘般的不规则毛刺感。这通常需要额外采样一张噪声图。与Bloom、失真等后处理结合描边不应是孤立的。将描边Mask作为输入驱动其他后处理效果可以产生惊艳的联动。描边发光将描边Mask输入到Bloom的亮度提取阈值之前这样描边区域也会产生辉光尤其在暗景下效果突出。热浪扭曲在描边区域对场景颜色采样的UV坐标施加一个基于时间变化的微小扰动可以模拟出热量上升导致的扭曲效果常用于表现火焰魔法或高温物体。实现这些扩展的关键在于将第一步得到的高质量对象ID图和第二步生成的灵活的描边遮罩视为宝贵的通用数据。它们不仅仅是用于描边更是屏幕空间内“物体轮廓”信息的载体可以驱动许多其他的视觉特效从而极大地丰富你的游戏画面表现力。
返回列表