尧图精选

Unity模型描边断线排查:法线平滑与Shader优化实战

🕒 发布时间:2026/9/18 7:51:28 📁 来源:尧图网络
做Unity外包项目这几年描边Outline是我被问得最多的效果之一。角色选中框、建筑高亮、宝箱发光、数字孪生场景里的设备高亮处处都要描边。但很多同行一到模型上就用出问题描边好好的一到棱角分明的模型上就断成一段一段像被什么东西啃过一样。这篇文章记录的是我最近一次排查这个问题的完整过程从现象、原理到最终解决把能直接用的工具脚本和Shader改动都整理出来希望能帮到正在被断线折磨的人。1. 先看现象这个断线到底长什么样1.1 一个能复现的场景这次的项目是一个低多边形风格的Roguelike游戏需要在角色脚下画圆环高亮同时给场景里的可交互物件加描边。美术给了一个石质底座FBX我把它拖进Unity挂上描边材质本来以为一分钟搞定结果一旋转镜头就傻眼了底座的四个棱角处描边直接断开缺口大概有一两毫米远看就像描边被人用橡皮擦掉了一段。当时我以为是材质球设置问题换了一个最简单的Standard加描边Shader还是一样。后来又换了一个不带光照的纯色描边Shader照样断。这时候我才确定问题不在Shader而在模型数据本身。我顺手把项目里其他几个模型都试了一遍发现一个规律凡是棱角分明、表面有明显硬边的模型断线必然出现而像球体、胶囊这种表面圆润的模型描边完全正常。后来我甚至拿Unity自带的Cube做测试六个角的描边全部断掉Sphere完全正常。这个对比结果其实已经把线索指向了顶点法线但当时我还不太确定直到查了一圈资料才明确断线的位置跟模型上硬边的位置完全重合。1.2 排查顺序先排除Shader本身的问题在怀疑模型之前我建议先做两步排查。第一步把描边Shader换成项目里最基础的那种双Pass外扩实现后面会讲原理如果描边还是断那说明问题不在效果叠加第二步新建一个Sphere和一个Cube做对照如果Sphere正常、Cube在角上断基本就能锁定是法线连续性的问题。这里要注意频繁在材质和模型之间换来换去很容易给你误判。我当时就是把描边断线误判成了Shader兼容性问题白白浪费了大半天。后来我把对照实验固定下来三个测试物体Sphere、Cube、出问题的模型、两个测试Shader纯色描边、带光照描边每次改动只动一个变量问题很快就能定位。另外提醒一下如果你的项目用了URP或HDRP先确认描边Shader的Pass是否完整适配了对应的渲染路径。有些老Shader是Built-in管线的写法放到URP里会出现描边Pass不执行的情况表现也是该有的线没有了容易和断线混淆。这个坑我见过很多次。2. 断线的根因全在法线上2.1 现在最常用的描边Shader是怎么做的目前Unity项目里最常见的描边方案是反转背面外扩也叫Inverted Hull。思路其实很简单正常Pass渲染物体本身另外加一个Pass把Cull设为Front只渲染背面的三角形然后把顶点沿着法线方向向外偏移一点点。偏移出来的那圈背面因为朝向相反、遮挡关系靠后最后看起来就是物体周围一圈干净的描边。这个方案的优点很多Shader代码短、效果好控制粗细、单个物体可以使用独立颜色。缺点也明显它非常依赖顶点法线的连续性。因为在顶点着色器里偏移方向用的就是mesh.normals哪怕你在顶点色里存了别的向量只要读取的还是法线属性结果都一样。核心的那段顶点偏移逻辑基本上长这样o.pos UnityObjectToClipPos(v.vertex v.normal * _OutlineWidth);就是这么简单的一句话看起来人畜无害但它正是断线的罪魁祸首。2.2 为什么会断顶点分裂与法线不连续这里需要稍微展开讲一下Unity里法线的存储逻辑。一个Mesh里的顶点如果两个三角面的法线方向差异超过一定阈值Unity导入时会把它们拆成两个重复顶点每个顶点记录自己的法线。这也是为什么你在Unity里看Cube虽然是8个角但mesh.vertices会返回24个顶点每个面都复制了一份这样每个面的法线才能保持各自的方向。当描边Pass把顶点沿法线外扩时同一个几何位置如果有多种法线外扩后的顶点就会朝不同方向跑。原本焊接在一起的位置被撕成了几个互不相连的点渲染出来自然就是一条条断口。用一个生活化的比喻把一张折过的纸板从折痕处向外撑开如果每个面是独立的纸板在折痕处就会裂开如果把折痕处粘牢整个轮廓才会连贯。描边断线本质就是纸板没粘牢。所以要让描边不断核心目标只有一个让模型在描边外扩时每个几何位置只有一个法线方向至少要让法线方向足够接近。2.3 除了法线包围盒也会让描边消失除了法线导致的那种一眼就能看出的断口还有一种情况容易被忽略描边顶点被包围盒裁剪了。Unity做视锥剔除时用的是Renderer.bounds对SkinnedMeshRenderer尤其明显。如果模型本身动起来后包围盒没有跟着顶点走或者程序化生成的网格包围盒是0外扩出来的描边顶点一旦超出包围盒范围就会被当成不可见裁剪掉。表现就是描边在画面边缘附近突然少了一段或者角色转身时描边闪烁、时有时无。这个问题的排查方式很直接运行时选中模型看Inspector里显示的Bounds对不对或者用代码临时把Bounds设得非常大如果描边立刻恢复说明就是剔除问题。程序化生成Mesh时尤其容易踩因为Mesh.bounds默认没有被计算如果是SkinnedMeshRenderer骨骼动画状态下包围盒更新不及时角色手臂一甩描边就断一截看起来跟法线断线很像但观察角度变化后你会发现位置是随机的。需要手动扩包围盒时可以在脚本里这样处理MeshFilter mf GetComponentMeshFilter(); Mesh mesh mf.mesh; mesh.RecalculateBounds(); // 把包围盒稍微放大留出描边外扩的距离 Bounds b mesh.bounds; b.Expand(0.1f); mesh.bounds b;SkinnedMeshRenderer没法直接改sharedMesh的bounds我一般会在Update里根据骨骼位移手动调整Renderer.bounds虽然有点笨但很管用。3. 实操解决从简单到彻底的三种方案3.1 方案一改导入设置里的Smoothing Angle如果你想快速验证是不是法线问题最简单的方法是改模型导入设置。Unity的FBX导入面板里Normals参数如果选的是Calculate下面会出现Smoothing Angle。把Smoothing Angle从0调到60甚至90再点Apply你会发现硬棱角上的断线基本消失了。原理是Smoothing Angle决定顶点法线合并的阈值相邻两个三角面的法线夹角小于这个值时Unity会共享顶点法线并做平均。夹角越大共享的顶点越多法线越平滑描边外扩时同一个位置的顶点自然就朝同一个方向跑了。但这个方法有个坑法线同时会影响光照。模型本来想保留硬边调大Smoothing Angle后棱角处会被错误打亮整个模型看起来像泡过水细节感全没了。尤其是硬表面风格的道具、载具这个副作用特别明显。所以它只适合验证问题不适合作为生产环境的最终方案。如果美术那边对光照效果有严格要求这一步做完基本就会被驳回。3.2 方案二写个工具给Mesh单独算一套平滑法线我最终采用的是另一种思路给模型单独准备一套描边专用法线不碰原来的光照法线。最直接的做法是写一个编辑器脚本读取Mesh的顶点和三角形把所有相邻三角面的法线求平均生成一套完全平滑的法线然后一步到位写入模型数据。核心计算函数是这样的using UnityEngine; using System.Collections.Generic; public static class SmoothNormalHelper { public static Vector3[] CalculateSmoothNormals(Mesh mesh) { Vector3[] vertices mesh.vertices; int[] triangles mesh.triangles; Vector3[] normals new Vector3[vertices.Length]; // 遍历所有三角形把每个面的法线累加到它的三个顶点上 for (int i 0; i triangles.Length; i 3) { int i1 triangles[i]; int i2 triangles[i 1]; int i3 triangles[i 2]; Vector3 p1 vertices[i1]; Vector3 p2 vertices[i2]; Vector3 p3 vertices[i3]; Vector3 faceNormal Vector3.Cross(p2 - p1, p3 - p1).normalized; normals[i1] faceNormal; normals[i2] faceNormal; normals[i3] faceNormal; } for (int i 0; i normals.Length; i) { normals[i].Normalize(); } return normals; } }这个函数从三角面出发往每个顶点累加它相邻面的法线最后归一化。注意它对重复顶点也有效因为重复顶点依然会被遍历到各自累加邻面法线。如果想让大部分面平滑、但保留个别尖锐棱角可以加一个角度阈值夹角超过阈值的面不参与累加。实际项目里我通常直接生成完全平滑的法线因为描边效果追求的就是轮廓连贯并不需要保留硬边信息。有了这套平滑法线下一步就是怎么把它存下来。3.3 方案三把平滑法线喂给Shader并在描边Pass里使用平滑法线可以存到三个地方直接替换mesh.normals、写入顶点色COLOR、写入UV2/UV3。我的建议是不要直接替换因为会影响光照优先用顶点色的RGB读取方便如果担心顶点色精度不够可以写到UV2并使用浮点数据但要确认项目没有用UV2做光照贴图。用顶点色存法线时因为颜色值范围是0到1而法线是-1到1需要做一次映射。写入端这样做Vector3 smoothNormal CalculateSmoothNormals(mesh)[i]; Vector3 colorNormal new Vector3( smoothNormal.x * 0.5f 0.5f, smoothNormal.y * 0.5f 0.5f, smoothNormal.z * 0.5f 0.5f ); colors[i] new Color(colorNormal.x, colorNormal.y, colorNormal.z, 1f);Shader端只需要在描边Pass里多取一个数据源把外扩方向从normal换成自定义法线。完整的描边Shader大概长这样Shader Custom/OutlineWithSmoothNormal { Properties { _MainTex (Texture, 2D) white {} _Color (Color, Color) (1,1,1,1) _OutlineColor (Outline Color, Color) (0,0,0,1) _OutlineWidth (Outline Width, Range(0, 0.05)) 0.01 } SubShader { Tags { RenderTypeOpaque } // 描边Pass只渲染背面外扩方向使用顶点色存的平滑法线 Pass { Name OUTLINE Cull Front ZWrite On CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 color : COLOR; }; struct v2f { float4 pos : SV_POSITION; }; float _OutlineWidth; fixed4 _OutlineColor; v2f vert (appdata v) { v2f o; // 从顶点色还原出平滑法线 float3 smoothNormal v.color.rgb * 2.0 - 1.0; // 使用平滑法线做外扩 o.pos UnityObjectToClipPos(v.vertex float4(smoothNormal, 0) * _OutlineWidth); return o; } fixed4 frag (v2f i) : SV_Target { return _OutlineColor; } ENDCG } // 正常渲染Pass光照还是用模型原始法线不受到描边数据影响 Pass { Name MAIN Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; }; 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 { fixed4 col tex2D(_MainTex, i.uv) * _Color; return col; } ENDCG } } }核心改动就一处正常光照还是用v.normal描边外扩用v.color.rgb解码出来的法线。其他都跟普通双Pass描边一致。注意点就是顶点色法线的编码映射关系写入端和解码端必须成对使用否则法线方向会乱描边会变成一段粗一段细的鬼样子。3.4 关于代码运行时替换和编辑器预处理怎么选还有一种做法是在启动时用C#给MeshFilter和SkinnedMeshRenderer分别跑一次计算脚本把平滑法线写进UV2。好处是不用改管线打包时不需要额外资源坏处是运行时计算有开销低端机上可能出现卡顿尤其是场景里几十个Mesh同时算的话启动时间会明显变长。我个人的经验是能用编辑器预处理解决的就别放在运行时。写一个AssetPostprocessor脚本在模型导入时自动生成平滑法线并写入顶点色或UV2再配合版本管理一起提交后期使用的人完全无感。这样跑起来零成本也方便和美术同学协作不用每次做新模型都手动跑一遍工具。4. 常见问题速查与避坑心得4.1 一张表看清不同断线的排查方向我把这次排查过程中遇到的各种断线现象整理成一个速查表方便遇到类似问题直接对号入座断线现象可能原因优先排查棱角处断线缺口位置固定顶点法线不连续描边外扩顶点分裂导入设置的Smoothing Angle或使用平滑法线工具UV接缝处断线同一位置存在重复顶点法线不同检查模型是否有UV接缝写工具统一顶点法线旋转视角时描边闪烁、时有时无包围盒过小外扩顶点被视锥剔除检查Mesh.bounds或Renderer.boundsLOD切换后描边粗细突变或断开不同LOD网格法线数据不一致统一各LOD的Smoothing Angle或共用描边法线数据角色动画中描边突然断一截SkinnedMeshRenderer包围盒未随骨骼更新手动扩展包围盒使用了合批后描边错乱动态合批合并Mesh顶点数据被重排关闭动态合批或使用GPU Instancing4.2 我踩过的几个坑希望你别再踩第一个坑是非均匀缩放下的描边宽度不一致。模型父节点scale如果是(1,2,1)在物体空间外扩描边时Y轴方向会被放大得比X轴粗看起来像描边粗细不均但不算典型断线问题很容易被混在一起。解决方法是把外扩放到世界空间做并用法线矩阵修正方向或者干脆在导入时把模型比例统一掉。第二个坑是把平滑法线存到顶点色后顶点色在部分移动端或桥接工具里被压缩。比如通过某些FBX导出插件顶点色变成了低精度或者干脆丢失。遇到过几次之后我改成统一存UV2的float4虽然代码多了一行但稳多了。如果你对平台覆盖要求高建议一开始就做UV2方案别贪图顶点色方便。第三个坑是动画骨骼导致SkinnedMeshRenderer的包围盒不更新描边在角色挥动手臂时断成几截。后来我写了脚本在骨骼变化后手动扩展renderer.bounds问题才解决。这个真的坑了好几个同事因为现象出现得毫无规律排查起来特别费劲。后来项目里只要用到SkinnedMeshRenderer加描边我都默认加一段扩Bounds的代码。5. 其他不断线的描边方案后处理描边与模板描边5.1 后处理描边Screen Space Outline后处理描边不依赖模型法线它通过深度加法线纹理检测边缘所以本质上不存在断线问题。适合整屏的风格化渲染比如卡通渲染里统一加轮廓线。做法是让相机生成深度法线纹理然后在屏幕空间做边缘检测把检测到的边缘像素替换成轮廓颜色。优点是不会因为模型顶点分裂而断线也不受单个模型法线数据影响。缺点也很明显不方便给单个物体指定独立描边颜色和粗细边缘线的宽度受屏幕分辨率影响而且移动端全屏后处理开销不小。如果你的项目是数字孪生大场景整体效果统一后处理描边反而更合适。5.2 模板缓冲描边Stencil Outline模板描边是先正常渲染物体并写入模板值然后把模型放大渲染一次只在模板值没被写入的区域显示颜色。放大同样需要法线方向所以理论上也存在断线风险但因为模板方案通常是把整个物体整体放大且描边渲染时会配合深度偏移对断线的免疫能力比单纯的法线外扩强一些。实际项目中模板描边更适合必须给单个物体加轮廓的场景比如FPS游戏里高亮敌人、RPG里显示可拾取物。它比法线外扩多一个Pass移动端需要确认显卡对Stencil的支持但整体效果更可控。5.3 我的选型建议给一个简化建议数字孪生大场景用后处理轮廓线整体效果统一单个交互物要高亮时用法线外扩描边加平滑法线这样能独立控制颜色和粗细快速原型验证直接调Smoothing Angle。这三种方案互相搭配能覆盖项目里九成以上的描边需求。如果项目风格比较卡通、模型棱角多法线外扩描边加平滑法线工具是最划算的选择。如果你只做UI级别的描边Unity自带的Outline组件或者Image的描边效果就够用不用牵扯到模型数据也不会遇到断线问题。最后说点个人体会。我现在的习惯是只要项目里角色或道具需要描边在首次导入模型时就直接配好平滑法线工具把断线这个隐患从源头干掉。很多问题其实不是技术复杂而是Model到Shader之间缺了一口数据统一的气。希望这篇文章能帮你少走一点弯路至少以后看到断线你能第一时间想到先看一眼法线再骂Shader。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →