天天看點

unity3D研究之Skinned Mesh原了解析

講述骨骼動畫的資料很多,但大部分都是針對 DX8 或 DX9 的 SkinnedMesh 進行講解。我覺得對于骨骼動畫初學者增加了不必要的負擔,還沒有了解骨骼動畫的實質就已被 DX 複雜的架構搞得暈頭轉向了。這篇文章把注意力集中在骨骼動畫的基本組成結構和原理上,并實作了一個最簡單純手工的自定義骨骼動畫例子幫助了解(使用最簡單的 OpenGL 指令,甚至沒有使用矩陣)。這篇文章在我學習了解骨骼動畫的過程中逐漸完善,是對這個技術的了解總結,屬于學習筆記。學習過程中參考了很多資料,其中給我啟發最大的是 Frank Luna 寫的 ”Skinned Mesh Character Animation with Direct3D 9.0c” 。由于本人自身也是初學者,是以錯誤和不精确的地方在所難免,歡迎指正和讨論,請發郵件到[email protected]

或加入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 不過是一層皮,一件衣服。

如何了解骨骼?請看第二個觀念:骨骼可了解為一個坐标空間。

在一些文章中往往會提到關節和骨骼,那麼關節是什麼 ? 骨骼又是什麼?下圖是一個手臂的骨骼層次的示例。

unity3D研究之Skinned 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)------>Bone space

------>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=MboneMfatherMgrandpar...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 是一個矩陣,這兒就應該是求逆矩陣,其實由于旋轉矩陣是正交的,隻要求出旋轉矩陣的轉置矩陣,并将平移部分取反即可 (sorry,這兒錯了,平移部分不是簡單的取負數,推導一下應該是-dot(R,T)),本文不做讨論了。注意由于我們計算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 (具體的實作 不同的引擎中可能都不一樣)。運作階段:根據時間從動畫資料中擷取骨骼目前時刻的 Transform Matrix ,調用 UpdateBoneMatrix 計算出各骨骼的 CombinedMatrix ,對于每個頂點根據 Skin info 進行 Vertex Blending 計算出頂點的世界坐标,最終進行模型的渲染。

三)關于本文的例子

這個例子做了盡可能的簡化,隻包含一個 cpp 檔案,使用 OpenGL 和 GLUT 作為渲染器和架構,僅有 400 多行代碼。例子中手工建立了一個骨骼層次和 Mesh, 手工設定 Skin info 并自動計算 BoneOffset ,使用程式控制骨骼平移示範了骨骼層次的運動和骨骼影響下 Mesh 頂點的運動,例子中甚至沒有使用矩陣。本例子僅作了解骨骼動畫之用。

unity3D研究之Skinned 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:[email protected]
//  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 m_y >0.2)
       dir *=-1;

    g_bone32 ->m_x +=0.00001f*dir2 ;

    if (g_bone32 ->m_x 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 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 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 [email protected]" );

    glutDisplayFunc (myDisplay );
    glutReshapeFunc (myReshape );
    glutIdleFunc (myIdle );

    myInit ();
    glutMainLoop ();
    myQuit ();

    return 0;
}
           

作者:n5

Email: happyfirecn##yahoo.com.cn

Blog: http://blog.csdn.net/n5

2008-10 月

Last Modify:2011-1-1

From:http://blog.csdn.net/n5/article/details/3105872