[OpenGL] 骨骼動畫原理和實現(Qt)

        最近在自己的練習項目中加入了骨骼動畫系統。本篇文章主要討論骨骼動畫的基本原理,以及動畫的導入和繪製。

多個骨骼動畫循環播放效果,素材來源:unreal商城

概念引入

        對於網格體而言有不少實現動畫的方式。直接對頂點進行操作也就是頂點動畫,適用於一些比較簡單的植物擺動、水面波動效果。此外,還有在兩個網格之間進行插值的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 t
            const 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;
}

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章