42. fejezet - Shadow volume


Mindenki értetlenül néz, hogy miért foglalkozok shadow volume-al, hiszen múlt századi technológia. De nem ám! CAD-ban a mai napig ez az egyetlen ami szóba jön ("itt kezdődik az árnyék"), annak ellenére, hogy memóriaigényes és lassú (DX10 óta létezik gyorsabb megoldás).

Először a Jedi Outcast-ban találkoztam ezzel (ott volumetric shadow-nak hívják), ami a Quake 3 enginet használja. Az akkori gépeket eléggé megizzasztotta (P2 800 MHz-en esélytelen volt ezt bekapcsolva játszani), és wikipédia szerint nem is működött túl jól. Az mindenestre biztos, hogy a self shadow elég baltával faragott volt.

A technika zászlóshajója viszont egyértelműen a Doom 3; gondolom most mindenkiben szép emlékeket idéztem fel (bizony, ez a játék egy mérföldkő volt a 3D grafikában). Az égvilágon minden vet (dinamikus) árnyékot (több fényforrásból) és egy akkori gépen is vígan szaladgált (P4 2.4 GHz, 512 MB, ATI 9600 XT 128 MB). Egyes családtagok azóta se bírják feldolgozni az átélt borzalmakat (merthogy ez horror játék).

Az interneten fellelhető idióta tutoriálok mind ugyanazt a hülyeséget másolják egymásról, kihagyva egy (több) kulcsfontosságú dolgot. Én persze még véletlenül sem ilyet írok, ez egy komoly szakmai értekezés kérem.

Why so serious?

Történelemből sosem voltam túl jó, de a Google szerint ezt a koncepciót már 1977-ben felvetette egy Frank Crow nevű ember, de az első implementáció 1991-ben történt csak meg, hol máshol mint a Silicon Graphics-nál (SGI). Aki nem tudná tőlük ered az OpenGL (amit akkor még IRIS GL-nek hívtak). Ezt csak azért mondom, hogy tudjátok kinek küldeni az (OpenGL-t) anyázó leveleket.

Az ötlet nem különösebben egetrengető: az árnyék valójában egy test; ami ezen belül van az árnyékban van, ami nem az nem. Az árnyéktestet még majdnem ki is lehet számolni úgy, hogy a fényforrás szemszögéből az objektum sziluettjét kihúzod a végtelenbe. Aztán el lehet dönteni, hogy egy adott pont beleesik-e ebbe a térrészbe, vagy nem.

Rögtön leírnám a feltételt, amit sokszor elhallgatnak (és bizonyos meg nem nevezett programok olyan eszméletlenül kerülik meg, hogy kirohansz a világból).

Az árnyékot vető objektum minden egyes éle pontosan két háromszöghöz kell tartozzon.

Ezt itt és most kőbe vési mindenki. Van egyébként - kvázi - megkerülési lehetőség, de a hablatyolásait olvasva felejtősnek mondanám. A feltételt egyesek (az emlegetett idióta tutoriálok, ami nem ez) úgy fogalmazzák meg, hogy az objektum zárt kell legyen (vagyis nem lehet benne törés). Ez gyengébb feltétel, nem elégséges. A helyes megfogalmazás az, hogy az objektum 2-manifold kell legyen (bármit is jelentsen az).

A feltételt az objektumok 99%-a nem teljesíti (még egy kocka sem), tehát rögtön következik, hogy külön célszerű tárolni a rendereléshez szükséges változatot (texcoordokkal, normálokkal) és amit árnyékgeneráláshoz használsz (csak pozíció). Amennyiben az árnyéktestet a CPU-n generálod, akkor a generátor objektum lehet a rendszermemóriában (és így kirakhatod külön szálakra). A Doom 3 tudtommal nem, de a Quake 4 előre eltárol collision object-et a modelljeihez, amit fizikához és árnyékhoz használ.


Menüett

Amennyiben sikerült kitalálnod az éleket (nem különösebben egetrengető feladat), akkor a rendereléskor az első teendő, hogy a fény irányából nézve meghatározd az objektum "körvonalait" (sziluett), azaz a megfelelő éleket. Ezt úgy lehet könnyen és gyorsan megcsinálni, hogy végigrombolsz az éleken, melyekben eltároltad, hogy melyik pontosan kettő háromszöghöz tartozik, és megvizsgálod a pontosan kettő háromszög normáljait. Ha az egyik a fény felé néz, a másik meg nem, akkor az a sziluetthez tartozó él. Vidám kódrészlet következik:

CODE
D3DXMATRIX inv; D3DXVECTOR3 lp; D3DXMatrixInverse(&inv, NULL, &caster.world); D3DXVec3TransformCoord(&lp, &lightpos, &inv); // for ... const Edge& e = edges[i]; a = D3DXVec3Dot(&lp, &e.n1) - D3DXVec3Dot(&e.n1, &e.v1); b = D3DXVec3Dot(&lp, &e.n2) - D3DXVec3Dot(&e.n2, &e.v1); if( (a < 0) != (b < 0) ) { if( a < 0 ) { std::swap(e.v1, e.v2); std::swap(e.n1, e.n2); } silhouette.push_back(e); }

Ha pont fényed van (nekem most az), akkor nyilván a háromszög síkjától való távolságot kell nézni. Ha pozitív, akkor a fény felé néz. Illetve van még egy megemlítendő rész, de az implementációtól függ: ha a második háromszög néz a fény felé, akkor megfordítom az élet (különben a kinyújtott volume bejárása fordított lesz).

Remélem senki nem siklott át azon tény felett, hogy a fény object space-ben kell legyen (és a modell is btw., ha még nem tűnt volna fel). Nem mindegyik tutoriál említi meg ezt a lényegtelen kulcskérdést.

A következő feladat az, hogy a sziluettet kinyújtsad a végtelenbe. Ha nem akarod nem muszáj, de valamivel ki kell töltenem a helyet. Valamikor mondtam ilyet, hogy homogén koordináta, sőt azt is mondtam, hogy ha w = 0, akkor az egy vektort jelent, speciel az (x, y, z) irányú egyenesek végtelenbeli közös pontját.

A volume palástja tehát élenként két háromszög, azaz négy vertex, melyből kettő az él vertexei, a másik kettő pedig... nos egy irányvektor a fényből a megfelelő élvertexbe.

CODE
D3DXMATRIX inv; D3DXVECTOR3 lp; D3DXMatrixInverse(&inv, NULL, &world); D3DXVec3TransformCoord(&lp, &lightpos, &inv); // for ... const Edge& e = silhouette[i / 4]; vdata[i] = D3DXVECTOR4(e.v1, 1); vdata[i + 1] = D3DXVECTOR4(e.v1 - lp, 0); vdata[i + 2] = D3DXVECTOR4(e.v2, 1); vdata[i + 3] = D3DXVECTOR4(e.v2 - lp, 0);

A fényt és a modellt továbbra is object space-ben kell megadni. World space-ben nem jó!!! ((p - l)M = pM - lM igaz, de gondold végig, hogy lM az micsoda). Ezek után a vertex shader a következőt műveli:

CODE
void vs_shadowvolume(in out float4 pos : POSITION) { pos = mul(mul(pos, matWorld), matViewProj); }

Ó mily egyszerű, nem kell ide semmiféle ?: Ne erőlködj, a fixed function pipeline-on nem tudod megcsinálni ezt, hacsak nem csinálod az egész transzformációt te és a végén D3DFVF_XYZRHW-ben küldöd le. Ugyanis a D3DFVF_XYZW azzal nem működik (OpenGL-ben lehet, hogy igen).


Vicc a projekciós mátrixról

A távoli vágósík a végtelenben kell legyen.

CODE
proj._33 = 1; proj._43 = -near;

Ez DirectX-es projmátrix, ne károgj hogy 2-vel kéne szorozni. Kiszámolod a lim (z - near) / z határértéket (z → ∞) és megnyugtatod magadat, hogy ez tényleg jó.


Epic fail

Másfél milliós kérdés következik: hogy mondod meg, hogy egy pont az árnyéktesten belül van-e? A közönséget nem tudod megkérdezni, de Tim Heidmann kitalált egy tök jó megoldást, úgyhogy őt hívd fel inkább.

Van ez a bizonyos stencil buffer, amibe lehet számolgatni. Például ha egy pixel elbukott a stencil test-en, akkor ott növelődjön meg a stencil buffer értéke. A stencil test megnézi, hogy a stencil buffer értéke milyen relációban van egy adott értékkel (stencil ref) és az alapján csinál vagy nem csinál valamit. A következő esetek lehetségesek:

  • a referencia <valami> mint a a stencil érték és a fragment átment a depth testen (stencil pass)
  • a referencia <valami> mint a a stencil érték és a fragment elbukott a depth testen (depth fail)
  • a referencia nem <valami> mint a a stencil érték (stencil fail)
Ahol a <valami> valamilyen reláció lehet (<, <=, >, >=, stb.). Mindhárom esetben történhet valami a stencil buffer értékével:
  • nem változik (keep)
  • eggyel nő, de nem fordul vissza nullába (incrsat)
  • eggyel nő, és visszafordulhat nullába (incrwrap)
  • eggyel csökken, de nem fordul vissza a maximális értékbe (decrsat)
  • eggyel csökken, és visszafordulhat a maximális értékbe (decrwrap)
  • nullázódik (zero)
  • felveszi a referencia értéket (replace)
  • bitenként invertálódik (invert)
Nagyon sok alkalmazási lehetősége van a mai napig is. Ki tudod maszkolni vele a kép tetszőleges részét, vagyis olyan mint egy univerzális scissor test. Nem meglepő, hogy annak eldöntéséhez, hogy egy pixel árnyékban van-e fel lehet használni. Kétféle módszer létezik:
  • depth pass
  • depth fail
A depth pass-ról nem írok semmit, mert nem csináltam meg (de egyébként bizonyos meg nem nevezett nVidia nevű hardvergyártó nagyon propagálja). Szerintem problémák tekintetében ugyanolyan nehéz, mint a nevezetes (és Creative által kisajátított) Carmack's reverse vagy konyhanyelven depth fail nevű technika.
  • z-buffert feltöltöd (ez forward renderereknél még jól is jön)
  • color, depth write disable, cullface-t megfordítani (CW)
  • stencil test be (ALWAYS), stencilpass: KEEP, stencilfail: KEEP, depthfail: INCR (wrap)
  • kirajzolod a volume-okat
  • cullface vissza (CCW), depthfail: DECR (wrap)
  • kirajzolod a volume-okat
  • visszaállítod amit elrontottál
pic2

A képen sárga a shadow volume és zöld a kamera néhány sugara. A számok a stencil buffer értékét jelentik abban a pixelben. Vegyük észre, hogy ahol árnyék van, ott a stencil buffer értéke >= 1 marad. Tök jó nem? Most jöjjön egy tévedés:
  • kirajzolod a jelenetet
  • stencil enable (LESSEQUAL), ref = 1
  • kirajzolsz egy fullscreen quadot
Ha most örülsz, hogy jajjdeszép árnyékod van, akkor csináld meg ugyanezt két fényforrással és gyönyörködj a recékben (az emlegetett Jedi Outcast). Jó tudom, olcsóbb, de akkor sem helyes.
  • stencil enable (GREATER), ref = 1
  • kirajzolod a jelenetet
Höfö integrálni a forward rendereredbe (ami szintén höfö volt, így azt már nem kell megcsinálnod). Na de, ha azt hitted, hogy ilyen egyszerű az élet, akkor tévedtél, ugyanis ennek a módszernek van egy elhanyagolhatóan baromira idegesítő követelménye:

A shadow volume-on kell legyen sapka (fején is, seggén is).

Az elülső befedésre a legegyszerűbb módszer, hogy végigdarálsz a háromszögeken, és ami a fény felé néz azt bepakolod a shadow volume-ba. Csak zöldfülűek ájulnak be a lockolástól; az okosak eleve sysmem-be rakják az árnyékvetőt.

A hátsó befedésre mondok két módszert:
  • a sziluett "közepét" kitolod a végtelenbe (+1 vertex), és csinálsz egy propellert háromszögekből
  • az elülső befedés bejárását megfordítod
Akármit is választasz, zfightolni fog, mint állat, úgyhogy én két centivel beljebb toltam az egész volume-ot a fényvektor mentén (illetve a hátsó befedésnél pedig 0 helyett egy nagyon pici értéket adtam meg). Ha sokat akarsz szívni, akkor megcsinálhatod depth bias-al is, én nem tudtam megfelelően belőni.

A legjobb cikk ami ezt az egészet leírja 12-szer ennyi oldalban itt található.


Gyorsítás geometry shader-el

Eddig DX10-el nem foglalkoztam és most emlékeztettem magamat arra, hogy miért nem. Rögtön mondom, hogy a PIX-et el lehet felejteni, mert nem működik (a Windows 7 SDK-val még igen), helyette Visual Studio 2012-ben van beépített debugger, ami jóval kevesebbet tud. A DirectX jó szokásához híven szarul van dokumentálva (a reference azért jó), az SDK-s példaprogikhoz pedig sok szerencsét... apropó, van shadow volume-os sample. 30 ezer sor.

Aminek a nagy része a totál fölösleges DXUT illetve egyéb segédhülyeségek (merthogy X fájl nincs többé). A lényeget a shaderben implementálták le, úgyhogy ha azt szúrós szemmel nézed, akkor látható, hogy rólam kopizták (biztos előrementek az időben). Külön DX10 tutoriál nem lesz, úgyhogy itt írom le, hogyan kell előkészíteni egy mesh-t a geometry shadernek:

CODE
caster->GenerateGSAdjacency(); caster->Discard(D3DX10_MESH_DISCARD_DEVICE_BUFFERS); caster->CommitToDevice();

Érdemes figyelni az output ablakot, mert kiírja, ha valami baja van. A Discard() különösen fontos (magától nem csinálná ám meg, neeeeeem...). Na de, ha ezen túltette magát mindenki, akkor jöhet a geometry shader.

Kettő Három fontos dolog van, amit figyelembe kell venni:
  • a geometry shader triangle strip-et köp ki magából
  • ha bruteforce módon rohansz neki, akkor minden élet kétszer fogsz kihúzni
  • van two-sided stencil (spec DX9-ben is, milyen jó höfö téma)
Az első nem igazi probléma, hiszen bármikor újra lehet indítani a strip-et a RestartStrip() metódussal (ezzel szimulálható a lista). A második pedig nagyon könnyen elkerülhető, ugyanis a kőbe vésett feltétel miatt, biztosak lehetünk benne, hogy jönni fog egyszer egy olyan triangleadj amiben a domináns háromszög a fény felé néz, egy másik nem, illetve ennek az esetnek a fordítottja. Tehát eleve csak olyan triangleadj-ok érdekelnek, amikben a domináns háromszög ilyen.

CODE
[maxvertexcount(18)] void gs_extrude( in triangleadj GS_Input verts[6], in out TriangleStream<GS_Output> stream) { float4 planeeq; float3 a = verts[2].origpos - verts[0].origpos; float3 b = verts[4].origpos - verts[0].origpos; float dist; // távolság a fénytől planeeq.xyz = cross(a, b); planeeq.w = -dot(planeeq.xyz, verts[0].origpos); dist = dot(planeeq, lightPos); if( dist > 0 ) { // palást ExtrudeIfSilhouette(verts[0].origpos, verts[1].origpos, verts[2].origpos, stream); ExtrudeIfSilhouette(verts[2].origpos, verts[3].origpos, verts[4].origpos, stream); ExtrudeIfSilhouette(verts[4].origpos, verts[5].origpos, verts[0].origpos, stream); // ... } }

A kinyújtás elég trivi, ha figyeltél fentebb, akkor könnyen ki lehet találni (a másik háromszög a fénytől elfelé kell nézzen). Egy kinyújtott él két darab háromszög (azaz 4 vertex, triangle strip).

Hátravan még a két sapka. Mivel lusta vagyok, ezt is ugyanebben a shaderben fogom elintézni (ne szivassuk a pipeline-t, szivat ő minket eleget). Meglepően egyszerű, a // ... helyére kell tenni. Kitolod a domináns háromszöget (front cap), majd kinyújtod a végtelenbe és a bejárását megfordítod (back cap). A homogén w koordinátának 1e-5f-et adtam meg, nézzük mi fog történni:

depth = (z - near * 1e-5f) / z

Ugye normál esetben (w = 0) az érték pont 1 lenne, namost ez nekem zfightolt (bármennyire is nem kéne neki). A módosítással legalábbis kisebb lesz 1-nél, de vigyázz, mert az xy is kisebb lesz!!! Mivel a pici érték miatt nem vehető észre, ezért nem foglalkoztam vele.

Ami viszont megér egy misét az a DX10 eszméletlenül idióta szemlélete. Oké, nem kötelező használni (arra lett tervezve, hogy shaderből állítsál be minden renderstatet), de ennél idiótábban nem is csinálhatták volna meg.

CODE
D3D10_RASTERIZER_DESC rasterdesc; ID3D10RasterizerState* nocull = 0; rasterdesc.AntialiasedLineEnable = FALSE; // mit érdekel engem??? rasterdesc.DepthBias = 0; // mit érdekel engem??? rasterdesc.DepthBiasClamp = 0; // mit érdekel engem??? rasterdesc.DepthClipEnable = TRUE; // mit érdekel engem??? rasterdesc.FillMode = D3D10_FILL_SOLID; // mit érdekel engem??? rasterdesc.FrontCounterClockwise = FALSE; // mit érdekel engem??? rasterdesc.MultisampleEnable = FALSE; // mit érdekel engem??? rasterdesc.ScissorEnable = FALSE; // mit érdekel engem??? rasterdesc.CullMode = D3D10_CULL_NONE; // ezt akartam beállítani device->CreateRasterizerState(&rasterdesc, &nocull); device->RSSetState(nocull); // és akkor most visszaállítani...

Emlékeztetőül DX9 és OpenGL:

CODE
device->SetRenderState(D3DRS_CULLMODE, D3DCULL_NONE); // dx9 glCullFace(GL_NONE); // opengl

Véleményemet egyetlen tömör és frappáns mondatban fogalmaznám meg a Microsoft felé:

anyátok.

De visszatérve a shadow volume-hoz: itt bukott ki, hogy a stencil operáció wrap kell legyen (thx syam kollégának). Hát persze, hiszen nem tudod, hogy two-sided stencil esetében először csökkent-e vagy növel. A Visual Studio hiányossága, hogy a stencil buffert nem tudja megmutatni (hacsak ki nem hackeled valahogy az alfából). Az nVidia NSight pedig ilyen üzenetet dob fel, hogy hát sajnos DX10-el nem működik. Megérte regisztrálni...nekik is címezném a fenti frappáns mondatot.


Summarum

Ez a cikk nem fölöslegesssss!!!! Ezt az oldalt meg nem akarom efelejteni (ikon konverter, nagyon jó minőség).

pic1


Höfö:
  • Mész és lekódolod, úgyis van forward renderered, amibe pont jó lesz.
  • Terjeszd ki irányított fényre!
  • A DX9-es kódot módosítsd úgy, hogy jobban hasonlítson a geometry shaderes megoldásra!

back to homepage

Valid HTML 4.01 Transitional Valid CSS!