OpenGL 骨骼动画
在实现了模型加载之后我们还希望这个模型可以动起来如果我们直接修改模型矩阵我们可以让整个模型整体移动但是我们仍希望实现更为精细的控制比如模型移动时我们希望人物的脚也动起来这就需要用到骨骼动画了。骨骼动画的本质其实还是矩阵变换但是我们需要对不同顶点应用不同的变换矩阵。首先我们先把模型加载出来这里 是我们之前用来加载背包的代码我们对它进行一些修改以加载人物模型。人物模型可以在 这里 下载。ModelourModel(resource/models/vampire/dancing_vampire.dae);去掉加载多个模型的部分改为加载 dancing_vampire.dae。Cameracamera(glm::vec3(0.0f,100.0f,350.0f),glm::vec3(0.0f,100.0f,0.0f),glm::vec3(0.0f,1.0f,0.0f));调整相机位置使得模型显示在窗口中心。#version330corein 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;uniformfloatshininess;uniform vec3 lightPos;uniform vec3 viewPos;out vec4 FragColor;voidmain(){FragColortexture(texture_diffuse1,TexCoord);}简化一下片段着色器。glClearColor(0.5f,0.5f,0.5f,1.0f);设置清屏颜色为灰色。//stbi_set_flip_vertically_on_load(true);注释掉纹理翻转代码可能是模型规范不一样有的纹理方向向上有的向下。最后我们看到的效果应该是这样的。编写着色器代码为了方便理解我们从着色器倒推我们需要的数据。#version330corelayout(location0)in vec3 aPos;layout(location1)in vec2 aTexCoord;layout(location2)in vec3 aNormal;layout(location3)in ivec4 boneIds;layout(location4)in vec4 weights;uniform mat4 model;uniform mat4 invModel;uniform mat4 view;uniform mat4 projection;// 最大骨骼数量constintMAX_BONES100;// 每个顶点最后受到 4 个骨骼影响constintMAX_BONE_INFLUENCE4;// 所有骨骼的变换矩阵uniform mat4 finalBonesMatrices[MAX_BONES];out vec2 TexCoord;out vec3 Normal;out vec3 FragPos;voidmain(){vec4 finalPositionvec4(0.0);for(inti0;iMAX_BONE_INFLUENCE;i){if(boneIds[i]-1){break;}if(boneIds[i]MAX_BONES){finalPositionvec4(aPos,1.0);break;}intboneIndexboneIds[i];finalPositionfinalBonesMatrices[boneIndex]*vec4(aPos,1.0)*weights[i];}gl_Positionprojection*view*model*finalPosition;TexCoordaTexCoord;Normalmat3(transpose(invModel))*aNormal;FragPosvec3(model*vec4(aPos,1.0));}顶点坐标增加了两个属性boneIds 表示的是影响这个顶点的骨骼对应在 finalBonesMatrices 的索引weight 表示这个骨骼对这个顶点影响的权重。而 finalBonesMatrices 表示的则是每块骨骼对应的变换矩阵不同骨骼变换矩阵应用到这个顶点上再根据权重累加就得到了最终的顶点坐标。读取顶点的骨骼索引和权重骨骼的数据存储在 aiMesh 的 mBones 数组里。for(unsignedinti0;imesh-mNumVertices;i){Vertex vertex;vertex.positionglm::vec3(mesh-mVertices[i].x,mesh-mVertices[i].y,mesh-mVertices[i].z);if(mesh-mNormals){vertex.normalglm::vec3(mesh-mNormals[i].x,mesh-mNormals[i].y,mesh-mNormals[i].z);}unsignedintnumUVChannelsmesh-GetNumUVChannels();if(mesh-mTextureCoords[0]){vertex.texCoordsglm::vec2(mesh-mTextureCoords[0][i].x,mesh-mTextureCoords[0][i].y);}for(inti0;iMAX_BONE_INFLUENCE;i){vertex.boneIDs[i]-1;vertex.weights[i]0.0f;}vertices.push_back(vertex);}先初始化一下索引和权重for(intboneIndex0;boneIndexmesh-mNumBones;boneIndex){intboneID-1;std::string boneNamemesh-mBones[boneIndex]-mName.C_Str();if(m_boneOffsetMap.find(boneName)m_boneOffsetMap.end()){BoneOffset boneOffset;boneOffset.idm_boneIndex;boneOffset.offsetMatrixAssimpGLMHelpers::ConvertMatrixToGLMFormat(mesh-mBones[boneIndex]-mOffsetMatrix);m_boneOffsetMap[boneName]boneOffset;boneIDm_boneIndex;m_boneIndex;}else{boneIDm_boneOffsetMap[boneName].id;}autoweightsmesh-mBones[boneIndex]-mWeights;intnumWeightsmesh-mBones[boneIndex]-mNumWeights;for(intweightIndex0;weightIndexnumWeights;weightIndex){intvertexIdweights[weightIndex].mVertexId;floatweightweights[weightIndex].mWeight;assert(vertexIdvertices.size());// 找到第一个空位设置权重和骨骼IDfor(inti0;iMAX_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 节点然后递归地计算它的子节点的变换矩阵。构建模型节点树structAssimpNodeData{glm::mat4 transformation;std::string name;intchildrenCount;std::vectorAssimpNodeData*children;};定义一个结构体用于存储模型节点信息。修改 model 类中processNode函数返回一个AssimpNodeData*指针。然后构建AssimpNodeData节点。AssimpNodeData*Model::processNode(aiNode*node,constaiScene*scene){// 处理当前节点的所有网格for(unsignedinti0;inode-mNumMeshes;i){aiMesh*meshscene-mMeshes[node-mMeshes[i]];m_meshes.push_back(processMesh(mesh,scene));}AssimpNodeData*nodeDatanewAssimpNodeData;nodeData-namenode-mName.data;nodeData-transformationAssimpGLMHelpers::ConvertMatrixToGLMFormat(node-mTransformation);nodeData-childrenCountnode-mNumChildren;if(nodescene-mRootNode){nodeData-transformationglm::mat4(1.0f);}// 递归处理所有子节点for(unsignedinti0;inode-mNumChildren;i){nodeData-children.emplace_back(processNode(node-mChildren[i],scene));}returnnodeData;}增加一个AssimpNodeData* m_root nullptr;成员变量用于存储根节点已经一个获取根节点的接口AssimpNodeData* GetRootNode() { return m_root; }if(nodescene-mRootNode){nodeData-transformationglm::mat4(1.0f);}这段代码可能看着比较奇怪这是因为我加载完动画后发现模型的大小不对我需要把视角移动到很近的位置才能看到模型最终发现是这里对模型进行了缩放所以我重置了根节点的transformation。加载动画在进行下一步之前我们先来看下 Bone 类的几个 public 函数Bone 类用于计算当前骨骼的变换矩阵它根据时间戳更新 transform 矩阵GetLocalTransform返回当前的变换矩阵。classBone{public:Bone()default;Bone(conststd::stringname,constaiNodeAnim*channel);// 根据时间戳更新变换矩阵voidUpdate(floatanimationTime);glm::mat4GetLocalTransform(){returnm_localTransform;}std::stringGetBoneName()const{returnm_name;}};Bone 类的具体实现我们稍后再介绍现在让我们先来看下 Animator 类Animator 用于计算更新最终的m_finalBoneMatrices矩阵。classAnimator{public:Animator(conststd::stringanimationPath,Model*model);voidUpdateAnimation(floatdt);std::vectorglm::mat4GetFinalBoneMatrices(){returnm_finalBoneMatrices;}private:voidCalculateBoneTransform(constAssimpNodeData*node,glm::mat4 parentTransform);private:std::vectorglm::mat4m_finalBoneMatrices;floatm_currentTick;floatm_totalTicks;intm_ticksPerSecond;std::mapstd::string,Bonem_bones;Model*m_model;};m_finalBoneMatrices用于存储所有骨骼的变换矩阵m_bones用于计算某块骨骼某个时刻的变换矩阵局部model用于获取模型节点树。值得注意的是这里动画的时间单位是刻度tick)m_ticksPerSecond表示每秒多少个tickm_totalTicks表示总刻度数m_currentTick表示当前的刻度。UpdateAnimation根据当前时间计算刻度然后调用CalculateBoneTransform计算骨骼变换矩阵。voidAnimator::UpdateAnimation(floatdt){m_currentTickm_ticksPerSecond*dt;m_currentTickfmod(m_currentTick,m_totalTicks);CalculateBoneTransform(m_model-GetRootNode(),glm::mat4(1.0f));}然后更新骨骼变换矩阵voidAnimator::CalculateBoneTransform(constAssimpNodeData*node,glm::mat4 parentTransform){std::string nodeNamenode-name;glm::mat4 nodeTransformnode-transformation;if(m_bones.find(nodeName)!m_bones.end()){Bone bonem_bones[nodeName];bone.Update(m_currentTick);nodeTransformbone.GetLocalTransform();}autoboneOffsetm_model-GetBoneOffsetMap();if(boneOffset.find(nodeName)!boneOffset.end()){intindexboneOffset[nodeName].id;glm::mat4 offsetboneOffset[nodeName].offsetMatrix;m_finalBoneMatrices[index]parentTransform*nodeTransform*offset;}for(inti0;inode-childrenCount;i){CalculateBoneTransform(node-children[i],parentTransform*nodeTransform);}}offset用于把顶点坐标转换到骨骼空间nodeTranform则用于把顶点坐标转换到父节点所在的空间parentTransform用于把父节点转换到模型空间。我们可以发现我们是直接用骨骼的变换矩阵替换nodeTranform而不是把变换矩阵应用到顶点再转换到父节点空间这是因为骨骼动画数据通常已包含相对于父骨骼的完整变换。完善我们的 Bone 类structKeyPosition{glm::vec3 position;floattimeStamp;};structKeyRotation{glm::quat orientation;floattimeStamp;};structKeyScale{glm::vec3 scale;floattimeStamp;};std::vectorKeyPositionm_positions;std::vectorKeyRotationm_rotations;std::vectorKeyScalem_scales;我们需要三个结构体用于存储从动画中读取到的平移旋转和缩放信息当时间变化时我们只需要根据时间戳找到对应的平移旋转和缩放信息然后构建变换矩阵即可。实际操作比这个稍微复杂一点但总体流程就是这样的。m_positionNumchannel-mNumPositionKeys;for(intpositionIndex0;positionIndexm_positionNum;positionIndex){aiVector3D aiPositionchannel-mPositionKeys[positionIndex].mValue;floattimeStampchannel-mPositionKeys[positionIndex].mTime;KeyPosition data;data.positionAssimpGLMHelpers::GetGLMVec(aiPosition);data.timeStamptimeStamp;m_positions.emplace_back(data);}m_rotationNumchannel-mNumRotationKeys;for(introtationIndex0;rotationIndexm_rotationNum;rotationIndex){aiQuaternion aiOrientationchannel-mRotationKeys[rotationIndex].mValue;floattimeStampchannel-mRotationKeys[rotationIndex].mTime;KeyRotation data;data.orientationAssimpGLMHelpers::GetGLMQuat(aiOrientation);data.timeStamptimeStamp;m_rotations.emplace_back(data);}m_scaleNumchannel-mNumScalingKeys;for(intkeyIndex0;keyIndexm_scaleNum;keyIndex){aiVector3D scalechannel-mScalingKeys[keyIndex].mValue;floattimeStampchannel-mScalingKeys[keyIndex].mTime;KeyScale data;data.scaleAssimpGLMHelpers::GetGLMVec(scale);data.timeStamptimeStamp;m_scales.emplace_back(data);}从aiNodeAnim中读取这些数据存储在我们的成员变量中。voidBone::Update(floatanimationTime){glm::mat4 translationInterpolatePosition(animationTime);glm::mat4 rotationInterpolateRotation(animationTime);glm::mat4 scaleInterpolateScale(animationTime);m_localTransformtranslation*rotation*scale;}Update函数用于更新变换矩阵Interpolate*则是用于创建平移旋转和缩放矩阵。由于动画里不会包含每个时刻的变换信息所以我们需要找到离当前时刻最近的两个关键帧然后进行插值幸运的是glm 已经帮我们实现了插值函数我们只需要传入两个关键帧的数据以及比例因子即可生成插值后的结果。floatBone::GetScaleFactor(floatlastTimeStamp,floatnextTimeStamp,floatanimationTime){floatscaleFactor0.0f;floatmidWayLengthanimationTime-lastTimeStamp;floatframesDiffnextTimeStamp-lastTimeStamp;scaleFactormidWayLength/framesDiff;returnscaleFactor;}GetScaleFactor用于计算当前时间在两个关键帧之间的比例因子。intBone::GetPositionIndex(floatanimationTime){for(intindex0;indexm_positionNum-1;index){if(animationTimem_positions[index].timeStampanimationTimem_positions[index1].timeStamp){returnindex;}}return-1;}GetPositionIndex用于查找当前时间在哪两个关键帧之间返回较小关键帧的索引值。GetRotationIndex和GetScaleIndex同理。glm::mat4Bone::InterpolatePosition(floatanimationTime){if(m_positionNum1){returnglm::translate(glm::mat4(1.0f),m_positions[0].position);}// 根据两个关键帧的平移量计算插值intp0IndexGetPositionIndex(animationTime);if(p0Index-1){returnglm::mat4(1.0f);}intp1Indexp0Index1;floatscaleFactorGetScaleFactor(m_positions[p0Index].timeStamp,m_positions[p1Index].timeStamp,animationTime);glm::vec3 finalPositionglm::mix(m_positions[p0Index].position,m_positions[p1Index].position,scaleFactor);returnglm::translate(glm::mat4(1.0f),finalPosition);}最后生成对应的变换矩阵。glm::quat finalRotationglm::slerp(m_rotations[p0Index].orientation,m_rotations[p1Index].orientation,scaleFactor);旋转四元数用于描述旋转的数学量的插值使用glm::slerp实现其他部分也都是类似的。这里 是完整的代码实现代码在 learnopengl 的基础上做了一些修改。