Bucka-leképezés ( Bump Mapping)

Ez a tutorial azért készült, hogy a nem teljesen kezdő 3D-s programozóknak bemutassa az egyik legalapvetőbb effektet. Ma már természetesen az effektek többségét vertex és fragment programokkal csinálják, de azért érdemes megismerkedni ezzel a módszerrel is. A tutorialban megpróbálom a hardveresen gyorsított diffúz pixelenkénti megvilágítás technikáját ismertetni. A közismert gyűjtőnév erre a bucka-leképezés (bump mapping). Ez azonban nem határozza meg teljesen, hogy miről is van szó.

Néhány OpenGL kiterjesztés (extension) használatával látványos és gyors buckaleképezést lehet készíteni. Egy ilyen viszonylag egyszerű megvalósításnál azonban némiképp le kell egyszerűsíteni a megvilágítási képletet, hogy a fényforrás felé mutató vektor és a normálvektor skalárszorzatával határozhassuk meg a világosságot pixelenként. A módszer velejárója, hogy nincs spekuláris (specular) világítás, csak egyetlen fényforrást használhatunk, és a fény erőssége nem változik. Azonban sokat nyerünk vele, hiszen nincs szükség valódi fényforrásra, csak egy pozícióra, és a kapott eredmény nagyon látványos tud lenni! A módszer tehát diffúz megvilágítást eredményez, amely azonban nem csak vertexek között interpolált, hanem előre meghatározott értékek alapján történik. Ezzel pedig a részletesség illúzióját lehet kelteni anélkül, hogy hatalmas poligonszámú modelleket kéne használni.

Hogy is fog ez működni?
A működési elve a néző átverése (mint a legtöbb effektnek ). Mivel a valódi megvilágítások is valójában ugyanígy verik át a nézőt, ezért a buckaleképezés semmivel sem rosszabb. A legfőbb része az, hogy a videókártyával egy fekete-fehér árnyalatos textúrát számíttatunk ki a fényforrás helyzetétől és a normálvektorok irányától függően. A fényforrás helyvektorát áttranszformáljuk úgy, hogy az új normálisokkal egy térben legyen (erről később), és a kártya a két vektor skalárszorzatát fogja textúraként használni. A két vektor normalizált, tehát a skalátszorzat képlete alapján (a*b = |a|*|b|*cosy) látható hogy a kiszámolt szín "világossága" csak a bezárt szögtől függ, ami megegyezik a diffúz megvilágítás elvi alapjával. Ezt a textúrát az eddig megszokott textúrával együtt húzzuk rá a vázra, és így a modulálás után a kapott kép olyan, mintha a megvilágítás látszódna rajta. A kulcs itt a modulálás. Ehhez kell, hogy fekete-fehér árnyalatai legyenek csak a textúrán. Ha tehát a világítás erőssége egy pixelen 0, akkor mindegy a textúra színe, mert a végeredmény fekete lesz. A moduláláskor ugyanis a textúrák színeit összeszorozza a kártya textúrázó egysége. A trükk tehát csak abban van, hogy az adatokat olyan formába alakítsuk, hogy a kártya el tudja végezni ezt a műveletet. Na meg persze be kell állítani, hogy ezt elvégezze, de ez az egyszerűbb feladatok közé tartozik. Így tudjuk elérni a nagy sebességet, hiszen a hardveresen támogatott lesz az eljárásunk, és a futás közbeni számítások legnagyobb részét a kártya végzi majd.

Mire is van tehát szükségünk?
Az egyetlen "kellék" a normális térkép (normal map), amely ugyan egy kép, de valójában térbeli vektorokat tartalmaz. Ezt elég egyszerű megérteni, hiszen a színek három komponensből épülnek fel, és a térbeli vektorok is három komponensre bonthatóak. A tárolt vektorok lesznek majd az új normálisok, amelyekkel már pixelenként tudjuk megvilágítani a felületet. A vektorok koordinátáit úgy kell megadni, hogy az x és az y a textúra síkjában lévő két tengely, a z pedig a textúra síkjára merőleges tengely. Az x tengely az s tengellyel párhuzamos (ez a vízszintes textúrakoordináta), az y pedig a t tengellyel. Ha tehát egy teljesen sík felületet szeretnénk, akkor az összes vektornak (0;0;1)-nek kell lennie. így a kép összes pontja (128,128,255)-ös színű lesz. Ezért van az, hogy a normális térképek kékesek, mivel ez a szín is kékes, és a legnagyobb rész általában sík. A példából látszik, hogy a vektort úgy kell színné alakítani (illetve vissza), hogy lehetséges legyen a tengelyeken a negatív érték megadása is. Át lehet konvertálni egy fekete-fehér bucka térképet normális térképpé, de ehhez kell egy arra alkalmas program. Én írtam magamnak egyet, ami egy fekte-fehérből nomrális térképpé alakít egy képet. A két típus között azonban át kell hidalni a köztük lévő kis elvi eltérést. Erről bővebben talán egy későbbi írásban :)...
Példaképpen egy normális térkép:

ex_normal.jpg

Hogy lesz a fényforrás pozíciójából olyan vektor, ami felhasználható a számításokhoz?
Na EZ a legnehezebb része az egész módszernek! Ezt így egyben nem is próbálnám meg elmagyarázni, mert valószínűleg nem lenne túl érthető. Ezért inkább most az elméleti hátteret venném, hogy arra később már csak utalni kelljen. Tehát akkor a terek közti transzformációról:
Több teret különböztetünk meg, és ezek között tudjuk transzformálni a vektorokat (így tehát a helyzeteket, és testeket is). Egy teret három egymásra merőleges térbeli egységvektor határoz meg. Ezt nevezzük bázisnak (basis). Ahhoz hogy két bázis között transzformálhassunk egy vektort, ahhoz kell egy olyan transzformációs mátrix amely erre alkalmas. Visszafelé pedig a mátrix inverze kell. Nekünk majd visszafelé kell majd transzformálni, ezért az inverz mátrixokról jegyeznék meg valamit. Ahhoz, hogy tényleg az összes elvégzett transzformáció inverzét kapjuk, az kell, hogy a transzformációk inverzét végezzük el, és fordított sorrendben. Ez a mátrixok szorzásának tulajdonságaiból adódik. Tehát ha mátrixokról beszélünk, akkor fordítva kell a transzformációs mátrixok inverzeit szorozni. Például az én programom a forgatásokat tárolja egy mátrixban. Ezt pedig így valósítja meg:

// Betöltjük az eddigi transzformációkat...
glLoadMatrixf(RotMatrix.GetArray());
// forgatások...
glRotatef(rotX, 1,0,0);
glRotatef(rotY, 0,1,0);
glRotatef(rotZ, 0,0,1);
// Lementjük a transzformációkat... (most már az újakkal együtt)
glGetFloatv(GL_MODELVIEW_MATRIX, Rotmatrix.GetArray());



És eltárolja a forgatások inverzét is:

// Betöltünk egy egységmátrixot...
glLoadIdentity();
// forgatások ellenkező irányban...
glRotatef(-rotX, 1,0,0);
glRotatef(-rotY, 0,1,0);
glRotatef(-rotZ, 0,0,1);
// Megszorozzuk az eddigiekkel...
glMultMatrixf(invRotMatrix.GetArray());
// Lementjük a transzformációkat...
glGetFloatv(GL_MODELVIEW_MATRIX, invRotmatrix.GetArray());



Ebből szerintem látszik miről is van szó. Mivel az első esetben először veszi az eddigi forgatásokat, és csak utána a mostaniakat. Ezzel ellentétben a második esetben (ahol az inverz kell) először végi el a mostani forgatásokat, és csak utánuk az előzőeket. Ezt az elvet alkalmazni kell minden olyan transzformációra, amely nem csak a méretet befolyásolja. Tehát kivételt képez ezalól a mindhárom irányban egyenlő arányú skálázás. Ez ugyanis nem befolyásolja az eredményt.

Először a világ térben (world space) lévő fényforrás helyvektorát kell transzformálni az objektum térbe (object space). Ennek megvalósítása elég egyértelmű, hiszen csak a fent leírt elvet kell követni. Illetve aki szintén mátrixokban akarja eltárolni a transzformációkat, annak pedig már egy példa kódrészlet is volt. Lehetséges lenne inverz mátrixot számolni, de én ennél gyorsabbnak gondolom az eltárolást, hiszen akkor minden kirajzoláskor újra kell számolni az egészet, így azonban csak "frissítjük" a mátrixot.

A következő lépés már kicsit nehezebben érthető. Most ugyanis transzformálnunk kell a vektort az objektum teréből, az érintő térbe (tangent space). Az érintő tér az a tér, amiben a normálisok vannak a textúránkban. Ez a tér az objektum térben egy poligon felszínén van. A bázisa függ a poligon textúrázásától, és a poligon alakjától is. Tehát ha ki akarjuk számítani, akkor szükség van a vertexek pozíciójára, és textúra-koordinátákra. Az érintő tér bázisa az objektum térben, és a textúrában:

terek.jpg

A fenti ábrán mindkét bázis az érintő teret határozza meg. A fenti eset speciális, hiszen a bázis két tengelye is párhuzamos a poligon éleivel. Ez most csak az egyszerűség kedvéért van így. Láthatjuk tehát, hogy a fényforrás helyét meghatározó vektor most objektum térben van. Így azonban nem használhatjuk fel, hiszen nem abban a térben van, mint a normálisok. Ehhez kell az érintő térbe transzformálás. Az érintő teret három kiszámítható vektor határozza meg az objektum terében. Az S érintő (S tangent), a T érintő (T tangent), és a felületi normális, amely a felületből "kifelé mutat", és merőleges arra. Ha ezekből kettő megvan, akkor a harmadik már meghatározható a másik kettő vektoriális szorzataként. Először tehát ki kell számolnunk ezeket. Erre itt az eljárás matematikai leírása:

Vec1 = Vertex3.pos – Vertex2.pos
Vec2 = Vertex1.pos – Vertex2.pos
DeltaS1 = Vertex3.s - Vertex2.s
DeltaS2 = Vertex1.s - Vertex2.s
tgT = |DeltaS2 * Vec1 - DeltaS1 * Vec2|
tgS = |tgT x Vertex.N|

Itt a Vertex1..3.pos a poligon vertexeinek pozíciója, .s az első textúra koordináta, tgT és tgS a T és S érintő vektora, az x a vektoriális szorzat, a .N a normális, a || pedig a normalizáció. A felületi normálist azért vettem itt megadottnak, mivel első sorban játékfejlesztőknek írom a tutorialt. Ebben az esetben pedig valószínűleg valamilyen modellt fognak betölti fájlból, amiben már adott lehet ez a vektor. Ilyen például a 3DS formátum is.
Az érintői teret meghatározó bázisunk tehát már megvan. most már csak transzformálni kéne. Ez már nagyon egyszerű, mivel a három kiszámított vektorból készíthetünk egy transzformációs mátrixot, amivel ezt meg tudjuk tenni. Ez pedig így épül fel:

( tgS.x, tgS.y, tgS.z)
( tgT.x, tgT.y, tgT.z)
( tgN.x, tgN.y, tgN.z)

A tgN a normális. Egy kis csellel azonban nincs is szükség mátrixra, mert az egyébként elvégzendő számításokat három skaláris szorzással is elvégezhetjük. Így valahogy:

tgL.x = tgS . objL
tgL.y = tgT . objL
tgL.z = tgN . objL

Itt a . a skaláris szorzás jele (az angol dot product elnevezésből), az objL a fényforrás vektora objektum térben, a tgL pedig az érintő térben. Ezt minden vertexre el kell végezni minden kirajzoláskor. Szerencsére ezek elég gyors műveletek, tehát a sebességgel nincs gond amíg nem kell magas poligonszámú modelleket kirajzolni. Egyes matematikára érzékenyebb olvasók most talán hiányolják a vektor normalizálását. Igazuk is van, és én is írtam már ennek szükségességéről. Ez azonban már egy új témához vezet...

Van egy olyan módszer, amivel nemcsak normalizálni tudjuk a vektorokat hardveresen, de még meg is oldjuk, hogy szín legyen azonnal, tehát a textúrázó egység számára megfelelő formában tároljuk a vektort. Ez pedig egy normalizációs kocka térkép (normalisation cube map) használata. A kocka térkép úgy általában egy textúra fajta. Ez azonban három dimenziós, és így egy texelt három koordináta határoz meg. Tehát textúra-koordinátáknak megadhatunk egy térbeli vektort. A kocka térkép valójában hat két dimenziós textúra, amiket kocka alakban rendezve kell elképzelnünk. A megadott vektor által "átdöfött" pixel színe lesz a kapott szín. Ezt szemléltetném a következő ábrával:

kocka.jpg

A kocka közepén lévő piros pont az origo jelzése, a kék négyzet pedig a vektor által átdöfött pixel, tehát a kapott szín. Az ábrából látható, hogy a vektornak csak az iránya számít, és nem lényeges a nagysága. Ez teszi lehetővé a normalizálást. Már csak generálnunk kell a program elején egy olyan kocka térképet, amelynek a pontjaiban olyan színek (vektorok) vannak, amelyek az azt átdöfő irányú vektorok normalizált vektorai. Erre egy példa található a mellékelt forráskódban. Ezzel tehát már van normalizált fényforrás vektorunk a a normálisokkal egyező térben. Tehát már csak be kell állítani a textúrázó egységeket hogy kihozzák a várt eredményt. Áttekintésül egy táblázat és egy ábra a céljainkról:

tablazat.jpg

texturazas.jpg

Itt jönnek az OpenGL kiegészítések. Ezekkel bőveben nem foglalkoznék itt. Tudni kell ellenőrizni a támogatottságot, és "lekérdezni" a függvényekhez mutatót, hogy használhassuk azokat. Ez is szerepel a forráskódban. a példában hardveres kirajzolást alkalmazok a grafikus kártya memóriájába másolt vertex és textúra koordináta tömbökkel. Ha ez valakinek új lenne, annak ajánlom a NeHe tutorialt a témában. Az egész alapja a multi-textúrázáshoz is használt módszer, tehát több textúrázó egység használata. Ezeket itt "sorba kell kötni", hogy a megfelelő műveletet végezzék el a megfelelő adatokkal. Ezt a textúrázási környezet (texture environment) beállításával lehet elérni. Ez a környezet azt határozza meg, hogy honnan vegye az adatokat, és hogy milyen műveletet végezzen el velük. Erre egy lehetséges megvalósítás:

Először a nullás egységhez hozzárendeljük a normális térképünket, és megadjuk a textúrázáshoz a koordinátákat. Ezek itt a szokásos textúra koordinátáival egyeznek meg az egyszerűség kedvéért, de lehetséges természetesen erre a célra külön koordinátákat megadni.

glEnable(GL_TEXTURE_2D); // engedélyezzük a 2D-s textúrák használatát
Exts.glClientActiveTextureARB(GL_TEXTURE0_ARB);
Exts.glActiveT extureARB(GL_TEXTURE0_ARB); // aktiválják a nullás egységet
glEnableClientState(GL_TEXTURE_COORD_ARRAY); // engedélyezzük a tömb használatát
glBindTexture(GL_TEXTURE_2D, uiNormalMapID); // hozzárendeljük a normális térképünket
Exts.glBindBufferARB(GL_ARRAY_BUFFER_ARB, uiVBOTexCoords1);
glTexCoordPointer(2, GL_FLOAT, 0, NULL); // megadjuk a koordinátákat



Ezután a kocka térképet rendeljük hozzá az egyes egységhez, és megadjuk textúra-koordinátákként az érintő térben lévő fényforrás irányát mutató még nem normalizált vektorokat...

Exts.glActiveTextureARB(GL_TEXTURE1_ARB);
Exts.glClientAc tiveTextureARB(GL_TEXTURE1_ARB); // aktiváljuk az egyes egységet
glEnable(GL_TEXTURE_CUBE_MAP_ARB); // engedélyezzük a kocka térkép használatát
glBindTexture(GL_TEXTURE_CUBE_MAP_ARB, uiCubeMap); // hozzárendeljük a kocka térképet
glEnableClientState(GL_TEXTURE_COORD_ARRAY); // engedélyezzük a textúra koordináta tömböt
Exts.glBindBufferARB(GL_ARRAY_BUFFER_ARB, 0); // ne a kártya memóriájában lévő tömböt használja
glTexCoordPointer(3, GL_FLOAT, 0, tgLights ); // megadjuk a vektorokat



Most pedig a valódi textúrát rakjuk be...

Exts.glClientActiveTextureARB(GL_TEXTURE2_ARB);
Exts.glActiveText ureARB(GL_TEXTURE2_ARB); // aktiváljuk a kettes egységet
glEnableClientState(GL_TEXTURE_COORD_ARRAY); // engedélyezzük a tömb használatát
glEnable(GL_TEXTURE_2D); // engedélyezzük a 2D-s textúrát
glBindTexture(GL_TEXTURE_2D, uiTexID1); // hozzárendeljük a valódi textúrát
Exts.glBindBufferARB(GL_ARRAY_BUFFER_ARB, uiVBOTexCoords1);
glTexCoordPointer(2, GL_FLOAT, 0, NULL); // megadjuk a koordinátákat



És most következik a környezet beállítása...

// aktiváljuk a nullás egységet...
Exts.glClientActiveTextureARB(GL_TEXTURE0_ARB);
Exts.glActiveT extureARB(GL_TEXTURE0_ARB);

// az egységet beállítjuk, hogy a textúrából vegye a színt...
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_COMBINE_ARB);
glTexEnvi(GL_TEXTURE_ENV, GL_SOURCE0_RGB_ARB, GL_TEXTURE);
glTexEnvi(GL_TEXTURE_ENV, GL_COMBINE_RGB_ARB, GL_REPLACE);

// aktiváljuk az egyes egységet...
Exts.glClientActiveTextureARB(GL_TEXTURE1_ARB);
Exts.glActiveT extureARB(GL_TEXTURE1_ARB);

// beállítjuk az egységet, hogy az előző egységtől vegye át az eredményt, és annak vegye a skaláris szorzatát az ő textúrájából vett adattal...
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_COMBINE_ARB);
glTexEnvi(GL_TEXTURE_ENV, GL_SOURCE0_RGB_ARB, GL_TEXTURE);
glTexEnvi(GL_TEXTURE_ENV, GL_COMBINE_RGB_ARB, GL_DOT3_RGB_ARB);
glTexEnvi(GL_TEXTURE_ENV, GL_SOURCE1_RGB_ARB, GL_PREVIOUS_ARB);

// aktiváljuk a kettes egységet...
Exts.glClientActiveTextureARB(GL_TEXTURE2_ARB);
Exts.glActiveT extureARB(GL_TEXTURE2_ARB);

// beállítjuk az egységet modulálásra
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);



A fenti kódrészletben szereplő GL_DOT3_RGB_ARB egy kiegészítés, amely a skaláris szorzat műveletét végezteti el a textúrázó egységgel. Ez a kombináció teszi lehetővé ezt az egész módszert. Ráadásul még gyors is, mivel a GPU végzi a számításokat. A végén található "ARB" arra utal, hogy gyártó-független ez a kiegészítés, tehát megtalálható a mai ATI és NVIDIA kártyákban egyaránt. Célszerű ARB kiegészítéseket használni, mert így nem kell kétszer megírnunk valamit illetve nem lesz márkától függő a programunk.
Még hátra van egy kis rész a kirajzolásból...

Megadjuk a vertexek tömbjét, kirajzoljuk az objektumot, és visszatérünk a normál beállításokhoz...

glEnableClientState(GL_VERTEX_ARRAY); // a vertex tömb engedélyezése
Exts.glBindBufferARB(GL_ARRAY_BUFFER_ARB, uiVBOVertices);
glVertexPointer(3, GL_FLOAT, 0, NULL); // megadjuk a koordinátákat

glDrawArrays(GL_TRIANGLES, 0, uiVertices); // és kirajzoljuk!

glDisableClientState(GL_VERTEX_ARRAY);
glDisableClientState(GL_TEXTURE_COORD_ARRAY);
Exts.glActiveTextureARB(GL_TEXTURE1_ARB);
glDisable(GL_TEXTURE_CUBE_MAP_ARB);
Exts.glActiveTextureARB(GL_TEXTURE0_ARB);
glDisableClientState(GL_TEXTURE_COORD_ARRAY);
Exts.glClientActiveTextureARB(GL_TEXTURE1_ARB);
Exts.glActiveTextureARB(GL_TEXTURE1_ARB);
glDisableClientState(GL_TEXTURE_COORD_ARRAY);
Exts.glActiveTextureARB(GL_TEXTURE2_ARB);
Exts.glClientActiveTextureARB(GL_TEXTURE2_ARB);
glDisableClientState(GL_TEXTURE_COORD_ARRAY);
glDisable(GL_TEXTURE_2D);
Exts.glClientActiveTextureARB(GL_TEXTURE0_ARB);
Exts.glActiveTextureARB(GL_TEXTURE0_ARB);
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);



Letiltjuk azokat, amiket itt engedélyeztünk, és visszaállítjuk a textúrázási környezetet az alapállapotba (modulálás), és aktiváljuk a nullás egységet.

Ahogy az az implementációból kiderül, nincs a modellben vertex-megosztás (vertex-sharing). Ez fontos kérdés, mivel a legtöbb formátum használ vertex-megosztást. A gond csak az, hogy az érintő tér meghatározása úgy komplikáltabb. Lehetséges úgy meghatározni, hogy az összes poligonra kiszámoljuk amiben a vertex szerepel, majd ezek összegét normalizáljuk. Ez azonban elvi hibás, mert a nem folytonos textúrázás miatt (ami mindig igaz) a textúrázás "határain" nem lehet ezt elméletileg megtenni. Ha megtesszük, akkor pedig lehetséges olyan bázisok számítása, amelyek rossz eredményt adnak majd. Például lehetséges olyan helyzet, ahol mindhárom vektor nullára jön ki az összeadás miatt, és így nem lehetséges számolni tovább vele rendesen. Ha ezeket kihagyjuk, akkor működik, viszont előáll egy olyan árnyalás, amely a legtöbb poligonra simított (átmenet lesz a poligonokon belül), míg néhány helyen láthatóan nem simított. Tehát a nagyobb része szépen fog kinézni, viszont lesznek helyek, ahol meg szögletesebb lesz.
Tehát a legegyszerűbb, ha nem használunk vertex-megosztás, vagy ha olyan a formátum, akkor átalakítjuk olyanra, hogy ne legyen benne. Ennek a technikának a segítségével egyébként szögletes, és primitív objektumokat tehetünk látványossá nagyon kis teljesítménycsökkenéssel. Erre jó példa az alábbi kép, ahol egy egyszerű kockát tettem látványosabbá egy jó normális térkép és textúra használatával:

PG_kocka.jpg

Nem akartam még azzal is növelni a terjedelmet, hogy kitérjek a mátrixokra, a kiegészítésekre, vagy más olyan dologra, amit esetleg egy kezdő számára új lehet. Erre azt javaslom, hogy keress más leírásokat, amelyekből elsajátítható az ide szükséges tudás. Remélem érthető volt. Ha mégsem, akkor javaslom az újraolvasást, a kód tanulmányozását. Ha valamivel komolyabb problémád akadna valamelyik részével, akkor nyugodtan írj nekem egy e-mailt. Várom az észrevételeket, kérdéseket, véleményeket a címemre!

Csendesi Ádám

E-mail: e-mail5@freemail.hu
Honlap: Prometheus Games - prometheusgames.atw.hu

Példaprogram forráskóddal: Visual C++ .NET
Grafikus mód beállításához a program: Setup

A PG kockáért (textúra, és normális térkép) köszönet Huszár Tamásnak!

Referenciák:
NeHe
Paul's projects
MSDN - Philip Taylor : Per-Pixel Lighting