尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity模型描边全攻略:Shader、GL与代码生成方案详解与选型指南

Unity模型描边全攻略:Shader、GL与代码生成方案详解与选型指南 最近接了几次和模型描边相关的需求有要选中角色亮边的有要做低多边形线框风格的还有调试工具里要实时看网格轮廓的。Unity 里搜了一圈“描边”无非就是 Shader、GL 和代码生成 Mesh 这几条路但每条路的适用场景差别非常大。这篇文章我把自己实测过的 Unity 模型描边方法整理出来从原理到可直接复用的代码再到各种坑一次性讲清楚方便你下次做选择。1. 先盘一圈三种描边思路分别适合什么场景1.1 为什么同一个需求有好几条技术路线先说个结论描边这件事在渲染上根本不是“同一类问题”。你以为你只是想要一条边但实际上你要的可能是下面三种东西之一视觉外轮廓随着相机视角变化而变化的模型外部轮廓线比如动漫角色周围那一圈黑边。几何棱边模型网格里客观存在的硬边、边界边比如一块方石头上的十二条棱或者 CAD 线框图里的线条。屏幕空间边缘整幅画面中深度、法线不连续的位置比如后处理描边。这三个目标对应三种完全不同的做法。用 Shader 做描边本质是在渲染流程里多画一个“边缘层”用 GL 做描边本质是直接向屏幕画线段用代码生成描边 Mesh本质是预先从模型拓扑里把边提取出来重新组装成可渲染的几何数据。我最开始做描边的时候走了弯路拿 Shader 去画几何棱边结果该出来的边不出现不该出现的裂缝反而一堆。后来才明白选型之前先搞清楚你要的是视觉轮廓还是几何棱边这比写代码重要得多。1.2 方案选型速查表方案实现成本性能和兼容性适合的画风/场景主要限制Shader 法线外扩描边低一个 Pass 加几句偏移逻辑高多一次背面渲染卡通角色、选中高亮、怪物描边模型法线、拓扑有问题时边缘开裂模板缓冲描边中多 Pass 加 Stencil 操作较高但多一个 Pass需要稳定轮廓的角色描边依然是几何放大思路凹面表现一般屏幕后处理边缘检测中高需要深度/法线纹理每帧全屏处理移动端有压力全屏动漫风、水墨风轮廓不适合单独给某个物件描边GL 绘制线段低临时调试代码差只适合开发期线框预览、Mesh 调试性能差线宽很多平台不生效代码生成描边 Mesh高需要组装顶点中等构建后可当普通网格渲染低模线框、建筑蓝图、可交互轮廓动态模型每帧重建成本高这张表是我做了几个项目之后总结出来的个人经验。核心规律是越成熟的管线方案越偏向 Shader 和屏幕后处理越想要“轮廓数据可控”越要走向代码生成。下面每一节我把这些方案的具体写法和实测心得展开讲。2. Shader 描边法线外扩的做法与踩坑记录2.1 法线外扩的核心原理和最小实现法线外扩是 Unity 社区里流传最广的描边方案原理其实一句话就能说清模型正常画一遍然后再画一遍“只有背面”的模型并把背面的顶点沿法线方向向外推一些。因为这一层渲染屏蔽了正面对应像素所以从正面看只有轮廓周围会露出这一圈“凸出来的背面”也就是你想要的描边。我写一个 Built-in 管线下最基础的双 Pass 版本Shader Custom/OutlineShader { Properties { _MainTex (Main Tex, 2D) white {} _OutlineColor (Outline Color, Color) (0, 0, 0, 1) _OutlineWidth (Outline Width, Range(0, 0.1)) 0.02 } SubShader { Tags { RenderTypeOpaque } Pass { Name OUTLINE Cull Front 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; float4 _OutlineColor; v2f vert (appdata v) { v2f o; // 顶点沿法线外扩 float3 worldNormal UnityObjectToWorldNormal(v.normal); float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; worldPos worldNormal * _OutlineWidth; o.pos UnityWorldToClipPos(worldPos); return o; } fixed4 frag (v2f i) : SV_Target { return _OutlineColor; } ENDCG } Pass { Name BASE // 正常模型渲染 Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; }; sampler2D _MainTex; 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); } ENDCG } } }这个版本用在 URP 下其实会报错因为 URP 默认不认UnityObjectToClipPos这一套 CG 函数需要改成 HLSL 写法。底层的偏移思路不变只是把UnityCG.cginc换成Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl并改用TransformObjectToHClip之类的 API。所以我不建议再新建 Built-in 项目来跑这个效果除非你项目确实还在老管线。这段最基本的代码刚跑通的时候你大概率会发现两个问题第一描边宽度离相机越远看起来越细第二模型凹角处、硬边处可能裂开或者出现奇怪的穿透。第一个问题我会在下节讲第二问题本质是拓扑和法线问题。2.2 描边宽度、相机距离和屏幕空间恒定宽度怎么办我见过很多新手直接把顶点在世界空间沿着法线推一个固定数值看起来没问题但相机一拉远描边就细到看不见。原因很简单世界空间位移会被透视投影按距离缩小。如果你希望描边在屏幕上保持大致相同的像素宽度写法上要变一下。最常用的一种做法是在裁剪空间偏移并且乘以裁剪坐标的w分量让偏移量在光栅化之前就是屏幕空间相关的v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); float2 offset TransformViewToProjection(viewNormal.xy); o.pos.xy offset * o.pos.w * _OutlineWidth; return o; }重点就是最后一行里o.pos.w。因为裁剪空间坐标最后要除以w才变成屏幕坐标所以如果想让屏幕上的偏移量恒定就必须先把偏移量乘一个w除完之后才恰好是想要的像素偏移。这个细节解释了为什么网上很多 Shader 抄下来后描边宽度忽大忽小多半就是少了这个乘法。这样处理后描边宽度基本不随相机距离变化。但要注意它和你模型本身的缩放尺度也有关系因为开头那段世界空间外扩的版本里缩放会被模型矩阵放进去。我的习惯是想保持风格统一优先用裁剪空间屏幕宽度想做“模型越大描边越粗”的偏写实效果就用世界空间偏移。这两种需求没有谁优谁劣看你项目风格定义。2.3 模板缓冲描边与后处理描边什么时候值得用法线外扩最大的痛点是什么模型不一定给你一个封闭实体。比如一个平面、一条边、一个非流形网格法线外扩后完全没有体积背面被 Cull 掉就啥也看不清。还有一些带硬边的低模顶点法线在不同面上是断开的外扩之后会裂开。模板缓冲方案可以在一定程度上缓解这些问题。思路是先用一个 Pass 渲染“轮廓标记”这一步通常把模型沿着法线略微放大或者干脆渲染纯色背面但关键是不写颜色只往 Stencil 缓冲里写值。下一步正常渲染模型时用 Stencil 测试把轮廓区域排除掉颜色缓冲区里剩下的就是一圈描边。因为标记层是整个模型区域的覆盖边缘断裂问题会比纯法线外扩轻一些。以下是模板缓冲描边的伪代码风格描述Pass 1: Cull Front ZWrite Off ColorMask 0 Stencil { Ref 1 Comp Always Pass Replace } 顶点沿法线向外偏移输出顶点位置 Pass 2: Cull Back Stencil { Ref 1 Comp NotEqual } 正常渲染模型纹理/颜色模板缓冲的价值在于即使模型表面存在小破面或法线不连续只要放大的量足够覆盖缝隙描边就不会出现明显缺口。缺点是它比普通法线外扩多一个 Pass而且如果放大不到位凹角里侧还是会有缺口。至于后处理边缘检测它走的是完全不同的思路拿到深度纹理和法线纹理在屏幕空间用 Sobel 算子检测不连续区域。这种方案适合整屏统一风格化的描边比如动漫渲染、水墨渲染不适合单独给某个角色描边因为一旦多个物体相邻屏幕空间边缘会把物体和物体的边界也画出来。它的实现成本主要在一个双 Pass 的 Blit 流程和深度法线纹理的采样移动端要谨慎纹理带宽消耗不低。3. GL 描边调试场景下的快速轮廓绘制3.1 在 OnRenderObject 里用 GL.Vertex 画线很多人一听到 GL第一反应是“这不是 OpenGL 的旧机制吗Unity 里还能用” 能但定位要摆正。Unity 里的GL类本质上是一个立即模式绘图接口适合编辑器调试、辅助线绘制不适合正式游戏渲染。如果你想快速看一个模型的棱边结构或者想做一个临时的线框预览它反而是最快的方式。核心写法是挂一个脚本在OnRenderObject里取当前相机的渲染流程手动提交线段using UnityEngine; public class GLOutlineGizmo : MonoBehaviour { public Material lineMaterial; void OnRenderObject() { if (lineMaterial null) return; Mesh mesh GetComponentMeshFilter().sharedMesh; if (mesh null) return; Vector3[] vertices mesh.vertices; int[] triangles mesh.triangles; // 先统计每一条无向边出现次数 Dictionary(int, int), int edgeCount new Dictionary(int, int), int(); for (int i 0; i triangles.Length; i 3) { AddEdge(edgeCount, triangles[i], triangles[i 1]); AddEdge(edgeCount, triangles[i 1], triangles[i 2]); AddEdge(edgeCount, triangles[i 2], triangles[i]); } lineMaterial.SetPass(0); GL.PushMatrix(); GL.MultMatrix(transform.localToWorldMatrix); GL.Begin(GL.LINES); GL.Color(Color.black); foreach (var kv in edgeCount) { if (kv.Value ! 1) continue; // 只画边界边 var a vertices[kv.Key.Item1]; var b vertices[kv.Key.Item2]; GL.Vertex(a); GL.Vertex(b); } GL.End(); GL.PopMatrix(); } void AddEdge(Dictionary(int, int), int dict, int a, int b) { if (a b) (a, b) (b, a); if (dict.ContainsKey((a, b))) dict[(a, b)]; else dict[(a, b)] 1; } }这段代码有几个关键点。第一GL.MultMatrix(transform.localToWorldMatrix)放在GL.Begin之前后面GL.Vertex传的顶点坐标就直接用 Mesh 局部坐标不需要手动转换。第二边要用无序对做 Key也就是索引小的在前不然同一条边会被当成两条边统计。第三AddEdge里我用了元组作为 Key这在较老版本的 Unity/C# 里可能不支持如果你在 Unity 2020 以下建议自己拼一个int key a * 100000 b之类的数值。这里我额外犯过一个低级错误忘了设lineMaterial结果控制台报GL: Pass has to have a valid shader之类的错。GL 绘制必须有一个材质并且材质里至少有一个可用 Pass才能正常 SetPass。简单做法是在 OnEnable 里创建材质或用Shader.Find(Sprites/Default)临时顶一下。3.2 GL.wireframe 的坑和移动端注意Unity 的GL类里有一个静态属性GL.wireframe设置成true后所有后续绘制都会以线框模式输出。听起来很省事但我测试下来这个属性在 Built-in 管线里可以用在 URP 下经常不生效或者说表现受图形 API 影响非常大。而且在编辑器 Scene 窗口和 Game 窗口的行为还不一致真机上更是经常直接被忽略。所以我的建议是GL.wireframe只作为编辑器的临时调试开关别把它写进正式功能里。真要在真机上预览线框用上面这种GL.Begin(GL.LINES)自己画线段的方式更可控。还有一个移动端最常见的问题OpenGL ES 上很多设备根本不支持自定义glLineWidth也就是说你用 GL 画的线永远只有 1 像素宽。就算你在代码里写了GL.LINES也别指望线条能加粗。如果你确实需要粗线就不要走 GL改用 LineRenderer或者把线段生成一片三角形带 Mesh让美术自定义宽度和颜色。这也是我后来在微信小游戏真机调试时遇到的坑最后老老实实换成了代码生成 Mesh 的那套方案。4. 代码生成描边 Mesh从拓扑里提取棱边的完整实现4.1 提取棱边从三角形边表到线框数据如果你希望在 runtime 拿到可控的描边数据比如点击某条边高亮、让边闪烁、把描边导出成美术可编辑的 Mesh那就要走代码生成路线。第一步是从Mesh.vertices和Mesh.triangles里提取所有边并统计每条边的出现次数。原理是一条边如果被两个三角形共享它就是网格内部边如果只被一个三角形引用它就是这个网格的边界边也就是最直观的“棱边”。核心逻辑如下public static ListVector3 ExtractBoundaryEdges(Vector3[] vertices, int[] triangles) { var edgeCounts new Dictionary(int, int), int(); for (int i 0; i triangles.Length; i 3) { AddEdge(edgeCounts, triangles[i], triangles[i 1]); AddEdge(edgeCounts, triangles[i 1], triangles[i 2]); AddEdge(edgeCounts, triangles[i 2], triangles[i]); } var lines new ListVector3(); foreach (var kv in edgeCounts) { if (kv.Value 1) { lines.Add(vertices[kv.Key.Item1]); lines.Add(vertices[kv.Key.Item2]); } } return lines; }得到的ListVector3可以直接塞给 LineRenderer也可以组装成独立 MeshGameObject lineObj new GameObject(OutlineMesh); var lr lineObj.AddComponentLineRenderer(); lr.positionCount lines.Count; lr.SetPositions(lines.ToArray());注意 LineRenderer 是按折线处理的如果你的模型有多条不相连的边界边按上面这种连续数组传给 LineRenderer 会导致首尾之间被无意义地连上线。更稳的做法是每两个相邻点之间生成一段独立线段或者自己构建一个GL.LINES模式的 Mesh 来渲染。LineRenderer 适合简单预览正式用建议构建 Mesh。4.2 进一步做视觉轮廓根据视线方向筛选轮廓边上面提取的棱边是模型本身的几何边界但很多“描边”需求要的不是几何棱边而是视觉外轮廓也就是随着相机角度变化而变化的轮廓线。这个用纯静态提取是拿不到的需要实时计算。经典做法是对每条边取它两个邻接三角形的法线然后和视线方向做点积如果两个三角形一个面向相机、一个背向相机那么这条边就属于当前视角下的轮廓边。做法分两步先构建边到邻接面的映射再对每个面计算法线。伪代码大概是for each edge e: if e has two adjacent triangles t1, t2: float d1 Vector3.Dot(viewDir, t1.normal); float d2 Vector3.Dot(viewDir, t2.normal); if (d1 * d2 0f): mark e as silhouette edged1 * d2 0f表示两个法线一个朝前一个朝后这正是视觉轮廓的定义。实际操作时还要把viewDir从世界空间转到模型空间或者在模型空间遍历边时统一使用模型空间视线方向。我的经验是在模型空间计算最省事因为Mesh.vertices本来就是模型空间坐标算完轮廓边直接取顶点连线就行不用来回变换。这种方案的优点是轮廓数据完全可控可以给任意一条边加颜色渐变、加粗细变化、做动画。缺点是每帧要遍历所有边模型几万面时 CPU 压力明显。我建议只在面数较低的物件上跑或者把计算结果缓存模型不动时就不重算。4.3 代码生成方案适合的风格化与交互场景代码生成描边 Mesh 最适合的场景我总结有三类。第一类是低多边形风格或线框视觉。游戏里经常有那种“建筑蓝图”“全息投影”“黑客帝国风格”的物件本质就是把几何棱边以线框形式渲染出来。这类需求如果用 Shader 去偏法线很难得到干净的线条数据用代码提前生成线段 Mesh 反而简单而且还能配合 UI 交互做局部高亮。第二类是可交互轮廓。比如你在做一个塔防游戏玩家点击某个建筑时建筑的外轮廓要有一圈有动态波浪效果的高亮线。用代码生成方案你可以拿到每条边的索引然后单独控制某条边的透明度、颜色、宽度这在 Shader 方案里很难做到。第三类是离线烘焙。很多手机游戏对运行时 CPU 很敏感尤其是微信小游戏这种受限环境。你可以把轮廓边提取放在编辑器阶段烘焙成额外的 Mesh 或顶点数据运行时直接渲染不参与动态计算。这样既享受了代码生成的可控性又避免了性能开销。我在 4.1 节里写的ExtractBoundaryEdges函数其实足够当成编辑器工具脚本放在[MenuItem]下使用选中一个 Mesh一键生成对应的轮廓 Mesh 资源然后美术直接把轮廓 Mesh 拖进场景里当子物体渲染。这个方式我用了挺长时间比每次运行时重新提取要稳得多。5. 常见问题与排查技巧实录5.1 描边断裂、宽窄不均、Z-fighting 怎么处理描边断裂是法线外扩方案里最常遇到的问题。如果你发现模型某些地方描边缺了一块大概率是顶点法线方向不一致或者网格存在非流形结构。最简单的解决办法是让美术把这些顶点焊接起来统一法线如果不想改模型就换模板缓冲方案或者后处理方案。另外有些低模为了保证硬边效果一个顶点会有多个法线这种模型在导出时建议让烘焙工具重新计算平均法线或者在 Shader 里用面法线而不是顶点法线来偏移。宽窄不均这个问题除了我在 2.2 节提到的裁剪空间恒定宽度之外还有一个常见原因是相机 FOV。FOV 越大边缘拉伸越明显描边看起来会不一致。如果你做的是第三人称游戏建议把描边宽度写成屏幕空间的像素值而不是世界空间或模型空间的值。Z-fighting 是描边层和模型本体重叠导致的深度竞争。解决手段有几个方向描边 Pass 设置ZWrite Off或者让描边层的深度测试改成ZTest Always或者把两个 Pass 用Offset做微小偏移。具体用哪个取决于你是否希望描边被其他物体遮挡。如果希望角色被墙挡住时描边也一起被挡住就保留正常的ZTest LEqual如果希望描边始终可见比如高层选中特效就用ZTest Always。5.2 性能损耗、多 Pass 与真机兼容性排查Shader 法线外扩的本质代价是每个模型多渲染一次DrawCall 翻倍。几十个角色同时描边时这个开销很容易把移动端拖垮。优化思路一般有几种批处理合并、只对关键物体开启描边、用条件渲染控制距离。另外如果你的项目用的是 URP建议把描边逻辑写成 Renderer Feature而不是给每个材质挂额外 Shader这样能统一控制哪些物体参与描边以及描边强度。GL 立即模式的性能是最差的它每次调用都要向图形 API 提交大量状态切换。哪怕只是几千条线也可能让 Profiler 里的 CPU 时间爆炸。所以我在项目里的原则是GL 只出现在#if UNITY_EDITOR的调试代码里绝不进入正式包。如果需要发布真机就把线框数据烘焙成 Mesh走普通 MeshRenderer。最后说一个兼容性相关的点很多人在写 Shader 描边时偷懒直接克Cull Off想让模型正反面一起渲染。这在部分模型上确实能掩盖破面但代价是增加 Overdraw并且在不透明物体的深度排序上容易出现穿插。建议你始终按“正面渲染本体、背面渲染描边”的经典结构来做除非有明确的风格化要求否则不要轻易改这个设计。5.3 动态模型描边怎么做才稳如果你的目标是带骨骼动画的角色或者会变形的物体上面所有“预烘焙”方案都会出问题。因为 Mesh 顶点每一帧都在变轮廓边会随着骨骼运动发生变化。两种方向可以选一种是在运行时每帧重新提取轮廓性能压力大但得到的结果最准确另一种是继续用 Shader 法线外扩因为它是纯顶点变换阶段的处理会自动跟随骨骼动画变形这也是大多数游戏角色描边选择 Shader 方案的核心原因。代码生成方案如果想用在动态模型上可以把轮廓 Mesh 作为角色的子物体跟随根节点旋转平移但无法跟随骨骼形变。如果你执意要做“骨骼动画角色上的可交互棱边”可以考虑把计算放在 Job System 里并行处理或者用 GPU 的 Geometry Shader 去实时生成轮廓线不过后者在 URP 和移动端上的支持又是一个大坑我个人不建议轻易碰。我自己的项目里最终的选择是普通角色描边用 Shader 法线外扩加屏幕空间恒定宽度调试预览用 GL 节假日版本风格化建筑和交互高亮用代码生成轮廓 Mesh。这个组合目前跑得比较稳改起来也不至于牵扯过深。描边这事没有银弹关键是把每种方案的边界看清楚。
返回列表