做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 { "RenderType"="Opaque" } // 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 { "RenderType"="Opaque" } // 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 = GetComponent<Camera>(); 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[(x+1)*3 + (y+1)] = dn; n[(x+1)*3 + (y+1)] = 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直接绘制线条:调试利器与快速原型方案
如果你只是想临时看看某个物体的轮廓,或者做着色器调试时需要画几条参考线,完全没必要上复杂的描边Shader,Unity自带的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 List<Vector3> lineVertices = new List<Vector3>(); void Start() { mesh = GetComponent<MeshFilter>().sharedMesh; ExtractEdges(); } void ExtractEdges() { Vector3[] verts = mesh.vertices; int[] tris = mesh.triangles; Vector3[] normals = mesh.normals; // 用边信息记录相邻三角形索引 Dictionary<EdgeKey, List<int>> edgeToTriangles = new Dictionary<EdgeKey, List<int>>(); 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) { List<int> 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点的法线方向外扩一段距离得到A1,B沿B点的法线方向外扩得到B1,这样A、B、A1、B1四个点就组成一个四边形。把这个四边形拆成两个三角形,就能挂进Mesh里参与渲染。
public static void BuildOutlineMesh(Mesh srcMesh, float outlineWidth, float angleThreshold, out List<Vector3> vertices, out List<int> triangles) { vertices = new List<Vector3>(); triangles = new List<int>(); 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画线。三套方案互相配合,基本覆盖了我遇到的绝大多数需求。你在自己的项目里也可以按这个思路做个“描边工具箱”,把几个方案都实现一遍,按需求切换。
这个领域还有个挺有意思的扩展方向:动态轮廓线。给描边粗细不断的脉冲值做呼吸效果、给命中反馈做描边闪烁、用描边颜色区分敌我或道具状态,这些都是在基础描边上做变体。核心还是先把基础方案吃透,后面加花样才有根基。