A 3D megjelenítés elmélete
Számábrázolás Mint az sokak előtt ismert, a számítástechnikában a kettes számrendszer használata terjedt el. A legkisebb információmennyiség a bit, amelynek két értéke lehet: a 0 és az 1. Egy adott szám értékét egyszerű meghatározni: összeadjuk azokat a kettő-hatványokat (innentől a 2^4 kettő-a-negyedikent jelent), melyeknek helyén egy szerepel. Egész ábrázolás esetén pl. a "101001" = 2^5+2^3+2^0, azaz tízes számrendszerben 41. Meghatározott bitszámon (azaz helyiértékeken) ábrázolva a különböző értékek száma 2^b (b a bitszám), a legkisebb ábrázolható érték 0, míg a legnagyobb (2^b)-1. Érdemes megjegyezni, hogy a kettővel való szorzás a bitsorozat egyel balra tolását, míg a kettővel való osztás a jobbra tolását jelenti (csakúgy mint a tízes számrendszerben a tízzel történő szorzás/osztás), és természetesen ez a további kettő hatványokra is igaz (nyolccal való szorzás 3-al történő balra tolás, általában 2^k-onnal szorzás k-val balra tolás). Ha tört számokra is szükségünk van akkor megtehetjük, hogy a bitsorozatban kijelöljük a kettedespont helyét így az attól balra eső értékek az egész-részt, a jobbra eső értékek a törtrészt jelentik. A fenti példánál maradva: "10.1001"=2^2+2^(-1)+2^(-4), azaz 2,5625 a kapott érték. Természetesen negatív számokra is szükség lehet. Az előjelbit használata (azaz hogy a legfelső bit azonosítja a szám negatív ill. pozitív voltát, a többi pedig a számot jelenti) a már ismert formában önmagában nem szerencsés, hiszen így azonnal két nulla értékünk is lesz (+/- 0). Sokkal jobb megoldás az ún. kettes komplemens kód használata. A legfelső bit értéke itt is egyértelműen meghatározza az előjelet, azonban az ábrázolás miatt csak egy nulla lesz, és minden művelet (+,-,*) továbbra is egyszerűen végezhető. Egy pozitív számból az alábbi módon lehet negatívat képezni: az összes bit értékét felcseréljük ("negáljuk", azaz 0->1, és 1->0), majd az így keletkezett értékhez egyet adunk. Ez mellesleg a (-1)-el való szorzás megfelelő hardver algoritmusa is. A legelső példa tehát (plusz egy bit az előjelnek): "0101001"->"1010111". Kettes komplemens kódban adott bithossznál ugyancsak 2^b különböző érték ábrázolható, a tartomány viszont -2^(b-1)-től (2^(b-1))-1-ig tart, azaz pl. 8 bit esetén -128-tól 127-ig. Lebegőpontos számábrázolás esetén a kettedespont helye nem fix, hanem a fixpontosként felírt számban vándorol. Egy "seeemmmmm" formátumú lebegőpontos szám értéke: (-1)^s*2^(e-bias)*1.m, azaz az "s" bit meghatározza az előjelet, az e-vel jelölt exponens kettő valamelyik hatványát adja (a "bias" egy fix konstans, a nulla érték helyét jelöli ki, így mind negatív, mind pozitív hatvány elérhető), ami a már említett jobbra/balra tologatás miatt az m-el jelölt mantissza eltolását jelenti (normalizát számokról lévén szó, a mantissza első bitje mindig 1, így azt nem ábrázoljuk, a maradék pedig törtrészt ad meg). De nézzünk példát a fentiekre, bias=3-t választva. "010101001" esetén az előjel 0, azaz pozitív számról van szó, a mantissza ábrázolt értéke "01001", amihez a fix 1-t hozzácsapva "1.01001"-t kapunk. Az exponens értéke 4, ebből kivonva a 3-t, egyet kapunk, tehát a végeredmény 10.1001=2*1.28125=2,5625. A lebegőpontos számábrázolás esetén az adott bithosszon ábrázolható különböző értékek száma nem változik (2^b), az átfogott tartomány viszont jóval nagyobb. Az úgynevezett IEEE single precision formátum például 32 biten ábrázol, 8 bites exponenst és 23 bites mantisszát használva, így a legnagyobb ábrázolható szám 10^38 nagyságrendű, a legkisebb pedig 10^(-38) nagyságrendbe esik. Ha 32 biten fix pontosan tárolnánk mondjuk 16.16 formátumban, akkor az ábrázolható legnagyobb szám 65536, míg a legkisebb 2^(-16) lenne. Természetesen az ábrázolási tartomány ilyetén történő kiterjesztésének ára van, s ez pedig a pontosság. Hogy érthető legyen: ha pl. az exponens értéke 23, akkor ez azt jelenti hogy a mantisszát éppen annyival kell balra tolni, mint ahány bitje van, tehát ekkora számoknál a törtrészt egyszerűen nem lehet ábrázolni.
Hardver alapok Kezdjük az órajellel. Mai hardvereink túlnyomó többsége szinkron rendszer, azaz van egy ütemező szinkronizáló jel, az órajel. Az alábbi ábra egy tipikus, 50% kitöltési tényezőjű órajelet mutat (azaz az idő fele részében 0, fele részében 1 az értéke. Miért is van erre szükség? Képzeljünk el a következő számot: "01111". Ehhez szeretnénk hozzáadni egyet, így az eredmény "10000" lesz, azaz az összes bitnek meg kell változni. Ez a változás sajnos bár nagyon gyors - az egyes biteken nem egyszerre megy végbe, azaz nem úgy történik, hogy az egyik pillanatban még "01111" látható, majd a következőben az eredmény, hanem több átmeneti - hamis - érték is megjelenik. A megfelelően megválasztott órajel lehetővé teszi, hogy mindig a jó értéket lássunk: tipikusan az órajel felfutó élénél elvárjuk hogy minden jel jó értéket mutasson. Ehhez persze az kell, hogy két felfutó él közötti idő hosszabb legyen, mint amennyi idő alatt a hardverben végbe mennek a jelváltozások; azaz a példánk "10000" értékéből kialakul a "01111". Minél bonyolultabb egy funkciót megvalósító logikai hálózat, annál hosszabb az az idő, amire a jó érték beáll, így annál kisebb órajellel működhet a rendszer. Másik sokat dobált kifejezés a regiszter. A regiszter nem más, mint egy tárolóelem. A memóriától - mint másik tipikus tárolóelemtől - az különbözteti meg, hogy a regiszterben tárolt összes információ folyamatosan hozzáférhető, vagy módosítható. A
memória ellenben tömbös kiképzésű, egy adott időpillanatban a tömbnek csak egyetlen eleme olvasható vagy írható. Tehát a regiszter sokoldalúbb, csak jóval költségesebb a megvalósítása. A regiszter kimenetén a benne tárolt adat folyamatosan hozzáférhető, a beírás pedig általában az órjel felfutó élénél történik. Harmadik magyarázatra szoruló kifejezésünk a pipeline, avagy a futószalag. Képzeljünk el egy bonyolultabb műveletet, pl. a "d = (a+b)*c"-t, ahol minden betű egy-egy regisztert jelöl, d a kimenet, a többi pedig bemenet. Blokkvázlat szintjén ez az alábbiak szerint képzekhető el:
Nézzük a folyamatos működést. Az órajel első felfutó élére az a,b,c regiszterbe beírunk értékeket, s ezek valamilyen kis késleltetéssel megjelennek a kimeneten. Ezek az értékek egészen addig kiolvashatók a regiszterből, amíg újat nem írunk oda - azaz az órajel következő (második) felfutó éléig. A kiolvasott értékekkel az összeadó és szorzó hálózat elvégzi a műveletet, mindkettő valamilyen késleltetéssel. A jó értéknek meg kell jelenni a d regiszter bemenetén a második órajel felfutó élig, hogy azt oda biztonsággal be tudjuk írni. Azaz a két felfutó él közti minimális időt az összeadó és a szorzó együttes késleltetése szabja meg. Mit tehetünk, ha ez nem elegendően kicsi? Bontsuk több lépésre a feldolgozást:
Két új, átmeneti regiszter került a blokkvázlatba. Nézzük a működést. Az első felfutó élre most is beírunk adatot az a,b,c regiszterbe, ezek a második felfutó élig a kimeneten rendelkezésre állnak. A második felfutó élre beírjuk az összeadás eredményét temp0-ba, a c regiszter tartalmát pedig temp1-be, innen ezek a harmadik órajel felfutó éléig kiolvashatók, aminek hatására a szorzás eredményét d-be írjuk. Tehát az elérhető órajelet az összeadó és a szorzó közül a lassabb - nagyobb késleltetésű - szabja meg. Megjegyzendő, hogy így a bemeneti adat változását követően d-ben az eredmény két órajelet késve jelenik meg, azonban a rendszer képes egy órajelenként új eredményt szolgáltatni, hiszen az összeadó és a szorzó párhuzamosan működnek (ezért kell a temp1 regiszter, így a szorzó bemenetére mindig a megfelelő c érték kerül). Mint mindig, most is ára van a gyorsulásnak: egyrészt a már említett késleltetés (azaz, hogy adott bemeneti értékekre a kimenet csak két órajel múlva áll rendelkezésre), másrészt pedig a két új regiszter az ár. A két ütemű feldolgozás miatt ezt két elemű pipeline-nak nevezhetjük.
Térjünk vissza egy picit a számábrázoláshoz. Fixpontos esetben a szorzó blokk erőforrásigénye (~tranzisztorszáma) jóval nagyobb, 24 bites esetben pl. kb. 27-szerese az összeadónak, és jóval nagyobb a késleltetése is. Lebegőpontos számábrázolásnál már más a helyzet. Az összeadás már elvileg is igen bonyolult műveletté válik (tessék elgondolkozni rajta, hogyan adható össze két ilyen szám), míg a szorzás bonyolultsága nem nő jelentősen a fixpontos esethez képest; ennek az eredménye az, hogy az összeadáshoz egy ötelemű pipeline a megfelelő, a szorzáshoz pedig elég három elemű. Erőforrás felhasználás szempontjából kb. egyenértékű a két műveletvégző, az összeadó azonban egy picit gyorsabb tud lenni (legalábbis az említett hosszúságú pipeline-ok esetében).
3D megjelenítés Ahhoz hogy remekül eltalált modelljeink némileg élethű 3D környezetben jelenjenek meg az alábbi lépésekre van szükség:
• • • • •
Tesszeláció. Az esetlegesen nem háromszögekkel, hanem valamilyen magasabb rendű felülettel (NURBS, Patch-k) adott objektumok háromszögesítése. Sajnos nem igazán elterjedt a magasabb rendű felületek támogatása. Transzformáció. A modellező az adott modellt mindig egy saját, lokális koordináta-rendszerben készíti el. Ezt a modellt kell elhelyezni a 3D világot alkotó térben, majd az egész teret a képernyőre leképezni. Vágás. A képernyőn nem látható elemek kiszűrése. Láthatósági vizsgálat. Minden egyes képernyő pixelre el kell dönteni, hogy ott mely objektum mely háromszöge esik legközelebb a kamerához. Árnyalás. A képernyő pixelek színének meghatározása, beleértve a vertex alapú megvilágítást és a textúrázást is.
Transzformáció A transzformációs egység felelős azért, hogy a modell-térben (a modellek saját koordináta rendszere) adott objektumaink helyesen megjelenjenek a képernyőn, azaz a képernyő-térbe kerüljenek. Vizsgáljuk meg, milyen műveletekre van szükség ehhez. Ez a rész egy picit szárazabb matek lesz, de nem vészes, és hamar vége (a mátrixoktól sem kell hanyatt esni, csupán az egyenletek egyszerűbb felírására szolgálnak). A vektoroka külön nem jelzett esetekben - nem túl meglepő módon - három dimenziósak, azaz van x,y és z koordinátájuk. Eltolás Az eltolás művelete az egyes pontokhoz egy konstans vektort ad hozzá. Skálázás A skálázás a méreteket a koordinátatengelyek mentén tetszőleges mértékben módosítja. Ez a művelet mátrixszorzással írható le. Forgatás Egy adott tengely körüli forgatás az adott koordinátát változatlanul hagyja, a másik kettőt módosítja. Három tengely körüli forgatással bármilyen orientáció előállítható. Homogén transzformáció megadás Mint láthattuk, az eltoláson kívül minden transzformáció megadható mátrixokkal. Az egymás utáni transzformációkat ebben az esetben az elemi transzformációs mátrixok szorzatával (konkatenáltjával) végzett transzformáció írja le. Homogén koordinátás megadásnál az eltolást is mátrixszal adjuk meg. Hogy ezt megtehessük, a koordináták kiegészítésére van szükség, mégpedig egy 1 értékű új változó bevezetésével (w koordináta). Most pedig térjünk rá a 3D képfeldolgozás során minimálisan szükséges transzformációkra.
Világ transzformáció A modell-térben adott koordinátákat képzi le a világ-térbe, abba az egységes koordináta rendszerbe, amely a virtuális világot megadja. Azaz elhelyezi az egyes objektumokat a világban.
Nézeti transzformáció A világ koordináta rendszerből képzi le a koordinátákat a kamera helyének és irányultságának megfelelően a kamera koordináta rendszerébe. A kamera koordináta rendszerében a kamera az origóban helyezkedik el, és a pozitív z tengely irányába néz. Tehát ha például a világ koordináta rendszerben a kamera a <30, 10, 50> pozícióban helyezkedett el (és a pozitív z irányba néz), akkor minden egyes pontot <-30, -10, -50> egységgel el kell tolni az x, y és z tengelyek mentén. Ha e mellett a kamera nézeti iránya is más, akkor forgatni is kell. Vetítési transzformáció Az utolsó, legbonyolultabb transzformáció a képernyő térbe képez le, mégpedig perspektivikusan korrekten, azaz a kamerához közelebb lévő objektumok nagyobbnak látszanak, mint a távoliak. Képszintézis során ezt szemléletesen úgy képzelhetjük el, hogy egy ablakon keresztül szemléljük a világot. Így ablakunk helyzete befolyásolja, hogy mit látunk a világból. Ezen transzformáció során a csonka gúla alakú renderelési tér perspektivikusan helyesen egy kockába képződik le, az első vágási sík z koordinátája 0, míg a hátsóé 1 lesz.
Láthatósági vizsgálat Bár elvi szinten a láthatósági feladat számos algoritmussal megoldható, hardver megvalósításban mégis csak az ún. Z-buffer algoritmussal találkozhatunk (illetve az ettől csak egy-két apróságban különböző W-buffer is használatos).
Z-buffer és W-buffer algoritmus A Z-buffer algoritmus a képernyő egy adott pixelét fedő összes háromszögre meghatározza az ott felvett Z értéket, s ezek közül kiválasztva a legkisebbet már meg is van az ott látható háromszög. A számításokhoz a transzformáció során kiadódó Zs értékek használhatók, amelyek természetesen a vertexekben adottak. Szerencsére ez a képernyő x, y koordinátáitól lineárisan függ, így a háromszögek belső pontjaira lineáris interpolációval meghatározható.
Hardver problémák A láthatóság eldöntésére két alapvetően különböző módszert használnak a valaha megjelent termékek: az ún. Immediate Mode Renderer(IMR)-ek azonnal elkezdenek dolgozni egy háromszögön, amint az a transzformációs egységből megérkezett. Ezzel ellentétben a Deferred Renderer(DR) megvalósítás késlelteti (innen az elnevezés) a feldolgozást amíg az összes, a képernyőt alkotó háromszög transzformációja meg nem történik.
Immediate Mode Rendering Nézzük az előbbi, elterjedtebb eljárást. A transzformált háromszög adatok megérkezésük után egy ún. Triangle Setup egységbe kerülnek, amely pl. a fent vázolt együtthatók kiszámításáért felelős. E mellett minden egyes pixel sorhoz meghatározza azt a két pixel oszlopot, amelyek között az adott sorban a háromszög belső pontjai találhatók. A továbbiakban a renderelés inkrementális elven történik: a sor első pixelétől kezdve végiglépkedünk a háromszög által fedett pixeleken. Minden pixelre meghatározzuk a pixel színét és a Z koordinátát. A pixelnek megfelelő Z-buffer helyről beolvassuk az ott található értéket, s amennyiben az új érték kisebb, akkor azt oda visszaírjuk, valamint a színeket tároló Frame-bufferben felülírjuk a szín információt is (megj.: az átlátszó háromszögek esetén picit máshogy történik a dolog, az érthetőség kedvéért maradjunk a nem átlátszó esetnél). Talán érzékelhető, hogy már maga a Z-buffer algoritmus is jelentős sávszélességet igényel (hiszen mindig be kell olvasni, és esetleg visszaírni), valamint az is látszik, hogy a háromszögek érkezési sorrendjétől függően sokszor teljesen feleslegesen számítunk ki pixel színt, hiszen ha az új Z koordináta nagyobb, mint a Z-bufferben levő, akkor a számítás felesleges volt, egyszerűen eldobjuk az adatot. Az első triviálisan adódó optimalizáció az, hogy a színszámítás megkezdése előtt ellenőrizzük a Z értéket, így az adott pillanatban feleslegesnek tűnő számításnak neki sem kezdünk (ezt "Early Z Check" néven illetik a marketingesek). A felesleges számításokat így sem feltétlenül kerüljük el, hiszen ha pl. a kamerától nézve távolság szerint fordítva érkeznek a háromszögek (tehát a legtávolabbi érkezik elöször - "Back to Front Rendering"), akkor minden egyes pixelt így is kiszámítunk, majd felülírjuk a következővel. Fordított esetben (tehát ha a legközelebbi háromszög érkezik elöször - "Front to Back Rendering") viszont csak a ténylegesen látható színek kerülnek kiszámításra. Bármennyire is jó lehet az eljárás, Z-buffer olvasási sávszélességet még mindig nem spóroltunk (írásit igen hiszen az előbb említett utolsó esetben egyszer sem kell felülírni a Z-bufferben található értéket). További csökkenés pl. az ATI hierarchikus Z-buffer megoldásával érhető el. Képzeljük el a teljes felbontású Z-buffert (ebben ugyebár minden képponthoz tárolódik egy Z érték), majd alkossunk ebben 4x4 pixeles négyzeteket, és hozzunk létre egy kisebb (16-od akkora) felbontású második Z-buffert. Ebben minden 4x4-es négyzetnek megfelelő helyen a 16 érték közül a legnagyobbat tároljuk. Egy háromszög renderelésénél a feldolgozás alatt levő háromszöghöz kiszámítjuk a négyzetnek megfelelő helyeken a Z értéket, majd megkeressük a legkisebbet. Amennyiben ez az érték nagyobb, mint az alacsonyabb felbontású Z bufferből kiolvasott érték, akkor a vizsgált 16 pixelen az új háromszög biztosan nem látszik, így egyetlen Z olvasással 16 pixelnyi munkát takarítottunk meg. Ha viszont nem teljesül az előbbi feltétel, akkor be kell olvasni mind a 16 Z értéket, így végeredményben rosszabbul jártunk, hisz 17 olvasásra van szükség. Általában azonban - a tapasztalati tények legalábbis ezt mutatják - egész jó a hatásfok. További spórolási lehetőség a Z értékek tömörítése, de ebbe nem mennék bele.
Deferred Rendering Más oldalról is megközelíthetjük a kérdést. Helyezzük el pl. a Z-buffer-t on-chip memóriában, így gyakorlatilag akkora sávszélességet biztosíthatunk, amennyi jólesik. Ezután várjuk meg az összes háromszög transzformálását, minden pixelre keressük meg a látható háromszöget (akár sok pixelre párhuzamosan, hiszen a chipben majdnem tetszőleges memória-architektúra kialakítható), majd számítsuk ki minden pixelhez a kimeneti színt. Az egyetlen apró probléma, hogy a teljes képernyő Z-buffer-ét nem nagyon lehet egy chipbe csempészni, ahhoz túl nagy. A PowerVR fejlesztői úgy hidalták át a problémát, hogy a képet kisebb téglalapokra (tile-okra) bontották, s tileonként történik a feldolgozás. Egy tile akkora, hogy Z- és Frame-buffere probléma nélkül beleférjen a chipbe. Persze az összes előnyt rögtön semmissé tenné, ha az összes tile feldolgozása során végig kéne nézni az összes háromszöget, így a transzformációs lépés mögé egy indexelési lépés kerül: minden háromszöget megvizsgálunk, hogy mely tile-okba esnek részei, és az eredményt egy - külső memóriában tárolt - Indexbufferbe helyezzük. Ezt követően egy tile feldolgozásakor az Index-buffer-ből szedegetjük ki azokat a háromszögeket, melyek a tile-ban előfordulnak. Ezzel persze mégiscsak elhasználunk némi extra sávszélességet, de ez nem vészes. A feldolgozás folyamán minden lépés párhuzamosan, de más adaton zajlik: amíg az egyik tile-t indexeljük, addig az előző már a Z-bufferelés fázisában jár, az azt megelőzőnek pedig a színeit számítjuk ki. A megoldás előnye, hogy a Z-buffer algoritmus nem használ túl sok memória-sávszélességet, és hogy a háromszögek érkezési sorrendjétől függetlenül mindig csak a látható pixel színe kerül kiszámításra.