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

承接前文骨骼動畫理論及程序部分實現(xiàn)(一)前向動力學(xué)吃既。

簡介

??如果說前向動力學(xué)是找到父骨骼的空間行嗤,來變換得到子骨骼空間瓤帚,那么通過子骨骼得到父骨骼的位置,就稱之為反向動力學(xué)松捉,也稱為逆向動力學(xué)夹界。
??常見應(yīng)用于機器人,或者即時演算游戲中隘世,人物腿部在凹凸不平地面中的表現(xiàn)可柿。諸如刺客信條等擁有豐富攀爬動畫系統(tǒng)的游戲?qū)K會有更多的應(yīng)用。

IK解算

基本原理

??對于只有兩個骨骼關(guān)節(jié)點丙者,其中一點固定复斥,那么剩下一點的位置也隨之確定:

黑X是目標(biāo)點
??這樣末端點無法到達目標(biāo),但方向總是能確定的械媒。
??對于有三個關(guān)節(jié)點的問題目锭,可以用余弦定理解決
??轉(zhuǎn)換成三角形問題,已知三角形三邊長度求夾角纷捞,余弦定理已經(jīng)給出了公式:
??常見的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始鱼,同時和它相疊加在一起(位置一樣)的還有左足首仔掸。
??其中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)賦予親和賦予子骨的父骨一般是一樣的滴须,大致情況如下:

紅色是骨骼繼承舌狗,藍色是賦予繼承
,所以一開始的繼承規(guī)則是:先更新自身扔水,然后更新賦予子骨(同級的藍色繼承)痛侍,但不會向下看賦予子骨的子骨和賦予子骨,再深度更新子骨魔市。
??結(jié)果個別骨骼就特殊了主届,它只繼承被賦予骨,不繼承普通骨骼待德,這樣的更新規(guī)則更新不到它岂膳,于是我只能設(shè)定更新賦予骨的同時,向下更新一級直接子骨磅网。
??雖說這樣很依賴模型本身,但也是沒辦法的事情筷屡,假如兩個骨骼互相認爹(反向?qū)嬍谊P(guān)系)涧偷,那一開始個骨骼樹解析也是沒完沒了的簸喂。建模不規(guī)范,程序兩行淚啊燎潮。
??前向動力學(xué)部分遍歷骨骼樹喻鳄,要記錄一開始的translatequaternion這個沒有技術(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)了矩陣變換M_{local}*V_{local}=V_{world}慎璧,其中V_{local}是點所處骨骼空間的坐標(biāo),V_{world}是點所處世界空間的坐標(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)系的盖文。
??兩個向量、角度檢測不得不做蚯姆,當(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解算:

其他

??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
也是個不錯的借鑒

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市趟妥,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌佣蓉,老刑警劉巖披摄,帶你破解...
    沈念sama閱讀 222,104評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異勇凭,居然都是意外死亡疚膊,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,816評論 3 399
  • 文/潘曉璐 我一進店門虾标,熙熙樓的掌柜王于貴愁眉苦臉地迎上來寓盗,“玉大人,你說我怎么就攤上這事璧函】觯” “怎么了?”我有些...
    開封第一講書人閱讀 168,697評論 0 360
  • 文/不壞的土叔 我叫張陵蘸吓,是天一觀的道長善炫。 經(jīng)常有香客問我,道長库继,這世上最難降的妖魔是什么箩艺? 我笑而不...
    開封第一講書人閱讀 59,836評論 1 298
  • 正文 為了忘掉前任,我火速辦了婚禮宪萄,結(jié)果婚禮上艺谆,老公的妹妹穿的比我還像新娘。我一直安慰自己拜英,他們只是感情好静汤,可當(dāng)我...
    茶點故事閱讀 68,851評論 6 397
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著聊记,像睡著了一般撒妈。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上排监,一...
    開封第一講書人閱讀 52,441評論 1 310
  • 那天狰右,我揣著相機與錄音,去河邊找鬼舆床。 笑死棋蚌,一個胖子當(dāng)著我的面吹牛嫁佳,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播谷暮,決...
    沈念sama閱讀 40,992評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼蒿往,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了湿弦?” 一聲冷哼從身側(cè)響起瓤漏,我...
    開封第一講書人閱讀 39,899評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎颊埃,沒想到半個月后蔬充,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,457評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡班利,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,529評論 3 341
  • 正文 我和宋清朗相戀三年饥漫,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片罗标。...
    茶點故事閱讀 40,664評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡庸队,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出闯割,到底是詐尸還是另有隱情彻消,我是刑警寧澤,帶...
    沈念sama閱讀 36,346評論 5 350
  • 正文 年R本政府宣布纽谒,位于F島的核電站证膨,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏鼓黔。R本人自食惡果不足惜央勒,卻給世界環(huán)境...
    茶點故事閱讀 42,025評論 3 334
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望澳化。 院中可真熱鬧崔步,春花似錦、人聲如沸缎谷。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,511評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽列林。三九已至瑞你,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間希痴,已是汗流浹背者甲。 一陣腳步聲響...
    開封第一講書人閱讀 33,611評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留砌创,地道東北人虏缸。 一個月前我還...
    沈念sama閱讀 49,081評論 3 377
  • 正文 我出身青樓鲫懒,卻偏偏與公主長得像,于是被迫代替她去往敵國和親刽辙。 傳聞我的和親對象是個殘疾皇子窥岩,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,675評論 2 359

推薦閱讀更多精彩內(nèi)容