Kurz DirectX (48.)

V dnešní lekci implementujeme podporu pixel shaderů do našeho projektu a vytvoříme nový vertex a pixel shader pro vodu. Aby toho nebylo málo, napíšeme i jednoduchý pixel shader pro terén, který v příštích lekcích budeme dále vylepšovat. Uvidíte, že voda s pixel shaderem vypadá mnohem lépe.

48.1. Podpora pixel shaderu

Tuto část lekce projdeme rychle, protože jsme podobný kód psali při implementaci vertex shaderů. Direct3D se totiž na vertex a pixel shadery dívá téměř shodně. Jediný rozdíl je v použitém rozhraní. Zatímco u vertex shaderu jsme použili IDirect3DVertexShader9, u pixel shaderu nečekaně použijeme IDirect3DPixelShader9. Vložíme tedy novou třídu XPixelShader a příslušné rozhraní IPixelShader, které bude mít stejné metody jako IVertexShader s tím rozdílem, že slovíčko "vertex" nahradíme slovem "pixel". Kromě toho tato třída podporuje tzv. texture shadery, což není nic jiného než pixel shader přesměrovaný do textury. Vzhledem k tomu, že tuto funkcionalitu budeme používat někdy v budoucnu, nebudu se tím nyní zabývat.

Dále upravíme třídy XDisplay, kde přidáme metodu:

virtual BOOL CheckPixelShader(DWORD dwPSVersion);

a proměnnou:

DWORD m_dwPSVersion;

Opět zcela analogicky k vertex shaderu. Rozdíl bude pouze v atributu struktury D3DCAPS9, kde v případě verze pixel shaderu použijeme atribut PixelShaderVersion.

Aby se voda z dnešní lekce zobrazila správně, budete potřebovat grafickou kartu podporující Pixel shader 2.0. Požadavky na vertex shader nejsou tak vysoké (stačil by vertex shader 1.1), ale nemyslím, že existuje karta, která podporuje PS 2.0 a ne VS 2.0. Jak už jsem zmínil minule, pixel shader nelze emulovat softwarově (tedy v případě HAL zařízení).

48.2. Texturovací instrukce pixel shaderu

Minule jsem slíbil, že se také podíváme na instrukční sadu pro pixel shader. Ta se ve verzi 2.0 příliš neliší od instrukcí vertex shaderu, ale přece jen obsahuje několik instrukcí navíc. Jedná se zejména o texturovací instrukce, kterými vzorkujeme texely.

Texturovací instrukce verze 2.0
Název
Popis
Ekvivalent v HLSL
texld
Vzorkuje texturu.
tex1D(), tex2D(), tex3D(),...
texldb
Vzorkuje texturu s mipmap biasem. Můžeme tak ovlivnit, které úrovně mipmapy se budou vzorkovat. Jako bias se použije 4. souřadnice (a nebo w).
tex2Dlod(), tex2Dbias(),...
texldp
Texturové souřadnice se před vzorkováním vydělí 4. složkou (a nebo w) tj. provede se projekce souřadnic.
tex2Dproj(), tex3Dproj(),...
texkill
Pokud je jedna z texturových koordinát záporná, daný pixel se vůbec nevykreslí.
clip()

Vzhledem k tomu, že my dnes již budeme používat pouze jazyk HLSL, nebudeme se instrukcemi podrobněji zabývat.

48.3. Nový materiál pro vodní hladinu

Jak už jsem zmínil v úvodu, hlavním tématem dnešní lekce bude vodní hladina. Dnes uděláme celkem realistickou vodu a někdy v budoucnu přidáme i reflexe okolí. Zřídka může pracovat pixel shader samostatně, většinou je to tedy ve spojení s vertex shaderem, který připravuje data a posílá je k dalšímu zpracovaní do pixel shaderu. Brzy uvidíte, že to nemusí být zdaleka jen barva vertexu nebo texturové souřadnice. Geometrie vodní hladiny se nezmění, jediný rozdíl bude v použití jiného typu vertexu.

Bump mapping

Jeden z efektů, který použijeme při modelování hladiny je tzv. bump-mapping. Tato technika je v dnešní době již naprosto běžná, dokonce má přímou podporu v Direct3D. My ovšem použijeme pixel shader (samozřejmě ve spojení s vertex shaderem). Bump-mapping vytváří iluzi nerovností na povrchu, i když geometrie povrchu je zcela jednoduchá. Jsou to takové hrátky se světlem, protože ve skutečnosti záleží jak příslušnou plochu vystínujeme. Tohle je trend dneška, nové materiály, které vytváří iluzi nerovného povrchu, se nanáší na relativně jednoduché modely. Výsledný objekt pak vypadá, jako kdyby byl složen z miliónu trojúhelníků.
Princip této techniku je vlastně velice jednoduchý. Stejně jako obarvujeme vertexy podle příslušné normály, budeme nyní obarvovat pixely podle normály roviny a podle normálového vektoru načteného z tzv. bump-mapy, což je speciální textura, která obsahuje normálové vektory pro každý pixel.

Do teď jsme používali strukturu VERTEX. Nyní vytvoříme strukturu novou a přidáme nový vektor v podobě druhé sady texturových souřadnic. Bude se jednat o tzv. tangent vektor. Tangent vektor není nic jiného než vektor tečny směřující ve směru jedné texturové souřadnice. K čemu takový vektor budeme potřebovat? Musíme si uvědomit, že vektor načtený z bump-mapy je definovaný v souřadnicích roviny (v tzv. tangent prostoru), zatímco vektor světla je ve světových souřadnicích. Možná je předchozí odstavec trochu neprůhledný. Podívejme se tedy na obrázek:

Zde vystupuje další vektor tzv. binormála (ve skutečnosti bitangenta). Binormála je kolmá na normálu a tangentu a tyto tři vektory tvoří nový prostor (tedy nové souřadnice) roviny. Abychom mohli počítat skalární součin vektoru světla a normály, musíme vektor světla transformovat do stejného prostoru jako je normála (tato normála je v prostoru roviny). Právě o tuto transformaci se postará vertex shader. Pixel shader tedy dostane transformovaný vektor světla, načte normálu z bumpmapy a podle toho spočítá barvu výsledného pixelu. Ve skutečnosti to bude ještě o něco složitější, protože přidáme i odrazy světla.

Vertex shader se kromě výše uvedené transformace bude starat ještě o vlnění na úrovni vertexů a posun texturových souřadnic. Vlnění bude generováno funkcí sinus, musíme tedy spočítat fázi, frekvenci a amplitudu, dosadit do rovnice vlny a vypočítat změnu polohy vertexu. Podívejme se postupně na HLSL kód:

float4 fPhase = Input.vPos.x * 4.5f + Input.vPos.y * 3.5f;

Fáze pro daný vertex vychází z polohy vertexu. Tím docílíme toho, že každý vertex bude v jiné fázi než jeho soused. Konstanty 4.5f a 3.5f jsou určeny experimentálně tak, aby vlnění bylo co nejvíc nepravidelné.

float4 fArgument = g_fTime * g_vecWaveSpeed + fPhase + g_vecWaveOffset;

Proměnná fArgument představuje parametr funkce sinus, kde vystupuje frekvence vlnění (g_vecWaveSpeed), čas (g_fTime), který přichází z venkovního prostředí a posun fáze složený z hodnot fPhase a g_vecWaveOffset. Proměnná g_vecWaveOffset je opět konstantní vektor.

float4 vecWave = g_vecWaveHeight * sin(fArgument);
float4 realPos = vecWave + Input.vPos;

Nakonec tu máme rovnici vlny a úpravu polohy vertexu. Amplitudu vlny určuje proměnná g_vecWaveHeight. Uvědomte si, že všechny parametry vlny jsou vektory, čili vertex se ve skutečnosti pohybuje i ve směru x a y, i když velice málo.

Dalším úkolem pro vertex shader je změna texturových souřadnic. Změnou souřadnic docílíme postupný posun textury po hladině. Do pixel shaderu navíc posíláme jednu sadu souřadnic navíc. Ve výsledku budeme vybírat texturu dvakrát, pokaždé s jinými souřadnicemi.

Output.TexCoord0 = Input.vTexCoord0 + frac(g_fTime * g_vecBumpSpeed.xy);

V proměnné g_vecBumpSpeed je uložena rychlost pohybu. K původním souřadnicím přičítáme pouze desetinnou část pomocí funkce frac(). Přírůstek souřadnic se tedy pohybuje v rozmezí 0-1. Druhou sadu nastavíme následovně:

vecNewTexCoord = Input.vTexCoord0 + frac(g_fTime * g_vecBumpSpeed.zw);
Output.TexCoord1 = vecNewTexCoord.yx;

Nyní použijeme z a w složky rychlosti g_vecBumpSpeed a navíc prohodíme souřadnice u a v.

Na závěr vertex shaderu provedeme výše popsanou transformaci vektoru světla a ještě vektoru kamery, který použijeme na světelné odrazy na hladině. Vytvoříme tedy transformační matici:

tangentSpaceMatrix[0] = normalize(Input.vTangent); // tangenta
tangentSpaceMatrix[1] = normalize(cross(Input.vNormal, Input.vTangent));
tangentSpaceMatrix[2] = normalize(Input.vNormal); // normala

Kde první řádek představuje tangentu, druhý binormálu a třetí řádek je normálový vektor. Povšimněte si použití funkce cross() na vektorový součin vektorů. Vše je třeba důsledně normalizovat.

A nyní provedeme samotnou transformaci:

Output.Light = mul(tangentSpaceMatrix, normalize(-g_vecLight));
Output.Camera = mul(tangentSpaceMatrix, normalize(-g_vecCamera));

Do pixel shaderu tedy posíláme barvu vertexu, dvě sady texturových souřadnic, vektor světla a vektor kamery. Oba vektory jsou transformovány do souřadnic roviny (tzv. tangent space). Když se nad výše uvedenou transformační maticí zamyslíme trochu déle, zjistíme, že se v případě vody jedná o jednotkovou matici! Pamatujete si, jak jsme nastavili normálový vektor u vodních vertexů? Tangentu nastavíme na (1, 0, 0) a pak přirozeně bude binormála (0, 1, 0). Je to tím, že vodní plocha je vlastně správně natočená vůči vektorům bumpmapy. Další práci již obstará pixel shader.

Pixel shader
To co jsem vyjmenoval jako výstup vertex shaderu vstupuje do pixel shaderu. Ale pozor, většina hodnot je interpolována z okolních vertexů. Jen konstantní hodnoty jako jsou dva transformované vektory vstupují v nezměněné podobě. A tyto hodnoty jsou konstantní pouze z toho důvodu, že daná transformační matice je pro všechny vertexy stejná.

Nejprve dvakrát navzorkujeme bumpmapu:

float4 bumpCol = (2*(tex2D(BumpSampler1, Input.TexCoord0)-0.5f) +
                  2*(tex2D(BumpSampler1, Input.TexCoord1)-0.5f));
float4 bumpVector = normalize(bumpCol);

A zároveň provedeme malou transformaci načtených hodnot. V textuře jsou totiž uložena barevná data 0-1 pro každý kanál. Ale protože my barvu interpretujeme jako vektor, musíme připustit i záporné hodnoty. To provedeme tak, že od načtené hodnoty odečteme 0.5 a výsledek vynásobíme dvěma. Pak bude rozsah 0-1 transformovaný na -1 až 1. Oba vektory sečteme a normalizujeme.

Dále určíme difusní složku výsledné barvy. To provedeme úplně stejně jako ve vertex shaderu, ale zde to počítáme pro každý pixel:

float colDiff = dot(bumpVector.xyz, Input.Light);

Dále určíme vektor odrazu:

float3 reflection = normalize(2 * bumpVector.xyz - Input.Light);
float4 colSpecular = pow(dot(reflection, Input.Camera), fPower);

A vypočteme odražené světlo. Zde už je vidět závislost na vektoru kamery. Odraz je největší, pokud je kamera přímo proti vektoru odrazu. Mocninou zde regulujeme míru odrazu. Proměnná fPower je nastavena jako konstanta a čím vyšší je, tím více se výsledná spekulární složka blíží k nule.

Na závěr programu složíme barvu a nastavíme alphu:

Output.Color0.rgb = Input.Diffuse * colDiff + colSpecular;
Output.Color0.a = 0.2; // alpha je nastavena na pevno

Výsledná barva se skládá z diffusní barvy vertexu, z diffusní barvy bumpmapy a z odrazové složky. A voda je na světě. Když se podíváte na přeložené shadery, zjistíte, že se již nejedná o jednoduché výpočty. Vertex shader má 48 instrukcí! Všimněte si, že nám stačí pouze jedna statická textura, takže ušetříme spoustu grafické paměti a výsledek je mnohem realističtější.

48.4. Pixel shader pro terén

V této části vytvoříme druhý pixel shader, který použijeme při kreslení terénu. Jedná se o úplně jednoduchý pixel shader, který jen vybírá barvu pixelu z 1D textury podle nadmořské výšky pixelu. 1D textura se ve skutečnosti většinou používá na obarvování. Nejprve upravíme vertex shader, ze kterého budeme posílat kromě barvy také netransformovanou výšku vertexu:

Output.Elevation = realPos.z/g_fMaxElevation;

Konstanta g_fMaxElevation je nastavena z vnějšího prostředí a jedná se vlastně o výšku nejvyššího kopce. Takže uvedenou rovnicí transformujeme výšku vertexu do prostoru 0-1. Tu pak následně použijeme v pixel shaderu jako texturovou souřadnici do 1D textury.

A pixel shader bude velice jednoduchý. Bude pouze kombinovat barvu vertexů s barvou vytaženou z textury trávy a z textury barvy:

float colorTex = Input.Elevation;
// Barva pixelu podle vysky pixelu nad morem
float4 color = tex1D(ColorSampler1, colorTex);

// Vysledna barva je spojeni textury travy, barvy pixelu podle elevace a
// stinovani z vertex shaderu

Output.Color0 = tex2D(GrassSampler0, Input.TextureUV) * color *
                Input.Diffuse.g;

Možná se ptáte, proč používáme pouze zelenou složku difusní barvy vertexu. Tuto složku zahrnujeme hlavně kvůli osvětlení. Kdybychom ji nezahrnuli, zcela bychom ignorovali stínování, které se počítalo ve vertex shaderu. Protože je barva vertexů spíše zelená, použili jsme zelenou složky, které nejlépe vystihuje stínování.

48.5. Úpravy v projektu

V první části jsme upravovali projekt Display. Nyní se ještě ve stručnosti podíváme na úpravy v projektu Engine, hlavně ve třídě XTerrain. Zde přidáme nový vertex shader pro vodu, dva pixel shadery (pro vodu a pro terén). Zcela odstraníme animovanou texturu vody, ale přidáme dvě statické textury pro vodu a na obarvení terénu.

V metodě InternalInit() musíme zkontrolovat dostupnost pixel shaderů 2.0 a podle toho vytvořit shadery. Pokud příslušný shader není dostupný, nahraje se statická textura vodní hladiny.

Dále upravíme metodu Render(), kde nastavujeme příslušné shadery pro terén a vodu. Nesmíme zapomenout nastavit všechny konstanty. Důležité je volání metody SetDefaults(), která nastaví implicitní hodnoty konstant v shaderech. Oba vertex shadery potřebují transformační matici a vektor světla. Pixel shaderům stačí pouze nastavené příslušné textury a pochopitelně data z vertex shaderu. Uvedená 1D textura vypadá následovně:

A bumpmapa na vodní hladinu takto:

Každý pixel uchovává normálový vektor. Alpha kanál se v našem případě nepoužije, ale můžeme ho využít na tzv. gloss mapu, která určuje odrazivost v daném pixelu.

Kompletní příklad si můžete stáhnout v sekci Downloads. Podívejme se ještě na malý screenshot:

Domácí úkol: Ačkoliv výsledek uvedeného shaderu na vodu vypadá docela pěkně, je v něm zásadní chyba. Můžete se nad tím do příští lekce zamyslet, případně mi poslat opravu tohoto záludného problému. V příští lekci budeme tento shader dále upravovat a chybu pochopitelně odstraníme.

48.6. Závěr

V příští lekci dále upravíme pixel shader pro vodu. Přidáme reflexe okolí a také se zamyslíme, jak bychom mohli vylepšit shader pro terén. Představte si, že nyní budeme schopni míchat více textur podle výšky a normálového vektoru pixelu. Tím docílíme krásného per-pixel texturování. Tohle všechno jsou efekty, které bychom bez shaderů těžko vytvořili. Také si ukážeme, k čemu jsou v Direct3D efekty a jak nám zjednoduší práci se shadery.

Těším se příště nashledanou.

Jiří Formánek