找回密码
 立即注册
查看: 368|回复: 0

[笔记] Unity3d GPUSkinning 手写SkinnedMeshRendener(2)

[复制链接]
发表于 2022-7-4 14:40 | 显示全部楼层 |阅读模式
前言:

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

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

public class AnimationData : ScriptableObject
{
    [System.Serializable]
    public class FrameData
    {
        public float time;
        public Matrix4x4[] matrix4X4s;
    }
    [SerializeField]
    public string animName;
    [SerializeField]
    public float animLen;
    [SerializeField]
    public int frame;
    [SerializeField]
    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
{
    [MenuItem("MyWindows/MatrixExtractor")]
    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[frameCount];

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

            //四个矩阵采样完毕后,把顶点局部坐标(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[2]



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

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-5-3 21:32 , Processed in 0.422026 second(s), 27 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表