37. fejezet - Cel shading


Először is egy kis fejtágítás: sokan helytelenül "cell shading"-nek nevezik a módszert valamiféle cellákra vagy sejtekre gondolva. Semmi köze a cellákhoz, az elnevezés a celluloid nevű átlátszó papírlap rövid elnevezése. Például a klasszikus Disney filmek is ezzel készültek: a celpapírra rajzolták a karaktereket, amögé pedig berakták a statikus hátteret. Ebben a cikkben azt fogom megmutatni, hogy hogyan lehet shaderekkel ilyen kézzel rajzolt hatást elérni.

Mert a textúra az lehet egy dimenziós is

Az ötlet annyira egyszerű, hogy szinte már triviális. Az első shaderes cikkben megtárgyaltam hogyan kell kiszámolni a szórt fényt. Az is feltűnhetett, hogy az egy folytonos érték, tehát az objektum szép folytonosan megy át világosból sötétbe. A cél az, hogy ne legyen folytonos, hanem ilyen "lépcsős függvény" legyen. Egy buta programozó ezt megoldaná egy rakat if-el, de mi shadert programozunk, ezért okosabbak vagyunk. A lépcsős függvényt tegyük egy 1D-s textúrába.

Lepcsos fv

Általában 3-4 intenzitási szint elég, ennél több már furán néz ki. Ezt a textúrát a shaderben címezzük meg a szórt fénnyel, így folytonos átmenet helyett kicsit durvább csíkokat kapva. A shadert most még nem irom le, mert ennél többet fogok csinálni.


Élj az éldetektálásért!

Egy rajzfilm attól lesz rajzfilm, hogy az objektumoknak van egy jól látható fekete vagy fehér kontúrja. Nem kisebb probléma ilyet előállitani, mint egy képen megkeresni az éleket. Szerencsére ezzel egy csomó ember foglalkozik és nagyon jó kis algoritmusokat találtak ki. Én most a Sobel-féle éldetektálást fogom majd implementálni. Ehhez először is tudni kell hogy mit jelent a konvolúció fogalma. Legyen f,g: Z → R két diszkrét pontokon (most az egész számok halmazán) értelmezett függvény, ekkor

convol

a két függvény diszkrét konvolúciója. Szemléletesen ez azt jelenti, hogy az egyik függvényt eltoljuk, a másikkal összeszorozzuk, majd a szorzatokat összeadjuk. Ha valaki implementált már valaha gaussian blur-t, akkor annak ismerős lehet. Ott is pontosan ez történik. Folytonos függvények (f,g ∈ C(R → R)) esetében a szumma egy integrálra cserélődik le.

A képet egyelőre konvertáljuk át fekete-fehérbe, ezzel csak az intenzitását megtartva. Ezek után ezt a szürkésített képet fogjuk fel úgy, mint egy diszkrét függvény. A konvolúció mindig két függvény közötti művelet, kell tehát egy másik függvény is, ez lesz a Sobel operátor. Ez két darab mátrixxal reprezentálható, de valójában úgy kell értelmezni, mint két darab R2-böl R-be képező függvényt, ami a kétdimenziós origó körül ez, máshol meg nulla.

sobel

Ez a két ún. kernel azért jó, mert ezekkel alkalmazva a konvolúciót a képre megkapjuk a kép mint intenzitásfüggvény gradiensének két komponensét (pongyolán mondva az intenzitás megváltozását). Ha most kedvenc kolléganőnk egyik fotójára alkalmazzuk ezt az operátort, akkor már majdnem látszik is, hogy mi lesz a buli vége:

gradient_mag

A jobb oldali képen Jessica teste és a gradiensek hossza látható. Nem egy bonyolult ötlet, hogy tekintsünk egy pixelt élnek akkor, ha a gradiens hosszabb egy küszöbnél. Az eredeti algoritmus szerint a gradiensek hosszára még alkalmazni kell a non-maxima suppression-t, ami kiszűri az olyan értékeket amik nem lokális maximumok, igy többnyire csak az éleket hagyva vissza.


Dehát séderben meg dájrektekszben ezt hogyan?

A fenti módszer kész képekhez van. De 3D grafikában ennél több információ is rendelkezésre áll, például az objektumok normálvektorai illetve mélysége. Nem lehetne-e ezt a plusz információt kihasználni egy jobb éldetektáláshoz? De ki lehet, éppen ezért egy olyan technikához fogok nyúlni amit már majdnem támogatnak is a kártyák.

A legelső shaderes cikkben azt mondtam, hogy nem csak COLOR0-t, hanem COLOR1, COLOR2, ... -t is ki lehet adni a pixel shaderben. Ezt a feature-t multiple rendertarget support-nak hívják és pont azt jelenti amit akar: egyszerre több targetbe rajzolsz, így a geometriát elég egyszer leküldeni.

Nézzük meg elöször is mi kell: a normálvektoroknak kell 3 darab float, a mélységnek 1. Tehát egy A8R8G8B8 textúrával meg is lehet úszni a dolgot. Megjegyezném, hogy a mélységnek általában nem elég 8 bit, most viszont linearizálva fogom kiírni így valamennyit javítva a dolgon. A rendertargeteket az alábbi módon szokás létrehozni:

CODE
LPDIRECT3DTEXTURE9 colortarget = NULL; LPDIRECT3DTEXTURE9 normaltarget = NULL; // optimalizálási célból LPDIRECT3DSURFACE9 colorsurface = NULL; LPDIRECT3DSURFACE9 normalsurface = NULL; // InitScene()-be: hr = device->CreateTexture( screenwidth, screenheight, 1, D3DUSAGE_RENDERTARGET, D3DFMT_A8R8G8B8, D3DPOOL_DEFAULT, &colortarget, NULL); hr = device->CreateTexture( screenwidth, screenheight, 1, D3DUSAGE_RENDERTARGET, D3DFMT_A8R8G8B8, D3DPOOL_DEFAULT, &normaltarget, NULL); colortarget->GetSurfaceLevel(0, &colorsurface); normaltarget->GetSurfaceLevel(0, &normalsurface);

A következő kódrészlet azt mutatja be, hogy hogyan kell ellenőrizni, hogy van-e MRT support. Ha nincsen akkor nyilván nem lehet megúszni a többszöri rajzolást, így érdemes erre is felkészülni (tipikusan a régi alaplapi kártyák nem tudnak ilyet). Én most egyszerűen leállok egy hibaüzenettel.

CODE
D3DCAPS9 caps; device->GetDeviceCaps(&caps); if( caps.NumSimultaneousRTs < 2 ) { MYERROR("Device does not support enough render targets"); return E_FAIL; }

Hasonló módon lehet ellenőrizni azt is, hogy mekkora shader model-t támogat a kártya, illetve egy csomó mást is. Ezt az objektumot el lehet kérni a direct3d objektumtól is még device létrehozás előtt, így korábban kideritve a hibát.

Megjegyzés: van néhány dolog amit MRT használatakor be kell tartani:
  • az együtt használt rendertargetek bitmélységének meg kell egyezni, hacsak a
    (caps.PrimitiveMiscCaps & D3DPMISCCAPS_MRTINDEPENDENTBITDEPTHS) igaz
  • mindegyik rendertarget méretének (szélesség/magasság) meg kell egyezni
  • nem minden kártya tud rendertargeteken utómunkákat végezni
    (caps.PrimitiveMiscCaps & D3DPMISCCAPS_MRTPOSTPIXELSHADERBLENDING)
  • nincs élsimitás
  • a depth-stencil surface méretének ≥-nek kell lennie a rendertarget méreténél
  • néhány driver elvárja, hogy ha be van állítva egy rendertarget akkor azt minden pixel shaderben írjad
Bővebben itt.

Rajzoláskor a SetRenderTarget() metódussal lehet beállitani az egyes textúrákat, ehhez kellenek a fenti surface pointerek. Nyilván az előző targetet le kell menteni, de ezt elég csak a nulladikra megtenni. A végső rajzolásnál ezt vissza kell állitani és felszabaditani (refcount!!!).

CODE
LPDIRECT3DSURFACE9 backbuffer = NULL; device->GetRenderTarget(0, &backbuffer); device->SetRenderTarget(0, colorsurface); device->SetRenderTarget(1, normalsurface); // ... device->SetRenderTarget(0, backbuffer); device->SetRenderTarget(1, NULL); backbuffer->Release();

Rögtön fel is hivnám a figyelmet két fontos dologra. Az egyik, hogy olyan rendertargetbe nem lehet renderelni ami be van állítva textúraként! Lehet, hogy némelyik driver megengedi, de általában érdekes hibákat lehet tapasztalni. Használd a DX debug runtime-ját és akkor a Visual Studio debug ablakába a runtime kiírja ha ilyet művelnél. Hogy lehet ezt megoldani: az ilyen textúra slotokat használat után nullázd ki (hacsak nem garantált, hogy másik textúra fog belekerülni még a rendertarget-be rajzolás előtt).

A másik fontos dolog, hogy ha egy rendertarget slotot nem használsz már, akkor állitsd NULL-ra különben szintén érdekes hibákat tapasztalhatsz.


Celséder render rendel

C++ oldalról minden készen áll, irjuk most meg először a cikk elején emlitett lépcsős árnyalást. A későbbi éldetektáláshoz kelleni fog a normálvektor, illetve a depth linearizálásához a két vágósík helyzete. Az 1-es számú textúra slotba tegyük be az 1D-s intenzitás képet; fontos, hogy a szűrését POINT-ra állitsd!

CODE
matrix matWorld; matrix matWorldInv; matrix matView; matrix matProj; float4 lightPos = { -10, 10, -10, 1 }; float2 clipPlanes = { 0.1f, 3.0f }; void vs_main( in out float4 pos : POSITION, in float3 norm : NORMAL, in out float2 tex : TEXCOORD0, out float4 normd : TEXCOORD1, out float3 ldir : TEXCOORD2) { pos = mul(pos, matWorld); normd.xyz = mul(matWorldInv, norm); ldir = lightPos.xyz - pos.xyz; pos = mul(pos, matView); normd.w = (pos.z - clipPlanes.x) / (clipPlanes.y - clipPlanes.x); pos = mul(pos, matProj); }
CODE
sampler mytex1 : register(s1) = sampler_state { MinFilter = point; MagFilter = point; }; void ps_main( in float2 tex : TEXCOORD0, in float4 normd : TEXCOORD1, in float3 ldir : TEXCOORD2, out float4 color0 : COLOR0, out float4 color1 : COLOR1) { float3 l = normalize(ldir); float3 n = normalize(normd.xyz); float diffuse = saturate(dot(n, l)); diffuse = tex1D(mytex1, diffuse); // szín color0 = tex2D(mytex0, tex) * diffuse; color0.a = 1; // normál + mélység color1 = float4(n * 0.5f + 0.5f, normd.w); }

Nem kell nagyon magyarázni a shadert. A depth-et egy lineáris interpolációval kaptam meg, a normált meg áttranszformáltam textúra térbe ([0, 1]2 tartomány).


Aliendetektálás

Amink van most az két textúra: a kép, és a normálok + mélységek. Ezekből kéne kikeverni egy olyan képet, amin az élek szép feketék. Ezt postprocessing-nek hívják: a képre utólag rajzolsz rá valami effektet. A legelterjedtebb megoldás, hogy kirajzolod a képet egy screen aligned quad-ra a pixel shaderrel, ami elvégzi az utómunkát. Ezt egyszerűen meg lehet csinálni úgy, hogy rögtön a képernyőn adod meg a vertexeket (tehát vertex shader nem kell). Valószinűleg mindenki ki tud rajzolni két darab háromszöget, DE. Ha shadereket használsz, akkor nem elég az hogy az FVF-et beállitod (sőt nem is jó); csinálni kell egy vertex declarationt. Hasonlóan működik a dolog, mint az előző cikkben:

CODE
LPDIRECT3DVERTEXDECLARATION9 vertexdecl = NULL; D3DVERTEXELEMENT9 elem[] = { { 0, 0, D3DDECLTYPE_FLOAT4, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITIONT, 0 }, { 0, 16, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 0 }, D3DDECL_END() }; device->CreateVertexDeclaration(elem, &vertexdecl);

A quad kirajzolása előtt ezt beállitod az eszközre. Az éldetektáló shader ezek után a fent leírt Sobel operátor alkalmazása a normálokra és a mélységekre. A kettő eredményének súlyozott átlagából dönthető aztán el, hogy mit tekintesz élnek. Ezzel érdemes lehet játszani kicsit.

CODE
float2 texelSize; static const float2 offsets[8] = { { -1, -1 }, { 0, -1 }, { 1, -1 }, { -1, 1 }, { 0, 1 }, { 1, 1 }, { -1, 0 }, { 1, 0 } }; void ps_edgedetect( in float2 tex : TEXCOORD0, out float4 color : COLOR0) { float4 n1 = tex2D(mytex0, tex + offsets[0] * texelSize); float4 n2 = tex2D(mytex0, tex + offsets[1] * texelSize); float4 n3 = tex2D(mytex0, tex + offsets[2] * texelSize); float4 n4 = tex2D(mytex0, tex + offsets[3] * texelSize); float4 n5 = tex2D(mytex0, tex + offsets[4] * texelSize); float4 n6 = tex2D(mytex0, tex + offsets[5] * texelSize); float4 n7 = tex2D(mytex0, tex + offsets[6] * texelSize); float4 n8 = tex2D(mytex0, tex + offsets[7] * texelSize); n1.rgb = n1.rgb * 2 - 1; n2.rgb = n2.rgb * 2 - 1; n3.rgb = n3.rgb * 2 - 1; n4.rgb = n4.rgb * 2 - 1; n5.rgb = n5.rgb * 2 - 1; n6.rgb = n6.rgb * 2 - 1; n7.rgb = n7.rgb * 2 - 1; n8.rgb = n8.rgb * 2 - 1; float3 ngx = -n1.rgb - 2 * n2.rgb - n3.rgb + n4.rgb + 2 * n5.rgb + n6.rgb; float3 ngy = -n1.rgb - 2 * n7.rgb - n4.rgb + n3.rgb + 2 * n8.rgb + n6.rgb; float3 ng = sqrt(ngx * ngx + ngy * ngy); ngx.x = -n1.a - 2 * n2.a - n3.a + n4.a + 2 * n5.a + n6.a; ngx.y = -n1.a - 2 * n7.a - n4.a + n3.a + 2 * n8.a + n6.a; float dg = sqrt(ngx.x * ngx.x + ngx.y * ngx.y); ngx.x = saturate(dot(ng, 1) - 3.5f); ngx.y = saturate(ngx.x + (dg * 10 - 0.4f)); color = ngx.y; }

Nem világos, hogy a normálok milyen metrika szerint játszanak szerepet az élekben, én a komponenseik összegét vettem. Valószinűleg ennél jobban is fel lehet használni...a közbezárt szög jó lehetne, de például két falnál nem találna meg semmit. Az eredmény mindenesetre elég jó.


Kontúrok stencil bufferrel

Ezt a módszert konkrétan használtam is a munkám során, úgyhogy leírom. Annyira nem "okos", mint az éldetektálás, sőt elég lassú és korlátozott is, de olyan kártyákon, ahol nincs mondjuk pixel shader (vagy valami okból az éldetektálás lassú), ott lehet alkalmazni.

Az alapötlet az, hogy a stencil bufferbe belerajzolod az objektumot 4-szer, mindig eltolva a viewportot valamelyik irányba. Így egy olyan hatás jön létre, mintha kicsit nagyobb méretben rajzoltad volna az objektumot, de megtartja az arányokat (míg a szimpla skálázás nem). Ötödik lépésben az eredeti viewporttal kivágod a közepét, így a stencil bufferben csak a kontúrok maradnak:

CODE
// ... device->SetRenderState(D3DRS_STENCILFUNC, D3DCMP_ALWAYS); device->SetRenderState(D3DRS_STENCILREF, 1); device->SetRenderState(D3DRS_STENCILPASS, D3DSTENCILOP_REPLACE); // ... float thickness = 3.5f; // render object 4 times with offseted frustum for( float i = -thickness; i < thickness + 1; i += 2 * thickness ) { for( float j = -thickness; j < thickness + 1; j += 2 * thickness ) { D3DXMatrixTranslation(&offproj, i / (float)screenwidth, j / (float)screenheight, 0); D3DXMatrixMultiply(&offproj, &proj, &offproj); device->SetTransform(D3DTS_PROJECTION, &offproj); mesh->DrawSubset(0); } } // erase area in the center device->SetRenderState(D3DRS_STENCILREF, 0); device->SetTransform(D3DTS_PROJECTION, &proj); mesh->DrawSubset(0);

OpenGL-ben a viewportot lehet negatívba is tologatni, DirectX-ben nem. Úgyhogy a projekciós mátrixot toltam el. Ezután már csak egy fekete screen aligned quad-ot kell kirajzolni D3DCMP_NOTEQUAL-al.

Némi hátránya azért láthatóan van; egyrészt csak az objektum körvonalait rajzolja, a belső éleket nem. Másrészt ha nem külön rendertargetbe tolod, akkor átlátszik mindenen (hiszen kontúrokra nem tudsz depth test-elni). Höfö megoldani ezeket a problémákat (vagy belátni, hogy nem lehet).


Summarum

Megmutattam a legegyszerűbb módszert rajzolt hatás elérésére. Ennél sokkal tágabb a téma, aminek a böcsületes neve non-photorealistic rendering. CAD-ban különösen szeretik használni, de játékokban is népszerű. Az egyik legjobb ilyen játék a XIII.

celshade

Egy megjegyzés: az XNA honalpján található egy implementáció ami a Roberts' cross filter-t alkalmazza és nem sokkal rosszabb mint ez. A cikk kódja letölthető itt.


Höfö:
  • Írd meg ugyanezt Prewitt-féle éldetektálással!
  • Implementáld az XNA-s megközelítést!
  • Javítsd fel a programot úgy, hogy MRT support nélkül is müködjön!
  • Csináld meg a stencil bufferrel is ugyanilyen szépre!

back to homepage

Valid HTML 4.01 Transitional Valid CSS!