Először egy kis nosztalgia: a számítógépek hőskorában sokan el se tudták képzelni, hogy valaha majd 3D játékokkal fogunk játszani. Különösen nagy meglepetés volt amikor megjelentek az első 3D gyorsító kártyák.
Akkoriban még játékdemókat is adtak hozzá, így találkoztam az Unreal-al, ami akkoriban elképesztö
grafikának számított (98%-ot kapott a játék az 576 KByte-ban!). A fő mozgatórugója a Glide (és így a Voodoo kártya) volt, de tudott OpenGL-t és Direct3D-t is,
sőt szoftveresen is képes volt azt a minőséget hozni, mint 3D gyorsítóval.
alapozás.
A programozás CPU oldali részét most nem fogom leírni, mindenki megnézi majd a kódot. Azt viszont fontosnak tartom, hogy hogyan illeszkednek bele a shaderek
a pipelineba:
Pongyolán összefoglalva: a vertex adat (pontok halmaza) ott ül a memóriában, a vertex shader mindegyik vertexre lefut. A kártya az így kapott transzformált vertexekből primitíveket (vonal, háromszög, stb.) épít. Azt hogy a primitívek hogyan jönnek létre a vertexekből primitív topológiának hivják (és nem azért, mert buta). Például háromszögek esetén az alábbiak lehetségesek:
A shaderek feladatai
A vertex shader minimális inputja a vertex pozíció, és minimális outputja szintén a vertex pozíció, tipikusan homogén euklideszi alakban (perspektív vetítés). Eltekintek attól az esettől, amikor
nem kell vertex shader (mert például explicit a képernyőn adtad meg a vertexeket).
Kicsit jobban lebontva a vertexek feldolgozását a következő lépéseket/állapotokat/"tereket" lehet elkülöníteni:
Ezek közül a world, view és projection transzformációkat szokás elvégezni a vertex shaderben, azaz ami a vertex shaderből kijön az egy clip space-beli pozíció. Ezután jön a raszterizálás, interpolálva a vertex attribútumokat, majd megtörténik a perspective division (szintén minden attribútumra!). A shaderek ereje abban rejlik, hogy a minimális követelményeken kívül egy rakás extra dolgot is át lehet adogatni nekik, illetve hasonlóan ki tudnak adni magukból extra dolgokat is. Egy vertex shader az alábbiakat tudja megkapni inputként:
Az utolsó hármat együttesen tangent frame-nek nevezik, ezt egy kis koordinátarendszerrel szokták vizualizálni a vertexben (pl. ha debugolni kell). Akkor használatos, ha a textúra terében kell valamilyen keresést vagy számolást végezni. A vertex shader outputjai közül legtöbbször a POSITION és a TEXCOORD0-7 szemantikákra van szükség. Ha valami olyat akarsz kiadni amire nincs szemantika, akkor azt texkoordba vagy colorba (ezt is lehet számozni) lehet tenni (és tipikusan mindent oda raksz, mert nem is nagyon lehet máshova). A pixel shader inputja majdnem ugyanaz, mint a vertex shader outputja, legfeljebb elhagyni lehet belőle. A pixel shadernek egy fragment az inputja, a minimális outputja pedig egy (vagy több) színérték (∈ [0, 1]4), ennek szemantikája COLOR (illetve COLOR0, COLOR1, stb.), illetve kiadhat még egy DEPTH szemantikájú mélységértéket is a depth test-hez. Ezek alapján meg is írnám a legalapvetöbb shadert, nevezetesen azt ami csak áttranszformálja a vertexet és kiad valamilyen színt:
A shadereket mostantól mindig ilyen dupla táblázatban fogom megadni, de ezek természetesen egy darab fájlban vannak. Amit nem fogok kiírni az a technika deklaráció (későbbi bekezdés), mert minden shaderre ugyanolyan. A teszt modell legyen egy gömb, ekkor a shader zöldre szinezi a gömböt. Na de mis is van itt... először is van egy mátrix, amit a főprogram ad át a shadernek. Ezeket összefoglalóan shader konstansoknak vagy más néven uniform-oknak hívják (öltönyös lebegő póniló). Ha külön jelezni akarod, hogy ezt kivülről kapja a shader, akkor elé lehet írni az uniform kulcsszót (GLSL-ben kötelező kiírni). Konstans regiszterből korlátozott, hogy mennyi van (de 4D vektorokat képesek tárolni), SM 2.0-ban tipikusan 32. Egy mátrix összesen 4 konstans regisztert foglal el. Amit tudni kell, hogy az uniformok csak olvashatóak a shaderben, tehát nem lehet "globális változókat" csinálni! A vertex shader inputja most mindössze a vertex pozíciója, azaz egy 3D vektor (4D is lehet, de ha nincs megadva a vertex bufferben, akkor w = 1 lesz). Ezt vadul le is vetítettem clip space-be. Vigyázni kell, hogy merről szorzol, mert a mátrixsszorzás nem kommutatív. A pixel shadert nem kell nagyon magyarázni, csak kiad egy szép zöld színt.
Textúra mixtúra
A gömb szép zöld. Na jó, de nemis látszik, hogy gömb, inkább olyan mint egy korong. Máris rakok rá egy textúrát:
Megjelent egy új konstans, aminek a típusa sampler (mintavételező). A deklarációban megadtam a regisztert is, amiben a textúra lesz, így elég a device->SetTexture() metódust meghívni a megfelelő textúra indexxel (itt most 0). A pixel shaderben levö tex2D() utasítás a megadott mintavételezővel és a megadott textúra koordinátával olvas a textúrából. Ha szeretnéd a textúrát többször ismételni a felületen, akkor a tex változót be lehet szorozni egy számmal (mondjuk ha kétszer akarod, akkor 2-vel). Tipikusan 8 sampler van (nem igaz, új kártyákon sok van), tehát ennyi textúrát lehet egyszerre beállítani. A samplerek egyes tulajdonságait beállíthatod a főprogramból is a device->SetSamplerState() metódussal, de akár a shaderből is úgy, hogy értékül adsz neki egy sampler_state struktúrát. Például ha lineáris szűrés kell, akkor így kéne deklarálni: CODE
sampler mytex0 : register(s0) = sampler_state
{
MinFilter = linear;
MagFilter = linear;
}
Ezeknek a beállítható tulajdonságoknak a neve nem case-sensitive, tehát leírhatod mindenféle módon.
A Blinn-Phong megvilágítási modell
Valamivel jobb a helyzet, de a való életben egy gömb legalábbis reagál a környezetböl érkezö fényekre. Egy fényt tipikusan elég egy ponttal vagy iránnyal reprezentálni (hogy hol van avagy merre mutat).
Lineáris algebrából tudjuk, hogy két vektor skaláris szorzata megadja a közbezárt szögük koszinuszát.
Amikor a két vektor egybeesik, akkor a szög nulla, tehát az érték 1, ahogy távolodnak egymástól úgy meg folyamatosan csökken.
Vigyázz, hogy hol normalizálod a dolgokat, mert nem minden mennyiségre helyes az interpoláció. A szórt fény erejét (diffuse) egy kicsit átvariáltam, hogy ne legyen fekete a gömb ahol nem éri fény (ez a környezeti vagy ambiens fény szimulálása). A shaderben szereplö saturate függvény a [0, 1] intervallumba szorítja az értéket (tehát ha kisebb mint 0 akkor 0 lesz, ha nagyobb mint 1 akkor 1). A pixel színének kiszámolása logikusan következik a fentiekböl.
Nooooormááális ez a mapping???
Ennél égetőbb problémák is voltak régen, például hogy nem tudtak elég részletes modelleket készíteni, mert nem bírta a hardver a sok poligont. Egy Cohen nevü ember felvetette, hogy mi lenne ha a high-poly mesh normáljait eltárolnánk
egy textúrában (ez a normal map), és a low-poly mesh-t rajzolnánk ki, de ezekkel a normálokkal.
Itt jön be a képbe a texkoordok megváltozása, ugyanis innentől a tangent space-ben (más néven textúra tér) kényelmes elvégezni a számolásokat.
CODE
hr = D3DXComputeTangentFrameEx(
newmesh,
D3DDECLUSAGE_TEXCOORD, 0,
D3DDECLUSAGE_TANGENT, 0,
D3DDECLUSAGE_BINORMAL, 0,
D3DDECLUSAGE_NORMAL, 0,
0, NULL, 0.01f, 0.25f, 0.01f, &mesh, NULL);
A shaderben megjelenik egy új sampler, ez fog majd a normalmapból olvasni, sőt most már jó ötlet beállítani a szűrést lineárisra, hogy ne legyen pixeles az eredmény. Egy fontos dolog, hogy a tangent és a binormal vektorokat nem úgy kell transzformálni, mint a normált, hanem rendesen a world mátrixxal. Ebből a három vektorból aztán építünk egy mátrixot, és ezzel transzformáljuk át a fény- és nézési irányt a textúratérbe. Ezt a kódot úgy írtam meg, hogy a fenti generálási módszerre jó eredményt adjon, más esetben elég könnyen lehet rossz. Mátrixxal nem muszáj szórakozni, elég ha dotolsz. A pixel shader szinte teljesen ugyanaz, csak a normálvektort most nem paraméterként adom meg, hanem a normalmapból olvasom. Mivel egy textúra [0, 1]4-beli értékeket tud tárolni, ezért vissza kell transzformálni a [-1, 1]4 tartományba. Ez minden további nélkül megtehető egy (* 2 - 1) művelettel.
Az eredmény magáért beszél, bár ha jobban megnézzük azért mégiscsak látszik, hogy a felület "lapos". Létezik ennek a módszernek egy továbbfejlesztett változata amit parallax occlusion mapping-nak hívnak, ezt azonban már kicsit bonyolultabb implementálni. Az előnye viszont az, hogy a pixelek tényleg kidomborodnak a felületböl, sőt még árnyékokat is vetnek! Itt egy videó róla; a DirectX SDK-ban megtalálható az implementációja is (sőt, a következő cikkem pont erről szól, hehe).
Hullámzó valami, ami akár víz is lehet
De várjunk csak, mi lenne ha az időt is belekevernénk a buliba? Konkrétan ahogy az idő telik, olvassunk mindig máshonnan a normalmapból; sőt ha lenne egy hullámnormálokat tartalmazó textúra, akkor még jól is nézne ki!
A legegyszerűbben így lehet összefakelni a vizet, nagyon sokáig alkalmazták is ezt, mert viszonylag jól néz ki és olcsó.
0.5, 0, 0, 0.5, 0, 0.5, 0, 0.5, 0, 0, 1, 0, 0, 0, 0, 1Ezzel balról beszorozva a pontot át is tessékeltük a megfelelő tartományba. A pixel shaderben leosztjuk a w koordinátájával és már címezhetjük is vadul a textúrát. Sőt, az előző leckékből tanulva rögtön hozzá is adtam a normalmapból olvasott normált (leskálázva egy texelre), így a hullámokhoz passzoló torzulást elérve. Már csak azt kéne kitalálni, hogy hogyan keverjük bele az időt is. Marha egyszerüen: a normalmapból úgy fogok olvasni, hogy a textúra koordinátát offsetelem valamilyen irányvektorral, aminek a hosszát az idő szerint variáltam. Sőt, ha ez több irányból is megtörténik, esetleg mindegyikből más sebességgel, akkor ezeknek az átlaga már elég random lesz ahhoz, hogy víznek nevezzük. Lássuk, lássuk:
A megbeszélt változtatásokon kívül teljesen hasonló az előzőhöz. A texkoordot egy picit leskáláztam, hogy jobban látszódjon a hullám (nem, nem haltam meg), az átlagolt normálvektort szintén, hiszen texelekben kell mérni az offsetelést (t + n.xy * 0.02f), illetve kicsit több ambiens fényt engedtem, hogy ne legyen olyan sötét.
SpaceBang 2-féle hullámzó effekt
A fentiekhez képest ez egy baromi egyszerű shader. Tulajdonképpen ugyanaz, mint a víz shader, de kihasználva, hogy a felület amire alkalmaztam sík, a tangens bázisos hercehurca elkerülhető.
A világításból a szórt fényt kihagytam, így nincsenek indokolatlan árnyékok, a specular világítás pedig ilyen érdekes "energiamező" benyomást kelt.
CODE
float3 h = normalize(float3(0, 0, 1) + normalize(float3(-1, 0, 1)));
float specular = saturate(dot(normalize(distort.xyz), h));
Ezen kívül még a képet egy kicsit beszürkítettem, mivel a téridő torzulásba az idő is beletartozik (és általában úgy szokták azt jelezni). CODE
float grey = dot(color0.rgb, float3(0.3, 0.59, 0.11));
Illetve egy egyszerű távolságfüggvénnyel eltüntettem a kép széléről a specular hightlightokat: CODE
float2 tmp = (tex - 0.5f);
float dist = 1.0f - saturate(dot(tmp, tmp) * 2);
dist = dist * dist * 0.65f;
specular = saturate(pow(specular, 150)) * dist;
A játék letölhető innen. Szükséges dll-ek itt.
A vertex declaration és a szemantikák kapcsolata
Néhány dolog még nem világos. Honnan a búbánatból tudja a Direct3D, hogy mely szemantikához honnan kell olvasni a vertex bufferből? A helyzet az, hogy az ID3DXMesh elrejti azt az objektumot, ami ezt definiálja,
nevezetesen a vertex declaration-t. Ez szintén a SM 2.0-val jelent meg, előtte az úgynevezett flexible vertex format töltötte be ennek a szerepét. Ez mindössze néhány flag volt, amikből logikai vagy-al
lehetett összeállítani a deklarációt. Mivel Direct3D 9-ben már nem csak hogy sokkal több tulajdonsága lehet egy vertexnek, még ezek akár külön vertex bufferekben is lehetnek (multistreaming)! Ezért szükséges volt
valami új felületet kitalálni, ez lett a vertex declaration.
CODE
D3DVERTEXELEMENT9 decl[] =
{
{ 0, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0 },
{ 0, 12, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_NORMAL, 0 },
{ 0, 24, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 0 },
{ 0, 32, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TANGENT, 0 },
{ 0, 44, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_BINORMAL, 0 },
D3DDECL_END()
};
LPDIRECT3DVERTEXDECLARATION9 declaration = NULL;
device->CreateVertexDeclaration(decl, &declaration);
Nem tűnik olyan bonyolultnak, főleg hogy hasonlóságokat lehet felfedezni a shader szemantikákkal. Fontos, hogy ne felejtsd el a lezáró elemet (D3DDECL_END())! A cucc láthatóan egy tömb, méghozzá struktúrák tömbje. Egy ilyen struktúra elemei sorrendben a következöek:
Technika deklaráció és render state-ek állítása
A technika az, ami összepárosítja a vertex és pixel shadereket. Nyilván újrafelhasználhatósági szempontból is jól jön, hiszen több technikának lehet ugyanaz valamelyik shadere. A technika
pass-okra bontható, egy pass egy rajzolást jelent. Azaz ha három pass-od van, akkor háromszor rajzolódik ki az objektum, de ez egyébként a C++ kódból is nyilvánvaló (for ciklus a passokra). Nyilván akkor van értelme
ha olyan blend módot állítottál be (pl. additive blend ha több fényforrásod van). Nem sűrűn használnak egynél több pass-t.
CODE
technique water
{
pass p0
{
FillMode = wireframe;
AlphaBlendEnable = true;
SrcBlend = srcalpha;
DestBlend = invsrcalpha;
vertexshader = compile vs_2_0 vs_main();
pixelshader = compile ps_2_0 ps_main();
}
}
Bár kényelmes a renderstate-eket így állítgatni, nem a leghatékonyabb módszer, mivel ha sok shader van azok redundánsan állítgatják ezeket és ezzel pazarolják az erőforrást. Egy haladó programozó már inkább kidobja az ID3DXEffect-et és ír egy sajátot (könnyebb, mint gondolnád). Direct3D 10 alatt az effekt átkerült a core-ba, de Direct3D 11-ben ismét kikerült belőle, szóval nem kell használni. DX10-et amúgy se érdemes, mert a DX11 visszafelé kompatibilis (akár DX9-re is!), nyilván az újabb feature-ök nélkül.
Shaderek debugolása
Bármily meglepő shadereket is lehet debugolni, bár nem olyan egyszerűen mint egy C++ program esetében, de mégis baromi hasznos tud lenni. Amire mindenképp szükség van, az a DirectX SDK-ban
megtalálható PIXWin nevű program, illetve a szintén itt található dxcpl program. Az utóbbi segítségével
engedélyezheted a DX debug verzióját (ne felejtsd el visszaállítani, ha játszani akarsz!), ekkor a Visual Studio debug ablakában látható, hogy miket írkál ki a runtime. Ezzel kapcsolatban egy
stackoverflow bejegyzés, ha esetleg nem sikerülne.
Summarum
Bemutattam néhány klasszikus shadert és a debugolásukhoz szükséges dolgokat. A következő fejezetben SM 3.0-val fogok implementálni valami komplexebb shadert.
Amit még érdemes megemlíteni, hogy SM 2.0-ban is lehet írni elágazásokat, de ezeknek mind a két ága kiértékelödik. Például az alábbi kódból
CODE
if( i == 1 )
color = float4(1, 0, 0, 1);
else
color = float4(0, 1, 0, 1);
valami ilyesmi lesz: color = (i == 1) * float4(1, 0, 0, 1) + (i != 1) * float4(0, 1, 0, 1); Tehát nem valódi elágazás. Hasonlóan a ciklusok is kiforgatódnak n darab ciklusmagra. A kódok letölthetőek itt. Höfö:
|