在实现了模型加载之后,我们还希望这个模型可以动起来,如果我们直接修改模型矩阵,我们可以让整个模型整体移动,但是我们仍希望实现更为精细的控制,比如模型移动时,我们希望人物的脚也动起来,这就需要用到骨骼动画了。骨骼动画的本质其实还是矩阵变换,但是我们需要对不同顶点应用不同的变换矩阵。
首先我们先把模型加载出来
这里 是我们之前用来加载背包的代码,我们对它进行一些修改,以加载人物模型。人物模型可以在 这里 下载。
cpp
Model ourModel("resource/models/vampire/dancing_vampire.dae");
去掉加载多个模型的部分,改为加载 dancing_vampire.dae。
cpp
Camera camera(glm::vec3(0.0f, 100.0f, 350.0f), glm::vec3(0.0f, 100.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
调整相机位置,使得模型显示在窗口中心。
cpp
#version 330 core
in vec2 TexCoord;
in vec3 Normal;
in vec3 FragPos;
uniform sampler2D texture_diffuse1;
uniform sampler2D texture_specular1;
uniform vec3 ambient;
uniform vec3 diffuse;
uniform vec3 specular;
uniform float shininess;
uniform vec3 lightPos;
uniform vec3 viewPos;
out vec4 FragColor;
void main()
{
FragColor = texture(texture_diffuse1, TexCoord);
}
简化一下片段着色器。
cpp
glClearColor(0.5f, 0.5f, 0.5f, 1.0f);
设置清屏颜色为灰色。
cpp
//stbi_set_flip_vertically_on_load(true);
注释掉纹理翻转代码,可能是模型规范不一样,有的纹理方向向上,有的向下。

最后,我们看到的效果应该是这样的。
编写着色器代码
为了方便理解,我们从着色器倒推我们需要的数据。
cpp
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;
layout (location = 2) in vec3 aNormal;
layout (location = 3) in ivec4 boneIds;
layout (location = 4) in vec4 weights;
uniform mat4 model;
uniform mat4 invModel;
uniform mat4 view;
uniform mat4 projection;
// 最大骨骼数量
const int MAX_BONES = 100;
// 每个顶点最后受到 4 个骨骼影响
const int MAX_BONE_INFLUENCE = 4;
// 所有骨骼的变换矩阵
uniform mat4 finalBonesMatrices[MAX_BONES];
out vec2 TexCoord;
out vec3 Normal;
out vec3 FragPos;
void main()
{
vec4 finalPosition = vec4(0.0);
for (int i = 0; i < MAX_BONE_INFLUENCE; i++) {
if (boneIds[i] == -1) {
break;
}
if (boneIds[i] >= MAX_BONES) {
finalPosition = vec4(aPos, 1.0);
break;
}
int boneIndex = boneIds[i];
finalPosition += finalBonesMatrices[boneIndex] * vec4(aPos, 1.0) * weights[i];
}
gl_Position = projection * view * model * finalPosition;
TexCoord = aTexCoord;
Normal = mat3(transpose(invModel)) * aNormal;
FragPos = vec3(model * vec4(aPos, 1.0));
}
顶点坐标增加了两个属性,boneIds 表示的是影响这个顶点的骨骼对应在 finalBonesMatrices 的索引,weight 表示这个骨骼对这个顶点影响的权重。而 finalBonesMatrices 表示的则是每块骨骼对应的变换矩阵,不同骨骼变换矩阵应用到这个顶点上,再根据权重累加就得到了最终的顶点坐标。
读取顶点的骨骼索引和权重

骨骼的数据存储在 aiMesh 的 mBones 数组里。
cpp
for (unsigned int i = 0; i < mesh->mNumVertices; i++) {
Vertex vertex;
vertex.position = glm::vec3(mesh->mVertices[i].x, mesh->mVertices[i].y, mesh->mVertices[i].z);
if (mesh->mNormals) {
vertex.normal = glm::vec3(mesh->mNormals[i].x, mesh->mNormals[i].y, mesh->mNormals[i].z);
}
unsigned int numUVChannels = mesh->GetNumUVChannels();
if (mesh->mTextureCoords[0]) {
vertex.texCoords = glm::vec2(mesh->mTextureCoords[0][i].x, mesh->mTextureCoords[0][i].y);
}
for (int i = 0; i < MAX_BONE_INFLUENCE; i++) {
vertex.boneIDs[i] = -1;
vertex.weights[i] = 0.0f;
}
vertices.push_back(vertex);
}
先初始化一下索引和权重
cpp
for (int boneIndex = 0; boneIndex < mesh->mNumBones; ++boneIndex) {
int boneID = -1;
std::string boneName = mesh->mBones[boneIndex]->mName.C_Str();
if (m_boneOffsetMap.find(boneName) == m_boneOffsetMap.end()) {
BoneOffset boneOffset;
boneOffset.id = m_boneIndex;
boneOffset.offsetMatrix = AssimpGLMHelpers::ConvertMatrixToGLMFormat(
mesh->mBones[boneIndex]->mOffsetMatrix);
m_boneOffsetMap[boneName] = boneOffset;
boneID = m_boneIndex;
m_boneIndex++;
} else {
boneID = m_boneOffsetMap[boneName].id;
}
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());
// 找到第一个空位,设置权重和骨骼ID
for (int i = 0; i < MAX_BONE_INFLUENCE; ++i) {
if (vertices[vertexId].boneIDs[i] < 0) {
vertices[vertexId].weights[i] = weight;
vertices[vertexId].boneIDs[i] = boneID;
break;
}
}
}
}
然后遍历每个网格的骨骼信息,使用 m_boneIndex 进行编号,然后把骨骼的编号和 offset 矩阵存储在 m_boneOffsetMap 中。offset 矩阵用于将顶点坐标从模型空间转换到骨骼空间,我们在文章开头就提到了,骨骼动画的本质其实还是矩阵变换,骨骼动画的变换矩阵是顶点相对于骨骼的相对坐标的变换矩阵,因此需要把骨骼转换到骨骼空间,当然,最终我们还是要把顶点转换到模型空间的,我们可以看下这一小节开头的类图 aiNode 这个数据结构,aiNode 有个成员 mTransformation,这个矩阵用于把节点的坐标转换到父节点的空间,比如一个人物模型,由躯干,四肢,头组成,躯干为父节点,四肢为躯干的子节点,当我们把手部顶点的坐标转换到手这一空间时,我们可以通过 mTransformation 把顶点坐标转到躯干的空间,再通过躯干的 mTransformation 把顶点转到人物模型空间。
这样我们就得到了骨骼变换矩阵的计算公式 m_finalBoneMatrices[index] = parentTransform * nodeTransform * offset; 所以当计算骨骼变换矩阵时,我们需要获取第一个 aiNode 节点,然后递归地计算它的子节点的变换矩阵。
构建模型节点树
cpp
struct AssimpNodeData
{
glm::mat4 transformation;
std::string name;
int childrenCount;
std::vector<AssimpNodeData*> children;
};
定义一个结构体,用于存储模型节点信息。
修改 model 类中 processNode 函数,返回一个 AssimpNodeData* 指针。然后构建 AssimpNodeData 节点。
cpp
AssimpNodeData* Model::processNode(aiNode* node, const aiScene* scene)
{
// 处理当前节点的所有网格
for (unsigned int i = 0; i < node->mNumMeshes; i++) {
aiMesh* mesh = scene->mMeshes[node->mMeshes[i]];
m_meshes.push_back(processMesh(mesh, scene));
}
AssimpNodeData* nodeData = new AssimpNodeData;
nodeData->name = node->mName.data;
nodeData->transformation = AssimpGLMHelpers::ConvertMatrixToGLMFormat(node->mTransformation);
nodeData->childrenCount = node->mNumChildren;
if (node == scene->mRootNode) {
nodeData->transformation = glm::mat4(1.0f);
}
// 递归处理所有子节点
for (unsigned int i = 0; i < node->mNumChildren; i++) {
nodeData->children.emplace_back(processNode(node->mChildren[i], scene));
}
return nodeData;
}
增加一个 AssimpNodeData* m_root = nullptr; 成员变量,用于存储根节点,已经一个获取根节点的接口 AssimpNodeData* GetRootNode() { return m_root; }
cpp
if (node == scene->mRootNode) {
nodeData->transformation = glm::mat4(1.0f);
}
这段代码可能看着比较奇怪,这是因为我加载完动画后,发现模型的大小不对,我需要把视角移动到很近的位置才能看到模型,最终发现是这里对模型进行了缩放,所以我重置了根节点的 transformation 。
加载动画
在进行下一步之前,我们先来看下 Bone 类的几个 public 函数,Bone 类用于计算当前骨骼的变换矩阵,它根据时间戳更新 transform 矩阵,GetLocalTransform 返回当前的变换矩阵。
cpp
class Bone
{
public:
Bone() = default;
Bone(const std::string& name, const aiNodeAnim* channel);
// 根据时间戳更新变换矩阵
void Update(float animationTime);
glm::mat4 GetLocalTransform() { return m_localTransform; }
std::string GetBoneName() const { return m_name; }
};
Bone 类的具体实现我们稍后再介绍,现在让我们先来看下 Animator 类,Animator 用于计算,更新最终的 m_finalBoneMatrices 矩阵。
cpp
class Animator
{
public:
Animator(const std::string& animationPath, Model* model);
void UpdateAnimation(float dt);
std::vector<glm::mat4> GetFinalBoneMatrices() {
return m_finalBoneMatrices;
}
private:
void CalculateBoneTransform(const AssimpNodeData* node, glm::mat4 parentTransform);
private:
std::vector<glm::mat4> m_finalBoneMatrices;
float m_currentTick;
float m_totalTicks;
int m_ticksPerSecond;
std::map<std::string, Bone> m_bones;
Model* m_model;
};
m_finalBoneMatrices 用于存储所有骨骼的变换矩阵,m_bones 用于计算某块骨骼某个时刻的变换矩阵(局部),model 用于获取模型节点树。值得注意的是这里动画的时间单位是刻度(tick),m_ticksPerSecond 表示每秒多少个 tick,m_totalTicks 表示总刻度数, m_currentTick 表示当前的刻度。
UpdateAnimation 根据当前时间计算刻度,然后调用 CalculateBoneTransform 计算骨骼变换矩阵。
cpp
void Animator::UpdateAnimation(float dt)
{
m_currentTick += m_ticksPerSecond * dt;
m_currentTick = fmod(m_currentTick, m_totalTicks);
CalculateBoneTransform(m_model->GetRootNode(), glm::mat4(1.0f));
}
然后更新骨骼变换矩阵
cpp
void Animator::CalculateBoneTransform(const AssimpNodeData* node, glm::mat4 parentTransform)
{
std::string nodeName = node->name;
glm::mat4 nodeTransform = node->transformation;
if (m_bones.find(nodeName) != m_bones.end()) {
Bone bone = m_bones[nodeName];
bone.Update(m_currentTick);
nodeTransform = bone.GetLocalTransform();
}
auto boneOffset = m_model->GetBoneOffsetMap();
if (boneOffset.find(nodeName) != boneOffset.end()) {
int index = boneOffset[nodeName].id;
glm::mat4 offset = boneOffset[nodeName].offsetMatrix;
m_finalBoneMatrices[index] = parentTransform * nodeTransform * offset;
}
for (int i = 0; i < node->childrenCount; i++) {
CalculateBoneTransform(node->children[i], parentTransform * nodeTransform);
}
}
offset 用于把顶点坐标转换到骨骼空间,nodeTranform 则用于把顶点坐标转换到父节点所在的空间,parentTransform 用于把父节点转换到模型空间。
我们可以发现,我们是直接用骨骼的变换矩阵替换 nodeTranform 而不是把变换矩阵应用到顶点,再转换到父节点空间,这是因为骨骼动画数据通常已包含相对于父骨骼的完整变换。
完善我们的 Bone 类
cpp
struct KeyPosition
{
glm::vec3 position;
float timeStamp;
};
struct KeyRotation
{
glm::quat orientation;
float timeStamp;
};
struct KeyScale
{
glm::vec3 scale;
float timeStamp;
};
cpp
std::vector<KeyPosition> m_positions;
std::vector<KeyRotation> m_rotations;
std::vector<KeyScale> m_scales;
我们需要三个结构体,用于存储从动画中读取到的平移,旋转和缩放信息,当时间变化时,我们只需要根据时间戳找到对应的平移,旋转和缩放信息,然后构建变换矩阵即可。实际操作比这个稍微复杂一点,但总体流程就是这样的。
cpp
m_positionNum = channel->mNumPositionKeys;
for (int positionIndex = 0; positionIndex < m_positionNum; ++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.emplace_back(data);
}
m_rotationNum = channel->mNumRotationKeys;
for (int rotationIndex = 0; rotationIndex < m_rotationNum; ++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.emplace_back(data);
}
m_scaleNum = channel->mNumScalingKeys;
for (int keyIndex = 0; keyIndex < m_scaleNum; ++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.emplace_back(data);
}
从 aiNodeAnim 中读取这些数据,存储在我们的成员变量中。
cpp
void Bone::Update(float animationTime) {
glm::mat4 translation = InterpolatePosition(animationTime);
glm::mat4 rotation = InterpolateRotation(animationTime);
glm::mat4 scale = InterpolateScale(animationTime);
m_localTransform = translation * rotation * scale;
}
Update 函数用于更新变换矩阵,Interpolate* 则是用于创建平移,旋转和缩放矩阵。由于动画里不会包含每个时刻的变换信息,所以我们需要找到离当前时刻最近的两个关键帧,然后进行插值,幸运的是,glm 已经帮我们实现了插值函数,我们只需要传入两个关键帧的数据以及比例因子,即可生成插值后的结果。
cpp
float Bone::GetScaleFactor(float lastTimeStamp, float nextTimeStamp, float animationTime)
{
float scaleFactor = 0.0f;
float midWayLength = animationTime - lastTimeStamp;
float framesDiff = nextTimeStamp - lastTimeStamp;
scaleFactor = midWayLength / framesDiff;
return scaleFactor;
}
GetScaleFactor 用于计算当前时间在两个关键帧之间的比例因子。
cpp
int Bone::GetPositionIndex(float animationTime)
{
for (int index = 0; index < m_positionNum - 1; ++index) {
if (animationTime >= m_positions[index].timeStamp && animationTime < m_positions[index + 1].timeStamp) {
return index;
}
}
return -1;
}
GetPositionIndex 用于查找当前时间在哪两个关键帧之间,返回较小关键帧的索引值。GetRotationIndex 和 GetScaleIndex 同理。
cpp
glm::mat4 Bone::InterpolatePosition(float animationTime)
{
if (m_positionNum == 1) {
return glm::translate(glm::mat4(1.0f), m_positions[0].position);
}
// 根据两个关键帧的平移量,计算插值
int p0Index = GetPositionIndex(animationTime);
if (p0Index == -1) {
return glm::mat4(1.0f);
}
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);
}
最后生成对应的变换矩阵。
cpp
glm::quat finalRotation = glm::slerp(m_rotations[p0Index].orientation,m_rotations[p1Index].orientation, scaleFactor);
旋转四元数(用于描述旋转的数学量)的插值使用 glm::slerp 实现,其他部分也都是类似的。
这里 是完整的代码实现,代码在 learnopengl 的基础上做了一些修改。