40. fejezet - Multiple light source módszerek


Ideje, hogy végre valami aktuális dologról írjak cikket, mert az eddigiekből legfeljebb annyi jött le, hogy milyen szépen lehet textúrázni egy gömböt. Ami egy játék megírásánál semmire nem jó. A jó hír az, hogy a megvilágítás sem, mert az idő 98%-át úgyis a játéklogikával töltöd el (és a hozzá kapcsolódó lehetetlen dolgokkal, pl. fizika). Az valahogy túl sok, úgyhogy inkább a maradék 2%-ról firkálok valamit.

Előre mondom, hogy van a deferred rendering, ami alatt azt értem ami deferred (pl. az SSAO is az). Ami konkrétan a megvilágítással foglalkozik, annak van két fajtája, a deferred shading és a deferred lighting. A különbség az, hogy az utóbbi külön bufferbe pakolja a megvilágítást és csak a legvégén blendeli rá az albedóra (nem libidóra!).

Forward rendering

Mindenki unja már ezt olvasni, de akkor is leírom. Forward rendering esetében két lehetőséged van:

  • a geometriát annyiszor újrarajzolod, ahány fényforrás van (additive blendinggel)
  • vagy írsz egy übershadert ami sok fényforrást tud kezelni
Übershader alatt az értendő, ami többfajta lighting modelt is tud kezelni. Ilyet írhatsz akár kézzel is, de az igazán elvetemültek on-the-fly pakolják össze kódból. Ennek értelemszerű hátránya, hogy esetenként if-elni kell, tehát költséges a futtatása.

Ha kicsit okosabb vagy, akkor először csinálsz egy z-only pass-t, azaz csak a mélységet rajzolod ki, majd ezután végzed el a megvilágítást (D3DCMP_EQUAL-ra állítva a depthfunc-ot). Jobb kártyák a pixel shader előtt elvégzik a depth testet (early z-test). Ha még okosabb vagy, akkor felosztod a képernyőt csempékre, és csempénként meghatározod, hogy hány fény hat oda és csak annyit rajzolsz (ez egyébként deferred shadinggel is ajánlott).

CODE
device.SetBlendMode(Additive); foreach (Object o in objects) { foreach (Light l in lights) o.DrawWithLight(l); }

Csak poénból írtam C# kódot. Rögtön itt említem meg, hogy míg egy fényforrásnál eddig nyomdafestéket nem tűrő módon sosem gamma korrektáltam, több fényforrás esetén ez erősen ajánlott. Világos, hogy ha a jelenet geometriai komplexitása nagy és sok fényforrásod van, akkor a forward renderinggel lábon lőtted magad. Az ötlet az, hogy csak egyszer kelljen renderelni a geometriát, és csak egyszer a fényeket. Huh? Mutatom:

CODE
foreach (Object o in objects) o.Draw(); device.SetBlendMode(Additive); foreach (Light l in lights) l.Draw();

Ez már annyira elmond mindent, hogy itt abba is hagyom a cikket.


Majdnem olyan, de ferred

Azt mondta erre Saito, hogy rajzold ki a normálokat és a depthet textúrákba, és végezd el a megvilágítást postprocessként. Olyan jó ötlet volt ez, hogy majdnem 20 évet kellett várni mire mindenki felfogta. Hehe...valójában a hardver volt béna, ugyanis ez meg azért lassú, mert baromi sok pixelre kell lefuttatni, illetve az adatok precíziója miatt a bus-t is leterheli. Most itt jön egy agyonnyúzott ábra:

deferredshading

Ha gyenge videókarid van, akkor ez kapásból háromszori jelenetrajzolást jelent, aztán még plusz valamennyit (legrosszabb esetben annyit ahány fényed van). Szóval most a másik lábadat lőtted agyon. Amiben a hardver fejlődése segít, az a multiple render target, az egyre több pixel shader egység és a memória sávszél növekedése (meg persze a GPGPU, lásd Frostbite 2 engine).

És persze a memória...merthogy ez bizony piszok sok memória (volt 10 évvel ezelőtt...ma már a 2 GB-os videókarik korában kutyafüle). Számoljuk csak meg: az albedónak 3 bájt (RGB8), normáloknak ha nem gondolkodsz nagyon, akkor 6 bájt (RGB16F), a depthnek 4 bájt (R32F), és persze kell a target buffer, ami megint 6 bájt, ha gamma korrektálsz. Az összesen 19 bájt, mondjuk 1920x1080-as felbontással számolva ~40 MB. És akkor még nincsenek árnyékok, HDR, meg mindenféle fölösleges dolog.

Ez persze senkit nem tántorít el attól, hogy használja, mert cserébe baromi sok fényed lehet (amíg elég kicsik, nincs árnyékuk és kíméled a hardvert). Azt a sok buffert ott fent együttesen G-buffer-nek hívják (semmi köze a G-ponthoz). A G itt geometry-t akar jelenteni és szinte adódik, hogy legalább normálok és mélység kell bele. Ezeknek az előállítása természetesen multiple render target-el kell történjen, hacsak nem diavetítőre optimalizálsz. Annyit még megemlítek, hogy ha normal mappingot is akarsz, akkor a normalmapban tárolt cuccot world spacebe kell trafózni:

CODE
float3 norm = tex2D(normalmapsampler, tex).rgb * 2 - 1; float3x3 tbn = { wtan, -wbin, wnorm }; color1.rgb = normalize(mul(tbn, norm));

Megint mért kell mínusz...ez a D3DXGenerateTangentFrameEx() függvény amúgy is elég fura, mert a szimplább verziója kajak fordítva működik, mint kéne (és akkor is kell mínusz). A binormált egyébként keresztszorzattal is ki lehet számolni, szóval nem muszáj a DX-ben bízni.

Nem kötelező egyébként world spaceben tárolni a normált; azt mondja a Crytek, hogy írd ki a view space-beli normál xy komponenset felszorozva a z-vel:

CODE
// kiirás color1.rg = normalize(n.xy) * sqrt(n.z * 0.5 + 0.5); // visszaolvasás n.z = dot(color1.rg, color1.rg) * 2 - 1; n.xy = normalize(color1.rg) * sqrt(1 - n.z * n.z);

Némi utánaszámolással adódik, hogy a formula helyes. A fólia szerint a PS3 miatt kellett ez nekik, de én maradok inkább a world space-nél.

Ezzel az input megvan, most jöhet a lényeg. Egyesek olyat csinálnak, hogy a world space pozíciót is kirenderelik a gbufferbe. Ne tedd! Ez ugyanis baromi egyszerűen visszaszámolható:

CODE
uniform matrix matViewProjInv; // ... float d = tex2D(depthsampler, tex).r; float4 wpos = float4(tex.x * 2 - 1, tex.y * -2 + 1, d, 1); wpos = mul(wpos, matViewProjInv); wpos /= wpos.w;

Ahol tex a szokásos fullscreen quad texcoordja. A Crytek erre is ad egy olcsóbb módszert:

CODE
float d = tex2D(depthsampler, tex).r; float3 wpos = eyePos + d * vdir;

Ahol d most lineáris, vdir pedig a kamera pozíciójából a távoli vágósíkjának négy sarkába mutató vektor (interpolálva a screenquad-on). Höfö megcsinálni. Innentől kezdve pedig minden ugyanúgy megy, mint eddig. Azaz majdnem...


Ez itt pont fény...pont egy fény

Mivel olyan költséges dolog a pixel shadert futtatni, nem kéne fölöslegesen. Egy pont fény általában nem világít a végtelenségig. A szép elhalványításhoz az alábbi algoritmust implementáltam le (az eredeti cikk nem érhető már el):

CODE
float Attenuate(float3 ldir) { ldir /= lightPos.w; float atten = dot(ldir, ldir); float att_s = 15; atten = 1.0f / (atten * att_s + 1); att_s = 1.0f / (att_s + 1); atten = atten - att_s; atten /= (1.0f - att_s); return saturate(atten); }

Egy tipikus hiba, hogy ha nem használsz gamma korrekciót, akkor a lineáris változat (atten = distance / lightrange) ad jobb eredményt, ugyanis a gamma hatványra való emelés (ami tipikusan 2.2) majdnem egybeesik a négyzetre emeléssel (és a valóságban olyan). Ez azonban hiba, úgyhogy tessék gamma korrektálni.

Na de ez még önmagában nem old meg semmit, ha viszont tudjuk, hogy a fény csak adott sugarú környezetben világít, akkor ki lehetne számolni, hogy a képernyőn mely pixelekre kell lefuttatni a shadert. Ehhez egy téglalapot kell megadni, és a scissor test az ezen kívülre eső pixeleket kidobja. Ennek kiszámolását ez a cikk szépen le is vezeti, de némi meggondolást hozzá kell tenni:

Ha megvan az érintősík normálvektora, akkor abból az érintési pont könnyen megvan, ugyanis P = L - r N. A másik észrevétel, hogy az e-val való varacskolás fölösleges (bár hatékonyabb), mert tipikusan úgyis csak a projekciós mátrixot tudod. Ekkor viszont Q = P * matProj ahonnan w-vel való osztás után megkapható a scissor rect koordinátája screen spaceben. A harmadik észrevétel pedig, hogy DX-ben az y screen space-beli koordináta a [1, -1] tartományban van, tehát -0.5-el kell szorozni.

Egy másik megoldás erre, hogy kirajzolsz egy gömböt és projektív textúrázással olvasol a gbufferből. Pixel shader szempontból ez jobban hangzik, sőt implementálni is könnyebb pl. spot lightokra (gömb helyett valami kúpszerű vacakkal). Arra viszont figyelni kell, hogy ha a kamera a gömbön belül van, akkor a back faceket rajzold.

A fényekre szintén érdemes view frustum culling-ot alkalmazni, ez pont fényeknél nagyon egyszerű, hiszen 6 darab skaláris szorzás és 6 darab összehasonlítás (ne felejtsd el, hogy a frustumon kívül még van egy r vastag elfogadási zóna).


Problémák a módszerrel

Először is MSAA-val értelmetlen, hacsak nem minden buffer MSAA-s. Na de az már tényleg pazarlás. Ha meg nem, akkor rezolválás után történhet csak a megvilágítás, ami elrontja az antialiasolt képet. Inkább post-AA (de az egy másik cikk). Hasonlóan a memóriahasználat miatt kompromisszumokat kell tenni arra nézve, hogy milyen egyéb adatot írsz ki (material színek: diffuse, specular, emissive, etc.). Szerintem egy material ID még nyugodtan elfér (jó esetben 1 bájt), persze az hogy a shaderben hogyan dolgozod fel az már más kérdés... Megint csak, manapság ez már nem akkora probléma.

Egy másik rossz hír, hogy átlátszó objektumokkal nem tud mit kezdeni, azokat utólag kell egy forward pass-ban kirajzolni. Létezik módszer ennek megoldására, de az csak adott mélységig működik, minden egyéb réteg extra memória, szóval no way (google: stencil routed K-buffer, order-independent transparency).

Tükröződéseket szintén ritkán alkalmaznak (hacsaknem síkfelületen, mert az még könnyű). Bár meg lehetne hackelni pl. az előbb említett material ID-s trükkel, de akkor is sokat kell szórakozni, hogy a gbufferben is a tükrözött értékek legyenek.

Deferred lighting

Milyen előnyei vannak annak, ha a megvilágítást (diffuse és specular) előre kiszámolod? Például az, hogy ehhez elég csak a fent említett normál és depth buffer (illetve a shininess faktor). Ez után viszont a jelenetet mégegyszer kirajzolhatod, immár textúrával/material tulajdonságokkal, az irradiance-t pedig kiolvasod az előbb készített textúrákból. Ez egy lényeges memóriacsökkentés. A még jobb hír, hogy MSAA-t is használhatsz (részben; a megvilágítás nem lesz MSAA-s).

deferredlighting

Szemfüles érdeklődőknek feltűnt, hogy nincs albedó. Dehogynemnincs, a harmadik pass egy második "forward" pass, amiben immár uniformként lepasszolható a textúra, material tulajdonságok, stb. A kirenderelt irradiance-t pedig projektív textúrázással olvassa.

Na de akkor mennyit is spóroltunk ezzel? Normálok + shininess ARGB16F, depth még mindig R32F és a két akkumuláló buffer is ARGB16F. Ez összeadva 55 MB, MSAA-val még plusz 64 MB (ha a végső buffert AA-zod csak). Szóval azt lehet mondani, hogy egy dupla renderelés árán a memóriahasználat lényegesen kisebb és nem függ material tulajdonságoktól.

Mivel ez ilyen, tükröződést is lehet vele használni. Az viszont nem trivi kérdés, hogy a cubemapet hogyan állítsad elő. Én azt mondom, hogy azt is deferred lighting-al, mert egyrészt minimális extra kódolás, másrészt nem is kell neki külön gbuffer, mert úgyis kisebb, mint a rendes, akkor meg lehet használni azt (viewport állítással).

Kérdés, hogy megéri-e? Egyáltalán megéri-e maga a deferred rendering (hiszen eleve az elérhető fps negyedéröl indul)? Az átlátszó objektumoknak még mindig kell egy forward renderer, szóval nem úszod meg a két külön pipeline karbantartását. Sőt, a gbuffer elkészítése miatt az első pass igen költséges (fillrate), azaz ha kevés fényed van, akkor már nem érte meg. A gbufferből való olvasás szintén költséges (memory bandwidth).


Compute shader alapú módszerek

Részletesen erről most nem írok, mert egy külön cikkben lesz róla szó. A lényege ezeknek az, hogy a képernyőt felosztod csempékre, majd egy compute shader mindegyik csempére meghatározza, hogy mely fények hatnak oda. Ez lehetővé teszi, hogy drasztikusan több fényforrást használhass (akár ezernél is többet; de komolyan, melyik játékba kell annyi?). Ha most az előző forward rendereres példakódot tekintjük, akkor az így módosulna (forward+):

CODE
DispatchCompute(...); device.SetBlendMode(Additive); foreach (Object o in objects) { // a fényekre vonatkozó ciklus pedig a shaderben o.Draw(); }

A módszer előnye egyben hátránya is: tényleg csak akkor van haszna, ha nagyon sok fényed van. Deferred shadinggel/lightinggal is ugyanúgy használható, sőt talán gyorsabb is (de akkor megint buktad az MSAA-t). A cikk kódjai között már megtalálható a forward+ renderer implementációja, de OpenGL 4.3-as kártya kell hozzá.


Megjegyzések a kódhoz

A második példaprogiban a híd is X fájl volt, de baromi lassan töltötte be (release módban is). Bár az X formátumnak is van bináris változata, az legacynak számít és nekem egyébként se működik soha. Szóval inkább áthoztam a Quadron mesh formátumát. Illetve bizonyos dolgokat most már kivittem egy közös részbe, amit szentimentálisan dxext.h-nak neveztem el.

Mindkét demóban az árnyék a lassító tényező, ezért a shadowmapek mérete kicsi, de alapvetően nem a felbontástól lassú hanem a geometriai komplexitástól. Például ez az autó eredetileg rengeteg kis batch volt (megőszültem mire összevakartam 5-6 darabba MAX-ban). Ez alapján ha jól ki akarod használni a módszert, akkor gennyesre kell optimalizálnod a geometriádat (pl. static/dynamic batching/instancing). Szintén kell view frustum culling, az occlusion cull-t pedig el lehet végezni az első passban (deferred lighting esetében).

Forward renderer mindenképpen kell. Ha az nincs, neki se állj (vagy győzz meg mindenkit, hogy nem olyan fontos az átlátszóság). Nemrég jelent meg egy forward+ renderer demó, ami baromi sok fényt kezel; bár realitásában nulla szintű szituációt sikerült belesuvasztani, a deferred rendering is kb. ilyen esetekben értelmes...

Summarum

Most sem kíméltem a videókarit, nálam a program alig 50 FPS-en fut. Csak deferred renderingre semmiképpen nem szabad építeni, egyrészt mert amúgyis kell mellé egy forward renderer, másrészt sok esetben az a jobb (pl. amikor a szabadban vagy, hiszen ott csak egy darab fényforrás van). Tehát az enginet úgy kell kialakítani, hogy mindkettővel működjön (és pixelre ugyanúgy nézzen ki). Hasznos dolgokat lehet olvasni deferred renderingről a GPU Gems-ben.

Összefoglalásképpen egy totál nem ide illő designos táblázat:

Totál nem ide illő designos táblázat
Forward Shading Lighting
Pro - mindenképpen kell
- MSAA
- kevés memória
- általános esetben nagy FPS-ről indul
- egyszerű implementálni
- sok kis fényforrás
- nem kell foglalkozni az obj-fény kapcsolatokkal
- egyszerű implementálni
- sok kis fényforrás
- nem kell foglalkozni az obj-fény kapcsolatokkal
- MSAA
- kevés memória
Kontra - geometriára érzékeny
- meg kell határozni, hogy melyik objra melyik fény hat
- komplex shaderek
- sok memória
- különböző materialokat nehéz kezelni
- nem tud reflectiont
- memória sávszél és fillrate gyilkos
- átlátszóakhoz forward renderer
- memória sávszél és fillrate gyilkos
- átlátszóakhoz forward renderer

A táblázat picit csal, mert ha árnyékot is akarsz, akkor mindenképpen optimalizálnod kell. Deferred renderinggel viszont elég limitált, hogy hány ilyen fényforrást engedhetsz meg magadnak (főleg ha pont fény). Általában a nagy fények hülyén néznek ki árnyék nélkül, de én erre azt mondom, hogy ha a közelében van egy árnyékot vető másik fény, akkor nem feltűnő. Szóval pont fényeket inkább eyecandy-hez használj és ne direkt megvilágításhoz.

Egy szó a gamma korrekcióról: ne a gbuffer rajzoláskor hatványozd az albedót, mert az RGB8 formátum nem tudja jól tárolni (tudjuk miért, ld. HDR-es cikk).

Kód itt, videó itt és itt.

deferred1 deferred2


Höfö:
  • Csináld meg forward renderinggel!

back to homepage

Valid HTML 4.01 Transitional Valid CSS!