2-Assimp 读取动画
在看完 GAMES104 动画系统那一部分后,我也想写一个简单的动画系统,又要开一个坑了!本文将简要介绍如何用 Assimp 库去读取动画信息。
用 Assimp 读取动画信息
在之前我们使用 Assimp 库读取了模型的几何和纹理信息,实际上还能读取模型的动画信息。
获取动画剪辑 Clip 信息
下图展示了 Assimp 库是如何存储动画信息的:
在 aiScene
中,存储了一个 aiAnimation
数组 Animations[]
。单个 aiAnimation
可看作一个动画剪辑(Animation Clip),它包含动画的名称,持续时间,帧率,关键帧等信息。其中关键帧信息存放在 aiNodeAnim
数组 Channels[]
中,单个 aiNodeAnim
包含当前关键帧的位移、旋转、缩放等数据。
获取多关节影响的权重
此外,由于是多关节绑定的蒙皮动画,单个顶点可能受到多个关节的影响,这里我们假定单个顶点最多受 4 个关节影响。来看看 Assimp 是如何存储关节对顶点影响的权重。
在 aiMesh
中有一个 aiBone
数组。aiBone
的 aiVertexWeight[]
则包含了当前关节对一些顶点的影响权重。还有一个叫做 offsetMatrix
的成员,它是一个 4x4 的矩阵,用于将处于初始绑定姿势的顶点从模型空间变换到关节的局部空间,即蒙皮矩阵。
代码实现
接下来就可以进行代码实现了。
拓展模型读取部分
首先需要拓展之前写的模型读取部分。对于顶点信息,之前只有位置,法向量和纹理坐标,现在额外添加了影响到该顶点的关节数组和对应权重:
#ifndef MAX_BONE_INFLUENCE #define MAX_BONE_INFLUENCE 4 #endif struct MeshVertex { glm::vec3 Position = glm::vec3(0.0f); glm::vec3 Normal = glm::vec3(0.0f); glm::vec2 TexCoords = glm::vec2(0.0f); // 影响该顶点的关节们 std::array<int, MAX_BONE_INFLUENCE> m_BoneIDs; // 关节对应的权重 std::array<float, MAX_BONE_INFLUENCE> m_BoneWeights; };
拓展顶点信息后,别忘了将它们传输给 GPU(这里直接搬教程了,和我代码不一样,详见我的项目 github):
// 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));
需要注意的是 m_BoneIDs
是 int 类型,需要用到 glVertexAttribIPointer
。
接下来添加结构体 BoneInfo
,用于存放 aiBone
里的关节数据:
struct BoneInfo { int id; glm::mat4 offset; // 将顶点从模型空间变换到关节局部空间的矩阵 };
然后拓展模型读取部分,实现数据的读取和存储:
// 模型读取类 Object.h // 对象骨骼动画属性 std::map<std::string, BoneInfo> m_boneInfoMap; int m_boneCounter = 0; // getter & setters std::map<std::string, BoneInfo>& getBoneInfoMap() { return m_boneInfoMap; } int getBoneCount() const { return m_boneCounter; } // 初始化骨骼数据 void InitVertexBoneData(MeshVertex& vertex) { for (int i = 0; i < MAX_BONE_INFLUENCE; ++i) { vertex.m_BoneIDs[i] = -1; vertex.m_BoneIDs[i] = 0.0f; } } // 为顶点设置骨骼影响权重 void SetVertexBoneData(MeshVertex& vertex, int boneID, float weight) { for (int i = 0; i < MAX_BONE_INFLUENCE; ++i) { if (vertex.m_BoneIDs[i] < 0) { vertex.m_BoneIDs[i] = boneID; vertex.m_BoneWeights[i] = weight; break; } } } // 读取并设置顶点的骨骼数据 void GLObject::ExtractBoneWeightForVertices(std::vector<MeshVertex>& vertices, aiMesh* mesh, const aiScene* scene) { for (int boneIdx = 0; boneIdx < mesh->mNumBones; ++boneIdx) { int boneID = -1; std::string boneName = mesh->mBones[boneIdx]->mName.C_Str(); if (!m_boneInfoMap.contains(boneName)) { BoneInfo newBoneInfo; newBoneInfo.id = m_boneCounter++; newBoneInfo.offset = AssimpGLMHelpers::GetGLMMat4(mesh->mBones[boneIdx]->mOffsetMatrix); m_boneInfoMap[boneName] = newBoneInfo; boneID = newBoneInfo.id; } else { boneID = m_boneInfoMap[boneName].id; } if (boneID == -1) { LOG_ERROR(std::format("[{}]: Cannot get boneID!", __FUNCTION__)); __debugbreak(); } auto weights = mesh->mBones[boneIdx]->mWeights; int numWeights = mesh->mBones[boneIdx]->mNumWeights; for (int weightsIdx = 0; weightsIdx < numWeights; ++weightsIdx) { int vertexID = weights[weightsIdx].mVertexId; float weight = weights[weightsIdx].mWeight; if (vertexID > vertices.size()) { LOG_ERROR(std::format("[{}]: vertices array out of bound!", __FUNCTION__)); __debugbreak(); } SetVertexBoneData(vertices[vertexID], boneID, weight); } } } Mesh processMesh(aiMesh* mesh, const aiScene* scene) { ... // 处理顶点信息 for (unsigned int i = 0; i < mesh->mNumVertices; ++i) { ... // 骨骼动画数据初始化 InitVertexBoneData(vertex); vertices.push_back(vertex); } // 处理骨骼动画数据 if (mesh->HasBones()) { ExtractBoneWeightForVertices(vertices, mesh, scene); } else { LOG_DEBUG(std::format("[{}]: This mesh has no bones for skinning animation.", __FUNCTION__)); } ... return {vertices, indices, textures, &m_modelData.pCustom->texturesLoaded}; }
实现初版动画类
接下来尝试实现动画类的初版,这些动画类应该可以让我们播放一个动画剪辑(Animation Clip)。以下是要实现的类:
- Bone 类:存储从
aiNodeAnim
中读出来的所有关键帧数据,也会给当前动画播放时间进行 SRT 变换的关键帧插值。 - AssimpNodeData 类:这个结构体将帮助我们从 Assimp 中独立出我们自己的动画类。
- Animation 类:读取
aiAnimation
中的数据,创建关节层次结构。 - Animator 类:读取
AssimpNodeData
的层次信息,用递归方式插值所有关节,然后准备我们需要的最终关节变换矩阵。
Bone 类
// Bone.h // ======================= 关键帧定义 ======================= struct KeyTranslate { glm::vec3 translate; float timeStamp; KeyTranslate(const glm::vec3& _translate, float _timeStamp) : translate(_translate), timeStamp(_timeStamp) {} }; struct KeyRotate { glm::quat orientation; float timeStamp; KeyRotate(const glm::quat& _orientation, float _timeStamp) : orientation(_orientation), timeStamp(_timeStamp) {} }; struct KeyScale { glm::vec3 scale; float timeStamp; KeyScale(const glm::vec3& _scale, float _timeStamp) : scale(_scale), timeStamp(_timeStamp) {} }; // ======================= 关键帧定义 ======================= class Bone { public: // 从aiNodeAnim->channel中读取该骨骼/关节的关键帧数据 Bone(const std::string& name, int ID, const aiNodeAnim* channel); // 根据动画的当前时间给SRT变换插值,并更新该骨骼/关节的局部空间M矩阵 void Update(float animationTime); // ======================= Getters & Setters ======================= // 根据给定播放时间获取最近位置的关键平移帧索引 int GetTranslateIndex(float animationTime) const; // 根据给定播放时间获取最近位置的关键旋转帧索引 int GetRotateIndex(float animationTime) const; // 根据给定播放时间获取最近位置的关键缩放帧索引 int GetScaleIndex(float animationTime) const; std::string GetBoneName() const { return m_name; } glm::mat4 GetLocalTransform() const { return m_localTransform; } // ======================= Getters & Setters ======================= private: // 获取用于Lerp和SLerp的标准化值 float GetScaleFactor(float lastTimeStamp, float nextTimeStamp, float animationTime) const; // 使用简单线性插值获取当前播放时间的平移矩阵 glm::mat4 InterpolateTranslate(float animationTime); // 使用简单线性插值获取当前播放时间的旋转矩阵 glm::mat4 InterpolateRotate(float animationTime); // 使用简单线性插值获取当前播放时间的缩放矩阵 glm::mat4 InterpolateScale(float animationTime); private: int m_ID; std::string m_name; int m_numTranslates; int m_numRotates; int m_numScales; std::vector<KeyTranslate> m_keyTranslations; std::vector<KeyRotate> m_keyRotations; std::vector<KeyScale> m_keyScales; glm::mat4 m_localTransform; };
// Bone.cpp Bone::Bone(const std::string& name, int ID, const aiNodeAnim* channel) : m_ID(ID), m_name(name), m_localTransform(glm::mat4(1.0f)) { m_numTranslates = channel->mNumPositionKeys; for (int posIdx = 0; posIdx < m_numTranslates; ++posIdx) { aiVector3D aiPosition = channel->mPositionKeys[posIdx].mValue; float timeStamp = channel->mPositionKeys[posIdx].mTime; m_keyTranslations.push_back({AssimpGLMHelpers::GetGLMVec3(aiPosition), timeStamp}); } m_numRotates = channel->mNumRotationKeys; for (int rotIdx = 0; rotIdx < m_numRotates; ++rotIdx) { aiQuaternion aiRotation = channel->mRotationKeys[rotIdx].mValue; float timeStamp = channel->mRotationKeys[rotIdx].mTime; m_keyRotations.push_back({AssimpGLMHelpers::GetGLMQuat(aiRotation), timeStamp}); } m_numScales = channel->mNumScalingKeys; for (int scaleIdx = 0; scaleIdx < m_numScales; ++scaleIdx) { aiVector3D aiScaling = channel->mScalingKeys[scaleIdx].mValue; float timeStamp = channel->mScalingKeys[scaleIdx].mTime; m_keyScales.push_back({AssimpGLMHelpers::GetGLMVec3(aiScaling), timeStamp}); } } void Bone::Update(float animationTime) { glm::mat4 translate = InterpolateTranslate(animationTime); glm::mat4 rotate = InterpolateRotate(animationTime); glm::mat4 scale = InterpolateScale(animationTime); m_localTransform = translate * rotate * scale; } int Bone::GetTranslateIndex(float animationTime) const { for (int idx = 0; idx < m_numTranslates - 1; ++idx) { if (animationTime < m_keyTranslations[idx + 1].timeStamp) return idx; } LOG_ERROR(std::format("[{}]: Invalid animationTime: {}!", __FUNCTION__, animationTime)); __debugbreak(); return -1; } int Bone::GetRotateIndex(float animationTime) const { for (int idx = 0; idx < m_numRotates - 1; ++idx) { if (animationTime < m_keyRotations[idx + 1].timeStamp) return idx; } LOG_ERROR(std::format("[{}]: Invalid animationTime: {}!", __FUNCTION__, animationTime)); __debugbreak(); return -1; } int Bone::GetScaleIndex(float animationTime) const { for (int idx = 0; idx < m_numScales - 1; ++idx) { if (animationTime < m_keyScales[idx + 1].timeStamp) return idx; } LOG_ERROR(std::format("[{}]: Invalid animationTime: {}!", __FUNCTION__, animationTime)); __debugbreak(); return -1; } float Bone::GetScaleFactor(float lastTimeStamp, float nextTimeStamp, float animationTime) const { float scaleFactor = 0.0f; float midWayLength = animationTime - lastTimeStamp; float framesDiff = nextTimeStamp - lastTimeStamp; scaleFactor = midWayLength / framesDiff; return scaleFactor; } glm::mat4 Bone::InterpolateTranslate(float animationTime) { if (m_numTranslates == 1) return glm::translate(glm::mat4(1.0f), m_keyTranslations[0].translate); int p0Idx = GetTranslateIndex(animationTime); int p1Idx = p0Idx + 1; float scaleFactor = GetScaleFactor(m_keyTranslations[p0Idx].timeStamp, m_keyTranslations[p1Idx].timeStamp, animationTime); glm::vec3 finalTranslate = glm::mix(m_keyTranslations[p0Idx].translate, m_keyTranslations[p1Idx].translate, scaleFactor); return glm::translate(glm::mat4(1.0f), finalTranslate); } glm::mat4 Bone::InterpolateRotate(float animationTime) { if (m_numRotates == 1) { auto rotate = glm::normalize(m_keyRotations[0].orientation); return glm::toMat4(rotate); } int p0Idx = GetRotateIndex(animationTime); int p1Idx = p0Idx + 1; float scaleFactor = GetScaleFactor(m_keyRotations[p0Idx].timeStamp, m_keyRotations[p1Idx].timeStamp, animationTime); glm::quat finalRotate = glm::slerp(m_keyRotations[p0Idx].orientation, m_keyRotations[p1Idx].orientation, scaleFactor); finalRotate = glm::normalize(finalRotate); return glm::toMat4(finalRotate); } glm::mat4 Bone::InterpolateScale(float animationTime) { if (m_numRotates == 1) return glm::scale(glm::mat4(1.0f), m_keyScales[0].scale); int p0Idx = GetScaleIndex(animationTime); int p1Idx = p0Idx + 1; float scaleFactor = GetScaleFactor(m_keyScales[p0Idx].timeStamp, m_keyScales[p1Idx].timeStamp, animationTime); glm::vec3 finalScale = glm::mix(m_keyScales[p0Idx].scale, m_keyScales[p1Idx].scale, scaleFactor); return glm::scale(glm::mat4(1.0f), finalScale); }
根据上述代码,首先定义了关键帧中的 3 种 SRT 变换,每种变换的结构体都包含了一个关键帧时间戳和变换信息。然后在 Update()
中根据当前动画播放时间调整该骨骼 / 关节的局部模型变换矩阵。相关插值则使用 Lerp 或 SLerp 实现,其中插值用到的缩放因子 GetScaleFactor()
则根据上一关键帧和下一关键帧的时间戳与当前时间进行求比计算:
Animation 类
// Animation.h // 用于和Assimp库解耦的节点类 struct AssimpNodeData { glm::mat4 transformation; std::string name; int childrenCount; std::vector<AssimpNodeData> children; }; class Animation { public: Animation() = default; Animation(const std::string& animationPath, GLObject* model); Bone* FindBone(const std::string& name); inline float GetTicksPerSecond() const { return m_ticksPerSecond; } inline float GetDuration() const { 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, GLObject& model); void ReadHeirarchyData(AssimpNodeData& dest, const aiNode* src); private: float m_duration; float m_ticksPerSecond; std::unordered_map<std::string, Bone> m_name2Bones; AssimpNodeData m_rootNode; std::map<std::string, BoneInfo> m_boneInfoMap; };
// Animation.cpp Animation::Animation(const std::string& animationPath, GLObject* 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); } Bone* Animation::FindBone(const std::string& name) { if (m_name2Bones.contains(name)) return &m_name2Bones[name]; return nullptr; } void Animation::ReadMissingBones(const aiAnimation* animation, GLObject& model) { int size = animation->mNumChannels; auto& boneInfoMap = model.getBoneInfoMap(); int& boneCount = model.getBoneCount(); for (int i = 0; i < size; ++i) { auto channel = animation->mChannels[i]; std::string boneName = channel->mNodeName.data; if (!boneInfoMap.contains(boneName)) { boneInfoMap[boneName].id = boneCount; boneCount++; } m_name2Bones[boneName] = Bone(channel->mNodeName.data, boneInfoMap[channel->mNodeName.data].id, channel); } m_boneInfoMap = boneInfoMap; } void Animation::ReadHeirarchyData(AssimpNodeData& dest, const aiNode* src) { assert(src); dest.name = src->mName.data; dest.transformation = AssimpGLMHelpers::GetGLMMat4(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); } }
对于 Animation
,它需要动画文件地址和播放此动画的模型类初始化,一个 Animation
包括了一个动画序列信息,例如动画的持续时间 m_duration
和动画的速度 m_ticksPerSecond
。
Ps:这个类可能还要改改,让它集成至模型类中。
Animator 类
// Animator.h class Animator { public: Animator(Animation* animation); void UpdateAnimation(float dt); void PlayAnimation(Animation* pAnimation); void CalculateBoneTransform(const AssimpNodeData* node, glm::mat4 parentTransform); 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.cpp Animator::Animator(Animation* animation) { m_currentTime = 0.0f; m_currentAnimation = animation; m_finalBoneMatrices = std::vector<glm::mat4>(100, 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 globalTransform = parentTransform * nodeTransform; auto boneInfoMap = m_currentAnimation->GetBoneIDMap(); if (boneInfoMap.contains(nodeName)) { int index = boneInfoMap[nodeName].id; glm::mat4 offset = boneInfoMap[nodeName].offset; m_finalBoneMatrices[index] = globalTransform * offset; } for (int i = 0; i < node->childrenCount; ++i) CalculateBoneTransform(&node->children[i], globalTransform); }
Animator
类则会管理动画剪辑序列 Animation
的播放,更新等。
编写 Shader
然后是编写 shader,由于动画变换的是顶点,编写顶点着色器就行了:
#version 330 core layout(location = 0) in vec3 position; layout(location = 1) in vec3 normal; layout(location = 2) in vec2 texCoords; layout(location = 3) in ivec4 boneIds; layout(location = 4) in vec4 weights; uniform mat4 u_MVP; uniform mat4 u_Model; uniform mat3 u_Normal; out vs_objOUT { vec3 Normal; vec3 FragPos; vec2 TexCoords; } vs_objData; const int MAX_BONES = 100; const int MAX_BONE_INFLUENCE = 4; uniform mat4 finalBonesMatrices[MAX_BONES]; void main() { vec4 totalPosition = vec4(0.0f); vec3 totalNormal = vec3(0.0f); for (int i = 0; i < MAX_BONE_INFLUENCE; ++i) { if (boneIds[i] == -1) continue; if (boneIds[i] >= MAX_BONES) { totalPosition = vec4(position, 1.0f); totalNormal = normal; break; } vec4 localPosition = finalBonesMatrices[boneIds[i]] * vec4(position, 1.0f); totalPosition += localPosition * weights[i]; vec3 localNormal = mat3(finalBonesMatrices[boneIds[i]]) * normal; totalNormal += localNormal * weights[i]; } vs_objData.Normal = u_Normal * normalize(totalNormal); vs_objData.FragPos = vec3(u_Model * totalPosition); vs_objData.TexCoords = texCoords; gl_Position = u_MVP * totalPosition; };
对每个顶点按权重添加对应关节的影响,包括位置和法向量。
使用的时候,需要初始化 Animator 和 Animation,然后在更新逻辑帧的时候使用 animator.UpdateAnimation()
,在更新渲染帧的时候更新 shader 的最终关节矩阵,复刻教程的结果如下:
Ps:代码结构需要改改,例如动画序列帧 Animation
和动画控制器 Animator
可以放到角色模型类中。
参考资料
- LearnOpenGL - Skeletal Animation
- 《C++ Game Animation Programming》