38. fejezet - Skeletal animation


Bizonyos játékokban szerepelnek ilyen nevek, hogy reanimated corpse. Én mindig azt hittem, hogy azért ez a nevük, mert az első animációt elrontották és egy patchben újraanimálták (és nem szégyellik bevallani). Később rájöttem, hogy a szó valami olyasmit jelent amikor nem természetes módon mozog a hulla (azaz nem marad nyugalmi állapotban, mint egyébként).

Ebből kiindulva a csontozott animációról azt lehetne gondolni, hogy biztos ezzel animálják a csontvázakat. Ez nem egy akkora mellényúlás mint a fenti, tényleg arról van szó, hogy a modellnek van egy csontváza, ami egy transzformáció-hierarchia. Ez van leanimálva, a mesh rajzolandó részét (skin) pedig hozzárendeli a csontokhoz. Világos, hogy a csontváz így újrafelhasználható más modellekhez/animációkhoz is.

Ebben a fejezetben azt fogom megmutatni, hogy hogyan lehet animált .x fájlokat betölteni és animálni kétféle módszerrel. Az első módszer az animációt a CPU-n végzi el (tehát minden frameben újraszámolja a vertexeket), a másik viszont a GPU-n csinálja ezt, így lényegesen gyorsabb. Az ilyen modelleket szokták még skinned- vagy rigged mesh-nek hívni, a módszert pedig skinning-nek.

Újranyúzott csontváz-állat

A webfordítás szépségei után nézzük meg hogyan kell egy ilyen modellt betölteni. Eddig az egész betöltés egy függvényhívás volt, ami még most is igaz, de hozzá kell venni egy csomó varacskolást is. A függvény ami a betöltést elvégzi a D3DXLoadMeshHierarchyFromX. Ennek paraméterben meg kell adni egy ID3DXAllocateHierarchy típusú pointert. Najó de az hol van? Sehol, neked kell megírni (pontosabban származtatni abból az interfészből).

CODE
class AnimatedMesh : public ID3DXAllocateHierarchy { public: STDMETHOD(CreateMeshContainer)( LPCSTR Name, const D3DXMESHDATA* meshData, const D3DXMATERIAL* materials, const D3DXEFFECTINSTANCE* effectInstances, DWORD numMaterials, const DWORD* adjacency, LPD3DXSKININFO skinInfo, LPD3DXMESHCONTAINER* retNewMeshContainer ); STDMETHOD(CreateFrame)(LPCSTR Name, LPD3DXFRAME* retNewFrame); STDMETHOD(DestroyFrame)(LPD3DXFRAME frameToFree); STDMETHOD(DestroyMeshContainer)(LPD3DXMESHCONTAINER meshContainerToFree); };

Ezt a négy metódust kell kötelező jelleggel leimplementálni. A DirectX frame-ekbe szervezi a csontokat és ezekből a háttérben egy fát épít, amit rekurzívan kell bejárni minden rajzoláshoz és updatehez. A kirajzolandó geometria adat a frame-en belül egy meshcontainer objektumban van. A metódusok implementációja nem igényel különösebb gondolkozást (copy-paste), azt viszont fontos megérteni, hogy hogyan működik ez az egész.

CPU skinning esetében a vertexeket újra kell számolni, DE. Az .x fájlok esetében az animáció abszolút transzformációkat ad meg, tehát mindig az eredeti vertexekre kell elvégezni azt. Ezért az eredeti mesh-t meg kell tartani és egy másik bufferbe végezni el a transzformációt.

GPU skinning esetében erre nyilván nincsen szükség; a vertex shaderben fogom elvégezni a transzformációkat. Ekkor viszont meg kell hívni a ConvertToIndexedBlendedMesh metódust, ami súlyokat (BLENDWEIGHT) és csont indexeket (BLENDINDICES) generál a mesh-nek.

Ami mindkét esetre igaz, hogy kétfajta mesh-t különböztetünk meg: normál- és skinned mesh(container)-t. Előbbit teljesen átlagos módon kell kirajzolni, de a transzformációját a hierarchiában elfoglalt helye határozza meg. Utóbbira viszont alkalmazni kell n darab (itt vertexenként 4) csont transzformációját (ettől lesz "skin"). CPU-s esetben a UpdateSkinnedMesh metódus végzi ezt el, GPU-s esetben a vertex shader.

A betöltés hívási fája nagyvonalakban az alábbi:
  • Load
    • D3DXLoadMeshHierarchyFromX
      • CreateFrame
      • CreateMeshContainer
        • GenerateSkinnedMesh
    • SetupMatrices()
A betöltés tehát a D3DXLoadMeshHierarchyFromX hívással kezdődik, ami rekurzívan meghívja a CreateFrame és CreateMeshContainer metódusokat. A CreateFrame csak létrehozza az adott frame-et és kinullázza a hozzátartozó adatokat. A CreateMeshContainer hívás létrehozza a meshcontainer-t, betölti a textúrákat és beállítja a csontok offsetjét (ez még nem az animált trafó!), majd továbbhívja a GenerateSkinnedMesh metódust. Ez attól függően, hogy milyen technikát használunk lemásolja a mesh-t vagy meghívja a ConvertToIndexedBlendedMesh függvényt. A betöltés után még a SetupMatrices() rekurzívan bejárja a fát és minden mesh-hez elkéri a hozzá tartozó csontok trafójára mutató pointert (hogy később ne kelljen).

Rajzoláskor pedig az alábbi metódusok hívódnak meg:
  • Update
    • UpdateMatrices
  • Draw
    • DrawFrame
      • DrawMeshContainer
Az Update metódus lépteti az animációt és a ennek megfelelően frissiti a mátrixokat, figyelembe véve a world mátrixot is. Továbbhívja az UpdateMatrices metódust, ami bejárja a fát és minden framere kiszámolja a totális transzformációt (amire eltároltunk egy pointert a mesh-ekben).

Ezek után a rajzolás meghívja a rekurzív DrawFrame függvényt, ami bejárja a fát és kirajzolja a mesh-eket a DrawMeshContainer hívással. Ha normál mesh akkor kirajzolja, ha meg skinned, akkor elvégzi a transzformációkat (avagy beállítja a shader konstansokat) és úgy rajzolja ki.

A felszabadítás hasonlóan történik mint a létrehozás, a D3DXFrameDestroy rekurzív függvény meghívásával.


Előkészületek

A fenti műveletek egyszerű elvégzéséhez az alap D3DXFRAME és D3DXMESHCONTAINER struktúrák nem biztosítanak elég tagváltozót, ezért ki kell bőviteni azokat. A frame-hez az alábbi két membert vettem hozzá:

  • exCombinedTransformationMatrix: ebbe számoljuk ki a frame totális transzformációját
  • exEnabled: rajzolhatók-e a framehez tartozó mesh-ek
A meshcontainerhez már egy picit több extra változó kell:
  • exTextures: ide tároljuk a mesh-hez tartozó textúrákat
  • exMaterials: itt meg a materialokat
  • exSkinMesh: ez lesz a klónozott vagy a blendezett mesh
  • exBoneOffsets: a mesh-hez tartozó csontok lokális transzformációi
  • exFrameCombinedMatrixPointer: a mesh-hez tartozó csontok totális trafójára mutató pointer
  • exNumBoneMatrices: a mesh-hez tartozó csontok száma
  • exNumAttributeGroups: hány subsetből áll a mesh
  • exNumInfl: maximum hány csont hat egy vertexre
  • exBoneCombinationBuff: mely csontok mely subsethez tartoznak
Az utolsó három változót a ConvertToIndexedBlendedMesh metódus adja meg, ezért csak GPU skinning-nél van rájuk szükség.


CPU skinning

Ebben az esetben a GenerateSkinnedMesh metódus lemásolja a container eredeti mesh-ét a fenti extra változóként felvett exSkinMesh-be. Érdekesebb a rajzolás, ugyanis lock-olni kell mind a két mesh-t, majd az eredetiből áttranszformálni a vertexeket a másikba:

CODE
// végső csont transzformációk kiszámolása DWORD Bones = meshContainer->pSkinInfo->GetNumBones(); for( DWORD i = 0; i < Bones; ++i ) { D3DXMatrixMultiply(&bonetransforms[i], &meshContainer->exBoneOffsets[i], meshContainer->exFrameCombinedMatrixPointer[i]); } // vertexek transzformálása a megfelelő csontokkal void* srcPtr = 0; void* destPtr = 0; meshContainer->MeshData.pMesh->LockVertexBuffer(D3DLOCK_READONLY, (void**)&srcPtr); meshContainer->exSkinMesh->LockVertexBuffer(0, (void**)&destPtr); { meshContainer->pSkinInfo->UpdateSkinnedMesh(bonetransforms, NULL, srcPtr, destPtr); } meshContainer->exSkinMesh->UnlockVertexBuffer(); meshContainer->MeshData.pMesh->UnlockVertexBuffer(); // rajzolás D3DXMATRIX id(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); device->SetTransform(D3DTS_WORLD, &id); // ...

Fontos, hogy mivel kézzel transzformáltuk a mesh-t, a world mátrixot az identitásra kell állítani. Ez a kód arra az esetre vonatkozik, amikor skinned mesh-t kell rajzolni, a normál mesheknél természetesen a hozzá tartozó mátrixot kell beállítani world-nek. A subseteket mindkét esetben a material-ok határozzák meg.


GPU skinning

Szemben a fentivel itt a subsetek a felhasznált csontok szerint vannak csoportosítva, tehát a rajzolás is más. A mesh-t itt is subsetenként rajzoljuk, de minden subsethez beállítjuk shader konstansként azt az k darab csont mátrixot ami a vertexeknél szóba jöhet. Fontos a különbség: egy meshhez tartozhat akár 30 csont mátrix (matBones) de ebből egy vertexre (itt) legfeljebb 4 csont hathat (numBones illetve a BLENDINDICES vertex attribútum).

CODE
LPD3DXBONECOMBINATION pBoneComb = reinterpret_cast<LPD3DXBONECOMBINATION>( meshContainer->exBoneCombinationBuff->GetBufferPointer()); for( DWORD iAttrib = 0; iAttrib < meshContainer->exNumAttributeGroups; ++iAttrib ) { for( DWORD iPaletteEntry = 0; iPaletteEntry < meshContainer->exNumBoneMatrices; ++iPaletteEntry ) { DWORD iMatrixIndex = pBoneComb[iAttrib].BoneId[iPaletteEntry]; if( iMatrixIndex != UINT_MAX ) { D3DXMatrixMultiply( &bonetransforms[iPaletteEntry], &meshContainer->exBoneOffsets[iMatrixIndex], meshContainer->exFrameCombinedMatrixPointer[iMatrixIndex]); } } Effect->SetMatrixArray("matBones", bonetransforms, meshContainer->exNumBoneMatrices); Effect->SetInt("numBones", meshContainer->exNumInfl - 1); // ... }

A transzformációt a vertex shader végzi el. A súlyok összege mindig 1 kell legyen, tehát az utolsó = (1 - előzők_összege), sőt, ha esetleg néhány súlyt kihagysz (MD5-nél lazán előfordulhat), akkor is ezt kell tenni, hiába van érvényes értéked az utolsó súlyra.

CODE
float4x3 matBones[MAX_MATRICES]; float4x4 matViewProj; int numBones; void vs_main( in out float4 pos : POSITION, in float4 weights : BLENDWEIGHT, in float4 indices : BLENDINDICES, in float3 norm : NORMAL, in out float2 tex : TEXCOORD0, uniform int bones) { float3 bpos = 0.0f; float3 bnorm = 0.0f; // kompatibilitás bizonyos kártyákkal int4 ind = D3DCOLORtoUBYTE4(indices); int blendindices[4] = (int[4])ind; float blendweights[4] = (float[4])weights; float last = 0; for( int i = 0; i < bones - 1; ++i ) { last += blendweights[i]; bpos += mul(pos, matBones[blendindices[i]]) * blendweights[i]; bnorm += mul(norm, matBones[blendindices[i]]) * blendweights[i]; } // az utolsót mindig így kell last = 1 - last; bpos += mul(pos, matBones[blendindices[bones - 1]]) * last; bnorm += mul(norm, matBones[blendindices[bones - 1]]) * last; pos = mul(float4(bpos.xyz, 1), matViewProj); }

A shaderben egy érdekes dolgot csinál az SDK-s példa: a vertexekre ható csontok száma szerint más vertex shadert hív meg. Ezt úgy lehet megtenni, hogy egy külső paraméter szerint többször lefordítod a vertex shadert, azaz négy program van, amik csak abban különböznek, hogy hányszor fut le a ciklus.

CODE
vertexshader vsarray[4] = { compile vs_2_0 vs_main(1), compile vs_2_0 vs_main(2), compile vs_2_0 vs_main(3), compile vs_2_0 vs_main(4) }; technique skinning { pass p0 { vertexshader = vsarray[numBones]; pixelshader = compile ps_2_0 ps_main(); } }

A pixel shadert mindenki el tudja képzelni: kiadja a textúra színét. Megjegyezném, hogy nem csak négy súly hathat egy vertexre, de négynél többet körülményes kezelni (viszont van BLENDWEIGHTn és BLENDINDICESn szemantika).


Blending két animáció között

Maga az animálás egy egyszerű controller->AdvanceTime() hívás ezért arról külön nem írok semmit. A kontrollernek viszont van egy rakás egyéb metódusa amit nem árt letisztázni.

Az ID3DXAnimationController objektum több animációt is képes egyszerre lejátszani, mindegyik egy külön track-en fut. Egy ilyen track-re meg lehet adni event key-eket, például álljon meg az animáció x idő múlva. Ezen kívül minden tracknek van sebessége (speed) és súlya (weight), amit akkor használ amikor több animáció fut párhuzamosan.

Az animáció cserélgetéséhez két tracket használok (0 és 1). Ha csak simán a SetTrackXXXX metódusokat használnám, akkor az animációk közötti váltás elég nyilvánvaló és durva lenne. A blendelést kihasználva viszont úgy lehet két animáció között váltani, hogy ebből semmit ne vegyen észre a játékos. Azaz teljesen mindegy, hogy mondjuk hol tart éppen a futás animációban, simán át tud menni támadásba.

CODE
LPD3DXANIMATIONSET set = NULL; UINT newtrack = (currenttrack == 0 ? 1 : 0); double transitiontime = 0.25f; controller->GetAnimationSet(index, &set); controller->SetTrackAnimationSet(newtrack, set); set->Release(); controller->UnkeyAllTrackEvents(currenttrack); controller->UnkeyAllTrackEvents(newtrack); // sima átmenet két animáció között controller->KeyTrackEnable(currenttrack, false, currenttime + transitiontime); controller->KeyTrackSpeed(currenttrack, 0.0f, currenttime, transitiontime, D3DXTRANSITION_LINEAR); controller->KeyTrackWeight(currenttrack, 0.0f, currenttime, transitiontime, D3DXTRANSITION_LINEAR); controller->SetTrackEnable(newtrack, true); controller->KeyTrackSpeed(newtrack, 1.0f, currenttime, transitiontime, D3DXTRANSITION_LINEAR); controller->KeyTrackWeight(newtrack, 1.0f, currenttime, transitiontime, D3DXTRANSITION_LINEAR); currenttrack = newtrack; currentanim = index;

Egy animációnak az objektum egy animáció halmazt feleltet meg (aminek az elemei nyilván az animáció lépései). Ezt el kell kérni és beállitani az új track-re. A sima átmenetet pedig úgy lehet elérni, hogy a régi és az új track-re beállítunk kulcsokat, nevezetesen a régire egy olyat ami mostantól számítva transitiontime idő alatt az aktuális sebességböl (KeyTrackSpeed) és súlyból (KeyTrackWeight) 0-ba megy át (lineáris interpoláció!), az újra pedig ugyanez de 1-ig. Nyilván az átmenet után a régi tracket ki kell kapcsolni (KeyTrackEnable).

Ennek az az eredménye, hogy például az átmenetidő felénél félig a régi animáció érvényesül, félig meg az új. Ennél korábban még a régi dominál, utána meg már az új (és a súlyok összege végig 1). Ez általában jó, de előfordulhat olyan eset amikor pont úgy blendelődik össze két animáció, hogy a karakter mindkét lába a levegőben van és ettől ugrik egyet. Tehát nem teljesen mindegy, hogy mikor blendelsz.


Animált objektumok példányosítása

Szebben nem tudom lefordítani az instancingot. Ezt a fogalmat kicsit rendbe kell rakni, mert általában a hardware instancing-ot értik alatta, amit viszont nem támogat minden kártya. Én viszont most a konyhai instancing-ra gondolok, azaz más world mátrixxal kirajzolod az objektumot mégegyszer. Az animált objektumoknál ez azért probléma, mert minden példány a saját animációs fázisában kell legyen és nem egy globálisban.

A következőt lehet csinálni: az animation controllert lemásolod a CloneAnimationController metódussal, ez az egyszerű rész. A problémásabb rész az, hogy a teljes skeletont is le kell másolni. Ehhez egyetlen függvény áll remdelkezésre, mégpedig a D3DXFrameAppendChild. Ennek segítségével maga a skeleton lemásolható viszonylag egyszerűen:

CODE
void AnimatedMesh::CloneFrames(LPD3DXFRAME from, LPD3DXFRAME fromparent, LPD3DXFRAME to, LPD3DXFRAME toparent) { // ... if( from->pFrameSibling ) { LPD3DXFRAME tosibling = NULL; CreateFrame(from->pFrameSibling->Name, &tosibling); D3DXFrameAppendChild(toparent, tosibling); CloneFrames(from->pFrameSibling, fromparent, tosibling, toparent); } if( from->pFrameFirstChild ) { LPD3DXFRAME tochild = NULL; CreateFrame(from->pFrameFirstChild->Name, &tochild); D3DXFrameAppendChild(to, tochild); CloneFrames(from->pFrameFirstChild, from, tochild, to); } }

Amit kicsit jobban végig kell gondolni az a meshcontainer-ek klónozása. Az világos, hogy az előkészületekben hozzáadott extra mezőket neked kell lemásolni. Ami nem világos, hogy a normál mezők közül miket kell. Rossz hír, hogy a DirectX ebben nem segít. A következőt csináltam én:
  • a konténer nevét le kell másolni
  • adjacency info-t le kell másolni
  • magát az ID3DXMesh-t nem kell, viszont AddRef()-et kell hívni
  • textúrákat és materialokat csak logikailag kell lemásolni
  • skininfo-t sem kell lemásolni, csak AddRef()
  • az extra pointereket nyilván az új mesh konkténer mezőire kell állítani
Két fontos dolog van még: a meshcontainerek sosem egyedül vannak (pNextMeshContainer), tehát ez is egy rekurzív hívás, illetve a klónozás végén meg kell hívni a D3DXFrameRegisterNamedMatrices függvényt, ami hozzárendeli a kontrollert az új skeletonhoz. Részletekért lásd a kódot.


Más modellformátumok

Az .x-en kívül még az MD5-ről tudok írkálni valamit (ez a Doom 3 és Quake 4 modellformátuma), mert azt leimplementáltam az engineben (de ebben a cikkben nincs benne a kód). Kicsit máshogy közelíti meg a dolgot: a csontokat joint-nak hívja és mátrix helyett kvaterniókat használ. Ami egy érdekesség, hogy nem kell külön vertex buffert lefoglalni a CPU skinning-hez, mert egy weight buffer-ben van eltárolva a kezdő pozíció. Kvaterniók esetében a transzformáció az alábbi módon történik:

vertex.pos += (csont.pos + (csont.orient × suly.pos × csont.orient-1) * suly.weight;

Nyilván × itt kvaterniószorzást jelent, -1 pedig konjugáltat. Amit picit nehéz ezzel megcsinálni az a GPU skinning, ugyanis vertexenként több súly is lehet, sőt ezeknek már pozíciója is van. Egy másik probléma, hogy a csontok nem subsetenként vannak megadva, hanem egyben, tehát egy külön algoritmussal kell újrasubsetelni a mesh-t, hogy 110 helyett elég legyen mondjuk 30 csontot beállitani egy subset rajzolásához. Egy külön cikkben részletezem majd, hogy hogyan lehet átkonvertálni az itt leírt megközelítésre.

Más animációt támogató formátumok még az .fbx, ami zárt forrású tehát az FBX SDK-n kivül mással nem lehet betölteni, cserébe viszont jóldefiniált és közvetlenül tudja exportálni/importálni a 3ds MAX. Ezen kívül van még a COLLADA, ami egy XML alapú formátum, viszont nem konkrétan modelleket, hanem egy egész jelenetet ír le, ezért az implementációja egy pain in the ass (hacsak nem hagysz ki minden mást).


Summarum

Az eddigiekhez képest ez egy elég nehéz része a DirectX-nek, amit fejből sokkal nehezebb megírni (szemben az előző fejezetekben látott dolgokkal). Éppen ezért nem is írtam túl sok kódrészletet, mivel nem túl informatívak (és sok részt én is az SDK-ból copyztam ki).

anim

A kód letölthetö itt.


Höfö:
  • Javítsd ki a cikk kódját...

back to homepage

Valid HTML 4.01 Transitional Valid CSS!