GameTech/URP 渲染实战

案例 14.6平滑法线烘焙工具进阶

目标:解决硬边模型的描边断裂问题——写一个编辑器工具,把平滑后的法线烘焙进切线或顶点色;并在拍摄脚本里对程序化网格调用同一个烘焙函数,对比效果。

为什么硬边会断

立方体的一个角在网格里其实是三个独立的顶点:位置相同,但各自带着三个面的法线(否则每个面就不会是平的)。描边 Pass 把它们分别沿三个不同的方向推出去,原本连在一起的角就裂开了,露出一道缝,线条在棱角处断开。凡是有硬边(光滑组断开、UV 接缝处法线不同)的模型都会有这个问题。

原始法线:同一个角被推向两个方向,出现缺口 平均后的平滑法线:同一位置只朝一个方向推
图 14-11硬边处描边断裂的原因(俯视示意):着色要用分开的法线,描边要用平均后的法线

解决办法是给描边单独准备一套“平滑法线”:对每个空间位置,把所有共享这个位置的顶点所属面的法线加起来求平均。着色仍然用原来的硬法线,描边用平滑法线。平滑法线需要存在某个顶点属性里,常见的选择有两个:

存放位置存什么优点缺点
切线 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;
}

几个实现上的考虑:

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:

图 14-12三个硬边网格(立方体、六棱柱、低多边形宝石),线宽 5 像素。左:用原始法线外扩,所有棱角处的描边都断开了;右:用烘焙进切线的平滑法线外扩,轮廓连续
常见坑① 模型导入设置里 Normals 如果选了 Calculate 并设了平滑角度,导入时 Unity 会重新拆分顶点,烘焙要在导入之后做。② 选“存进切线”时,导入设置的 Tangents 不能是 Calculate(否则每次重新导入切线都会被覆盖),要么用另存的 .asset,要么在后处理器里写入。③ 平滑法线只解决“断开”,解决不了“极薄的部位描边穿插到另一侧”的问题(例如很薄的裙摆、刘海),那需要配合顶点色线宽遮罩,或者把描边顶点沿观察方向往后推一点(Z Offset)。