35. fejezet - Klasszikus shaderek


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.

A 3D gyorsítás a mai mércéhez képest meglehetősen limitált volt, például a vertex transzformációt a CPU-n kellett elvégezni, a kártya inkább a pixel operációkhoz nyújtott segítséget, például Gouraud shading, textúrázás, depth buffer. Akit érdekel az olvashat róla itt. Egy nagy előrelépés az volt, amikor megjelent a hardveres transform & lighting (T&L). Gyakorlatilag innentől lehet beszélni a fixed function pipeline-ról (persze ki mit tekint annak, hiszen előtte is az volt). Azért hívták így, mert maga a vertex trasnszformáció és fragment processing kívülről egy fekete doboz volt, amihez csak bizonyos state állításokkal lehetett beleszólni. Teljesítményben igen sokat hozott, viszont felmerült az igény egy alacsonyabb szintű felületre is.

A DirectX 8.1 megjelenésével lehetőség nyílt a GPU közvetlen programozására, azaz megjelent a programmable pipeline. Az ilyen programokat shader-nek nevezték el, de ezen belül több fajta is van. Egyelőre a vertex shader (ami vertexenként fut) és a pixel shader (fragmentenként) az érdekes (a jelentéseikről később). Az első shadereket assemblyben lehetett programozni, néhány C-szerü extra utasítással (a DirectX-ben már ekkor is HLSL-nek hívták a nyelvet), késöbb ezt leváltotta egy teljesen C-szerü nyelv. Az utasításkészlet evolúciós lépéseit shader model-nek nevezték el. A SM 2.0-ig bezárólag elég limitáltan lehetett csak programozni, az instrukciók/aritmetikai müveletek/textúra olvasások száma korlátozott volt (összesen 96 utasítás, ebből 64 aritmetikai). Ez még a SM 3.0-ban is igaz, de már nehezebb túllépni a határokat, illetve itt már lehet írni valódi elágazásokat és ciklusokat. Ebben a cikkben DirectX 9 alatt fogom bemutatni a SM 2.0 alapú shadereket.

OpenGL-ben a fenti történet a következő: nVidia kitalál valamit, varacskolás. ATI kitalál valamit, varacskolás. ARB elfogadja a szabványba, hajtépés. Visszafelé kompatibilitás miatt egy rakás ocsmány kód. Rájönnek, hogy ez így nem jó, és a szabvány felét kidobják. Varacskolás és újraírod a programot. GLSL fordító platformonként változó hibákat produkál. Egy csomó HLSL-es függvény nincs bent GLSL-ben, csak 3.3-tól. Apple telibeszarja és nincs 3.3-as GLSL. Az Applenél úgyis minden második ATI driver szanaszét száll. Hajtépés, varacskolás, anyázás,

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:

Programmable pipeline

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:
  • triangle list: ahogy mész végig az adaton minden három vertexből lesz egy háromszög
  • triangle strip: az első három vertexből jön létre az első háromszög, majd innentől kezdve az előző háromszög utolsó két vertexe és a következő vertex alkotja a következő háromszöget
  • triangle fan: az első három vertexből jön létre az első háromszög, a többi háromszög pedig a következő két vertex és a legelső vertexből
Ezután jön a háromszögek raszterizálása, minden vertex adatot lineárisan interpolálva. Egy ilyen interpolált vertex adatot hívnak fragment-nek. A fragmentekre lefut a pixel shader (de fragment shader a becsületes neve). Ezután még jönnek egyéb műveletek, pl. depth test, alpha test, stb. Ez egy pongyola összefoglalás volt, de a cikkhez nem kell most több.


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:

TérMagyarázat
object spaceaz eredeti adat, ahogy le lett modellezve, így van a kártyán
world spacea modellre alkalmazol valamilyen transzformációt (pl. eltolás, forgatás)
view spacea kamera terébe transzformálod a modellt a nézeti mátrixxal
clip spacea vetítési mátrixxal levetíted a modellt; ilyenkor a pozíciók DC (device coordinates) alakba kerülnek, ez egy (x, y, z, w) homogén vektor
perspective divisiona DC-k leosztódnak a w komponensükkel, így lesznek NDC-k (normalized device coordinates)
screen spaceez az NDC, így kapja meg a pixel shader; a pozíció immár a [-1, 1, 0] × [1, -1, 1] tartományon belül van
képernyőNDC-ből pixel lesz, ebbe már nem lehet beleszólni

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:

Vertex shader input
HLSL szemantikaJelentés
POSITIONa vertex pozíciója (hacsak nem hackelted meg, akkor object space-ben)
COLORa vertex színe
TEXCOORD0-7textúra koordináta; ebből egy vertexnek akár 8 is lehet
BLENDWEIGHTcsontokhoz tartozó súlyok
BLENDINDICEScsontok indexei
NORMALnormálvektor, szintén object space-ben
TANGENTa texkoordok megváltozásának iránya U-ra nézve
BINORMALa texkoordok megváltozásának iránya V-re nézve

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:

CODE
matrix matWVP; void vs_main( in out float4 pos : POSITION) { // vigyázz, hogy honnan szorzol pos = mul(pos, matWVP); }
CODE
void ps_main( out float4 color : COLOR0) { // zöld color = float4(0, 1, 0, 1); }

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). Textured shader 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:

CODE
matrix matWVP; void vs_main( in out float4 pos : POSITION, in out float2 tex : TEXCOORD0) { pos = mul(pos, matWVP); }
CODE
sampler mytex0 : register(s0); void ps_main( in float2 tex : TEXCOORD0, out float4 color : COLOR0) { color = tex2D(mytex0, tex); }

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). Textured shader 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. Blinn-Phong 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.

Ez ránézésre elég jól közelíti a szórt fényt (diffuse lighting), legalábbis ha nem engedjük negatívba menni. A két vektor legyen a vertexből a fénybe mutató vektor (ldir) és a vertex normálvektora (wnorm). Raszterizáláskor ezek a vektorok interpolálódnak a háromszögön, tehát pixelenként ki lehet számolni a szórt fényt.

A Phong reflection model-ben ezenkívül még szerepel a "tökéletesen visszavert" fény is (specular lighting), ez azt a fényt jelenti, ami a felületről közvetlenül a kamerába jut. Hasonló meggondolásokkal, mint előbb: ha a fény irányvektorát tükrözzük a vertex normáljára, akkor a szemedbe beleállt vektor (vdir) és a tükrözött fényvektor skaláris szorzata megadja a visszavert fény erejét. Ezt még felemelve valamilyen λ hatványra lehet szabályozni a csillogás mértékét. A probléma az ezzel, hogy a reflektált vektor kiszámítása költséges művelet (legalábbis az akkori kártyákon az volt).

Azt mondta erre Blinn, hogy akkor közelítsük ezt a vektort a félvektorral (vektor és tünde gyereke):

h = normalize(vdir + ldir)
specular = dot(h, wnorm)


Miben különbözik ez a reflektált vektortól? Legfeljebb kisebb lehet (egy túróst...vektoroknál nincs rendezés; a szög lesz kisebb), tehát a fény jobban "szétfekszik" a felületen. Semmi gond, akkor λ helyett emeljük valami nagyobb hatványra, ezzel approximálva a Phong modellt. Írjuk is meg ezt shader kódban. Kelleni fog elöször is a fény poziciója (lightPos), a kamera pozíciója (eyePos), illetve mostantól érdemes szétszedni a matWVP mátrixot egy matWorld és matViewProj mátrixra, ugyanis a fenti irányvektorok kiszámolásához world space-ben kell a vertex pozíciója. OpenGL-esek view space-ben szokták csinálni, mert ott a worldview mátrix van megadva (persze egy ideje már túlléptek ezen és a GLSL is olyan amilyet akarsz). Egészségükre.

Ezenkívül kell még a world mátrix inverze is, ugyanis tipikus hiba szokott lenni, hogy a normálvektort is a world mátrixxal transzformálják, ami bizonyos esetekben egybeesik a helyes megoldással, de képzeld el, hogy mondjuk a modellt megnyújtod valamerre. Ekkor a rossz megoldással a normálvektor is megnyúlik és csodálkozol, hogy miért marhaság az eredmény. Tehát a normálvektort helyesen a (world -1)T mátrixxal kell beszorozni jobbról, ami ekvivalens azzal, hogy balról szorzod world -1-el.

CODE
matrix matWorld; matrix matWorldInv; matrix matViewProj; float4 lightPos = { -1, 1, -1, 1 }; float4 eyePos; void vs_main( in out float4 pos : POSITION, in float3 norm : NORMAL, in out float2 tex : TEXCOORD0, out float3 wnorm : TEXCOORD1, out float3 ldir : TEXCOORD2, out float3 vdir : TEXCOORD3) { pos = mul(pos, matWorld); wnorm = mul(matWorldInv, norm); ldir = lightPos.xyz - pos.xyz; vdir = eyePos.xyz - pos.xyz; pos = mul(pos, matViewProj); }
CODE
sampler mytex0 : register(s0); void ps_main( in float2 tex : TEXCOORD0, in float3 wnorm : TEXCOORD1, in float3 ldir : TEXCOORD2, in float3 vdir : TEXCOORD3, out float4 color : COLOR0) { float3 l = normalize(ldir); float3 v = normalize(vdir); float3 h = normalize(l + v); float3 n = normalize(wnorm); float diffuse = saturate(dot(n, l)); float specular = saturate(dot(n, h)); // [0, 1] -> [0.2, 1] diffuse = diffuse * 0.8f + 0.2f; // Phong közelitése (nagyobb exponens kell) specular = pow(specular, 60); // végső szin color = tex2D(mytex0, tex) * diffuse + specular; color.a = 1; }

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. Normal mapping 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.

Gondoljuk meg miért kell ez: egy normalmap tipikusan azt az állapotot rögzíti, amikor -z irányból nézünk egy síklapot. De világos, hogy mondjuk egy kocka 6 oldala közül csak egy esik egybe ezzel az esettel, az is csak akkor ha még nem alkalmaztunk rá transzformációkat. Tehát a ldir és vdir vektorokat át kell transzformálni ebbe a térbe, mielőtt bármit is számolnánk.

Egy meshnek generálhatsz tangent és binormal vektorokat a CloneMesh() és a D3DXComputeTangentFrameEx() függvénnyel. Az utóbbihoz bemásolom a kódot, mert ez is össze tudja kavarni rendesen a dolgokat (és amúgyis mindig elrontom, például most is).

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.

CODE
matrix matWorld; matrix matWorldInv; matrix matViewProj; float4 lightPos = { -1, 1, -1, 1 }; float4 eyePos; void vs_main( in out float4 pos : POSITION, in float3 norm : NORMAL, in float3 tang : TANGENT, in float3 bin : BINORMAL, in out float2 tex : TEXCOORD0, out float3 ldirts : TEXCOORD1, out float3 vdirts : TEXCOORD2) { pos = mul(pos, matWorld); norm = normalize( mul(matWorldInv, float4(norm, 0)).xyz); tang = normalize( mul(float4(tang, 0), matWorld).xyz); bin = normalize( mul(float4(bin, 0), matWorld).xyz); ldirts = lightPos.xyz - pos.xyz; vdirts = eyePos.xyz - pos.xyz; // a DX balkezesen csinálja meg a binormált, // de a textúratér jobbkezes float3x3 tbn = { tang, -bin, norm }; ldirts = mul(tbn, ldirts); vdirts = mul(tbn, vdirts); pos = mul(pos, matViewProj); }
CODE
sampler mytex0 : register(s0) = sampler_state { minfilter = linear; magfilter = linear; }; sampler mytex1 : register(s1) = sampler_state { minfilter = linear; magfilter = linear; }; void ps_main( in float2 tex : TEXCOORD0, in float3 ldirts : TEXCOORD1, in float3 vdirts : TEXCOORD2, out float4 color : COLOR0) { // hozzáigazítom egy kicsit a textúrához float2 t = tex * normuv; float3 l = normalize(ldirts); float3 v = normalize(vdirts); float3 h = normalize(l + v); float3 n = tex2D(mytex1, t) * 2 - 1; float diffuse = saturate(dot(n, l)); float specular = saturate(dot(n, h)); diffuse = diffuse * 0.8f + 0.2f; specular = pow(specular, 80); color = tex2D(mytex0, tex) * diffuse + specular; color.a = 1; }

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ó. Water

A módszerben bejön egy új fogalom, a projektív textúrázás. Gondoljuk végig mit is csinál a víz (azon kívül, hogy hullámzik meg nyaldossa a...mindegy...). Először is átlátszó, tehát látjuk ami alatta van, sőt a hullámoktól függően torzul is. Viszont a víz mint térbeli objektum nem különösebben befolyásolja az alatta levő dolgokat, tehát ha a víz most a gömb, akkor az alatta levő dolgoknak nem kéne hogy gömb alakja legyen.

Biztos mindenki látott már projektort, sőt szemfüles olvasók azt is észrevehették, hogy tökmindegy mire vetítem rá a képet, nem torzul az objektum alakjával (max az árnyékok miatt egyes részeit nem látod). Hogy lehetne valami hasonlót megcsinálni shaderben? Tudjuk, hogy a vertexek screen space-ben a [-1, 1]2 tartományban vannak, egy vad összeadás és osztás után a [0, 1]2 tartományba lehet öket rakni. Akkor viszont használhatjuk őket textúra koordinátának!

Optimalizációs célból ezt a bizonyos átrakást clip space-ben szokás megcsinálni. Nyilván a homogén w koordinátát nem szabad skálázni, tehát a transzformációt a többi koordinátán kell elvégezni úgy, hogy majd a pixel shaderben egy w-vel való osztás a helyes eredményt adja. Egy rövid fejszámolás után az alábbi mátrix pattan ki:

0.5,    0,   0, 0.5,
  0,  0.5,   0, 0.5, 
  0,    0,   1,   0,
  0,    0,   0,   1
Ezzel 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:

CODE
matrix matWorld; matrix matWorldInv; matrix matViewProj; float4 lightPos = { -1, 1, -1, 1 }; float4 eyePos; static const matrix matScale = { 0.5, 0, 0, 0.5, 0, 0.5, 0, 0.5, 0, 0, 1, 0, 0, 0, 0, 1 }; void vs_main( in out float4 pos : POSITION, in float3 norm : NORMAL, in float3 tang : TANGENT, in float3 bin : BINORMAL, in out float2 tex : TEXCOORD0, out float3 ldirts : TEXCOORD1, out float3 vdirts : TEXCOORD2, out float4 tproj : TEXCOORD3) { pos = mul(pos, matWorld); norm = normalize( mul(matWorldInv, float4(norm, 0)).xyz); tang = normalize( mul(float4(tang, 0), matWorld).xyz); bin = normalize( mul(float4(bin, 0), matWorld).xyz); ldirts = lightPos.xyz - pos.xyz; vdirts = eyePos.xyz - pos.xyz; float3x3 tbn = { tang, -bin, norm }; ldirts = mul(tbn, ldirts); vdirts = mul(tbn, vdirts); pos = mul(pos, matViewProj); tproj = mul(matScale, pos); }
CODE
sampler mytex0 : register(s0); sampler mytex1 : register(s1); float time = 0; static const float2 wavedir1 = { -0.02, 0 }; static const float2 wavedir2 = { 0, -0.013 }; static const float2 wavedir3 = { 0.007, 0.007 }; void ps_main( in float2 tex : TEXCOORD0, in float3 ldirts : TEXCOORD1, in float3 vdirts : TEXCOORD2, in float4 tproj : TEXCOORD3, out float4 color : COLOR0) { float2 t = tex * 0.4f; float3 l = normalize(ldirts); float3 v = normalize(vdirts); float3 h = normalize(l + v); float3 n = 0; n += (tex2D(mytex1, t + time * wavedir1) * 2 - 1) * 0.3f; n += (tex2D(mytex1, t + time * wavedir2) * 2 - 1) * 0.3f; n += (tex2D(mytex1, t + time * wavedir3) * 2 - 1) * 0.4f; n = normalize(n); t = tproj.xy / tproj.w; float4 base = tex2D(mytex0, t + n.xy * 0.02f); float diffuse = saturate(dot(n, l)); float specular = saturate(dot(n, h)); diffuse = diffuse * 0.6f + 0.4f; specular = pow(specular, 120); color = base * diffuse + specular; color.a = 1; }

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.

Azért itt is bejön egy új fogalom amit postprocessing-nek neveznek. Ez arról szól, hogy a már kirajzolt képre alkalmazunk valamilyen utómunkát (mint a fotózásban). Ez shaderekkel szintén hatékonyan elvégezhető, mivel még mindig párhuzamosan futnak az egyes pixel shader példányok. Elég annyit tenni, hogy leküldünk egy screen space-ben megadott négyszöget (pl. két darab háromszög) a lementett backbufferrel (akár StretchRect()-el, vagy textúrába rajzolással). Vertex shader nem kell ide (hacsak nem optimalizálási célból).

Néhány kódrészlettel: a SpaceBang 2-ben használt shader teljesen hasonló a fentihez, de a fény és nézési vektorokat explicit megadtam textúra térben:

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:
  • stream index (ld. device->SetStreamSource())
  • offset (egy vertexre nézve)
  • típus
  • tesszellációs módszer (bármi is legyen az)
  • szemantika
  • szemantika index (pl. TEXCOORD4 esetében 4)
Rajzolás előtt a device->SetVertexDeclaration() metódussal lehet beállítani, ezt az ID3DXMesh magától megcsinálja. Device lost esetén nem kell felszabadítani (nem is GPU resource). Az viszont fontos, hogy míg az FVF esetében előre meghatározott sorrendje volt a vertex layoutnak, itt ez nem kötelező, de ha visszafelé kompatibilis akarsz lenni, akkor ajánlott. A sorrend nagyjából ez: pos, color, norm, tex, tan, bin.


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.

A sampler state-ek állítására már mutattam példát, de nyilván a renderstate-eket is hasonlóan lehet állítgatni. Ezt a technika deklarációjában lehet megtenni, a pass-on belül. Az effekt objektum természetesen ezeket device->SetRenderState() hívásokra fordítja majd (nem igaz, stateblockot használ). Például kapcsoljuk be a wireframe módot és az alpha blendinget:

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 dxcpl 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.

A shadereket a PIX-el lehet debugolni, ennek használatát meg kell tanulni... Ha a HLSL kódot is látni akarod, akkor a D3DXSHADER_DEBUG flag-el töltsed be a shadert. Megjegyzendő, hogy a generált assembly kód teljesen másnak tűnik, mint amit HLSL-ben leírtam; ez azért van, mert a fordító brutálisan optimalizálja a shadert.

Egy átlagos debugolás a következőképpen néz ki: megnyitod a PIX-et, majd a File->New Experiment ablakban kiválasztod a programot és a beállításokat. Tipikusan a single frame capture elég szokott lenni. A Start Experiment gombra kattintva elindul a program. pix Vigyázz, mert ilyenkor az exe könyvtára a working directory (míg Visual Studio-ban a projekt könyvtára). F12-t nyomva lementi az összes D3D hívást, a GPU regisztereit, stb. Ha bezárod a programot, akkor a PIX-ben a bal alsó ablakban megjelenik a lementett frame-ek listája.

Itt több mindent lehet csinálni. A középső ablakban a Direct3D objektumok állapota és tulajdonságai vannak, ez minden hívás után változhat (pl. felszabadul, létrejön). A bal alsó ablakban lenyitva egy frame-et láthatóak a D3D hívások. Ha kiválasztasz egyet, akkor a jobb alsó ablak Render fülén látható, hogy éppen mi van a frame bufferben. Ebben a Render ablakban egy pixelen jobb klikket nyomva debugolhatod. Az új fülön megjelenik a pixel története (depth test, stb.), és minden vele kapcsolatos dolgot vissza lehet nézni. Például ha a Debug Pixel-re kattintasz, akkor megjelenik a pixel shader és meg lehet nézni a változók értékeit. Ugyanitt lehet vertexeket és primitíveket is (geometry shader) debugolni. Pirosra színezi a program az olyan változókat, amiket az aktuális utasítás megváltoztat.

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ö:
  • Írj egy olyan shadert ami két fényt tud kezelni!
  • Miért nincs értelme skálázni a homogén w koordinátát?

back to homepage

Valid HTML 4.01 Transitional Valid CSS!