Skinned Mesh原理解析和一個最簡單的實現示例
作者:n5
Email: happyfirecn@yahoo.com.cn
Blog: http://blog.csdn.net/n5
2008-10月
Histroy:
Version:1.01 Date:2008-11-01
修改了一些不精確的用語
Version:1.00 Date:2008-10-19
講述骨骼動畫的資料很多,但大部分都是針對DX8或DX9的SkinnedMesh進行講解。我覺得對于骨骼動畫初學者增加了不必要的負擔,還沒有理解骨骼動畫的實質就已被DX復雜的架構搞得暈頭轉向了。這篇文章把注意力集中在骨骼動畫的基本組成結構和原理上,并實現了一個最簡單純手工的自定義骨骼動畫例子幫助理解(使用最簡單的OpenGL指令,甚至沒有使用矩陣)。這篇文章在我學習理解骨骼動畫的過程中逐步完善,是對這個技術的理解總結,屬于學習筆記。學習過程中參考了很多資料,其中給我啟發最大的是Frank Luna寫的”Skinned Mesh Character Animation with Direct3D 9.0c”。由于本人自身也是初學者,所以錯誤和不精確的地方在所難免,歡迎指正和討論,請發郵件到happyfirecn@yahoo.com.cn
或加入3DGameStudy郵件列表:http://happyfire.googlepages.com/3dgamestudymaillist。另外文本不涉及任何高級骨骼動畫技術,也不涉及DX架構的SkinnedMesh技術和硬件加速,但本文中會引用SkinnedMesh中的約定俗成的名詞,如Transform Matrix,Bone Offset Matrix等。
一)3D模型動畫基本原理和分類
3D模型動畫的基本原理是讓模型中各頂點的位置隨時間變化。主要種類有Morph動畫,關節動畫和骨骼蒙皮動畫(Skinned Mesh)。從動畫數據的角度來說,三者一般都采用關鍵幀技術,即只給出關鍵幀的數據,其他幀的數據使用插值得到。但由于這三種技術的不同,關鍵幀的數據是不一樣的。
Morph(漸變,變形)動畫是直接指定動畫每一幀的頂點位置,其動畫關鍵中存儲的是Mesh所有頂點在關鍵幀對應時刻的位置。
關節動畫的模型不是一個整體的Mesh,而是分成很多部分(Mesh),通過一個父子層次結構將這些分散的Mesh組織在一起,父Mesh帶動其下子Mesh的運動,各Mesh中的頂點坐標定義在自己的坐標系中,這樣各個Mesh是作為一個整體參與運動的。動畫幀中設置各子Mesh相對于其父Mesh的變換(主要是旋轉,當然也可包括移動和縮放),通過子到父,一級級的變換累加(當然從技術上,如果是矩陣操作是累乘)得到該Mesh在整個動畫模型所在的坐標空間中的變換(從本文的視角來說就是世界坐標系了,下同),從而確定每個Mesh在世界坐標系中的位置和方向,然后以Mesh為單位渲染即可。關節動畫的問題是,各部分Mesh中的頂點是固定在其Mesh坐標系中的,這樣在兩個Mesh結合處就可能產生裂縫。
第三類就是骨骼蒙皮動畫即Skinned Mesh了,骨骼蒙皮動畫的出現解決了關節動畫的裂縫問題,而且效果非常酷,發明這個算法的人一定是個天才,因為Skinned Mesh的原理簡單的難以置信,而效果卻那么好。骨骼動畫的基本原理可概括為:在骨骼控制下,通過頂點混合動態計算蒙皮網格的頂點,而骨骼的運動相對于其父骨骼,并由動畫關鍵幀數據驅動。一個骨骼動畫通常包括骨骼層次結構數據,網格(Mesh)數據,網格蒙皮數據(skin info)和骨骼的動畫(關鍵幀)數據。下面將具體分析。
二)Skinned Mesh原理和結構分析
Skinned Mesh中文一般稱作骨骼蒙皮動畫,正如其名,這種動畫中包含骨骼(Bone)和蒙皮(Skinned Mesh)兩個部分,Bone的層次結構和關節動畫類似,Mesh則和關節動畫不同:關節動畫中是使用多個分散的Mesh,而Skinned Mesh中Mesh是一個整體,也就是說只有一個Mesh,實際上如果沒有骨骼讓Mesh運動變形,Mesh就和靜態模型一樣了。Skinned Mesh技術的精華在于蒙皮,所謂的皮并不是模型的貼圖(也許會有人這么想過吧),而是Mesh本身,蒙皮是指將Mesh中的頂點附著(綁定)在骨骼之上,而且每個頂點可以被多個骨骼所控制,這樣在關節處的頂點由于同時受到父子骨骼的拉扯而改變位置就消除了裂縫。Skinned Mesh這個詞從字面上理解似乎是有皮的模型,哦,如果貼圖是皮,那么普通靜態模型不也都有嗎?所以我覺得應該理解為具有蒙皮信息的Mesh或可當做皮膚用的Mesh,這個皮膚就是Mesh。而為了有皮膚功能,Mesh還需要蒙皮信息,即Skin數據,沒有Skin數據就是一個普通的靜態Mesh了。Skin數據決定頂點如何綁定到骨骼上。頂點的Skin數據包括頂點受哪些骨骼影響以及這些骨骼影響該頂點時的權重(weight),另外對于每塊骨骼還需要骨骼偏移矩陣(BoneOffsetMatrix)用來將頂點從Mesh空間變換到骨骼空間。在本文中,提到骨骼動畫中的Mesh特指這個皮膚Mesh,提到模型是指骨骼動畫模型整體。骨骼控制蒙皮運動,而骨骼本身的運動呢?當然是動畫數據了。每個關鍵幀中包含時間和骨骼運動信息,運動信息可以用一個矩陣直接表示骨骼新的變換,也可用四元數表示骨骼的旋轉,也可以隨便自己定義什么只要能讓骨骼動就行。除了使用編輯設定好的動畫幀數據,也可以使用物理計算對骨骼進行實時控制。
下面分別具體分析骨骼蒙皮動畫中的結構部件。
1)理解骨骼和骨骼層次結構(Bone Hierarchy)
首先要明確一個觀念:骨骼決定了模型整體在世界坐標系中的位置和朝向。
先看看靜態模型吧,靜態模型沒有骨骼,我們在世界坐標系中放置靜態模型時,只要指定模型自身坐標系在世界坐標系中的位置和朝向。在骨骼動畫中,不是把Mesh直接放到世界坐標系中,Mesh只是作為Skin使用的,是依附于骨骼的,真正決定模型在世界坐標系中的位置和朝向的是骨骼。在渲染靜態模型時,由于模型的頂點都是定義在模型坐標系中的,所以各頂點只要經過模型坐標系到世界坐標系的變換后就可進行渲染。而對于骨骼動畫,我們設置模型的位置和朝向,實際是在設置根骨骼的位置和朝向,然后根據骨骼層次結構中父子骨骼之間的變換關系計算出各個骨骼的位置和朝向,然后根據骨骼對Mesh中頂點的綁定計算出頂點在世界坐標系中的坐標,從而對頂點進行渲染。要記住,在骨骼動畫中,骨骼才是模型主體,Mesh不過是一層皮,一件衣服。
如何理解骨骼?請看第二個觀念:骨骼可理解為一個坐標空間。
在一些文章中往往會提到關節和骨骼,那么關節是什么?骨骼又是什么?下圖是一個手臂的骨骼層次的示例。

骨骼只是一個形象的說法,實際上骨骼可理解為一個坐標空間,關節可理解為骨骼坐標空間的原點。關節的位置由它在父骨骼坐標空間中的位置描述。上圖中有三塊骨骼,分別是上臂,前臂和兩個手指。Clavicle(鎖骨)是一個關節,它是上臂的原點,同樣肘關節(elbow joint)是前臂的原點,腕關節(wrist)是手指骨骼的原點。關節既決定了骨骼空間的位置,又是骨骼空間的旋轉和縮放中心。為什么用一個4X4矩陣就可以表達一個骨骼,因為4X4矩陣中含有的平移分量決定了關節的位置,旋轉和縮放分量決定了骨骼空間的旋轉和縮放。我們來看前臂這個骨骼,其原點位置是位于上臂上某處的,對于上臂來說,它知道自己的坐標空間某處(即肘關節所在的位置)有一個子空間,那就是前臂,至于前臂里面是啥就不考慮了。當前臂繞肘關節旋轉時,實際是前臂坐標空間在旋轉,從而其中包含的子空間也在繞肘關節旋轉,在這個例子中是finger骨骼。和實際生物骨骼不同的是,我們這里的骨骼并沒有實質的骨頭,所以前臂旋轉時,他自己沒啥可轉的,改變的只是坐標空間的朝向。你可以說上圖的藍線在轉,但實際藍線并不存在,藍線只是畫上去表示骨骼之間關系的,真正轉的是骨骼空間,我們能看到在轉的是wrist joint,也就是兩個finger骨骼的坐標空間,因為他們是子空間,會跟隨父空間運動,就好比人跟著地球轉一樣。
骨骼就是坐標空間,骨骼層次就是嵌套的坐標空間。關節只是描述骨骼的位置即骨骼自己的坐標空間原點在其父空間中的位置,繞關節旋轉是指骨骼坐標空間(包括所有子空間)自身的旋轉,如此理解足矣。但還有兩個可能的疑問,一是骨骼的長度問題,由于骨骼是坐標空間,沒有所謂的長度和寬度的限制,我們看到的長度一方面是蒙皮后的結果,另一方面子骨骼的原點(也就是關節)的位置往往決定了視覺上父骨骼的長度,比如這里upper arm線段的長度實際是由elbow joint的位置決定的。第二個問題,手指的那個端點是啥啊?實際上在我們的例子中手指沒有子骨骼,所以那個端點并不存在:)那是為了方便演示畫上去的。實際問題中總有最下層的骨骼,他們不能決定其他骨骼了,他們的作用只剩下控制Mesh頂點。對了,那么手指的長度如何確定?我們看到的長度應該是由蒙皮決定的,也就是由Mesh中屬于手指的那些點離腕關節的距離決定。
經過一段長篇大論,我們終于清楚骨骼和骨骼層次是啥了,但是為什么要將骨骼組織成層次結構呢?答案是為了做動畫方便,設想如果只有一塊骨骼,那么讓他動起來就太簡單了,動畫每一幀直接指定他的位置即可。如果是n塊呢?通過組成一個層次結構,就可以通過父骨骼控制子骨骼的運動,牽一發而動全身,改變某骨骼時并不需要設置其下子骨骼的位置,子骨骼的位置會通過計算自動得到。上文已經說過,父子骨骼之間的關系可以理解為,子骨骼位于父骨骼的坐標系中。我們知道物體在坐標系中可以做平移變換,以及自身的旋轉和縮放變換。子骨骼在父骨骼的坐標系中也可以做這些變換來改變自己在其父骨骼坐標系中的位置和朝向等。那么如何表示呢?由于4X4矩陣可以同時表示上述三種變換,所以一般描述骨骼在其父骨骼坐標系中的變換時使用一個矩陣,也就是DirectX SkinnedMesh中的FrameTransformMatrix。實際上這不是唯一的方法,但應該是公認的方法,因為矩陣不光可以同時表示多種變換還可以方便的通過連乘進行變換的組合,這在層次結構中非常方便。在本文的例子-最簡單的skinned mesh實例中,我只演示了平移變換,所以只用一個3d坐標就可以表示子骨骼在父骨骼中的位置。下面是Bone Class最初的定義:
class Bone
{
public:
float m_x, m_y, m_z;//這個坐標是定義在父骨骼坐標系中的
};
OK,除了使用矩陣,坐標或某東西描述子骨骼的位置,我們的Bone Class定義中還需要一些指針來建立層次結構,也就是說我們要能通過父骨骼找到子骨骼或反之。問題是我們需要什么指針呢?從父指向子還是反之?結論是看你需要怎么用了。如果使用矩陣,需要將父子骨骼矩陣級聯相乘,無論你的矩陣是左乘列向量還是右乘行向量,從哪邊開始乘不重要,只要乘法中父子矩陣的左右位置正確,所以可以在骨骼中只存放指向父的指針,從子到父每次得到父矩陣循環相乘。也可以像DX中那樣從根開始相乘并遞歸。在文本的DEMO中由于沒用矩陣,直接使用坐標相加計算坐標,所以要指定父的位置,然后計算出子的位置,那么需要在Bone Class中加入子骨骼的指針,因為子骨骼有n個,所以需要n個指針嗎?不一定,看看DirectX的做法,只需要兩個就搞定了,指向第一子的和指向兄弟骨骼的。這樣事先就不需要知道有多少子了。下面是修改后的Bone Class:
class Bone
{
Bone* m_pSibling;
Bone* m_pFirstChild;
float m_x, m_y, m_z;//pos in its parent's space
float m_wx, m_wy, m_wz; //pos in world space
};
同時增加了一組坐標,存放計算好的世界坐標系坐標。
將各個骨骼相對于其父骨骼擺放好,就行成了一個骨骼層次結構的初始位置,所謂初始是指定義骨骼層次時,那后來呢?后來動畫改變了骨骼的相對位置,準確的說一般是改變了骨骼自身的旋轉而位置保持不變(特殊情況總是存在,比如雷曼,可以把拳頭扔出去的那個家伙),總之骨骼動了,位置變化了。初始位置很重要,因為通過初始位置骨骼層次間的變換,我們確定了骨骼之間的關系,然后在動畫中你可以只用旋轉。
假設我們通過某種方法建立了骨骼層次結構,那么每一塊骨骼的位置都依賴于其父骨骼的位置,而根骨骼沒有父,他的位置就是整個骨骼體系在世界坐標系中的位置。可以認為root的父就是世界坐標系。但是初始位置時,根骨骼一般不是在世界原點的,比如使用3d max character studio創建的biped骨架時,一般兩腳之間是世界原點,而根骨骼-骨盆位于原點上方(+z軸上)。這有什么關系呢?其實也沒什么大不了的,只是我們在指定骨骼動畫模型整體坐標時,比如設定坐標為(0,0,0),則根骨骼-骨盆被置于世界原點,假如xy平面是地面,那么人下半個身子到地面下了。我們想讓兩腳之間算作人的原點,這樣設定(0,0,0)的坐標時人就站在地面上了,所以可以在兩腳之間設定一個額外的根骨骼放在世界原點上,或者這個骨骼并不需要真實存在,只是在你的骨骼模型結構中保存骨盆骨骼到世界原點的變換矩陣。在微軟X文件中,一般有一個Scene_Root節點,這算一個額外的骨骼吧,他的變換矩陣為單位陣,表示他初始位于世界原點,而真正骨骼的根Bip01,作為Scene_root的子骨骼,其變換矩陣表示相對于root的位置。說這么多其實我只是想解釋下,為什么要存在Scene_Root這種額外的骨骼,以及加深理解骨骼定位骨骼動畫模型整體的世界坐標的作用。
有了骨骼類,現在讓我們看一下建立骨骼層次的代碼,在bone class中增加一個構造函數和兩個成員函數:
class Bone
{
public:
Bone(float x, float y, float z)
:m_pSibling(NULL),m_pFirstChild(NULL),m_pFather(NULL),
m_x(x),m_y(y),m_z(z){}
void SetFirstChild(Bone* pChild)
{
m_pFirstChild = pChild; m_pFirstChild->m_pFather = this;
}
void SetSibling(Bone* pSibling)
{
m_pSibling = pSibling; m_pSibling->m_pFather = m_pFather;
}
};
注意我增加了一個成員變量,Bone* m_pFather,這是指向父骨骼的指針,在這個例子中計算骨骼動畫時本不需要這個指針,但我為了畫一條從父骨骼關節到子骨骼關節的連線,增加了它,因為每個骨骼只有第一子骨骼的指針,繪制父骨骼時從父到子畫線就只能畫一條,所以記錄每個骨骼的父,在繪制子骨骼時畫這根線。
有了這個函數,就可以創建骨骼層次了,例如:
Bone* g_boneRoot;
Bone* g_bone1, *g_bone21, *g_bone22;
void buildBones()
{
g_boneRoot = new Bone(0, 0, 0);
g_bone1 = new Bone(0.1, 0, 0);
g_bone21 = new Bone(0.0, 0.1, 0);
g_bone22 = new Bone(0.1, 0.0, 0);
g_boneRoot->SetFirstChild(g_bone1);
g_bone1->SetFirstChild(g_bone21);
g_bone21->SetSibling(g_bone22);
}
接下來是骨骼層次中最核心的部分,更新骨骼!由于動畫的作用,某個骨骼的變換(TransformMatrix)變了,這時就要根據新的變換來計算,所以這個過程一般稱作UpdateBoneMatrix。因為骨骼的變換都是相對父的,要變換頂點必須使用世界變換矩陣,所以這個過程是根據更新了的某些骨骼的骨骼變換矩陣(TransformMatrix)計算出所有骨骼的世界變換矩陣(也即CombinedMatrix)。在本文的例子中,骨骼只能平移,甚至我們沒有用矩陣,所以當有骨骼變動時要做的只是直接計算骨骼的世界坐標,因此函數命名為ComputeWorldPos,相當于UpdateBoneMatrix后再用頂點乘以CombinedMatrix。
class Bone
{
//give father's world pos, compute the bone's world pos
void ComputeWorldPos(float fatherWX, float fatherWY, float fatherWZ)
{
m_wx = fatherWX+m_x;
m_wy = fatherWY+m_y;
m_wz = fatherWZ+m_z;
if(m_pSibling!=NULL)
m_pSibling->ComputeWorldPos(fatherWX, fatherWY, fatherWZ);
if(m_pFirstChild!=NULL)
m_pFirstChild->ComputeWorldPos(m_wx, m_wy, m_wz);
}
};
其中的遞歸調用使用了微軟例子的思想。
有了上述函數,當某骨骼運動時就可以讓其子骨骼跟隨運動了,但是怎么讓骨骼運動呢?這就是動畫問題了。我不打算在這個簡單的例子中使用關鍵幀動畫,而只是通過程序每幀改變某些骨骼的位置,DEMO中animateBones就是做這個的,你可以在里面改變不同的骨骼看看效果。在本文下面會對骨骼的關鍵幀動畫做簡單的討論。
至此,我們定義了骨骼類的結構,手工創建了骨骼層次(實際引擎應該從文件讀入),并且可以根據新位置更新骨骼了(實際引擎應該從動畫數據讀入新的變換或使用物理計算),這樣假如我們用連線將骨骼畫出來,并且讓某個骨骼動起來,我們就會看見他下面的子骨骼跟著動了。當然只有骨骼是不夠的,我們要讓Mesh跟隨骨骼運動,下面就是蒙皮了。
2)蒙皮信息和蒙皮過程
2-1)Skin info的定義
上文曾討論過,Skinned Mesh中Mesh是作為皮膚使用,蒙在骨骼之上的。為了讓普通的Mesh具有蒙皮的功能,必須添加蒙皮信息,即Skin info。我們知道Mesh是由頂點構成的,建模時頂點是定義在模型自身坐標系的,即相對于Mesh原點的,而骨骼動畫中決定模型頂點最終世界坐標的是骨骼,所以要讓骨骼決定頂點的世界坐標,這就要將頂點和骨骼聯系起來,Skin info正是起了這個作用。下面是DEMO中頂點類的定義的代碼片段:
#define MAX_BONE_PER_VERTEX 4
class Vertex
{
float m_x, m_y, m_z; //local pos in mesh space
float m_wX, m_wY, m_wZ;//blended vertex pos, in world space
//skin info
int m_boneNum;
Bone* m_bones[MAX_BONE_PER_VERTEX];
float m_boneWeights[MAX_BONE_PER_VERTEX];
};
頂點的Skin info包含影響該頂點的骨骼數目,指向這些骨骼的指針,這些骨骼作用于該頂點的權重(Skin weight)。由于只是一個簡單的例子,這兒沒有考慮優化,所以用靜態數組存放骨骼指針和權重,且實際引擎中Skin info的定義方式不一定是這樣的,但基本原理一致。
MAX_BONE_PER_VERTEX在這兒用來設置可同時影響頂點的最大骨骼數,實際上由于這個DEMO是手工進行Vertex Blending并且也沒用硬件加速,可影響頂點的骨骼數量并沒有限制,只是恰好需要一個常量來定義數組,所以定義了一下。在實際引擎中由于要使用硬件加速,以及為了確保速度,一般會定義最大骨骼數。另外在本DEMO中,Skin info是手工設定的,而在實際項目中,一般是在建模軟件中生成這些信息并導出。
Skin info的作用是使用各個骨骼的變換矩陣對頂點進行變換并乘以權重,這樣某塊骨骼只能對該頂點產生部分影響。各骨骼權重之和應該為1。
Skin info是針對頂點的,然而在使用Skin info前我們必須要使用Bone Offset Matrix對頂點進行變換,下面具體討論Bone offset Matrix。(寫下這句話的時候我感覺有些不妥,因為實際是先將所有的矩陣相乘最后再作用于頂點,這兒是按照理論上的順序進行講述吧,請不要與實際情況混淆,其實他們也并不矛盾。而且在我們的DEMO中由于沒有使用矩陣,所以變換的順序和理論順序是一致的)
2-2)Bone Offset Matrix的含義和計算方法
上文已經說過:“骨骼動畫中決定模型頂點最終世界坐標的是骨骼,所以要讓骨骼決定頂點的世界坐標”,現在讓我們看下頂點受一塊骨骼的作用時的坐標變換過程:
mesh vertex (defined in mesh space)---<BoneOffsetMatrix>--->Bone space
---<BoneCombinedTransformMatrix>--->World
從這個過程中可看出,需要首先將模型頂點從模型空間變換到某塊骨骼自身的骨骼空間,然后才能利用骨骼的世界變換計算頂點的世界坐標。Bone Offset Matrix的作用正是將模型從頂點空間變換到骨骼空間。那么Bone Offset Matrix如何得到呢?下面具體分析:
Mesh space是建模時使用的空間,mesh中頂點的位置相對于這個空間的原點定義。比如在3d max中建模時(視xy平面為地面,+z朝上),可將模型兩腳之間的中點作為Mesh空間的原點,并將其放置在世界原點,這樣左腳上某一頂點坐標是(10,10,2),右腳上對稱的一點坐標是(-10,10,2),頭頂上某一頂點的坐標是(0,0,170)。由于此時Mesh空間和世界空間重合,上述坐標既在Mesh空間也在世界空間,換句話說,此時實際是以世界空間作為Mesh空間了。在骨骼動畫中,在世界中放置的是骨骼而不是Mesh,所以這個區別并不重要。在3d max中添加骨骼的時候,也是將骨骼放入世界空間中,并調整骨骼的相對位置使得和mesh相吻合(即設置骨骼的TransformMatrix),得到骨架的初始姿勢以及相應的Transform Matrix(按慣例模型做成兩臂側平舉直立,骨骼也要適合這個姿態)。由于骨骼的Transform Matrix(作用是將頂點從骨骼空間變換到上層空間)是基于其父骨骼空間的,只有根骨骼的Transform是基于世界空間的,所以要通過自下而上一層層Transform變換(如果使用行向量右乘矩陣,這個Transform的累積過程就是C=Mbone*Mfather*Mgrandpar*...*Mroot),得到該骨骼在世界空間上的變換矩陣 - Combined Transform Matrix,即通過這個矩陣可將頂點從骨骼空間變換到世界空間。那么這個矩陣的逆矩陣就可以將世界空間中的頂點變換到某塊骨骼的骨骼空間。由于Mesh實際上就是定義在世界空間了,所以這個逆矩陣就是Offset Matrix。即OffsetMatrix就是骨骼在初始位置(沒有經過任何動畫改變)時將bone變換到世界空間的矩陣(CombinedTransformMatrix)的逆矩陣,有一些資料稱之為InverseMatrix。在幾何流水線中,是通過變換矩陣將頂點變換到上層空間,最終得到世界坐標,逆矩陣則做相反的事,所以Inverse這種提法也符合慣例。那么Offset這種提法從字面上怎么理解呢?Offset即骨骼相對于世界原點的偏移,世界原點加上這個偏移就變成骨骼空間的原點,同樣定義在世界空間中的點經過這個偏移矩陣的作用也被變換到骨骼空間了。從另一角度理解,在動畫中模型中頂點的位置是根據骨骼位置動態計算的,也就是說頂點跟著骨骼動,但首先必須確定頂點和骨骼之間的相對位置(即頂點在該骨骼坐標系中的位置),一個骨骼可能對應很多頂點,如果要保存這個相對位置每個頂點對于每塊受控制的骨骼都要保存,這樣就要保存太多的矩陣了。。。所以只保存mesh空間到骨骼空間的變換(即OffsetMatrix),然后通過這個變換計算每個頂點在該骨骼空間中的坐標,所以OffsetMatrix也反應了mesh和每塊骨骼的相對位置,只是這個位置是間接的通過和世界坐標空間的關系表達的,在初始位置將骨骼按照模型的形狀擺好是關鍵之處。
以上的分析是通過將mesh space和world space重合得到Offset Matrix的計算方法。那么如果他們不重合呢?那就要先計算頂點從mesh space變換到world space的變換矩陣,并乘上(還是右乘為例)Combined Matrix的Inverse Matrix從而得到Offset Matrix。但是這不是找麻煩嗎?因為Mesh的原點在哪兒并不重要,為啥不讓他們重合呢?
還有一個問題是,既然Offset Matrix可以計算出來,為啥還要在骨骼動畫文件中同時提供TransformMatrix和OffsetMatrix呢?實際上文件中確實可以不提供OffsetMatrix,而只在載入時計算。但TransformMatrix不可缺少,動畫關鍵幀數據一般只存儲骨骼的旋轉和根骨骼的位置,骨骼間的相對位置還是要靠TransformMatrix提供。在微軟的X文件結構中提供了OffsetMatrix,原因是什么呢?我不知道。我猜想一個可能的原因是為了兼容性和靈活性,比如mesh并沒有定義在世界坐標系,而是作為一個object放置在3d max中,在導出骨骼動畫時不能簡單的認為mesh的頂點坐標是相對于世界原點的,還要把這個object的位置考慮進去,于是導出插件要計算出OffsetMatrix并保存在x文件中以避免兼容性問題。
關于OffsetMatrix和TransformMatrix含有平移,旋轉和縮放的討論:
首先,OffsetMatrix取決于骨骼的初始位置(即TransformMatrix),由于骨骼動畫中我們使用的是動畫中的位置,初始位置是什么樣并不重要,所以可以在初始位置中只包含平移,而旋轉和縮放在動畫中設置(一般也僅僅使用旋轉,這也是為啥動畫通常中可以用一個四元數表示骨骼的關鍵幀)。在這種情況下,OffsetMatrix只包含平移即可。因此一些引擎的Bone中不存放Transform矩陣,而只存放骨骼在父骨骼空間中的坐標,然后旋轉只在動畫幀中設置,最基本的骨骼動畫即可實現。但也可在Transform和Offset Matrix中包括旋轉和縮放,這樣可以提高創建動畫時的容錯性。
在本文DEMO中,我們也沒有使用矩陣保存Bone Offset,而只用了一個坐標保存偏移位置。
class BoneOffset
{
public:
float m_offx, m_offy, m_offz;
};
在Bone class中,有一個方法用來計算Bone Offset
class Bone
{
public:
BoneOffset m_boneOffset;
//called after ComputeWorldPos() when bone loaded but not animated
void ComputeBoneOffset()
{
m_boneOffset.m_offx = -m_wx;
m_boneOffset.m_offy = -m_wy;
m_boneOffset.m_offz = -m_wz;
if(m_pSibling!=NULL)
m_pSibling->ComputeBoneOffset();
if(m_pFirstChild!=NULL)
m_pFirstChild->ComputeBoneOffset();
}
};
在ComputeBoneOffset()中,使用計算好的骨骼的世界坐標來計算bone offset,這兒的計算只是取一個負數,在實際引擎中,如果bone offset是一個矩陣,這兒就應該是求逆矩陣,其實由于旋轉矩陣是正交的,只要求出旋轉矩陣的轉置矩陣,并將平移部分取反即可,本文不做討論了。注意由于我們計算Bone offset時是使用計算好的世界坐標,所以在這之前必須在初始位置時對根骨骼調用ComputeWorldPos()以計算出各個骨骼在初始位置時的世界坐標。
2-3)最終:頂點混合(vertex blending)
現在我們有了Skin info,有了Bone offset,可謂萬事具備,只欠東風了。現在就可以做頂點混合了,這是骨骼動畫的精髓所在,正是這個技術消除了關節處的裂縫。頂點混合后得到了頂點新的世界坐標,對所有的頂點執行vertex blending后,從Mesh的角度看,Mesh deform(變形)了,變成動畫需要的形狀了。
首先,讓我們看看使用單塊骨骼對頂點進行作用的過程,以下是DEMO中的相關代碼:
class Vertex
{
public:
void ComputeWorldPosByBone(Bone* pBone, float& outX, float& outY, float& outZ)
{
//step1: transform vertex from mesh space to bone space
outX = m_x+pBone->m_boneOffset.m_offx;
outY = m_y+pBone->m_boneOffset.m_offy;
outZ = m_z+pBone->m_boneOffset.m_offz;
//step2: transform vertex from bone space to world sapce
outX += pBone->m_wx;
outY += pBone->m_wy;
outZ += pBone->m_wz;
}
};
這個函數使用一塊骨骼對頂點進行變換,將頂點從Mesh坐標系變換到世界坐標系,這兒使用了骨骼的Bone Offset Matrix和 Combined Transform Matrix (嗯,我知道這兒沒用矩陣,但意思是一樣的對嗎)
對于多塊骨骼,對每塊骨骼執行這個過程并將結果根據權重混合(即vertex blending)就得到頂點最終的世界坐標。進行vertex blending的代碼如下:
class Vertex
{
void BlendVertex()
{//do the vertex blending,get the vertex's pos in world space
m_wX = 0;
m_wY = 0;
m_wZ = 0;
for(int i=0; i<m_boneNum; ++i)
{
float tx, ty, tz;
ComputeWorldPosByBone(m_bones[i], tx, ty, tz);
tx*= m_boneWeights[i];
ty*= m_boneWeights[i];
tz*= m_boneWeights[i];
m_wX += tx;
m_wY += ty;
m_wZ += tz;
}
}
};
這些函數我都放在Vertex類中了,因為只是一個簡單DEMO所以沒有特別考慮引擎結構問題,在BlendVertex()中,遍歷影響該頂點的所有骨骼,用每塊骨骼計算出頂點的世界坐標,然后使用Skin Weight對這些坐標進行加權平均。tx,ty,tz是某塊骨骼作用后頂點的世界坐標乘以權重后的值,這些值相加后就是最終的世界坐標了。
現在讓我們用一個公式回顧一下Vertex blending的整個過程(使用矩陣變換)
Vworld = Vmesh * BoneOffsetMatrix1 * CombindMatrix1 * Weight1
+ Vmesh* BoneOffsetMatrix2 * CombinedMatrix2 * Weight2
+ …
+ Vmesh * BoneOffsetMatrixN * CombindMatrixN * WeightN
(這個公式使用的是行向量左乘矩陣)
由于BoneOffsetMatrix和Combined Matrix都是矩陣,可以先相乘這樣就減少很多計算了,在實際PC游戲中可以使用VS進行硬件加速計算。
3)動畫數據和播放動畫
正如前面所說,本例子中并沒有使用動畫數據,但動畫數據在骨骼動畫中確實最重要的,因為我們的最終目的就是播放動畫。所以作為DEMO的補充,這兒簡要討論一下動畫數據相關問題。其實我覺得動畫的處理在骨骼動畫中是很靈活的,需要專門的一篇文章討論。
本文的最開始說,3D模型動畫的基本原理是讓模型中各頂點的位置隨時間變化。骨骼動畫的情況是,骨骼的位置隨時間變化,頂點位置隨骨骼變化。所以動畫數據中必然包含的是骨骼的運動信息。可以在動畫幀中包含某時刻骨骼的Transform Matrix,但骨骼一般只是做旋轉,所以也可以用一個四元數表示。但有時候骨骼層次整體會在動畫中進行平移,所以可能需要在動畫幀中包含根骨骼的位置信息。播放動畫時,給出當前播放的時間值,對于每塊需要動畫的骨骼,根據這個值找出該骨骼前后兩個關鍵幀,根據時間差進行插值,對于四元數要使用四元數球面線性插值。然后將插值得到的四元數轉換成Transform Matrix,再調用UpdateBoneMatrix(其含義上文已介紹)更新計算整個骨骼層次的CombinedMatrix。
4)總結
從結構上看,SkinnedMesh包括:動畫數據,骨骼數據,包含Skin info的Mesh數據,以及Bone Offset Matrix。
從過程上看,載入階段:載入并建立骨骼層次結構,計算或載入Bone Offset Matrix,載入Mesh數據和Skin info(如果是DX的Skinned Mesh這個過程更復雜,因為還涉及到Matrix Palette等)。運行階段:根據時間從動畫數據中獲取骨骼當前時刻的Transform Matrix,調用UpdateBoneMatrix計算出各骨骼的CombinedMatrix,對于每個頂點根據Skin info進行Vertex Blending計算出頂點的世界坐標,最終進行模型的渲染。
三)關于本文的例子
這個例子做了盡可能的簡化,只包含一個cpp文件,使用OpenGL和GLUT作為渲染器和框架,僅有400多行代碼。例子中手工創建了一個骨骼層次和Mesh,手工設置Skin info并自動計算BoneOffset,使用程序控制骨骼平移演示了骨骼層次的運動和骨骼影響下Mesh頂點的運動,例子中甚至沒有使用矩陣。本例子僅作理解骨骼動畫之用。

截圖中綠色網格是模型原始形狀,藍色是骨骼,紅色是動畫時的模型形狀。DEMO中左數第二個骨骼做上下運動,最下方的骨骼做x方向平移。DEMO沒有使用旋轉,而實際的骨骼動畫中往往是沒有平移只有旋轉的,因為胳膊只能轉不能變長,但原理一致。
代碼的執行過程為,初始化時:
buildBones();//創建骨骼層次
buildMesh(); //創建Mesh,設置Skin info,計算Bone offset
每幀運行時:
//draw original mesh
g_mesh->DrawStaticMesh(0,0,0);
//move bones
animateBones();
//update all bone's pos in bone tree
g_boneRoot->ComputeWorldPos(0, 0, 0);
//update vertex pos by bones, using vertex blending
g_mesh->UpdateVertices();
//draw deformed mesh
g_mesh->Draw();
//draw bone
g_boneRoot->Draw();
為確保本文的完整性,下面貼出所有代碼。
// A simplest Skinned Mesh demo, written by n5, 2008.10,
// My email:happyfirecn@yahoo.com.cn
// My blog: http://blog.csdn.net/n5
#include <GL/glut.h>
#define NULL 0
//-------------------------------------------------------------
class BoneOffset
{
public:
//BoneOffset transform a vertex from mesh space to bone space.
//In other words, it is the offset from mesh space to a bone's space.
//For each bone, there is a BoneOffest.
//If we add the offset to the vertex's pos (in mesh space), we get the vertex's pos in bone space
//For example: if a vertex's pos in mesh space is (100,0,0), the bone offset is (-20,0,0), so the vertex's pos in bone space is (80,0,0)
//Actually, BoneOffset is the invert transform of that we place a bone in mesh space, that is (-20,0,0) means the bone is at (20,0,0) in mesh space
float m_offx, m_offy, m_offz;
};
//----------------------------------------------------------------
class Bone
{
public:
Bone() {}
Bone(float x, float y, float z):m_pSibling(NULL),m_pFirstChild(NULL),m_pFather(NULL),m_x(x),m_y(y),m_z(z){}
~Bone() {}
Bone* m_pSibling;
Bone* m_pFirstChild;
Bone* m_pFather; //only for draw bone
void SetFirstChild(Bone* pChild) { m_pFirstChild = pChild; m_pFirstChild->m_pFather = this; }
void SetSibling(Bone* pSibling) { m_pSibling = pSibling; m_pSibling->m_pFather = m_pFather; }
float m_x, m_y, m_z;//pos in its parent's space
float m_wx, m_wy, m_wz; //pos in world space
//give father's world pos, compute the bone's world pos
void ComputeWorldPos(float fatherWX, float fatherWY, float fatherWZ)
{
m_wx = fatherWX+m_x;
m_wy = fatherWY+m_y;
m_wz = fatherWZ+m_z;
if(m_pSibling!=NULL)
m_pSibling->ComputeWorldPos(fatherWX, fatherWY, fatherWZ);
if(m_pFirstChild!=NULL)
m_pFirstChild->ComputeWorldPos(m_wx, m_wy, m_wz);
}
BoneOffset m_boneOffset;
//called after compute world pos when bone loaded but not animated
void ComputeBoneOffset()
{
m_boneOffset.m_offx = -m_wx;
m_boneOffset.m_offy = -m_wy;
m_boneOffset.m_offz = -m_wz;
if(m_pSibling!=NULL)
m_pSibling->ComputeBoneOffset();
if(m_pFirstChild!=NULL)
m_pFirstChild->ComputeBoneOffset();
}
void Draw()
{
glColor3f(0,0,1.0);
glPointSize(4);
glBegin(GL_POINTS);
glVertex3f(m_wx,m_wy,m_wz);
glEnd();
if(m_pFather!=NULL)
{
glBegin(GL_LINES);
glVertex3f(m_pFather->m_wx,m_pFather->m_wy,m_pFather->m_wz);
glVertex3f(m_wx,m_wy,m_wz);
glEnd();
}
if(m_pSibling!=NULL)
m_pSibling->Draw();
if(m_pFirstChild!=NULL)
m_pFirstChild->Draw();
}
};
//--------------------------------------------------------------
#define MAX_BONE_PER_VERTEX 4
class Vertex
{
public:
Vertex():m_boneNum(0)
{
}
void ComputeWorldPosByBone(Bone* pBone, float& outX, float& outY, float& outZ)
{
//step1: transform vertex from mesh space to bone space
outX = m_x+pBone->m_boneOffset.m_offx;
outY = m_y+pBone->m_boneOffset.m_offy;
outZ = m_z+pBone->m_boneOffset.m_offz;
//step2: transform vertex from bone space to world sapce
outX += pBone->m_wx;
outY += pBone->m_wy;
outZ += pBone->m_wz;
}
void BlendVertex()
{//do the vertex blending,get the vertex's pos in world space
m_wX = 0;
m_wY = 0;
m_wZ = 0;
for(int i=0; i<m_boneNum; ++i)
{
float tx, ty, tz;
ComputeWorldPosByBone(m_bones[i], tx, ty, tz);
tx*= m_boneWeights[i];
ty*= m_boneWeights[i];
tz*= m_boneWeights[i];
m_wX += tx;
m_wY += ty;
m_wZ += tz;
}
}
float m_x, m_y, m_z; //local pos in mesh space
float m_wX, m_wY, m_wZ;//blended vertex pos, in world space
//skin info
int m_boneNum;
Bone* m_bones[MAX_BONE_PER_VERTEX];
float m_boneWeights[MAX_BONE_PER_VERTEX];
void SetBoneAndWeight(int index, Bone* pBone, float weight)
{
m_bones[index] = pBone;
m_boneWeights[index] = weight;
}
};
//-----------------------------------------------------------
class SkinMesh
{
public:
SkinMesh():m_vertexNum(0){}
SkinMesh(int vertexNum):m_vertexNum(vertexNum)
{
m_vertexs = new Vertex[vertexNum];
}
~SkinMesh()
{
if(m_vertexNum>0)
delete[] m_vertexs;
}
void UpdateVertices()
{
for(int i=0; i<m_vertexNum; ++i)
{
m_vertexs[i].BlendVertex();
}
}
void DrawStaticMesh(float x, float y, float z)
{
glColor3f(0,1.0,0);
glPointSize(4);
glBegin(GL_POINTS);
for(int i=0; i<m_vertexNum; ++i)
glVertex3f(m_vertexs[i].m_x+x,m_vertexs[i].m_y+y,m_vertexs[i].m_z+z);
glEnd();
glBegin(GL_LINE_LOOP);
for(int i=0; i<m_vertexNum; ++i)
glVertex3f(m_vertexs[i].m_x+x,m_vertexs[i].m_y+y,m_vertexs[i].m_z+z);
glEnd();
}
void Draw()
{
glColor3f(1.0,0, 0);
glPointSize(4);
glBegin(GL_POINTS);
for(int i=0; i<m_vertexNum; ++i)
glVertex3f(m_vertexs[i].m_wX,m_vertexs[i].m_wY,m_vertexs[i].m_wZ);
glEnd();
glBegin(GL_LINE_LOOP);
for(int i=0; i<m_vertexNum; ++i)
glVertex3f(m_vertexs[i].m_wX,m_vertexs[i].m_wY,m_vertexs[i].m_wZ);
glEnd();
}
int m_vertexNum;
Vertex* m_vertexs; //array of vertices in mesh
};
//--------------------------------------------------------------
Bone* g_boneRoot;
Bone* g_bone1, *g_bone2, *g_bone31, *g_bone32;
void buildBones()
{
g_boneRoot = new Bone(0, 0, 0);
g_bone1 = new Bone(0.2, 0, 0);
g_bone2 = new Bone(0.2, 0, 0);
g_bone31 = new Bone(0.2, 0.1, 0);
g_bone32 = new Bone(0.2, -0.1, 0);
g_boneRoot->SetFirstChild(g_bone1);
g_bone1->SetFirstChild(g_bone2);
g_bone2->SetFirstChild(g_bone31);
g_bone31->SetSibling(g_bone32);
}
void deleteBones()
{
delete g_boneRoot;
delete g_bone1;
delete g_bone2;
delete g_bone31;
delete g_bone32;
}
void animateBones()
{
static int dir=-1, dir2=-1;
//animate bones manually
g_bone1->m_y +=0.00001f*dir;
if(g_bone1->m_y<-0.2 || g_bone1->m_y>0.2)
dir*=-1;
g_bone32->m_x +=0.00001f*dir2;
if(g_bone32->m_x<0 || g_bone32->m_x>0.2)
dir2*=-1;
}
SkinMesh* g_mesh;
void buildMesh()
{
float _meshData[]=
{//x,y,z
-0.1,0.05,0,
0.1,0.05,0,
0.3,0.05,0,
0.45,0.06,0,
0.6,0.15,0,
0.65,0.1,0,
0.5,0,0,
0.65,-0.1,0,
0.6,-0.15,0,
0.45,-0.06,0,
0.3,-0.05,0,
0.1,-0.05,0,
-0.1,-0.05,0,
};
float _skinInfo[]=
{//bone_num,bone id(0,1,2,31 or 32), bone weight 1~4,
1, 0, -1, -1, -1, 1.0, 0.0, 0.0, 0.0,
2, 0, 1, -1, -1, 0.5, 0.5, 0.0, 0.0,
2, 1, 2, -1, -1, 0.5, 0.5, 0.0, 0.0,
2, 2, 31, -1, -1, 0.3, 0.7, 0.0, 0.0,
2, 2, 31, -1, -1, 0.2, 0.8, 0.0, 0.0,
1, 31, -1, -1, -1, 1.0, 0.0, 0.0, 0.0,
2, 31, 32, -1, -1, 0.5, 0.5, 0.0, 0.0,
1, 32, -1, -1, -1, 1.0, 0.0, 0.0, 0.0,
2, 2, 32, -1, -1, 0.2, 0.8, 0.0, 0.0,
2, 2, 32, -1, -1, 0.3, 0.7, 0.0, 0.0,
2, 1, 2, -1, -1, 0.5, 0.5, 0.0, 0.0,
2, 0, 1, -1, -1, 0.5, 0.5, 0.0, 0.0,
1, 0, -1, -1, -1, 1.0, 0.0, 0.0, 0.0,
};
int vertexNum = sizeof(_meshData)/(sizeof(float)*3);
g_mesh = new SkinMesh(vertexNum);
for(int i=0; i<vertexNum; ++i)
{
g_mesh->m_vertexs[i].m_x = _meshData[i*3];
g_mesh->m_vertexs[i].m_y = _meshData[i*3+1];
g_mesh->m_vertexs[i].m_z = _meshData[i*3+2];
}
//set skin info
for(int i=0; i<vertexNum; ++i)
{
g_mesh->m_vertexs[i].m_boneNum = _skinInfo[i*9];
for(int j=0; j<g_mesh->m_vertexs[i].m_boneNum; ++j)
{
Bone* pBone = g_boneRoot;
if(_skinInfo[i*9+1+j]==1)
pBone = g_bone1;
else if(_skinInfo[i*9+1+j]==2)
pBone = g_bone2;
else if(_skinInfo[i*9+1+j]==31)
pBone = g_bone31;
else if(_skinInfo[i*9+1+j]==32)
pBone = g_bone32;
g_mesh->m_vertexs[i].SetBoneAndWeight(j, pBone, _skinInfo[i*9+5+j]);
}
}
//compute bone offset
g_boneRoot->ComputeWorldPos(0, 0, 0);
g_boneRoot->ComputeBoneOffset();
}
void deleteMesh()
{
delete g_mesh;
}
void myInit()
{
buildBones();
buildMesh();
}
void myQuit()
{
deleteBones();
deleteMesh();
}
void myReshape(int width, int height)
{
GLfloat h = (GLfloat) height / (GLfloat) width;
glViewport(0, 0, (GLint) width, (GLint) height);
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
// glFrustum(-1.0, 1.0, -h, h, 5.0, 60.0);
glFrustum(-1.0, 1.0, -h, h, 1.0, 100.0);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
glTranslatef(0.0, 0.0, -1.0);
}
void myDisplay(void)
{
glClear(GL_COLOR_BUFFER_BIT);
//draw original mesh
g_mesh->DrawStaticMesh(0,0,0);
//move bones
animateBones();
//update all bone's pos in bone tree
g_boneRoot->ComputeWorldPos(0, 0, 0);
//update vertex pos by bones, using vertex blending
g_mesh->UpdateVertices();
//draw deformed mesh
g_mesh->Draw();
//draw bone
g_boneRoot->Draw();
glFlush();
glutSwapBuffers();
}
void myIdle(void)
{
myDisplay();
}
int main(int argc, char *argv[])
{
glutInit(&argc, argv);
glutInitDisplayMode(GLUT_RGB | GLUT_DEPTH | GLUT_DOUBLE);
glutInitWindowPosition(100, 100);
glutInitWindowSize(640, 480);
glutCreateWindow("A simplest skinned mesh DEMO, by happyfirecn@yahoo.com.cn");
glutDisplayFunc(myDisplay);
glutReshapeFunc(myReshape);
glutIdleFunc(myIdle);
myInit();
glutMainLoop();
myQuit();
return 0;
}