39. fejezet - High dynamic range


Pár évvel ezelőtt egy zh-ban az volt a kérdés, hogy mi a HDR. Akkor még elég zöldfülű voltam a grafikához, de nem írtam túl nagy marhaságot. Az általános iskolából frissen kirúgott részeges parasztember úgy fogalmazza ezt meg, hogy "aaa hádéerr?? hát az az amikó' kiégeti a szemed a fíny...". Most mindenki be fog ájulni, mert a HDR nem ez. Ezt az effektet light blooming-nak, vagy jobb helyeken glare-nek hívják és mindössze csak egy optikai illúzió (szemlencsén megtörő fény és társai). Mégis ha 3D grafikában valaki HDR-ről beszél akkor mindig ott van ez az effekt és valószinűleg ez az oka, hogy a szót ennek feleltetik meg, helytelenül.

Na de akkor mi a HDR? Emlékeztetőül a képernyőn a pixelek 24 bites színértékeket tudnak megjeleníteni, tehát (0, 0, 0)-tól (255, 255, 255)-ig. Ezt úgy is lehet mondani, hogy a legsötétebb és a legvilágosabb intenzitás aránya 1:256 -hoz. És akkor mivan? Hát az, hogy a szem esetében ez nagyjából 1:10000-hez (a valóságban meg akár 1:1000000-hoz). Puff. A HDR tehát ezt a bizonyos "nagy" intenzitási tartományt jelenti, amit a jelenlegi fényképezők és monitorok nem képesek explicit rögzíteni/megjeleníteni.

Ebben a témakörben két ember munkásságát illik ismerni, az első Paul Debevec. Ő dolgozta ki azt a módszert amivel ezt a nagy dinamikájú tartományt le lehet fotózni: a jelenetről több képet készítesz, változó exponálással (mennyi fényt engedsz be a gép íriszén). Ennek az eredménye egy képsorozat lesz, ahol az alacsony exponálással készült kép alulexponált (sötét), a magassal készült pedig túlexponált (kiégeti a szemed). A honlapján találhatóak úgynevezett light probe-ok, amik ezen képsorozatokból készült HDR képek (tipikusan environment mappinghoz). Ezek .hdr formátumban vannak, amit az ATI-féle CubeMapGen nevü program be is tud tölteni (amennyiben a cube cross változatot adod be neki).

A másik fontos név Masaki Kawase, aki összegyűjtötte a HDR-hez kapcsolódó algoritmusokat és technikákat. A honlapja valamiért nem elérhető, de szerencsére a nevezetes demója még igen. Apró szépséghiba, hogy forrás- és shaderkódokat nem tett közzé, ezzel igen sok hajtépős percet okozva azoknak akik implementálni próbálják. Ebben a fejezetben ismertetni fogom a Kawase féle demóban látható effekteket (ami a gyakorlatban egy csomó reverse engineering-et jelent).

HDR lépések

A legtipikusabb dolog, hogy environment map-nek (amit az égboltra húzol rá) egy HDR képet raksz be. DirectX 9.0 óta támogatott módszer ehhez a floating point texture, ami azt jelenti, hogy az eddig csatornánként egy byte helyett csatornánként egy float-ot tud tárolni (akár 32 de tipikusan 16 biteset). Nyilván innentől nincs megszorítva a textúra a [0, 1] intervallumra, tehát ténylegesen nagy intenzitástartományokat is el tud tárolni.

A félreértések elkerülése végett a kirajzolás végén ezt a nagy tartományt vissza kell skálázni a képernyő tartományába úgy, hogy a kontrasztarányok megmaradjanak. Ezt a lépést úgy hívják, hogy tone mapping; egy egyszerű megoldás erre például color / (color + 1). Sok egyéb megoldás létezik, de nekem eddig a sima gamma korrekció (később) adta a legjobb eredményt.

A rajzolás nagyvonalakban az alábbi részekből áll:

  • jelenet kirajzolása FP rendertargetbe
  • fénysűrűség megmérése
  • bright pass (kiszedi a nagy intenzitású részeket)
  • downsample (lekicsinyíti a képet)
  • glare generálás (a fény átsüssön az objektumokon)
  • star, ghost, egyéb effektek
  • gamma korrekció
De mi a helyzet a régi kártyákkal (amiken nincs FP textúra)? Kawase erre is ad megoldásokat, például az RGBE formátumot (alapból nincs ilyen, de igy könnyebb leírni). A színt eltárolod úgy ahogy van, és az alfa csatorna mondja meg, hogy mennyire intenzív az a pixel. Nyilván számoláskor a pixel intenzitása valami color * alpha * scale lesz (hiszen az alfa is a [0, 1] intervallumra van megszorítva).

Én most itt mindenhez FP textúrát használok, de nyilván a glare generáláshoz nem feltétlenül kell ez (de akkor máshogy kell megírni a bright pass filtert). Megjegyezném, hogy ha nincs HDR cubemaped, akkor is lehet értelme FP targetet használni, ha mondjuk több fényforrásod van (sőt, akkor ajánlott is).


Előkészítés

Az environment mapet az említett CubeMapGen nevű programmal lehet megcsinálni, de fontos, hogy float16 formátumba mentsed. Én bunkó módon kivettem ezeket Kawase demójából (Cache mappa), mivel valahogy jobban meg vannak szűrve. Ha valaki közvetlenül a .hdr-t akarná betölteni, akkor itt egy kód.

A program elején hasznos ellenőrizni, hogy van-e egyáltalán FP support; ezt az alábbi módon lehet megtenni:

CODE
HRESULT hr; D3DDISPLAYMODE mode; if( FAILED(hr = direct3d->GetAdapterDisplayMode(0, &mode)) ) { MYERROR("Could not get adapter mode"); return hr; } if( FAILED(hr = direct3d->CheckDeviceFormat( 0, D3DDEVTYPE_HAL, mode.Format, D3DUSAGE_RENDERTARGET, D3DRTYPE_TEXTURE, D3DFMT_A16B16G16R16F)) ) { MYERROR("No floating point rendertarget support"); return hr; } // más depth/stencil-el még müködhet if( FAILED(hr = direct3d->CheckDepthStencilMatch( 0, D3DDEVTYPE_HAL, mode.Format, D3DFMT_A16B16G16R16F, D3DFMT_D24S8)) ) { MYERROR("D3DFMT_A16B16G16R16F does not support D3DFMT_D24S8"); return hr; }

A textúrákat ezek után teljesen ugyanúgy kell betölteni mint eddig, a rendertargetek létrehozásakor viszont a D3DFMT_A16B16G16R16F formátumot kell megadni. Ez persze jóval több memóriát foglal (és a fillrate-nek sem tesz jót), mint az A8R8G8B8, ezért a többi target mindenképpen kisebb legyen a képernyőnél.


Bright pass filter

Mint tudjuk a light blooming csak a nagyon világos részeknél látható, ezért ki kell szűrni azokat a részeket ahol ténylegesen fény (vagy annak visszaverődése van). Egy egyszerű képlet erre:

bright = max((color - threshold) / (1 - threshold), 0);

Ez abban az esetben is jó, ha nem FP a cubemaped. Na rendben, de Kawase itt elhallgat egy apró dolgot: a threshold függ az exposure értékétől (és ezt csak a visszafejtett assembly kódból sikerült észrevennem). A regiszterek értékei alapján a következő közelitést csináltam:

bright = color * (exposure / 500.0f) - 0.002;
bright = min(max(bright, 0), 16384.0f);


Ahol az exposure 0.004 és 16.0 között változhat (de általában 0.5-1.7).

brightpass

Ez a bright pass target már kisebb mint amibe a jelenetet rajzoltam, konkrétan a fele. A blurhoz viszont ezt még tovább kell kicsinyíteni.


Gaussian blur

A cel shading-es fejezetben elmondtam mi az a diszkrét konvolúció, ezért ezt nem írom le újra. Ami most más lesz, az a kernel: a blurhoz a normális eloszlás sűrűségfüggvényét kell használni (Gauss filter). Az 1D-s és 2D-s Gauss filterek képlete az alábbi:

gauss

Ahol σ a normális eloszlás szórása (általában 0.8), a kitevőben szereplö x és y értékek pedig az origótól való távolságot jelzik (amiből kihozható a filter sugara). A Directx SDK-s HDRPipeline példában ez implementálva is van, így előre kiszámolható a kernel. Egy baromi fontos tulajdonsága ennek a függvénynek, hogy szeparálható, tehát ugyanazt az eredményt kapom ha először x-re alkalmazom az 1D-s filtert, majd ennek eredményére szintén 1D-set, de y-ra.

A probléma az, hogy kis sugárral nem látszik a blur, nagy sugárral pedig baromi lassú. Ezért azt mondja Kawase, hogy ne a blur sugarát növeljük, hanem a képet skálázzuk egyre kisebbre és használjuk mindig ugyanazt a sugarat. Ezeket összeadva és lineáris szűrést használva egy elég jó blur-t lehet kihozni:
gaussblur

Nagyon fontos, hogy először lekicsinyíted a képet és utána blurozod meg külön-külön mindet. A végső additive blendeléskor pedig ne felejtsd el bekapcsolni a lineáris szűrést mindegyik samplerre (D3DSAMP_MAGFILTER). A vicc az, hogy Kawase a demójában egyáltalán nem használja ezt (ugyanakkorák a blur targetek), ugyanis bármennyire jó is a lineáris szűrés, a glare mozgás közben láthatóan "vándorol". Ennek megoldásához lehet használni kézzel valami más szűrőt (pl. bicubic).


Milyen sztár? Olvasztár!

Ez nem egy szükséges, de elég látványos effekt. Hasonló látható például a Crysis 2-ben és a Mass Effect 3-ban (cinema light). Az effekt négyszer blurozza meg a brightpass eredményét, négyféle irányban, majd ezeket összeadja. Két változóval operál, az egyik neve b, ez a passtól függően a textúra offsetet mondja meg, illetve az a változó a texel súlyát adja meg (mennyire halványuljon el a csillag a széle felé).

CODE
float2 texelsize; int starpass = 0; int stardir = 0; static const float2 staroffsets[4] = { { -1, -1 }, { 1, -1 }, { -1, 1 }, { 1, 1 } }; void ps_star( in float2 tex : TEXCOORD0, out float4 color0 : COLOR0) { color0 = 0; float b = pow(4, starpass); float a = 0.9f; float2 off = staroffsets[stardir]; float2 stex; for( int i = 0; i < 4; ++i ) { stex = tex + b * i * texelsize * off; color0 += tex2D(sampler0, stex) * pow(a, b * i); } color0.a = 1; }

Nyilván a starpass és stardir konstansokat a program adja át a shadernek. Általában 2-3 pass elég, tehát ez a pixel shader összesen 8-12-szer fut le, ezért érdemes a negyedelt targetre meghívni.


Lens flare

Ezt az effektet szintén ritkán látni a valóságban, ugyanis a kamera lencséi közötti belső fényvisszaverődések okozzák. Más néven ghost-nak hívják, számomra érthetetlen módon. Az ötlet nagyon egyszerű: skálázzuk le a képet a közepénél fogva. Világos, hogy ha ezt elég sokszor megcsináljuk akkor pont a kívánt eredményt érjük el vele. Ezt a bizonyos skálázást tisztán a textúra koordinátákkal el lehet érni, ekkor viszont a "maradék" részt ki kell maszkolni.

CODE
void ps_ghost( in float2 tex : TEXCOORD0, out float4 color0 : COLOR) { float2 t = tex - 0.5f; // a képet a közepénél fogva felskálázza több méretbe float2 a = (tex - 0.5f) * -1.724f; float2 b = (tex - 0.5f) * -2.845f; float2 c = (tex - 0.5f) * -1.0f; float2 d = (tex - 0.5f) * -1.957f; float2 e = (tex - 0.5f) * -2.147f; float2 f = (tex - 0.5f) * -4.0f; float2 g = (tex - 0.5f) * -1.794f; // kimaszkolja a fölösleges részeket float sa = saturate(1 - dot(a, a) * 3); float sb = saturate(1 - dot(b, b) * 3); float sc = saturate(1 - dot(c, c) * 3); float sd = saturate(1 - dot(d, d) * 3); float se = saturate(1 - dot(e, e) * 3); float sf = saturate(1 - dot(f, f) * 3); // vissza textúra térbe a += 0.5f; b += 0.5f; c += 0.5f; d += 0.5f; e += 0.5f; f += 0.5f; // mindegyik flare más színű color = tex2D(sampler0, a) * sa * float4(0.250f, 0.175f, 0.125f, 1); color += tex2D(sampler1, b) * sb * float4(0.131f, 0.187f, 0.131f, 1); color += tex2D(sampler0, c) * sc * float4(0.103f, 0.103f, 0.103f, 1); color += tex2D(sampler1, d) * sd * float4(0.2f, 0.2f, 0.250f, 1); color += tex2D(sampler0, e) * se * float4(0.101f, 0.050f, 0.050f, 1); color += tex2D(sampler1, f) * sf * float4(0.102f, 0.102f, 0.102f, 1); color += tex2D(sampler2, g) * sg * float4(0.248f, 0.248f, 0.248f, 1); color0.a = 1; }

A kódban az si változók reprezentálják azt a bizonyos maszkot; nyilván ez minden skálázásra egyedi lesz. A textúra olvasások száma limitált, ezért nem lehet akármennyi ilyen flare-t csinálni, ezért Kawase azt csinálja, hogy kirajzol mondjuk négyet a képernyő közepéhez közel, majd ezt a textúrát használja fel a továbbiakhoz (tehát négyesével rajzolja ki a flareket). Én most csak szétszórtam őket, ahogy sikerült.


Afterimage

Ezt eddig nem csináltam meg, de most kedvet kaptam hozzá. Ezt az okozza, hogy a retinán levő sejtek ráállnak az erős fényre és így a gyenge fényhez lassabban szoknak hozzá (ld. automatic exposure control bekezdés). Ettől, ha mozgatod a szemed, néhány másodpercig egy fénycsíkot látsz, amivel akár még a nevedet is le tudod írni! A shader ami ezt megcsinálja baromi egyszerű:

CODE
void ps_afterimage( in float2 tex : TEXCOORD0, out float4 color0 : COLOR) { float4 prev = tex2D(sampler0, tex); // előző frame-beli afterimage float4 current = tex2D(sampler1, tex); // mostani bright pass color0 = prev * 0.998f + current * 0.011f; color0 = min(max(color0, 0), 256); color0.a = 1; }

Nyilván a 0.998f és 0.011f buherálásával lehet rövidíteni a fénycsík láthatóságát. Az effekt két rendertarget között pingpongozik (hiszen nem írhatok abba amiből olvasok), de alapvetően a bright pass eredményét használja fel. A végső blendeléskor ugyanúgy kell kezelni, mint a bloomot.


Gamma korrekció

Sokáig nem értettem, hogy miért nem úgy néz ki a program, mint a Kawase-féle, aztán feltűnt két érdekes renderstate állítás:

CODE
device->SetRenderState(D3DRS_DITHERENABLE, true); device->SetRenderState(D3DRS_SRGBWRITEENABLE, true);

Az első fölösleges, mert csak akkor működik ha 16 bites targetbe renderelsz. Az olyan színekhez amik nem férnek be a 16 bitbe, keres valami más színt. Például .gif képeknél látható ilyen, amikor egy folytonos színátmenet helyett foltok vagy csíkok jelennek meg (posterization).

A második viszont egy nagyon fontos dolog, amihez meg kell érteni a gamma korrekció fogalmát. A fénysűrűséget (luminance) ahogyan a kamerák képesek azt rögzíteni, lineárisnak lehet tekinteni. Ez azt jelenti, hogy ha kétszer annyi fotont engedsz be a kamerába (ld. exponálás), akkor azt ő kétszer világosabbnak fogja érzékelni. Az emberi szem viszont nemlineárisan működik: kétszer annyi fénysűrűséget csak kicsit lát világosabbnak.

A kamera lineáris érzékelésével önmagában semmi baj nem lenne, ha a képet nem 8 bites csatornákkal tárolná. Így ugyanis sok bit elmegy a világos részeknek, amiket amúgy sem látunk és a sötét tónusoknak meg kevés marad (amiket viszont jól látunk). Ezért a legtöbb képformátum (ami nem .raw) az érzékelt színt felemeli egy gamma hatványra, tipikusan 1 / 2.2 -re (JPG). Ez lehetővé teszi, hogy az intenzitások egyenletesebben tárolódjanak el (sRGB).

Amikor viszont meg akarsz nézni egy ilyen képet vissza kell konvertálni. Ezt a monitor automatikusan elvégzi, tehát neked nem kell vele törődni. Akkor most mégis miért? Az a helyzet, hogy a 3D grafikai algoritmusok nem működnek sRGB térben. Tehát ha van egy tetszőleges textúrád, akkor az első dolgod az kéne legyen, hogy linearizálod (powf(color, 2.2f)). Persze ezt tipikusan mindenki elfelejti...

A másik jó hír, hogy ez a float16 cubemap nem sRGB, úgyhogy a végső renderelésnél kell gamma korrektálni (powf(color, 1 / 2.2f). Szerencsére a DirectX 9.0c-vel megjelentek az sRGB textúrák, amik megkönnyítik az említett dolgokat. Ha sRGB-ként hozol létre egy textúrát, akkor a kártya hardveresen elintézi a linearizálást, amikor olvasol belőle (illetve ha írsz, akkor gamma korrektál). A backbuffer esetében a fenti renderstate állítással lehet a gamma korrektált írást bekapcsolni.

Megjegyzés: vigyázz, hogy hogyan használod együtt a HDR és LDR textúrákat. Nem mindig néznek ki jól együtt, érthető okokból, ezért külön okoskodni kell, hogy hogyan legyen mégis jó a kép. Még egy fontos dolog, hogy az ARGB8 formátum nem tud lineáris értékeket tárolni (ronda lesz, amikor korrektálod).


Automatic exposure control

Amikor egy sötét szobából kimész a napra, akkor pár másodpercig nem látsz semmit, ugyanis a szemnek idő kell amíg alkalmazkodik a fényviszonyokhoz. Ezt light adaptation-nak hívják. Ahhoz, hogy valami hasonló effektet lehessen elérni, először meg kell mérni a jelenet átlagos fénysűrűségét. Erik Reinhard azt mondja, hogy ez kiszámolható az alábbi módon:

avglum

A δ azért kell, hogy ne legyen az érték nulla (merthogy a logaritmusfüggvény ott nincs értelmezve). A módszer az alábbi: kiszámoljuk a jelenet fénysűrűségének logaritmusát egy 64x64-es textúrába, majd ezt leskálázzuk 16x16, 4x4 és végül 1x1-re. Az utolsó 1x1-es textúrába rajzoláskor végezzük el az exp-et. Az első lépés tehát az alábbi:

CODE
void ps_avglum( in float2 tex : TEXCOORD0, out float4 color0 : COLOR0) { float3 sample; float3 lumvec = float3(0.2125f, 0.7154f, 0.0721f); float sum = 0; for( int i = 0; i < 9; ++i ) { sample = tex2D(sampler0, tex + texelsize * avglumoffsets3x3[i]); sum += log(dot(sample, lumvec) + 0.0001f); } sum *= 0.1111f; color0 = float4(sum, sum, sum, 1); }

Ezt egy box filterrel lekicsinyítjük 16x16-ra, majd azt 4x4-re, és végül 1x1-re, és ezen utolsó lépésben alkalmazzuk az exp-et. Az átlag fénysűrűség tehát ebben az 1 texelnyi textúrában van. A sima átmenethez csináltam még kettő 1x1-es textúrát (merthogy nem renderelhetek abba ami textúraként be van állítva), és a DirectX SDK által javasolt módon végeztem el az adaptációt:

CODE
void ps_adaptluminance( in float2 tex : TEXCOORD0, out float4 color0 : COLOR0) { float adaptedlum = tex2D(sampler0, float2(0.5f, 0.5f)); float targetlum = tex2D(sampler1, float2(0.5f, 0.5f)); float newadaptation = adaptedlum + (targetlum - adaptedlum) * (1 - pow(0.98f, 30 * elapsedtime)); color0 = float4(newadaptation, newadaptation, newadaptation, 1); }

Ez nem annyira látványos, mint a Kawase-féle, de legalább nem égeti ki a szemedet a kép. Az elapsedtime konstans az előző frame óta eltelt időt reprezentálja, tehát az adaptáció kb. 30 fps-en megy. Most hogy megvan az átlagos fénysűrűség (és szép simán adaptálódik nem azonnal), ideje lenne kiszámolni belőle az exposure-t. Ez már nagyon egyszerűen megtehető, ugyanis:

CODE
float exposure = targetluminance / tex2D(sampler5, float2(0.5f, 0.5f)).r;

Ahol a targetluminance konstans olyan 0.01 és 0.05 közötti érték (nálam 0.03). Nyilván a bright pass-ban és a végső pass-ban is ezzel kell számolni.


Summarum

Ebben a fejezetben videókártyát nem kímélve egy olyan technikát mutattam be, ami nagyban hozzájárul egy játékbeli jelenet látványossá tételéhez. Szándékosan nem "valósághű"-t mondok, mert egy játékban a látvány szokta megfogni az embert; márpeding ilyen fényeket a valóságban ritkán látni.

hdr

Az implementációt SM 2.0-ra optimalizáltam, így bármely olyan kártyán elindul, ahol van FP texture support (alsó határként egy GeForce 6200-at jelölnék meg).

A kód letölthetö innen. A képekhez használt többi HDR textúrát felraktam külön.
A cikkhez az alábbi két forrást használtam: egyik, másik.


Höfö:
  • Implementáld a HDR-t az RGBE formátummal!
  • Egészítsd ki a lens flare effektet!

back to homepage

Valid HTML 4.01 Transitional Valid CSS!