|
前言:
继上一节梳理了骨骼蒙皮动画的基本原理,这一节我们使用代码手动实现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(&#34;解析矩阵&#34;))
{
var dir = EditorUtility.SaveFolderPanel(&#34;导出动画数据&#34;, &#34;&#34;, &#34;&#34;);
if (!string.IsNullOrEmpty(dir))
{
dir = dir.Replace(&#34;\\&#34;, &#34;/&#34;);
if (!dir.StartsWith(Application.dataPath))
{
Debug.LogError(&#34;请选择以【Assets/...】开头的文件夹路径&#34;);
return;
}
dir = dir.Replace(Application.dataPath, &#34;Assets&#34;);
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(&#34;Playable 必须在 runtime下进行采样,请先Play后再采样&#34;);
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 + &#34;/&#34; + $&#34;AnimationExtract{animationClip.name}&#34; + &#34;.asset&#34;;
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=&#34;frameCount&#34;></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
|
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|