骨骼動(dòng)畫(huà)理論及程序?qū)崿F(xiàn)(二)反向動(dòng)力學(xué)

承接前文骨骼動(dòng)畫(huà)理論及程序部分實(shí)現(xiàn)(一)前向動(dòng)力學(xué)

簡(jiǎn)介

??如果說(shuō)前向動(dòng)力學(xué)是找到父骨骼的空間,來(lái)變換得到子骨骼空間,那么通過(guò)子骨骼得到父骨骼的位置,就稱(chēng)之為反向動(dòng)力學(xué),也稱(chēng)為逆向動(dòng)力學(xué)。
??常見(jiàn)應(yīng)用于機(jī)器人,或者即時(shí)演算游戲中,人物腿部在凹凸不平地面中的表現(xiàn)。諸如刺客信條等擁有豐富攀爬動(dòng)畫(huà)系統(tǒng)的游戲?qū)K會(huì)有更多的應(yīng)用。

IK解算

基本原理

??對(duì)于只有兩個(gè)骨骼關(guān)節(jié)點(diǎn),其中一點(diǎn)固定,那么剩下一點(diǎn)的位置也隨之確定:

黑X是目標(biāo)點(diǎn)
??這樣末端點(diǎn)無(wú)法到達(dá)目標(biāo),但方向總是能確定的。
??對(duì)于有三個(gè)關(guān)節(jié)點(diǎn)的問(wèn)題,可以用余弦定理解決
??轉(zhuǎn)換成三角形問(wèn)題,已知三角形三邊長(zhǎng)度求夾角,余弦定理已經(jīng)給出了公式:
??常見(jiàn)的IK骨,例如腿,用這個(gè)就能解決,不過(guò)有時(shí)候給頭發(fā)或者蜈蚣、觸手、機(jī)械臂等物體加IK時(shí),IK鏈會(huì)很長(zhǎng),余弦定理就無(wú)法很好的解決了。
??對(duì)于更通用的IK解算方法,常用的有循環(huán)坐標(biāo)下降法(Cyclic Coordinate Descent, 簡(jiǎn)稱(chēng)CCD)和雅克比矩陣法(Jacobian Matrix)。
??針對(duì)于MikuMikudance的模型文件存儲(chǔ)參數(shù),很容易發(fā)現(xiàn)它是用CCD來(lái)解決IK解算問(wèn)題的,因此我們也用CCD來(lái)進(jìn)行IK解算。

循環(huán)坐標(biāo)下降法

名詞

??首先我們確定一些名詞。

  • IK鏈(Link\Chain):IK解算的單位,受IK效應(yīng)器影響的一系列父子骨骼。
  • 開(kāi)始節(jié)點(diǎn)(Start Joint):IK鏈固定的那一端節(jié)點(diǎn)。
  • 末端效應(yīng)器(End Effector):IK鏈的末端節(jié)點(diǎn),也是盡量要到達(dá)目標(biāo)的節(jié)點(diǎn)。
  • IK目標(biāo)(Target):希望末端效應(yīng)器到達(dá)的目標(biāo)點(diǎn)。

??注意:這里我們約定,IK鏈條從接近末端開(kāi)始計(jì)數(shù),如下圖。
??現(xiàn)在比照MMD模型對(duì)應(yīng)上面的名詞:

??最下面腳踝處選中變紅的骨骼便是左足IK,同時(shí)和它相疊加在一起(位置一樣)的還有左足首
??其中IK鏈包含左膝(左ひざ)、開(kāi)始節(jié)點(diǎn)左足(左腿跟),以及末端效應(yīng)器
??注意末端效應(yīng)器在MMD中其實(shí)是上面標(biāo)著Target的左足首,而真正的IK目標(biāo)其實(shí)是左足IK本身。
??如果看骨骼親子繼承關(guān)系,左足>左ひざ>左足首才是一條鏈的,而真·IK目標(biāo)的繼承關(guān)系是全ての親>左足IK親>左足IK,這意為著左足IK直屬于全親骨,只隨著全親骨的變化而變化,其位置不會(huì)輕易改變。

算法

??從IK鏈靠近末端效應(yīng)器的第一個(gè)節(jié)點(diǎn)開(kāi)始,算當(dāng)前節(jié)點(diǎn)末端效應(yīng)器的向量與當(dāng)前節(jié)點(diǎn)IK目標(biāo)向量的夾角,然后旋轉(zhuǎn)之;一直算到開(kāi)始節(jié)點(diǎn),然后再?gòu)牡谝粋€(gè)節(jié)點(diǎn)開(kāi)始,不斷循環(huán)。


??如上圖,先像紅色線條那樣算角度,然后讓末端效應(yīng)器落在當(dāng)前節(jié)點(diǎn)和IK目標(biāo)的向量(下一張圖的黑色線)之上。(由于我作圖時(shí)是目測(cè)旋轉(zhuǎn),后兩張圖沒(méi)有準(zhǔn)確落到黑色線條張,這個(gè)別在意)
??然后沒(méi)有然后了,原理就這么簡(jiǎn)單,下面開(kāi)始放部分程序?qū)崿F(xiàn)。

程序?qū)崿F(xiàn)

??首先,因?yàn)椴粩嘁鹿趋赖男D(zhuǎn),之前前向動(dòng)力學(xué)的BNode不夠用,我們此時(shí)不光是要記錄生成骨骼空間變換本身,同時(shí)要計(jì)算它自身的旋轉(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)歷的移動(dò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ò)的
    //更新規(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;
    }
}

??吐個(gè)槽,之前沒(méi)有存translate和quaternion,每次都用親骨反向計(jì)算,后來(lái)還有顧及賦予親的影響。更新也是將當(dāng)前骨骼到所有子骨、賦予子骨都更新一遍,為了保證更新的子骨用的是親骨更新前的變換矩陣,還搞了個(gè)雙棧,分別前序和后續(xù)遍歷,結(jié)果輸出程序結(jié)果不正確,我也要被繁雜的邏輯搞炸了。完全搞不清到底是IK解算的問(wèn)題,還是更新骨骼的問(wèn)題。
??然后多存了那兩個(gè)變量,更新骨骼簡(jiǎn)化為只更新自身,程序就變得特別清爽和可控;說(shuō)多了都是淚。
??關(guān)于updateSelfAndChildData的更新規(guī)則也是根據(jù)情況來(lái)的。我發(fā)現(xiàn)賦予親和賦予子骨的父骨一般是一樣的,大致情況如下:

紅色是骨骼繼承,藍(lán)色是賦予繼承
,所以一開(kāi)始的繼承規(guī)則是:先更新自身,然后更新賦予子骨(同級(jí)的藍(lán)色繼承),但不會(huì)向下看賦予子骨的子骨和賦予子骨,再深度更新子骨。
??結(jié)果個(gè)別骨骼就特殊了,它只繼承被賦予骨,不繼承普通骨骼,這樣的更新規(guī)則更新不到它,于是我只能設(shè)定更新賦予骨的同時(shí),向下更新一級(jí)直接子骨。
??雖說(shuō)這樣很依賴(lài)模型本身,但也是沒(méi)辦法的事情,假如兩個(gè)骨骼互相認(rèn)爹(反向?qū)嬍谊P(guān)系),那一開(kāi)始個(gè)骨骼樹(shù)解析也是沒(méi)完沒(méi)了的。建模不規(guī)范,程序兩行淚啊。
??前向動(dòng)力學(xué)部分遍歷骨骼樹(shù),要記錄一開(kāi)始的translatequaternion這個(gè)沒(méi)有技術(shù)含量,代碼我就不放了(別忘了賦予親就行),接下來(lái)是重點(diǎn)的IK解算部分。
??我將IK解算器封裝為一個(gè)類(lèi),在此之前先定義一個(gè)內(nèi)部結(jié)構(gòu)IKChain(這個(gè)命名可能有問(wèn)題,應(yīng)該叫IKJoint更準(zhǔn)確)

struct IKChain {
    Animation::BNode* node;
    bool enableAxisLimit;

    glm::vec3 min;
    glm::vec3 max;

    IKChain* pre = nullptr;//前一條鏈節(jié)
    Animation::BNode* endPre = nullptr;//末端效應(yīng)器沒(méi)有鏈節(jié)的屬性,額外存儲(chǔ)

    bool updateChain() {//更新IK鏈
        IKChain* curr = this;
        do {
            curr->node->updateSelfData();//更新自身
            if (curr->pre == nullptr) {//如果是第一個(gè)鏈節(jié),就更新末端效應(yīng)器
                curr->endPre->updateSelfData();
            }
            curr = curr->pre;
        } while (curr != nullptr);
                
        return true;
    }
};

??注意:這是更新順序,不是解算順序,可以理解為:第一次解算更新前一個(gè)關(guān)節(jié)就行,第二次要更新前兩個(gè)……

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命名為T(mén)arget,其實(shí)是末端效應(yīng)器
    limitAngle = ikNode->bone.ikLimit;//單位角,不讓一次旋轉(zhuǎn)角度過(guò)大的限制
    this->ikNode = ikNode;//IK目標(biāo)節(jié)點(diǎn)

    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;
}

??接下來(lái)是重頭戲:

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) {//兩個(gè)向量差距太小,目的達(dá)到,直接退出解算
                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ù)不得超過(guò)限制角

                
            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鏈末端(開(kāi)始節(jié)點(diǎn))更新所有子骨
}

??第一重循環(huán)的循環(huán)次數(shù)模型自己會(huì)給出,第二重循環(huán)是對(duì)每個(gè)關(guān)節(jié)鏈進(jìn)行對(duì)比、旋轉(zhuǎn)。
??關(guān)于那個(gè)逆矩陣是這樣的:我們上次推導(dǎo)了矩陣變換M_{local}*V_{local}=V_{world},其中V_{local}是點(diǎn)所處骨骼空間的坐標(biāo),V_{world}是點(diǎn)所處世界空間的坐標(biāo),左右兩邊的左側(cè)都乘上M_{local}的逆矩陣得到新的等式:V_{local}=M_{local}^{-1}*V_{world},也就是可以輸入世界坐標(biāo),得到骨骼關(guān)節(jié)空間的坐標(biāo),這樣旋轉(zhuǎn)的角度能保證是基于骨骼關(guān)節(jié)坐標(biāo)系的。
??兩個(gè)向量、角度檢測(cè)不得不做,當(dāng)夾角太小時(shí),cos值接近1,求arccos可能會(huì)出現(xiàn)nan。
??是不是很簡(jiǎn)單?(才怪,這程序?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è)由剛剛理論寫(xiě)成的程序生成,末端效應(yīng)器看來(lái)是落在目標(biāo)點(diǎn)上了,不過(guò)小姐姐你的左腿是不是有點(diǎn)不對(duì)勁啊,趕緊去砍醫(yī)生吧。
??才怪了,單單是我們上面那個(gè)三節(jié)點(diǎn)模型,用余弦定理,在三維空間中也能解除無(wú)數(shù)個(gè)解(第二個(gè)關(guān)節(jié)點(diǎn)在空間中一個(gè)圓邊上都可以),類(lèi)似腿這樣的關(guān)節(jié)點(diǎn)都是有限制的,膝蓋關(guān)節(jié)只能在自己關(guān)節(jié)點(diǎn)空間的X軸旋轉(zhuǎn),并且只能向內(nèi)彎折。
??看其他人的程序,以前的PMD模型文件可能給骨骼一個(gè)標(biāo)記,告訴程序:這個(gè)骨骼是膝蓋(isLeg),也有部分程序直接把左右膝蓋的shift-jis編碼值寫(xiě)死在程序里,特殊處理這些關(guān)節(jié)。
??不過(guò)PMX文件的IK鏈中,給了每個(gè)鏈節(jié)的限制范圍(在最上面的圖能看到,注意下上面記錄的都是左手坐標(biāo)系的范圍),由此,我們對(duì)這樣的關(guān)節(jié)特殊處理:

if (chain.enableAxisLimit) {//如果這個(gè)關(guān)節(jié)點(diǎn)有軸限制
    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 {//沒(méi)有軸限制,程序和上邊一樣
    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解算:

其他

??IK解算參考了很多github上現(xiàn)有的代碼,但還是寫(xiě)的頭快禿了,賦予親中間還過(guò)來(lái)?yè)v亂,簡(jiǎn)直……給你們看看我中間調(diào)試時(shí)出現(xiàn)得到N種奇葩狀態(tài):

??模型來(lái)自女仆麗塔-洛絲薇瑟2.0-神帝宇,希望不要打我(滑稽。
??如果還是有些看不懂,可以看看以下的網(wǎng)站:

引用

循環(huán)坐標(biāo)下降(CCD)算法中對(duì)骨骼動(dòng)畫(huà)中膝蓋等關(guān)節(jié)的特殊處理
挺久前一個(gè)先輩的文章,也是搞MMD的
saba-OpenGL Viewer (OBJ PMD PMX)
從我未接觸圖形學(xué)時(shí)就看上了這個(gè)github項(xiàng)目,現(xiàn)在越寫(xiě)越心驚,骨骼動(dòng)畫(huà)、表情動(dòng)畫(huà)、物理都有,以后也要繼續(xù)借鑒這里的代碼
MikuMikuDance PMX/VMD Viewer for Windows, OSX, and Linux
上面的saba項(xiàng)目太龐大,中間封裝了很多層,疑惑的時(shí)候看看輕量些的代碼也是個(gè)不錯(cuò)的選擇
MMDAgent
也是個(gè)不錯(cuò)的借鑒

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容