承接前文骨骼動畫理論及程序部分實現(xiàn)(一)前向動力學(xué)吃既。
簡介
??如果說前向動力學(xué)是找到父骨骼的空間行嗤,來變換得到子骨骼空間瓤帚,那么通過子骨骼得到父骨骼的位置,就稱之為反向動力學(xué)松捉,也稱為逆向動力學(xué)夹界。
??常見應(yīng)用于機器人,或者即時演算游戲中隘世,人物腿部在凹凸不平地面中的表現(xiàn)可柿。諸如刺客信條等擁有豐富攀爬動畫系統(tǒng)的游戲?qū)K會有更多的應(yīng)用。
IK解算
基本原理
??對于只有兩個骨骼關(guān)節(jié)點丙者,其中一點固定复斥,那么剩下一點的位置也隨之確定:
??對于有三個關(guān)節(jié)點的問題目锭,可以用余弦定理解決
??常見的IK骨痢虹,例如腿,用這個就能解決主儡,不過有時候給頭發(fā)或者蜈蚣世分、觸手、機械臂等物體加IK時缀辩,IK鏈會很長,余弦定理就無法很好的解決了踪央。
??對于更通用的IK解算方法臀玄,常用的有循環(huán)坐標(biāo)下降法(Cyclic Coordinate Descent, 簡稱CCD)和雅克比矩陣法(Jacobian Matrix)。
??針對于MikuMikudance的模型文件存儲參數(shù)畅蹂,很容易發(fā)現(xiàn)它是用CCD來解決IK解算問題的健无,因此我們也用CCD來進行IK解算。
循環(huán)坐標(biāo)下降法
名詞
??首先我們確定一些名詞液斜。
- IK鏈(Link\Chain):IK解算的單位累贤,受IK效應(yīng)器影響的一系列父子骨骼叠穆。
- 開始節(jié)點(Start Joint):IK鏈固定的那一端節(jié)點。
- 末端效應(yīng)器(End Effector):IK鏈的末端節(jié)點臼膏,也是盡量要到達目標(biāo)的節(jié)點硼被。
- IK目標(biāo)(Target):希望末端效應(yīng)器到達的目標(biāo)點。
??注意:這里我們約定渗磅,IK鏈條從接近末端開始計數(shù)嚷硫,如下圖。
??現(xiàn)在比照MMD模型對應(yīng)上面的名詞:
??其中IK鏈包含左膝(左ひざ)、開始節(jié)點左足(左腿跟)医清,以及末端效應(yīng)器起暮。
??注意末端效應(yīng)器在MMD中其實是上面標(biāo)著Target的左足首,而真正的IK目標(biāo)其實是左足IK本身会烙。
??如果看骨骼親子繼承關(guān)系负懦,左足>左ひざ>左足首才是一條鏈的,而真·IK目標(biāo)的繼承關(guān)系是全ての親>左足IK親>左足IK持搜,這意為著左足IK直屬于全親骨密似,只隨著全親骨的變化而變化,其位置不會輕易改變葫盼。
算法
??從IK鏈靠近末端效應(yīng)器的第一個節(jié)點開始残腌,算當(dāng)前節(jié)點到末端效應(yīng)器的向量與當(dāng)前節(jié)點到IK目標(biāo)向量的夾角,然后旋轉(zhuǎn)之贫导;一直算到開始節(jié)點抛猫,然后再從第一個節(jié)點開始,不斷循環(huán)孩灯。
??如上圖闺金,先像紅色線條那樣算角度,然后讓末端效應(yīng)器落在當(dāng)前節(jié)點和IK目標(biāo)的向量(下一張圖的黑色線)之上峰档。(由于我作圖時是目測旋轉(zhuǎn)败匹,后兩張圖沒有準確落到黑色線條張,這個別在意)
??然后沒有然后了讥巡,原理就這么簡單掀亩,下面開始放部分程序?qū)崿F(xiàn)。
程序?qū)崿F(xiàn)
??首先欢顷,因為不斷要更新骨骼的旋轉(zhuǎn)槽棍,之前前向動力學(xué)的BNode不夠用,我們此時不光是要記錄生成骨骼空間變換本身,同時要計算它自身的旋轉(zhuǎn):
struct BNode {
BNode(PMX::Bone& _bone) : bone(_bone) {};
int32_t index;
PMX::Bone& bone;
BNode* parent = nullptr;
std::vector<BNode*> childs;
bool haveAppendParent = false;
BNode* appendParent = nullptr;
float appendWeight;
std::vector<BNode*> appendChilds;
//棄用Mconv矩陣炼七,而是每次用getLocalMat生成
glm::vec3 position;
glm::quat rotate;
//記錄骨骼本身經(jīng)歷的移動和旋轉(zhuǎn)
glm::vec3 translate;
glm::quat quaternion;
inline glm::mat4 getLocalMat() {//conv before local to after world 變換初始狀態(tài)骨骼空間坐標(biāo)到完成位置的世界坐標(biāo)
return glm::translate(glm::mat4(1), position) * glm::mat4_cast(rotate);
}
inline glm::mat4 getGlobalMat() {//conv before world to after world 變換初始狀態(tài)世界坐標(biāo)到完成位置的世界坐標(biāo)
return glm::translate(glm::mat4(1), position) * glm::mat4_cast(rotate) * glm::translate(glm::mat4(1), -bone.position);
}
//更新rotate 和 position 缆巧,使用前確保所有父骨骼都是最新更新的
//只管自己,不管子骨和賦予親
bool updateSelfData() {
glm::mat4 parentLocalMat(1);
glm::vec3 parentPosition(0);
glm::quat parentRotate(1, 0, 0, 0);
glm::vec3 appendParentTranslate(0);
glm::quat appendParentQuaternion(1, 0, 0, 0);
if (parent != nullptr) {
parentPosition = parent->bone.position;
parentRotate = parent->rotate;
parentLocalMat = parent->getLocalMat();
}
if (appendParent != nullptr) {
appendParentTranslate = appendParent->translate;
appendParentQuaternion = appendParent->quaternion;
}
position = parentLocalMat * glm::vec4(bone.position - parentPosition + translate + appendParentTranslate * appendWeight, 1);
rotate = parentRotate * quaternion * glm::quat(glm::eulerAngles(appendParentQuaternion) * appendWeight);
return true;
}
//更新自身和所有子骨和賦予親豌拙,使用前確保父骨都是更新過的
//更新規(guī)則:先更新自身陕悬,然后更新自身的賦予親本身和賦予親的直屬子骨,再更新自身子骨
bool updateSelfAndChildData() {
std::stack<Animation::BNode*> nodes;
nodes.push(this);
while (!nodes.empty()) {
Animation::BNode* curr = nodes.top();
nodes.pop();
curr->updateSelfData();//更新自身
for (Animation::BNode* appendChild : curr->appendChilds) {//更新賦予親
std::stack<Animation::BNode*> appendChildNodes;
appendChildNodes.push(appendChild);
while (!appendChildNodes.empty()) {
Animation::BNode* appendChildCurr = appendChildNodes.top();
appendChildNodes.pop();
appendChildCurr->updateSelfData();
for (auto child = appendChildCurr->childs.rbegin(); child != appendChildCurr->childs.rend(); ++child) {
appendChildNodes.push(*child);
}
}
}
for (auto child = curr->childs.rbegin(); child != curr->childs.rend(); ++child) {//所有孩子壓棧
nodes.push(*child);
}
}
return true;
}
}
??吐個槽姆蘸,之前沒有存translate和quaternion墩莫,每次都用親骨反向計算,后來還有顧及賦予親的影響逞敷。更新也是將當(dāng)前骨骼到所有子骨狂秦、賦予子骨都更新一遍,為了保證更新的子骨用的是親骨更新前的變換矩陣推捐,還搞了個雙棧裂问,分別前序和后續(xù)遍歷,結(jié)果輸出程序結(jié)果不正確牛柒,我也要被繁雜的邏輯搞炸了堪簿。完全搞不清到底是IK解算的問題,還是更新骨骼的問題皮壁。
??然后多存了那兩個變量椭更,更新骨骼簡化為只更新自身,程序就變得特別清爽和可控蛾魄;說多了都是淚虑瀑。
??關(guān)于updateSelfAndChildData的更新規(guī)則也是根據(jù)情況來的。我發(fā)現(xiàn)賦予親和賦予子骨的父骨一般是一樣的滴须,大致情況如下:
??結(jié)果個別骨骼就特殊了主届,它只繼承被賦予骨,不繼承普通骨骼待德,這樣的更新規(guī)則更新不到它岂膳,于是我只能設(shè)定更新賦予骨的同時,向下更新一級直接子骨磅网。
??雖說這樣很依賴模型本身,但也是沒辦法的事情筷屡,假如兩個骨骼互相認爹(反向?qū)嬍谊P(guān)系)涧偷,那一開始個骨骼樹解析也是沒完沒了的簸喂。建模不規(guī)范,程序兩行淚啊燎潮。
??前向動力學(xué)部分遍歷骨骼樹喻鳄,要記錄一開始的translate和quaternion這個沒有技術(shù)含量,代碼我就不放了(別忘了賦予親就行)确封,接下來是重點的IK解算部分除呵。
??我將IK解算器封裝為一個類,在此之前先定義一個內(nèi)部結(jié)構(gòu)IKChain(這個命名可能有問題爪喘,應(yīng)該叫IKJoint更準確)
struct IKChain {
Animation::BNode* node;
bool enableAxisLimit;
glm::vec3 min;
glm::vec3 max;
IKChain* pre = nullptr;//前一條鏈節(jié)
Animation::BNode* endPre = nullptr;//末端效應(yīng)器沒有鏈節(jié)的屬性颜曾,額外存儲
bool updateChain() {//更新IK鏈
IKChain* curr = this;
do {
curr->node->updateSelfData();//更新自身
if (curr->pre == nullptr) {//如果是第一個鏈節(jié),就更新末端效應(yīng)器
curr->endPre->updateSelfData();
}
curr = curr->pre;
} while (curr != nullptr);
return true;
}
};
??注意:這是更新順序秉剑,不是解算順序泛豪,可以理解為:第一次解算更新前一個關(guān)節(jié)就行,第二次要更新前兩個……
class IKSolve
{
public:
IKSolve(BNode* _ikNode, BoneManager& boneManager);//初始化
~IKSolve();
void Solve();//解算
private:
Animation::BNode* ikNode;
std::vector<IKChain> ikChains;
Animation::BNode* targetNode;
float limitAngle;
};
IKSolve::IKSolve(Animation::BNode* ikNode, Animation::BoneManager& boneManager)
{
assert(ikNode->bone.isIK());//確保是IK骨
targetNode = boneManager.linearList[ikNode->bone.ikTargetBoneIndex];//按照MMD命名為Target侦鹏,其實是末端效應(yīng)器
limitAngle = ikNode->bone.ikLimit;//單位角诡曙,不讓一次旋轉(zhuǎn)角度過大的限制
this->ikNode = ikNode;//IK目標(biāo)節(jié)點
ikChains.resize(ikNode->bone.ikLinks.size());
for (int i = 0; i < ikChains.size(); ++i) {
ikChains[i].node = boneManager.linearList[ikNode->bone.ikLinks[i].boneIndex];
ikChains[i].enableAxisLimit = ikNode->bone.ikLinks[i].enableLimit();
ikChains[i].min = ikNode->bone.ikLinks[i].min;
ikChains[i].max = ikNode->bone.ikLinks[i].max;
if (i != 0) {
ikChains[i].pre = &ikChains[i - 1];
}
}
ikChains[0].endPre = targetNode;
}
??接下來是重頭戲:
void IKSolve::Solve() {
for (int ite = 0; ite < ikNode->bone.ikIterationCount; ++ite) {
for (size_t chainIdx = 0; chainIdx < ikChains.size(); ++chainIdx) {
IKChain& chain = ikChains[chainIdx];
glm::mat4 worldToLocalMat = glm::inverse(chain.node->getLocalMat());
glm::vec3 jointLocalIK = worldToLocalMat * glm::vec4(ikNode->position, 1); //關(guān)節(jié)本地坐標(biāo)系下IK目標(biāo)位置
glm::vec3 jointLocalTarget = worldToLocalMat * glm::vec4(targetNode->position, 1); //關(guān)節(jié)本地坐標(biāo)系下末端效應(yīng)器位置
if (glm::distance(jointLocalIK, jointLocalTarget) < 1e-3) {//兩個向量差距太小,目的達到略水,直接退出解算
ite = ikNode->bone.ikIterationCount;
break;
}
float cos_deltaAngle = glm::dot(glm::normalize(jointLocalIK), glm::normalize(jointLocalTarget));
if (cos_deltaAngle > 1 - 1e-6) {//夾角太小pass
continue;
}
float deltaAngle = glm::acos(cos_deltaAngle);
deltaAngle = glm::clamp(deltaAngle, -limitAngle, limitAngle);//一次旋轉(zhuǎn)的度數(shù)不得超過限制角
glm::vec3 axis = glm::normalize(glm::cross(glm::normalize(jointLocalTarget), glm::normalize(jointLocalIK)));//旋轉(zhuǎn)軸
chain.node->quaternion *= glm::quat_cast(glm::rotate(glm::mat4(1), deltaAngle, axis));
chain.updateChain();
}
}
ikChains.end()[-1].node->updateSelfAndChildData();//從IK鏈末端(開始節(jié)點)更新所有子骨
}
??第一重循環(huán)的循環(huán)次數(shù)模型自己會給出价卤,第二重循環(huán)是對每個關(guān)節(jié)鏈進行對比、旋轉(zhuǎn)渊涝。
??關(guān)于那個逆矩陣是這樣的:我們上次推導(dǎo)了矩陣變換慎璧,其中
是點所處骨骼空間的坐標(biāo),
是點所處世界空間的坐標(biāo)驶赏,左右兩邊的左側(cè)都乘上
的逆矩陣得到新的等式:
炸卑,也就是可以輸入世界坐標(biāo),得到骨骼關(guān)節(jié)空間的坐標(biāo)煤傍,這樣旋轉(zhuǎn)的角度能保證是基于骨骼關(guān)節(jié)坐標(biāo)系的盖文。
??兩個向量、角度檢測不得不做蚯姆,當(dāng)夾角太小時五续,cos值接近1,求arccos可能會出現(xiàn)nan龄恋。
??是不是很簡單疙驾?(才怪,這程序?qū)懙念^都禿了)把IK解算放到FK之后(上一章POSE的構(gòu)造函數(shù)最后):
for (Animation::BNode* node : boneManager.linearList) {
if (node->bone.isIK()) {
Animation::IKSolve solve(node, boneManager);
solve.Solve();
}
}
??看看現(xiàn)在的成果:??左側(cè)由剛剛理論寫成的程序生成郭毕,末端效應(yīng)器看來是落在目標(biāo)點上了它碎,不過小姐姐你的左腿是不是有點不對勁啊,趕緊去砍醫(yī)生吧。
??才怪了扳肛,單單是我們上面那個三節(jié)點模型傻挂,用余弦定理,在三維空間中也能解除無數(shù)個解(第二個關(guān)節(jié)點在空間中一個圓邊上都可以)挖息,類似腿這樣的關(guān)節(jié)點都是有限制的金拒,膝蓋關(guān)節(jié)只能在自己關(guān)節(jié)點空間的X軸旋轉(zhuǎn),并且只能向內(nèi)彎折套腹。
??看其他人的程序绪抛,以前的PMD模型文件可能給骨骼一個標(biāo)記,告訴程序:這個骨骼是膝蓋(isLeg)电禀,也有部分程序直接把左右膝蓋的shift-jis編碼值寫死在程序里幢码,特殊處理這些關(guān)節(jié)。
??不過PMX文件的IK鏈中鞭呕,給了每個鏈節(jié)的限制范圍(在最上面的圖能看到蛤育,注意下上面記錄的都是左手坐標(biāo)系的范圍),由此葫松,我們對這樣的關(guān)節(jié)特殊處理:
if (chain.enableAxisLimit) {//如果這個關(guān)節(jié)點有軸限制
glm::mat4 inv = glm::inverse(chain.node->parent->getLocalMat());
glm::vec3 selfRotate = glm::eulerAngles(chain.node->quaternion);//本身基于父空間的旋轉(zhuǎn)的歐拉角表示
if (ite == 0) {//第一次迭代瓦糕,直接到旋轉(zhuǎn)到可容忍區(qū)間的一半
glm::vec3 targetAngles = (chain.min + chain.max) / 2.0f;//目標(biāo)旋轉(zhuǎn)
chain.node->quaternion = glm::quat(targetAngles);//令關(guān)節(jié)和全部子骨旋轉(zhuǎn)
chain.updateChain();
}
else {//不是第一次迭代,也要保證旋轉(zhuǎn)在區(qū)間內(nèi)
glm::vec3 axis = glm::normalize(glm::cross(glm::normalize(jointLocalTarget), glm::normalize(jointLocalIK)));
glm::vec3 deltaRotate = glm::eulerAngles(glm::quat_cast(glm::rotate(glm::mat4(1), deltaAngle, axis)));
deltaRotate = glm::clamp(deltaRotate, chain.min - selfRotate, chain.max - selfRotate);
chain.node->quaternion *= glm::quat(deltaRotate);
chain.updateChain();
}
continue;
}
else {//沒有軸限制腋么,程序和上邊一樣
glm::vec3 axis = glm::normalize(glm::cross(glm::normalize(jointLocalTarget), glm::normalize(jointLocalIK)));
chain.node->quaternion *= glm::quat_cast(glm::rotate(glm::mat4(1), deltaAngle, axis));
chain.updateChain();
}
??第一次迭代中咕娄,直接將關(guān)節(jié)旋轉(zhuǎn)到允許空間的一半,如膝蓋的限制是[(0~180), (0, 0), (0,0)]珊擂,那么就直接旋轉(zhuǎn)到[90, 0, 0](右手坐標(biāo)系的)圣勒。隨后其他迭代中,保證限制關(guān)節(jié)不出允許區(qū)間摧扇,例如本身是[30, 0, 0]圣贸,那么它只能旋轉(zhuǎn)范圍為:[(-30, 150), (0, 0), (0, 0)]。
其他
??IK解算參考了很多github上現(xiàn)有的代碼吁峻,但還是寫的頭快禿了,賦予親中間還過來搗亂在张,簡直……給你們看看我中間調(diào)試時出現(xiàn)得到N種奇葩狀態(tài):??模型來自女仆麗塔-洛絲薇瑟2.0-神帝宇用含,希望不要打我(滑稽。
??如果還是有些看不懂帮匾,可以看看以下的網(wǎng)站:
引用
循環(huán)坐標(biāo)下降(CCD)算法中對骨骼動畫中膝蓋等關(guān)節(jié)的特殊處理
挺久前一個先輩的文章啄骇,也是搞MMD的
saba-OpenGL Viewer (OBJ PMD PMX)
從我未接觸圖形學(xué)時就看上了這個github項目,現(xiàn)在越寫越心驚瘟斜,骨骼動畫缸夹、表情動畫痪寻、物理都有,以后也要繼續(xù)借鑒這里的代碼
MikuMikuDance PMX/VMD Viewer for Windows, OSX, and Linux
上面的saba項目太龐大明未,中間封裝了很多層槽华,疑惑的時候看看輕量些的代碼也是個不錯的選擇
MMDAgent
也是個不錯的借鑒