1. 为什么我最终放弃了Mesh Shader描边方案做过Unity项目的人大概都有过这种经历美术跑过来说“这个角色边缘能不能加一圈描边看起来更卡通一点”然后你打开搜索引擎发现铺天盖地都是Mesh Shader描边的教程。我一开始也是走的这条路毕竟思路很直观——把模型往外扩一圈背面渲染成描边色正面正常渲染完事。但实际用下来问题一个接一个冒出来。最典型的就是硬表面模型上的描边粗细完全失控。一个立方体顶点法线是朝六个方向的你沿着法线外扩棱角处的描边宽度直接变成面片上的好几倍看起来就像模型炸了一样。后来有人提出用平滑法线来解决也就是把顶点法线按位置加权平均一遍让棱角处的法线方向趋于一致。这个方法确实能缓解问题但代价是你得在导入模型时额外烘焙一套平滑法线数据或者写工具在运行时处理。对于大量模型的项目来说这个流程成本不低。更头疼的是描边断裂和遮挡关系。Mesh Shader描边本质上是把背面渲染出来如果模型本身有开口、有薄片结构描边就会在那些地方断掉。而且描边和场景中其他物体的遮挡关系也很难处理——描边是模型自身的一部分它没法感知场景中其他物体的深度信息所以经常出现描边穿透其他物体的情况。我印象最深的一次是做一个卡通渲染的角色展示场景角色站在一个半透明的玻璃地板上面。Mesh Shader描边直接从地板下面透出来看起来就像角色踩在一团黑色的雾气上。美术看了直接摇头说这个效果没法用。当时我试了各种办法调整渲染队列、改深度测试、甚至尝试在描边Shader里手动采样深度图但都治标不治本。后来我静下心来想了一个问题描边这个东西本质上是一个屏幕空间的视觉效果为什么非要在模型空间里做呢就像Photoshop里的描边图层样式它是在像素层面处理的跟模型的几何结构没有关系。Unity的后处理栈正好提供了这样一个屏幕空间的舞台于是我开始转向后处理描边方案。这个转变带来的好处是立竿见影的。首先描边粗细在屏幕上完全均匀不管模型多复杂、法线多乱描边宽度只跟屏幕像素有关。其次描边天然尊重场景深度关系因为后处理阶段你手里有完整的深度缓冲谁在前谁在后一目了然。最后描边效果和模型本身解耦了你可以随时开关、调整颜色和粗细不需要改模型资源。当然后处理描边也不是没有代价。它需要额外的全屏Pass对移动端性能有一定压力它需要处理深度和法线的边缘检测参数调不好容易产生噪点它还需要考虑与透明物体的交互。但这些问题是可控的、可调的而Mesh Shader描边的那些坑很多时候是方案本身的硬伤。所以这篇文章我想把后处理描边的完整实现过程拆开来讲。从深度法线纹理的获取到边缘检测算子的选择再到描边合成与性能优化每一步我都会说清楚为什么这么做、怎么做、以及我踩过哪些坑。如果你也在被Mesh Shader描边折磨希望这篇内容能帮你少走一些弯路。2. 后处理描边的整体设计思路拆解2.1 核心原理从屏幕空间找边缘后处理描边的核心思想其实很朴素在屏幕空间里物体的边缘就是深度或者法线发生剧烈变化的地方。你可以把深度图想象成一张灰度图近处的物体亮、远处的物体暗那么物体轮廓处就会有一条明显的亮度跳变。法线图也是类似的道理物体表面的朝向在边缘处会发生突变。所以整个流程可以拆成三步第一步在渲染完场景的不透明物体之后把深度和法线信息写入一张纹理第二步用一个全屏Pass读取这张纹理对每个像素检查它周围像素的深度和法线差异如果差异超过阈值就判定为边缘第三步把边缘像素和原始画面混合输出最终带描边的图像。这个思路跟Photoshop里的“查找边缘”滤镜几乎一模一样只不过Photoshop用的是RGB颜色差异我们用的是深度和法线差异。用深度和法线的好处是即使两个物体颜色完全一样只要它们前后位置不同或者表面朝向不同边缘依然能被检测出来。2.2 为什么选择后处理而不是Mesh Shader我在上一节已经吐槽了很多Mesh Shader描边的问题这里再从技术角度系统对比一下两种方案。对比维度Mesh Shader描边后处理描边描边粗细均匀性受法线影响大硬表面容易炸屏幕空间均匀与模型无关遮挡关系无法感知其他物体容易穿透基于深度缓冲遮挡关系正确透明物体支持基本无法处理可通过深度写入策略部分支持性能开销每个模型多一个Pass一次全屏Pass与模型数量无关模型资源要求需要平滑法线或额外顶点数据无特殊要求效果可调性需要改Shader或模型参数实时可调移动端适配模型多时DrawCall压力大全屏Pass对带宽有要求从表格里可以看出来后处理描边在效果质量和灵活性上全面占优唯一的劣势是移动端的全屏Pass开销。但实际测试下来只要分辨率不是特别高这个开销是完全可接受的。而且后处理描边只需要一次Pass场景里有一百个物体和有一个物体开销是一样的这一点在复杂场景里优势非常明显。2.3 深度法线纹理的获取方式选择在Unity里获取深度和法线纹理有几种方式我逐一试过这里说说我的选择。第一种方式是给Camera挂一个脚本在OnRenderImage里用CommandBuffer把深度和法线Blit出来。这种方式最灵活你可以控制什么时候抓取、抓取哪些信息。但缺点是代码量比较大而且需要自己管理RenderTexture的分配和释放。第二种方式是使用Unity内置的DepthTexture和DepthNormalsTexture。Camera组件上有一个DepthTextureMode属性设置成DepthNormals之后Unity会自动在渲染不透明物体后生成一张深度法线纹理在Shader里用_CameraDepthNormalsTexture就能访问。这种方式最省事不需要额外代码而且Unity内部做了很多优化。第三种方式是URP或者HDRP里的Renderer Feature。如果你用的是URP可以直接在Forward Renderer里添加一个Render Objects Feature把深度和法线输出到一张RT上。这种方式最规范跟管线集成得最好。我最终选择的是第二种方式也就是Camera的DepthTextureMode。原因很简单它足够用而且兼容性最好。不管你是Built-in管线还是URP这个属性都能工作。当然如果你用的是URP我更推荐用Renderer Feature的方式因为DepthTextureMode在URP下有一些限制比如不能同时获取深度和法线。注意在Unity 2021以后的版本里如果你用的是URPCamera的DepthTextureMode可能会被管线覆盖。这时候你需要检查URP Asset里的Depth Texture选项是否开启以及Renderer里是否配置了DepthNormals Pass。2.4 边缘检测算子的选型边缘检测的算子有很多种常见的有Sobel、Roberts、Prewitt、Laplacian等等。在后处理描边这个场景里我用得最多的是Roberts算子偶尔会用Sobel。Roberts算子的原理很简单它取对角线上的两个像素做差分。比如对于当前像素取它右下和左上的像素计算深度和法线的差异。这个算子的优点是计算量小只需要采样四个对角像素而且对细边缘的响应比较好。缺点是它对噪声比较敏感如果深度图有噪点描边就会断断续续。Sobel算子则是取水平和垂直方向各三个像素做加权差分计算量比Roberts大一些但抗噪能力更强边缘更连续。我在做风格化渲染的时候更倾向于用Sobel因为它的描边看起来更“实”。还有一个细节是深度和法线的权重分配。深度差异和法线差异对边缘的贡献是不一样的。深度差异主要检测物体之间的前后关系法线差异主要检测物体表面的朝向变化。在实际调参时我会把法线差异的权重调得比深度高一些因为法线对边缘更敏感而且不容易受到深度精度的影响。3. 核心细节解析与实操要点3.1 深度法线纹理的编码与解码Unity的_CameraDepthNormalsTexture并不是简单地把深度和法线分别存在不同的通道里而是做了一些编码压缩。具体来说深度信息被编码在A通道和R通道的一部分里法线信息被编码在RGB通道里。这个编码方式跟平台有关在大多数桌面平台上深度是16位精度法线是8位精度。如果你直接用tex2D采样_CameraDepthNormalsTexture然后取rgb当法线、取a当深度你会发现法线是错的深度也不对。正确的做法是使用Unity提供的DecodeDepthNormal函数它会把编码后的数据还原成线性的深度和法线。float4 depthNormal tex2D(_CameraDepthNormalsTexture, uv); float depth; float3 normal; DecodeDepthNormal(depthNormal, depth, normal);这个函数在UnityCG.cginc里定义你只需要包含这个头文件就能用。注意DecodeDepthNormal返回的深度是0到1之间的非线性深度如果你需要线性深度还得用Linear01Depth或者LinearEyeDepth转换一下。法线解码出来之后是在观察空间下的方向如果你需要世界空间法线还得用UNITY_MATRIX_I_V或者对应的矩阵转换。不过对于边缘检测来说观察空间法线已经够用了因为边缘检测只关心法线之间的差异不关心绝对方向。提示如果你发现描边在某些角度下特别粗或者特别细很可能是法线解码出了问题。检查一下你的平台是否使用了不同的编码方式比如某些移动平台会用RGBM编码。3.2 边缘检测的采样策略与阈值设定边缘检测的采样策略直接决定了描边的质量和性能。最朴素的做法是对每个像素采样它周围的八个邻居然后计算最大差异。但这样做采样次数太多全屏Pass里每个像素八次采样在移动端压力很大。我的做法是用Roberts算子只采样四个对角像素左上、右上、左下、右下。然后分别计算深度差异和法线差异取最大值作为边缘强度。float2 uv i.uv; float2 texelSize _MainTex_TexelSize.xy * _OutlineWidth; float4 tl tex2D(_CameraDepthNormalsTexture, uv float2(-texelSize.x, texelSize.y)); float4 tr tex2D(_CameraDepthNormalsTexture, uv float2(texelSize.x, texelSize.y)); float4 bl tex2D(_CameraDepthNormalsTexture, uv float2(-texelSize.x, -texelSize.y)); float4 br tex2D(_CameraDepthNormalsTexture, uv float2(texelSize.x, -texelSize.y)); float depthTL, depthTR, depthBL, depthBR; float3 normalTL, normalTR, normalBL, normalBR; DecodeDepthNormal(tl, depthTL, normalTL); DecodeDepthNormal(tr, depthTR, normalTR); DecodeDepthNormal(bl, depthBL, normalBL); DecodeDepthNormal(br, depthBR, normalBR); float depthDiff max(max(abs(depthTL - depthBR), abs(depthTR - depthBL)), 0); float normalDiff max(max(distance(normalTL, normalBR), distance(normalTR, normalBL)), 0); float edge depthDiff * _DepthThreshold normalDiff * _NormalThreshold; edge saturate(edge);这里的_OutlineWidth控制描边粗细它实际上是在调整采样偏移量。偏移量越大检测到的边缘越粗但太大也会导致描边模糊。_DepthThreshold和_NormalThreshold分别控制深度和法线的敏感度值越小越容易检测到边缘但也更容易产生噪点。调参的时候我一般先把_NormalThreshold设成0.1左右_DepthThreshold设成0.01左右然后根据实际效果微调。如果描边太粗先降_OutlineWidth如果描边断断续续降_NormalThreshold如果描边出现在不该出现的地方升_NormalThreshold。3.3 描边颜色与混合模式检测到边缘之后下一步就是把描边颜色混合到原始画面上。最简单的做法是直接用lerpfloat4 sceneColor tex2D(_MainTex, uv); float3 outlineColor _OutlineColor.rgb; float3 finalColor lerp(sceneColor.rgb, outlineColor, edge); return float4(finalColor, sceneColor.a);这种混合方式的好处是描边颜色可以完全覆盖原始颜色适合做纯色描边。但如果你想要描边带一点透明度或者想要描边颜色跟原始颜色做乘法那就需要改混合公式。我比较喜欢的一种做法是让描边颜色跟原始颜色做正片叠底这样描边会保留一些原始画面的明暗信息看起来更自然float3 finalColor sceneColor.rgb * lerp(float3(1,1,1), outlineColor, edge);还有一种做法是只保留描边的Alpha让描边作为一层叠加在画面上。这种方式适合做发光描边或者渐变描边你可以把描边颜色做成一张渐变纹理根据边缘强度采样不同的颜色。注意如果你的场景里有透明物体后处理描边会把透明物体的边缘也描出来。如果你不想描透明物体需要在深度法线Pass里把透明物体的深度写入关掉或者在边缘检测时根据深度值过滤掉透明物体所在的深度范围。3.4 描边宽度与屏幕分辨率的关系_OutlineWidth这个参数有一个很容易被忽略的问题它跟屏幕分辨率是相关的。如果你在1080p下调好了描边宽度换到4K分辨率下同样的_OutlineWidth值会让描边看起来细了一半因为采样偏移量是按像素算的而4K的像素密度是1080p的四倍。解决这个问题有两种方式。第一种是把_OutlineWidth定义成相对于屏幕高度的比例然后在Shader里乘以屏幕高度float2 texelSize _MainTex_TexelSize.xy * _OutlineWidth * _ScreenParams.y;第二种方式是直接用像素单位来定义描边宽度然后在不同分辨率下动态调整。我一般用第一种方式因为它在不同分辨率下的一致性更好。还有一个细节是描边的抗锯齿。由于边缘检测是基于像素差异的描边边缘往往会有锯齿。如果你想要平滑的描边可以在边缘检测之后做一个简单的模糊或者用SmoothStep代替Saturate来做软阈值edge smoothstep(_Threshold - _Softness, _Threshold _Softness, edge);_Softness控制边缘过渡的柔和程度值越大描边越柔和但太大也会让描边变得模糊。4. 完整实操流程与核心环节实现4.1 场景准备与相机设置在开始写代码之前先把场景和相机配置好。首先确保你的场景里有一些不透明物体最好是有硬表面和软表面混合的这样方便观察描边效果。然后选中主相机在Inspector里找到Camera组件把Depth Texture Mode设置成DepthNormals。如果你用的是URP这一步需要在URP Asset里开启Depth Texture然后在Forward Renderer里添加一个Render Objects Feature设置Event为AfterRenderingOpaques并勾选Depth和DepthNormals。具体操作是在Project窗口里找到URP Asset在General设置里勾选Depth Texture然后找到Forward Renderer点击Add Renderer Feature选择Render Objects在设置里把Event改成AfterRenderingOpaques把Depth和DepthNormals的Write勾上。这一步做完之后你可以在Frame Debugger里确认一下深度法线纹理是否已经生成。打开Frame Debugger找到AfterRenderingOpaques这个阶段应该能看到一个叫_CameraDepthNormalsTexture的渲染目标。4.2 后处理脚本的编写接下来写一个挂在相机上的后处理脚本。这个脚本的核心逻辑是在OnRenderImage里调用Graphics.Blit把原始画面和描边Shader关联起来。using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class OutlinePostProcess : MonoBehaviour { public Shader outlineShader; public Color outlineColor Color.black; [Range(0.5f, 5.0f)] public float outlineWidth 1.0f; [Range(0.0f, 1.0f)] public float depthThreshold 0.01f; [Range(0.0f, 1.0f)] public float normalThreshold 0.1f; [Range(0.0f, 1.0f)] public float softness 0.05f; private Material outlineMaterial; void OnEnable() { if (outlineShader null) { outlineShader Shader.Find(Hidden/OutlinePostProcess); } if (outlineShader ! null !outlineShader.isSupported) { enabled false; return; } outlineMaterial new Material(outlineShader); outlineMaterial.hideFlags HideFlags.HideAndDontSave; } void OnDisable() { if (outlineMaterial ! null) { DestroyImmediate(outlineMaterial); } } void OnRenderImage(RenderTexture source, RenderTexture destination) { if (outlineMaterial null) { Graphics.Blit(source, destination); return; } outlineMaterial.SetColor(_OutlineColor, outlineColor); outlineMaterial.SetFloat(_OutlineWidth, outlineWidth); outlineMaterial.SetFloat(_DepthThreshold, depthThreshold); outlineMaterial.SetFloat(_NormalThreshold, normalThreshold); outlineMaterial.SetFloat(_Softness, softness); Graphics.Blit(source, destination, outlineMaterial); } }这个脚本里我用了ExecuteInEditMode这样在编辑器里调参数的时候能实时看到效果不用每次都运行。注意OnEnable里检查了Shader的isSupported如果当前平台不支持这个Shader就直接禁用组件避免报错。4.3 描边Shader的完整实现Shader部分我分成两个Pass第一个Pass负责边缘检测第二个Pass负责混合。不过实际上用一个Pass就够了因为边缘检测和混合可以在同一个全屏Pass里完成。Shader Hidden/OutlinePostProcess { Properties { _MainTex (Texture, 2D) white {} _OutlineColor (Outline Color, Color) (0,0,0,1) _OutlineWidth (Outline Width, Float) 1.0 _DepthThreshold (Depth Threshold, Float) 0.01 _NormalThreshold (Normal Threshold, Float) 0.1 _Softness (Softness, Float) 0.05 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; sampler2D _CameraDepthNormalsTexture; fixed4 _OutlineColor; float _OutlineWidth; float _DepthThreshold; float _NormalThreshold; float _Softness; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } float RobertsCross(float2 uv, float2 texelSize) { float4 tl tex2D(_CameraDepthNormalsTexture, uv float2(-texelSize.x, texelSize.y)); float4 tr tex2D(_CameraDepthNormalsTexture, uv float2(texelSize.x, texelSize.y)); float4 bl tex2D(_CameraDepthNormalsTexture, uv float2(-texelSize.x, -texelSize.y)); float4 br tex2D(_CameraDepthNormalsTexture, uv float2(texelSize.x, -texelSize.y)); float depthTL, depthTR, depthBL, depthBR; float3 normalTL, normalTR, normalBL, normalBR; DecodeDepthNormal(tl, depthTL, normalTL); DecodeDepthNormal(tr, depthTR, normalTR); DecodeDepthNormal(bl, depthBL, normalBL); DecodeDepthNormal(br, depthBR, normalBR); float depthDiff max(abs(depthTL - depthBR), abs(depthTR - depthBL)); float normalDiff max(distance(normalTL, normalBR), distance(normalTR, normalBL)); return depthDiff * _DepthThreshold normalDiff * _NormalThreshold; } fixed4 frag(v2f i) : SV_Target { float2 texelSize _MainTex_TexelSize.xy * _OutlineWidth * _ScreenParams.y; float edge RobertsCross(i.uv, texelSize); edge smoothstep(0.0, _Softness, edge); fixed4 sceneColor tex2D(_MainTex, i.uv); fixed3 finalColor lerp(sceneColor.rgb, _OutlineColor.rgb, edge * _OutlineColor.a); return fixed4(finalColor, sceneColor.a); } ENDCG } } }这个Shader里我做了几件事首先用RobertsCross函数计算边缘强度然后把边缘强度用smoothstep做软阈值处理最后用lerp把描边颜色混合到原始画面上。_OutlineColor.a控制描边的整体透明度如果你想要描边半透明把这个值调低就行。提示如果你发现描边在物体内部也出现了很可能是深度阈值设得太低把物体表面的微小深度变化也当成了边缘。这时候把_DepthThreshold调大一些或者把_NormalThreshold调大一些。4.4 参数调试与效果优化Shader写完之后最关键的一步是调参。我一般按照这个顺序来调第一步先把_OutlineWidth设成1.0_DepthThreshold设成0.01_NormalThreshold设成0.1_Softness设成0.05看看基础效果。这时候应该能看到物体边缘有黑色的描边但可能有些地方断断续续有些地方又太粗。第二步调整_OutlineWidth。如果描边太粗降到0.5左右如果太细升到2.0左右。注意_OutlineWidth是相对于屏幕高度的比例所以1.0意味着采样偏移量是屏幕高度的百分之一。第三步调整_NormalThreshold。如果描边断裂把值降到0.05如果描边出现在物体内部把值升到0.2。这个参数对描边质量的影响最大需要耐心微调。第四步调整_DepthThreshold。如果描边穿透了前后物体把值升到0.05如果描边在深度变化平缓的地方消失了把值降到0.005。这个参数主要影响物体之间的边缘检测。第五步调整_Softness。如果描边边缘有锯齿把值升到0.1如果描边太模糊把值降到0.01。这个参数控制描边的柔和程度对性能没有影响。调参的时候我建议用一个有代表性的场景里面既有硬表面物体比如立方体也有软表面物体比如球体还有前后遮挡关系。这样调出来的参数适应性更强。5. 常见问题与排查技巧实录5.1 描边不显示或全屏都是描边这是最常见的问题通常有几个原因。第一个原因是深度法线纹理没有正确生成。你可以在Frame Debugger里检查一下看看AfterRenderingOpaques阶段有没有_CameraDepthNormalsTexture这个渲染目标。如果没有检查相机的DepthTextureMode是否设置正确或者URP Asset里的Depth Texture是否开启。第二个原因是Shader里的_CameraDepthNormalsTexture采样不到数据。这通常是因为你在Shader里声明了sampler2D _CameraDepthNormalsTexture但没有在脚本里设置相机的DepthTextureMode。Unity不会自动为你生成这个纹理你必须显式地告诉相机你需要它。第三个原因是阈值设得太低或太高。如果_DepthThreshold和_NormalThreshold都设成0那么任何微小的差异都会被判定为边缘结果就是全屏都是描边。反过来如果设成1那就什么都检测不到。我建议从0.01和0.1开始调。5.2 描边在透明物体上表现异常后处理描边默认会把所有写入深度缓冲的物体都描出来包括透明物体。但透明物体通常不写入深度所以它们的边缘检测结果可能很奇怪。如果你不想描透明物体有两个办法。第一个办法是在深度法线Pass里把透明物体的渲染队列排除掉。在URP里你可以在Render Objects Feature里设置Layer Mask只渲染不透明层。在Built-in管线里你可以在Shader里把透明物体的Queue设成Transparent然后相机的DepthTextureMode只会渲染Opaque队列的物体。第二个办法是在边缘检测时根据深度值过滤。透明物体的深度通常跟背景深度很接近你可以在Shader里加一个判断如果深度值超过某个范围就不做边缘检测。但这个办法不太精确因为透明物体的深度范围很难界定。5.3 描边在移动端性能不佳后处理描边的性能瓶颈主要在两个方面全屏Pass的填充率和深度法线纹理的带宽。在移动端这两个方面都需要特别注意。对于填充率你可以降低描边Pass的分辨率。比如把描边渲染到一张半分辨率的RT上然后再上采样混合到全屏。这样做描边会稍微模糊一点但性能提升很明显。具体做法是在OnRenderImage里创建一个半分辨率的RenderTexture把Graphics.Blit的目标改成这个RT然后再用另一个Pass把它混合到屏幕上。对于带宽你可以把深度法线纹理的格式从ARGB32改成RHalf或者RGHalf减少每个像素的字节数。但要注意改变格式可能会影响深度和法线的精度需要实际测试一下效果。还有一个优化点是减少采样次数。Roberts算子只需要四次采样已经很少了。如果你还想再省可以只采样两个对角像素但这样边缘检测的准确度会下降。5.4 描边颜色与场景颜色不协调有时候描边颜色跟场景颜色搭配起来很突兀特别是当场景颜色比较丰富的时候。这时候可以试试让描边颜色跟场景颜色做混合而不是直接覆盖。我常用的一个技巧是让描边颜色乘以场景颜色的亮度。这样描边会保留场景的明暗信息看起来更自然float luminance dot(sceneColor.rgb, float3(0.299, 0.587, 0.114)); float3 outlineTint _OutlineColor.rgb * luminance; float3 finalColor lerp(sceneColor.rgb, outlineTint, edge);还有一个技巧是让描边颜色根据边缘强度渐变。比如边缘强的地方用深色边缘弱的地方用浅色。这样描边会有一种手绘的感觉float3 outlineColor lerp(_OutlineColorLight.rgb, _OutlineColorDark.rgb, edge);5.5 常见问题速查表问题现象可能原因排查方法解决方案描边完全不显示深度法线纹理未生成Frame Debugger检查设置DepthTextureMode或URP Depth Texture全屏都是描边阈值设得太低检查_DepthThreshold和_NormalThreshold调高阈值到0.01和0.1描边断断续续法线阈值太高观察描边断裂处的法线变化降低_NormalThreshold描边穿透物体深度阈值太低检查前后物体的深度差异提高_DepthThreshold描边有锯齿没有做软阈值观察描边边缘增加_Softness移动端帧率下降全屏Pass开销大Profiler检查降低描边分辨率或减少采样透明物体描边异常透明物体写入深度检查透明物体的深度写入排除透明层或过滤深度范围描边颜色不协调直接覆盖场景颜色观察描边与场景的对比用亮度混合或渐变颜色5.6 我踩过的几个坑第一个坑是深度法线纹理的精度问题。在有些平台上_CameraDepthNormalsTexture的深度精度只有16位对于远距离的物体深度差异可能小于精度导致描边检测不到。解决办法是尽量把相机的远裁剪面调近一些或者用LinearEyeDepth转换后再做差异计算。第二个坑是法线解码的平台差异。在有些移动平台上法线是用RGBM编码的DecodeDepthNormal函数可能不适用。这时候你需要自己写解码逻辑或者改用其他方式获取法线。我一般会在项目初期就在目标平台上测试一下确认解码是否正确。第三个坑是描边宽度跟分辨率的关系。我一开始没有把_OutlineWidth乘以_ScreenParams.y结果在编辑器里调好的效果打包到手机上完全不一样。后来统一改成按屏幕高度比例计算才解决了这个问题。第四个坑是描边跟后处理栈的顺序。如果你的项目里还有其他后处理效果比如Bloom、Color Grading描边的顺序会影响最终效果。我一般把描边放在Bloom之前这样描边也会被Bloom影响看起来更融合。如果你想让描边不受其他后处理影响就把它放在最后。第五个坑是Shader的变体管理。后处理Shader通常会有很多关键字变体如果不做变体剔除打包出来的包体会很大。我一般会在Shader里用#pragma multi_compile_local来声明变体然后在Project Settings里做变体剔除。6. 后处理描边的扩展玩法6.1 彩色描边与渐变描边基础的黑色描边做出来之后可以试试彩色描边。最简单的做法是把_OutlineColor改成你想要的颜色比如红色、蓝色、金色。但纯色描边有时候会显得单调你可以试试渐变描边。渐变描边的思路是根据边缘的朝向或者屏幕位置来采样不同的颜色。比如根据法线的方向来决定描边颜色朝上的边缘用暖色朝下的边缘用冷色。或者根据屏幕UV来决定颜色左边用红色右边用蓝色。float3 outlineColor lerp(_OutlineColorA.rgb, _OutlineColorB.rgb, i.uv.x);这个效果在风格化渲染里很常见可以让描边看起来更有层次感。6.2 描边与内发光的组合描边和内发光经常一起使用特别是在做卡通渲染或者科幻风格的时候。内发光的原理跟描边类似也是基于深度法线纹理只不过它检测的是物体内部的边缘而不是物体之间的边缘。具体做法是在边缘检测之后把边缘强度取反然后乘以发光颜色叠加到原始画面上。这样物体内部会有一种微微发光的感觉跟描边搭配起来效果很好。float innerGlow 1.0 - edge; float3 glowColor _GlowColor.rgb * innerGlow * _GlowIntensity; finalColor glowColor;注意内发光的强度要控制好太强了会过曝太弱了看不出来。我一般把_GlowIntensity设在0.5到2.0之间。6.3 描边在UI和2D场景中的应用后处理描边不仅可以用在3D场景里也可以用在UI和2D场景里。对于UI元素你可以把UI渲染到一张RT上然后用同样的描边Shader处理。对于2D Sprite你可以把Sprite的深度写入打开这样后处理描边就能检测到Sprite的边缘。不过UI和2D场景的深度关系通常比较简单用后处理描边可能有点杀鸡用牛刀。如果你只是想做UI描边用Shader里的顶点外扩或者Image组件的Outline效果可能更简单。但如果你想要UI和3D场景统一的描边风格后处理描边是更好的选择。6.4 描边性能的进一步优化如果你的项目对性能要求很高可以试试这几个优化手段。第一个手段是降低描边Pass的分辨率。把描边渲染到半分辨率或者四分之一分辨率的RT上然后再上采样。这样做描边会稍微模糊但性能提升很明显。实测下来半分辨率描边的性能开销只有全分辨率的四分之一左右。第二个手段是只在需要描边的区域做边缘检测。你可以用一个Mask纹理来标记哪些区域需要描边然后在Shader里根据Mask来决定是否做边缘检测。这个手段适合描边区域比较固定的场景比如只有角色需要描边背景不需要。第三个手段是用Compute Shader来做边缘检测。Compute Shader的并行度更高而且可以更好地利用GPU的缓存。不过Compute Shader的兼容性不如Fragment Shader需要根据目标平台来选择。第四个手段是把描边跟其他后处理效果合并到一个Pass里。比如描边和Bloom可以共用一个全屏Pass减少RT的切换和带宽消耗。这个手段需要你对后处理管线比较熟悉实现起来稍微复杂一些。6.5 描边效果的风格化调整后处理描边的一个好处是它很容易做风格化调整。你可以通过改Shader来实现各种不同的描边风格。比如想要手绘风格的描边可以在边缘检测之后加一点噪声让描边边缘不那么规整。噪声可以用一张噪声纹理或者用Mathf.PerlinNoise生成。float noise tex2D(_NoiseTex, i.uv * _NoiseScale).r; edge saturate(edge (noise - 0.5) * _NoiseStrength);想要铅笔风格的描边可以把描边颜色改成灰色然后根据边缘强度调整透明度让描边看起来像铅笔素描。想要霓虹灯风格的描边可以把描边颜色改成高饱和度的颜色然后叠加Bloom效果让描边发光。这些风格化调整都不需要改模型或者改渲染管线只需要改后处理Shader就行非常灵活。6.6 与其他描边方案的混合使用后处理描边虽然好用但也不是万能的。有些场景下Mesh Shader描边反而更合适。比如你需要描边有厚度感或者描边需要跟随模型的动画变形这时候Mesh Shader描边可能更好。我实际项目里有时候会把两种方案混合使用。比如角色用Mesh Shader描边因为角色的描边需要跟随骨骼动画场景物体用后处理描边因为场景物体的描边只需要屏幕空间的效果。这样各取所长效果和性能都能兼顾。混合使用的关键是控制好两种描边的颜色和粗细让它们看起来像是一套系统。我一般会把两种描边的颜色设成一样的粗细也调到接近这样视觉上比较统一。7. 一些实操心得与建议后处理描边这个方案我从第一次尝试到现在差不多用了两三年时间中间踩了不少坑也积累了一些经验。这里分享几个我觉得比较有用的心得。第一个心得是关于深度法线纹理的获取时机。如果你在URP里用Render Objects Feature来获取深度法线注意把Event设在AfterRenderingOpaques而不是BeforeRenderingOpaques。设在Before的话深度法线纹理里只有天空盒的深度什么都检测不到。这个坑我踩过好几次每次都要重新查文档。第二个心得是关于描边宽度的单位。我建议把_OutlineWidth定义成相对于屏幕高度的比例而不是绝对像素值。这样在不同分辨率下描边宽度是一致的不需要针对每个分辨率单独调参。具体做法就是在Shader里把_OutlineWidth乘以_ScreenParams.y。第三个心得是关于阈值的调试。我建议在编辑器里做一个实时预览的窗口把深度图、法线图、边缘检测结果都显示出来。这样调参的时候能直观地看到每个参数的影响比盲调快很多。具体做法是写一个Editor脚本用Graphics.Blit把中间结果渲染到EditorWindow里。第四个心得是关于性能测试。后处理描边的性能开销跟分辨率成正比所以在低端设备上测试的时候一定要用目标设备的分辨率来测不要用编辑器的分辨率。我一般会在真机上用Profiler跑一遍看看描边Pass占了多少毫秒然后决定是否需要优化。第五个心得是关于Shader的兼容性。后处理Shader里用到的_CameraDepthNormalsTexture和DecodeDepthNormal函数在不同Unity版本和不同渲染管线下的行为可能不一样。我建议在项目初期就确定好目标Unity版本和渲染管线然后针对这个组合来写Shader。如果后期需要换管线描边Shader可能需要重写。第六个心得是关于描边的美术效果。技术实现只是基础真正让描边好看的是美术调参。我一般会让美术同学参与调参让他们决定描边的颜色、粗细、软硬度。技术同学负责保证描边在不同场景下都能稳定工作美术同学负责让描边看起来舒服。这样分工合作效果最好。最后再分享一个小技巧如果你觉得后处理描边的边缘不够锐利可以试试在边缘检测之前先对深度法线纹理做一个锐化。锐化的原理很简单就是把当前像素的深度法线值减去周围像素的平均值然后乘上一个系数加回去。这样边缘处的差异会被放大描边会更明显。不过锐化也会放大噪声所以系数不要设太大一般0.5到1.0之间就够了。