本文的主要內容是介紹了一下自己驗證捏臉系統一個方案的小實驗。
很早之前便有捏臉系統的一個設想,但由於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或者本地路徑,對捏臉數據也單獨管理。