2-Assimp读取动画

在看完GAMES104动画系统那一部分后,我也想写一个简单的动画系统,又要开一个坑了!本文将简要介绍如何用Assimp库去读取动画信息。

用Assimp读取动画信息

在之前我们使用Assimp库读取了模型的几何和纹理信息,实际上还能读取模型的动画信息。

获取动画剪辑Clip信息

下图展示了Assimp库是如何存储动画信息的:

aiScene中,存储了一个aiAnimation数组Animations[]。单个aiAnimation可看作一个动画剪辑(Animation Clip),它包含动画的名称,持续时间,帧率,关键帧等信息。其中关键帧信息存放在aiNodeAnim数组Channels[]中,单个aiNodeAnim包含当前关键帧的位移、旋转、缩放等数据。

获取多关节影响的权重

此外,由于是多关节绑定的蒙皮动画,单个顶点可能受到多个关节的影响,这里我们假定单个顶点最多受4个关节影响。来看看Assimp是如何存储关节对顶点影响的权重。

aiMesh中有一个aiBone数组。aiBoneaiVertexWeight[]则包含了当前关节对一些顶点的影响权重。还有一个叫做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》