5-动画的混合
在看完GAMES104动画系统那一部分后,我也想写一个简单的动画系统,又要开一个坑了!本文将简要介绍如何实现动画混合,包括:
- 简单线性混合。
- CrossFading。
- 部分骨骼混合
- 附加混合。
动画混合综述
简单线性混合
这是动画混合的最简单,也是最基本的形式,给定一个骨架和两个绑定姿势A、B,就能用简单线性混合得到与混合因子\(\beta\)相关的绑定中间姿势。如果因子为0,混合的姿势就是A;如果因子为1,混合的姿势就是B。 \[ \nonumber \begin{aligned} \left(\mathbf{P}_{\mathrm{LERP}}\right)_j& =\mathrm{LERP}\left((\mathbf{P}_A)_j,(\mathbf{P}_B)_j,\beta\right) \\ &=(1-\beta)(\mathbf{P}_A)_j+\beta(\mathbf{P}_B)_j. \end{aligned} \]
CrossFading
在两段动画序列进⾏衔接时,偶尔会遇到突变情况,这给⼈视觉上有不适感。对于两段动画的变换曲线,我们希望它看上去是没有跳变的,即C0连续:
此外,这些变换的⼀阶导曲线也要看上去连续,即C1连续。如果对动画衔接的流畅性还有更⾼的要求,则需要曲线满⾜C2,甚⾄更高等的连续。C1连续在计算机上是比较难实现的,但通过线性混合可以让曲线达到可接受的C0连续,也能近似达到C1连续。
这种在两动画序列间的线性混合⽅法也被称为 CrossFading 。为了在两动画间进行 CrossFading,需要将两动画的时间线进⾏合理重叠,然后线性混合这两个动画。混合因子\(\beta\)从\(t_{start}\)开始为0,意思是当CrossFading开始时只看⻅动画A。接着逐渐增加\(\beta\)直到到达时间\(t_{end}\) ,此时只有动画B能看⻅,可以丢掉动画A了。进⾏CrossFading的时间也被称为混合时间,\(\Delta t_{blend}=t_{end}-t_{start}\)。
CrossFading 主要有两种:
平滑过渡:Smooth Transition,在CrossFading时动画A和B均播放。要求两个动画必须是循环动画且时间线对⻬。
冻结过渡:Frozen Transition,在动画B开始播放时停止播放动画A。
除了上⾯的正相关直线,还能⾃定义CrossFading的混合曲线。有关混合因⼦\(\beta\)的曲线公式如下,其中\(\beta_{start}\)是\(t_{start}\)时的混合因⼦,\(\beta_{end}\)是\(t_{end}\)时的混合因⼦,\(u\)是在\(t_{start}\)和\(t_{end}\)间的归⼀化后的时间,\(v\)则是它的逆向。\(T_{start}\)和\(T_{end}\)则分别与\(\beta_{start}\)和\(\beta_{end}\)相同: \[ \nonumber \begin{aligned} \mathrm{let}~u& =\left(\frac{t-t_{\mathrm{start}}}{t_{\mathrm{end}}-t_{\mathrm{start}}}\right) \\ \text{and v}& =1-u \\ \beta(t)& =(v^3)\beta_{\mathrm{start}}+(3v^2u)T_{\mathrm{start}}+(3vu^2)T_{\mathrm{end}}+(u^3)\beta_{\mathrm{end}} \\ &=(v^3+3v^2u)\beta_\text{start }+(3vu^2+u^3)\beta_\text{end}. \end{aligned} \] 该公式的曲线如下图:
部分骨骼混合
⼈体可以⾃由控制身体各部分的移动,因此动画也该如此,可以给不同位置的骨骼播放不同动画。实现这种效果的⼀个⽅法被称为部分骨骼混合(Partial-skeleton blending)。
在之前的简单线性混合中,同样的混合因⼦\(\beta\)被⽤于骨骼中的每个关节。部分骨骼混合则允许不同的混合因⼦作⽤于骨骼中的不同关节,也就是说可以给每个关节\(j\)定义⼀个单独的混合因⼦\(\beta_j\)。这些混合因⼦的集合也被称为⼀个混合遮罩(Blend Mask),因为可以通过设定特定关节的混合因⼦为0从⽽排除这个关节。
以⼈体在站⽴,⾛路和跑步时右胳膊挥⼿为例,为了实现这种部分混合,⾸先要准备好4个全身动画:⾛路,跑步,站⽴和挥⼿。接下来创建⼀个混合遮罩,让除了右胳膊以外的地⽅混合因⼦为0,其余为1。 \[ \nonumber \beta_j=\left\{\begin{matrix}1&\text{关节 }j\text{ 属于右胳膊,}\\0&\text{否则.}\end{matrix}\right. \] 然后让⾛路/跑步/站立动画⽤这个混合遮罩与挥手动画线性混合即可,这样就能边⾛路/跑步/站⽴边挥⼿了。但这种做法也有⼀定的瑕疵存在,会让⻆⾊动作看起来不⾃然:
- 混合遮罩的做法让身体的⼀部分和整体脱离,很怪。可以修改混合遮罩的值,对该部 分和其他部分连接处关节的混合因⼦做渐变,⽽不是0和1的突变。
- 该⽅法让⼈物⾏动和挥⼿动作完全独⽴,不⾃然。需要使⽤ 附加混合(Additive Blending) 来解决。
附加混合
附加混合则为动画的组合提供了⼀种新⽅法。它引入⼀种称为 差别序列(difference clip) 的新动画,⽤于表达两个常规动画序列的差别。⼀个差别序列可以被添加到⼀个常规动画序列上,以便让⻆⾊和姿势产⽣⼀些有趣的变化。实际上,⼀个差别序列存储了从⼀个姿势到另⼀个姿势所需的变换。它也被称为附加动画序列。
有两个作为输⼊的动画序列,源序列S和参考序列R,那么它们的差别序列D = S - R。如果差别序列D和它的原参考序列R相加,就会得到源序列(S = D + R)。也能通过类似于简单线性混合的⽅式⽣成基于R和S之间的动画,这需要给D和R添加⼀个混合因⼦。此外,附加混合真正的含⾦量在于,只要⼀个差别序列被创建好,它就能被附加到其他不相干动画中,不只是R。对于这些不相干动画,统称为⽬标序列T。
例如R是⻆⾊普通跑步动画,S是⻆⾊“累”着跑步动画,那么D就只包含了⻆⾊跑步时“累”的变化。如果D被附加到⻆⾊走路动画的T上,那么最终结果就是角色“累”着走路。
数学原理
一个姿势就是一个将点和向量从子关节空间转换到父关节空间的4x4仿射矩阵,那么上述的加减法在矩阵里则通过乘法实现。
首先是\(D=S-R\),在矩阵中通过右乘一个逆矩阵实现: \[ \nonumber \mathbf{D}_j=\mathbf{S}_j\mathbf{R}_j^{-1} \] 然后是\(A=D+T\),给目标序列T附加一个差别序列D,得到一个新的姿势A: \[ \nonumber \mathbf{A}_j=\mathbf{D}_j\mathbf{T}_j=\left(\mathbf{S}_j\mathbf{R}_j^{-1}\right)\mathbf{T}_j \] 接下来是插值,差别序列的插值也可以通过简单线性混合实现,需要注意的是 源序列S 和 参考序列R 的时长必须相同,这样插值得到的差别序列D才有意义。
此外,还可为附加混合添加权重以控制混合的程度。例如一个差分序列可以让头朝右看80°,那么权重为50%就能让头朝右看40°。这可以通过线性混合来实现: \[ \nonumber \begin{aligned} \mathbf{A}_{j}& =\mathrm{LERP}\left(\mathbf{T}_{j},\mathbf{D}_{j}\mathbf{T}_{j},\beta\right) \\ &=(1-\beta)\left(\mathbf{T}_{j}\right)+\beta\left(\mathbf{D}_{j}\mathbf{T}_{j}\right) \end{aligned} \]
和部分骨骼混合对比
附加混合与部分骨骼混合相似,例如可从“站着动画”与“站着挥手动画”中获得挥手动画的差分序列,这和将一个挥手动画通过部份骨骼混合应用到其他动画上类似。区别是附加混合看起来更流畅一点,这是因为我们没有把挥手部分完全替换,而是在目标动画基础上附加。
应用
可用于角色姿态变换,例如给一个基础姿势附加不同的差别序列就能呈现出不同效果:
也能被用作瞄准和朝向,给一个朝前的角色根据混合权重附加不同朝向的差别序列:
还能重用动画时间轴,实际上动画时间轴不需要被用作表示当前播放时间,而是根据需要获取在对应时间轴上的关节姿态。例如三帧动画,第一帧朝左看,第二帧朝中间看,第三帧朝右看。想要一直朝右看就将该动画序列的时间限制在第三帧即可,如果是朝右看50%,可以通过混合插值获取第2.5帧的姿态。
代码实现
简单线性混合
这里让绑定姿态和动画序列的姿态进行线性混合。首先要拓展Bone类,让我们获取到动画序列当前的SRT变换:
glm::vec3 m_curTranslate;
glm::vec3 m_curScale;
glm::quat m_curOrientation;
接下来给Animator
类添加线性混合参数:
// 动画混合 - 简单线性混合
bool m_enableBlending = true;
float m_blendFactor = 1.0f;
然后就能和绑定姿态的SRT进行线性混合了,由于我用的是Assimp库,绑定姿态的SRT变换已经被计算为矩阵了,需要使用glm::interpolate()
进行矩阵插值或用glm::decompose()
拆分绑定姿态变换再插值。这里推荐后者,因为前者忽略了缩放变换的插值。可能有不对的结果:
// 前者
#define GLM_ENABLE_EXPERIMENTAL
#include "glm/gtx/matrix_interpolation.hpp"
nodeTransform = glm::interpolate(node->transformation, bone->GetLocalTransform(), m_blendFactor);
// 后者
#define GLM_ENABLE_EXPERIMENTAL
#include "glm/gtx/matrix_decompose.hpp"
glm::quat orientation;
glm::vec3 scale;
glm::vec3 translation;
glm::vec3 skew;
glm::vec4 perspective;
if (glm::decompose(node->transformation, scale, orientation, translation, skew, perspective))
{
glm::quat interOrientation = glm::slerp(orientation, bone->GetCurOrientation(), m_blendFactor);
glm::vec3 interTranslate = glm::mix(translation, bone->GetCurTranslate(), m_blendFactor);
glm::vec3 interScale = glm::mix(scale, bone->GetCurScale(), m_blendFactor);
nodeTransform = glm::translate(glm::mat4(1.0f), interTranslate) *
glm::toMat4(interOrientation) *
glm::scale(glm::mat4(1.0f), interScale);
}
结果如下:
CrossFading
是在两个动画过渡时的混合操作,需要两个动画序列,在第一个动画序列即将播放完成和第二个动画序列开始播放时进行。
核心实现如下:
// Animator.cpp
float curAnimDuration = m_currentAnimation->GetDuration();
float dstAnimDuration = m_pDstAnimation->GetDuration();
m_currentTime += (m_currentAnimation->GetTicksPerSecond() + m_pDstAnimation->GetTicksPerSecond()) * 0.5 * dt;
m_currentTime = fmod(m_currentTime, curAnimDuration + dstAnimDuration);
// 看看是否需要CrossFading
float startTime = curAnimDuration - 0.15 * dstAnimDuration;
float endTime = curAnimDuration + 0.15 * dstAnimDuration;
if (startTime <= m_currentTime && m_currentTime <= endTime)
{
// 计算混合因子
float u = (m_currentTime - startTime) / (endTime - startTime);
float v = 1 - u;
m_blendFactorForCrossFading = 3 * v * u * u + u * u * u;
m_blendFactorForCrossFading = std::clamp(m_blendFactorForCrossFading, 0.f, 1.f);
// CrossFading
CalculateBoneTransform(m_currentAnimation, &m_currentAnimation->GetRootNode(),
m_currentTime, glm::mat4(1.0f));
std::vector<glm::mat4> srcTransMat(m_finalBoneMatrices);
std::vector<glm::mat2x4> srcTransDq(m_boneDualQuaternions);
CalculateBoneTransform(m_pDstAnimation, &m_pDstAnimation->GetRootNode(),
fmod(m_currentTime, dstAnimDuration), glm::mat4(1.0f));
// 混合
if (m_useDualQuaternion)
{
for (size_t i = 0; i < srcTransDq.size(); ++i)
{
glm::dualquat srcDq = srcTransDq[i];
glm::dualquat dstDq = m_boneDualQuaternions[i];
glm::dualquat resDq = glm::lerp(srcDq, dstDq, m_blendFactorForCrossFading);
m_boneDualQuaternions[i] = glm::mat2x4_cast(glm::normalize(resDq));
}
}
else
{
for (size_t i = 0; i < srcTransMat.size(); ++i)
{
m_finalBoneMatrices[i] = glm::interpolate(srcTransMat[i],
m_finalBoneMatrices[i],
m_blendFactorForCrossFading);
}
}
}
else if (m_currentTime < startTime)
{
CalculateBoneTransform(m_currentAnimation, &m_currentAnimation->GetRootNode(),
m_currentTime, glm::mat4(1.0f));
}
else if (m_currentTime > endTime)
{
CalculateBoneTransform(m_pDstAnimation, &m_pDstAnimation->GetRootNode(),
fmod(m_currentTime, dstAnimDuration),
glm::mat4(1.0f));
}
如果当前播放时间位于CrossFading时间段,就进行CrossFading操作。在CrossFading操作中,需要用曲线公式计算出当前时间下的混合因子值,然后分别计算两个动画序列在当前时间下各自的变换姿态进行线性混合。在其他时间段则单独播放相应的动画即可。
效果如下:
部分骨骼混合
接下来尝试实现部分骨骼混合,需要我们先准备一个骨架遮罩,然后进行两个动画的线性混合。
首先需要给动画中的骨骼添加遮罩信息,它存储了当前动画中每个关节的混合遮罩,用于决定该关节是否播放当前动画:
// Animation.h
std::vector<bool> m_animMask;
还要对它进行初始化,在构造函数读取完丢失的关节后便能求得关节总数,就能初始化了:
// Animation.cpp
...
ReadMissingBones(animation, *model);
m_animMask.resize(m_boneInfoMap.size());
std::fill(m_animMask.begin(), m_animMask.end(), true);
}
然后还要实现一个SetAnimMaskHierarchy()
方法,用于快速设置当前关节和其子关节的混合遮罩数值:
// Animation.cpp
void Animation::SetAnimMaskHierarchy(const AssimpNodeData& node, bool value)
{
if (m_boneInfoMap.contains(node.name))
{
int id = m_boneInfoMap[node.name].id;
m_animMask.at(id) = value;
}
for (int i = 0; i < node.childrenCount; ++i)
{
SetAnimMaskHierarchy(node.children[i].name, value);
}
}
别忘了向外部提供一个Getter
方法,方便进行相关计算:
inline const std::vector<bool>& GetAnimMask() { return m_animMask; }
inline const bool GetAnimMaskById(int id) { return m_animMask[i]; }
接下来就能在Animator
类中应用遮罩了:
// 计算好两个动画的矩阵/对偶四元数后, 进行部份骨骼混合
if (m_useDualQuaternion)
{
for (size_t i = 0; i < srcTransDq.size(); ++i)
{
float partialFactor = m_currentAnimation->GetAnimMaskById(i) ? 1.0 : 0.0;
glm::dualquat srcDq = srcTransDq[i];
glm::dualquat dstDq = m_boneDualQuaternions[i];
glm::dualquat resDq = glm::lerp(srcDq, dstDq, partialFactor);
m_boneDualQuaternions[i] = glm::mat2x4_cast(glm::normalize(resDq));
}
}
else
{
for (size_t i = 0; i < srcTransMat.size(); ++i)
{
float partialFactor = m_currentAnimation->GetAnimMaskById(i) ? 1.0 : 0.0;
m_finalBoneMatrices[i] = glm::interpolate(srcTransMat[i],
m_finalBoneMatrices[i],
partialFactor);
}
}
接下来该测试了,这里默认让src动画
的左右腿遮罩为false
,让dst动画
的左右腿替换它,结果如下:
可以看到结果有些别扭,上半身和下半身在各做各的事,没有协调感。这也是部分骨骼混合的缺点。
Ps:用到的Aru模型的骨骼树:
-bone_root
-Bip001
-Bip001 Pelvis
-Bip001 L Thigh
-Bip001 L Calf
-Bip001 L Foot
-Bip001 L Toe0
-Bip001 R Thigh
-Bip001 R Calf
-Bip001 R Foot
-Bip001 R Toe0
-Bip001 Spine
-Bip001 Spine1
-Bip001 L Clavicle
-Bip001 L UpperArm
-Bip001 L Forearm
-Bip001 L Hand
-Bip001 L Finger0
-Bip001 L Finger01
-Bip001 L Finger1
-Bip001 L Finger11
-Bip001 L Finger2
-Bip001 L Finger21
-bone L ForeArm Twist
-Bip001 Neck
-Bip001 Head
-Bip001 bone_eye_D_L
-Bip001 bone_eye_D_L_01
-Bip001 bone_eye_D_L_02
-Bip001 bone_eye_D_R
-Bip001 bone_eye_D_R_01
-Bip001 bone_eye_D_R_02
-Bip001 eye_L
-Bip001 eye_L_1
-Bip001 eye_L_2
-Bip001 eye_R
-Bip001 eye_R_1
-Bip001 eye_R_2
-Bip001 Xtra_eyeblowL1
-Bip001 Xtra_eyeblowL2
-Bip001 Xtra_eyeblowR1
-Bip001 Xtra_eyeblowR2
-Bip001 Xtra_eyeL
-Bip001 Xtra_eyeR
-bone_hair_F_01
-bone_hair_F_02
-bone_hair_F_R_01
-bone_hair_F_R_02
-bone_hair_F_R_03
-bone_hair_L_01
-bone_hair_L_02
-bone_hair_L_03
-bone_hair_R_01
-bone_hair_R_02
-bone_hair_R_03
-Bip001 R Clavicle
-Bip001 R UpperArm
-Bip001 R Forearm
-Bip001 R Hand
-Bip001 R Finger0
-Bip001 R Finger01
-Bip001 R Finger1
-Bip001 R Finger11
-Bip001 R Finger2
-Bip001 R Finger21
-bone R ForeArm Twist
-jacket_Root
-bone_jacket_C_01
-bone_jacket_C_02
-bone_jacket_C_03
-bone_jacket_L_01
-bone_jacket_L_02
-bone_jacket_L_03
-bone_jacket_R_01
-bone_jacket_R_02
-bone_jacket_R_03
-bone_jacketArm_L_01
-bone_jacketArm_L_02
-bone_jacketArm_R_01
-bone_jacketArm_R_02
-bone_skirtB00
-bone_skirtB01
-bone_skirtB_L_00
-bone_skirtB_L_01
-bone_skirtB_R_00
-bone_skirtB_R_01
-bone_skirtF00
-bone_skirtF01
-bone_skirtF_L_00
-bone_skirtF_L_01
-bone_skirtF_R_00
-bone_skirtF_R_01
-Bip001_Weapon
-bone_buttstock
-bone_magazine
-bone_w_Handle
附加混合
最后尝试实现一下附加混合,代码写的并不是怎么好:
float curClipDuration = m_currentAnimation->GetDuration();
m_currentTime += m_currentAnimation->GetTicksPerSecond() * dt;
m_currentTime = fmod(m_currentTime, curClipDuration);
// 1. 先求差分序列
float diffClipDuration = std::min(m_pSrcClip->GetDuration(), m_pRefClip->GetDuration());
CalculateBoneTransform(m_pSrcClip, &m_pSrcClip->GetRootNode(),
fmod(m_currentTime, diffClipDuration), glm::mat4(1.0f));
std::vector<glm::mat4> srcTransMat(m_finalBoneMatrices);
std::vector<glm::mat2x4> srcTransDq(m_boneDualQuaternions);
CalculateBoneTransform(m_pRefClip, &m_pRefClip->GetRootNode(),
fmod(m_currentTime, diffClipDuration), glm::mat4(1.0f));
std::vector<glm::mat4> refTransMat(m_finalBoneMatrices);
std::vector<glm::mat2x4> refTransDq(m_boneDualQuaternions);
// 2. 再求原序列
CalculateBoneTransform(m_currentAnimation, &m_currentAnimation->GetRootNode(),
m_currentTime, glm::mat4(1.0f));
// 3. 进行叠加
if (m_useDualQuaternion)
{
for (size_t i = 0; i < m_boneDualQuaternions.size(); ++i)
{
m_boneDualQuaternions[i] = srcTransDq[i] - refTransDq[i] + m_boneDualQuaternions[i];
}
}
else
{
for (size_t i = 0; i < m_finalBoneMatrices.size(); ++i)
{
m_finalBoneMatrices[i] = (srcTransMat[i] * glm::inverse(refTransMat[i])) * m_finalBoneMatrices[i];
}
}
这里用Normal_Idle(23)
动画作为参考序列R,Normal_Reload(24)
动画作为源序列S,那么差分序列D就是装弹的动画。然后我们将装弹动画应用到某一动画上,效果如下:
可见效果比较差,这种附加动画最好是变换幅度比较小的,例如朝向、点头等。
Ps:我对动画混合的实现不满意,可能后续会重构。不满意的地方包括:
- 总时间线
m_currentTime
的计算,应该有更合理的方式去计算它;- CrossFading时间段
startTime
和endTime
的定义,直接粗暴硬编码定义了,应该有更动态灵活的方式去定义它们;- Animator类设计的也有些问题,应该把动画混合相关功能拆出一个工具类来用,而且
EnableXXXBlending
也应该在TestAnimation.h
里,而不是在Object.h
中。- 代码有bug。。。
参考资料
- 《C++ Game Animation Programming》
- 《游戏引擎架构》