Direct X 12 – Character Animation 角色动画

23.2 蒙皮网格模型(Skinned Meshes)

23.2.1 定义

下图展示了一个角色网格模型。白色的高亮部分被称为骨骼,其提供了用于驱动角色动画系统的层级结构。骨骼由外部的蒙皮所包围,在游戏中蒙皮就是3D几何体(顶点与多边形)。最初,蒙皮的顶点位于绑定空间(bind spce),也就是整个蒙皮的本地坐标系(通常为根节点的坐标系)。而骨骼结构中的每一根骨头将会影响其蒙皮的形状与位置(也就是说骨头会影响顶点)。之后,随着我们移动骨骼,我们所绑定的蒙皮也会基于骨骼的pose(姿势)进行相应的改变。

23.2.2 重新构建骨头的To-Root转换矩阵

与23.1小节不同,我们将从根节点坐标系转换至世界坐标系归纳为一个独立的步骤。所以,我们不需要再为每一根骨头找到to-world矩阵,只要找到to-root矩阵即可(例如,该转换矩阵能够将骨头的本地坐标系直接转换为根节点的坐标系)。

此外,在本小节中,我们将改变由下自上的遍历骨头的方式(例如,我们找到某一根骨头,之后便利其所有父节点)。而由上自下的方式明显效率更高(如下图所示),也就是说,我们由根节点向下遍历树状结构。假设我们将骨头标记为0,1,…,n-1,那么以下公式就是第i根骨头的to-Root转换矩阵:

其中,p表示父节点骨头的标记位,矩阵toRoot(p)能够将骨头p直接转换至根节点的坐标系。所以,我们只需要求出将骨头i转换至骨头p所在的坐标系的矩阵即可。而我们唯一需要注意的是,当处理第i根骨头时,我们必须已经计算出了它的父物体的to-Root转换矩阵。

如果我们使用这种由上自下的计算方式,对于骨头i,我们已经得到了它的父节点的to-Root转换矩阵;之后只要进行一次乘法运算,我们就能求出骨头i的to-Root转换矩阵。如果使用自下而上的计算方式,,我们需要遍历每根骨头的每一个父节点,如果多根骨头分享一个父节点,那么会产生许多重复的矩阵乘法计算。

23.2.3 补偿转换(Offset Transform)

现在我们需要解决的问题是,骨头所影响的顶点并不处在骨头的坐标系中(现在顶点位于绑定空间,也就是网格模型所在的空间坐标系)。所以我们需要将顶点从绑定空间转换至顶点所对应的骨头的所在空间。该转换被称为补偿转换(offset transformation);如下图所示。

上图中,我们首先使用补偿转换矩阵将骨头所影响的顶点由绑定空间转换至骨头所在的空间。之后,我们再使用骨头的to-Root矩阵将顶点转换至根节点所在的空间,也就是网格模型的本地空间(这就是此刻网格模型所呈现的pose)。

现在我们将介绍一种新的转换矩阵,我们称其为final转换矩阵,其由骨头的补偿转换矩阵与ro-Root矩阵组成。第i根骨头的final转换矩阵的公式为:

23.2.4 骨骼动画

我们将会定义一个关键帧(key frame),其表示物体在某一刻的位置,朝向以及缩放大小,动画(animation)就是由时间排列的一组关键帧,其定义了动画的总体表现。之后我们将学习如何进行关键帧之间的插值以计算关键帧之间,物体的位置等信息。现在,我们会将动画系统延伸为骨骼动画。下文中的关于动画的类定义在SkinnedData.h/.cpp中。

驱动整套骨骼的运动并没有大家想象中那么困难。我们只要将单个物体想象成单个骨头即可,而骨骼只是由互相连接的骨头所组成。我们假设每一根骨头都能够独立运动。因此,为了驱动整套骨骼,我们只需要驱动每一根骨头的本地空间即可。在每一根骨头完成其本地空间内的“动画”之后,我们再来计算其父物体的移动,并将骨头转换至根节点所在的空间。

我们将animation clip定义为一组动画(每一个动画代表骨骼结构中的一根骨头),这一组动画的“协作”形成了骨骼动画。

// AnimationClip,例如,“行走”,“跑步”,“攻击”,“防御”
// AnimationClip由每一根骨头的BoneAnimation组成
struct AnimationClip
{
    // 所有骨头动画的开始时间
    float GetClipStartTime() const;

    // 所有骨头动画的结束时间
    float GetClipEndTime() const;

    // 遍历AnimationClip中每一个BoneAnimation并且进行插值
    void Interpolate(float t, std::vector<XMFLOAT4X4>& boneTransforms) const;

    // 每一根骨头的动画
    std::vector<BoneAnimation> BoneAnimations;
};

在游戏中,角色往往会拥有多个animation clip,而animation clip与骨骼相对应。在demo中,我们使用一个unordered_map的数据结构来存储所有的animation clip,并且通过它的名字对其引用:

std::unordered_map<std::string, AnimationClip> mAnimations;
AnimationClip& clip = mAnimations["attack"];

最后,正如我们之前提到的,每一根骨头都需要一个补偿转换矩阵,其能够将顶点从绑定空间转换至骨头的本地空间;此外,我们需要找出一种方法来展示骨骼的层级(我们将在下一小节学习)。以下为存储骨骼动画数据的数据结构:

class SkinnedData
{
public:
    UINT BoneCount() const;
    float GetClipStartTime(const std::string& clipName) const;
    float GetClipEndTime(const std::string& clipName) const;
    void Set(
        std::vector<int>& boneHierarchy,
        std::vector<DirectX::XMFLOAT4X4>& boneOffsets,
        std::unordered_map<std::string, AnimationClip>& animations);
    
    // 在实际的项目中,我们一般会将Final转换矩阵进行缓存
    void GetFinalTransforms(const std::string& clipName, float timePos,
        std::vector<DirectX::XMFLOAT4X4>& finalTransform) const;

private:
    std::vector<int> mBoneHierarchy;
    std::vector<DirectX::XMFLOAT4X4> mBoneOffsets;
    std::unordered_map<std::string, AnimationClip> mAnimations;
}

23.2.5 计算Final转换矩阵

我们demo中的骨骼层级仍然是树状结构。而我们使用整型数的数组来模拟这种结构,也就是说,数组中第i个成员表示第i根骨头的父节点的索引。此外,此外,第i个成员与animation clip中第i个BoneAnimation相对应,其也对应第i个补偿转换矩阵。根节点永远是第0个成员,其没有父节点。所以,第i根骨头的动画与其祖父节点的补偿转换矩阵可以通过以下代码求出:

int parentIndex = mBoneHierarchy[i];
int grandParentIndex = mBoneHierarchy[parentIndex];
XMFLOAT4X4 offset = mBoneOffsets[grandParentIndex];
AnimationClip& clip = mAnimations["attack"];
BoneAnimation& anim = clip.BoneAnimations[grandParentIndex];

因此,我们可以通过以下函数计算每根骨头的final转换矩阵:

void SkinnedData::GetFinalTransform(const std::string& clipName,
    float timePos, std::vector<XMFLOAT4X4>& finalTransforms) const
{
    UINT numBones = mBoneOffsets.size();
    std::vector<XMFLOAT4X4> toParentTransforms(numBones);

    // 基于时间,对clip中所有的骨头进行插值
    auto clip = mAnimations.find(clipName);
    clip->second.Interpolate(timePos, toParentTransforms);

    // 遍历层级并将所有的骨头转换至根节点所在的空间
    std::vector<XMFLOAT4X4> toRootTransforms(numBones);

    // 根节点的索引为0。根节点没有父节点,
    // 所以根节点的toRootTransform就是根节点的本地坐标系
    toRootTransform[0] = toParentTransforms[0];

    // 现在求出所有子节点的toRootTransform
    for(UINT i = 1; i < numBones; ++i)
    {
        XMMATRIX toParent = XMLoadFloat4x4(&toParentTransforms[i]);
        int parentIndex = mBoneHierarchy[i];
        XMMATRIX parentToRoot = XMLoadFloat4x4(&toRootTransform[parentIndex]);
        XMMATRIX toRoot = XMMatrixMultiply(toParent, parentToRoot);
        XMStoreFloat4x4(&toRootTransform[i], toRoot);
    }

    // 乘以骨头的补偿转换矩阵以得到final转换矩阵
    for(UINT i = 0; i < numBones; ++i)
    {
        XMMATRIX offset = XMLoadFloat4x4(&mBoneOffsets[i]);
        XMMATRIX toRoot = XMLoadFloat4x4(&toRootTransform[i]);
        XMStoreFloat4x4(&finalTransforms[i], XMMatrixMultiply(offset, toRoot));
    }
}

上述代码中我们需要注意一点。当我们使用for循环遍历骨骼层级时,我们会读取骨头的父节点的to-root转换矩阵:

int parentIndex = mBoneHierarchy[i];
XMMATRIX parentToRoot = XMLoadFloat4x4(&toRootTransforms[parentIndex]);

上述代码的成立条件是我们必须预先处理每个骨头的父节点的to-root矩阵。同时,在我们的demo中,骨头在数组中的顺序永远是父骨头在子骨头的前面。以下是demo中角色的前10根骨头的层级数据:

ParentIndexOfBone0: -1
ParentIndexOfBone1: 0
ParentIndexOfBone2: 0
ParentIndexOfBone3: 2
ParentIndexOfBone4: 3
ParentIndexOfBone5: 4
ParentIndexOfBone6: 5
ParentIndexOfBone7: 6
ParentIndexOfBone8: 5
ParentIndexOfBone9: 8
例如,骨头9的父节点是骨头8,骨头8的父节点是骨头5,骨头5的父节点是骨头4,骨头4的父节点是骨头3,骨头3的父节点是骨头2,骨头2的父节点是骨头0。我们可以看到,在数组中,子骨头永远不会位于父骨头的前面。

留下评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据