Árnyékokról már volt szó a shadow volume-os cikkben, amit pofátlan módon csak azért írtam, mert különben baromi hosszú lett volna ez a cikk.
Sőt, külön arcátlanság volt ez tőlem, mert a játékok alig használnak shadow volume-ot (de a Doom 3 elég jól megcsinálta).
TartalomjegyzékKi gondolta volna, hogy ezt a technikát már 1978-ban kitalálta Lance Williams? Azért meglepő ez, mert ezek szerint még 2004-ben sem használták érdemben (dehogynem, csak nem valós időben). De mint tudjuk a legtöbb technikával ez így volt, teljesítmény- avagy látványbeli problémák miatt. Utóbbi ezzel is van bőven.
CODE
float z = tex2D(shadowmap, <light space pozíció>).r; // projektív textúrázás
float d = <a pont mélysége light space-ben> // ugyanúgy számolva, mint a shadowmap-ben
float s;
if( z < d )
s = 0; // árnyékban van
else
s = 1; // nincs árnyékban
color = lighting * s;
Amit ha végiggondolunk teljesen világos is, hiszen ha az adott pontot valami eltakarja, akkor ott árnyék van (z < d). Márpedig a shadowmap pont ezeket a takaró pontokat tartalmazza. A későbbiek érdekében ezt a fenti kódot formalizálni fogom a cikkhez felhasznált irodalmaknak megfelelően: Legyen x ∈ R3 egy pont a térben amiről el akarjuk dönteni, hogy árnyékban van-e. Legyen p ∈ R2 ennek a pontnak a levetítése light space-be (tehát amivel olvasni lehet a shadowmapből), és definiáljuk az árnyékolást (s) az alábbi f: R2 → [0, 1] függvénnyel: Ami tehát ugyanaz, mint a fenti kód. Az eddigiekhez képest most vastag betűvel jelölöm a vektorokat, hogy az irodalmakkal konzisztens legyen (gondolom ez senkit nem fog zavarni). A függvényt röviden f(d, z)-nek fogom írni. Ami egy fontos észrevétel, hogy ez az f könnyen átkonvertálható egy egyváltozós H függvénnyé úgy, hogy f(d, z) = H(z - d): Itt egy picit eltértem az irodalmaktól, mert azok fordítva végzik el a kivonást, de én abban nem látom a logikát (kavarást viszont igen). A lényeg, hogy ez a H függvény (Heaviside step, magyarul "egységugrás") központi szerepet fog játszani a későbbiekben, ugyanis ezt szeretnénk majd közelíteni olyan függvényekkel, amikben z (d-től) független tagként szerepel (ezáltal szűréssel elsimítani a recés árnyékot). Ha a fenti kódot valaki megcsinálja, akkor rövid időn belül fel fog tűnni, hogy az árnyék széle nagyon pixeles. Ez a perspektív megjelenítésnek egy hátránya, mivel a shadowmap world space-ben terül szét valami felületen, egy shadowmap texelhez több képernyőpixel tartozhat.
A nagyobbik probléma, hogy egyszerűen nem lehet ezt elsimítani (pl. a shadowmap blurozásával), mert z-re nem alkalmazható
konvolúció (részletes levezetés megtalálható itt).
Informálisan gondolkodva: ha megblurozod a shadow mapet, azzal csak annyit érsz el, hogy a mélységértékek egy átlagát hasonlítod az éppen aktuális pontéhoz, ami még mindig 0 vagy 1, de legalább marhaság.
A kép egy nagyon extrém esetet ábrázol, de megjegyezném, hogy bármekkora shadowmap felbontással előállhat ugyanez, ha elég nagy jelenetet kell lefednie. Egy kis trade-off-al jelentős javulás érhető el, amit irregular PCF-nek hívnak, és annyiból áll, hogy nem egy fixen meghatározott szomszédságban végzi el az átlagolást, hanem véletlenszerűen. Ennek az eredménye nagy frekvenciájú zaj lesz, de a szerzők állítása szerint ez sokkal kevésbé vehető észre. Egy korábbi cikkben írtam már erről, úgyhogy nem részletezném (és amúgyis ott a kód). Mézesmadzag gyanánt legyen elég annyi, hogy 25 olvasás helyett 8-al is jobb eredményt ér el... A PCF és a PCSS egy állandó problémája a shadow acne, amit szintén kitárgyaltam az előbb linkelt cikkben. Röviden a lényege az, hogy a shadowmap véges felbontása miatt a beleírt mélységértékek diszkretizálódnak, így az objektumokon zfightol az árnyék. Négy lehetséges megoldás van erre:
Ami még megemlítendő, hogy léteznek ún. shadow sampler-ek, amik hardveresen elvégzik a PCF-et (így kevesebb olvasás is elég). Én ezt sosem használtam, mert kártyafüggő. Itt most egy kis valószínűségszámítás következik, amiből én mindig is síkhülye voltam, de ennyit azért sikerült felfognom. A végéről közelítem meg a dolgot, mert úgy lehet értelmesen elmagyarázni.
Feltételezve, hogy a z, mint valószínűségi változó valamilyen μ várható értékű és σ2 szórásnégyzetű eloszlásból való, és d > μ (ez fontos!). A várható értéket jobb helyeken átlagnak is mondják, tehát μ = E(z), ami megfelel a megblurozott shadow mapnek. A másik amire szükség van a szórásnégyzet, amit viszont ki lehet számolni az alábbi képlettel: Na és ehhez mi kell? Hát az, hogy a shadowmap ne csak z-t tartalmazza, hanem z2-et is. Tehát a shadowmap GR32F formátumú kell legyen (32 bites float, red és green channel), ami picit több memória. 16 bites float-al is lehet próbálkozni, de az én tapasztalataim szerint nem szép az eredmény. Előbb megmutatnám a kódot, aztán a problémákat: CODE
float2 moments = tex2D(shadowmap, <light space pozíció>).rg; // E(z) és E(z2)
float d = <a pont mélysége light space-ben>
float mean = moments.x; // μ = E(z)
float variance = max(moments.y - moments.x * moments.x, 1e-5f); // ne lehessen 0
float chebychev = variance / (variance + (d - mean) * (d - mean)); // a fenti képlet
// de a feltételnek teljesülnie kell
if( d > mean )
s = chebychev;
else
s = 1;
Ez szép és jó, csak egy baj van vele: nézzük csak meg a Chebychev egyenlőtlenséget: nem egyenlő van ott, hanem kisebb-egyenlő. Tehát amit itt kiszámoltunk az csak egy felső korlát, és nem fog mindig jó eredményt adni. A gyakorlatban ez light bleeding formájában fog megmutatkozni, mégpedig akkor, amikor a mélységértékekben egy nagyobb ugrás van (a szórásnégyzet nagy). Ez egy durva probléma, és következik belőle az is, hogy a shadowmap-be mindent bele kell rajzolni (árnyékvetőket és fogadókat is), sőt értelmes értékkel nem is lehet letörölni (a jelenet teljesen ki kell töltse). A bleedinget teljesen nem lehet eltüntetni, de csökkenthető az itteni érvelésnek megfelelően: ha egy ilyen "hibás" pixel egyébként teljesen árnyékban lenne, akkor a Chebychev képlet értéke "elég kicsi". Tehát például az alábbi kód elég jól kiszűri a bleedinget: CODE
const float amount = 0.1f; // ki kell kísérletezni
chebychev = smoothstep(amount, 1.0f, chebychev);
Ahova ez nem elég, oda alkalmazható a layered VSM technika, ami a shadowmapet diszjunkt régiókra osztja mélység szerint, amikben a szórásnégyzet nem leng ki nagyon. A módszer tehát közel sem használhatatlan, sőt a Crysis használja is a főhős árnyékához. Most elkezdhetnék itt regélni Fourier sorokról (kiejtés: furié), de úgy az életben nem fogom tudni kiszámolni amit kell. Úgyhogy arról majd a következő matekos cikkben lesz szó.
Azért is fölösleges most ismertetni, mert az idevonatkozó cikk
se tette ezt meg, hanem a lehető legegyszerűbb utat választotta: H(t)-t felírta egy 2 periodicitású "négyzethullámként" (fordítsd le szebben, hogy square wave), aminek a Fourier sora már ismert.
Így sokkal egyszerűbben kezelhető a dolog. Mint megbeszéltük, f(d, z) = H(z - d), tehát akkor (és azt a π-t meg beviszem mostmár mert nem férek ki én se): És kihasználva azt az azonosságot, hogy sin(a - b) = sin(a)cos(b) - cos(a)sin(b) kapjuk, hogy: Ha van még olyan elvetemült, aki felírja erre a képletre a konvolúciót is, akkor látható lesz, hogy a blur !!!-al megjelölt részekre előre alkalmazható a konvolúció (tehát elég azt megblurozni). Nem, én nem írtam fel; ha mégsem igaz, akkor az eredeti cikk szerzőinél tessék dörömbölni :) Ugyanide lehet egyébként eljutni a "rendes" Fourier sor definícióból is, csak hússzor ennyi számolással. Ami zavaró itt az a végtelen szumma, nyilván ez a gyakorlatban nem alkalmazható, de előbb nézzük meg mennyire jó a közelítés ha a szumma csak valami M-ig megy: A közelítés láthatóan működik, csak épp van némi probléma vele. Először is hullámos (ezen majd segítünk). A nagyobbik gond, hogy ahogy M növekszik, úgy egyre több sin és cos textúra kell (hiszen minden iterációs lépéshez külön textúrákra van szükség). Mivel a sin és cos olyan, hogy a [-1, 1]-ben veszi fel az értékeit, nyugodtan bepakolhatóak ARGB8 textúrákba, tehát akkor mondhatjuk, hogy egy textúra 4 darab iterációs lépést el tud intézni (azaz M / 2 darab textúra kell összesen). A spórolás érdekében most csak az M = 4 eset érdekel (de az M = 16 eset adna igazán szép eredmény). Nézzük mit lehet ezzel kezdeni: legyen corrected(x) = (fourier(x + u) - v) * w. Ezzel a képlettel meg lehet úgy buherálni a függvényt, hogy a hullámzásokat egy [0, 1]-be kényszerítéssel eltüntessük: A megfelelő u, v, w értékeket a diagramból is ki lehet következtetni, nekem végül u = 0.055, v = 0.055, w = 1.15 lett a nyerő. Az, hogy 1 környékén mi történik az annyira nem érdekel, mert ekkora távolság ritkán fordul elő egy jelenetben (de jobb értékválasztással az is eltüntethető). Az viszont elég baj, hogy a 0-tól balra a függvény nem elég meredek. Ennek az az eredménye, hogy ha z - d elég kicsi, akkor ott árnyék helyett világosság lesz (tehát még durvábban bleedel, mint a variance shadow). Speciel egy p hatványra emeléssel lehet rajta segíteni, de ez nem lehet fix érték, mert minél nagyobb annál inkább elrontja az árnyék puhaságát is (hiszen az is ugyanabból adódik, mint a bleeding). Ennek megjavításához meg már kell PCSS (később). Erre a módszerre nem is érdemes több szót pazarolni, ezért kódot sem írok, ugyanis a szerzők is rájöttek, hogy a "korrekció" miatt totál fölösleges Fourier sorokkal küszködni, így született meg az Merthogy valójában elég lenne azt a nulla környéki részt modellezni, arra pedig az exponenciális függvény teljesen megfelelő. Az új képlet tehát:
Valamilyen c konstanssal. Megint csak z-t sikerült függetleníteni d-től, tehát az azt tartalmazó részre előre alkalmazható a konvolúció (tehát akkor a shadowmapbe ecz-t kell kiírni). Hasonlítsuk össze a fentebbi Fourier részletösszegből kikatyvasztott függvénnyel: Hát igen, gyakorlatilag halál ugyanaz, sokkal egyszerűbben és olcsóbban. Viszont a baja is ugyanaz: ahogy egyre nagyobb c-t választasz, úgy persze a közelítés is javul, és csökken a light bleeding, de vele együtt az árnyék puhasága is. Ezen is PCSS-el lehet segíteni (dinamikusan variálni c értékét). Viszont van egy nagyobb probléma, mégpedig az az eset, amikor z > d. A diagramon látszik, hogy a [0, 1] intervallumon a függvénynek (meglepő módon) exponenciálisan nő az értéke, tehát ezekben az esetekben egyrészt a shadow map precíziója drasztikusan romlik (ez a kisebbik a gond), másrészt a blur miatt olyankor is előállhat ez az eset, amikor egyébként nem kéne: A doboz szélénél (és ezt lehet detektálni a shaderben) a blur miatt a függvény hibás módon átesik ebbe a z > d esetbe, így ott nem tudja elsimítani az árnyékot. Az említett cikk ilyenkor egy PCF-szerű megoldásra fallbackel: egy 2x2-es régióban kiértékeli a függvényt, majd az eredményeket átlagolja. Szerintem baromi ronda. Egyébként sem tetszik nekem ez a két utóbbi módszer (CSM és ESM), mert semmi kedvem hackelgetni a light bleeding eltüntetéséhez. Ez elsőre elég rejtélyes technikának tűnik, mert senki nem részletezi túl, még az eredeti dolgozat sem.
De a lényege egyszerű, mint a kő: (z, z2) és d helyett használjuk ugyanezeknek az exponenciális változatát, tehát (ecz, e2cz) és ecd-t (ahol c egy hasonló konstans, mint előbb). A Chebychev-egyenlőtlenség így egy jobb közelítést tud mondani.
Mintha minden álmunk valóra válna...hát sajnos nem, mert a szórástól még mindig függ, bár kevésbé; másrészt az exp használatából adódó hibák megmaradnak (csak a diagram nem tudja kihozni). A dolgozat még ehhez hozzácsap annyit, hogy ne csak ezt a két értéket írjuk ki a shadowmapbe, hanem ennek a negatív változatát is, azaz (-e-cz, -e-2cz)-t, majd erre is számoljuk ki a Chebychev-egyenlőtlenség értékét. Ezt az indokolja, hogy nagy c esetén ugyanúgy artifactok keletkezhetnek ha több fogadón is áthalad az árnyék (nem azért, hogy igyon). Én a diagramokon nem tudtam kisilabizálni, hogy mire gondolt, de a lényeg, hogy a végső árnyék a kapott két felső korlát minimuma: CODE
float4 warps = tex2D(shadowmap, <light space pozíció>);
float d = <a pont mélysége light space-ben>
float posd = exp(POS_SCALE_FACTOR * d);
float negd = -1.0f / exp(NEG_SCALE_FACTOR * d);
float posbound = chebychev(warps.xy, posd);
float negbound = chebychev(warps.zw, negd);
s = min(posbound, negbound);
Tehát akkor egy RGBA32F shadowmap kell (plusz ugyanez blurhoz). Hát elég meredek memóriahasználata van, de cserébe az eredmény lényegesen jobb: A legrondább light bleedinget megszüntette, viszont a doboznál még mindig rossz (holott a dolgozat szerint nem lenne szabad). Szerintem ebben az esetben kéne képbe jönnie a negatív warp-nak; höfö kitalálni, hogy így van-e, és hogyan kell belőni c-t. Ez nem egy különálló technika, mert bármelyikhez hozzá lehet csapni (bizonyos meggondolásokkal). A lényeg annyi, hogy az árnyék távolabb eső részei puhábbak legyenek, mint a közelebbi részei. A módszer nincs
túlmagyarázva sehol, viszont az NVIDIA SDK-ban van rá példakód, úgyhogy én is az alapján dolgoztam.
Ahol a fény sugarát (r) a fény terében kell megadni, tehát r = fény_sugara / frustum_méretei. Az ábrán kiszámolt érték (?) annak a régiónak a sugara, amiben az átlagolást el kell végezni. Megjegyezném, hogy a textúra olvasások száma fix (9x9) és az algoritmus csak az offsetet buherálja, aminek látható következménye van, de legalább hatékony. Legyen ennek a lépésnek a neve findAverageBlockerDepth. Ezek után a penumbra mérete meghatározható az itt leírt képlettel, azaz (inkább kódban írom): CODE
float2 avgdepth = findAverageBlockerDepth(d, vpos.z, ptex); // [0, 1]-ben
float avgdepthls = clipPlanes.x + avgdepth.x * (clipPlanes.y - clipPlanes.x); // [0, 1] -> fény view space
float2 penumbra = lightRadius * (vpos.z - avgdepthls) / avgdepthls; // a PDF-beli képlet
float2 filterradius = penumbra * clipPlanes.x / vpos.z; // vissza shadowmap térbe
float2 stepuv = (filterradius / PCF_STEPS);
float s = 0;
// 5x5-ös PCF továbbra is, de változó offsettel
for( int i = -PCF_STEPS; i <= PCF_STEPS; ++i ) {
for( int j = -PCF_STEPS; j <= PCF_STEPS; ++j ) {
z = tex2D(shadowmap, ptex + float2(i, j) * stepuv).r;
s += ((z < d) ? 0.0f : 1.0f);
}
}
s /= 25.0f;
Ábrát ehhez nem rajzoltam, mert a PDF-ben található elég egyértelmű (de az átlagszámoláshoz tartozó nem volt az). Az eredménye elég látványos, de a fix textúra olvasások miatt csíkos. Ez a szemléltetéshez nem baj, a fejlettebb technikákhoz pedig az irodalomjegyzékben található anyag (XXXX Soft Shadow Mapping). Az első lépés hatékonyságán lehet javítani ún. summed area táblákkal, de ahhoz nem találtam elég anyagot. Maga az algoritmus egyszerű, csak a GPU-n nem trivi kiszámolni (esetleg compute shaderrel).
Mint látható sok módszer van, de én használhatónak csak az (irregular) PCF-et és a variance-ot gondolom, elsősorban a többi módszer memóriaigénye miatt. Ez azért is fontos, mert általában nem elég egy shadowmap a jelenet lefedéséhez, hanem egy "shadow mip chain"-t kell építeni (pl. külső terekhez). Ezekről nem írtam semmit, mert szintén sok van belőlük, de a leghasználhatóbb a cascaded shadow maps és nem is
olyan egetrengetően bonyolult.
Irodalomjegyzék http://www.cad.zju.edu.cn/home/jqfeng/papers/Exponential/... - Exponential Soft Shadow Mapping (EGSR, 2013) http://research.edm.uhasselt.be/tmertens/papers/... - Real-Time, All-Frequency Shadows in Dynamic Scenes (SIGGRAPH, 2008) http://developer.download.nvidia.com/presentations/2008/GDC/... - Advanced Soft Shadow Mapping Techniques (NVIDIA, 2008) http://research.edm.uhasselt.be/tmertens/papers/gi_08_esm.pdf - Exponential Shadow Maps (2008) http://http.developer.nvidia.com/GPUGems3/gpugems3_ch08.html - Summed-Area Variance Shadow Maps (NVIDIA, 2008) http://research.edm.uhasselt.be/tmertens/papers/csm.pdf - Convolution Shadow Maps (EGSR, 2007) http://developer.download.nvidia.com/shaderlibrary/docs/shadow_PCSS.pdf - Percentage-Closer Soft Shadows (NVIDIA) |