
1. 为什么我最终放弃了Mesh Shader描边方案做过Unity项目的人大概都有过这种经历美术跑过来说“这个角色边缘能不能加一圈发光描边”然后你打开搜索引擎翻到一篇讲Mesh Shader描边的教程照着做了一遍发现效果还行但项目一跑起来帧率掉了十几帧或者在某些机型上直接花屏。我在三个不同类型的项目里都踩过这个坑最后统一换成了后处理描边方案才算是把这个问题彻底按住了。这篇内容主要聊的就是Unity后处理描边的完整实现路径以及为什么我建议你在大多数场景下优先考虑它而不是Mesh Shader方案。核心关键词包括Unity、后处理描边、Mesh Shader、屏幕空间、Shader涉及的技术点覆盖从深度纹理采样、法线重建、边缘检测算子选择到最终描边颜色混合的全流程。适合有一定Shader基础、正在做Unity渲染效果、或者被描边需求折磨过的开发者阅读。即使你之前没写过后处理Shader跟着思路走也能理解整个链路。先说结论Mesh Shader描边也就是常说的背面外扩法在特定条件下确实好用比如单个角色展示、模型面数低、不需要处理遮挡关系。但一旦进入实际游戏场景——多角色同屏、复杂遮挡、移动端性能敏感——它的短板就会集中爆发。后处理描边虽然也有自己的局限但综合来看在通用性和可控性上明显更胜一筹。2. 两种描边方案的核心差异与选型逻辑2.1 Mesh Shader描边到底在做什么Mesh Shader描边的原理其实很直观把模型再渲染一遍沿着法线方向向外扩一圈只渲染背面然后用纯色填充看起来就像给物体描了个边。具体操作通常是在Shader里加一个Pass设置Cull Front在顶点着色器里把顶点位置沿法线方向偏移一定距离。这个方案的优势在于实现简单、不依赖后处理管线、对单个物体的描边效果非常精确。但它的问题也很明显法线不连续的地方会断裂比如立方体的硬边顶点法线是分裂的外扩之后边角会出现缺口。虽然可以通过平滑法线或者存储额外的平滑法线信息来解决但这又增加了模型处理的成本。描边宽度不统一因为外扩距离是在模型空间计算的距离摄像机越远屏幕上的描边越细距离越近描边越粗。想要屏幕空间等宽描边还得在顶点着色器里做额外的距离补偿计算。遮挡关系处理麻烦如果角色被其他物体挡住Mesh Shader描边依然会渲染出来因为它是独立的一个Pass不参与深度测试的遮挡判断除非你手动处理。结果就是角色被墙挡住了描边还浮在墙前面。多物体叠加时性能堪忧每个需要描边的物体都要多一个Draw Call场景里几十个物体需要描边Draw Call直接翻倍。我之前做过一个塔防项目场上同时有三十多个敌人需要描边用Mesh Shader方案在移动端直接掉到二十多帧。后来换成后处理帧率回到了五十帧以上。2.2 后处理描边的核心思路后处理描边的逻辑完全不同它不关心场景里有多少个物体需要描边而是在整个场景渲染完成之后拿深度纹理和法线纹理做一次全屏的边缘检测把检测到的边缘像素涂上描边颜色。这个方案的核心优势屏幕空间等宽不管物体距离摄像机多远描边宽度在屏幕上是一致的视觉上更统一。天然处理遮挡因为是基于深度纹理做边缘检测被遮挡的物体边缘不会产生描边遮挡关系自动正确。性能可控一次全屏Pass搞定所有物体的描边Draw Call不随物体数量增加。风格统一适合卡通渲染、二次元风格等需要全局统一描边的场景。当然它也有代价需要额外的深度纹理和法线纹理对不支持深度纹理的平台需要做兼容处理边缘检测的精度受限于屏幕分辨率极细的描边可能会有锯齿。2.3 选型决策表对比维度Mesh Shader描边后处理描边实现复杂度低中屏幕空间等宽需额外计算天然支持遮挡处理需手动处理自动正确多物体性能Draw Call翻倍一次Pass搞定法线断裂问题存在不存在移动端兼容性好需深度纹理支持适合场景单物体展示、低面数模型多物体、复杂场景、卡通渲染我个人的经验是如果你的项目里需要描边的物体超过五个或者有复杂的遮挡关系直接上后处理方案别犹豫。如果只是UI里展示一个角色模型Mesh Shader方案更省事。3. 后处理描边的完整实现链路3.1 摄像机深度纹理的获取与配置后处理描边的第一步是拿到深度纹理。Unity里获取深度纹理的方式取决于你的渲染管线Built-in管线在摄像机的脚本里设置Camera.main.depthTextureMode | DepthTextureMode.Depth然后在Shader里声明_CameraDepthTexture即可采样。如果需要法线信息还要加上DepthTextureMode.DepthNormals。URP管线URP默认不生成深度纹理需要在URP Asset里勾选Depth Texture选项。然后在Shader里通过_CameraDepthTexture采样。法线纹理在URP里需要通过_CameraNormalsTexture获取但要注意URP的法线纹理编码方式和Built-in不同。这里有个坑我踩过URP下如果没勾选Depth Texture_CameraDepthTexture采样出来的全是1边缘检测完全失效但Shader不会报错排查起来很费时间。建议在Renderer Feature的初始化阶段加一个检查如果深度纹理未启用就输出警告。// URP下检查深度纹理是否启用的示例 if (asset.supportsCameraDepthTexture false) { Debug.LogWarning(URP Asset未启用Depth Texture后处理描边将无法正常工作); }深度纹理的采样需要注意平台差异。在OpenGL ES上深度值范围是0到1在DirectX上也是0到1但某些平台可能有反转。Unity提供了UNITY_REVERSED_Z宏来处理这个问题写Shader时最好用LinearEyeDepth或Linear01Depth函数做转换不要手动去算。3.2 法线信息的重建与边缘检测算子选择后处理描边的边缘检测通常用Roberts算子、Sobel算子或者简单的深度差分。我试过几种方案最后稳定用的是深度法线双通道检测。纯深度检测的问题是两个物体深度相同但法线方向差异很大的地方比如桌面上放一个盒子深度差分可能检测不到边缘。纯法线检测的问题是深度突变但法线连续的地方比如两个平行平面一前一后法线差分也检测不到。所以两者结合是最稳妥的。具体做法是在片元着色器里采样当前像素周围四个对角方向的深度和法线分别计算深度差值和法线差值然后取两者的最大值或者加权和作为边缘强度。// 简化的边缘检测核心逻辑 float depthCenter SampleDepth(uv); float depthRight SampleDepth(uv float2(_TexelSize.x, 0)); float depthUp SampleDepth(uv float2(0, _TexelSize.y)); float depthEdge abs(depthCenter - depthRight) abs(depthCenter - depthUp); float3 normalCenter SampleNormal(uv); float3 normalRight SampleNormal(uv float2(_TexelSize.x, 0)); float3 normalUp SampleNormal(uv float2(0, _TexelSize.y)); float normalEdge 1.0 - saturate(dot(normalCenter, normalRight)) 1.0 - saturate(dot(normalCenter, normalUp)); float edge max(depthEdge * _DepthThreshold, normalEdge * _NormalThreshold);这里_DepthThreshold和_NormalThreshold是两个关键参数。深度阈值控制多深的深度差才算边缘法线阈值控制多大的法线夹角才算边缘。这两个值需要根据场景尺度来调没有万能值。我的经验是深度阈值从0.01开始试法线阈值从0.1开始试。如果描边太粗说明阈值太小把边缘检测的灵敏度降低如果描边断断续续说明阈值太大需要调小。3.3 描边颜色的混合与风格化处理检测到边缘之后最后一步是把描边颜色混合到原图上。最简单的做法是lerp(originalColor, outlineColor, edge)但这样描边会显得很生硬。我通常会用step或者smoothstep做一个硬边缘或者软边缘的过渡float outlineMask step(_EdgeThreshold, edge); float3 finalColor lerp(originalColor, _OutlineColor.rgb, outlineMask);如果想要描边有发光效果可以在描边颜色上乘以一个强度系数然后在后处理链的最后加一个Bloom。如果想要描边有手绘感可以在边缘检测的时候加入一点噪声扰动让描边线条不那么规整。还有一个细节描边颜色最好支持透明度。有些场景下我们希望描边是半透明的比如角色被遮挡时描边淡出。这时候可以用_OutlineColor.a来控制混合强度。4. 实操步骤从零搭建后处理描边系统4.1 环境准备与管线配置假设你用的是URP管线目前大多数新项目都是URP完整步骤如下在URP Asset中勾选Depth Texture和Opaque Texture如果不需要不透明纹理可以只勾深度。创建一个新的Renderer Feature脚本继承ScriptableRendererFeature。在Create方法里实例化自定义的Pass。在AddRenderPasses方法里把Pass加入到渲染队列插入位置选RenderPassEvent.AfterRenderingPostProcessing或者BeforeRenderingPostProcessing取决于你想要描边在Bloom之前还是之后。这里有个关键点描边Pass的执行顺序会影响最终效果。如果描边在Bloom之前描边会被Bloom影响产生辉光如果在Bloom之后描边就是干净的线条。我一般把描边放在Bloom之前这样描边也能有发光效果看起来更柔和。4.2 深度与法线纹理的采样实现在Pass的Execute方法里需要设置渲染目标并执行Blitpublic override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd CommandBufferPool.Get(OutlinePass); RenderTargetIdentifier source renderingData.cameraData.renderer.cameraColorTarget; int tempRT Shader.PropertyToID(_TempOutlineRT); cmd.GetTemporaryRT(tempRT, renderingData.cameraData.cameraTargetDescriptor); cmd.Blit(source, tempRT, _outlineMaterial); cmd.Blit(tempRT, source); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); }在Shader里深度纹理的采样要注意平台差异#if UNITY_REVERSED_Z float depth SampleSceneDepth(uv); #else float depth SampleSceneDepth(uv); depth 1.0 - depth; #endif法线纹理在URP里的编码方式是八面体编码需要用DecodeFromNormalBuffer函数解码。这个函数在UnityCG.cginc或者URP的Core.hlsl里有定义直接调用就行。4.3 边缘检测参数调优与效果验证参数调优是后处理描边最耗时的环节。我一般会做一个调试面板实时调整深度阈值、法线阈值、描边宽度、描边颜色这几个参数然后在场景里跑一圈看效果。描边宽度通过采样偏移量来控制。偏移量越大描边越粗但计算量也越大。我通常用_TexelSize乘以一个_OutlineWidth系数float2 offset _TexelSize * _OutlineWidth;_OutlineWidth设为1的时候是1像素宽设为2就是2像素宽。移动端建议不要超过2否则边缘检测的采样次数会增加性能下降明显。验证效果的时候要注意几个场景角色站在地面上看脚底和地面的交界处是否有描边角色被墙壁遮挡看遮挡边缘是否干净多个角色重叠看重叠区域的描边是否正确摄像机拉远拉近看描边宽度是否保持一致我实测下来深度阈值0.02、法线阈值0.15、描边宽度1.5、描边颜色纯黑在大多数卡通渲染场景里效果都不错。当然具体项目还是要微调。5. 常见问题与排查技巧实录5.1 描边不显示或显示异常的排查路径问题一描边完全不出来先检查深度纹理是否启用。Built-in管线看摄像机的depthTextureModeURP看URP Asset的Depth Texture选项。如果深度纹理没启用_CameraDepthTexture采样出来全是1边缘检测自然失效。然后检查Shader的渲染队列和Blend模式。后处理Shader一般设ZTest Always、ZWrite Off、Cull OffBlend设为Off或者SrcAlpha OneMinusSrcAlpha。如果Blend设错了描边可能被原图覆盖。问题二描边断断续续大概率是阈值设得太高。把深度阈值和法线阈值都调小试试。如果调小之后描边变粗但不断了说明阈值确实偏高。另外检查一下法线纹理的采样是否正确URP下法线纹理的编码方式如果解错了法线差值计算会完全乱掉。问题三描边在移动端花屏移动端对深度纹理的精度支持参差不齐。有些低端机只支持16位深度深度差值计算容易产生精度问题。解决办法是把深度阈值调大一些或者改用法线检测为主、深度检测为辅的策略。5.2 性能优化与移动端适配要点后处理描边的性能开销主要来自全屏Pass的采样次数。每个像素要采样周围四个方向的深度和法线一共八次采样。在移动端这个开销不算小。优化手段降低采样次数只用两个对角方向做检测而不是四个方向。效果会差一点但性能提升明显。半分辨率渲染把描边Pass渲染到半分辨率的RT上然后再上采样混合。描边本身是低频信息半分辨率损失不大。动态开关在设置里给玩家一个描边开关低端机关闭描边。限制描边物体通过LayerMask只对特定层级的物体做描边减少无效计算。我在一个移动端项目里用半分辨率两方向采样描边Pass的开销从2.3ms降到了0.8ms视觉上几乎看不出差别。5.3 与其他后处理效果的兼容处理后处理描边和Bloom、景深、颜色校正这些效果的顺序很重要。我的建议顺序是颜色校正描边Bloom景深最终输出描边放在Bloom之前描边会被Bloom影响产生辉光适合发光描边风格。如果放在Bloom之后描边就是干净的线条。景深放在描边之后描边会被景深模糊适合远景描边淡出的效果。还有一个坑如果项目里用了多个Renderer Feature要确保描边Pass的RenderPassEvent和其他Pass不冲突。我遇到过描边Pass和Bloom Pass抢同一个RT导致画面闪烁的问题后来把描边的执行时机调整到Bloom之前就解决了。6. 一些实战中的经验补充后处理描边这套方案我在三个项目里用过从PC端到移动端都有覆盖。最大的体会是参数调优比代码实现更花时间。代码写完之后真正让效果看起来舒服的是那组阈值参数而每个项目的场景尺度、模型风格都不一样没有一套参数能通吃。另外如果你的项目是二次元风格描边颜色不一定要用纯黑。深棕色、深蓝色甚至深紫色在某些场景下比纯黑更自然。我试过用#1A1A2E这种深蓝紫色做描边配合暖色调的场景效果比纯黑柔和很多。还有一个细节描边的边缘检测对法线纹理的精度很敏感。如果模型法线贴图有接缝或者法线不连续描边会在这些地方产生噪点。解决办法是在边缘检测之前对法线纹理做一次轻微的模糊或者提高法线阈值的容差。最后分享一个调试技巧在Scene视图里用Frame Debugger逐步查看后处理链的每一步输出能快速定位是哪个环节出了问题。比在Game视图里盲猜效率高得多。