
简介面向Unity开发者的描边效果资源包仿照Highlighting System提供完整的Outline Effect实现方案适用于角色高亮、交互对象提示、场景元素边界强调等常见3D渲染需求。内容围绕Shader着色器技术展开详细讲解通过法线贴图或深度贴图检测模型边缘并生成轮廓的原理覆盖描边宽度、颜色、透明度等参数的调节方式同时介绍Shader Graph可视化编辑工具的使用思路以及利用LOD系统按距离动态调整描边效果的性能优化技巧帮助初级到中级开发者从原理到实践完整掌握该特效。压缩包共582个文件以shader着色器、cs脚本、mat材质、asset资源、txt说明、png与exr贴图、unity场景为主要类型整体仅4.9MB目录结构清晰便于按模块查阅和学习。目前已有172人学习使用适合希望提升游戏视觉表现力并深入理解Unity渲染细节的开发者。1. 拿到 OutlineEffect 不急着解压这一行文件名背后是什么坑我最早从同事手里接到一个名为 OutlineEffect 的压缩包时以为就是一个“拖进工程就能用的描边特效”。结果把它当成黑匣子直接导入我们的模拟项目X第一帧就翻车——角色模型边缘出现了巨大的黑色劈裂整张脸都被挤开网格像炸了一样。后来才明白叫 OutlineEffect 的资源包在不同渲染管线、不同实现方案下完全是两码事有的靠顶点挤压有的靠屏幕后处理有的依赖深度纹理有的只是给编辑器选中框用的。这次就把我按这个标题整理出的判断思路、复现步骤和踩坑记录写下来。这类包解决的核心问题很简单让某个物体或整帧画面的边缘出现可控的轮廓线用来做选中提示、击杀高亮、NPC 标记、RTS 框选视觉反馈。适合做玩法表现、编辑器工具的开发者也适合刚开始碰渲染的同学。接下来我先讲方案选型再给能落地的代码最后说清楚调参和翻车点。2. 描边效果包的三种实现路径先看清方案再决定怎么用2.1 法线外扩最经典的轮廓描边代价是网格处理法线外扩是最直接、也最常见的描边实现。做法是把模型的顶点沿着他的法线方向向外推一点点先渲染这个被“放大”的背面再正常渲染模型正面背壳露出来的那一圈就是轮廓。很多单物体描边资源包都采用这个思路因为实现简单、颜色纯粹、不依赖相机深度纹理而且只影响挂了对应材质的物体。优势很明显第一效果独立不会误伤场景里其他物体第二背壳颜色可以任意指定不需要额外计算第三渲染流程里只要多一个 Pass内置管线和 URP 都能跑。代价也藏得很深模型如果有非等比缩放法线方向被拉伸后挤出就会歪半边脸凹陷处会漏描边宽度在模型空间是固定距离离相机越近的物体屏幕上看起来线越宽。做单物体高亮时我一般优先选这个方案。它适合任务目标、NPC 提示、可交互物品的描边。要注意的是半透明物体和带蒙皮的骨骼动画模型容易出现破壳尤其是嘴唇、眼眶这类凹面较多的角色。蒙皮网格需要在顶点动画之后取法线但描边 Pass 如果不加顶点动画同步处理轮廓就会飘在皮肤上动画里特别明显。2.2 后处理深度边缘检测全屏统一描边RTS 框选的首选法线外扩解决不了“全场景所有物体统一描边”的需求。RTS 框选时你希望被框住的建筑、单位、地形边缘同时亮起来总不能一个个改材质。所以更常见的方案是屏幕后处理拿到相机渲染好的画面以及深度纹理或法线纹理通过边缘检测算子找出像素不连续的位置再用描边色覆盖上去。边缘检测的基本原理是卷积。对当前像素周围一圈的亮度或深度做差分如果某个方向的梯度超过阈值就认为当前像素属于边缘。好处是描边宽度在屏幕空间是一致的不会出现近粗远细的问题场景里任何物体只要和背景存在深度差或法线差就会被自动描上。坏处也很突出它是一个全屏效果所有像素都要执行卷积计算移动端压力大同时深度和法线的细微变化会产生误判比如地上的低矮草丛、墙面斜射的暗部渐变都可能被描边。这种方案适合框选、扫描特效、解谜游戏的高亮物体。因为它是后期叠加所以不会破坏模型原本的 Shader和 HDR、抗锯齿的兼容性也比法线外扩好。不过要单独给某个物体描边就得配合 Stencil 或 ID 索引复杂度会上一档。2.3 怎么靠文件内容快速识别包内方案拿到 OutlineEffect 这类压缩包不要急着拖进工程。先解压看一眼文件结构能省下后面一整天排查时间。我一般会看三个特征。第一个特征是看有没有明显带“Backface”“DrawOutline”“Extrude”字样的 Shader 文件或者一个独立描边 Shader 里写了两个 Pass其中一个是Cull Front。如果有基本就是法线外扩方案。第二个特征是看是否有挂相机的 C# 脚本脚本里如果不依赖 MeshRenderer 而是处理RenderTexture那多半是后处理方案脚本里如果出现了depthTextureMode、_CameraDepthTexture、_CameraNormalsTexture那肯定依赖深度或法线纹理。第三个特征是看渲染管线标签有RenderFeature、ScriptableRendererFeature的是 URP 专用有OnRenderImage的是内置管线专用。下表是我常用的快速判断方式文件特征对应方案使用前提一个 Shader 含两个 Pass其中Cull Front法线外扩单物体高亮模型无明显凹面一个挂相机的脚本处理RenderTexture屏幕后处理需要开启相机深度纹理脚本里有depthTextureMode深度/法线后处理内置管线或 URP 深度纹理开启脚本继承ScriptableRendererFeatureURP Feature 后处理需要现代管线配置Shader 里有 Stencil 块基于模板的物体描边透视描边或指定物体描边如果是老资源包通常自带说明文件或一个示例场景。示例场景往往藏在Scenes文件夹下场景里的相机挂了什么脚本、物体的材质球上挂了什么 Shader一眼就能看出来。比读文档更直观也比猜文件名靠谱。这一步做完再决定是直接复用还是只借鉴思路重写。3. 用 OutlineEffect 的思路跑通最小复现从导入材质到第一帧描边3.1 最快复现法线外扩描边 Shader跨管线通用不管包内是什么方案法线外扩总是最快能验证的一版。我写了一个最小可用的 Shader放在内置管线和 URP 下都能跑因为它不依赖任何 SRP 独有的变量。创建一个 Shader 文件把下面代码粘进去。Shader Custom/DrawOutline { Properties { _MainTex (Main Texture, 2D) white {} _Color (Main Color, Color) (1,1,1,1) _OutlineColor (Outline Color, Color) (1,0.8,0,1) _OutlineWidth (Outline Width, Range(0,0.1)) 0.003 } SubShader { // Pass 1背面挤出形成轮廓背壳 Pass { Name OUTLINE_BACK Cull Front ZWrite On CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; }; float _OutlineWidth; fixed4 _OutlineColor; v2f vert(appdata v) { v2f o; // 关键在世界空间挤出避免非等比缩放导致轮廓歪斜 float3 worldNormal normalize(UnityObjectToWorldNormal(v.normal)); float4 worldPos mul(unity_ObjectToWorld, v.vertex); worldPos.xyz worldNormal * _OutlineWidth; o.pos mul(UNITY_MATRIX_VP, worldPos); return o; } fixed4 frag(v2f i) : SV_Target { return _OutlineColor; } ENDCG } // Pass 2正常渲染模型正面 Pass { Name BASE Cull Back ZWrite On ZTest LEqual CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; fixed4 _Color; v2f vert(appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag(v2f i) : SV_Target { return tex2D(_MainTex, i.uv) * _Color; } ENDCG } } FallBack Diffuse }这个 Shader 的两个 Pass 分别是背壳和本体。第一个 Pass 用Cull Front剔除正面只渲染背向相机的那一层然后把顶点沿整形过的世界法线向外推裁掉之后就形成了一个比模型大一圈的彩色壳第二个 Pass 走正常渲染把原本的贴图颜色画上去盖住背壳的内部最后只在边缘露一圈轮廓。这里有个容易踩的细节挤出操作必须在世界空间做。直接在对象空间把法线乘上宽度遇到模型缩放到(1,2,1)这种非等比情况轮廓会被拉伸成椭圆甚至出现一边大一边小的残废效果。代码里先用UnityObjectToWorldNormal把法线转世界再放到世界坐标里去偏移最后用UNITY_MATRIX_VP变换到裁剪空间这样无论模型怎么旋转缩放轮廓都能保持相对均匀。使用方法很简单在 Project 窗口创建材质把 Shader 指定为Custom/DrawOutline再将材质拖到模型的 MeshRenderer 上。默认参数_OutlineWidth 0.003、_OutlineColor 黄色就能看到一圈明显的描边。如果宽度太小看不见可以按 0.005、0.01、0.02 逐步加大如果太大盖住了本体降回 0.01 以内。这个数值是“世界单位”一个 1 米高的方块0.003 大概对应三毫米的偏移屏幕上看就是细细一圈。3.2 全屏后处理描边用 OnRenderImage 把边缘变成像素级如果包里是后处理方案那在复现之前要先把渲染管线搞清楚。最简单的是先在内置管线工程里跑通 OnRenderImage 版本。这个脚本挂在相机上在画面渲染完成后、显示到屏幕之前对整帧图像做一次 Sobel 卷积。using UnityEngine; // 内置渲染管线专用挂在相机上的全屏描边后处理 [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class OutlinePost : MonoBehaviour { public Shader outlineShader; // 指定屏幕描边 Shader public Color edgeColor Color.cyan; // 描边颜色 [Range(0f, 1f)] public float edgeOnly 0f; // 0原图描边1只显示描边 [Range(0.1f, 2f)] public float sampleDistance 1f; // 采样间隔控制线宽 [Range(0.05f, 1f)] public float edgeThreshold 0.3f; // 边缘判定阈值 private Material mat; void OnEnable() { if (mat null) mat new Material(outlineShader); // 开启深度纹理为后续深度边缘检测预留 GetComponentCamera().depthTextureMode | DepthTextureMode.Depth; } void OnRenderImage(RenderTexture src, RenderTexture dst) { if (outlineShader null || mat null) { Graphics.Blit(src, dst); return; } mat.SetColor(_EdgeColor, edgeColor); mat.SetFloat(_EdgeOnly, edgeOnly); mat.SetFloat(_SampleDistance, sampleDistance); mat.SetFloat(_EdgeThreshold, edgeThreshold); Graphics.Blit(src, dst, mat); } }配套的屏幕 Shader 如下Shader Hidden/OutlinePost { Properties { _MainTex (Base (RGB), 2D) white {} _EdgeColor (Edge Color, Color) (1,1,1,1) _SampleDistance (Sample Distance, Range(0.1,2)) 1 _EdgeThreshold (Edge Threshold, Range(0.05,1)) 0.3 _EdgeOnly (Edge Only, Range(0,1)) 0 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; fixed4 _EdgeColor; float _SampleDistance; float _EdgeThreshold; float _EdgeOnly; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert(appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed luminance(fixed3 c) { return dot(c, fixed3(0.2126, 0.7152, 0.0722)); } fixed4 frag(v2f i) : SV_Target { float2 delta _MainTex_TexelSize.xy * _SampleDistance; float L luminance(tex2D(_MainTex, i.uv float2(-delta.x, 0)).rgb); float R luminance(tex2D(_MainTex, i.uv float2(delta.x, 0)).rgb); float U luminance(tex2D(_MainTex, i.uv float2(0, delta.y)).rgb); float D luminance(tex2D(_MainTex, i.uv float2(0, -delta.y)).rgb); float LU luminance(tex2D(_MainTex, i.uv float2(-delta.x, delta.y)).rgb); float RU luminance(tex2D(_MainTex, i.uv float2(delta.x, delta.y)).rgb); float LD luminance(tex2D(_MainTex, i.uv float2(-delta.x, -delta.y)).rgb); float RD luminance(tex2D(_MainTex, i.uv float2(delta.x, -delta.y)).rgb); // Sobel 梯度值横向和纵向 float gx -L - 2*LU - LD R 2*RU RD; float gy -U - 2*LU - RU D 2*LD RD; float edge sqrt(gx*gx gy*gy); fixed4 origin tex2D(_MainTex, i.uv); // 用 smoothstep 过渡避免硬阈值导致闪烁 float mask smoothstep(_EdgeThreshold, _EdgeThreshold * 1.5, edge); fixed4 colored fixed4(_EdgeColor.rgb, 1.0) * mask; fixed4 result lerp(origin colored, colored, _EdgeOnly); return result; } ENDCG } } }这段 Shader 的职责不是分析场景而是分析屏幕图像。它取当前像素周围 8 个像素的亮度算出 Sobel 梯度。梯度大说明这个位置左右或上下的亮度差异大也就是边缘。然后用smoothstep把阈值附近的边缘过渡拉平防止边缘像素在相邻帧之间突然出现或消失。_SampleDistance控制采样像素之间的距离越大描出来的线越粗但超过 1.5 之后会开始出现类似马赛克的断点因为采样范围太大已经跳到隔壁物体内部去了。_EdgeThreshold则控制灵敏度越低越容易把暗部渐变、贴图花纹也算成边缘越高越容易漏掉细轮廓。我习惯先用 0.3 起步再看画面调整。要注意的是Graphy 这种纯亮度 Sobel 会对所有亮度突变都响应包括 UI、天空渐变、阴影边缘。它适合快速预览但不适合严肃的物体描边。所以 4.2 会讲到如何扩展成深度法线边缘检测那才是多数商业方案的样子。4. 参数调优描边宽度、颜色阈值与挤压量怎么配合4.1 宽度不是越大越好屏幕像素与模型空间的换算法线外扩的_OutlineWidth是带着“世界空间长度”含义的这在屏幕上没有固定像素值。同样 0.005 的宽度在一米外的角色身上是两三像素到了十米外可能一个像素都占不到。所以总有人问为什么同样的参数换个场景描边就看不见了因为距离变了。如果你想要像素级恒定的描边常见做法是在顶点着色器里把顶点先变换到裁剪空间再沿屏幕方向挤出。这样宽度在屏幕上固定不受物体距离和缩放影响。我提供的思路是先算出裁剪空间的 NDC 坐标再根据相机的屏幕宽高把宽度换算成 NDC 偏移量。float4 clipPos UnityObjectToClipPos(v.vertex); float2 ndc clipPos.xy / clipPos.w; // 归一化坐标 [-1,1] float2 offset 2.0 * _ScreenParams.zw - 1.0; // 屏幕尺寸的倒数 clipPos.xy normalize(ndc.xy) * offset * _OutlinePixelWidth; o.pos clipPos;这个写法能保证宽度固定成屏幕像素值但会丢失世界空间的真实深度关系近的物体和远的物体描边粗细完全一样。适合 UI 风格、锁定图标、编辑器的选中框不适合追求写实的项目。实际很多 OutlineEffect 资源包提供两种模式就是为了应对这种场景差异。我再给一个换算经验值世界空间宽度 0.003在 1080p 分辨率下距离摄像机 5 米的一个普通角色描边大约 1 到 2 像素距离 2 米约 3 到 4 像素。想快速得到“像素宽度 P”可以用P 宽度 * 相机高度 / 距离这种近似实际还受 FOV 影响不用算得很精确只要记住调宽度之前先固定相机距离再调到一个肉眼看着舒服的值最后记录下来作为该项目的单位基准。4.2 深度/法线阈值边缘漏检与误检的平衡纯亮度 Sobel 有一个让渲染老手头疼的问题一张纯色的墙因为贴图有细微纹理或者光照在墙面上产生渐变都会被误判成边缘。所以更可靠的描边会用深度和法线做判定。深度连续变化但颜色不连续的场景比如一个平面上的色块纯颜色 Sobel 会描边而深度 Sobel 不会。两个物体贴在一起颜色又相同纯颜色 Sobel 会漏掉深度 Sobel 能通过深度差抓出来。因此把深度、法线、颜色三者结合是更稳定的做法。典型 Shader 片段如下// 采样当前像素以及周围四个方向上的深度/法线 float depthCenter LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv)); float depthRight LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv delta)); // 深度差超过阈值则判定边缘 float depthDiff saturate((depthCenter - depthRight) * _DepthSensitivity); // 法线差用点积度量 half3 normalCenter tex2D(_CameraNormalsTexture, i.uv).xyz; half3 normalRight tex2D(_CameraNormalsTexture, i.uv delta).xyz; float normalDiff 1.0 - saturate(dot(normalCenter, normalRight)); float edge max(depthDiff, step(_NormalAngle, normalDiff));这里_DepthSensitivity表示深度差换算成边缘强度的倍数一般在 0.1 到 0.5 之间越大越容易把前后景深拉开的物体判定为边缘。_NormalAngle是法线夹角的阈值超过比如 30° 就认为这里是棱边。这个参数组合能把墙壁上的贴图渐变压下去同时保留窗框、墙角的结构线。调参的顺序我一般固定先关掉颜色 Sobel只留法线差把_NormalAngle调到 20° 到 40°让建筑的棱角干净出来再开深度差把_DepthSensitivity从 0.2 往上加直到两个交叠物体之间出现清晰的轮廓线最后加少量颜色 Sobel用来补一些法线深度都察觉不到的纯贴图边缘比如一块黑板上贴的红纸。4.3 一个可照抄的调参顺序我自己在模拟项目X里调描边参数时固定走四步能省掉很多来回试的时间。第一步先把宽度调到“刚刚可见”。法线外扩从 0.001 起步每次乘 2 往上加后处理从_SampleDistance 1.0起步不超过 1.5。这一步只看轮廓是否连续不追求粗细。第二步调阈值一直到“杂边最少”。对深度和法线阈值从紧到松先调到几乎不误判再放宽一点点把真实结构线纳入。第三步调颜色。描边色不要直接取纯黑或纯白纯黑在暗场景里看不清纯白在亮场景里刺眼我习惯取物体主色调的对冲色比如暖色调场景用偏青的冷色。第四步回看动态效果。旋转镜头、跑动一下确认描边没有抖动、闪烁再做最终保存。5. 描边效果的通病排查黑边、闪烁、遮挡漏边的 5 条踩坑记录5.1 描边只显示一半模型像被切开现象同一个模型正面看有描边转到某个角度描边就缺了一块甚至半边脸没有轮廓。原因法线外扩的对象空间处理被非等比缩放破坏。模型缩放是(1,2,1)时法线和顶点挤出不匹配背壳在部分面被压到本体内部看不到了。解决把 Shader 里的挤出改成世界空间像 3.1 代码那样用UnityObjectToWorldNormal并且在脚本或材质里把_OutlineWidth除以当前 transform 的最大缩放值。如果项目允许尽量保证运行时物体缩放保持等比避免这个坑。5.2 全屏后处理描边完全没有输出现象相机挂上脚本后画面没有任何轮廓线连颜色都不变。原因最常见的是 Shader 属性名和脚本设置的属性名不一致比如脚本里写_EdgeColorShader 里写_OutlineColorMat.SetColor 找不到属性就静默失败。另一个原因是后处理 Shader 里没有ZTest Always导致它在深度测试被挡住什么都不画。解决先检查 Console 有没有 Shader error再用 Frame Debugger 截一帧看后处理 Pass 有没有执行。属性名一定要统一建议直接复制 Shader 里Properties中拼写。5.3 描边一直在闪烁越是边缘越明显现象镜头不动时描边也会在高光边缘一明一暗一动镜头细小的锯齿边像霓虹灯一样跳。原因边缘检测的阈值用了硬step把连续梯度强行切成 0 和 1相邻两帧只要边缘像素亮度的差值在阈值附近抖动就会导致整块描边时有时无。解决像 3.2 那样改成smoothstep(_EdgeThreshold, _EdgeThreshold * 1.5, edge)让边缘有一个过渡带。更彻底的方案是把描边计算放到深度域深度是几何量不像颜色那样受光照和抗锯齿影响稳定性好很多。5.4 法线外扩的轮廓穿模挡住了其他物体现象角色背后有一堵墙描边的背壳直接穿到墙外面像人物被一团光晕包裹。原因描边背壳被渲染到了墙的前面。背壳使用ZWrite On并且没有针对深度做特殊处理深度值也按挤出后的位置写入所以墙的像素深度比不过背壳时背壳就盖在墙上了。解决在描边 Pass 里把ZWrite Off并用ZTest LEqual如果仍然穿模可以给描边颜色加上ZTest Greater只让描边在“被遮挡”时显示这样能同时实现背后轮廓特的效果。5.5 半透明物体上的描边像贴在玻璃上的贴纸现象给半透明玻璃杯加描边结果描边是实心的玻璃本身半透明轮廓却是一圈不透明的色带。原因法线外扩背壳的输出颜色 alpha 是 1而半透明物体的渲染队列又按透明方式混合导致背壳完全不透。解决描边 Pass 里设置Blend SrcAlpha OneMinusSrcAlpha并让_OutlineColor.a跟随材质主色 alpha 来驱动更稳妥的做法是配合模板缓冲只对不透明物体做描边半透明物体单独走边缘检测后处理。这种问题没有银弹核心是把描边和不透明渲染分离开。6. 进阶思路遮挡描边、抖动抑制与性能预算验证法6.1 透视描边让轮廓从遮挡物后面透出来很多玩法希望目标被墙挡住时也能看到轮廓这就是透视描边。常见做法是用 Stencil 标记目标物体的像素先渲染目标写入模板值为 1再渲染其他不透明物体不写模板但正常遮挡最后后处理描边的时候只对模板值为 1 且当前像素深度大于目标深度的位置输出描边。效果就是目标本体被墙盖住但轮廓透过墙露出来。模板缓冲的写法并不复杂关键是渲染顺序目标物体要在遮挡物之前写入模板否则模板会被墙覆盖。6.2 抖动抑制把硬边缘变成软边缘TAA 或相机抖动开启时全屏描边是抖动重灾区。我的习惯是彻底抛弃硬阈值所有边缘强度都用fwidth做一个自适应过渡。fwidth能拿到当前像素在屏幕上的变化率边缘梯度大的地方过渡带宽一点梯度小的地方过渡带窄一点这样既能抗锯齿又不会让轮廓糊成一片。代码实现就一行float alpha smoothstep(threshold - fwidth(edge), threshold fwidth(edge), edge);这比任何后处理降噪都划算。6.3 性能验证别只看帧率要量 GPU 耗时描边效果做得再漂亮移动端跑 30 帧以下就没意义。全屏 Sobel 的耗时和分辨率成正比最简单的优化是先降一半分辨率再上采样视觉差异很小性能却能省下一半。我验证性能的习惯是在编辑器里开 Frame Debugger锁定描边相关 Pass记录一次Profiler的 GPU 时间再用同样的场景关闭描边对比。如果描边占掉总渲染时间超过 15%就会考虑从全屏方案降级到 Stencil局部描边方案。这个方向踩坑最多的不是我上面列的某一条而是“没有想清楚用途就导入包”。我后来养成了一个习惯拿到任何描边资源先删掉自带示例场景只看核心脚本和 Shader 的依赖关系再决定是直接套用还是拆了重写。一个能保证效果稳定、参数可控的描边方案比压缩包里那几段现成代码重要得多。希望这篇文章能帮你在自己的项目里少走几步弯路。本文还有配套的精品资源点击获取