骨骼动画原理
动画相关理论详解
一 、骨架
骨架由一系列具有层次关系的关节(骨骼)和关节链组成,是一种树结构,选择其中一个是根关节,其它关节是根关节的子孙,可以通过平移和旋转根关节移动并确定整个骨架在世界空间中的位置和方向。父关节运动能影响子关节运动,但子关节运动对父关节不产生影响,因此,平移或旋转父关节时,也会同时平移或旋转其所有子关节。
二、骨骼的表示
通常会将关节进行编号\(0\sim N-1\),编号也称关节索引,通过索引查找关节比关节名查找高效的多。通常一个关节包含以下信息
- 关节名
- 父关节索引(根关节的父关节索引为-1,为无效索引)
- 关节绑定姿势的逆变换矩阵(offset矩阵)
所谓绑定姿势,是指蒙皮网格顶点绑定至骨骼时,关节的位置、朝向、及缩放,通常会存储关节变换的逆矩阵。
struct Joint
{
Matrix4x4 m_inverseBindPose;
const char* m_name;
U8 m_iParent;
};
struct Skeleton
{
U32 m_jointCount; // 关节数
Joint* m_aJoint; // 关节数组
};
三、姿势
把关节旋转、平移、缩放,就能为骨架摆出各种姿势,关节的姿势被定义为关节相对于某坐标系的位置、朝向、缩放。通常骨架存在绑定姿势、局部姿势、全局姿势。
3.1 绑定姿势
绑定姿势是网格绑定到骨骼之前的姿势,也就是讲网格当作正常、没有蒙皮、完全不涉及骨骼三角形网格来渲染的姿势,通常是设计师绑定模型时预设的。如下面的左图就是一个绑定姿势,右图是任意一个姿势。
3.2 局部姿势
局部姿势是关节相对于父关节来指定的,是一种常见的姿势。局部姿势存储为\(TQS\)的格式,表示相对与父关节的位置、朝向、缩放,根关节的父节点可以认为是世界坐标系原点。
关节在三维软件里通常是显示成一个点或者一个球,实际上,每个关节定义了一个坐标空间。在数学上,关节姿势就是一个仿射变换,用\(P_j\)表示关节\(j\)代表的仿射变换,它是一个\(4\times 4\)的矩阵,它由平移向量\(T_j\),旋转矩阵\(R_j\)以及对角缩放矩阵\(S_j\)组成
局部关节姿势可以表示为
struct JointPose
{
Quaternion m_rot; // 相对父关节朝向
Vector3 m_trans; // 在父关节中的坐标
Vector3 m_scale; // 相对父关节的缩放
};
局部关节姿势矩阵\(P_j\)作用到以关节\(j\)坐标系表示的点或者向量时,其结果是将点或者向量变换到父关节坐标空间表示的点。
3.3 全局姿势
全局姿势是相对于局部姿势而言的,它是关节相对于模型空间或者世界空间的姿势(局部姿势是相对于父关节的姿势)。试想一下,我们如何将关节\(j\)坐标空间的点\(q\)表示成全局坐标(世界坐标)呢?通过前面的介绍,我们知道\(P_jq\)将得到\(q\)在\(j\)父节点空间中的坐标,如此,可以通过从该关节开始一直遍历到根关节,即可将\(q\)变换到世界坐标系中表示的坐标形式。
如上图,左边是人体骨架名称,右边是对应的标号,root的编号为1,现在假我们将关节rfemur空间的点\(q\)变换到世界坐标系中,rfemur关节编号是8,我们用\(P_8\)表示它的局部姿势矩阵,以此类推,那么\(q\)在世界坐标系中的坐标可以表示为
对应的变换矩阵是\(P_1P_7P_8\),该变换矩阵便是关节8的全局姿势矩阵,也就是前面说的那样,它直接将关节8坐标系下的点变换到世界坐标系中。我们知道,变换矩阵的逆表示逆变换,全局姿势矩阵的逆矩阵可以将世界空间的点变换到关节空间中,如上面的例子
我们称\((P_1P_7P_8)^{-1}\)矩阵为Offset矩阵,这个矩阵很关键,有必要强调它的含义:Offset矩阵将全局空间(世界空间)点变换到关节的局部空间中,是全局姿势矩阵的逆矩阵,它在模型绑定后一直保持不变。另外,还有一个比较重要的矩阵是GlobalTransform矩阵(也叫Combine矩阵),这个矩阵与Offset做的事情恰好相反,它是将关节空间的点变换到全局空间,那它不就是全局姿势矩阵嘛?当然不是,前面说过,这些叫xx姿势矩阵的是针对绑定姿势而言的,绑定的姿势是唯一的,当动画播放时,模型会存在一个实时姿势,这个GlobalTransform矩阵正是将实时姿势下的关节空间点变换到世界空间中。接下来还有一个矩阵就是骨架的最终变换矩阵FinalTransform矩阵(也称蒙皮矩阵,后面会解释),这个矩阵表示了骨架最终的变换,他们的关系是
上面\(F,G,O\)分别是FinalTransform矩阵、GlobalTransform矩阵、Offset矩阵的简写。上面说的\(F\)与\(G\)矩阵的作用恰好相反,那是不是\(F\)就成了单位矩阵了呢?其实不是的,再次强调,O矩阵是固定的,但是\(G\)矩阵是实时姿势矩阵,因为当骨架运动时关节姿势变了\(G\)就会变了。既然这样,那么\(G\)矩阵如何计算呢?用\(P_j'\)表示动画播放时关节\(j\)的实时姿势,还是用上面的例子
这样关节8的蒙皮矩阵就是
四、蒙皮原理
所谓蒙皮就是计算骨架在某一状态下网格顶点的位置。每个顶点可以绑定到一个至多个骨骼(unity里面是最多4个),动画播放时,顶点随着关节运动,顶点的最终变换就等于它所绑定的骨架变换的加权和。对于每个顶点,我们需要有以下信息
- 该顶点绑定到的骨骼索引
- 该顶点的每个绑定骨骼的权重,他表示了骨架对顶点的影响力。
加权平均的计算时,顶点绑定的所有骨架的权重和为1。通常设每个顶点最多绑定到4个骨架,程序存储如下
struct SkinnedVertex
{
float m_position[3]; // 顶点位置(x,y,z)
float m_normal[3]; // 顶点法向量 (Nx,Ny,Nz)
float m_u, m_v; // 纹理坐标
U8 m_jointIndex[4]; // 关节的索引
float m_jointWeight[4]; // 关节权重
};
上面提到过蒙皮矩阵,但是还没正式定义,蒙皮矩阵就是把顶点从绑定姿势变换到骨骼的当前姿势的矩阵。蒙皮矩阵和前面的基变更矩阵不同,它只是把顶点变换到新的位置,顶点变换前后都在世界空间中(或模型空间中)。和上面一样用\(F_j\)表示关节\(j\)的蒙皮矩阵,假设某个顶点\(v\)受到上面关节14、18、15、25的影响,对应点权重分别为\(w_1,w_2,w_3,w_4\),则顶点\(v\)的最终变换位置为
上面公式就是顶点的蒙皮计算方法。实际上,在内存中,一般存储关节的两个矩阵,一个是局部姿势矩阵\(P_j\)(也可以叫节点矩阵,Node矩阵),一个是全局姿势的逆矩阵Offset矩阵,通过前面的知识我们知道,其实Offset矩阵可以通过局部姿势矩阵计算得到,为什么我们还要存一个呢?因为Offset矩阵用的非常频繁,每次使用都去计算一次没有必要,世界开销略大。
蒙皮矩阵的计算过程可以理解为先将点通过Offset矩阵变换到关节空间,再通过GlobalTransform矩阵变换到全局空间。这里需要提一点,unity和C++中存储的模型顶点坐标是一开始就确定的,动画过程中不会变,也就是每一帧不会去更新顶点坐标,只是会通过蒙皮计算得到顶点最终位置,然后渲染到屏幕上。
五、动画混合
动画混合指的是让一个以上的动画片段对角色最终姿势起作用的技术,也即是将两个或者多个输入姿势结合,产生骨骼的输出姿势。混合技术可以自动产生大量新动画,而无须设计师手工绘制。例如现在有一个闭嘴动画和张大嘴动画,我们可以在这两个动画间进行一系列插值,这样就可以得到从闭嘴到张大嘴的一系列动画。线性插值是
常用的动画混合技术,设某关节的两个姿势是\(P_a\)和\(P_b\),线性插值计算公式为
其中\(\beta\)是混合因子,它的值介于\(0\sim1\)。对关节姿势进行线性插值即为对\(4 \times 4\)变换矩阵插值,然而直接对矩阵插值是不行的,我们需要分别对\(TRS\)进行插值。
对\(T\)进行插值很简单,直接使用普通的线性插值(\(T\)是平移向量)
旋转部分需要使用四元数插值(球面线性插值),假设我们需要在两个单位四元数之间插值,从\(q_a\)到\(q_b\), 如下图
根据四元数的运算性质,我们的插值公式为
我们的目的是求出\(A(\theta)\)和\(B(\theta)\),在(1)两边点乘\(q_b\),得
化简后即
同样的,两边点乘\(q_a\)化简得
综合\((3),(4)\)得
代入\(A(\theta)、B(\theta)\)从而得到四元数插值公式。