蒙皮骨骼动画

本篇文章为翻译文章,由于此文章 LearnOpenGLCN 没有进行翻译,我这里翻译一下。
原文链接: https://learnopengl.com/Guest-Articles/2020/Skeletal-Animation

3D动画可以给我们的游戏带来生命。3D世界中的物体,如人类和动物,当他们移动他们的四肢做某些事情,如行走,奔跑和攻击时,感觉更有生机。本教程是关于你期待已久的骨骼动画。我们首先将彻底理解一些概念,然后我们通过使用Assimp库,了解需要使用3D模型动画的数据。我建议你完成这个saga的 模型加载 章节作为本教程代码继续从那里。您仍然可以理解这个概念,并以自己的方式实现它。让我们开始吧。


1. 插值

为了理解动画的最基本的工作原理,我们需要理解插值的概念。插值可以被定义为随着时间的推移而发生的事情。就像一个敌人在T时间从A点移动到B点,随着时间的推移平移也随之发生。炮塔平滑地面向目标旋转,即旋转随着时间的推移而发生,树木在时间T中从A的大小缩放到B的大小,即缩放随着时间的推移而发生。

一个用于平移和缩放的简单插值方程是这样的。

a = a * (1 - t) + b * t

它被称为线性插值方程或Lerp。对于旋转,我们不能使用向量。这样做的原因是,如果我们继续尝试在向量X(Pitch),Y(Yaw)和Z(Roll)上使用线性插值方程,那么插值就不是线性的。 你会遇到一些奇怪的问题,比如万向环锁(参见下面的参考资料部分来了解它)。为了避免这个问题,我们使用四元数进行旋转。 四元数提供了所谓的 球面插值 或叫做 Slerp方程 ,它给出了相同的结果,但对于两个旋转A和B,我将无法解释这个方程是如何工作的, 因为它超出了目前的范围。你当然可以参考下面的章节来理解四元数。


2. 一个动画模型的组件:皮肤,骨骼和关键帧

动画的整个过程从添加第一个组件开始,该组件是blender或Maya等软件中的 蒙皮 (The Skin)。皮肤只不过是网格,它给模型增加了视觉效果,告诉观者它是什么样子。 但是,如果你想移动任何网格,像真实世界一样,你需要添加骨骼。你可以看到下面的图片来理解它在比如blender这些软件中的样子。

译者注: 蒙皮骨骼动画,实际上是指骨骼动画,蒙皮只是附着在骨骼上的皮肤。

这些骨头通常是以分级的方式添加到人物身上,比如人类和动物,原因很明显。我们想要四肢之间存在父子关系。例如,如果我们移动右肩,那么我们的右二头肌、前臂、手和手指也应该移动。这就是层次结构的样子…

在上图中,如果你抓住髋骨并移动它,所有的四肢都会受到它移动的影响。

此时,我们准备为动画创建关键帧。关键帧是动画中不同时间点的姿势。我们将在代码中为这些关键帧之间进行插值,以便可以从一个姿势平滑地切换到另一个姿势。下面你可以看到如何为一个简单的4帧跳跃动画创建姿势。。。


3. Assimp如何保存动画数据

我们几乎到了代码部分,但首先我们需要了解assimp如何保存导入的动画数据。看下面的图表

就像在模型加载一章中一样,我们将从aiScene指针(保存指向根节点的指针)开始,看看这里有什么,一个动画数组。

此动画数组包含一些信息,比如动画的持续时间 mDuration ,一个 mTicksPerSecond 变量, 表示帧间插值的速度。如果您记得上一节中的动画有关键帧。同样,aiAnimation 包含一个名为 ChannelsaiNodeAnim 数组。 该数组包含将参与动画的所有骨骼及其关键帧。一个 aiNodeAnim 包含骨骼的名称, 在这里你会发现3种类型的插值关键字,Translation(平移)Rotation(旋转)Scale(缩放)

好吧,还有最后一件事我们需要了解,我们就可以去写一些代码了。


4. 多个骨骼对顶点的影响

当我们卷曲前臂,我们看到我们的二头肌收缩。我们也可以说前臂骨骼的变化影响着我们肱二头肌的顶点。类似地,可能有多个骨骼影响网格中的同一个顶点。对于像实心金属机器人这样的角色,所有前臂顶点只会受到前臂骨骼的影响,但是对于像人类、动物等角色,最多可以有4块骨骼影响一个顶点。让我们看看assimp如何存储这些信息的。

我们再次从 aiScene 指针开始,它包含所有 aiMeshes 的数组。 每个 aiMesh 对象都有一个 aiBone 数组, 包含这个 aiBone 对网格上的顶点集有多大影响之类的信息。 aiBone 包含骨骼的名称,一个aiVertexWeight 数组,该数组基本上告诉我们这个 aiBone 对网格上的顶点有多大的影响。现在我们还有一个aiBone的成员, 就是offsetMatrix 。它是一个4x4矩阵,用于将顶点从模型空间转换到骨骼空间。你可以在下面的图片中看到这一点

当顶点在骨骼空间中时,它们将按照预期相对于其骨骼进行变换。您很快就会在代码中看到这一点。


5. 终于!我们来编码。

谢谢你能看到这里。我们将从直接查看最终结果开始,这是我们的最终顶点着色器代码。这会让我们很清楚我们到底需要什么。

#version 430 core

layout(location = 0) in vec3 pos;
layout(location = 1) in vec3 norm;
layout(location = 2) in vec2 tex;
layout(location = 3) in ivec4 boneIds; 
layout(location = 4) in vec4 weights;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;

const int MAX_BONES = 100;
const int MAX_BONE_INFLUENCE = 4;
uniform mat4 finalBonesMatrices[MAX_BONES];

out vec2 TexCoords;

void main()
{
    vec4 totalPosition = vec4(0.0f);
    for(int i = 0 ; i < MAX_BONE_INFLUENCE ; i++)
    {
        if(boneIds[i] == -1) 
            continue;
        if(boneIds[i] >= MAX_JOINTS) 
        {
            totalPosition = vec4(pos,1.0f);
            break;
        }
        vec4 localPosition = finalBoneMatrices[boneIds[i]] * vec4(pos,1.0f);
        totalPosition += localPosition * weights[i];
        vec3 localNormal = mat3(finalBoneMatrices[boneIds[i]]) * norm;
   }
    
    mat4 viewModel = view * model;
    gl_Position =  projection * viewModel * totalPosition;
    TexCoords = tex;
}

片段着色器在 模型加载 一章中保持不变。

从顶部开始,您将看到两个新的 attributes layout 被声明。第一个是boneIds , 第二个是 weights 。我们还有一个uniform的数组 finalBonesMatrices , 它存储所有骨骼的变换。boneIds 包含用于读取 finalBonesMatrices 数组并将这些转换应用到pos顶点的索引,其各自的权重存储在 weights 数组中。这发生在上面的for循环内部。现在让我们为网格类中添加骨骼权重的支持。

#define MAX_BONE_INFLUENCE 4

struct Vertex {
    // 顶点
    glm::vec3 Position;
    // 法线
    glm::vec3 Normal;
    // 纹理坐标
    glm::vec2 TexCoords;
    
    // 影响该顶点的骨骼索引
    int m_BoneIDs[MAX_BONE_INFLUENCE];

    // 每一个骨骼的权重
    float m_Weights[MAX_BONE_INFLUENCE];
};

我们为顶点(Vertex)添加了两个新属性,就像我们在顶点着色器中看到的那样。现在,让我们像 Mesh::setupMesh 函数( 实际上就是创建VBO、EBO和VAO )中的其他属性一样,将它们加载到GPU缓冲区中。

class Mesh
{
    ...
    
    void setupMesh()
    {
        ...
        
        // ids
        glEnableVertexAttribArray(3);
        glVertexAttribIPointer(3, 4, GL_INT, sizeof(Vertex), 
                               (void*)offsetof(Vertex, m_BoneIDs));

        // weights
        glEnableVertexAttribArray(4);
        glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, sizeof(Vertex), 
                              (void*)offsetof(Vertex, m_Weights));
        
        ...
    }
    ...
}

就像以前一样,除了现在我们为 boneIdsweights 指定3号ID和4号ID属性外。 这里需要注意的一件重要的事情是我们如何为 boneIds 传递数据。我们使用 glVertexAttribIPointer ,并将 GL_INT 作为第三个参数传递。

现在我们可以从assimp数据结构中提取 bone-weight 信息。让我们在 Model 类中做一些更改。

struct BoneInfo
{
    /*finalBoneMatrices中的索引id*/
    int id;

    /*从模型空间到骨骼空间的偏移矩阵*/
    glm::mat4 offset;

};

这个 BoneInfo 将存储我们的偏移矩阵,还有一个唯一的id,它将被用作索引,即存储在我们前面在着色器中看到的 finalbonematrics 数组的索引。

class Model 
{
private:
    ...
    std::map<string, BoneInfo> m_BoneInfoMap; //
    int m_BoneCounter = 0;
    
    ...
    void SetVertexBoneDataToDefault(Vertex& vertex)
    {
        for (int i = 0; i < MAX_BONE_WEIGHTS; i++)
        {
            vertex.m_BoneIDs[i] = -1;
            vertex.m_Weights[i] = 0.0f;
        }
    }

    Mesh processMesh(aiMesh* mesh, const aiScene* scene)
    {
        vector vertices;
        vector indices;
        vector textures;

        for (unsigned int i = 0; i < mesh->mNumVertices; i++)
        {
            Vertex vertex;
            
            SetVertexBoneDataToDefault(vertex);

            vertex.Position = AssimpGLMHelpers::GetGLMVec(mesh->mVertices[i]);
            vertex.Normal = AssimpGLMHelpers::GetGLMVec(mesh->mNormals[i]);
            
            if (mesh->mTextureCoords[0])
            {
                glm::vec2 vec;
                vec.x = mesh->mTextureCoords[0][i].x;
                vec.y = mesh->mTextureCoords[0][i].y;
                vertex.TexCoords = vec;
            }
            else
                vertex.TexCoords = glm::vec2(0.0f, 0.0f);

            vertices.push_back(vertex);
        }
        ...
        ExtractBoneWeightForVertices(vertices,mesh,scene);

        return Mesh(vertices, indices, textures);
    }

    void SetVertexBoneData(Vertex& vertex, int boneID, float weight)
    {
        for (int i = 0; i < MAX_BONE_WEIGHTS; ++i)
        {
            if (vertex.m_BoneIDs[i] < 0)
            {
                vertex.m_Weights[i] = weight;
                vertex.m_BoneIDs[i] = boneID;
                break;
            }
        }
    }

    void ExtractBoneWeightForVertices(std::vector& vertices, aiMesh* mesh, 
                                      const aiScene* scene)
    {
        for (int boneIndex = 0; boneIndex < mesh->mNumBones; ++boneIndex)
        {
            int boneID = -1;
            std::string boneName = mesh->mBones[boneIndex]->mName.C_Str();
            if (m_BoneInfoMap.find(boneName) == m_BoneInfoMap.end())
            {
                BoneInfo newBoneInfo;
                newBoneInfo.id = m_BoneCounter;
                newBoneInfo.offset = AssimpGLMHelpers::
                    ConvertMatrixToGLMFormat(mesh->mBones[boneIndex]->mOffsetMatrix);
                m_BoneInfoMap[boneName] = newBoneInfo;
                boneID = m_BoneCounter;
                m_BoneCounter++;
            }
            else
            {
                boneID = m_BoneInfoMap[boneName].id;
            }
            assert(boneID != -1);
            auto weights = mesh->mBones[boneIndex]->mWeights;
            int numWeights = mesh->mBones[boneIndex]->mNumWeights;

            for (int weightIndex = 0; weightIndex < numWeights; ++weightIndex)
            {
                int vertexId = weights[weightIndex].mVertexId;
                float weight = weights[weightIndex].mWeight;
                assert(vertexId <= vertices.size());
                SetVertexBoneData(vertices[vertexId], boneID, weight);
            }
        }
    }
    .......
};

我们首先声明一个map m_BoneInfoMap 和一个计数器 m_ boneconter , 一旦我们读取了一个新的骨骼,它就会递增。我们在前面的图中看到,每个 aiMesh 都包含与 aiMesh 相关联的所有 aiBones 。整个 bone-weight 提取过程从 processMesh 函数开始。对于每个循环迭代,我们都通过调用函数 SetVertexBoneDataToDefaultm_BoneIDsm_Weights 设置为它们的默认值。在 processMesh 函数结束之前, 我们调用 ExtractBoneWeightData 。在 ExtractBoneWeightData 函数中, 我们运行一个for循环获取每一个 aiBone,并检查这个骨骼是否已经存在于 m_BoneInfoMap 中。 如果我们找不到它,那么就认为它是一个新的骨骼,我们用创建一个新的 BoneInfo ,并将其关联的 mOffsetMatrixid 赋值给它。然后我们将这个新的 BoneInfo 存储在 m_BoneInfoMap 中, 然后我们计数器 m_BoneCounter 自增,为下一个骨骼创建id。如果我们在 m_BoneInfoMap 中找到骨骼名称,那么这意味着该骨骼会影响其他网格的顶点。所以我们取它的Id,进一步了解它影响哪些顶点。

需要注意的一点是,我们调用 AssimpGLMHelpers::ConvertMatrixToGLMFormatAssimp 存储矩阵数据的格式与 GML 是不同的,所以这个函数讲 Assimp 格式的矩阵数据转换为 GLM 格式的矩阵。

我们已经提取了骨骼的 offsetMatrix ,现在我们将简单地迭代 aivertexweightsarray, 并提取将受此骨骼影响的所有顶点索引及其各自的权重,并调用 SetVertexBoneData 函数, 填充 Vertex.boneIdsVertex.weights


6. 骨骼、动画和Animator类

下面是类的视图。

让我们提醒自己我们正在努力实现的目标。对于每个渲染帧,我们希望平滑地插值动画中的所有骨骼,并得到它们的最终变换矩阵,这些矩阵将提供给着色器统一的uniform finalBonesMatrices 。以下是每个类都做的什么:

  • Bone: 从 aiNodeAnim 读取所有关键帧数据的单个骨骼。它还会根据当前动画时间在关键点(平移、缩放和旋转)之间进行插值。
  • AssimpNodeData: 这个结构将帮助我们从Assimp中分离动画。
  • Animation: 从动画中读取数据并创建 Bone 的数组集合。
  • Animator: 这将处理 AssimpNodeData ,以递归方式对所有骨骼进行插值,然后准备我们需要的最终的骨骼变换矩阵。

下面是 Bone 的代码:

struct KeyPosition
{
    glm::vec3 position;
    float timeStamp;
};

struct KeyRotation
{
    glm::quat orientation;
    float timeStamp;
};

struct KeyScale
{
    glm::vec3 scale;
    float timeStamp;
};

class Bone
{
private:
  std::vector<KeyPosition> m_Positions;
  std::vector<KeyRotation> m_Rotations;
  std::vector<KeyScale> m_Scales;
  int m_NumPositions;
  int m_NumRotations;
  int m_NumScalings;
    
  glm::mat4 m_LocalTransform;
  std::string m_Name;
  int m_ID;
public:
  /*reads keyframes from aiNodeAnim*/
  Bone(const std::string& name, int ID, const aiNodeAnim* channel)
    :
    m_Name(name),
    m_ID(ID),
    m_LocalTransform(1.0f)
    {
        m_NumPositions = channel->mNumPositionKeys;

        for (int positionIndex = 0; positionIndex < m_NumPositions; ++positionIndex)
        {
            aiVector3D aiPosition = channel->mPositionKeys[positionIndex].mValue;
            float timeStamp = channel->mPositionKeys[positionIndex].mTime;
            KeyPosition data;
            data.position = AssimpGLMHelpers::GetGLMVec(aiPosition);
            data.timeStamp = timeStamp;
            m_Positions.push_back(data);
        }

        m_NumRotations = channel->mNumRotationKeys;
        for (int rotationIndex = 0; rotationIndex < m_NumRotations; ++rotationIndex)
        {
            aiQuaternion aiOrientation = channel->mRotationKeys[rotationIndex].mValue;
            float timeStamp = channel->mRotationKeys[rotationIndex].mTime;
            KeyRotation data;
            data.orientation = AssimpGLMHelpers::GetGLMQuat(aiOrientation);
            data.timeStamp = timeStamp;
            m_Rotations.push_back(data);
        }

        m_NumScalings = channel->mNumScalingKeys;
        for (int keyIndex = 0; keyIndex < m_NumScalings; ++keyIndex)
        {
            aiVector3D scale = channel->mScalingKeys[keyIndex].mValue;
            float timeStamp = channel->mScalingKeys[keyIndex].mTime;
            KeyScale data;
            data.scale = AssimpGLMHelpers::GetGLMVec(scale);
            data.timeStamp = timeStamp;
            m_Scales.push_back(data);
        }
    }
    
    /* Interpolates b/w positions,rotations & scaling keys based on the curren time of the 
    animation and prepares the local transformation matrix by combining all keys tranformations */
    void Update(float animationTime)
    {
        glm::mat4 translation = InterpolatePosition(animationTime);
        glm::mat4 rotation = InterpolateRotation(animationTime);
        glm::mat4 scale = InterpolateScaling(animationTime);
        m_LocalTransform = translation * rotation * scale;
    }

    glm::mat4 GetLocalTransform() { return m_LocalTransform; }
    std::string GetBoneName() const { return m_Name; }
    int GetBoneID() { return m_ID; }
    
    /* Gets the current index on mKeyPositions to interpolate to based on the current 
    animation time */
    int GetPositionIndex(float animationTime)
    {
        for (int index = 0; index < m_NumPositions - 1; ++index)
        {
            if (animationTime < m_Positions[index + 1].timeStamp)
                return index;
        }
        assert(0);
    }
    
    /* Gets the current index on mKeyRotations to interpolate to based on the current 
    animation time */
    int GetRotationIndex(float animationTime)
    {
        for (int index = 0; index < m_NumRotations - 1; ++index)
        {
            if (animationTime < m_Rotations[index + 1].timeStamp)
                return index;
        }
        assert(0);
    }

    /* Gets the current index on mKeyScalings to interpolate to based on the current 
    animation time */
    int GetScaleIndex(float animationTime)
    {
        for (int index = 0; index < m_NumScalings - 1; ++index)
        {
            if (animationTime < m_Scales[index + 1].timeStamp)
                return index;
        }
        assert(0);
    }
private:

    /* Gets normalized value for Lerp & Slerp*/
    float GetScaleFactor(float lastTimeStamp, float nextTimeStamp, float animationTime)
    {
        float scaleFactor = 0.0f;
        float midWayLength = animationTime - lastTimeStamp;
        float framesDiff = nextTimeStamp - lastTimeStamp;
        scaleFactor = midWayLength / framesDiff;
        return scaleFactor;
    }

    /* figures out which position keys to interpolate b/w and performs the interpolation 
    and returns the translation matrix */
    glm::mat4 InterpolatePosition(float animationTime)
    {
        if (1 == m_NumPositions)
            return glm::translate(glm::mat4(1.0f), m_Positions[0].position);

        int p0Index = GetPositionIndex(animationTime);
        int p1Index = p0Index + 1;
        float scaleFactor = GetScaleFactor(m_Positions[p0Index].timeStamp,
                            m_Positions[p1Index].timeStamp, animationTime);
        glm::vec3 finalPosition = glm::mix(m_Positions[p0Index].position, 
                                           m_Positions[p1Index].position
            , scaleFactor);
        return glm::translate(glm::mat4(1.0f), finalPosition);
    }

    /* figures out which rotations keys to interpolate b/w and performs the interpolation 
    and returns the rotation matrix */
    glm::mat4 InterpolateRotation(float animationTime)
    {
        if (1 == m_NumRotations)
        {
            auto rotation = glm::normalize(m_Rotations[0].orientation);
            return glm::toMat4(rotation);
        }

        int p0Index = GetRotationIndex(animationTime);
        int p1Index = p0Index + 1;
        float scaleFactor = GetScaleFactor(m_Rotations[p0Index].timeStamp,
                            m_Rotations[p1Index].timeStamp, animationTime);
        glm::quat finalRotation = glm::slerp(m_Rotations[p0Index].orientation,
                                  m_Rotations[p1Index].orientation, scaleFactor);
        finalRotation = glm::normalize(finalRotation);
        return glm::toMat4(finalRotation);
    }

    /* figures out which scaling keys to interpolate b/w and performs the interpolation 
    and returns the scale matrix */
    glm::mat4 Bone::InterpolateScaling(float animationTime)
    {
        if (1 == m_NumScalings)
            return glm::scale(glm::mat4(1.0f), m_Scales[0].scale);

        int p0Index = GetScaleIndex(animationTime);
        int p1Index = p0Index + 1;
        float scaleFactor = GetScaleFactor(m_Scales[p0Index].timeStamp,
                            m_Scales[p1Index].timeStamp, animationTime);
        glm::vec3 finalScale = glm::mix(m_Scales[p0Index].scale, 
                               m_Scales[p1Index].scale, scaleFactor);
        return glm::scale(glm::mat4(1.0f), finalScale);
    }	
};

我们首先为我们的关键类型创建3个结构体。每个结构都保存一个值和一个时间戳。时间戳告诉我们,我们要差值的所在动画的哪个时间点。 Bone 有一个构造函数,它从 aiNodeAnim 读取并存储keys及其时间戳, 以将其存储到 MPositonKeysmRotationKeysmScalingKeys 。 主要的插值过程从 Update(float animationTime) 开始,该函数每个帧都会被调用。 此函数对所有类型的key调用相应插值函数,并将所有最终插值结果组合在一起,并将其存储到4x4矩阵 m_uLocalTransform 。 平移和缩放关键点的插值函数相似,但对于旋转,我们使用 SLRP 在四元数之间插值。 LerpSLRP 都有3个参数。第一个参数取上一个关键帧,第二个参数取下一个关键帧, 第三个参数取范围0-1,这里称之为比例因子。让我们看看我们如何在函数 GetScaleFactor 中计算这个比例因子。

代码中:

float midWayLength = animationTime - lastTimeStamp;
float framesDiff = nextTimeStamp - lastTimeStamp;
scaleFactor = midWayLength / framesDiff;

我们来看一下 Animation

struct AssimpNodeData
{
    glm::mat4 transformation;
    std::string name;
    int childrenCount;
    std::vector<AssimpNodeData> children;
};

class Animation
{
public:
    Animation() = default;

    Animation(const std::string& animationPath, Model* model)
    {
        Assimp::Importer importer;
        const aiScene* scene = importer.ReadFile(animationPath, aiProcess_Triangulate);
        assert(scene && scene->mRootNode);
        auto animation = scene->mAnimations[0];
        m_Duration = animation->mDuration;
        m_TicksPerSecond = animation->mTicksPerSecond;
        ReadHeirarchyData(m_RootNode, scene->mRootNode);
        ReadMissingBones(animation, *model);
    }

    ~Animation()
    {
    }

    Bone* FindBone(const std::string& name)
    {
        auto iter = std::find_if(m_Bones.begin(), m_Bones.end(),
            [&](const Bone& Bone)
            {
                return Bone.GetBoneName() == name;
            }
        );
        if (iter == m_Bones.end()) return nullptr;
        else return &(*iter);
    }

    
    inline float GetTicksPerSecond() { return m_TicksPerSecond; }

    inline float GetDuration() { return m_Duration;}

    inline const AssimpNodeData& GetRootNode() { return m_RootNode; }

    inline const std::map<std::string,BoneInfo>& GetBoneIDMap() 
    { 
        return m_BoneInfoMap;
    }

private:
    void ReadMissingBones(const aiAnimation* animation, Model& model)
    {
        int size = animation->mNumChannels;

        auto& boneInfoMap = model.GetBoneInfoMap();//getting m_BoneInfoMap from Model class
        int& boneCount = model.GetBoneCount(); //getting the m_BoneCounter from Model class

        //reading channels(bones engaged in an animation and their keyframes)
        for (int i = 0; i < size; i++)
        {
            auto channel = animation->mChannels[i];
            std::string boneName = channel->mNodeName.data;

            if (boneInfoMap.find(boneName) == boneInfoMap.end())
            {
                boneInfoMap[boneName].id = boneCount;
                boneCount++;
            }
            m_Bones.push_back(Bone(channel->mNodeName.data,
                              boneInfoMap[channel->mNodeName.data].id, channel));
        }

        m_BoneInfoMap = boneInfoMap;
    }

    void ReadHeirarchyData(AssimpNodeData& dest, const aiNode* src)
    {
        assert(src);

        dest.name = src->mName.data;
        dest.transformation = AssimpGLMHelpers::ConvertMatrixToGLMFormat(src->mTransformation);
        dest.childrenCount = src->mNumChildren;

        for (int i = 0; i < src->mNumChildren; i++)
        {
            AssimpNodeData newData;
            ReadHeirarchyData(newData, src->mChildren[i]);
            dest.children.push_back(newData);
        }
    }
    float m_Duration;
    int m_TicksPerSecond;
    std::vector<Bone> m_Bones;
    AssimpNodeData m_RootNode;
    std::map<std::string, BoneInfo> m_BoneInfoMap;
};

这里, Animation 的创建从构造函数开始。这需要两个参数,第一个参数是动画文件的路径,第二个参数是该动画的模型。 稍后您将看到为什么我们需要此模型参数。然后我们创建一个 Assimp::Importer 来读取动画文件,然后执行断言检查, 如果找不到动画,该检查将抛出一个错误。然后我们读取动画数据,比如这个动画的持续时间是多长 mDuration , 以及用表示的动画速度 mTicksPerSecond 。然后,我们调用 ReadHeirarchyData 函数,它复制了Assimp的 aiNode 继承关系并创建了 AssimpNodeData 的继承关系。

然后我们调用一个名为 ReadMissingBones 的函数。我不得不编写这个函数,因为有时当我单独加载FBX模型时, 它丢失了一些骨骼,我在动画文件中发现了丢失的骨骼。此函数读取丢失的骨骼信息,并将其信息存储在模型的 m_BoneInfoMap 中,并在 m_ BoneInfoMap 中本地保存 m_BoneInfoMap 的引用。

我们已经准备好动画了。现在让我们看看最后一个阶段,Animator

class Animator
{
public:
    Animator::Animator(Animation* Animation)
    {
        m_CurrentTime = 0.0;
        m_CurrentAnimation = currentAnimation;

        m_FinalBoneMatrices.reserve(100);

        for (int i = 0; i < 100; i++)
            m_FinalBoneMatrices.push_back(glm::mat4(1.0f));
    }
    
    void Animator::UpdateAnimation(float dt)
    {
        m_DeltaTime = dt;
        if (m_CurrentAnimation)
        {
            m_CurrentTime += m_CurrentAnimation->GetTicksPerSecond() * dt;
            m_CurrentTime = fmod(m_CurrentTime, m_CurrentAnimation->GetDuration());
            CalculateBoneTransform(&m_CurrentAnimation->GetRootNode(), glm::mat4(1.0f));
        }
    }
    
    void Animator::PlayAnimation(Animation* pAnimation)
    {
        m_CurrentAnimation = pAnimation;
        m_CurrentTime = 0.0f;
    }
    
    void Animator::CalculateBoneTransform(const AssimpNodeData* node, glm::mat4 parentTransform)
    {
        std::string nodeName = node->name;
        glm::mat4 nodeTransform = node->transformation;
    
        Bone* Bone = m_CurrentAnimation->FindBone(nodeName);
    
        if (Bone)
        {
            Bone->Update(m_CurrentTime);
            nodeTransform = Bone->GetLocalTransform();
        }
    
        glm::mat4 globalTransformation = parentTransform * nodeTransform;
    
        auto boneInfoMap = m_CurrentAnimation->GetBoneIDMap();
        if (boneInfoMap.find(nodeName) != boneInfoMap.end())
        {
            int index = boneInfoMap[nodeName].id;
            glm::mat4 offset = boneInfoMap[nodeName].offset;
            m_FinalBoneMatrices[index] = globalTransformation * offset;
        }
    
        for (int i = 0; i < node->childrenCount; i++)
            CalculateBoneTransform(&node->children[i], globalTransformation);
    }
    
    std::vector<glm::mat4> GetFinalBoneMatrices() 
    { 
        return m_FinalBoneMatrices;  
    }
        
private:
    std::vector<glm::mat4> m_FinalBoneMatrices;
    Animation* m_CurrentAnimation;
    float m_CurrentTime;
    float m_DeltaTime;	
};

Animator构造函数传递一个Animation作为参数播放动画,然后将动画时间 m_CurrentTime 重置为0。 它还初始化 m_finalbonemartics ,这为一个 std::vector<glm::mat4> 。 这里主要关注的是 UpdateAnimation(float deltaTime) 函数。它以 m_TicksPerSecond 更新 m_CurrentTime,然后调用 CalculateBoneTransform 函数。 我们将在开始时传递两个参数,第一个参数是 m_CurrentAnimationm_RootNode , 第二个参数是作为parentTransform传递给此函数的单位矩阵,然后通过在动画的 m_Bones 数组中查找 m_RootNodes bone来检查它是否参与此动画。如果找到骨骼,则调用 bone.Update() 函数,该函数对所有骨骼进行插值,并将局部骨骼变换矩阵返回给 nodeTransform 。但这是局部空间矩阵,如果传入着色器,它将围绕原点移动骨骼。所以我们将这个节点转换与 parentTransform 相乘,并将结果存储在 globalTransformation 中。这就足够了, 但顶点仍在默认模型空间中。我们在 m_BoneInfoMap 中找到offset matrix,然后将其与 globalTransfromMatrix 相乘。我们还将得到id索引,该索引将用于将此骨骼的最终转换写入 m_finalbonemartics

最后!我们为这个节点的每个子节点调用 CalculateBonetTransform 函数,并将 globalTransformation 作为 parentTransform 传递。当没有子循环可以进一步处理时, 我们会打破这个递归循环。


7. 动画效果

我们努力的成果终于来了!下面是我们将显示在main.cpp中如何播放动画。。。

int main()
{
    ...
    
    Model ourModel(FileSystem::getPath("resources/objects/vampire/dancing_vampire.dae"));
    Animation danceAnimation(FileSystem::getPath(
            "resources/objects/vampire/dancing_vampire.dae"), &ourModel);
    Animator animator(&danceAnimation);

    // draw in wireframe
    //glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);

    // render loop
    // -----------
    while (!glfwWindowShouldClose(window))
    {
        // per-frame time logic
        // --------------------
        float currentFrame = glfwGetTime();
        deltaTime = currentFrame - lastFrame;
        lastFrame = currentFrame;

        // input
        // -----
        processInput(window);
        animator.UpdateAnimation(deltaTime);
        
        // render
        // ------
        glClearColor(0.05f, 0.05f, 0.05f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

        // don't forget to enable shader before setting uniforms
        ourShader.use();

        // view/projection transformations
        glm::mat4 projection = glm::perspective(glm::radians(camera.Zoom), 
            (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
        glm::mat4 view = camera.GetViewMatrix();
        ourShader.setMat4("projection", projection);
        ourShader.setMat4("view", view);

        auto transforms = animator.GetFinalBoneMatrices();
        for (int i = 0; i < transforms.size(); ++i)
            ourShader.setMat4("finalBonesTransformations[" + std::to_string(i) + "]", 
                              transforms[i]);

        // render the loaded model
        glm::mat4 model = glm::mat4(1.0f);
        model = glm::translate(model, glm::vec3(0.0f, -0.4f, 0.0f)); 
        model = glm::scale(model, glm::vec3(.5f, .5f, .5f));
        ourShader.setMat4("model", model);
        ourModel.Draw(ourShader);

        // glfw: swap buffers and poll IO events (keys pressed/released, mouse moved etc.)
        // -------------------------------------------------------------------------------
        glfwSwapBuffers(window);
        glfwPollEvents();
    }

    // glfw: terminate, clearing all previously allocated GLFW resources.
    // ------------------------------------------------------------------
    glfwTerminate();
    return 0;
}

我们从加载模型开始,该模型将为着色器设置骨骼权重数据,然后通过为其指定路径来创建动画。然后我们通过传递创建的动画来创建Animator对象。在“渲染循环”(render loop)中,我们将更新动画制作程序,获取最终的骨骼变换并将其交给着色器。这是我们一直在等待的结果。。。

使用的模型下载在 这里 。 请注意,动画和网格在单个DAE(collada)文件中烘焙。你可以在 这里 找到完整的源代码


拓展阅读

不会飞的纸飞机
扫一扫二维码,了解我的更多动态。

下一篇文章:Qt实战小工具 -- 简单的音乐播放器