URP 渲染实战 / 第 14 章 卡通渲染 / 案例 14.6
案例 14.6平滑法线烘焙工具进阶
目标:解决硬边模型的描边断裂问题——写一个编辑器工具,把平滑后的法线烘焙进切线或顶点色;并在拍摄脚本里对程序化网格调用同一个烘焙函数,对比效果。
为什么硬边会断
立方体的一个角在网格里其实是三个独立的顶点:位置相同,但各自带着三个面的法线(否则每个面就不会是平的)。描边 Pass 把它们分别沿三个不同的方向推出去,原本连在一起的角就裂开了,露出一道缝,线条在棱角处断开。凡是有硬边(光滑组断开、UV 接缝处法线不同)的模型都会有这个问题。
解决办法是给描边单独准备一套“平滑法线”:对每个空间位置,把所有共享这个位置的顶点所属面的法线加起来求平均。着色仍然用原来的硬法线,描边用平滑法线。平滑法线需要存在某个顶点属性里,常见的选择有两个:
| 存放位置 | 存什么 | 优点 | 缺点 |
|---|---|---|---|
| 切线 tangent.xyz | 物体空间的平滑法线 | 最简单,Shader 里直接用 | 切线被占用,法线贴图、各向异性都不能用了;蒙皮时 Unity 会按切线变换它,虽然方向仍正确,但语义被混用 |
| 顶点色 color.rgb | 切线空间的平滑法线(×0.5+0.5) | 不占切线;存在切线空间里,骨骼动画、形变后依然正确 | 顶点色被占用;精度只有 8 位(一般够用);Shader 里要多一次 TBN 变换 |
也有项目存在 UV2~UV7 里,原理相同。下面的工具同时支持前两种:
Assets/Cookbook/Ch14/Editor/SmoothNormalBaker.cs(节选:bake)
public static void Bake(Mesh mesh, Target target) { var verts = mesh.vertices; var tris = mesh.triangles; var normals = mesh.normals; var tangents = mesh.tangents; if (tangents == null || tangents.Length != verts.Length) { mesh.RecalculateTangents(); tangents = mesh.tangents; } // 1) 按位置分组:硬边处同一个位置有好几个顶点(各自带不同法线) var groups = new Dictionary<Vector3Int, Vector3>(); Vector3Int Key(Vector3 p) => new Vector3Int(Mathf.RoundToInt(p.x * 1e4f), Mathf.RoundToInt(p.y * 1e4f), Mathf.RoundToInt(p.z * 1e4f)); // 2) 累加每个三角形的面法线,用“顶点处的夹角”加权(比按面积加权更不受三角剖分影响) for (int t = 0; t < tris.Length; t += 3) { Vector3 p0 = verts[tris[t]], p1 = verts[tris[t + 1]], p2 = verts[tris[t + 2]]; Vector3 fn = Vector3.Cross(p1 - p0, p2 - p0).normalized; for (int k = 0; k < 3; k++) { Vector3 a = verts[tris[t + k]], b = verts[tris[t + (k + 1) % 3]], c = verts[tris[t + (k + 2) % 3]]; float angle = Vector3.Angle(b - a, c - a) * Mathf.Deg2Rad; var key = Key(a); groups.TryGetValue(key, out var sum); groups[key] = sum + fn * angle; } } // 3) 写回 var outTangents = new Vector4[verts.Length]; var outColors = new Color[verts.Length]; var oldColors = mesh.colors; for (int i = 0; i < verts.Length; i++) { Vector3 smooth = groups[Key(verts[i])].normalized; if (target == Target.Tangent) { outTangents[i] = new Vector4(smooth.x, smooth.y, smooth.z, 1); } else { // 物体空间 → 该顶点自己的切线空间(T, B, N) Vector3 n = normals[i]; Vector3 tg = tangents[i]; Vector3 bt = Vector3.Cross(n, tg) * tangents[i].w; var ts = new Vector3(Vector3.Dot(smooth, tg), Vector3.Dot(smooth, bt), Vector3.Dot(smooth, n)); float alpha = oldColors != null && oldColors.Length == verts.Length ? oldColors[i].a : 1f; // A 通道留给线宽遮罩 outColors[i] = new Color(ts.x * 0.5f + 0.5f, ts.y * 0.5f + 0.5f, ts.z * 0.5f + 0.5f, alpha); } } if (target == Target.Tangent) mesh.tangents = outTangents; else mesh.colors = outColors; }
几个实现上的考虑:
- 按位置分组时,坐标先乘 10⁴ 再取整作为字典的键,容忍浮点误差;如果模型的“同一位置”误差更大(比如导出时精度损失),可以把系数调小。
- 按夹角加权平均面法线,而不是简单相加或按面积加权:一个四边形面被三角化成两个三角形时,按夹角加权不会让这个面的法线被算两次,结果与三角剖分方式无关。
- 存进顶点色时,把物体空间的平滑法线投影到该顶点自己的 TBN 上。注意同一个位置的几个顶点切线空间各不相同,存进去的颜色也不同,但在 Shader 里各自变换回物体空间后会得到同一个方向。A 通道保留原来的值,留给线宽遮罩。
Assets/Cookbook/Ch14/Editor/SmoothNormalBaker.cs(节选:menu)
[MenuItem("Cookbook/Ch14/Bake Smooth Normals (Tangent)")] static void BakeToTangent() => BakeSelection(Target.Tangent); [MenuItem("Cookbook/Ch14/Bake Smooth Normals (Vertex Color)")] static void BakeToColor() => BakeSelection(Target.VertexColor); static void BakeSelection(Target target) { foreach (var obj in Selection.objects) { string path = AssetDatabase.GetAssetPath(obj); var meshes = new List<Mesh>(); if (obj is Mesh m) meshes.Add(m); else foreach (var a in AssetDatabase.LoadAllAssetsAtPath(path)) if (a is Mesh mm) meshes.Add(mm); foreach (var src in meshes) { var copy = Object.Instantiate(src); // 不改原始模型,另存一份 copy.name = src.name + "_SmoothNormals"; Bake(copy, target); string dir = System.IO.Path.GetDirectoryName(path); string outPath = AssetDatabase.GenerateUniqueAssetPath($"{dir}/{copy.name}.asset"); AssetDatabase.CreateAsset(copy, outPath); Debug.Log($"[Ch14] 平滑法线已烘焙到 {target}:{outPath}"); } } AssetDatabase.SaveAssets(); } [MenuItem("Cookbook/Ch14/Bake Smooth Normals (Tangent)", true)] [MenuItem("Cookbook/Ch14/Bake Smooth Normals (Vertex Color)", true)] static bool Validate() => Selection.objects.Length > 0;
菜单在 Project 窗口选中模型后使用:它会复制一份网格、烘焙后另存为 xxx_SmoothNormals.asset,不修改原始模型。正式项目里更好的做法是写一个 AssetPostprocessor,在模型导入时自动烘焙(OnPostprocessModel 里调用同一个 Bake),美术重新导出模型也不会丢。
拍摄脚本对程序化生成的硬边网格(复制的内置立方体、六棱柱、低多边形宝石)直接调用同一个 SmoothNormalBaker.Bake:

常见坑① 模型导入设置里 Normals 如果选了 Calculate 并设了平滑角度,导入时 Unity 会重新拆分顶点,烘焙要在导入之后做。② 选“存进切线”时,导入设置的 Tangents 不能是 Calculate(否则每次重新导入切线都会被覆盖),要么用另存的 .asset,要么在后处理器里写入。③ 平滑法线只解决“断开”,解决不了“极薄的部位描边穿插到另一侧”的问题(例如很薄的裙摆、刘海),那需要配合顶点色线宽遮罩,或者把描边顶点沿观察方向往后推一点(Z Offset)。