[OpenGL] 使用Assimp库的骨骼动画

reference:http://ogldev.atspace.co.uk/www/tutorial38/tutorial38.html

你可以在主页找到教程全套的代码下载



Tutorial 38:

Skeletal Animation With Assimp



        最终,我们来到了这里。有数百万的读者都要求这一教程(我可能夸大了一些,但确实有不少)。骨骼动画(skeletion animation),同样也称作skinning,它使用了Assimp库。

        骨骼动画事实上有两部分。第一部分是由设计师完成的,而第二部分是由你,程序员完成的(或者说,你写的引擎)。第一部分发生在模型软件中,它被称作骨骼装配(rigging)。在这里,设计师指定了皮肤下的骨架。mesh就是指对象的皮肤(无论它是人,怪物或者是其它)而骨骼是用于移动mesh以模拟现实世界中的运动。这是通过指定某个点到一块或多块骨头完成的。当一个顶点指定了一个骨头的时候,我们给出在移动中这块骨头对顶点影响的权重。对于每个顶点,实践中要保证权重总和为1(每个顶点)。例如,如果一个顶点恰处在两块骨头之间,我们可能会赋予每块骨头0.5的权值,因为我们希望每块骨头对顶点有相等的影响。但是,如果一个顶点完全收到一块骨头的影响,那么权值应当是1(这意味着骨头自主控制了顶点的运动)。

        这里是一个blender中创建的骨骼:

        [OpenGL] 使用Assimp库的骨骼动画_第1张图片

        我们在上面看到的事实上是动画的一个重要部分。设计师装配了骨架结构,并且为每个动画类型(走路,跑步,死亡等)定义了关键帧集合。关键帧包含了所有骨头在动画路径上的关键点的变换。图形引擎在关键帧变换中插值,并构造出它们之间的流畅运动。骨骼动画中的骨架结构通常是分层次的。这意味着骨头之间有孩子/父母的关系,所以形成了一棵骨头树。每个骨头都有一个父节点,除了根节点。在人类身体的情况下,例如,你把后背的骨头设为根,在下一层次,手臂、脚、手指头的骨骼作为其孩子。当一个父结点移动的时候,它的所有孩子也会移动,但是当一个孩子移动的时候,父结点不会移动。(我们的手指头可以在手不移动的情况下移动)。从练习的角度来看,这意味着当我们运行骨骼变换的时候,我们需要结合其到根所有的父节点。

        接下来我们不会继续讨论骨骼装配了。它是非常复杂的课题,并且超出了图形程序员的范围。造型软件有高级的技术来帮助设计师完成这一工作,你需要一个好的设计师来创建一个好的皮肤以及骨架。让我们来看看图形引擎需要为骨骼动画做些什么。

        第一步是利用每个顶点的骨头信息填充顶点缓冲区。有几个可选的变量,但是我们打算做的非常直接。对于每个顶点我们加入一个槽的数组,每个槽包含了一个骨骼Id以及它的权重。为了简化我们将会使用四个槽的数组,这意味着一个顶点不会被四块以上的骨头控制。如果你想要加载有更多骨头的模型,你需要调整一下数组的大小,但是对于这一教程使用的Doom3模型的demo而言四块骨头已经足够了。所以我们的新顶点结构看起来是这样的:

          [OpenGL] 使用Assimp库的骨骼动画_第2张图片

        骨头的IDs是一个骨头变换数组的索引。这些变换将会在WVP矩阵(它们把顶点从骨头空间转换到本地空间)之前应用到位置和法线上。权值将用于结合不同骨头的变换为一个变换,在任何情况下,权值总和都应该是1(模型软件会负责)。通常情况下,我们会在关键帧之间插值并在每一帧更新骨头变换的数组。

        骨头数组的变换创建方法通常是具有迷惑性的部分。变换被放置在一个分层结构体里(i.e.树)。普通的练习里使用尺度向量,树中每个结点有一个旋转四元数和一个平移向量。事实上,每个结点都包含这些项的一个数组。数组的每一项都应当有一个时间戳。应用的时间恰好和时间戳联系上的可能很小,所以我们的代码必须能够插值缩放/平移/旋转使得应用能够及时得到正确的变换。对于从当前骨头到根的每一结点我们完成相同的过程,并且把这一串变换相乘得到最终结果。我们对每个骨头都这么做,然后更新着色器。

        目前为止,我们讨论的一切都已经有了好的封装。但是这是一个使用Assimp的骨骼动画教程,所以我们需要再次进入库中来看看我们怎么实现skining。关于Assimp的一个好消息是它支持加载一些格式的骨骼信息。坏消息是你仍然需要一些工作量,创建数据结构来生成着色器所需的骨骼变换。

        让我们从顶点层次的骨骼信息开始。这是Assimp数据结构中相关的片段:

[OpenGL] 使用Assimp库的骨骼动画_第3张图片


        你可能回忆起了Assimp教程中,所有东西都包含在aiScene类中(我们导入mesh文件时得到的对象)。aiScene包含了aiMesh对象数组。一个aiMesh是模型的一部分,它包含了每个顶点层次的信息,如位置,法线,纹理坐标等等。现在我们看到aiMesh同样包含了aiBone对象。很显然,一个aiBone代表了骨架下的一块骨头。每个骨头都有一个名称,依靠这个名称它们可以在层次中被找到(看下方),也就是一个顶点权值数组和一个4X4的偏移量矩阵。你需要这个矩阵的原因是顶点存储在本地空间。这意味着就算不支持骨骼动画,我们已有的代码框架也可以加载模型并正确渲染它。但是层次中的骨骼变换是在骨骼空间中完成的(每块骨头有属于自己的空间,所以我们需要把变换相乘)。所以偏移量矩阵的工作就将顶点位置从mesh的本地空间变换到特定骨头的骨骼空间。

        我们感兴趣的是顶点权值数组。数组中的每一项都包含aiMesh顶点数组中的一个索引(记得顶点是以相同长度跨越了几个数组)和一个权值。所有顶点的权值和必须为1,但是为了找到它们,你需要遍历所有的骨头并且累加和到每个特定定点的某一种链表中。

        我们在顶点层次创建完可以运行骨骼变换层次以及产生最终变换的骨骼信息后,我们将其加载到着色器。接下来的图片显示了相关数据结构:

 [OpenGL] 使用Assimp库的骨骼动画_第4张图片

        我们再一次从aiScene开始。aiScene对象包含了一个aiNode的指针,其中aiNode类是分层(树)的根。树上的每个结点都有一个指针指向了它们的父母,一个指针数组指向了它们的孩子。这允许我们方便的来回遍历树。例外,结点存储了从结点空间到父空间的变换矩阵。最后,结点可能有名字,也可能没有名字。如果一个结点代表分层中的一块骨头,那么这个节点的名字必须和骨头对应。但是有时候结点没有名字(这意味着没有对应的骨头),它们的工作仅仅是帮助建模师分解模型并执行一些中间变换。

        难题的最后一部分是aiAnimation数组,它同样也存储在aiScene对象中。一个单一的aiAnimation对象代表着一序列动画帧,例如“走路”,“奔跑”,“射击”等等。通过在帧之间插值我们得到了如动画名所示的想要的可见效果。一个动画有它持续的ticks以及每秒的ticks(共100ticks,25ticks每分钟代表着一个四秒的动画)这帮助我们计时程序,这样的话在不同硬件上动画表现才能一样。另外,一个动画有一个aiNodeAnim对象的数组,叫做通道。每个通道事实上包含了其所有变换的骨头。通道包含的名字必须与分层中的一个结点和三个变换数组对应。

        为了在特定点及时计算最终的骨骼变换,我们需要在每个点的三个数组中找到两个时间并且插值对应的数组。然后我们需要结合变换到一个矩阵中。完成了这些后,我们需要找到分层中的一个对应结点,然后访问其父亲结点。接下来我们需要父节点对应的通道,并执行相同的插值操作。我们将两个变换相乘,继续如此,直到到达分层的根。

Source walkthru


(mesh.cpp:75)

bool Mesh::LoadMesh(const string& Filename)
{
    // Release the previously loaded mesh (if it exists)
    Clear();

    // Create the VAO
    glGenVertexArrays(1, &m_VAO); 
    glBindVertexArray(m_VAO);

    // Create the buffers for the vertices attributes
    glGenBuffers(ARRAY_SIZE_IN_ELEMENTS(m_Buffers), m_Buffers);

    bool Ret = false; 

    m_pScene = m_Importer.ReadFile(Filename.c_str(), aiProcess_Triangulate | aiProcess_GenSmoothNormals | 
                                    aiProcess_FlipUVs);

    if (m_pScene) { 
       m_GlobalInverseTransform = m_pScene->mRootNode->mTransformation;
       m_GlobalInverseTransform.Inverse();
       Ret = InitFromScene(m_pScene, Filename);
    }
    else {
       printf("Error parsing '%s': '%s'\n", Filename.c_str(), m_Importer.GetErrorString());
    }

    // Make sure the VAO is not changed from the outside
    glBindVertexArray(0); 

    return Ret;
}

        用粗体标记的地方是Mesh类的更新点(注:代码格式化后就没有粗体了,可以在原文中看粗体部分)。这里有一些我们需要注意的变化。一个是导入器和aiScene对象现在是类成员,而不再是栈变量。原因在于运行时我们会多次返回到aiScene对象,因此我们需要扩展导入器和场景的范围。在实时游戏中,你也许想要拷贝你需要的资料并且将其存储在一个更优的格式中,但是出于教程的目的这已经足够了。

        第二个变化是层次中根的变换矩阵被选取、求逆以及存储。我们将继续采取这样的方式。注意到我们从Assimp库中拷贝了矩阵求逆的代码到Matrix4f类中。

(mesh.h:69)
struct VertexBoneData
{ 
    uint IDs[NUM_BONES_PER_VEREX];
    float Weights[NUM_BONES_PER_VEREX];
}

(mesh.cpp:107)
bool Mesh::InitFromScene(const aiScene* pScene, const string& Filename)
{ 
    ...
    vector Bones;
    ...
    Bones.resize(NumVertices);
    ...
    glBindBuffer(GL_ARRAY_BUFFER, m_Buffers[BONE_VB]);
    glBufferData(GL_ARRAY_BUFFER, sizeof(Bones[0]) * Bones.size(), &Bones[0], GL_STATIC_DRAW);
    glEnableVertexAttribArray(BONE_ID_LOCATION);
    glVertexAttribIPointer(BONE_ID_LOCATION, 4, GL_INT, sizeof(VertexBoneData), (const GLvoid*)0);
    glEnableVertexAttribArray(BONE_WEIGHT_LOCATION); 
    glVertexAttribPointer(BONE_WEIGHT_LOCATION, 4, GL_FLOAT, GL_FALSE, sizeof(VertexBoneData), (const GLvoid*)16);
    ...
}


        上面的结构包含了我们在顶点层次需要的所有东西。默认下,我们有足够的空间存储4个骨头(Id + 每个骨头的权值)。VertexBoneData是能让其更简单地传送到着色器的结构。我们已经得到了处在0,1,2的位置,纹理坐标以及法线。所以,我们可以设置我们的VAO在3处绑定骨头ID,在4处绑定权值。一个重要的注意事项是我们使用glVertexAttriblPointer儿不是glVertexAttribPointer来绑定IDs。原因在于ID是整数而不是浮点数。要关注着一点,否则你可能在着色器中得到错误的数据。

(mesh.cpp:213)
void Mesh::LoadBones(uint MeshIndex, const aiMesh* pMesh, vector& Bones)
{
    for (uint i = 0 ; i < pMesh->mNumBones ; i++) { 
        uint BoneIndex = 0; 
        string BoneName(pMesh->mBones[i]->mName.data);

        if (m_BoneMapping.find(BoneName) == m_BoneMapping.end()) {
            BoneIndex = m_NumBones;
            m_NumBones++; 
            BoneInfo bi; 
            m_BoneInfo.push_back(bi);
        }
        else {
            BoneIndex = m_BoneMapping[BoneName];
        }

        m_BoneMapping[BoneName] = BoneIndex;
        m_BoneInfo[BoneIndex].BoneOffset = pMesh->mBones[i]->mOffsetMatrix;

        for (uint j = 0 ; j < pMesh->mBones[i]->mNumWeights ; j++) {
            uint VertexID = m_Entries[MeshIndex].BaseVertex + pMesh->mBones[i]->mWeights[j].mVertexId;
            float Weight = pMesh->mBones[i]->mWeights[j].mWeight; 
            Bones[VertexID].AddBoneData(BoneIndex, Weight);
        }
    } 
}

        上面的函数对一个单一的aiMesh对象加载了顶点骨骼信息。其调用了Mesh::InitMesh()。除了设定VertexBoneData结构外,这一函数还更新了骨头名字和骨头IDs的映射(一个由函数管理的运行中的索引)并且存储基于骨骼ID的偏移量矩阵到一个向量中。注意这个顶点ID是如何被计算出来的。因为顶点IDs和单一mesh有关系,而且我们把所有的mesh存储到了一个向量中,我们可把当前aiMesh基本顶点ID加到mWeights数组的顶点ID上,来得到绝对的顶点ID。

(mesh.cpp:29)
void Mesh::VertexBoneData::AddBoneData(uint BoneID, float Weight)
{
    for (uint i = 0 ; i < ARRAY_SIZE_IN_ELEMENTS(IDs) ; i++) {
        if (Weights[i] == 0.0) {
            IDs[i] = BoneID;
            Weights[i] = Weight;
            return;
        } 
    }

    // should never get here - more bones than we have space for
    assert(0);
}

        这个有用的函数找到了一个VertexBoneData结构中的空槽,并且存入骨头ID和权重。一些顶点可能会受到少于四个骨头的影响,但是因为一个不存在的骨头的权值保持为0(可以看VertexBoneData的构造器),这意味着我们可以对任意数量的骨头使用相同的权值计算。


(mesh.cpp:473)
Matrix4f Mesh::BoneTransform(float TimeInSeconds, vector& Transforms)
{
    Matrix4f Identity;
    Identity.InitIdentity();

    float TicksPerSecond = m_pScene->mAnimations[0]->mTicksPerSecond != 0 ? 
                            m_pScene->mAnimations[0]->mTicksPerSecond : 25.0f;
    float TimeInTicks = TimeInSeconds * TicksPerSecond;
    float AnimationTime = fmod(TimeInTicks, m_pScene->mAnimations[0]->mDuration);

    ReadNodeHeirarchy(AnimationTime, m_pScene->mRootNode, Identity);

    Transforms.resize(m_NumBones);

    for (uint i = 0 ; i < m_NumBones ; i++) {
        Transforms[i] = m_BoneInfo[i].FinalTransformation;
    }
}

        我们之前看到的顶点层次的骨骼信息的加载仅在开始加载mesh的时候完成。现在我们来到了第二部分,也就是计算传入着色器每一帧的骨骼变换。上面的函数是这一活动的入口。调用者给出了按秒计算的当前时间(它可以是一个分数),并且提供了我们需要更新的矩阵向量。我们在动画循环中找到相关时间,并且运行结点层次。得到的结果是一个变换矩阵,它将返回给调用者。

void Mesh::ReadNodeHeirarchy(float AnimationTime, const aiNode* pNode, const Matrix4f& ParentTransform)
{ 
    string NodeName(pNode->mName.data);

    const aiAnimation* pAnimation = m_pScene->mAnimations[0];

    Matrix4f NodeTransformation(pNode->mTransformation);

    const aiNodeAnim* pNodeAnim = FindNodeAnim(pAnimation, NodeName);

    if (pNodeAnim) {
        // Interpolate scaling and generate scaling transformation matrix
        aiVector3D Scaling;
        CalcInterpolatedScaling(Scaling, AnimationTime, pNodeAnim);
        Matrix4f ScalingM;
        ScalingM.InitScaleTransform(Scaling.x, Scaling.y, Scaling.z);

        // Interpolate rotation and generate rotation transformation matrix
        aiQuaternion RotationQ;
        CalcInterpolatedRotation(RotationQ, AnimationTime, pNodeAnim); 
        Matrix4f RotationM = Matrix4f(RotationQ.GetMatrix());

        // Interpolate translation and generate translation transformation matrix
        aiVector3D Translation;
        CalcInterpolatedPosition(Translation, AnimationTime, pNodeAnim);
        Matrix4f TranslationM;
        TranslationM.InitTranslationTransform(Translation.x, Translation.y, Translation.z);

        // Combine the above transformations
        NodeTransformation = TranslationM * RotationM * ScalingM;
    }

    Matrix4f GlobalTransformation = ParentTransform * NodeTransformation;

    if (m_BoneMapping.find(NodeName) != m_BoneMapping.end()) {
        uint BoneIndex = m_BoneMapping[NodeName];
        m_BoneInfo[BoneIndex].FinalTransformation = m_GlobalInverseTransform * GlobalTransformation * 
                                                    m_BoneInfo[BoneIndex].BoneOffset;
    }

    for (uint i = 0 ; i < pNode->mNumChildren ; i++) {
        ReadNodeHeirarchy(AnimationTime, pNode->mChildren[i], GlobalTransformation);
    }
}


        这一函数遍历了结点树并且根据特定动画时间产生了最终每块骨头的变换。它的局限在于它假定mesh仅有单一的动画序列。如果你想要支持更多的动画,你需要告诉它动画的名字,并且在m_pScene->mAnimations[]数组中找到它。上面的代码对于我们使用的demo mesh已经足够好了。

        结点变换是根据结点中的变换成员初始化的。如果结点没有对应着骨头,那么这就是它最终的变换。如果有的话,我们用生成的矩阵重写它。这是按以下步骤完成的:首先在动画通道数组里搜索节点名字。然后在基于动画时间的缩放向量,旋转四元数以及平移向量中进行插值。我们将这些结合到一个矩阵中,并做为参数与我们得到的矩阵相乘(名为GlobalTransformation)。这一函数是递归的,由以GlobalTransformation为单位矩阵的根节点调用。每个结点都对它的所有孩子递归调用这一函数,并且传送他自己的变换以作为GlobalTransformation。由于我们是从顶开始而向下运行,我们得到了每个结点的变换链。

       m_BoneMapping数组映射一个结点名字到一个我们生成的索引,我们使用索引作为m_BoneInfo数组的入口,在那里存储了最终的变换。最终的变换是这样计算的:我们从结点偏移量矩阵(它把顶点从本地空间位置转换到结点空间)开始。然后我们乘以所有结点父母的变换加上我们根据动画时间计算的结点特定变换。

        注意到我们在这里使用了Assimp的代码来进行数学运算。我觉得没有必要在我们的代码中重复这一工作,所以我就使用了Assimp。

(mesh.cpp:387)
void Mesh::CalcInterpolatedRotation(aiQuaternion& Out, float AnimationTime, const aiNodeAnim* pNodeAnim)
{
    // we need at least two values to interpolate...
    if (pNodeAnim->mNumRotationKeys == 1) {
        Out = pNodeAnim->mRotationKeys[0].mValue;
        return;
    }

    uint RotationIndex = FindRotation(AnimationTime, pNodeAnim);
    uint NextRotationIndex = (RotationIndex + 1);
    assert(NextRotationIndex < pNodeAnim->mNumRotationKeys);
    float DeltaTime = pNodeAnim->mRotationKeys[NextRotationIndex].mTime - pNodeAnim->mRotationKeys[RotationIndex].mTime;
    float Factor = (AnimationTime - (float)pNodeAnim->mRotationKeys[RotationIndex].mTime) / DeltaTime;
    assert(Factor >= 0.0f && Factor <= 1.0f);
    const aiQuaternion& StartRotationQ = pNodeAnim->mRotationKeys[RotationIndex].mValue;
    const aiQuaternion& EndRotationQ = pNodeAnim->mRotationKeys[NextRotationIndex].mValue;
    aiQuaternion::Interpolate(Out, StartRotationQ, EndRotationQ, Factor);
    Out = Out.Normalize();
}

        这一方法插值了基于动画时间的特定通道的旋转四元数(记得通道包含了关键帧数组)。首先我们在需要的动画时间前找到关键帧的索引。我们计算从动画时间到关键帧的距离和关键帧到下一帧的距离之间的比例。我们需要使用这一因子在两个关键帧之间插值。我们使用Assimp代码来完成插值,并标准化结果。位置以及缩放对应的方法非常类似,所以我们在这里不再赘述。
(mesh.cpp:335)
uint Mesh::FindRotation(float AnimationTime, const aiNodeAnim* pNodeAnim)
{
    assert(pNodeAnim->mNumRotationKeys > 0);

    for (uint i = 0 ; i < pNodeAnim->mNumRotationKeys - 1 ; i++) {
        if (AnimationTime < (float)pNodeAnim->mRotationKeys[i + 1].mTime) {
            return i;
        }
    }

    assert(0);
}

        这一有用的方法找到了关键帧的旋转,它恰在动画时间之前。如果我们有N个关键帧旋转,结果可以是0-N-2。动画时间通常包含在通道的区间内,所以最后的关键帧(N-1)从来都不是有效的结果。

(skinning.vs)
#version 330 

layout (location = 0) in vec3 Position; 
layout (location = 1) in vec2 TexCoord; 
layout (location = 2) in vec3 Normal; 
layout (location = 3) in ivec4 BoneIDs;
layout (location = 4) in vec4 Weights;

out vec2 TexCoord0;
out vec3 Normal0; 
out vec3 WorldPos0; 

const int MAX_BONES = 100;

uniform mat4 gWVP;
uniform mat4 gWorld;
uniform mat4 gBones[MAX_BONES];

void main()
{ 
    mat4 BoneTransform = gBones[BoneIDs[0]] * Weights[0];
    BoneTransform += gBones[BoneIDs[1]] * Weights[1];
    BoneTransform += gBones[BoneIDs[2]] * Weights[2];
    BoneTransform += gBones[BoneIDs[3]] * Weights[3];

    vec4 PosL = BoneTransform * vec4(Position, 1.0);
    gl_Position = gWVP * PosL;
    TexCoord0 = TexCoord;
    vec4 NormalL = BoneTransform * vec4(Normal, 0.0);
    Normal0 = (gWorld * NormalL).xyz;
    WorldPos0 = (gWorld * PosL).xyz; 
}

        现在我们完成了mesh类的改变。让我们看看在着色器层次我们要做些什么。首先,我们已经添加了骨骼IDs以及权值数组到VSinput结构。接下来,有一个新形式的数组,它包含了骨骼变换。在着色器自身我们计算了最终的骨骼变换,它结合了顶点骨骼变换矩阵和它们的权值。最终的矩阵用于从骨骼空间变换位置和法线到本地空间。从这里开始所有事情都是一样的了。

(tutorial38.cpp:140)
float RunningTime = (float)((double)GetCurrentTimeMillis() - (double)m_startTime) / 1000.0f;

m_mesh.BoneTransform(RunningTime, Transforms);

for (uint i = 0 ; i < Transforms.size() ; i++) {
    m_pEffect->SetBoneTransform(i, Transforms[i]);
}

        最终我们要做的事情是把所有过程统一。这是由以上简单的代码完成的。函数GetCurrentTimeMillis()返回了从应用开始时的毫秒时间。

        如果你正确的完成了所有事情,最终的结果看起来和这个会很类似。(注:在墙外

 

你可能感兴趣的:(OpenGL)