[OpenGL] 捏臉系統

        本文的主要內容是介紹了一下自己驗證捏臉系統一個方案的小實驗。

        很早之前便有捏臉系統的一個設想,但由於demo中沒有引入動畫系統所以一直沒有來得及驗證。捏臉系統一個美術工作量比較小的方案是基於骨骼動畫的,我一開始的想法是,對於骨骼動畫而言,既然綁定姿態(bindPose)能夠反映不同的體型,那麼它同樣也能夠反映不同的臉型。也就是說,我們需要實現一套運行時修改角色綁定姿態(臉部骨骼平移、旋轉等)並能實時反饋修改,支持記錄並讀入修改數據,支持場景中同一體型中不同角色加載各自的捏臉這樣一套系統。

        爲了確保捏臉系統的多樣性、美觀性和易用性,實際上我們需要對臉部骨骼做精心設計,提供符合大衆審美的基本臉型,並對滑桿的數值進行合理的限制,當然這一部分內容需要一個團隊的共同努力,而我在此會更加關注捏臉本身的實現。

 

從動畫系統開始

        爲了詳細展開對捏臉系統設計的描述,開篇依然要從動畫系統開始說起。角色動畫本身會在運行時佔據大量的內存空間,所以一般而言我們需要做以下操作:

       (1) 動畫數據的曲線壓縮和實時解析、四元數等數據的壓縮存儲

       (2) 多角色同體型引用同一份骨骼數據和動畫數據

       (3) 同一人形骨架下不同體型的同一動作,支持映射,在內存中僅存一份動畫數據

       (4) 動畫數據在第一次調用後,在後臺流式加載,未調用的動畫數據則不進行加載

       (5) 長時間未使用的動畫數據/僅調用一次的動畫數據進行動態卸載 

        ……

       對於最終蒙皮矩陣的計算,在比較複雜的系統中,我們應該要經歷:對於每一骨骼,從壓縮數據解析出localTransform -> 一些後處理計算 -> 換算成globalTransform -> 一些後處理計算 -> 乘以offset矩陣(綁定姿態的逆矩陣)得到蒙皮矩陣 -> 傳遞給GPU(或直接在CPU中)做蒙皮運算。

        那麼現在,我們面臨的一個問題就是,捏臉功能應該如何集成到上述這樣一個流程中?

        實際上我們可能已經發現了一些問題:同一體型的綁定姿態是一致的,所以理論上我們應該只存儲一份offset矩陣,但是捏臉需要對不同角色應用不同offset矩陣,如此看來我們則不得不多存儲許多運行時數據。

        但是仍然有一部分數據是共享的——我們只可能修改臉部骨骼,剩餘的身體數據依然有着同樣的綁定姿態,而且用戶可能只修改了少量的骨骼數據。因此在這裏我們可以考慮引入一個類似於蒙版的機制,也就是說,僅在數據發生了修改的時候,才記錄對應的改動。那麼我們最終得到的捏臉數據就是“發生了改動的骨骼的offset矩陣”。

        在運行時解算動畫時,我們對每根骨骼檢查是否存在本地修改,如果有,則讀取捏臉數據,否則讀取公共的骨骼綁定數據。

捏臉組件

         對於角色而言,它會關聯到許多系統,比如爲了支持不同部位換裝與穿搭,我們需要把mesh分爲多個部分,如臉部、頭髮、上身、下身等;對於部分角色,它可能有對應的骨架、材質、動畫、特效、布料、破碎體等,對於這種可選的模塊,我們可以將其設計爲組件。我們只加載物體所需的組件。

        那麼對Object而言,我們可以寫出一個簡單的組件系統,支持從Component繼承而來的各種組件:

class Object 
{
privarte:
    // ...
    unordered_map<string, unordered_map<string, unique_ptr<Component>>> m_mapComponent;

    // ...

public:

    void AddComponent(Component* component, const string& name);

    template<typename T, typename Args>
    T* CreateComponent(const string& name, Args args...)
    {
        string typeName = GetTypeName<T>();
        m_mapComponent[typeName][name] = make_unique<T>(args);
        return static_cast<T*>(m_mapComponent[typeName][name].get());
    }

    template<typename T>
    T* CreateComponent(const string& name)
    {
        string typeName = GetTypeName<T>();
        m_mapComponent[typeName][name] = make_unique<T>();
        return static_cast<T*>(m_mapComponent[typeName][name].get());
    }

    template<typename T>
    string GetTypeName()
    {
        string typeName = typeid(T).name();
        const string& prefix = "class ";
        if(typeName.find(prefix) != string::npos)
        {
            typeName = typeName.substr(prefix.size());
        }
        return typeName;
    }

    template<typename T>
    T* TryGetDefaultComponent()
    {
        string typeName = GetTypeName<T>();
        if (m_mapComponent.find(typeName) == m_mapComponent.end()) return nullptr;
        return static_cast<T*>(m_mapComponent[typeName].begin()->second.get());
    }

    template<typename T>
    T* TryGetComponent(const string& name)
    {
        string typeName = GetTypeName<T>();
        if (m_mapComponent.find(typeName) == m_mapComponent.end()) return nullptr;
        if (m_mapComponent[typeName].find(name) == m_mapComponent[typeName].end()) return nullptr;
        return static_cast<T*>(m_mapComponent[typeName][name].get());
    }

    // ...
};

        對所有組件支持序列化後,我們便得到了一個預製件(prefab),之後讀取這一預製件,就會自動創建對應的組件。

        由於不同數據之間可能存在共享,所以我們的數據將由一個專門的資源管理類進行控制,而在組件中存儲對應的鏈接/索引。我們在創建組件的時候,如果對應的資源沒有加載,我們申請創建;如果已經存在,則直接鏈接過去。

        對於捏臉數據這樣的非共享數據而言,我們就可以將其直接存儲在組件中:

class CAnimationComponent : public Component
{
public:
    CAnimationComponent(const string& name)
        : m_skeletonName(name) { }
    CAnimationComponent() { }

    QMatrix4x4 GetInvBindPose(const string& boneName);
    QMatrix4x4 GetInvBindPose(int boneIndex);

    SFaceData m_faceData;
    vector<QMatrix4x4> m_vecSkinMatrix;
    string m_skeletonName;
    SInvBindPoseType m_mapInvBindPose;
};

        如上,我們首先在動畫組件中存儲了骨架名,以便鏈接到對應的骨骼數據(包含獲取對應的offset矩陣);我們同時緩存了一個skinMatrix的矩陣,這是爲了將動畫數據更新和渲染的邏輯分離,更新每個角色的動畫數據時,將最終運算結果存儲在skinMatrix矩陣中,渲染時讀取使用這一矩陣。

        接下來是捏臉數據,我們這裏存儲了兩份捏臉數據,一份是SFaceData,它存儲了骨骼的偏移旋轉和縮放,這一數據主要是爲了方便我們進行實時的捏臉操作,裏面的數據直接對應滑桿上的數值。在不進行捏臉操作時,這一數據可以卸載,在進入捏臉模式後才從最終的數據中一次性反推出所有偏移值。

struct SFaceData
{
    unordered_map<string, QVector3D> m_offsetRotation;
    unordered_map<string, QVector3D> m_offsetPosition;
    // ...
};

        另一份數據爲SInvBindPoseType,它主要應用於最終骨骼動畫的渲染,它存儲了進行捏臉變換後的offset矩陣:

struct SInvBindPoseType
{
    unordered_map<string, QMatrix4x4> data;
    // ...

};

        在動畫更新時,我們封裝一個GetInvBindPose的方法(即獲取offset矩陣),判斷讀取本地修改的矩陣,而是公共鏈接的矩陣數據。

REGISTER_COMPONENT(CAnimationComponent)
QMatrix4x4 CAnimationComponent::GetInvBindPose(const string& boneName)
{
    CAnimationCharacter& animCharacter = CAnimationEngine::Inst()->AccessSkeleton(m_skeletonName);
    shared_ptr<Bone> bone = animCharacter.GetBone(boneName);
    if (m_mapInvBindPose.data.find(boneName) != m_mapInvBindPose.data.end())
    {
        return m_mapInvBindPose.data[boneName];
    }
    else
    {
        return bone->m_invBindPose;
    }
}

QMatrix4x4 CAnimationComponent::GetInvBindPose(int boneIndex)
{
    CAnimationCharacter& animCharacter = CAnimationEngine::Inst()->AccessSkeleton(m_skeletonName);
    const string& boneName = animCharacter.GetBone(boneIndex)->m_name;
    return GetInvBindPose(boneName);
}

        此時動畫更新時的操作如下:

{
    // ...
    const QMatrix4x4& invBindPose = aniComponent->GetInvBindPose(i);
    QMatrix4x4 mat;
    mat.translate(trans);
    mat.rotate(quat);
    mat = mat * invBindPose;

    final.emplace_back(mat);
}

 

捏臉操作

(找一個帶綁骨和貼圖的人形模型並導入太麻煩了,先將就着用這個吧,演出效果大打折扣這也是沒有辦法的事情)

       以上UI依然是通過反射機制生成的,這不是本文的重點,我們來關注操作滑竿將會綁定一個回調函數,內部會做對應的數據更新。

       如下所示,顯示了寫入和讀取數據的操作。如對寫入而言,我們獲取當前偏移數據,然後在faceData中記錄偏移數據。然後通過調用一個UpdatePosition的函數,將偏移數據換算成offset矩陣。

    faceTransXInit("transX", -1.0f, 1.0f, [this](float data)
    {
        auto activeObj = ObjectInfo::Inst()->GetActiveObject();
        if (shared_ptr<Object> obj = activeObj.lock())
        {
            CAnimationComponent* aniComponent = obj->TryGetDefaultComponent<CAnimationComponent>();
            if(aniComponent) aniComponent->m_faceData.SetOffsetPositionX(m_strBone, data);
        }
        UpdatePosition(m_strBone);
    }, [this]()->float{
        auto activeObj = ObjectInfo::Inst()->GetActiveObject();
        if (shared_ptr<Object> obj = activeObj.lock())
        {
            CAnimationComponent* aniComponent = obj->TryGetDefaultComponent<CAnimationComponent>();
            if (aniComponent) return aniComponent->m_faceData.GetOffsetPosition(m_strBone).x();
        }
        return 0.0f;
    });

        在UpdatePosition中,我們從角色鏈接的骨骼中得到原始bindPose數據,然後累加偏移的位移、旋轉,計算得到新的offset矩陣,存儲到動畫組件的m_mapInvBindPose中。

void CFaceWidget::UpdatePosition(const string& boneName)
{
    auto activeObj = ObjectInfo::Inst()->GetActiveObject();
    if (shared_ptr<Object> obj = activeObj.lock())
    {
        CAnimationComponent* aniComponent = obj->TryGetDefaultComponent<CAnimationComponent>();
        if (!aniComponent) return;
        auto bone = CAnimationEngine::Inst()->AccessSkeleton(aniComponent->m_skeletonName).GetBone(boneName);
        if (!bone) return;

        const STransform& transform = bone->m_bindPose;
        QMatrix4x4 mat;
        mat.translate(transform.position + aniComponent->m_faceData.GetOffsetPosition(boneName));
        QVector3D rot = transform.rotation.toEulerAngles();
        rot += aniComponent->m_faceData.GetOffsetRotation(boneName) / 180 * PI;
        mat.rotate(QQuaternion::fromEulerAngles(rot));
        aniComponent->m_mapInvBindPose.data[boneName] = mat.inverted();
    }
}

       由此可見,m_faceData是我們執行捏臉操作時的一個輔助數據,我們最終存儲的數據是m_mapInvBindPose,這可以隨着動畫組件一起序列化,直接存儲在組件中。我們在編輯過程中實時計算m_mapInvBindPose,動畫更新便會自動讀取更新後的數據並應用到角色身上。至此,我們就支持了不同角色的捏臉數據存儲、讀入、修改和更新。

      如果想要做進一步優化,在多份捏臉數據中也實現共享,我們可對每份捏臉數據,記錄一個uuid/guid或者本地路徑,對捏臉數據也單獨管理。

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