關於骨骼動畫及微軟示例Skinned Mesh的解析

關於骨骼動畫及微軟示例Skinned Mesh的解析-----zzzworm

骨骼動畫是D3D的一個重要應用。儘管微軟DXSDK提供了示例Skinned Mesh,但由於涉及衆多概念和技術細節,示例相對於初學者非常復 雜,難以看懂。在此,提供一些重要問題評論,以使初學者走出迷局,順利上手。文中所述都是參照各種資料加上自己的理解,也有可能出些偏差,有則回貼拍磚, 無則權當一笑。


一 骨骼動畫原理
原理方面在網上資料比較多,大家都基本明白。在此說一下重點:
總體上,絕大部分動畫實現原理一致,就是“提供一種機制,描述各頂點位置隨時間的變化”。有三種方法:
1.1 關節動畫:由 於大部分運動,都是皮膚隨骨骼在動,皮膚相對於它的骨骼本身並沒有發生運動,所以只要描述清楚骨骼的運動就行了。用矩陣描述各個骨骼的相對於父骨骼運 動。(大多運動都是旋轉型) 易知,從子骨骼用矩陣乘法累積到最頂層根骨骼,就可以得到每個子骨骼相對於世界座標系的轉換矩陣。
  這種動畫,只須用普通Mesh保存最初始的各頂點座標,以及一系列後續時刻所對應的各骨骼的運動矩陣。不用保存每時刻的頂點數據,節省了大量存儲空間。而且比較靈活,可以利用關鍵幀插值運算,便於通過運算調節動作。缺點是在兩段骨骼交接處,容易產生裂縫,影響效果。

1.2 漸變動畫:通過保存一系列時刻的頂點座標來完成動畫。雖然比較逼真,但佔用大量空間,靈活性也不高。

1.3 骨骼蒙皮動畫(skinned Mesh)
  相當於上面兩方法的折中。現在比較流行。
  在關節動畫的基礎上,利用頂點混合(Vertex Blend)技術,對於關節附近的頂點,由影響這些頂點的兩段(或多段)骨骼運動,分別賦以權值,共同決定頂點位置。相當於在骨骼關節上動態蒙皮,有效解決了裂縫問題。

  這裏,引入一個D3D技術概念:“Vertex Blending”---頂點混合技術。比如說,你肯定用過SetTransform(D3DTS_WORLD,....),但SetTransform(D3DTS_WORLDMATRIX(i),....)是不是很奇怪?這個問題後文會講到。 你也可以在微軟的DXSDK的幫助文件中搜索“Geometry Blending”主題,有裂縫及其解決辦法圖示。

 

二 X文件如何保存骨骼動畫

理解X文件格式,對用好相關的DX函數是非常重要的。

不含動畫的普通X文件,有一個Mesh單元,保存了各頂點信息、各三角面的索引信息、材質種類及定義等。

動畫X文件,則在這個單元中增加了“各骨骼蒙皮信息”、“骨骼層次及結構信息”、“各時刻骨骼矩陣信息”等。

2.1 網格蒙皮信息:首先,在Mesh{}單元中,在原有的普通網格頂點數據基礎上,新增了XSkinMeshHeader{}結構,以及多個SkinWeights{}結構。用以描述各個骨骼的蒙皮信息。

其中,XSkinMeshHeader是總括,舉一實例,如下:

XSkinMeshHeader
{
 2,//一個頂點可以受到骨骼影響的最大骨骼數,可用於計算共同作用時減少遍歷次數
 4,//一個三角面可以受到骨骼影響的最大骨骼數。這個數字對硬件頂點混合計算提出了基本要求。
 35 //當前Mesh的骨骼總數。
}

由於每個骨骼的蒙皮信息都需要用SkinWeights結構去描述,所以有多少塊骨骼,在Mesh中就有多少個SkinWeights對象。
注意,一般把SkinWeights視作Mesh的一部分。這種Mesh又稱Skinned Mesh (蒙皮網格)

SkinWeights 結構如下:
{
  STRING      transformNodeName;      //骨骼名
  DWORD       nWeights;               //權重數組的元素個數,即該骨骼相關的頂點個數
  array DWORD vertexIndices[nWeights];//受該骨骼控制的頂點索引,實際上定義了該骨骼的蒙皮
  array float weights[nWeights];      //蒙皮各頂點的受本骨骼影響的權值
  Matrix4x4   matrixOffset;           //骨骼偏移矩陣,用來從初始Mesh座標,反向計算頂點在子骨骼座標系中的初始座標。
}
在有的書中,把上面的matrixOffset叫骨骼權重矩陣,是不恰當的。應該稱爲骨骼偏移矩陣比較合適。

[問題] 在整個動畫過程中,子骨骼運動矩陣的數值是不斷變化的。上面的骨骼偏移矩陣變化嗎?有沒有必要重新計算?它在什麼時候使用?
答: 各骨骼的偏移矩陣matrixOffset專門用來從原始Mesh數據計算出各頂點相對於骨骼座標系的原始座標。在繪製前,把它與當前變換矩陣相乘,就可 以得到該骨骼的當前的最終變換矩陣。 總之,骨骼偏移矩陣是與原始Mesh頂點數值相關聯的,在整個動畫過程中是不變的,也不應該變。在動畫過程中變化是 當前骨骼變換矩陣,可由.X中的AnimatonKey中的各時刻矩陣得到。這個矩陣乘法在示例中的對應代碼如下:
D3DXMatrixMultiply( &matTemp, &pMeshContainer->pBoneOffsetMatrices[iMatrixIndex], pMeshContainer->ppBoneMatrixPtrs[iMatrixIndex] );

即,D3DXMatrixMultiply(輸出最終世界矩陣, 該骨骼的偏移矩陣, 該骨骼的變換矩陣)


2.2 骨骼層次信息

在X文件中,Frame是基本的組成單元。又稱框架Frame。 一個.x可以有多個Frame。(注意此處的Frame不是幀,與幀沒什麼關係)

框架Frame允許嵌套,這樣就存在父子框架了。而並列的框架,稱爲兄弟框架。這兩種關係組合在一起,即可以縱深,又可以並列,形成一種層次結構。這種結構,可用二叉樹描述。

每個框架結構的最前面,有一個FrameTransformMatrix矩陣數據,描述了該框架相對於父框架的變換矩陣。也就是說,該框架中的座標,與該矩陣相乘,可轉換爲父框架座標系的座標。
這種層次結構,使得X文件能描述許多複雜的物體。如地形場景。

在骨骼動畫文件中,框架結構可直接拿來描述人物骨骼的層次結構。框架的名字通常爲對應的骨骼名。
如“左上臂->左前臂->手掌->手指”就形成一個父子骨骼鏈。而左上臂與右上臂是並行關係。

數據示例: D:/D9XSDK/Samples/Media/tiny.x

Frame ...{
  .....

  Frame Bip01_R_Calf { //子骨骼
      
       FrameTransformMatrix {
        1.000000,-0.000691,-0.000000,0.000000,0.000691,1.000000,-0.000000,0.000000,0.000000,0.000000,1.000000,0.000000,119.231522,0.000021,-0.000011,1.000000;;
       }

        Frame Bip01_R_Foot {//--孫子骨骼
      
        FrameTransformMatrix {
         0.988831,0.124156,0.082452,0.000000,-0.122246,0.992109,-0.027835,0.000000,-0.085257,0.017445,0.996206,0.000000,119.231476,-0.000039,0.000023,1.000000;;
        }

        ....縮進
    }
}

[問題]查看示例tiny.x文件,發現只有根框架下有一個Mesh,包含了所有頂點信息。其它各個Frame都沒有Mesh數據。怎麼理解?
答: 一般來說,每個動畫文件只有一個Mesh網格,包含物體所有頂點信息。
     其 它Frame,只是借用來描述各骨骼的層次信息,沒必要再定義骨骼網格。每塊骨骼對應的蒙皮頂點信息,由根Mesh中的相應骨骼的SkinWeights 中蒙皮頂點索引描述的。在動畫過程中,各個頂點的新座標,要藉助SkinWeights中的頂點索引來進行重新計算。

2.3 動畫信息:
由一系列AnimatonKey組成,數據示例如下:

  AnimationKey {
   4;--動畫類型 4表示矩陣
   62; --動畫幀數,即下面矩陣個數
   0;16;1.000000,-0.000691,-0.000000,0.000000,0.000691,1.000000,0.000000,0.000000,0.000000,-0.000000,1.000000,0.000000,119.231514,-0.000005,0.000001,1.000000;;,
   80;16;0.992696,-0.120646,-0.000000,0.000000,0.120646,0.992696,0.000000,0.000000,-0.000000,-0.000000,1.000000,0.000000,119.231514,0.000002,-0.000002,1.000000;;,

   ..上面紅數字表示時刻tick,蘭數字表示數值的個數。
   ...其它各時刻矩陣...

   { Bip01_R_Calf }--對應的骨骼對象引用
  }


注意:
(1)每塊骨骼都有一個AnimationKey{}.
(2)在上面數據結構中,主要保存了各典型時刻的該骨骼相對於父的變換矩陣.
(3) 在0時刻的矩陣,與該骨骼對應的前面的Frame所對應的矩陣是相同的。如Frame Bip01_R_Calf{}中的變換矩陣,與 Bip01_R_Calf所對應的AnimationKey 的第0時刻矩陣是一樣的。這說明,在以後動畫運行時,DX會提供一種功能,用 AnimatonKey中的對應數據刷新初始的變換矩陣(也可能啓用關鍵幀插值算法)。這個功能對應於示例中的 m_pAnimController->SetTime(...)語句。

三 怎樣從X文件加載骨骼動畫信息?
3.1 負責加載的函數:
  可能有多種加載方式,在此以SDK中的示例爲準,敘述一種標準加載方式,需要用到DX函數D3DXLoadMeshHierarchyFromX(),函數字面意思是讀取Mesh層次信息。
HRESULT WINAPI 
    D3DXLoadMeshHierarchyFromX(
        LPCSTR Filename,                 //.x文件名
        DWORD MeshOptions,               //Mesh選項,一般選D3DXMESH_MANAGED
        LPDIRECT3DDEVICE9 pD3DDevice,    //指向D3D設備Device
        LPD3DXALLOCATEHIERARCHY pAlloc,  //自定義數據容器
        LPD3DXLOADUSERDATA pUserDataLoader,  //一般選NULL
        LPD3DXFRAME *ppFrameHierarchy,       //返回根Frame指針,指向代表整個骨架的Frame層次結構
        LPD3DXANIMATIONCONTROLLER *ppAnimController //返回相應的動畫控制器
);

這個函數後面的兩個輸出參數很重要,也很好理解,但輸入參數中的自定義數據容器是怎麼回事呢?
原來,鑑於動畫數據的複雜性,需要你配合完成加載過程。比如你是否用到自定義擴展結構,Mesh等數據保存在哪裏,怎樣使用戶自己創建容器,自己決定卸載等等。 
DX提供了ID3DXALLOCATEHIERARCHY接口,提供了這個自定義的機會,你重載這個接口的虛函數,在加載過程中,它就像回調函數那樣運作。

你需要像下面這樣建立一個自定義數據容器類:
class CAllocateHierarchy: public ID3DXAllocateHierarchy
{
public:
    STDMETHOD(CreateFrame)(THIS_ LPCTSTR Name, LPD3DXFRAME *ppNewFrame);
    STDMETHOD(CreateMeshContainer)(THIS_ LPCTSTR Name, LPD3DXMESHDATA pMeshData,
                            LPD3DXMATERIAL pMaterials, LPD3DXEFFECTINSTANCE pEffectInstances, DWORD NumMaterials, 
                            DWORD *pAdjacency, LPD3DXSKININFO pSkinInfo, 
                            LPD3DXMESHCONTAINER *ppNewMeshContainer);
    STDMETHOD(DestroyFrame)(THIS_ LPD3DXFRAME pFrameToFree);
    STDMETHOD(DestroyMeshContainer)(THIS_ LPD3DXMESHCONTAINER pMeshContainerBase);
    CAllocateHierarchy(CMyD3DApplication *pApp) :m_pApp(pApp) {}
public:
    CMyD3DApplication* m_pApp;
};

[問題]上面的STDMETHOD是什麼意思?
答:相當於virtual   HRESULT   __stdcall 的宏。<評論> 因爲這種類要與D3D的COM接口打交道,不僅僅在C++內部使用,所以,所有類方法必須做成stdcall的,可對外開放的。
#define   STDMETHOD(method)               virtual   HRESULT   STDMETHODCALLTYPE   method   
#define   STDMETHODCALLTYPE               __stdcall   
這樣當寫一個函數STDMETHOD(op1(int   i))       
展開後成爲:     virtual   HRESULT   __stdcall   op1(int   i);   

3.2 自定義數據容器以及具體的讀取過程:
根據.X文件,在加載過程中,主要有兩方面數據需要保存,一個是骨架Frame信息,一個是網格蒙皮Mesh信息。這兩個信息保存在如下結構中。

框架信息(對應於骨骼)
typedef struct _D3DXFRAME
{
    LPSTR                   Name;
    D3DXMATRIX              TransformationMatrix; //本骨骼的轉換矩陣

    LPD3DXMESHCONTAINER     pMeshContainer;       //本骨骼所對應Mesh數據

    struct _D3DXFRAME       *pFrameSibling;       //兄弟骨骼
    struct _D3DXFRAME       *pFrameFirstChild;    //子骨骼
} D3DXFRAME, *LPD3DXFRAME;

自定義數據容器,其數據來源由上面接口的CreateMeshContainer()函數提供
typedef struct _D3DXMESHCONTAINER
{
    LPSTR                   Name;       //容器名
    D3DXMESHDATA            MeshData;   //Mesh數據,可創建SkinMesh取代這個Mesh
    LPD3DXMATERIAL          pMaterials; //材質數組
    LPD3DXEFFECTINSTANCE    pEffects;   
    DWORD                   NumMaterials;//材質數
    DWORD*                  pAdjacency;  //鄰接三角形數組
    LPD3DXSKININFO          pSkinInfo;   //蒙皮信息,其中含.x中的各個skinweight蒙皮頂點索引及各骨骼偏移矩陣等。
    struct _D3DXMESHCONTAINER *pNextMeshContainer;
} D3DXMESHCONTAINER, *LPD3DXMESHCONTAINER;


[評論]
.在動畫文件中,框架通常用來描述骨骼。可以把Frame視做骨骼,所以不細加區分。
.在上面D3DXFRAME結構中,pFrameSibling, pFrameFirstChild兩個指針,常用於遞歸函數中,遍歷整個骨架。
.在D3DXFRAME結構中有一個pMeshContainer指針,難道框架與Mesh是一一對應的嗎?
 有一個框架(骨骼)就有一個Mesh嗎?怎麼.X文件中只有一個Mesh?難道加載時拆開存放?
答:從D3DXFrame結構上看,每個Frame都有一個pMeshContainer指針。這就有三種解釋:
   第一種,加載到內存後所有的pMeshContainer都指向同一個全局Mesh
   第二種,加載到內存後,只有一個主框架的pMeshContainer不爲空,其它Frame的pMeshContainer均爲NULL,因爲在.X中,它們沒有定義自己的Mesh
   第三種,加載到內存後,D3D將Mesh拆分,分開到各骨骼所對應的Frame,每個Frame都有自己的Mesh。
   這 個問題我以前也不是很清楚,通過查看示例源碼及跟蹤發現,正確解釋應該是第2種。唯一的一個全局Mesh存放在Frame "body"下的無名 Frame中。而其它Frame由於沒有自己專門的Mesh而指向NULL. 應該大致如此。這個問題之所以讓人困繞,是因爲從後續代碼上看,在渲染 DrawFrame時,是遍歷每一個frame分別繪製它們對應的Mesh. 如果對應於同一個mesh,就繪製多遍。如果對應各自mesh,那麼變換矩 陣怎麼組織運算等等。所以,根據第二種解釋,由於只有一個pMeshContainer不爲NULL,所以參與繪製及蒙皮的只有這一個 MeshContainer,人體所有頂點數據及蒙皮信息都在這個mesh中。
所以,讀取tiny.x文件後,會產生多個D3DXFRAME對象,但只有一個D3DXMESHCONTAINER對象。

在示例代碼的CMyD3DApplication::InitDeviceObjects()中,有:
    hr = D3DXLoadMeshHierarchyFromX(strMeshPath, D3DXMESH_MANAGED, m_pd3dDevice, &Alloc, NULL, &m_pFrameRoot, &m_pAnimController);
    if (FAILED(hr))
        return hr;
其中的Alloc是就自定義的數據容器對象。m_pFrameRoot是根骨骼,對遍歷很重要。m_pAnimController是動畫控制器,對刷新矩陣很重要。

你在運行完這句話後,下一個斷點,觀察m_pFrameRoot,會發現如下內容:

m_pFrameRoot 0x00c59380 {Name=0x00c53630 "Scene_Root" .....} //根框架
  pMeshContainer 0x00000000 
pFrameSibling 0x00000000 
pFrameFirstChild 0x00c59428 {Name=0x00c53ca8 "body" pMeshContainer=0x00000000...}//子框架 骨骼body
   +---  pMeshContainer 0x00000000 
           +---  pFrameSibling  0x01419f00 {Name=0x00c5ffd8 "Box01" pMeshContainer=0x00000000 ...}//兄弟框架
      +---  pFrameFirstChild 0x00c594d0 {Name=0x00000000 pMeshContainer=0x00c59828 //子框架---該框架就是.x中含有唯一全局Mesh的無名框架


可見,在內存中的Frame佈局是與.x中一一對應的。除了pFrameFirstChild 0x00c594d0這個地方的Frame中的pMeshContainer不爲空,其它框架的這個mesh指針都是空值。
另外一點可以看出,並不是每個Frame都對就一塊骨骼,有的是別的用途。也就是說Frame對象的個數可能多於骨骼數。

3.3 分析CAllocateHierarchy類
下面繼續研究自定義數據容器CAllocateHierarchy,顧名思義,該類是在加載過程中自行分配層次數據空間。它有4個成員,都是重載D3D的接口虛函數。
它 的成員CreateFrame()是用來創建D3DXFrame對象的,而CreateMeshContainer()是用來創建Mesh數據對象的。你 可以在這兩個函數中下斷點,發現CreateFrame會運行多次,而CreateMeshContainer只運行一次,再次驗證了上面的說法。

值得注意的是,示例對上面的D3DXFRAME,D3DXMESHCONTAINER兩個結構做了擴展,分別代之以D3DXFRAME_DERIVED結構和D3DXMESHCONTAINER_DERIVED結構,以集中存儲數據方便程序處理。

CreateFrame()處理比較簡單,你只是new一個Frame對象空間,填入傳進來的Name,其它內容由DX負責維護填充。

CreateMeshContainer()較爲複雜。它的任務一是保存傳入的網格數據數據,二是根據這些數據及蒙皮信息調用 GenerateSkinnedMesh()函數生成蒙皮網格。只有這個新的BlendMesh才能在Render()時支持頂點混合,完成蒙皮的顯示。 在D3DXMESHCONTAINER_DERIVED結構中,用pOrigMesh保存舊的Mesh普通網格信息。而Meshdata.Mesh則指向 新產生的BlendMesh

在這個函數中,多次用到了AddRef(),對COM不熟悉的新手容易困惑。D3D是 COM組件,它在服務進程中運行,而不在當前的客戶進程中。在DX組件運行過程中,要創建一系列接口對象,如CreateDevice()返回接口指針, 這些接口及其佔用內存什麼時候釋放,要通過“引用計數”的技術來解決。AddRef()給這個接口指針的計數加1,而Release()會將之減1。一旦 減到0,表示沒有客戶使用了,相關的接口就釋放了。 由此可知,每次調用Rlease()後,並不一定會釋放內存,而是當引用計數歸0時釋放內存。
這樣,對接口指針的使用,就像維護堆棧的平衡一樣,要仔細,而且按照某種約定規則使用。

但平時D3D編程中,怎麼不用AddRef()呢?這是由於一個接口指針,如ID3DDevice,或VertexBuf指針,都是 D3DXCreate出來的,在Create時候,在內部已經事先AddRef()了,你就不需要再做這工作了。只要你在不用時,調用 p指針 ->Relase()就釋放了。一般編程,特別是小型示例程序,都是初始化時建立一次,關閉時釋放,都遵守了這種約定,所以不存在這種問題。

但在CreateMeshContainer()函數中,以多種方式使用了指針,在局部指針變量中來回傳遞,所以問題複雜化了。在COM編程中約 定,任何時候地接口指針賦值(複製),都要AddRef(),在指針變量結束生命期前,再Release(). 但許多程序員都不是嚴格這麼做。因爲在局 部變量用完就廢了,先AddRef()增加計數再Release()減少,和直接使用最後是等效的。幾乎是多此一舉。這與編程習慣有關係。一旦引用計數不 對,如果沒有統一的習慣,不好排查。在CreateMeshContainer()中,對接口指針的使用有三種方式,例舉如下:

方式一:不使用AddRef()。和普通指針一樣,臨時變量是左值,接口指針是右值,直接賦值使用。如:
        pMesh = pMeshData->pMesh; 
        這是由於pMesh是局部變量,它只是臨時引用一下,沒必要爲它先AddRef(),後Release()。

方式二:隱式的使用AddRef()。 由於用到了一些內部有AddRef()動作的函數,就要按照COM約定,在子程序結束前Release()
        pMesh->GetDevice(&pd3dDevice);//此處d3d設備引用計數已經加1
        ....
        SAFE_RELEASE(pd3dDevice);//--此處將引用計數減1,並不是真的釋放d3d設備
        在本例中,pd3dDevice在GetDevice()中已經Addref()過了,所以,在退出CreateMeshContainer()前,必須pd3dDevice->Release()

方式三:顯式的使用AddRef()。 如果一個指針值,不是由D3DXCreate出來的,而是通過賦值方式複製給一個全局變量或長期變量的。 所以,可以通過AddRef()的方式來延遲該對象的釋放。因爲,如果不AddRef(),極有可能在函數返回該對象就可能釋放了。它就像一個加油站,使得傳入對象的壽命延長至自己控制範圍內。用了AddRef(),就要在相關的Destroy中添加Release()。

 在本函數,有三處這樣的語句:
        pMeshContainer->MeshData.pMesh = pMesh;
        pMeshContainer->MeshData.Type = D3DXMESHTYPE_MESH;
        pMesh->AddRef();
         ....
        pMeshContainer->pSkinInfo = pSkinInfo;
        pSkinInfo->AddRef();

        pMeshContainer->pOrigMesh = pMesh;
        pMesh->AddRef();
         ....

        將來在DestroyMeshContainer()中,要釋放這些指針:
        ....
        SAFE_RELEASE( pMeshContainer->MeshData.pMesh );
        SAFE_RELEASE( pMeshContainer->pSkinInfo );
        SAFE_RELEASE( pMeshContainer->pOrigMesh );

        由於這些指針值的創建、更改等都是用戶自己經營的,所以務必要加前後吻合,在CreateMeshContainer()中AddRef(),在DestroyMeshContainer()中Release().


再來看數據的保存部分。
在CreateMeshContainer()的傳入參數中,有pMeshData,pMaterials,pEffectInstances,NumMaterials,pAdjacency,pSkinInfo
你需要把這些數據保存到自己的D3DXMESHCONTAINER對象中。並且其中的所有數組所需的空間都要在全局堆中new出來。所以在該代碼中,有如下new:
pMeshContainer = new D3DXMESHCONTAINER_DERIVED;//自定義的擴展數據容器對象
memset(pMeshContainer, 0, sizeof(D3DXMESHCONTAINER_DERIVED));//初始化pMeshContainer,清0
    ...
pMeshContainer->pMaterials = new D3DXMATERIAL[pMeshContainer->NumMaterials];//準備保存材質
pMeshContainer->ppTextures = new LPDIRECT3DTEXTURE9[pMeshContainer->NumMaterials];//準備創建紋理對象。它聲明在擴展部分。
pMeshContainer->pAdjacency = new DWORD[NumFaces*3];//準備保存鄰接三角形數組,NumFaces = pMesh->GetNumFaces();

然後,對數據進行memcpy保存。pEffectInstances由於在繪製中不需要,並沒進行保存。對於沒有貼圖的賦以默認材質屬性。
值得注意的是,所有這些new,必須在DestroyMeshContainer()時進行delete.

接下來的處理中,如果發現Mesh的FVF中沒有法向量,要用CloneMeshFVF()重建Mesh,計算頂點平均法向量。以備光照處理。

最後,我們看看蒙皮信息pSkinInfo的處理。這是重頭戲。
如果發現pSkinInfo!=NULL,就準備着手從各個蒙皮骨骼信息創建SkinMesh.
首 先,用擴展容器結構D3DXMESHCONTAINER_DERIVED中的各屬性保存原Mesh指針 值,pMeshContainer->pOrigMesh = pMesh, 因爲接下來我們要創建SkinMesh替代原Mesh.然後,把 SkinInfo中的各骨骼的偏移矩陣保存到pMeshContainer->pBoneOffsetMatrices中
      cBones = pSkinInfo->GetNumBones();
      pMeshContainer->pBoneOffsetMatrices = new D3DXMATRIX[cBones];
      .....
     每個“骨骼偏移矩陣”pBoneOffsetMatrices,在將來DrawMeshContainer()中是必須要用的。因爲原始Mesh中的頂點數據乘以“骨骼偏移矩陣”,再乘以“變換矩陣”,才能求得各骨骼頂點在世界座標系中的座標。 即: 
    骨骼上各點在世界座標系中的新座標=初始網格中的各點座標*骨骼偏移矩陣*骨骼當前的變換矩陣
    其中,“初始網格中的各點座標*骨骼偏移矩陣” = 骨骼上各點初始時刻在該骨骼座標系中的局部座標

做了以上工作後,調用GenerateSkinnedMesh(pMeshContainer),創建SkinMesh. 接下來,我們看看GenerateSkinnedMesh()做了哪些工作。

3.4 怎樣生成蒙皮網格SkinMesh? GenerateSkinnedMesh()分析

由於要重定義pMeshContainer->MeshData.pMesh,所以先SAFE_RELEASE( pMeshContainer->MeshData.pMesh ); 釋放原pMesh

在這個函數中,是根據當前繪圖方式設置進行加載數據的。因爲頂點混合,有無索引的頂點混合,有含索引的頂點混合,所使用的函數和對應的SkinMesh數據內容也有所不同。
在示例中,自定義了枚舉m_SkinningMethod,主要分爲D3DNONINDEXED和D3DINDEXED,以有純軟件渲染等。運行示例後,你可以選擇菜單中的Options選擇不同的渲染方式。

我們着重分析一下帶索引的蒙皮網格。在程序中,就是D3DINDEXED相關的部分。
if (m_SkinningMethod == D3DINDEXED){ ....}

注意! 示例默認工作在D3DNONINDEXED下,如果要跟蹤D3DINDEXED部分的代碼,必須選擇菜單中的Options選擇indexed!


最主要的,要通過DX的ConvertToIndexedBlendedMesh()函數,生成支持“索引頂點混合”的SkinMesh. 有關索引頂點混合的技術,你可以在DXSDK幫助文件中搜索“Indexed Vertex Blending”主題,對着英文和插圖將就看,確有收穫。

要想用硬件對頂點進行混合,那麼參與混合者不能太多。也就是說同時影響一個頂點的骨骼數不能多。我們假定一個頂點最多同時受4個骨骼的影響(也就是同時最多有4個骨骼矩陣參與加權求和),那麼同時影響一個三角形面的骨骼數最多就是3*4=12個。
我 們用NumMaxFaceInfl表示影響一個三角面的最多骨骼矩陣數,那麼,通過調用 pSkinInfo->GetMaxFaceInfluences()獲取這個數值,一般也就3-4。如果這個數值太大,我們強制使用 NumMaxFaceInfl = min(NumMaxFaceInfl, 12);來最多取值12。

用NumMaxFaceInfl 這個數值幹什麼呢? 我們用來它分析當前的顯卡倒底行不行。

if (m_d3dCaps.MaxVertexBlendMatrixIndex + 1 < NumMaxFaceInfl)//如果顯卡達不到該要求
{
      //很奇怪。2005年底買的GeForce 6600GT顯卡,竟然m_d3dCaps.MaxVertexBlendMatrixIndex=0, 不支持索引頂點混合!是驅動問題還是怎麼了?
      //但它支持非索引混合。或者,也許要用HLSL支持混合。看起來,3D編程要多考慮。
       ..
      pMeshContainer->UseSoftwareVP = true;//用軟件渲染頂點。顯然不實用。
}
else
{
      pMeshContainer->NumPaletteEntries = min( ( m_d3dCaps.MaxVertexBlendMatrixIndex + 1 ) / 2, 
                                                     pMeshContainer->pSkinInfo->GetNumBones() );//--什麼意思?
      pMeshContainer->UseSoftwareVP = false;//採用硬件頂點混合。
      Flags |= D3DXMESH_MANAGED;
}

[評論]在上面有一行代碼:
     pMeshContainer->NumPaletteEntries = min( ( m_d3dCaps.MaxVertexBlendMatrixIndex + 1 ) / 2,pMeshContainer->pSkinInfo->GetNumBones() );
儘管作者加了大段註釋,還是讓人一頭霧水。其實,我們做一個實驗,反爾更能理解它的用途。
第一步,你在這句話後面下一個斷點,看一下在你機器上這個數值。我的ATI 9550顯卡機器上是19。比tiny.x中的骨骼數35少很多。
第二步,你將上面=右邊瞎填一個大於4的數字,比如6。編譯後照樣運行。而且效果上幾乎看不出任何差別。
爲什麼會這樣呢? 我們在繪製代碼部分,看看這個數值起什麼作用。
在DrawMeshContainer()代碼中,我們查找D3DINDEXED相關的部分。在mesh各子集的DrawSubset()之前,有如下代碼:
      for (iAttrib = 0; iAttrib < pMeshContainer->NumAttributeGroups; iAttrib++)
      {
                // first calculate all the world matrices
                for (iPaletteEntry = 0; iPaletteEntry < pMeshContainer->NumPaletteEntries; ++iPaletteEntry)
                {
                    iMatrixIndex = pBoneComb[iAttrib].BoneId[iPaletteEntry];
                    if (iMatrixIndex != UINT_MAX)
                    {
                        D3DXMatrixMultiply( &matTemp, &pMeshContainer->pBoneOffsetMatrices[iMatrixIndex], pMeshContainer->ppBoneMatrixPtrs[iMatrixIndex] );
                        m_pd3dDevice->SetTransform( D3DTS_WORLDMATRIX( iPaletteEntry ), &matTemp );
                    }
                }
       ...
      }
下面仔細評估一下這些代碼.
先 注意看其中奇怪的D3DTS_WORLDMATRIX()宏,我們以前還沒這樣用過。它是做什麼用的呢?通過查DXSDK幫助,我們在 Geometry Blending主題中找到相關說明,並在"Indexed Vertex Blending"主題中給出了內部實現原理。原來,當你 用m_pd3dDevice->SetRenderState(D3DRS_INDEXEDVERTEXBLENDENABLE, TRUE);開 啓了索引頂點混合後,在硬件上就啓用了“palette of matrices”,即矩陣寄存器組,它最多支持同時256個索引。就像過去用256色調 色板來表現彩色一樣。D3DTS_WORLDMATRIX()宏就是有256-511這256個數表示矩陣索引號。

這些矩陣參與如下計算:

V最終頂點位置=V*M[索引值1]*權重1 + V*M[索引值2]*權重2 + ....+V*M[索引n]*(1-其它權重和)

這個公式的來源,相信大家在衆多資料上見過,不贅述。 當然,我們也可以用程序完成這個蒙皮計算過程,但逐個讀頂點卻很麻煩。現在是由硬件代勞了。我們只設矩陣就行了。
我們用m_pd3dDevice->SetTransform( D3DTS_WORLDMATRIX( iPaletteEntry ), &matTemp );這種方式設定各索引對應的矩陣。

那麼權重呢?我們怎麼設?原來在上面所說的DX提供的ConvertToIndexedBlendedMesh()函數中,生成SkinMesh 時,各網格頂點格式FVF已經有變化了,增加了新格式,D3DFVF_XYZB2,D3DFVF_LASTBETA_UBYTE4,用以記錄頂點對應的權 重值以及矩陣索引。如下
struct VERTEX
{
    float x,y,z;
    float weight;
    DWORD matrixIndices;
    float normal[3];
};
#define D3DFVF_CUSTOMVERTEX (D3DFVF_XYZB2 | D3DFVF_LASTBETA_UBYTE4 |D3DFVF_NORMAL);

D3DFVF_LASTBETA_UBYTE4對應於DWORD數值,用於矩陣索引時,每個字節表示一個索引,最多可以允許4個索引,同時有4個矩 陣參於該點的混合。如果一次繪製中涉及了9塊骨骼矩陣,你可以把這9個矩陣全部用SetTransform設置到矩陣寄存器中,但每個頂點在渲染時,最多 使用其中的4個。由此可知,pMeshContainer->NumPaletteEntries這個數值,確定了一趟DrawSubset繪製所 用到的矩陣個數,個數越多,在一趟繪製中就可以納入的更多頂點。所以,當我們減少 pMeshContainer->NumPaletteEntries這個數值 時,pMeshContainer->NumAttributeGroups數值就會增加。也就是說,一趟繪製中所允許涉及的骨骼數越少,那麼子集 的數量NumAttributeGroups就會增加,需要多繪幾趟。
你可以在此下斷點觀察,當NumPaletteEntries=19 時,NumAttributeGroups=3 當NumPaletteEntries=6時,NumAttributeGroups=12 當 NumPaletteEntries=4時,NumAttributeGroups=31,幾乎和無索引時的分組一樣多了。

頂點中的權重weight存放了它當前骨骼的權重。(一個頂點對應的多個骨骼權重怎麼存放?是不是在當前子集中有多個同樣的頂點,權重不同,對應的矩陣索引不同,然後混合)


由上所述,ConvertToIndexedBlendedMesh()是一個很重要函數,由DX自動將Mesh頂點分組成多個子集,以便DrawSubset. 你必須把它的返回參數都記錄下來,在繪製時使用。

 

四. 怎樣繪製顯示動畫?

DrawFrame()用來繪製整個X框架。它遍歷各個框架,找到Mesh不爲空的進行繪製。(其實整個.x中通常只有一個不爲空,見上文所述)
DrawMeshContainer()是繪製函數。

4.1 怎樣開啓頂點混合?
注意應用有關的Vertex Blending技術。如在索引方式的繪製中,
m_pd3dDevice->SetRenderState(D3DRS_VERTEXBLEND, pMeshContainer->NumInfl - 1);
其實是設定了D3DVBF_2WEIGHTS或D3DVBF_3WEIGHTS
注意要m_pd3dDevice->SetRenderState(D3DRS_INDEXEDVERTEXBLENDENABLE, TRUE);

4.2 矩陣的刷新:
首先,在FrameMove()調用 m_pAnimController->SetTime()設置當前時間(或在DX9.0c中用AdvanceTime()設置時間差),從而刷新 各個pFrame->TransformationMatrix,即骨骼轉換矩陣
其次,調用UpdateFrameMatrices()做乘法累積,計算出各骨骼座標系到根世界轉換矩陣。
最後,在繪製前,將該轉換矩陣左乘偏移矩陣,得到最終的轉換矩陣。
      D3DXMatrixMultiply( &matTemp, &pMeshContainer->pBoneOffsetMatrices[iMatrixIndex], pMeshContainer->ppBoneMatrixPtrs[iMatrixIndex] );

由此可見,你如果註釋掉了m_pAnimController->SetTime,畫面肯定停了。

4.3 繪製輸出 是在DrawMeshContainer()中,調用SkinMesh的DrawSubset進行繪製。一些細節內容如D3DTS_WORLDMATRIX(),在上面已經有說明,不再羅嗦。

 

4.4 關於示例中多種繪製方式分析
在示例中,用到了多種渲染方式,包括傳統 的非索引頂點混合,還有新興的HLSL方式。而且我發現,ATI RADEON 9550 顯卡 MaxVertexBlendMatrixIndex=37,而價格更高的 Gefoce 6600GT MaxVertexBlendMatrixIndex竟然爲0,不支持index vertex blending!
所以,還是有必要分析一下該示例中各種vertex blending方式的處理,以便掌握多種繪製方式適應不同顯卡。
經測試,示例中所涉及的多種方式,由慢到快,依次是以下幾種:
    SOFTWARE,
    D3DNONINDEXED,
    D3DINDEXED,
    D3DINDEXEDVS,
    D3DINDEXEDHLSLVS,

從最慢的SW到最快的HLSL,大約相差20%,有時會大到40%。 差別不是特別懸殊的原因,主要是頂點混合並不是瓶頸。

關於頂點處理方式,是在創建D3D設備時指定的。共有三種方式:
   D3DCREATE_SOFTWARE_VERTEXPROCESSING 軟件頂點運算  (簡記 sw vp)
   D3DCREATE_HARDWARE_VERTEXPROCESSING 硬件頂點運算。必須有這項才支持有HAL (簡記 hw vp)
   D3DCREATE_MIXED_VERTEXPROCESSING 混合頂點運算,即硬件+軟件 (簡記 mixed vp)

一旦用D3DCREATE_HARDWARE_VERTEXPROCESSING方式創建設備,就只能在硬件方式下進行頂點處理。如果調用 m_pd3dDevice->SetSoftwareVertexProcessing(TRUE)來切換到軟件頂點處理,HRESULT會返回失 敗。
  所以,如果你對客戶的顯卡沒有足夠的信息,就用D3DCREATE_MIXED_VERTEXPROCESSING方式創建設備。它默認 工作方式是HAL。一旦發現進行某種繪製時硬件能力不夠,就可以調用調用 m_pd3dDevice->SetSoftwareVertexProcessing(TRUE)切換到軟件模式。在示例中就是這麼做的,啓動示 例後,運行在mixed模式下。

在Gefoce6600GT顯卡中,由於D3DINDEXED方式不支持,採用了軟件混合方式,在這種方式下速度甚至比SOFTWARE慢。HLSL還好,還是最快。

要確定設備的硬件頂點處理能力,可以參考D3DCAPS9結構的VertexProcessingCaps成員。可以獲取下列屬性
MaxActiveLights,MaxUserClipPlanes,MaxVertexBlendMatrices,MaxStreams,MaxVertexIndex

(1)D3DNONINDEXED方式:

首先看GenerateSkinnedMesh()中怎樣創建蒙皮網格的。
這種方式下,用ConvertToBlendedMesh()建立蒙皮網格,而不是ConvertToIndexBlendedMesh()

爲了繪製蒙皮,在這個函數中對Mesh各子集的頂點再次進行的分組。分組的標準是各頂點(或三角面)所涉及的骨骼矩陣個數不超過 pMeshContainer->NumInfl個。(這個數字是由在ConvertToBlendedMesh()時,由參數 pMaxFaceInfl返回的)。一個Mesh子集可能被拆開成多個分組。 最後,分組的屬性保存在pBoneCombinationBuf中,如子集 ID,該子集的各骨骼ID,起始三角面,三角面個數等供繪製時使用,分組的個數保存在 pMeshContainer->NumAttributeGroups中。

接下來檢查每個分組所涉及的骨骼數,是不是超過硬件允許的最大混合矩陣數---MaxVertexBlendMatrices。如果超過了就把所有 分組截爲兩大部分,前一部分用硬件混合,後一部分採用軟件混合。而且,一旦發現有需要軟件混合,要採用 CloneMeshFVF(D3DXMESH_SOFTWAREPROCESSING|...)的方式重新生成網格。

再來看繪製部分DrawMeshContainer()

用pBoneComb指向骨骼分組屬性,掃描各分組。找出其中骨骼數滿足硬件性能的用進行繪製。
然後開啓軟件頂點渲染m_pd3dDevice->SetSoftwareVertexProcessing(TRUE),對那些骨骼數超出硬件性能的進行繪製。
SetSoftwareVertexProcessing()需要當前d3d設備以D3DCREATE_MIXED_VERTEXPROCESSING方式創建。

(2)D3DINDEXED,這種方式上面分析過了,從略。用pMeshContainer->UseSoftwareVP表示是否採用軟件繪製。
值得注意的是在這種方式下,一旦硬件性能不足,會徹底使用軟件頂點渲染,而不是像上面一樣拆爲兩部分。

(3)D3DINDEXEDVS,D3DINDEXEDHLSLVS
這種情況下使用了着色器和高級着色語言。超出本文主旨,討論從略。

(4)SOFTWARE--軟件方式? 讓人有些迷惑,與上面的m_pd3dDevice->SetSoftwareVertexProcessing(TRUE)有何區別?

從代碼看,這種方式下反而比較簡單。GenerateSkinnedMesh()中,
先直接從原始Mesh克隆一個Mesh,然後讀取它的材質屬性數組。開闢一個空間m_pBoneMatrices,用以存放各塊骨骼的轉換矩陣。

在繪製時,從pMeshContainer中的變換矩陣乘以偏移矩陣,放在pBoneMatrices中。把這個矩陣數組,以原Mesh的頂點作爲 源頂點,以新克隆的MeshData.pMesh做爲目標頂點,調用pSkinInfo->UpdateSkinnedMesh(),用軟件方式計 算各骨骼頂點的新位置(相當於軟件計算方式蒙皮)。

然後調用MeshData.pMesh->DrawSubset()繪製。

可見,在SOFTWARE方式下,最終頂點的渲染還是HAL方式的,只不過蒙皮計算是由軟件完成的。它和上面的m_pd3dDevice->SetSoftwareVertexProcessing(TRUE)直接設置軟件頂點渲染還是有區別的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章