redhat9i 发表于 2022-7-30 17:41

Unity 学习笔记:Smooth Shade 平均法线与加权法线

模型的表面有两种常见的着色形态,平直着色(硬边),光滑着色(软边),二者因法线的不同而产生,在 常见的 DCC 软件(如: Maya / 3DsMax / Blender)里处理模型法线的平直与光滑是一件稀松平常的事,但是在游戏引擎该怎么实现呢?
先导入一个平直着色的倒角 Cube 到 Unity 里,可以看出模型是棱角分明的;


写一个简单的脚本画 Gizmos 来观察一下 Cube 的法线:
using System;
using System.Collections.Generic;
using UnityEngine;


public class DrawNormalGizmos : MonoBehaviour
{
    public float lineLength = 0.1f;
    private float _lineLengthCache = 0.1f;
    private Mesh _mesh;
    private Vector3[] _normalCache;

    struct NormalLine
    {
      public Vector3 posFrom;
      public Vector3 posTo;
    }

    private List<NormalLine> _normalLines;

    void CalculateNormalLine()
    {
      _normalLines.Clear();
      if (_mesh != null)
      {
            for (int i = 0; i < _mesh.normals.Length; i++)
            {
                var normalLine = new NormalLine();
                var mat = transform.localToWorldMatrix;
                normalLine.posFrom = mat.MultiplyPoint(_mesh.vertices);
                normalLine.posTo = mat.MultiplyPoint(_mesh.vertices + (_mesh.normals.normalized * lineLength));
                _normalLines.Add(normalLine);
            }
      }
      _lineLengthCache = lineLength;
      _normalCache = _mesh.normals;
    }
    void OnEnable()
    {
      _normalLines = new List<NormalLine>();
      if(TryGetComponent<MeshFilter>(out MeshFilter filter))
                _mesh = filter.sharedMesh;
      else
            _mesh = GetComponent<SkinnedMeshRenderer>().sharedMesh;

      CalculateNormalLine();
    }

    private void Update()
    {
      if (Math.Abs(lineLength - _lineLengthCache) > 0 || _normalCache != _mesh.normals)
      {
            CalculateNormalLine();
      }
    }

    private void OnDisable()
    {
      _mesh = null;
      _normalLines = null;
    }

    private void OnDrawGizmos()
    {
      Gizmos.color = Color.magenta;
      if (_mesh != null)
      {
            foreach (var normalLine in _normalLines)
            {
                Gizmos.DrawLine(normalLine.posFrom, normalLine.posTo);
            }
      }
    }
}


可以观察出,每一个顶点坐标的位置上,都有三种不同的法线朝向,如果想要平滑着色,就必须每个顶点坐标的位置上点表现出同样的法线朝向。最简单的就是平均法线了,把每个坐标上的不同法线收集起来算平均值就行了。
那么首先是写一个用 vertex position 作为 key 来查找对应坐标上的全部 normal 的字典 ,通过遍历 Mesh 的 Triangles 得到每个三角上的三个顶点序列,因为模型的顶点是逆时针方向排列为法线正向,所以我们可以用每个三角的第一个点到第二个点的向量和第一个点到第三个点的向量求叉积,来算出每个平直三角面上的法线;
private static Dictionary<Vector3, List<Vector3>> CreateSurfaceNormalDictionary(Mesh mesh)
{
    Dictionary<Vector3, List<Vector3>> surfaceNormalDictionary = new Dictionary<Vector3, List<Vector3>>();
   
    for (int i = 0; i < mesh.triangles.Length - 3 ; i+=3)
    {
      
      Vector3 a = mesh.vertices] - mesh.vertices];
      Vector3 b = mesh.vertices] - mesh.vertices];
      Vector3 normal = Vector3.Cross(a, b);

      for (int j = 0; j < 3; j++)
      {
            int tri = mesh.triangles;
            if (!surfaceNormalDictionary.ContainsKey(mesh.vertices))
            {
                List<Vector3> noramls = new List<Vector3>();
                surfaceNormalDictionary.Add(mesh.vertices, noramls);
            }

            bool containsNormal = false;

            for (int k = 0; k < surfaceNormalDictionary].Count; k++)
            {
                if (surfaceNormalDictionary].normalized.Equals(normal.normalized))
                {
                  surfaceNormalDictionary] += normal;
                  containsNormal = true;
                  break;
                }
            }

            if (!containsNormal)
            {
                surfaceNormalDictionary].Add(normal);
            }
      }
    }
   
   
平均法线

有了字典,之后便是遍历 Mesh 的 Vertex 计算法线归一化均值。
private static Vector3[] CalculateAverageNoraml(Dictionary<Vector3, List<Vector3>> surfaceNormalDictionary, Mesh mesh)
{
    List<Vector3> averageNoramls = new List<Vector3>();
    for (int i = 0; i < mesh.vertices.Length; i++)
    {
      List<Vector3> normals = surfaceNormalDictionary];

      Vector3 n = Vector3.zero;
      float ws = 0;
      foreach (var normal in normals)
      {
            n += normal.normalized;
      }
      
      averageNoramls.Add(n.normalized);
    }
   
    return averageNoramls.ToArray();
}
于是得到的 Cube 如下:


加权法线

虽然平均法线使得模型表面变得光滑了,但是过于平均使得平面变得像弧面,这也是不希望看到的,所以,这就需要加权法线了,不知到大家有没有注意到,前面我在收集三角面的法线时,是用三角形两条的边的向量叉积计算的法线而非直接遍历 Mesh 的 Normal 去收集的,这么做有什么区别呢?直接遍历收集 Mesh 的 Normal 得到的每一条的法线的长度大几率是一致的。而叉积得到的法线的长度等于叉积的两条向量围成的平面面积,这样,三角面越大,法线的长度就越长,这样一来就可以用三角形的面积作为权重混合法线,遇到共点共向的法线意味着它们在同一个面上,长度会累加来,使得最终的混合结果趋向于面积较大的多边形平面,以此保证面积大的多边形保持法线平整。
private static Vector3[] CalculateWeightedNoraml(Dictionary<Vector3, List<Vector3>> surfaceNormalDictionary, Mesh mesh, float threshold, float weight)
{
    List<Vector3> weightedNoramls = new List<Vector3>();
    for (int i = 0; i < mesh.vertices.Length; i++)
    {
      List<Vector3> normals = surfaceNormalDictionary];

      Vector3 ns = Vector3.zero;
      float ws = 0;
      foreach (var normal in normals)
      {
            ns += normal;
            ws += Mathf.Sqrt(normal.sqrMagnitude);
      }

      for (int j = 0; j < normals.Count; j++)
      {
            float l0 = Mathf.Sqrt(normals.sqrMagnitude);
            float l1 = Mathf.Sqrt((ns - normals).sqrMagnitude);
            float r = (l0 - l1) / ws;
            if (r > threshold || 1 + r > 1 - threshold)
            {
                ns = Vector3.Lerp((ns - normals), normals, weight);
            }
      }
      weightedNoramls.Add(ns.normalized);
    }
   
    return weightedNoramls.ToArray();
}
计算出的法线效果如下:


最后,为什么要在引擎里计算模型法线?
当下流行的卡通描边 ,以及用曲面细分光滑模型表面时,都会需要法线的光滑着色,往往我们希望模型的法线的法线维持原状,用于特殊效果的光滑法线额外另存,而 DCC 软件通常对自定义的 Attributes 支持的不太好(例如 Blender uv 只支持双通道,color 不支持负数,且精度差等),相比于 Unity 不那么自由,所以在 Unity 里计算需要的 Attributes 更为稳妥。

kirin77 发表于 2022-7-30 17:47

这个怎么用啊 能不能合起来?大佬
页: [1]
查看完整版本: Unity 学习笔记:Smooth Shade 平均法线与加权法线