Bármilyen endzsinnek gúnyolt kódhalmaz egy sarkalatos pontja a pongyolán mondva fotoreaisztikus megjelenítés, melynek legfontosabb eleme a fények, illetve megvilágítás kezelése.
A korábban írt deferred lighting-os tutoriál az előkészítése volt annak amit most az elmúlt hónapokban az engineben kidolgoztam.
Régi szép idők
Kezdeném azzal, hogy hogyan nézett ki az engine a fejlesztés előtt. A jelenetet egy jelenetleíró XML fájlban lehet megadni, természetesen ez a hozzám hasonló lusta programozóknak jelent segítséget.
Kicsit kockábbak meg tudják csinálni kódból is, de a jövőre nézve mindenképpen célszerűbb az XML-t használni, hiszen a majdani editorral ezen keresztül fog kommunikálni. Na de egy példa:
CODE
<?xml version="1.0" encoding="utf-8"?>
<scene>
<def id="def_grass">
<material id="grass" file="effects/blinnphong.qfx">
<uniform name="lightPos" value="0;50;-50" />
<texture stages="0" file="textures/grass.jpg" />
</material>
</def>
<transform scale="1000;10;1000" translate="0;-40;0">
<use href="#def_grass">
<box />
</use>
</transform>
</scene>
A részlet a homokozóból van (itt szoktam kitesztelni dolgokat). Tipikusan az egész XML fájl ilyenekből áll, illetve lehet részfákat definiálni a <def> taggel, így több objektumnak meg lehet adni pl. ugyanazt a materialt a <use> taggel. Ebből az XML-bl épül fel a színtérgráf. Részletesebben ebbe most nem megyek bele. Amit fontos elmagyarázni az az engine material fogalma, ez ugyanis nem csak felületi tulajdonságok (diffuse, specular, opacity, stb.) halmaza, hanem shadereket is tartalmazhat. A material tehát megfelel egy effekt fájlnak kiegészítve az említett felületi tulajdonságokkal. Az objektumok egyes részeinek (subset) külön-külön materialja lehet. Egy még fontosabb dolog, hogy a materialokat két csoportba lehet osztani aszerint, hogy hogyan akarod használni. Világos, hogy vannak esetek, amikor egy effektet sok objektumra akarsz alkalmazni (pl. kirajzolni a normálokat), de azoknak már van saját materialja. Azt a materialt ami objektumhoz tartozik lokálisnak hívom. Nem nehéz kitalálni, hogy akkor van globális is, de mondok egy meglepő dolgot: van parciális is. Ezt az enginebe még nem implementáltam le (máshol igen), amúgymeg elfelejtettem mit csinál (de most pont kéne). No de, ahhoz hogy ez működjön bevezettem egy új render state-et: material mode. Amikor egy globális material-t akarsz használni a következőt mondod: CODE
myglobalmat->IsGlobal = true;
// ...
Graphics->SetMaterialMode(qMM_Global);
myglobalmat->Apply();
{
Scene->Render();
}
myglobalmat->Unapply();
Graphics->SetMaterialMode(qMM_Object); // vissza lokálisba
Ez az új render state annyit csinál, hogy egyrészt csak globális effektet enged beállítani, másrészt minden lokális material felületi tulajdonságait átpasszolja az éppen beállított globális materialnak (amikor beállítódnának). Ez már nagyon régóta benne van az engineben és nagyon sok mindent meg lehet vele csinálni (a mostani fénykezelés is erősen épít rá). Visszatérve a jelenetre: eddig ha egy materialnak nem volt effektje, akkor a fixed function pipeline-al rajzolódott (ez mindig van, ha máshogy nem, emulálva). Az egyik fontos változás most, hogy ez továbbra is igaz, de csak akkor ha a custom pipeline van bekapcsolva (ezekről később). Tehát eddig minden megvilágításhoz itt kellett megadni a használandó shadert, nem volt általánosan összeszedve. Ez elég problémás volt, mert több hasonló effekt fájlt kellett karbantartani.
Látom a fényt!
Az első lépés az volt, hogy kitaláljam hogyan fognak elhelyezkedni a fények a színtérgráfban. Első ötlet az volt, hogy hierarchikusan, mint a material és transform csúcsok. Ezt azért vetettem el, mert nehéz volt azt leírni, hogy egy fény
két totál más helyen (az XML-en belül) levő objektumra hat. Van olyan objektum amire mondjuk három fény hat, egy másikra ebből csak kettő; nagyon hamar összegubancolódsz az XML fában, az implementációról nem is beszélve.
Rendelj előre
Először a forward renderert mutatom be, ugyanis az implementáció oroszlánrésze itt van. A fent emlegetett színtérgráfot nodevisitor-ok segítségével lehet bejárni (látogató tervminta); speciel a renderer is ilyen. Sokáig problémát okozott, hogy miben tároljam a rajzoláshoz szükséges információt, hiszen:
A renderelés menete a következő:
Látható, hogy elég sok mindent művel a renderer, de azt legalább gyorsan teszi. A z pre-pass nagyon fontos, hogy minimalizáljad a fölösleges pixel shader hívásokat. A legtöbb GPU-ban van early z test, azaz ha a fragment takarásban van, akkor a pixel shader nem hívódik meg. Megemlítendő még a custom objektumok rajzolása, ugyanis azokra a következő szabályok érvényesülnek:
Éclairage de Ferred
Mivel a forward renderer minden szükséges információt előállít, a deferred renderernek nincs túl sok dolga. Az objektumok rendezésénél megmelítettem a normal map figyelembevételét, ugyanis az egy külön shader, és azért vannak az objektumok így rendezve, hogy ne kelljen sokszor váltogatni. Hasonlóan a fények is ezért vannak rendezve.
Rögtön kiemelnék egy fontos dolgot DirectX-el kapcsolatban. Gondolom mindenki ismeri a nevezetes cikket, melynek lényege, hogy a screenquadot -0.5-el el kell tolni screen spaceben. Azért fontos ez, mert a harmadik, azaz a forward passban ezt kompenzálni kell: CODE
float2 ptex = cpos.xy / cpos.w;
#ifdef __HLSL
ptex.xy += renderParams.zw; // 0.5f / screen size
#endif
// read irradiance
half4 dlight = tex2D(sampler4, ptex);
half4 slight = tex2D(sampler5, ptex);
// ...
Ha ezt nem teszed meg, akkor egy jól látható csík lesz az objektumok bal oldalán. OpenGL-ben és DX10-től fölfele már nem kell ezzel szórakozni. A deferred rendererre nem is fecsérelnék több szót, mert szinte teljesen ugyanaz mint a tutoriálban.
Unit cube clipping
Szerintem elég hülye elnevezés, de ettől most tekintsünk el. A feladat az, hogy a shadow map a lehető legoptimálisabban legyen kitöltve mindhárom irányban. Tehát a projekciós mátrixot kell ráilleszteni az árnyékvető objektumokra.
A pont fény a legegyszerűbb, hiszen ott a far plane legyen a fény sugara és kb. ennyit tudtál tenni.
Meg kell említeni a Crysis 2-t, ott ugyanis a pont fény 6 darab különálló fény (árnyék szempontból), és mint ilyen különböző paraméterek vonatkozhatnak mindegyik részre.
CODE
qAABoundingBox casterbox = SZUMMA(<shadow_casterek_boundingboxa>);
qVector3 refpoint = casterbox.GetCenter();
qVector3 forward = light->GetDirection();
qVector3 right = qVector3::Cross(<megfelelő_up_vektor>, forward);
qVector3 up = qVector3::Cross(forward, right);
qPlane leftright = qPlane::FromRay(refpoint, right);
qPlane bottomtop = qPlane::FromRay(refpoint, up);
qPlane nearfar = qPlane::FromRay(refpoint, forward);
float r = casterbox.Farthest(leftright) - leftright.d;
float l = -casterbox.Farthest(-leftright) - leftright.d;
float t = casterbox.Farthest(bottomtop) - bottomtop.d;
float b = -casterbox.Farthest(-bottomtop) - bottomtop.d;
float f = casterbox.Farthest(nearfar) - nearfar.d;
float n = -casterbox.Farthest(-nearfar) - nearfar.d;
view = qMatrix4::ViewBasisLH(qVector3::Zero, forward, right, up);
proj = qMatrix4::OrthoLH(l, r, b, t, n, f);
Mint látható a nézeti mátrix egy sima forgatás (merőleges vetítésben nincs kamera pozíció), tehát a projekciós mátrixban szereplő értékek abszolútak (az origótől mérendők), ezért kell levonni a sík távolságát. Ez még mindig egy viszonylag könnyű dolog volt. A legnehezebb azonban a spotlight, illetve akkor nehéz, ha mindenáron meg akarod csinálni a unit cube clippinget. Gondoljuk végig mit kell csinálni. A frustum most egy csonkagúla, tegyük fel, hogy a nagy része üres. Tehát valahogy ezt a gúlát kéne úgy megvagdalni (egy nem szimmetrikus gúlára), hogy befoglalja az árnyékvetőket. Az előbb world spaceben számoltam ki a szükséges értékeket, most meg kell fordítani a dolgot: vetítsük le a casterbox-ot screen spacebe és legyen az így kapott két érték ssmin és ssmax. CODE
ssmin.x = (ssmin.x * n) / proj._11;
ssmin.y = (ssmin.y * n) / proj._22;
ssmax.x = (ssmax.x * n) / proj._11;
ssmax.y = (ssmax.y * n) / proj._22;
// create assymetric projection
proj = qMatrix4::PerspectiveOffCenterLH(ssmin.x, ssmax.x, ssmin.y, ssmax.y, n, f);
Mi történik itt: NDC-ből visszakonvertál view spacebe. Ha felrajzolod azonnal látszik (proj._11 = 1 / tan(fovy / 2)). A mátrixos függvényt meg tudod nézni a D3DX dokumentációban, teljesen hasonló egyébként mint a fenti ortho, csak perspektív zw-vel. Megjegyezném, hogy ez nemlineáris mélységet fog csinálni, tehát a vertex shaderben fel kell szorozni a távoli vágósíkkal. Az nem jó, ha átírod a mátrixot 1 / (f - n) és -n / (f - n)-re, mert az nem a [0, 1]-be fogja transzformálni a z-t hanem [0, 1 / f]-be!
A shadow acne probléma
Ettől viszont falra másztam. De mi ez tulajdonképpen? Arra mindenki rájött, hogy a shadow map felbontása és precíziója véges, melynek az a következménye hogy az objektum hibás módon saját magára is vet árnyékot. A képen is jól látható, hogy a mélység diszkretizációja milyen mintát varázsol az objektum felületén. Ez egy örök probléma, amit teljesen soha nem lehet megoldani. Viszont lényegesen lehet rajta javítani különféle trükkökkel. Ezek közül többet is kipróbáltam (a Secrets of CryEngine 3 papert bújva):
Először is mit csinál a ddx, illetve GLSL-ben a dFdx függvény? A dokumentáció ilyen mágikus szavakat ír, mint parciális derivált; hát persze hogy az, de gondoljuk meg, hogy pixeleket raszterizálunk, azaz egy diszkrét függvény deriváltjáról van szó, amit pongyolán nevezhetünk különbségnek. Ezt azért tudja megtenni a kártya, mert nem egyenként dolgozza fel a pixeleket, hanem 2x2-es blokkokban (de még mindig párhuzamosan!). Egy blokkon belül mindegyik pixelre meg tudja mondani a többitől vett eltérését a paraméterben megadott értéknek. CODE
// shadowmap készítéskor
float dx = abs(ddx(dist));
float dy = abs(ddy(dist));
dist += depthBias.x * (dx + dy) + depthBias.y;
color = float4(dist, 0, 0, 1);
Amennyiben nincs ddx, akkor ezt meg lehet tenni a shadow kirajzolásakor is, de akkor háromszor kell olvasni a shadow mapből. A paraméterek megfelelő belövésével majdnem megoldja a problémát, viszont az objektumok szélénél peter pannel. Kis belegondolással adódik, hogy miért: ha a derivált elég nagy, akkor kitolja a depth értékeket a jó tartományból (mint a konstans bias). Márpedig az objektum szélénél annyi lesz amennyit kiszámolt egy érvényes pixelben: nagy. Open for research kérdés, hogy hogyan lehetne jól felhasználni. Egy fontos dolgot megjegyeznék: a ddx-et nem lehet (dinamikus) elágazásban/ciklusban használni, ugyanis ha a blokkon belül valamelyik pixel más végrehajtási ágra futhat, akkor nem definiált a megadott változó értéke.
Shadow mask
A Crytek nevezetes fóliája alapján csináltam. Miért kellett ez nekik és miért kell nekem?
Ugyanazért: hogy SM 2.0-val is menjen az árnyék. Majdnem minden DirectX-es tudja, hogy az ilyen shaderekben maximum 96 utasítás lehet, amiből maximum 64 lehet aritmetikai. A felhasználható konstans regiszterek száma 32 (uniformok).
Feltűnő, hogy a shadow mask-ban van egy csomó acne, a végleges képen viszont nem látszik (de, sajnos átlátszó objektumoknál igen). Ez azért van, mert az acne pont az objektumok árnyék felőli oldalán van (hát persze, hiszen backfacet rajzoltam). A Crytek is megemlíti, hogy átlátszó objektumokra ez nem jó; elvileg jó lehet, de akkor objektumonként kellene megcsinálni a maskot, na azt én nem vállaltam be (ők se). Ezért a forward rendererrel átlátszó objektumokra nem hat árnyék, illetve mint mondtam azokon keresztülnézve néha látszik az acne. Az nem megoldás, hogy belerajzolod azokat is a mask-ba, mert akkor az árnyék nem fog átlátszani rajtuk.
Irregular PCF
A shadowmap-nek egy további hátránya, hogy az árnyékrajzoláskor egy shadowmap texel sok pixelhez rendelődik hozzá. Ezt perspective aliasing-nak hívják, és ez sem egy trivi probléma, de szerencsére elég sok algoritmus létezik már az árnyékok puhítására.
De egyébként ez nem egy olyan bonyi algoritmus, hogy sokat kelljen reverse engineeringelni. Az alapötlet az, amit SSAO-hoz is csináltak: a PCF-hez használt kernel nem fix, hanem random változik. Kérféle megközelítés létezik:
Az implementációt nem ragozom túl: a random kernelt el kell forgatni a textúrából olvasott szögekkel. A kernel az egységkörön belül Poisson eloszlású értékek; copy-paste. CODE
const float2 irreg_kernel[8] =
{
{ -0.072, -0.516 }, { -0.105, 0.989 },
{ 0.949, 0.258 }, { -0.966, 0.216 },
{ 0.784, -0.601 }, { 0.139, 0.230 },
{ -0.816, -0.516 }, { 0.529, 0.779 }
};
template <int samples>
float PCFIrregular2D(sampler shadowmap, sampler noisetex, float3 cpos, float2 texelsize)
{
const float kernelradius = 2.0f;
const float kerneloffset = 30.0f; // radius * 15
// ...
float2 stex = cpos.xy * kerneloffset;
float2 noise = tex2D(noisetex, stex);
noise = normalize(noise * 2.0f - 1.0f);
float2 rotmat0 = float2(noise.x, noise.y);
float2 rotmat1 = float2(-noise.y, noise.x);
for( int i = 0; i < samples; ++i )
{
rotated.x = dot(irreg_kernel[i], rotmat0) * kernelradius;
rotated.y = dot(irreg_kernel[i], rotmat1) * kernelradius;
sd = tex2D(shadowmap, cpos.xy + rotated * texelsize).r;
s += ((d > sd) ? 0.0f : 1.0f);
}
return s * (1.0f / samples);
}
Ez a 15 egy mágikus konstans, nem tudom honnan jött, de nekem megfelel így is. A forward renderer 4 samplet vesz, a deferred 8-at. Mindkettő bőven elég. A shadow mask-ot egyébként meg is lehet blurozni még egy bilateral filter-el.
Irregular PCF pont fényekre
Ez szintén egy elég bonyolult dolog, ugyanis tökjó, hogy van cubemap, csak baromi nehéz szűrőket alkalmazni rá. A Crytek mint említettem eintézte ezt úgy, hogy nem használ cubemapet árnyékhoz, helyette egy textúra atlaszba pakolja az összes
ilyen fény shadowmapjeit.
CODE
float3 l = normalize(ldir);
float3 up = { 0, 1, 0 };
if( l.y > 0.98f )
up = up.xzy;
// sose tudom merről kell szorozni, de itt oly mindegy
u = normalize(cross(l, up));
v = normalize(cross(l, u));
// ezekkel olvasol a cubemapből
off2 = l + u * texelsize.x;
off3 = l + v * texelsize.y;
off4 = off2 + v * texelsize.y;
Világos, hogy miért rossz: ez a gömböt érintő korongon szedett mintának köze nincs a majdani textúra térhez, így ahogy a cubemap széle felé haladsz egyre rondább lesz a PCF. Speciel meg lehetne buherálni úgy, hogy a vektortől függően változtatni a kernel sugarát. Mivel ígyis-úgyis ronda, az is elfogadható ha egyáltalán nem filterelsz (pont fényre úgyis ritkán kell árnyék). Na de a másik "megoldás". Az ötlet az, hogy a cubemap címzése valójában visszavezethető 2D-s címzésre, ugyanis a vektor leghosszabb komponense megmondja a cubemap egyik oldalát, a másik két komponens pedig onnantól egy mezei 2D koordináta ((másikkettő / leghosszabb) * 0.5 + 0.5). Ha tehát meg tudnám úgy buherálni a vektort, hogy a művelet elvégzése után egy offsetelt texelt adjon meg, akkor működhet is. Tegyük fel most, hogy x a leghosszabb komponens, ekkor CODE
float3 CubeOffsetXYZ(float3 swiz, float2 off, float2 texelsize)
{
float3 ret;
ret.yz = swiz.yz + 2.0f * off * texelsize * swiz.x;
ret.x = sqrt(1.0f - dot(ret.yz, ret.yz));
if( swiz.x < 0 )
ret.x *= -1.0f;
return ret;
}
egy jó nagy baromság, de annyira nem ocsmány, mint vártam. Ez a "tegyük fel, hogy" úgy értendő, hogy néhány if-el lekezeled, hogy mit kell meghívni. Egy rondább dolog a zaj textúra megcímzése. Választhatsz, hogy:
Tone mapping és gamma korrekció
Azt már említettem, hogy ha több fényforrás van, azt FP rendertargetbe szokás akkumulálni, így implicit módon HDR lesz. A képernyő tartományába való visszaskálázást hívják tone mapping-nak. Én ezt egyelőre
nem használok, de innen lehet inspirációt meríteni.
Emlékeztetőül, hogy hogyan is működik ez: a content amit a modellezőtől kapsz, tipikusan srgb color space-ben van, tehát az intenzitása nemlineárisan változik (srgb = pow(rgb, 1 / 2.2)). Ez azért van, hogy optimálisan használja ki a channelenként 8 bitet. Az engineben viszont nem számolhatsz srgb térben, mert a fény fizikai modellje nem arra vonatkozik. Ezért a textúrákat használat előtt konvertálni kell. Erre két lehetőség van:
Babérgyűjtés
Kiraktam a homokozót egy példa jelenettel. A progiban le van rakva egy kamera valahova az origó köré és a z tengelyen néz előre (balkezes koordinátarendszer). Tone mapping és gamma korrekció be van kapcsolva. A jelentleíró fájl (sandbox.xml) szerkeszthető.
Vacak gépen nem teszteltem. Ha problémád van OpenGL-el, akkor a settings.ini-ben átírod a renderert d3d9-re. Ha valami miatt elszállna a program akkor egy log-ot küldj emailben :)
Summarum
Azt hiszem ez egy tartalmas cikk lett, sok problémát viszonylag jól meg tudtam oldani (a túrót, még mindig nem javítottam a modelleket, a cubemapos PCF-től meg rosszakat álmodok).
Amit nem csináltam meg, az a transzparens objektumok árnyékának gyengítése (de gondold meg, hogy deferred rendererrel ezt nem is nagyon lehet),
illetve a világításba való beleszólás egy custom effekttel (ez megint csak orbitálisan nehéz dolog, nem éri meg).
Egyelőre ezt a részt azt hiszem nem nagyon kell fejlesztgetni, illetve amint lesz DX11 kártyám, fogok csinálni egy forward+ renderert is, ugyanis láthatóan erre gyúrnak manapság (ld. Unreal 4 tech videó). Höfö:
|