BlaXuan 发表于 2022-7-4 14:40

Unity3d GPUSkinning 手写SkinnedMeshRendener(2)

前言:

继上一节梳理了骨骼蒙皮动画的基本原理,这一节我们使用代码手动实现Unity组件【SkinnedMeshRendener】
以实现CPUSkinning。
CPU Skinning

根据上文所述,首先我们得把一个动画Clip的动画数据应用到骨骼后(一般这种操作叫做采样),把骨骼的【localToWorldMatrix】矩阵给记录下来。
在我翻阅参考资料时,许多文章采用的方式都是Legacy动画,使用Animation组件进行播放采样。
这种方式过于过时且兼容性差,并且在本例中,使用的模型动画是独立的,无法设置为Legacy类型,故此方法Pass。
而如果使用Unity的Mecanim动画系统,必须绑定Animator且设置动画状态机,采样时要代码访问Animator的各种信息,非常麻烦,故也Pass掉。
最后,笔者决定使用【Unity Playables API】来实现动画采样。
(顺便说一句,Playable目测是unity 第三代动画解决方案,学了不亏)
1. 创建动画数据ScriptableObject。

public class AnimationData : ScriptableObject
{
   
    public class FrameData
    {
      public float time;
      public Matrix4x4[] matrix4X4s;
    }
   
    public string animName;
   
    public float animLen;
   
    public int frame;
   
    public FrameData[] frameDatas;
}
主要用来记录每帧下的骨骼矩阵信息,同时记录一些动画帧率等额外信息,简单易懂。
2. 创建采样工具窗口

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using UnityEngine.Playables;
using UnityEngine.Animations;

public class MatrixExtractor : EditorWindow
{
   
    public static void OpenWindow()
    {
      EditorWindow.GetWindow<MatrixExtractor>().Show();
    }

    private Animator m_animator;
    private AnimationClip animationClip;
    private GameObject m_obj;
    private Transform[] m_bones;
    private bool doExtract;
    private string m_dir;
    private List<AnimationData.FrameData> m_frameList = new List<AnimationData.FrameData>();
    private PlayableGraph m_graph;
    private AnimationClipPlayable clipPlayable;
    private float m_sampleTime;
    private int m_frameCount;
    private float m_perFrameTime;
    private int frameCounter = 0;

    private void OnEnable()
    {
      m_graph = PlayableGraph.Create();
      EditorApplication.update += Extract;
    }
    private void OnDisable()
    {
      m_graph.Destroy();
      EditorApplication.update -= Extract;
    }
    private void OnGUI()
    {
      m_animator = EditorGUILayout.ObjectField(m_animator, typeof(Animator), true) as Animator;
      animationClip = EditorGUILayout.ObjectField(animationClip, typeof(AnimationClip), false) as AnimationClip;
      if (GUILayout.Button("解析矩阵"))
      {
            var dir = EditorUtility.SaveFolderPanel("导出动画数据", "", "");
            if (!string.IsNullOrEmpty(dir))
            {
                dir = dir.Replace("\\", "/");
                if (!dir.StartsWith(Application.dataPath))
                {
                  Debug.LogError("请选择以【Assets/...】开头的文件夹路径");
                  return;
                }
                dir = dir.Replace(Application.dataPath, "Assets");
                ExportAnim(dir);
            }
      }
    }
    private AnimationData.FrameData GetFrameData()
    {
      AnimationData.FrameData frameData = new AnimationData.FrameData();
      frameData.time = m_sampleTime;
      List<Matrix4x4> matrix4X4s = new List<Matrix4x4>();
      foreach (var bone in m_bones)
      {
            matrix4X4s.Add(bone.localToWorldMatrix);
      }
      frameData.matrix4X4s = matrix4X4s.ToArray();
      return frameData;
    }
    private void Extract()
    {
         if (doExtract)
      {
            if (Application.isPlaying)
            {
                if (frameCounter < m_frameCount)
                {
                  clipPlayable.SetTime(m_sampleTime);
                  var data = GetFrameData();
                  m_frameList.Add(data);
                  m_sampleTime += m_perFrameTime;
                  frameCounter++;
                }
                else
                {
                  SaveAssets();
                  doExtract = false;
                }
            }
            else
            {
                Debug.LogError("Playable 必须在 runtime下进行采样,请先Play后再采样");
                doExtract = false;
            }
      }
    }
    private void SaveAssets()
    {
      AnimationData animData = ScriptableObject.CreateInstance<AnimationData>();
      animData.frameDatas = m_frameList.ToArray();
      animData.name = animationClip.name;
      animData.animLen = animationClip.length;
      animData.frame = m_frameCount;
      string path = m_dir + "/" + $"AnimationExtract{animationClip.name}" + ".asset";
      AssetDatabase.CreateAsset(animData, path);
      AssetDatabase.Refresh();
    }
    private void ExportAnim(string dir)
    {
      if (m_animator != null)
      {
            m_obj = m_animator.gameObject;
      }
      m_obj.transform.position = Vector3.zero;
      m_obj.transform.rotation = Quaternion.identity;
      m_obj.transform.localScale = Vector3.one;
      m_bones = m_obj.GetComponentInChildren<SkinnedMeshRenderer>().bones;
      doExtract = true;
      m_dir = dir;
      m_frameCount = (int)(animationClip.frameRate * animationClip.length);
      m_perFrameTime = animationClip.length / m_frameCount; ;
      m_frameList.Clear();

      SetPlayableGraph();
    }
    private void SetPlayableGraph()
    {
      clipPlayable = AnimationClipPlayable.Create(m_graph, animationClip);
      AnimationPlayableUtilities.Play(m_animator, clipPlayable, m_graph);
      clipPlayable.Pause();
    }
}
emmm,咱也没啥思路对这段代码进行讲解,反正就是创建窗口,设置基本信息与参数就是了。
(Tip:这是unity的Editor代码,必须放在Editor文件夹下,没有自己新建一个)
只不过,由于动画采样使用的时Playables API,而此接口似乎只能在Runtime运行,故取巧地进行了Editor Update注册:
EditorApplication.update += Extract
在Runtime时,每帧调用注册方法Extract,来逐帧采样。
            if (frameCounter < m_frameCount)
            {
                clipPlayable.SetTime(m_sampleTime); //设置采样时间
                var data = GetFrameData(); //读取bone 的矩阵信息
                m_frameList.Add(data); //记录读取数据
                m_sampleTime += m_perFrameTime; //采样时间增加
                frameCounter++; //帧数增加
            }
            else
            {
                SaveAssets(); //结束采样,保存采样结果
                doExtract = false; //退出采样循环
            }
最后,使用是这个样子(记得一定是在Runtime模式下):



选择场景Animator实例,选择AnimationClip资源文件

点击【解析矩阵】,选择保存路径后,会生成矩阵的解析文件:



35帧,每帧有68个矩阵,对应着模型有68个骨骼

3. 编写运行代码

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CPUSkinning : MonoBehaviour
{
    public AnimationData animData;//保存的SO文件
    private Mesh mesh; //目标mesh
    private Matrix4x4[] bindPoses; //bindPoses 信息
    private List<Vector3> sourcePoints; //原模型的顶点信息
    private List<Vector3> newPoints; //新(进行动画采样后的)顶点信息
    private int frameCount = 0; //帧数,用于控制动画播放
    void Awake()
    {
      //初始化信息
      mesh = GetComponent<MeshFilter>().mesh;
      sourcePoints = new List<Vector3>();
      mesh.GetVertices(sourcePoints);
      bindPoses = mesh.bindposes;
      newPoints = new List<Vector3>(sourcePoints);
    }
    private void Update()
    {
      //逐帧播放
      if (frameCount < animData.frame)
      {
            ApplyFrame(frameCount);
            frameCount++;
      }
      else
      {
            frameCount = 0;
      }
    }

    /// <summary>
    /// 使用帧采样信息计算顶点位置。
    /// </summary>
    /// <param name="frameCount"></param>
    void ApplyFrame(int frameCount)
    {
      //单帧数据
      AnimationData.FrameData frameData = animData.frameDatas;

      //遍历模型顶点
      for (int i = 0; i < sourcePoints.Count; ++i)
      {
            //某一个模型顶点
            Vector3 point = sourcePoints;
            //此顶点的BoneWeight信息
            BoneWeight weight = mesh.boneWeights;
            //遍历影响这个顶点的骨骼(4个),计算变换矩阵。
            //frameData.matrix4X4s记录的就是这一帧下,index对应bone的bone.localToWorldMatrix;
            Matrix4x4 tempMat0 = frameData.matrix4X4s * bindPoses;
            Matrix4x4 tempMat1 = frameData.matrix4X4s * bindPoses;
            Matrix4x4 tempMat2 = frameData.matrix4X4s * bindPoses;
            Matrix4x4 tempMat3 = frameData.matrix4X4s * bindPoses;

            //四个矩阵采样完毕后,把顶点局部坐标(point)变换到世界坐标 tempMat0.MultiplyPoint(point)
            //同时乘以权重,进行混合叠加。
            Vector3 temp = tempMat0.MultiplyPoint(point) * weight.weight0 +
                                 tempMat1.MultiplyPoint(point) * weight.weight1 +
                                 tempMat2.MultiplyPoint(point) * weight.weight2 +
                                 tempMat3.MultiplyPoint(point) * weight.weight3;
            //最后,把顶点世界坐标写入 newPoints;
            newPoints = temp;
      }
      //计算完毕后,把计算好的顶点信息应用到mesh上。
      mesh.SetVertices(newPoints);
    }
}
实现代码也很简单,在Awake初始化必要信息,在Update逐帧读取数据进行顶点计算。
而核心方法【ApplyFrame】的逻辑也是平铺直叙:

[*]获取帧数据
[*]遍历模型Mesh的初始顶点信息
[*]获取顶点Weight数据,获取对应骨骼index,计算变换矩阵
[*]根据Weight进行坐标叠加
[*]把新的坐标信息写入Mesh
4. 运行测试

我们先创建一个基本的模型,用Animator来看看原本的表现是怎样的。模型、动画来自:DogKnight



Mesh自然是SkinnedMeshRenderer,父级挂载了Animator



这是SkinnedMeshRenderer+Animator的动画效果

然后换上我们自己的动画代码!



值得注意的是组件信息,这是Mesh Renderer与我们自己的CPU Skinning

新建空物体,挂载【Mesh Renderer】【Mesh Filter】【CPU Skinning】,指定网格、材质、Anim Data。
(这里“倒”着的原因是模型网格原始坐标就是这样,不过没关系,世界坐标的矩阵对的话,动画表现就是对的)
点击运行看看:



哦厉害!动起来了!

棒极了!我们使用Mesh Renderer + CPU Skinning也能驱动模型,实现动画效果!
只不过,细心的读者可能注意到了,原版的方式帧率明显比我们高啊。(虽然GIF图本身帧率就有所下降,但是两种方式的帧率差异还是肉眼可见)
打开Profiler,看看消耗。



Cost大到上天

好家伙,我们的CPUSkinning Update每帧耗时300+ms,每帧有 360MB 的CG Alloc。
看看模型数据:


区区3430个点,68根骨骼,表现得就如此差,难怪CPU Skinning不可行,还是得上GPU Skinning啊。
想一想,为什么GPU表现比CPU好?
因为从我们的算法可以知道,mesh上的每一个点,计算坐标的流程都是独立的,只需要他自己的weight数据与骨骼矩阵数据,互不干扰。那么,这就意味着他们完全可以并行计算,这正是GPU的强项。
说个题外话,之前笔者用的Mixamo模型来实现CPU Skinning,这种有着1w+顶点的模型,可想而知,根本跑不动。无奈只有找到DogKnight这种“低模”跑测试。
当然,Skinned Mesh Renderer内部逻辑自然不会和我们的写法一模一样,它肯定是有自己的优化方案与高效算法,我们只是遵从基本原理,手动实现了一次骨骼动画,便于理解罢了。
小结:

这一节我们亲手实现了CPU Skinning,也明白了动画的本质就是顶点坐标的更新,更理解了CPU Skinning那令人咋舌的性能消耗而使用GPU驱动的必要性,下一节,我们终于能正式进入GPU Skinning啦。
参考


[*]^Legacy参考实现https://zhuanlan.zhihu.com/p/87583171
[*]^DogKnighthttps://assetstore.unity.com/packages/3d/characters/animals/dog-knight-pbr-polyart-135227
页: [1]
查看完整版本: Unity3d GPUSkinning 手写SkinnedMeshRendener(2)