Skinned Mesh 原理解析和一個最簡單的實現示例

講 述骨骼動畫的資料很多,但大部分都是針對 DX8 或 DX9 的 SkinnedMesh 進行講解。我覺得對於骨骼動畫初學者增加了不必要的負擔,還沒有理解骨骼動畫的實質就已被 DX 複雜的架構搞得暈頭轉向了。這篇文章把注意力集中在骨骼動畫的基本組成結構和原理上,並實現了一個最簡單純手工的自定義骨骼動畫例子幫助理解(使用最簡單的 OpenGL 指令,甚至沒有使用矩陣)。這篇文章在我學習理解骨骼動畫的過程中逐步完善,是對這個技術的理解總結,屬於學習筆記。學習過程中參考了很多資料,其中給我啓發最大的是 Frank Luna 寫的 ”Skinned Mesh Character Animation with Direct3D 9.0c” 。 

一) 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 是一個矩陣,這兒就應該是求逆矩陣,其實由於旋轉矩陣是正交的,只要求出旋轉矩陣的轉置矩陣,並將平移部分取反即可 (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 頂點的運動,例子中甚至沒有使用矩陣。本例子僅作理解骨骼動畫之用。

代碼的執行過程爲,初始化時:

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 <-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 [email protected]" );

   

    glutDisplayFunc (myDisplay );

    glutReshapeFunc (myReshape );

    glutIdleFunc (myIdle );

   

    myInit ();

    glutMainLoop ();

    myQuit ();

 

    return 0;

}

 

本文來自CSDN博客,轉載請標明出處:http://blog.csdn.net/n5/archive/2008/10/19/3105872.aspx

 

發佈了4 篇原創文章 · 獲贊 3 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章