ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unity模型描边方案全解析:Shader、后处理与轮廓网格对比

Unity模型描边方案全解析:Shader、后处理与轮廓网格对比 做Unity开发这几年模型描边这个需求我接过太多次了。RTS里选中单位要亮一圈卡通渲染的角色要有一层轮廓线Boss战里弱点部位要高亮闪烁甚至一些UI特效也要用描边做视觉强调。同一个需求不同项目里做出来的效果却可能天差地别原因很简单——描边方案的选型从来不是一个Shader能解决的。网上搜“Unity 模型描边”翻来覆去就是几个现成Shader很多人抄完发现要么描边粗细不稳定要么移动端卡成幻灯片要么模型一凹进去描边就断成一段段。这篇文章不做那种“只给结论不给过程”的教程我把Shader、GL、代码生成网格这三条路线完整过一遍把每个方案背后的原理、适用场景、性价比和坑全讲清楚。你先搞明白每个方案到底在做什么再决定项目里用哪套比直接抄一个插件要靠谱得多。1. 先把需求理清楚你的描边是给谁看的很多人在选描边方案时第一个错误就是跳过需求分析直接去搜Shader。描边这东西表面看起来都是“物体边缘多一圈线”但同一句话在不同场景下的要求完全不同。1.1 描边在游戏里的三种典型身份我大致把描边需求分成三类。第一类是选中和交互反馈。RTS里的单位选中圈、冒险游戏里的可交互物品高亮、编辑器里的物体选中框。这类描边的核心要求是宽度在屏幕上感觉稳定颜色醒目不能因为物体离相机远就细得看不见也不能因为贴近相机就粗得糊一片。它只需要作用在特定物体上通常是一闪而过对性能要求不算苛刻。第二类是风格化渲染。卡通、三渲二、动漫风格的场景和角色。这类描边不是临时反馈而是画面的基础构成元素每个角色、每个道具都要有而且要跟光照、色调配合。它的核心要求是轮廓线要连贯、粗细符合美术风格、不能闪烁、不能时有时无。因为要全场景应用性能开销必须压到很低。第三类是逻辑提示或调试追踪。给某个特定区域画个临时范围、把骨骼关节连线显示出来、把碰撞体轮廓投影到屏上。这类需求通常不需要进最终版渲染管线往往是开发期调试用的所以代价越小越好哪怕丑一点都行。这三类需求直接决定了你该选哪条技术路线。第一种可以上屏幕后处理或模板缓冲Shader第二种适合法线外扩加稳定化处理第三种用GL直接画线是最省事的。1.2 动手前先问自己三个问题我建议你在搜索任何描边教程之前先拿纸把这几个问题写下来。第一个问题描边的宽度是像素宽度还是世界空间宽度像素宽度意味着不管你离物体多远描边看起来几乎一样粗世界空间宽度意味着描边是物体表面“长”出来的一层壳离远了会变小。大多数选中高亮效果要的是像素宽度大多数卡通风格轮廓线其实要的是接近像素宽度但对齐到世界方向。这个区别直接决定了Shder里该往哪个方向挤出顶点。第二个问题描边只作用在一个物体上还是整个屏幕所有物体只针对单个物体你可以用模型空间的法线外扩不影响其他物体要全屏幕统一描边你就得考虑后处理方案在深度和法线纹理上做边缘检测。后处理的性能开销跟屏幕分辨率强相关跟场景复杂度关系不大所以它在大型开放场景里通常更稳。第三个问题目标设备是什么如果你只做PC端那方案选择余地很大如果要上移动端每个像素级操作都要精打细算如果做VR那双眼渲染会让后处理开销直接翻倍你可能得回到Shader法线外扩的路线。1.3 四条路线一张表看懂把常见方案放在一张表里对比选型的时候就能有个大致方向。方案实现难度性能开销描边风格适用场景Shader法线外扩双Pass低低壳状描边受模型法线影响单物体选中、NPC高亮、风格化角色Shader模板缓冲中低描边不侵入物体正面追求边缘干净的选中效果屏幕后处理边缘检测中高中高与分辨率相关全屏统一描边边缘检测式全场景风格化、地图编辑预览GL直接绘制线条低低但效果朴素线框感、调试风格编辑器辅助、逻辑调试、碰撞可视化代码生成轮廓网格高低真正的几何体轮廓可复用光照追求高质量风格化、需要毫秒级性能时这里要特别提醒一句没有任何方案是全能的。我见过团队坚定用法线外扩做所有描边结果在凹形模型比如一个碗、一把椅子上描边穿模穿得没法看最后只能临时改用后处理。也见过用后处理做单物体高亮结果每帧全屏处理一次在低端手机上发热明显。方案选错后面全是返工。2. Shader 描边最常用也最考验基本功的做法Shader法线外扩是入门最容易出效果的方案也是坑最多的一种。它不止是“把顶点往法线方向挪一下”这么简单里面有大量细节决定最终效果好不好。2.1 法线外扩核心原理与第一版可跑Shader法线外扩的基本思路只有三步先用正常Pass渲染模型本体再用一个Pass把模型顶点沿着法线方向往外推一段距离并且只绘制模型的背面这样从正面看物体边缘就会露出一圈外扩部分的颜色形似描边。为什么只画背面因为如果外扩后的模型不剔除正面那正面会覆盖掉原本的模型颜色——外扩出来的面是朝向你的你就看到一堆膨胀的面而不是一条线了。但如果你剔除正面只保留背面外扩部分只有边缘处不会被模型本体遮挡露出来的那一圈自然就成了描边。基础的Shader代码大概长这样Shader Custom/OutlineSimple { Properties { _OutlineColor (Outline Color, Color) (0, 0, 0, 1) _OutlineWidth (Outline Width, Range(0, 0.1)) 0.02 } SubShader { Tags { RenderTypeOpaque } // Pass 1: 正常渲染模型本体 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag (v2f i) : SV_Target { return tex2D(_MainTex, i.uv); } ENDCG } // Pass 2: 法线外扩Cull Front Pass { 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 vertex : SV_POSITION; }; float _OutlineWidth; fixed4 _OutlineColor; v2f vert (appdata v) { v2f o; float3 normal normalize(v.normal); float3 pos v.vertex.xyz normal * _OutlineWidth; o.vertex UnityObjectToClipPos(float4(pos, 1)); return o; } fixed4 frag (v2f i) : SV_Target { return _OutlineColor; } ENDCG } } }这个版本能跑但仅限于非常规整的模型。你把宽度调成0.02左右挂在Cube或Sphere上看效果还行一旦换到角色模型、复杂道具上问题马上就来了。别急着直接用到项目里先把下面几个问题解决掉。2.2 模板缓冲修正让描边不“糊”进模型内部法线外扩有个天生缺陷外扩的是整个模型表面不只是边缘。在凹形结构的物体上外扩的部分会在模型自身的凹陷处穿进正面表面之下从视觉上看就是描边渗进模型内部、出现断裂或者脏脏的色块。举个最直观的例子一把椅子的椅背和坐面之间有个凹角外扩出来的背面可能在椅背的缝隙处叠加在模型正面上原本干净的轮廓线就从中间断开露出一大块描边颜色。模板缓冲Stencil Buffer能解决这个问题。核心思路是先用一个Pass把模型渲染到模板缓冲里标记出“物体表面”所在区域然后在描边Pass中开启模板测试强制只在模板值不等于物体表面标记的地方绘制。这样描边永远不会画到模型表面的正上方只会在边缘外侧露出来。Shader Custom/OutlineStencil { Properties { _OutlineColor (Outline Color, Color) (1, 0.3, 0.2, 1) _OutlineWidth (Outline Width, Range(0, 0.1)) 0.02 } SubShader { Tags { RenderTypeOpaque } // Pass 1: 写入模板不输出颜色 Pass { Stencil { Ref 1 Comp Always Pass Replace } ColorMask 0 ZWrite On } // Pass 2: 外扩描边只在模板不等于1处绘制 Pass { Cull Front ZWrite On Stencil { Ref 1 Comp NotEqual } CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 vertex : SV_POSITION; }; float _OutlineWidth; fixed4 _OutlineColor; v2f vert (appdata v) { v2f o; float3 normal normalize(v.normal); float3 pos v.vertex.xyz normal * _OutlineWidth; o.vertex UnityObjectToClipPos(float4(pos, 1)); return o; } fixed4 frag (v2f i) : SV_Target { return _OutlineColor; } ENDCG } } }加了模板缓冲之后凹形模型的描边断裂问题基本能压住。但代价是模板缓冲每个像素多读一次、多写一次好在现代GPU处理这个很快移动端也扛得住。要注意多个物体共用同一个Ref值会互相干扰如果场景里有多个需要描边的物体最好给它们分配不同的Ref值或者用Unity的Stencil操作结合RenderQueue做隔离。2.3 法线外扩的三个暗坑非均匀缩放、硬边裂缝、描边粗细不一致模板缓冲解决了穿模但还有三个坑是新手最容易忽略的。第一个坑是非均匀缩放。如果你的物体Transform的Scale不是(1,1,1)而是比如(2,1,1)那么顶点沿物体空间法线外扩的0.02经过非均匀缩放后在X轴方向和Z轴方向的实际偏移长度就不一样描边会变得一边厚一边薄。解决方法是在Shader里把法线转换到世界空间再在世界空间做外扩。到了世界空间非均匀缩放的影响依然存在但至少你可以用矩阵的逆转置来修正法线方向。简单做法是v2f vert (appdata v) { v2f o; float3 worldNormal normalize(UnityObjectToWorldNormal(v.normal)); float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; worldPos worldNormal * _OutlineWidth; o.vertex UnityWorldToClipPos(worldPos); return o; }第二个坑是硬边裂缝。模型表面如果有一条明显的硬边——比如立方体的棱角、角色模型上两块法线朝向相差很大的Mesh接缝——沿法线外扩的顶点会向两边岔开在两块面片之间形成一条裂缝深度测试会把它判定为背后看起来就像描边在棱角处缺了一块。缓解方法之一是适当增加外扩宽度让裂缝被两块面片的外扩部分盖住一部分另一种做法是在建模阶段给边缘端点一个平滑的法线但这需要美术配合不是所有项目都愿意做。第三个坑是描边粗细不一致。法线外扩产生的描边在世界空间是等宽的但投影到屏幕之后靠近相机的部分看起来粗远离相机的部分看起来细。如果你的需求是“屏幕像素宽度恒定”就得在Shader里根据深度把外扩宽度做缩放。常见的做法是把世界空间宽度除以顶点到相机的距离乘上某个系数。当然反过来如果你想要的就是世界空间恒定离远了变小那就不用处理。// 屏幕空间近似恒定宽度的外扩 float dist distance(worldPos, _WorldSpaceCameraPos); float screenSpaceWidth _OutlineWidth * dist * 0.1; worldPos worldNormal * screenSpaceWidth;系数0.1要按实际项目调因为还跟相机的FOV有关。这个近似做得不算完美但视觉上已经足够稳。如果你只需要单个物体描边Shader法线外扩就是性价比最高的选择。一句经验做三渲二或卡通角色描边大多数团队最终都回归到这个方案再加上模板缓冲和屏幕空间宽度修正。3. 屏幕后处理描边适合大面积统一效果的技术管线Shader外扩描边处理单物体很顺手但当你需要在整个场景里给所有物体统一描边时逐物体做外扩就变得繁琐还容易出现物体之间描边粗细不统一的问题。这时候就该考虑屏幕后处理方案。3.1 深度法线纹理与Sobel边缘检测的原理后处理描边的思路跟法线外扩完全不同。法线外扩是“在模型上做文章”后处理则是“在屏幕上找边缘”。它先让相机渲染出一张深度纹理和一张法线纹理然后在屏幕空间里对每个像素做边缘检测如果一个像素跟它周围的像素相比深度值有明显跳变或者法线方向有明显突变就判定这个像素位于边缘位置给它涂上描边颜色否则保留原画面。深度反映的是“这个物体离相机多远”法线反映的是“这个像素所在的表面朝哪个方向”。当两个物体一前一后挨在一起深度会跳变于是就有了边缘当一个表面的朝向突然拐弯法线会突变于是也有边缘。两种信息结合就能捕捉到绝大多数视觉边缘。边缘检测用的算子有很多最常用的是Sobel。Sobel用3x3的卷积核分别计算横向和纵向的梯度然后取平方和开根号得到这个像素的“边缘强度”。在Unity的Shader里这个操作通常是逐像素采样深度和法线纹理。3.2 从相机设置到完整Shader的实操步骤要用后处理描边先要让相机输出深度法线纹理void OnEnable() { Camera cam GetComponentCamera(); cam.depthTextureMode | DepthTextureMode.DepthNormals; }然后在OnRenderImage里调用自定义的Blit材质void OnRenderImage(RenderTexture src, RenderTexture dest) { Graphics.Blit(src, dest, outlineMaterial); }描边Shader的基本结构Shader Custom/OutlinePostEffect { Properties { _MainTex (Base Texture, 2D) white {} _OutlineColor (Outline Color, Color) (0, 0, 0, 1) _DepthThreshold (Depth Threshold, Range(0, 1)) 0.1 _NormalThreshold (Normal Threshold, Range(0, 1)) 0.1 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; sampler2D _CameraDepthNormalsTexture; float4 _MainTex_TexelSize; float _DepthThreshold; float _NormalThreshold; fixed4 _OutlineColor; 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; } float3 DecodeNormal(float4 enc) { // Unity深度法线纹理的编码格式 float3 n enc.xyz * 2.0 - 1.0; return normalize(n); } float EdgeFactor(float2 uv) { float2 texel _MainTex_TexelSize.xy; // 3x3邻域的深度法线 float4 d[9]; float3 n[9]; for (int x -1; x 1; x) { for (int y -1; y 1; y) { float2 offset float2(x, y) * texel; float4 dn tex2D(_CameraDepthNormalsTexture, uv offset); d[(x1)*3 (y1)] dn; n[(x1)*3 (y1)] DecodeNormal(dn); } } // Sobel横向与纵向 float depthX abs(d[6].w - d[0].w) abs(d[7].w - d[1].w) abs(d[8].w - d[2].w); float depthY abs(d[2].w - d[0].w) abs(d[5].w - d[3].w) abs(d[8].w - d[6].w); float depthGrad depthX depthY; float3 normalX abs(n[6] - n[0]) abs(n[7] - n[1]) abs(n[8] - n[2]); float3 normalY abs(n[2] - n[0]) abs(n[5] - n[3]) abs(n[8] - n[6]); float normalGrad max(normalX.r, max(normalX.g, normalX.b)) max(normalY.r, max(normalY.g, normalY.b)); float depthEdge step(0.01, depthGrad * _DepthThreshold); float normalEdge step(0.01, normalGrad * _NormalThreshold); return max(depthEdge, normalEdge); } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); float edge EdgeFactor(i.uv); return lerp(col, _OutlineColor, edge); } ENDCG } } }这里的d[n].w是编码后的深度值Unity把深度法线打包成一张RGBA纹理其中深度存在w分量法线存在xyz分量。注意解码时法线要乘2减1从[0,1]映射回[-1,1]。3.3 后处理描边的适用场景与两个重要缺陷后处理描边的优势是全屏统一、不需要改任何模型、不依赖美术配合只要相机挂一个脚本就能对整个画面起效。它很适合做“整个地图编辑状态的轮廓显示”、“全局风格化轮廓”或者“透视辅助线预览”这类场景。但它有两个绕不开的缺陷。第一个是性能跟分辨率强相关。每帧都要处理整张屏幕的像素每个像素还要采样9次深度法线纹理在4K分辨率下开销非常大。移动端玩这个基本等于自寻死路除非你把分辨率降到很低或者只在特定相机上开启。网上有些项目在移动端后处理描边做得不错那也是基于硬件特性做了大量降采样优化。第二个是会把所有边缘都描出来。物体和物体挨在一起的接触线、地面上凹凸不平的接缝、雾效中的深度跳变全都会变成描边。你可能只想让角色有轮廓结果整个场景都变成了漫画线条。解决方法是配合Layer或Stencil来限制描边范围或者在Fragment做边缘判定时增加过滤条件。更进阶的做法是拿模板缓冲预先标记特定层再在边缘检测时只对这些区域做处理。我做后处理描边时习惯把_DepthThreshold和_NormalThreshold暴露给美术调。美术团队在调参时常常为了“让描边别太碎”而把阈值拉得很高结果边缘变得断断续续。建议提供一个调试模式把边缘强度直接输出为灰度图让美术能直观看到每个阈值对应的边缘区域。这个调试模式在Shader末尾加一句return edge;就行非常方便。4. GL直接绘制线条调试利器与快速原型方案如果你只是想临时看看某个物体的轮廓或者做着色器调试时需要画几条参考线完全没必要上复杂的描边ShaderUnity自带的GL类就能搞定。4.1 GL类在Unity渲染管线里的定位UnityEngine.GL是一个低级别的渲染API封装可以在脚本里直接用代码绘制线段、三角形等基本图元。它的调用时机很讲究一般放在相机的OnRenderObject或OnPostRender回调里这样画出来的东西会跟相机的渲染管线同步。这里要注意GL绘制并不能脱离材质单独存在。使用GL绘制任何图元之前必须调用一个Material的SetPass方法告诉GPU当前用什么着色器来渲染这些顶点。否则GL绘制会直接失败这也是很多人踩过的坑。最基础的用法public class GLDrawTest : MonoBehaviour { public Material lineMat; void OnRenderObject() { if (lineMat null) return; lineMat.SetPass(0); GL.Begin(GL.LINES); GL.Color(Color.red); GL.Vertex(transform.position); GL.Vertex(transform.position Vector3.up * 2f); GL.End(); } }这样相机会在渲染完场景后画出一条红色的线。4.2 从Mesh里提取硬边并用GL画出来用GL给模型画描边关键是先从Mesh里提取“哪些边算轮廓”。最朴素的方法是遍历所有三角形找到相邻三角形法线夹角较大的边硬边以及只被一个三角形使用的边边界边把它们作为轮廓线。提取逻辑的伪代码using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(MeshFilter))] public class GLLineOutline : MonoBehaviour { public Material outlineMat; public float angleThreshold 60f; // 硬边夹角阈值 private Mesh mesh; private ListVector3 lineVertices new ListVector3(); void Start() { mesh GetComponentMeshFilter().sharedMesh; ExtractEdges(); } void ExtractEdges() { Vector3[] verts mesh.vertices; int[] tris mesh.triangles; Vector3[] normals mesh.normals; // 用边信息记录相邻三角形索引 DictionaryEdgeKey, Listint edgeToTriangles new DictionaryEdgeKey, Listint(); for (int i 0; i tris.Length; i 3) { int a tris[i]; int b tris[i 1]; int c tris[i 2]; AddEdge(edgeToTriangles, a, b, i / 3); AddEdge(edgeToTriangles, b, c, i / 3); AddEdge(edgeToTriangles, c, a, i / 3); } foreach (var pair in edgeToTriangles) { Listint triIndices pair.Value; if (triIndices.Count 1) { // 只有一条三角形使用的边边界边必是轮廓 AddLinePair(pair.Key.a, pair.Key.b); } else if (triIndices.Count 2) { // 两条三角形共享的边比较法线夹角 Vector3 n1 GetTriangleNormal(tris, verts, triIndices[0]); Vector3 n2 GetTriangleNormal(tris, verts, triIndices[1]); float angle Vector3.Angle(n1, n2); if (angle angleThreshold) { AddLinePair(pair.Key.a, pair.Key.b); } } } } // ... 辅助方法省略核心就是上面的遍历逻辑 }拿到轮廓线顶点列表之后在OnRenderObject里用GL.Begin(GL.LINES)画出来void OnRenderObject() { if (outlineMat null) return; outlineMat.SetPass(0); GL.Begin(GL.LINES); GL.Color(Color.yellow); for (int i 0; i lineVertices.Count; i 2) { Vector3 worldA transform.TransformPoint(lineVertices[i]); Vector3 worldB transform.TransformPoint(lineVertices[i 1]); GL.Vertex(worldA); GL.Vertex(worldB); } GL.End(); }4.3 GL描边的性能定位和适用边界GL画线的最大优点是实现简单、代码直观做编辑器扩展精确定位问题时特别好用。比如你要排查一个模型的哪些边被判定成了硬边直接把边画出来比对着法线数据空想要高效得多。但它的短板也很明显。第一GL绘制的是裸线条不支持抗锯齿在屏幕上会有明显的锯齿感除非你额外做后处理否则很难用做正式游戏画面。第二线宽不可控多数平台默认就是1像素宽你无法让它变粗。第三GL绘制无法参与阴影、光照、遮挡等常规渲染流程它属于“后置”的绘制很多东西需要你自己控制绘制顺序。所以我的定位很清楚GL方案适合做调试工具适合快速验证想法适合编辑器辅助可视化。要作为最终用户可见的正式效果一般不值得在这个方向深挖。5. 代码生成轮廓网格追求高性能风格化的进阶方案前几种方案各有各的短板。Shader法线外扩在凹形模型上会穿模后处理开销大且无法精确控制每根轮廓线GL画线又太粗糙。如果你要高品质的卡通渲染、需要精确控制轮廓、又要满足移动端性能要求那就得考虑在代码里生成一套真正的轮廓网格。5.1 硬边检测算法细化这个方法要做两件事先用程序从模型网格中提取出所有轮廓边再沿着轮廓边生成一圈窄条网格Quad Strip使这圈网格替代Shader外扩直接参与渲染。第一步提取轮廓边核心逻辑跟前面GL方案一致遍历所有三角形对每条边做哈希记录它属于哪些三角形。然后对每条边做判断如果是模型开放边界的边缘只有一个三角形使用直接认定为轮廓如果是两个三角形共享的边计算两个三角形法线的夹角如果夹角大于某个阈值判定为硬边轮廓如果夹角较小说明这里比较平滑视觉上不会出现明显棱线就跳过。这步的关键是阈值的选取。阈值越小判定为轮廓的边越多轮廓越密阈值太大轮廓线条变少细节丢失。常用范围在30度到80度之间具体数值取决于模型风格。低多边形风格的模型因为面片少、转角都挺硬60度上下比较合适高模角色、曲面模型想要突出脸部、边缘而不是皮肤表面的细微变化阈值建议放到75度以上。5.2 为轮廓边生成顶点带与三角形索引提取到轮廓边之后第二步是把每条线变成一长条四边形网格。以一条边为例它有两个端点A和B把A沿A点的法线方向外扩一段距离得到A1B沿B点的法线方向外扩得到B1这样A、B、A1、B1四个点就组成一个四边形。把这个四边形拆成两个三角形就能挂进Mesh里参与渲染。public static void BuildOutlineMesh(Mesh srcMesh, float outlineWidth, float angleThreshold, out ListVector3 vertices, out Listint triangles) { vertices new ListVector3(); triangles new Listint(); Vector3[] srcVerts srcMesh.vertices; int[] srcTris srcMesh.triangles; Vector3[] srcNormals srcMesh.normals; // 1. 构建边到三角形映射简化代码实际项目会用哈希表 // 2. 判断每条边是否为轮廓边 // 3. 对每根轮廓边生成Quad for (int i 0; i contourEdges.Count; i) { Edge e contourEdges[i]; int idxA e.a; int idxB e.b; // 外扩宽度沿顶点法线方向 Vector3 a0 srcVerts[idxA]; Vector3 b0 srcVerts[idxB]; Vector3 normalA srcNormals[idxA].normalized; Vector3 normalB srcNormals[idxB].normalized; Vector3 a1 a0 normalA * outlineWidth; Vector3 b1 b0 normalB * outlineWidth; int baseIndex vertices.Count; vertices.Add(a0); vertices.Add(b0); vertices.Add(a1); vertices.Add(b1); // 两个三角形 triangles.Add(baseIndex 0); triangles.Add(baseIndex 1); triangles.Add(baseIndex 2); triangles.Add(baseIndex 2); triangles.Add(baseIndex 1); triangles.Add(baseIndex 3); } }生成的网格可以直接赋给新的GameObject挂MeshRenderer和一个纯色Shader的材质。这样做的优势是描边网格是一次性生成的运行时没有额外Shader指令每帧只多一次DrawCall和几百个顶点的绘制性能比后处理和逐像素外扩都好。5.3 边界情况和优化建议这套方案听起来完美但有几个边界情况要处理。非均匀缩放的物体上外扩宽度会跟着Scale变形所以实际项目中我通常把宽度换算到世界空间再挤出或者要求所有带描边的物体保持均匀缩放。后一种限制在部分项目里是可行的美术规范里写明就好。凹形模型上生成的外扩条带可能有一部分会埋在模型内部渲染时被自身遮挡这倒不影响轮廓线效果因为描边网格是独立物体它跟原模型共用一个物体空间坐标但两个Mesh在渲染时深度关系要正确处理。简单做法是生成网格时让条带略微向外偏移防止深度冲突导致的闪烁。在原有代码上还可以做几个优化把Mesh烘焙到Asset避免每次启动都计算把多根轮廓条带合并到一个Mesh里减少DrawCall给条带UV设置递增颜色支持后续做动画描边。6. 踩坑实录与排查技巧所有效果做到最后拼的都是排查问题的能力。我把自己在项目里真正遇到过的几个典型问题整理出来未必每个你都能碰到但碰到了能少走很多弯路。6.1 描边断裂、闪烁和穿模的常见原因描边断裂最常见的原因是硬边裂缝加外扩宽度不够前面已经讲过。排查方法是把外扩宽度调大看看断裂处是否合拢如果调大后合拢说明是挤出量不足如果调大还是断那要查模型是否有大量非流形边一条边被三个以上三角形共享这种数值上无效的边会导致法线外扩方向上发生冲突。闪烁问题一般跟深度冲突有关。描边网格和原模型表面在深度上几乎重叠深度测试的精度不够时画面会一跳一跳。我的做法是给描边Pass开启ZWrite Off、把RenderQueue调后一点或者生成网格时把描边条带在外法线方向再偏移0.0001给深度值留一点余量。这个偏移量不能太大太大看起来描边就飘了。穿模问题在凹形物体上属于结构性难题。如果模板缓冲还压不住那就要考虑回到代码生成轮廓网格让描边以独立几何体存在从根源上消除“外扩面嵌进物体正面”的问题。6.2 移动端性能对比哪些做法需要谨慎移动端的GPU架构和PC差别很大。简单总结我的实测体会Shader法线外扩的性能最优因为顶点计算和像素填充量都很小模板缓冲方案次之多个Pass带来的带宽开销在低端机上能感觉到后处理描边要谨慎使用尤其注意纹理采样次数一个Sobel要做9次深度法线采样在中端机型上能占到整个渲染帧时间的15%到20%GL画线在移动端的兼容性参差不齐部分设备不支持在线宽上的预期表现不适合做正式功能。代码生成轮廓网格在移动端是另一个极优选择因为生成过程只在启动时发生运行时就是普通网格渲染。如果模型数量不多也不会有内存压力。6.3 常见问题速查表现象可能原因解决方向描边在远处变细、近处变粗世界空间恒定宽度改用屏幕空间宽度按深度缩放外扩量描边在棱角处断裂硬边裂缝加大外扩宽度平滑顶点法线改用代码生成轮廓描边钻到模型内部凹形结构单纯法线外扩加模板缓冲改用独立轮廓网格描边闪烁深度冲突偏移描边位置关闭ZWrite调RenderQueue全屏描边太碎后处理阈值过低增大深度/法线阈值增加过滤条件移动端卡顿后处理分辨率高降采样限制触发区域改回Shader方案多个描边物体互相干扰模板Ref冲突给物体分配不同Ref或用Layer隔离这些坑我基本都在实际项目里踩过。所以每次有人问我“哪种描边方案最好”我都不直接回答而是先问一句你的项目里描边要做什么事答案不同方案完全不同。我自己在项目里最常用的组合是单物体选中用Shader法线外扩加模板缓冲卡通角色描边用代码生成的轮廓网格编辑器调试和原型验证用GL画线。三套方案互相配合基本覆盖了我遇到的绝大多数需求。你在自己的项目里也可以按这个思路做个“描边工具箱”把几个方案都实现一遍按需求切换。这个领域还有个挺有意思的扩展方向动态轮廓线。给描边粗细不断的脉冲值做呼吸效果、给命中反馈做描边闪烁、用描边颜色区分敌我或道具状态这些都是在基础描边上做变体。核心还是先把基础方案吃透后面加花样才有根基。
返回列表