一般的模型的加载在LeanOpenGL里已经讲解得比较清楚了,本博文是介绍如何在LeanOpenGL示例Mesh.h
,和Model.h
基础之上扩展,使其支持骨骼动画的播放。
骨骼动画的原理

带有骨骼动画的模型除了有skin(即一系列的网格),还有骨骼aiBone
和动画aiAnimation
。骨骼没有大小和位置,只有一个名字和初始旋转矩阵(决定了骨骼的初始姿态),除此之外,每一个骨骼还存储了它所影响的顶点的ID以及影响的权重;动画aiAnimation
存储了一系列的关键帧和当前动画的持续时间。关键帧存储的是从初始姿态到当前姿态,所有骨骼要经过的旋转平移和缩放。
在骨骼动画播放的时候,首先根据当前时间找到动画的前一个关键帧和后一个关键帧,然后根据到这两个关键帧的时间距离进行线性插值,得到当前关键帧。再将当前关键帧的旋转平移和缩放应用到所有相关的骨骼上,从而改变当前的骨骼姿态。

最后在着色器中,将骨骼当前的姿态,按照权重作用到受其影响的顶点上,从而改变顶点的位置。
骨骼相关数据的加载
扩展Mesh
中的顶点结构体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| #define BONE_INFO_NUM 4
struct Vertex { glm::vec3 Position; glm::vec3 Normal; glm::ivec4 boneID;
glm::vec4 boneWeight;
Vertex() { Position = glm::vec3(0.0f, 0.0f, 0.0f); Normal = glm::vec3(0.0f, 0.0f, 0.0f); boneID = glm::ivec4(-1, 0, 0, 0); boneWeight = glm::vec4(0.0f, 0.0f, 0.0f, 0.0f); }
void AddBoneData(uint BoneID, float Weight) { for (unsigned int i = 0; i < BONE_INFO_NUM; i++) { if (Weight > boneWeight[i]) { for (unsigned int j = BONE_INFO_NUM - 1; j > i; j--) { boneWeight[j] = boneWeight[j - 1]; boneID[j] = boneID[j - 1]; } boneWeight[i] = Weight; boneID[i] = BoneID; break; } } }
void normalizeBoneWeight() { float totalWeight = boneWeight.x + boneWeight.y + boneWeight.z + boneWeight.w; boneWeight.x = boneWeight.x / totalWeight; boneWeight.y = boneWeight.y / totalWeight; boneWeight.z = boneWeight.z / totalWeight; boneWeight.w = boneWeight.w / totalWeight; }
};
|
顶点记录了影响它的骨骼的IDboneID
,和对应的权重boneWeight
,影响顶点的骨骼数量是没有上限的,在这里我们只取影响最大的4个骨骼。这里要注意的是,只取最大的四个,最后他们的权重和并不为1,导致在播放骨骼动画的时候,模型会变形,所以在所有骨骼都处理完毕之后,要对每一个顶点执行normalizeBoneWeight
函数,使它们的权重值和为1。
加载骨骼数据并归一化
在加载完Mesh
的其他数据的时候,遍历Mesh
中的所有骨骼,将骨骼的ID和权重添加到对应的顶点的属性中,这里参照ogldev
教程(参考3)的做法,所有Mesh
的骨骼是存在一起的,这样方便最后一次性将所有骨骼的姿态传入着色器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| for (uint i = 0; i < mesh->mNumBones; i++) { unsigned int BoneIndex = 0; string BoneName(mesh->mBones[i]->mName.data); if (boneMap.find(BoneName) == boneMap.end()) { BoneIndex = numBones; numBones++; Bone tmpBone; tmpBone.boneOffset = mesh->mBones[i]->mOffsetMatrix; tmpBone.name = BoneName; boneMap[BoneName] = BoneIndex; allBones.push_back(tmpBone); } else { BoneIndex = boneMap[BoneName]; } for (uint j = 0; j < mesh->mBones[i]->mNumWeights; j++) { unsigned int vertexID = mesh->mBones[i]->mWeights[j].mVertexId; float weight = mesh->mBones[i]->mWeights[j].mWeight; vertices[vertexID].AddBoneData(BoneIndex, weight); } }
for (auto& vertex : vertices) { vertex.normalizeBoneWeight(); }
|
骨骼动画的渲染
计算当前帧
以下直接直接复用了ogldev
教程的源码
这里设置的动画是循环播放的,所以将当前时间以模型持续时间取模,计算出动画的时间位置。
骨骼是一个树的结构,骨骼树的信息存储在以Scene->mRootNode
为根节点的树中,如果当前node
的mName
不为空,那么它就代表一根骨骼,node
的childNode
就是它的子骨骼。因为父骨骼的姿态要影响到子骨骼的姿态(如大腿骨移动,小腿骨骼也要跟着移动),所以要从根节点开始,一层一层递归地计算。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| void BoneTransform(float TimeInSeconds, vector<Matrix4f>& Transforms) { Matrix4f Identity; Identity.InitIdentity();
float TicksPerSecond = (float)(pScene->mAnimations[0]->mTicksPerSecond != 0 ? pScene->mAnimations[0]->mTicksPerSecond : 25.0f); float TimeInTicks = TimeInSeconds * TicksPerSecond; float AnimationTime = fmod(TimeInTicks, (float)pScene->mAnimations[0]->mDuration);
ReadNodeHeirarchy(AnimationTime, pScene->mRootNode, Identity);
Transforms.resize(numBones);
for (uint i = 0; i < numBones; i++) { Transforms[i] = allBones[i].FinalTransformation; } }
|
根据当前时间找出当前的前一帧和后一帧,再根据时间差线性插值,计算出的平移,旋转和缩放并结合父骨骼的变换作用到当前骨骼上,最后将计算好的当前骨骼的姿态作为父骨骼的变换,递归地处理所有子骨骼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| void ReadNodeHeirarchy(float AnimationTime, const aiNode* pNode, const Matrix4f& ParentTransform) { string NodeName(pNode->mName.data);
const aiAnimation* pAnimation = pScene->mAnimations[0];
Matrix4f NodeTransformation(pNode->mTransformation);
const aiNodeAnim* pNodeAnim = FindNodeAnim(pAnimation, NodeName);
if (pNodeAnim) { aiVector3D Scaling; CalcInterpolatedScaling(Scaling, AnimationTime, pNodeAnim); Matrix4f ScalingM; ScalingM.InitScaleTransform(Scaling.x, Scaling.y, Scaling.z);
aiQuaternion RotationQ; CalcInterpolatedRotation(RotationQ, AnimationTime, pNodeAnim); Matrix4f RotationM = Matrix4f(RotationQ.GetMatrix());
aiVector3D Translation; CalcInterpolatedPosition(Translation, AnimationTime, pNodeAnim); Matrix4f TranslationM; TranslationM.InitTranslationTransform(Translation.x, Translation.y, Translation.z);
NodeTransformation = TranslationM * RotationM * ScalingM; }
Matrix4f GlobalTransformation = ParentTransform * NodeTransformation;
if (boneMap.find(NodeName) != boneMap.end()) { uint BoneIndex = boneMap[NodeName]; allBones[BoneIndex].FinalTransformation = globalInverseTransform * GlobalTransformation * allBones[BoneIndex].boneOffset; } for (uint i = 0; i < pNode->mNumChildren; i++) { ReadNodeHeirarchy(AnimationTime, pNode->mChildren[i], GlobalTransformation); } }
|
将骨骼姿态作用在顶点上
最后将计算好的所有的骨骼姿态传入着色器中:
1 2 3 4 5 6
| BoneTransform(time, Transforms); for (unsigned int i = 0; i < numBones; i++) { sprintf(uniformName, "gBones[%d]", i); GLuint location = glGetUniformLocation(shader.ID, uniformName); glUniformMatrix4fv(location, 1, GL_TRUE, (const GLfloat*)Transforms[i]); }
|
在着色器中,先根据顶点受影响的骨骼ID和权重,计算出当前顶点受骨骼姿态的影响矩阵BoneTransform
,顶点在乘以Model,View,Projection
矩阵前,先乘以BoneTransform
矩阵。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; layout (location = 2) in ivec4 BoneIDs; layout (location = 3) in vec4 Weights;
... uniform mat4 model; uniform mat4 view; uniform mat4 projection;
... const int MAX_BONES = 100; uniform mat4 gBones[MAX_BONES];
void main() { mat4 BoneTransform = mat4(1.0); BoneTransform = gBones[BoneIDs[0]] * Weights[0]; BoneTransform += gBones[BoneIDs[1]] * Weights[1]; BoneTransform += gBones[BoneIDs[2]] * Weights[2]; BoneTransform += gBones[BoneIDs[3]] * Weights[3]; vs_out.FragPos = vec3(model * BoneTransform * vec4(aPos, 1.0)); ... gl_Position = projection * view * vec4(vs_out.FragPos, 1.0); }
|
Code
扩展后完整的Mesh和Model
参考