最近在自己的练习项目中加入了骨骼动画系统。本篇文章主要讨论骨骼动画的基本原理,以及动画的导入和绘制。
概念引入
对于网格体而言有不少实现动画的方式。直接对顶点进行操作也就是顶点动画,适用于一些比较简单的植物摆动、水面波动效果。此外,还有在两个网格之间进行插值的morphing动画;但它们本质上都是对顶点进行操作。
而在某些情况下,我们认为某些区域的顶点具有关联性,并且希望能够对其整体进行控制,比如整体移动腿部的顶点。为此,我们引入了骨骼动画系统。我们定义某个顶点可由一个或多个骨骼控制,每个骨骼对顶点的贡献有着不同的权重,对于同一个顶点而言,所有对其产生影响的骨骼的权重加起来应该为1。这样,我们就可以通过修改骨骼的平移、旋转、缩放数据,来控制顶点的变换。
对于骨骼而言,不同的骨骼存在父子关系。比如,当我们旋转手臂的时候,也会带动手部和手指的旋转。这些骨骼的父子关系之间形成了一棵骨骼“树”结构。
常用变换
骨骼动画中存在着大量坐标空间和坐标变换,搞懂这些空间或变换是理解骨骼动画最核心的部分。
在此之前,我们应该对坐标空间/姿态和坐标变换这两个概念有一定的了解。为了更方便描述这两个概念,我们可以用相机空间来举例。我们的相机位于空间中的某一点,我们认为它此时平移、旋转、缩放对应的矩阵就是相机空间,或者说相机的姿态矩阵。如名字所见,这反映的是相机当前的状态。
那么,如果我们希望将点/向量变换到相机空间呢?我们需要用到的变换矩阵和相机空间矩阵又是什么关系呢?此处,实际上可以得到一个一般性的结论:
● 如果希望将物体从世界空间变换到A空间,变换矩阵为A空间矩阵的逆矩阵;反之,从A空间变换到世界空间,变换矩阵即为A空间矩阵。
我们所谓的变换到某个空间,也就是找到当前物体在某个空间下的坐标表达。对于相机空间而言,我们以相机原点为例,它在相机空间的坐标应为(0,0,0),这意味如果我们想要从世界空间的相机位置通过转换得到(0,0,0)这一结果,需要乘以相机姿态矩阵的逆矩阵,此时两个变换就刚好抵消得到0的结果。
对以上几个概念有了基本的了解后,我们开始引入骨骼变换中几个常见的空间/变换:绑定姿态、骨骼空间、offset变换矩阵、局部变换矩阵、全局变换矩阵、蒙皮变换矩阵等。
由于本文的重点是实现骨骼动画的渲染,因此,在此仅对相关的矩阵进行介绍。
BindPose
绑定姿态(bindPose) 也就是我们常说的T-Pose,这通常是由美术在建模软件中设定的,它定义了骨架的一种默认姿态,绑定矩阵存储了在这一姿态下,所有骨骼的变换数据。我们所有的骨骼动画变换都是在这一绑定姿态的基础上进行的。对于不同体型的角色而言,它们的高矮胖瘦是不同的,因而它们的绑定姿态也是不一样的。
Offset Matrix
根据上面的一般性结论,我们知道如果我们希望将物体从世界空间转换到绑定空间,我们需要乘以绑定姿态的逆矩阵。我们把这一矩阵称为Offset矩阵。这个矩阵在后面的计算会派上用场。
GlobalTransform & LocalTransform
还有两个比较重要的变换,一个是全局变换矩阵,一个是局部变换矩阵。它们反映了骨骼动画的每帧的变换。其中全局变换矩阵是是关节从根处变换到它最终所在位置所对应的变换矩阵,它通常用于骨骼动画渲染中,计算每个顶点的最终位置的时候;而拒不变换矩阵是指关节在父关节空间下的变换矩阵,它通常用于我们希望编辑某一关节的变换的时候,比如做出让头部抬起来这样的动作。
在最简单的情况下,已知一个关节的局部空间变换时,连乘它的所有父骨骼的局部空间变换矩阵,就能得到该关节的全局变换矩阵。某些格式实际上还可能有别的一些特别定义的矩阵,在实际实现的时候需要特别注意。
Mesh Transform
实际上确定每个顶点最终位置的是蒙皮变换矩阵。对于每个处于绑定姿态的顶点而言,它通过蒙皮矩阵变换得到最终位置。它和全局变换矩阵(GlobalTransform)的区别在于,全局变换矩阵是将顶点从root处变换到最终位置,而蒙皮矩阵是将顶点从绑定姿态变换到最终位置。因此,我们需要先将顶点变换到绑定空间(通过乘以offset矩阵),再变换到最终位置。
因此,蒙皮矩阵 = OffsetMatrix * GlobalTransform
此外,我们还需要考虑到一个顶点可能受到多个骨骼不同权重的影响。此处需要我们对所有蒙皮矩阵进行加权平均,得到最终的变换矩阵。
骨骼动画导入和绘制
我们可以把骨骼动画的加载分为三个部分。
第一部分,导入标准骨骼数据。对于不同的角色,如人和动物,我们有着不同的骨架,在这个过程中,我们导入多套骨架,包含每个骨架的名字,以及它对应的所有骨骼的名字,按顺序存储。我们得到了每个骨骼和其对应的索引下标,之后在连续地址存储骨骼矩阵的时候,我们将按此顺序存储(but,我贴出的代码只存了一份骨架)。
需要导入的主要为骨骼名字和对应的绑定姿态,索引id可在导入的过程中生成。
此处我们可以借助于assimp库,不过在此之前,需要对模型加载有一定了解(参考https://blog.csdn.net/ZJU_fish1996/article/details/90143844)。在获得了aiMesh的基础上,我们可以继续提取骨骼数据,同时存储offset矩阵:
struct Bone
{string m_name;QMatrix4x4 m_invBindPose;Bone(const string& name, const QMatrix4x4& pose):m_name(name), m_invBindPose(pose) { }
};
void GeometryEngine::processBone(const aiMesh* pMesh, vector<BoneVertexData>& vertices, bool bAdd)
{for (uint i = 0 ; i < pMesh->mNumBones ; i++){string BoneName(pMesh->mBones[i]->mName.data);QMatrix4x4 dst;TransformMatrix(pMesh->mBones[i]->mOffsetMatrix, dst);CAnimationEngine::Inst()->AddBone(new Bone(BoneName, dst));}
}
第二部分是导入带绑定骨骼的模型,但不包含动画。此时我们不仅获取每个点的法线、位置等信息,还存储了影响每个顶点的骨骼id和对应的权重,以生成带骨骼模型的vao/vbo相关数据。默认情况下,我们认为最多有四个骨骼影响同一顶点。具体而言,我们把顶点格式定义如下:
struct BoneVertexData
{QVector3D position;QVector3D normal;QVector3D tangent;QVector2D texcoord;QVector4D boneWeight;QVector4D boneIndex;
};
(注:骨骼索引应该存为int类型,由于我本地在向顶点着色器传int类型数据出现了一些问题,我将其存储为了浮点数,并在着色器中转换为ivec4)
相比起静态物体,带绑骨的模型多存储了boneWeight和boneIndex这两个信息,我们在读入了其它数据之后(见导入模型代码),再读入绑骨信息:
void GeometryEngine::processBone(const aiMesh* pMesh, vector<BoneVertexData>& vertices, bool bAdd)
{for (uint i = 0 ; i < pMesh->mNumBones ; i++){string BoneName(pMesh->mBones[i]->mName.data);int boneIdx = CAnimationEngine::Inst()->GetBoneIndex(BoneName);if(boneIdx == -1) continue;for (uint j = 0 ; j < pMesh->mBones[i]->mNumWeights; j++){size_t VertexID = pMesh->mBones[i]->mWeights[j].mVertexId;float Weight = pMesh->mBones[i]->mWeights[j].mWeight;if(VertexID >= vertices.size()){qDebug() << "err larger " << VertexID;}for(int k = 0;k < 4;k++){if(vertices[VertexID].boneIndex[k] == INVALID_BONE_IDX){vertices[VertexID].boneIndex[k] = boneIdx;vertices[VertexID].boneWeight[k] = Weight;break;}}}}
}
第三部分是解析动画,并应用于对应的带绑骨的模型(原则上动画中的骨骼信息应该和应用的骨架匹配)。此处我们需要做的是存储每一帧,每个骨骼的蒙皮变换矩阵(在这里我们不考虑动画信息的压缩算法)。解析动画处使用了fbx自带的sdk(assimp也是可行的,但是需要自己解算全局变换矩阵,具体可以参考https://blog.csdn.net/ZJU_fish1996/article/details/52450008):
CFbxImporter::CFbxImporter()
{InitSdk();
}QMatrix4x4 TransformToQMatrix(const FbxAMatrix& mat)
{QMatrix4x4 res;res.setRow(0,{float(mat.Get(0,0)),float(mat.Get(0,1)),float(mat.Get(0,2)),float(mat.Get(0,3))});res.setRow(1,{float(mat.Get(1,0)),float(mat.Get(1,1)),float(mat.Get(1,2)),float(mat.Get(1,3))});res.setRow(2,{float(mat.Get(2,0)),float(mat.Get(2,1)),float(mat.Get(2,2)),float(mat.Get(2,3))});res.setRow(3,{float(mat.Get(3,0)),float(mat.Get(3,1)),float(mat.Get(3,2)),float(mat.Get(3,3))});res.setRow(3,{float(mat.Get(3,0)) * 0.01f,float(mat.Get(3,1)) * 0.01f,float(mat.Get(3,2)) * 0.01f,float(mat.Get(3,3))});res = res.transposed();return res;
}bool CFbxImporter::InitSdk()
{pManager = FbxManager::Create();if( !pManager ){return false;}FbxIOSettings* ios = FbxIOSettings::Create(pManager, IOSROOT);pManager->SetIOSettings(ios);FbxString lPath = FbxGetApplicationDirectory();pManager->LoadPluginsDirectory(lPath.Buffer());pScene = FbxScene::Create(pManager, "Scene");if( !pScene ){return false;}qDebug() << "success init sdk " ;return true;
}void CFbxImporter::ProcessAnimation()
{FbxAnimStack* pAnimStack = pScene->GetCurrentAnimationStack();FbxTime timePerFrame;timePerFrame.SetTime(0, 0, 0, 1, 0, pScene->GetGlobalSettings().GetTimeMode());const FbxTimeSpan animTimeSpan = pAnimStack->GetLocalTimeSpan();const FbxTime startTime = animTimeSpan.GetStart();const FbxTime endTime = animTimeSpan.GetStop();unsigned int frameNum = static_cast<unsigned int>(animTimeSpan.GetDuration().GetFrameCount(pScene->GetGlobalSettings().GetTimeMode())) + 1;int boneNum = CAnimationEngine::Inst()->GetBoneNum();if(pCurrentAnimator){pCurrentAnimator->Init(boneNum, frameNum);}for (FbxNode* pNode : vecNodes){unsigned int numFrames = 0;int boneIdx = CAnimationEngine::Inst()->GetBoneIndex(pNode->GetName());if(boneIdx == -1){continue;}FbxAMatrix kBindPose = pNode->EvaluateGlobalTransform();const QMatrix4x4& bindPose = TransformToQMatrix(kBindPose);const QMatrix4x4& invBindPose = bindPose.inverted();// 这里直接提取了bindPose并计算offset矩阵,可以取我们之前预先算好的for (FbxTime time = startTime; time <= endTime; time += timePerFrame, ++numFrames){FbxAMatrix kMatGlobal = pNode->EvaluateGlobalTransform(time);//transform of bone in world space at time tconst QMatrix4x4& globalMat = TransformToQMatrix(kMatGlobal);if(pCurrentAnimator){QMatrix4x4 renderMatrix = globalMat * invBindPose;pCurrentAnimator->AddFrame(numFrames, boneIdx, renderMatrix);}}}}
bool CFbxImporter::LoadFbx(const string& pFilename)
{vecNodes.clear();pCurrentAnimator = &CAnimationEngine::Inst()->CreateAnimator(pFilename);FbxImporter* lImporter = FbxImporter::Create(pManager,"");const bool lImportStatus = lImporter->Initialize(pFilename.c_str(), -1, pManager->GetIOSettings());if( !lImportStatus ){FbxString error = lImporter->GetStatus().GetErrorString();qDebug() << "load false";return false;}if(!lImporter->Import(pScene)){lImporter->Destroy();return false;}lImporter->Destroy();ProcessNode(pScene->GetRootNode());ProcessAnimation();return true;
}
首先出于简单考虑,我们的动作没有经过任何曲线压缩处理,而是直接存储了每帧的所有骨骼蒙皮数据。
蒙皮的这个过程可以在CPU或GPU中完成,不同引擎出于不同的考虑会有自己的实现。此处我们暂且放到GPU中实现,这意味着需要我们向顶点着色器发送所有骨骼的蒙皮变换矩阵,顶点根据自己相关的骨骼下标去查找对应的矩阵,并根据权重进行加权平均,得到最终的变换矩阵。
以上是最为普通的线性蒙皮计算方式。在实际应用中可能会遇到肩膀之类的地方变得非常细长的问题,此处需要我们考虑新的蒙皮计算来规避这一问题,具体内容可以尝试查阅相关资料。
void main()
{vec4 position = vec4(0,0,0,1);if(HasAnim){ivec4 index = ivec4(a_boneindex);if(index.x < 0 || index.x >= BONE_NUM){position = a_position;}else{mat4 BoneTransform;BoneTransform = Bones[index.x] * a_boneweight.x;BoneTransform += Bones[index.y] * a_boneweight.y;BoneTransform += Bones[index.z] * a_boneweight.z;BoneTransform += Bones[index.w] * a_boneweight.w;position = BoneTransform * a_position;v_tangent = mat3(BoneTransform) * a_tangent;v_normal = mat3(BoneTransform) * a_normal; // 有非等比缩放需为逆转置}}else{position = a_position;v_normal = a_normal;v_tangent = a_tangent;}mat3 M1 = mat3(IT_ModelMatrix[0].xyz, IT_ModelMatrix[1].xyz, IT_ModelMatrix[2].xyz);v_normal = normalize(M1 * v_normal);mat3 M2 = mat3(ModelMatrix[0].xyz, ModelMatrix[1].xyz, ModelMatrix[2].xyz);v_tangent = normalize(M2 * v_tangent);v_texcoord = a_texcoord;gl_Position = ModelMatrix * position;gl_Position = ViewMatrix * gl_Position;gl_Position = ProjectMatrix * gl_Position;
}
在GPU中,我们做一个简单的线性混合,此时还要注意处理法线相关数据的变换,避免做动作时光照错误。
动画管理实现
为了控制骨骼动画的播放、更新,我们需要一个动画管理的类。我们默认每个角色同一时间只能播放一个动作。
首先我们需要一个存储动画信息的类,也就是导入动画时我们用到的pCurrentAnimator变量。使用一个二维vector存储每帧每骨骼的蒙皮矩阵。
class CAnimator
{
private:vector<vector<QMatrix4x4>> m_vecFrames;
public:QMatrix4x4& GetFrame(int time, unsigned int boneIdx) { return m_vecFrames[time][boneIdx]; }vector<QMatrix4x4>& GetFrame(int time) { return m_vecFrames[time]; }void AddFrame(int time, unsigned int boneIdx, const QMatrix4x4& frame){m_vecFrames[time][boneIdx] = frame;}int GetFrameNum() { return static_cast<int>(m_vecFrames.size()); }void Init(unsigned int boneNum, unsigned int frameNum);
};
其次,为了播放每个动作,我们需要定义我们如何播放这个动作,比如是否循环、混合时间、回调等信息。
struct SEvent
{float m_time = 0;float m_lastTime = 0;string m_path;bool m_bLoop = true;int m_blendFrame = 10;unordered_map<int,function<void()>> m_callbacks;vector<QMatrix4x4> m_cachePose; // 和上一动作混合时,在事件中缓存一下上一动作姿态SEvent() { }SEvent(const string& path, bool bLoop, int blendFrame): m_path(path), m_bLoop(bLoop), m_blendFrame(blendFrame) { }
};
最后是我们最终控制播放的动画类,执行播放、更新、管理相关操作。
#define FRAME_PER_MS 0.03f
class CAnimationEngine
{
private:CAnimationEngine() { }static CAnimationEngine* m_inst;vector<Bone*> m_bones;unordered_map<string, int> m_mapBonesIndex;unordered_map<string, CAnimator> m_animators;unordered_map<Object*, SEvent> m_events;public:static CAnimationEngine* Inst(){if(!m_inst) m_inst = new CAnimationEngine();return m_inst;}void Init();void PlayAnimation(Object* obj, const string& path, bool bLoop = true, int blendFrame = 10);bool UpdateAnimation(Object* obj, QOpenGLShaderProgram* program);bool HasAnimator(const string& name) { return m_animators.find(name) != m_animators.end(); }CAnimator& GetAnimator(const string& name) { return m_animators[name]; }CAnimator& CreateAnimator(const string& name) { m_animators[name] = CAnimator(); return m_animators[name]; }void AddBone(Bone* bone) { m_bones.push_back(bone); m_mapBonesIndex[bone->m_name] = m_bones.size() - 1;}int GetBoneNum() { return static_cast<int>(m_bones.size());}int GetBoneIndex(const string& name) { return m_mapBonesIndex.find(name) == m_mapBonesIndex.end() ? -1 : m_mapBonesIndex[name]; }Bone* GetBones(int i) { return m_bones[static_cast<size_t>(i)];}
};
播放动作实际上就是给角色添加新的动作指令,并且覆盖掉旧的数据。
void CAnimationEngine::PlayAnimation(Object* obj, const string& path, bool bLoop, int blendFrame)
{if(m_animators.find(path) == m_animators.end()){return;}int frame;string oldPath;if(m_events.find(obj) != m_events.end() && m_animators.find(m_events[obj].m_path) != m_animators.end()){SEvent& event = m_events[obj];if(blendFrame){oldPath = event.m_path;frame = static_cast<int>(event.m_time * FRAME_PER_MS);}}m_events[obj] = SEvent(path, bLoop, blendFrame);if(!oldPath.empty()){CAnimator& animator = m_animators[oldPath];m_events[obj].m_cachePose = animator.GetFrame(frame);}
}
此处执行的更新动画也就是根据计算得到的当前帧数取得对应蒙皮矩阵,然后根据传入的shaderProgram向着色器发送蒙皮矩阵信息。我们采样的时间可能在两帧之间,此时可以选择在前后两帧之间按时间插值,也可以选择我这样偷懒的方法,在两者之间直接取一个作为结果:
bool CAnimationEngine::UpdateAnimation(Object* obj, QOpenGLShaderProgram* program)
{if(m_events.find(obj) == m_events.end()){return false;}SEvent& event = m_events[obj];if(m_animators.find(event.m_path) == m_animators.end()){return false;}CAnimator& animator = m_animators[event.m_path];if(animator.GetFrameNum() == 0){return false;}float curTime = RenderCommon::Inst()->GetMsTime();event.m_time += curTime - event.m_lastTime;int frame = static_cast<int>(event.m_time * FRAME_PER_MS); // 30/1000帧每毫秒if(frame >= animator.GetFrameNum()){if(event.m_bLoop){event.m_time = 0;frame = 0;}else{frame = animator.GetFrameNum() - 1;}}int size = static_cast<int>(m_bones.size());int location = program->uniformLocation("Bones");if(event.m_blendFrame > 0 && frame <= event.m_blendFrame && event.m_cachePose.size() > 0){// ...// 此处做动作融合,代码略,主要是根据cachePose和currentPose先Decompose反解出位移旋转// 缩放信息,然后分别插值,计算得到新的变换矩阵// program->setUniformValueArray(location,final.data(), size);}else{program->setUniformValueArray(location,animator.GetFrame(frame).data(), size);}if(callbacks.find(frame) != callbacks.end()){callbacks[frame]();}event.m_lastTime = curTime;return true;
}