
前段有个朋友做卡通渲染游戏跑来问我Unity模型描边到底该用Shader、GL还是代码生成。这个问题看着像是三种写法之争真做起来会发现每种方案都有自己的“脾气”Shader外扩快但容易碎GL画线方便但没有深度语义代码生成可控但CPU开销又让人肉疼。我干脆把几种常见路数全走了一遍从原理到坑都记录下来也算给后来人省点时间。这篇文章不是教你抄一个Shader就完事而是把“描边到底在画什么”这件事拆开为什么有些方案边缘整齐有些方案一换模型就碎为什么调试时用GL很快上线却没人这么干后处理描边漂亮但成本到底高在哪。你读完应该能根据自己项目的模型特点、目标平台和美术风格做出不那么后悔的选型。1. 绕不开的选型问题先搞清楚描边到底在画什么1.1 描边本质是“轮廓”而不只是“线框”很多刚接触描边的人第一反应是“把模型所有边画出来不就行了”。这个理解方向一开始就偏了。描边要画的是轮廓不是线框。线框是网格本身的拓扑结构比如一个立方体的12条棱不管你怎么转它都在轮廓是物体在屏幕上投影的外边界会随着视角变化而变化你转到一个角度时有些棱可能正好落在轮廓上有些则被物体自身挡住。用个生活化的类比一个人站在你面前你看到的身形轮廓是他肩膀到手臂再到腿的连续剪影而不是衣服上的每一条接缝。描边要表现的就是这个剪影接缝要不要画是另一回事看美术风格。这个认知直接决定了你选哪条路。如果你需要的是固定拓扑结构比如选中物体后高亮显示外框、场景里的调试线框那用线框方案就够了成本最低如果你要的是卡通渲染里角色和背景清晰分离的那种轮廓线就必须走真正的轮廓提取方案。1.2 三种主流路线的技术本质针对Unity里的模型描边常见实现可以归成三类背面外扩把模型顶点沿法线方向向外推一点再做一层外扩壳。渲染时剔除正面只画背面壳的边缘露出来就形成描边。屏幕空间后处理在最终画面合成前用全屏Pass检测深度、法线的突变区域突变处就是物体边缘然后直接上色。代码生成/CPU提取在CPU端分析网格拓扑找出当前视角下的轮廓边生成线段数据交给LineRenderer或自定义Mesh渲染。这三类方法的本质分别对应几何膨胀、屏幕边缘检测、拓扑分析。它们都能得到“看起来像描边”的东西但适用场景差异非常大。几何膨胀的实现最简单几乎不增加多余DrawCall屏幕检测能给整个画面做统一风格但会描出所有物体的边想单独控制某个角色比较麻烦CPU提取理论上是控制力最强的但每帧分析和生成几何体的开销不是普通项目能随便吃的。1.3 动手前先回答四个问题我建议你正式选方案之前先回答下面四个问题比去论坛反复问“哪个描边效果好”有用得多描边是只给主角、NPC等少数物体还是场景里所有物体都要前者用外扩Shader往往就够了后者很可能要上后处理。描边需要随遮挡关系变化吗在一些RTS或动作游戏里角色被墙挡住后依然要看到轮廓这时候需要额外渲染到深度或模板缓冲技术方案会完全不一样。目标平台是PC还是移动端移动端带宽和GPU频率都比较紧张全屏后处理要谨慎评估外扩Shader在这方面优势明显。描边宽度是像素级恒定还是随距离远近变化美术如果要求“角色无论远近轮廓线看起来一样粗”那就要走屏幕空间外扩或后处理单纯的视图空间外扩做不到。这四个问题其实就是在逼你明确需求边界。大部分项目最终都会做成混合方案比如主角单独做外扩描边全场景风格统一时再加后处理。比较少出现“一招吃遍天下”的情况。2. Shader法线外扩描边见效最快但法线决定成败2.1 为什么要剔除正面画背面先看原理。一个描边Pass真正做的事情是把模型顶点沿法线方向外推然后只渲染背面。外形上外扩后的模型比原模型大一圈而由于我们只画背面原模型的正面会挡住外扩壳的内部最终露出来的只有大圈外侧那一圈也就是描边。这就像穿了一件大一号的外套人的身体在里面边缘露出来的部分就是轮廓。Shader里对应的关键设置是Cull Front只绘制背向摄像机的三角形。这个Pass一般放在正常模型渲染之前先画描边再画主体这样主体会把描边内部遮掉边缘一圈留出来。有个细节很容易忽略描边Pass不要写深度或者至少要写对应的深度关系。一般我会把描边Pass的ZWrite Off掉让主模型后面的描边部分被主体覆盖避免描边穿透主体导致闪烁。主模型的渲染通道正常写深度即可。2.2 一个可以直接当底座的描边Pass下面这个版本我常用特点是在视图空间外扩而不是在对象空间直接加法线偏移。因为对象空间外扩在模型有非均匀缩放时会变形比如一个长方体的X轴压缩过沿法线外扩就会得到不均匀的描边厚度。视图空间外扩能规避这个问题代码也干净。Shader Echo/Outline/ViewSpace { Properties { _OutlineColor (Outline Color, Color) (0, 0, 0, 1) _OutlineWidth (Outline Width, Float) 0.05 } SubShader { Tags { RenderTypeOpaque QueueGeometry } Pass { Name OUTLINE Cull Front ZWrite Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc fixed4 _OutlineColor; float _OutlineWidth; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; float3 viewNormal normalize(mul((float3x3)UNITY_MATRIX_IT_MV, v.normal)); float3 viewPos UnityObjectToViewPos(v.vertex.xyz); viewPos viewNormal * _OutlineWidth; o.pos UnityViewToClipPos(viewPos); return o; } fixed4 frag (v2f i) : SV_Target { return _OutlineColor; } ENDCG } // 这里放正常模型渲染的Pass } }UNITY_MATRIX_IT_MV是模型视图矩阵的逆转置矩阵用来把模型空间法线正确转换到视图空间。这个矩阵对非均匀缩放是友好的。_OutlineWidth的单位是视图空间单位具体数值取决于你模型的尺寸一般以米为单位的长宽比例来看0.01到0.1都有可能出现。2.3 尖锐模型接缝断裂的问题法线外扩最经典的坑就是硬表面模型上描边会“碎掉”。你拿一个立方体去试可能边缘描边不连续甚至某个面完全没有描边棱角处还会出现一些奇怪的斜线。原因在于外扩方向依赖顶点法线。对于平滑模型比如球体每个顶点法线方向是周围面的平滑插值外扩后壳是连续的。但对硬表面模型比如立方体、机械部件同一个顶点可能被多个法线方向相差很大的面共享顶点法线只是一个平均值甚至有些建模软件会拆成多个顶点每个顶点法线指向自己所属面的方向。这时候外扩壳的相邻顶点可能一个往左一个往右中间就直接裂开了。你看到的描边自然断成一截一截的。这个问题不是调宽度能解决的根源在数据本身。2.4 烘焙平滑法线一个能救命的C#脚本解决办法是用一套独立的平滑法线来做外扩而不是直接用美术原始的法线。基本思路是遍历网格的所有三角形把每个顶点相关三角面的法线累加再归一化这套法线会非常平滑外扩时就不会断裂。我一般会把平滑法线烘焙到模型的UV2或UV3通道然后Shader从texcoord2里取出来做外扩。下面是一个编辑器脚本的简化版可以提供右键菜单直接处理MeshFilterusing UnityEngine; using UnityEditor; using System.Collections.Generic; public static class SmoothNormalBaker { [MenuItem(Tools/Bake Smooth Normal To UV3)] public static void Bake() { GameObject go Selection.activeGameObject; if (go null) return; MeshFilter filter go.GetComponentMeshFilter(); if (filter null) { Debug.LogError(Need MeshFilter); return; } Mesh mesh filter.sharedMesh; Vector3[] verts mesh.vertices; int[] tris mesh.triangles; Vector3[] baked new Vector3[verts.Length]; Vector3[] faceNormals new Vector3[max(0, tris.Length / 3)]; for (int i 0; i faceNormals.Length; i) { int a tris[i * 3]; int b tris[i * 3 1]; int c tris[i * 3 2]; faceNormals[i] Vector3.Cross( verts[b] - verts[a], verts[c] - verts[a] ).normalized; } for (int i 0; i tris.Length; i) { int face i / 3; int vertIndex tris[i]; baked[vertIndex] faceNormals[face]; } for (int i 0; i baked.Length; i) { baked[i].Normalize(); } mesh.SetUVs(2, new ListVector3(baked)); EditorUtility.SetDirty(mesh); AssetDatabase.SaveAssets(); Debug.Log(Baked done.); } private static int max(int a, int b) a b ? a : b; }注意如果你是处理SkinnedMeshRenderer改动sharedMesh会影响所有引用同一份Mesh的角色应该在资产生成管线里做一次离线处理而不是运行时去变。烘焙完UV3后Shader里把外扩法线从texcoord2读出来struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float3 smoothNormal : TEXCOORD2; }; v2f vert (appdata v) { v2f o; float3 viewNormal normalize(mul((float3x3)UNITY_MATRIX_IT_MV, v.smoothNormal)); float3 viewPos UnityObjectToViewPos(v.vertex.xyz); viewPos viewNormal * _OutlineWidth; o.pos UnityViewToClipPos(viewPos); return o; }这个处理对面数少、硬边多的模型特别有效。我做过一个机甲项目一开始描边碎得像被啃过烘焙平滑法线之后立刻整齐。2.5 宽度、距离、平台差异的调节细节法线外扩描边有个天然问题视图空间外扩时离摄像机越远描边看起来越细这是符合透视的但某些美术风格希望描边宽度在屏幕上恒定看上去更像“贴图描边”。想要屏幕空间恒定宽度就不能只在视图空间加偏移而是要把法线转换到屏幕空间然后在NDC里偏移。核心思路是把外扩方向投影到屏幕除以clipPos.w做透视校正。这类写法网上很多我不建议一上来就抄因为涉及正交相机、宽屏、分辨率适配调起来烦。我的建议是先做视图空间外扩等美术明确说“远处看不见描边了”再升级。还有一个容易翻车的点不同平台抗锯齿和渲染缩放不同同样的宽度在PC上合适在手机上可能粗得离谱。最好把宽度做成全局参数进入游戏时读取Screen.height之类的信息做一次缩放系数调整而不是美术改死一个值。3. 屏幕空间后处理描边全屏风格化时别绕路3.1 深度法线纹理里藏着什么后处理描边的思路是画面已经渲染出来后我在每个像素旁边采样几个邻居看看深度或者法线有没有突然变化。如果变化足够大说明这里是两个物体的交界处或者同一个物体的棱边所以我把它涂成描边色。关键是拿到深度和法线信息。Unity里只需设置camera.depthTextureMode | DepthTextureMode.DepthNormals;Camera就会生成一张_CameraDepthNormalsTexture里面打包了深度和视图空间法线。用内置函数DecodeDepthNormal可以解出来。用生活话讲深度是“离摄像机多远”法线是“这个表面朝哪个方向”。两个像素如果深度差很多多半是前后物体交界如果深度接近但法线差很多多半是同一个物体的折角。这两种情况都值得画描边所以后处理方案能同时覆盖轮廓和硬棱边。3.2 用简化的Sobel算子找边缘我常用的做法是对“深度差异”和“法线差异”分别做横向和纵向采样再合并成一个边缘强度值。完整Sobel要采8个点移动端上可以退化成4个点也能用具体看你要的质量。Shader关键片段大致长这样fixed4 frag (v2f i) : SV_Target { float2 texelSize _CameraDepthNormalsTexture_TexelSize.xy; float2 uv i.uv; float4 centerDN tex2D(_CameraDepthNormalsTexture, uv); float centerDepth; float3 centerNormal; DecodeDepthNormal(centerDN, centerDepth, centerNormal); float4 upDN tex2D(_CameraDepthNormalsTexture, uv float2(0, texelSize.y)); float upDepth; float3 upNormal; DecodeDepthNormal(upDN, upDepth, upNormal); // 同理采样 down / left / right float depthDiff abs(centerDepth - upDepth) abs(centerDepth - downDepth) abs(centerDepth - leftDepth) abs(centerDepth - rightDepth); float normalDiff 1.0 - dot(centerNormal, upNormal); normalDiff 1.0 - dot(centerNormal, downNormal); normalDiff 1.0 - dot(centerNormal, leftNormal); normalDiff 1.0 - dot(centerNormal, rightNormal); float edge saturate(depthDiff * _DepthScale normalDiff * _NormalScale); fixed4 col tex2D(_MainTex, uv); return lerp(col, _EdgeColor, edge * _EdgeStrength); }注意解码后的法线是在视图空间里的直接dot比较可以但两个方向完全相反时dot会接近-1所以用1 - dot这个形式值越大说明法线变化越剧烈。深度差异一般要乘一个缩放系数否则不同场景深度范围差异巨大。这个系数没有万能值需要你在实际场景里调。我的经验是先开着法线差异做主体再用深度差异补强“前后物体交界”的情况调参更快。3.3 想指定单个物体后处理方案没那么简单后处理方案最大的坑是它天生“一视同仁”画面里所有边缘都会被描出来。你想只描主角、不描地上的石头和墙光靠深度法线判断做不到。有的做法是把物体渲染到一个独立的ID纹理里每个物体填一个纯色ID后处理时看ID跳变来判断边缘。这个方案在大世界项目里实现成本偏高因为你需要维护对象ID列表还要多一张全屏RT。我之前在一个开放世界项目里没有用这个而是给主角单独做了Shader外扩描边后处理只负责场景风格化两边互不干扰。另有一种常见需求是“角色被墙挡住后依然显示轮廓”。这要先把角色外扩渲染到一张RT再用深度比较判断角色是否被遮挡最后把轮廓画到屏幕上。本质上已经不是简单的后处理描边而是多了一层遮挡检测。这种我建议用CommandBuffer来做而不是堆一堆OnRenderImage不然流程会非常难维护。3.4 后处理在移动端和URP下的性能账后处理描边的开销主要集中在全屏采样的次数上。我在一个中端机上测过4点采样的简化Sobel大概要增加1到2毫秒的GPU时间如果场景复杂、分辨率高这个数字还会涨。8点采样轻松翻倍。要控制成本最有效的一招是降低后处理计算分辨率单独用一个只有屏幕一半大小的RT来做边缘检测检测完再blur一下然后和主画面合成。这样细节会损失一点但移动端能明显流畅。URP项目里不再是OnRenderImage那套写法了你需要写一个ScriptableRendererFeature或者CustomPass在RenderPassEvent里插入一个全屏Blit。原理没变但接入方式要跟着管线走。4. 用GL API画辅助描边问题定位阶段的利器4.1 GL.LINES和OnRenderObject的配合说完正经渲染方案回头看GL。Unity早期留了一套GL立即模式API专门用来画线、画基本几何体。用起来很直接GL.Begin(GL.LINES)往里面塞GL.Vertex最后GL.End。但必须注意调用时机一般放在OnRenderObject或OnPostRender里。如果你试图在Update里直接调用GL.Begin大概率什么都画不出来。因为GL指令要绑定在某个相机渲染事件里你直接在生命周期函数里调用没有渲染上下文。我会配合一个专用材质来用避免受场景光照影响public class GLDebugDrawer : MonoBehaviour { public Material lineMat; public MeshFilter targetMesh; void OnRenderObject() { if (lineMat null) { lineMat new Material(Shader.Find(Hidden/Internal-Colored)); } lineMat.SetPass(0); GL.PushMatrix(); GL.MultMatrix(transform.localToWorldMatrix); GL.Begin(GL.LINES); GL.Color(Color.yellow); // 绘制包围盒8个顶点 Bounds bounds targetMesh.mesh.bounds; Vector3 min bounds.min; Vector3 max bounds.max; Vector3[] corners new Vector3[8]; corners[0] new Vector3(min.x, min.y, min.z); corners[1] new Vector3(max.x, min.y, min.z); corners[2] new Vector3(max.x, max.y, min.z); corners[3] new Vector3(min.x, max.y, min.z); corners[4] new Vector3(min.x, min.y, max.z); corners[5] new Vector3(max.x, min.y, max.z); corners[6] new Vector3(max.x, max.y, max.z); corners[7] new Vector3(min.x, max.y, max.z); int[] edges new int[] { 0, 1, 1, 2, 2, 3, 3, 0, 4, 5, 5, 6, 6, 7, 7, 4, 0, 4, 1, 5, 2, 6, 3, 7 }; for (int i 0; i edges.Length; i 2) { GL.Vertex(corners[edges[i]]); GL.Vertex(corners[edges[i 1]]); } GL.End(); GL.PopMatrix(); } }这个代码画的是Mesh包围盒在编辑器里做工具时特别方便。如果只是想看模型线框也可以把GL.wireframe设成true画完再设回false。4.2 给模型画包围盒和线框的代码骨架上面的代码其实很直白但有一行容易漏lineMat.SetPass(0)。没有这行后面的GL指令没有材质状态画出来全是黑的或直接不显示。还有GL.PushMatrix和GL.PopMatrix如果你忘了MultMatrix画出来的坐标会变成世界坐标下的数值而Mesh.bounds是局部坐标坐标一乱就画到天边去了。GL画线不经过光照、阴影、后处理也不会被深度遮挡所以画出来的线始终在最前面。调试时这是优点能保证你一眼看到位置。但这同时也是它不能直接上线的关键原因。4.3 为什么它只适合调试而不适合最终表现GL方案有三个硬伤没有逐像素厚度控制。GL.LINES画出来的线永远是1像素宽你没法做粗描边最多用多条平行线拼但边缘质量非常差。没有深度语义。默认情况下画线不参与深度测试物体跑进墙里墙后依然能看到线在最终渲染里是穿帮的。性能不适合大数据量。每条线段都要CPU端递顶点一帧画几千条线DrawCall和CPU开销都会上来而且你在屏幕上看不到任何缓冲优化。我之前在编辑器里用GL画过场景里几百个物体的热力圈帧率能掉十几帧。后来我改用Handles.DrawWireCube和Gizmos编辑器体验好很多。GL这条路适合做自定义编辑器工具、Prefab调试、碰撞体可视化不适合作为游戏正式画面的一部分。5. 代码生成描边网格毫厘之间都要可控时的偏方5.1 边界边与轮廓边的区分算法视角如果你追求的是“画哪根线由我代码说了算”那就得走代码生成路线。第一步是理解网格边缘提取。一条边有两个用途边界边只被一个三角形引用。比如一块平面、一件没有封口的衣服、一个被切开一半的模型都会存在边界边。无论从哪个角度看边界边都有视觉意义因为它确实没有邻居属于物体边缘。轮廓边一条边被两个三角形共享从某个视角看两个三角形的面法线一个朝前一个朝后这时候两个面之间就产生了“折”的视觉分界。典型例子是球体的外轮廓球体表面上没有所谓边界边但转到一个角度时很多共享边两侧的面恰好一个正对一个背对形成轮廓。判断轮廓边的方法直观描述就是记这调边两侧面的法线为N1、N2摄像机朝向为V。如果dot(N1, V)和dot(N2, V)符号不同一条朝向摄像机、一条背离摄像机那么这条边就是当前视角下的轮廓边。5.2 把轮廓边转成LineRenderer/Mesh思路清楚后实现就分成几步从Mesh取顶点和三角形索引。构建“边到邻接面”的字典key是无向顶点对。遍历所有边如果只有一个邻接面标记为边界边如果两个邻接面的法线相对视角符号不同标记为轮廓边。把所有标记出的边转成世界空间线段交给LineRenderer或生成新Mesh。这几步在低模上跑起来很顺。比如一个几百面的人物头模一帧算一遍都能接受。转到LineRenderer时有几个细节useWorldSpace要设成true才方便运行时变换startWidth和endWidth要一致才能保持均匀材质要选Unlit/Color之类不受光照影响的Shader。如果你不想每帧重建ListVector3导致GC可以预分配一个足够大的数组只更新长度部分。5.3 CPU生成的代价什么时候值得用代码生成的问题很直接它把GPU能并行处理的事情变成CPU串行分析。每帧遍历三角形、查字典、排序动辄几万面模型在移动端撑不住。我在一个端游项目里用这个方案做过单主角镜头特写模型面数控制在2000左右帧耗时大概多了1毫秒不到还能接受。但一旦角色面数上千、场景里同时出现四五个角色CPU开销立刻拉满。所以这个方案适合的场景通常是模型面数可控、描边精度要求高、只针对单个或少数关键物体。如果你要做的是大世界几十个人物同屏千万别选它。不过可以把计算拆成离线/半离线。比如预计算所有边的邻接关系运行时只做视角判断不重新解析整个拓扑能省掉大量字典和哈希开销。还可以只在物体进入“描边状态”时才计算平时关掉。5.4 另一个“代码生成”顶点外扩壳网格和轮廓边提取不同还有一种“代码生成”很容易被忽略在CPU端直接生成外扩壳Mesh做法和Shader外扩一样只是把外扩结果固化到Mesh数据里。用途主要有两类有些环境不支持自定义Shader或者目标平台不允许运行时改Shader你可以提前生成一个外扩壳Mesh再给这个壳用普通材质渲染配合Cull Front效果一样。美术想在建模软件里选出一部分顶点做描边而不是整模型外扩那么在DCC工具里手动处理比运行时用Shader更可控。我之前处理WebGL项目时用过这个方案。WebGL平台上Shader兼容性很麻烦通用Shader在部分低端手机上会编译失败但普通Mesh渲染不受影响。做法是复制一份Mesh把所有顶点沿烘焙好的平滑法线偏移_ShellWidth然后新建材质、开启Cull Front渲染。缺点是真改变了模型资源你要在内存里维护两份顶点数据包体和内存都会有所增加。6. 方案横向对比渲染帧、开发成本和工程维护的权衡6.1 四类方案对比表我做了个项目选型时会用到的对比表给美术和主程一起看比扯皮高效很多方案实现位置轮廓精度动态物体支持典型性能成本维护难度适合场景Shader法线外扩GPU顶点中取决于法线质量好极低一个额外Pass中角色选中、卡通角色描边、大世界屏幕后处理GPU像素高全屏统一好高全屏多次采样较高全屏风格化、雾中轮廓、整体边缘GL/LinesCPU低1像素线中数据量大时CPU高低编辑器调试、工具可视化代码生成网格CPU高可精确控制取决于计算速度中到高高单角色特写、高精度轮廓这个表没有哪一行是“最好”的。实际上很多项目会在不同阶段混用几种至少我的项目里经常是Shader外扩做主角、GL做调试、后处理做氛围三者共存。6.2 我的典型选型参考给你一个相对稳妥的起步组合如果是PC端单机、风格化渲染先试Shader法线外扩只给主要角色和交互物体开。发现画面整体缺少“手绘感”时再加后处理描边但建议先把后处理的RT分辨率降到一半观察画质是否可接受。如果是移动端大世界我的优先级很明确主角和重要NPC用Shader外扩场景物体基本不描。全屏后处理除非是专门的美术风格否则不做因为移动端功耗受不了。如果是纯工具类项目比如数字孪生、编辑器扩展那直接GL或Gizmos别折腾Shader。6.3 混合方案示例选中描边加全屏风格一个比较常见的实际需求是全屏要有轻微描边风格同时选中某个物体时要特别突出。我们可以分开做场景里的所有物体用后处理做轻描边检测深度法线变化宽度小一点整体增加手绘感。选中的物体额外叠加Shader外扩描边宽度更大、颜色更艳。要注意渲染顺序先正常渲染场景再做后处理风格描边最后合成外扩描边在物体Pass阶段直接渲染。这样两个方案不会互相覆盖因为后处理描边在最终合成阶段只影响边缘选中物体的外扩描边又是先画好的颜色叠加时会和场景互相混合。如果你想让它完全不被后处理干扰可以把选中物体的轮廓放到后处理之后单独Blit上去这就是工程上的进一步优化。混合方案会增加Pass数量和全屏处理步骤但换来的是表现力。做主程的人要习惯“方案没有绝对优劣只有成本收益”。7. 实际项目里最容易被忽略的几个坑7.1 描边被雾效、阴影或景深“吃掉”这是我调项目时遇到的第一个玄学问题。描边Pass是有的颜色也设了但远处看就是不明显走近才正常。后来发现是雾效在搞事。如果你Shader里开着Fog远处描边会被雾的颜色覆盖等于白画。解决办法有两类在描边Pass里把雾关掉或者在Shader里单独处理。在SubShader块里加Fog { Mode Off }最直接。还要小心部分后处理体积雾如果它作用在最终画面上描边一样会被覆盖这时候你需要把描边放到体积雾之后渲染或者让描边颜色直接和雾做混合。阴影最容易出现在“UI提示”类描边里。比如一个按钮外框想描边结果阴影打的暗了一块用户以为是Bug。这种往往是模型本身用了ShadowCaster外扩壳也跟着投阴影。记得在外扩Pass里单独处理阴影投射或者干脆对描边对象关闭Cast Shadows。7.2 Shader变体数量与包体/热更Shader描边大多要搭配各种Keyword比如是否启用屏幕空间恒定宽度、是否读取平滑法线、是否使用顶点色遮罩。每个Feature开一个变体意味着Shader编译时间和包体里的变体会膨胀。我见过一个项目描边Shader里写了七八个Keyword最后变体数量上千手机上热更包大了不少编译时间也直线上升。调整方法就是给描边Shader建立单独的变体清单只保留实际会用到的组合。Shader变体不是免费的能用开关控制就尽量别让它变成全体组合。7.3 描边宽度与分辨率适配像素和世界单位不能混为一谈屏幕空间恒定宽度方案下同一个像素宽度在1080p和5K屏上感觉完全不同。单纯按_ScreenParams.y缩放还不够宽屏和窄屏下同一宽度看起来也有差异。我的经验是描边宽度全部以像素为单位来定义但加一个基于屏幕对角线长度的缩放因子而不是用某一个轴的尺寸。移动端普遍是从小屏到大屏、宽高比差异大用对角线长度相对稳一些。同时给美术提供一个可调参数上线后根据真机效果做整体缩放而不是让美术逐台机器调。7.4 未来扩展CommandBuffer与SRP如果你工作在URP或HDRP环境OnRenderImage和GL的合法使用场景会越来越少。URP里更推荐用ScriptableRendererFeature插入自定义Pass用CommandBuffer做描边渲染。法线外扩Shader本身基本能跨管线复用但后处理和GL方案都要改。CommandBuffer的优点是你可以在渲染流程中间精确插入描边步骤比如主角渲染完之后立刻加轮廓Pass然后再进入后处理。这样遮挡检测、深度关系都能控制得更好。缺点是你得理解渲染顺序否则容易把描边画到底层或者被后续Pass覆盖。我自己的习惯是新项目一律先确认管线再选描边方案。Built-in管线里顺手的老套路到URP里不一定还能用反过来URP那些特性在Built-in里也没有。很多坑不是描边算法本身而是你把它放进了错误的渲染流程里。最后说一个我自己实践下来最实在的建议不要从网上随便下一个描边Shader就往项目里塞。描边效果和你的模型规模、渲染管线、美术风格强耦合。先拿最小场景跑通再决定要不要上后处理、要不要烘焙平滑法线、要不要把宽度做成屏幕空间恒定。等这一切都动起来之后你会发现描边本质上不是“画一条线”而是你要清楚这条线是从哪一层数据里算出来的。想明白这一点剩下的只是API调用。