1
DirectX ▪ Dot3
Witam w kolejnym tutorialu. Przechodząc od teorii do praktyki nowoczesnego mapowania wypukłości, które dzisiaj
całkowicie rewolucjonizuje grafikę 3D czasu rzeczywistego na prostym przykładzie pokażemy sobie jak zrealizować tę
technikę za pomocą pakietu Direct3D. W zasadzie wszystko wiemy z lekcji teoretycznej - co nam potrzeba, jak to policzyć i
jak zastosować. Pozostanie nam tylko omówienie odpowiednich kawałków kodu źródłowego. Żeby więc niepotrzebnie nie
komplikować i nie zanudzać na wstępie połączymy teorię z praktyką. Po pierwsze - jak już wiemy z lekcji teoretycznej o tej
technice będziemy dzisiaj zmuszeni zmienić naszą już dosyć starą i zardzewiałą strukturę wierzchołka. Na potrzeby
mapowania wypukłości niestety to co mieliśmy do tej pory to jest stanowczo za mało i trzeba co nieco dodać wierzchołkowi
aby coś jeszcze wycisnąć z naszego sprzętu. Wprawdzie dodawanie danych do wierzchołków nie jest mile widziane przez
nasze karty graficzne - bo one to tylko jedno by chciały - jak najmniej bajtów. No ale wysiłek włożony w przetworzenie tych
kilku dodatkowych bajtów na wierzchołek na pewno by nam wybaczyły, gdyby wiedziały ile danych musiałyby przetworzyć
aby otrzymać podobną geometrię za pomocą małych, ale jakże licznych wierzchołków ;). Przypatrzmy się więc, czym
obciążymy dzisiaj nasz wysłużony sprzęt:
struct SVertex
{
float x, y, z; // position
float nx, ny, nz; // normal
DWORD color; // diffuse color
float u1, v1; // firts texture channel
float u2, v2; // second texture channel
float ux, uy, uz; // U vector
float vx, vy, vz; // V vector
float uvx, uvy, uvz; // UxV vector
};
#define FVF_VERTEX ( D3DFVF_XYZ | D3DFVF_NORMAL | D3DFVF_DIFFUSE | D3DFVF_TEX5 | \
D3DFVF_TEXCOORDSIZE2( 0 ) | D3DFVF_TEXCOORDSIZE2( 1 ) | \
D3DFVF_TEXCOORDSIZE3( 2 ) | D3DFVF_TEXCOORDSIZE3( 3 ) | \
D3DFVF_TEXCOORDSIZE3( 4 ) )
Na początku oczywiście wiadomo - pozycja, normalna wierzchołka, kolor diffuse oraz dwa zestawy współrzędnych tekstur.
Ten zestaw w zasadzie doskonale znamy i omawiać go nie muszę, no może troszkę o teksturach tutaj. Pierwszy zestaw
posłuży do przechowania współrzędnych mapy normalnych (to już chyba wiemy czym jest), drugi natomiast będzie
przechowywał ewentualne tekstury właściwe obiektowi, jeśli będzie takowe posiadał. Dlaczego w takiej kolejności okaże się
już niezadługo. Nowością natomiast, zgodnie zresztą z teorią są trzy wektory złożone z trzech liczb float (wektor w
przestrzeni oczywiście). Te trzy wektory posłużą nam jak wiemy do przechowywania macierzy obrotu, która przekształci
wektor światła padający na obiekt do przestrzeni tekstury zawierającej normalne. Taka a nie inna konstrukcja wierzchołka
pozwoli nam na przekazanie tych macierzy dla każdego wierzchołka bezpośrednio do Vertex Shadera, co jest dla nas bardzo
ważne, bo jak wiemy dzięki Shaderowi nie będziemy musieli się dobierać do buforów wierzchołków tylko zrobi to za nas
urządzenie. Nieco dziwnie tym razem może wyglądać dla nas definicja typu wierzchołka, a szczególnie konstrukcje
D3DFVF_TEXCOORDSIZEX( Y )
. Tak naprawdę jednak jest to bardzo proste. Już nie jeden raz wspominaliśmy o tym jak
wspaniałe możliwości daje nam do ręki Direct3D w zakresie definiowania formatu wierzchołka. Nie dosyć, że możemy sobie
w miarę swobodnie wybierać co w tym wierzchołku ma się znaleźć to niektóre z tych składowych możemy jeszcze sobie
dodatkowo regulować. Szczególnie dotyczy się to współrzędnych mapowania tekstury. Jak wiemy standardowo zestaw
współrzędnych zawiera dwie dane dla tradycyjnych tekstur. Ponieważ jednak współrzędne te zostały niejako zaprojektowane
nie tylko do przechowywania danych tradycyjnych tekstur więc mamy tutaj pewne możliwości manewru. Otóż możemy
zrobić następującą rzecz - każdy zestaw współrzędnych tekstur może zawierać od jednej do czterech danych. Dla
tradycyjnych tekstur jak wiemy są to dwie U i V albo X i Y albo S i T - jak kto woli. Wiemy także, że w Direct3D możemy
mieć aż osiem zestawów współrzędnych mapowania - na każdy poziom tekstury po jednym. Popatrzmy teraz na naszą
strukturę wierzchołka. Widać, że na pewno będziemy używać dwóch poziomów tekstur - zerowego dla mapy normalnych i
pierwszego dla tekstury definiującej wygląd obiektu. Każdy z tych poziomów będzie zawierał po dwie dane. Ponieważ
dzisiaj namieszamy sporo w naszym formacie wierzchołków więc trzeba także trochę to uporządkować i powiedzieć
urządzeniu co i ile będzie na każdym poziomie tekstur. Aby tak zrobić użyjemy właśnie konstrukcji
D3DFVF_TEXCOORDSIZEX( Y )
, która mówi urządzeniu ile danych "X" będzie ważnych na poziomie "Y". Tak więc aby
urządzenie wiedziało, że na poziomie zero używamy dwóch danych (bo tyle potrzeba dla tekstury) przekażemy mu -
D3DFVF_TEXCOORDSIZE2( 0 )
, analogicznie w przypadku poziomu 1 -
D3DFVF_TEXCOORDSIZE2( 1 )
. Następnych
poziomów mapowania użyjemy natomiast do przechowywania macierzy - bo dla tekstur nie będą one już potrzebne w
naszym przypadku. Dla macierzy obrotu wystarczą nam trzy wektory definiujące obroty wokół poszczególnych osi - więc
ostatecznie trzy po trzy. Zabieramy więc naszemu urządzeniu poziomy 2, 3 i 4 i mówimy, że na każdym z nich są ważne trzy
dane:
D3DFVF_TEXCOORDSIZE3( 2 )
D3DFVF_TEXCOORDSIZE3( 3 )
D3DFVF_TEXCOORDSIZE3( 4 )
2
DirectX ▪ Dot3
Po zdefiniowaniu struktury wierzchołka następuje załadowanie wszystkich potrzebnych danych - a więc tekstur i modelu. Tej
procedury nie będę omawiał bo nie ma to najmniejszego sensu - wszyscy potrafimy to robić już przecież doskonale.
Następnie jak wiemy, po załadowaniu naszego modelu ze standardowego pliku *.x musimy dokonać klonowania obiektu
siatki, ponieważ używamy dosyć specyficznej struktury wierzchołka. Robimy to poniższym kawałkiem kodu:
// convert mesh to appropriate vertex format
hResult = g_lpMesh->CloneMeshFVF( 0, FVF_VERTEX, g_lpD3DDevice, lpCloneMesh );
if( D3D_OK != hResult )
{
return false;
}
g_lpMesh = lpCloneMesh;
Teraz nasz załadowany model ma na pewno taki format jak potrzeba. To klonowanie jednak pociągnęło za sobą kolejną
rzecz, o której trzeba pamiętać. Otóż model w pliku *.x na pewno nie zawierał wszystkich potrzebnych nam danych. W
naszym prostym przykładzie problem taki zaistnieje przy współrzędnych tekstury. Model załadowany z pliku *.x będzie
posiadał współrzędne mapowania, ale tylko dla poziomu zerowego, który u nas będzie odpowiedzialny za mapę normalnych.
Tak się dla nas jednak szczęśliwie składa, że zarówno mapa normalnych jak i mapa definiująca końcowy wygląd obiektu
(diffuse) będą nakładane w ten sam sposób, więc wystarczy skopiować dane współrzędnych mapowania z poziomu 0 na
poziom 1 i będzie w porządku. Ale jeśli będziecie kiedyś stosować różne mapowanie normalnych i właściwych tekstur
obiektu to musicie oczywiście rozwiązać to w jakiś inny sposób - będzie nim chyba tylko własne ładowanie modeli z
odpowiednim formatem. W naszym przykładzie sprawa na szczęście jest bardzo prosta. Aby skopiować dane mapowania
wystarczy więc zalockować bufor wierzchołków i zrobić co trzeba:
// lock mesh vertex buffer
hResult = lpVB->Lock( 0, 0, (void**) &pVertices, 0 );
if( D3D_OK != hResult )
{
return false;
}
// copy uv mapping to second layer
for( DWORD i = 0; i < dwVertNum; i++ )
{
pVertices[i].u2 = pVertices[i].u1;
pVertices[i].v2 = pVertices[i].v1;
pVertices[i].ux = pVertices[i].uy = pVertices[i].uz = 0.0f;
pVertices[i].vx = pVertices[i].vy = pVertices[i].vz = 0.0f;
pVertices[i].uvx = pVertices[i].uvx = pVertices[i].uvx = 0.0f;
}
Jak widać, przy okazji także zerujemy nasze wektory odpowiedzialne za obroty, które właśnie teraz, zgodnie z lekcją
teoretyczną wyliczymy. Jednak, jak wspomniałem w tejże lekcji obliczeń tych będzie dosyć sporo, dlatego też pozwoliłem
przygotować sobie mały aparat matematyczny do tego celu. Funkcje, które tutaj wykorzystam będą dla was dostępne w
postaci kodu, więc będzie sobie można je wykorzystać, skopiować czy zawzorować się (tak jak ja to zrobiłem), aby dla
własnych potrzeb to zaadoptować. My wiemy skąd się wezmą nasze dane, więc tylko pokażmy jak skorzystać z naszego
narzędzia:
// get data using appropriate tools
std::vector<SVertexFix> g_vertices;
std::vector<int> g_indices;
// store vertex data
for( int i = 0; i < dwVertNum; i++ )
{
SVertexFix vertex;
// copy texture coordinates
pVertices[i].u2 = pVertices[i].u1;
pVertices[i].v2 = pVertices[i].v1;
// prepare data for mesh correction
3
DirectX ▪ Dot3
vertex.vectPos = SVector3f( pVertices[i].x, pVertices[i].y, pVertices[i].z );
vertex.vectUV = SVector2f( pVertices[i].u1, pVertices[i].v1 );
g_vertices.push_back( vertex );
}
// store index data
for( i = 0; i < dwIndicesNum; i++ )
{
int nIndex;
switch( D3DIBDesc.Format )
{
case D3DFMT_INDEX16:
{
nIndex = ((WORD*) pIndices)[i];
g_indices.push_back( nIndex );
}
break;
case D3DFMT_INDEX32:
{
nIndex = ((DWORD*) pIndices)[i];
g_indices.push_back( nIndex );
}
break;
}
}
// make calculations
Dot3Mesh( g_vertices, g_indices );
// restore tangent space
for( i = 0; i < dwVertNum; i++ )
{
pVertices[i].ux = g_vertices[i].vectTangent.x;
pVertices[i].uy = g_vertices[i].vectTangent.y;
pVertices[i].uz = g_vertices[i].vectTangent.z;
pVertices[i].vx = g_vertices[i].vectBinormal.x;
pVertices[i].vy = g_vertices[i].vectBinormal.y;
pVertices[i].vz = g_vertices[i].vectBinormal.z;
pVertices[i].uvx = g_vertices[i].vectNormal.x;
pVertices[i].uvy = g_vertices[i].vectNormal.y;
pVertices[i].uvz = g_vertices[i].vectNormal.z;
}
// unlock mesh vertex buffer
hResult = lpVB->Unlock();
if( D3D_OK != hResult )
{
return false;
}
// unlock mesh index buffer
hResult = lpIB->Unlock();
if( D3D_OK != hResult )
{
return false;
}
Postępując zgodnie z algorytmem podanym w części teoretycznej, dla każdego wierzchołka wyliczamy trzy wektory
definiujące przestrzeń tekstury i umieszczamy je w kolejnych współrzędnych na danych poziomach tekstur. Wektor
definiujący obrót wokół osi x na poziomie nr 2, y na poziomie nr 3 i z na nr 4. Po zakończeniu wyliczania wektorów
oczywiście należy odblokować zalockowane wcześniej bufory wierzchołków i indeksów. Metoda podana tutaj w zasadzie
4
DirectX ▪ Dot3
niczym się nie różni od tej z części teoretycznej. Nieścisłości mogą wynikać z zastosowanej konstrukcji kodu, ale chodzi
dokładnie o to samo - na podstawie ułożenia tekstury normalnej na obiekcie definiujemy po prostu układ współrzędnych,
który niejako obrazuje naturalne ułożenie tekstury - osie X i Z leżą w płaszczyźnie tekstury a kierunki osi wskazują na
rosnące wartości współrzędnych mapowania natomiast oś Y jest skierowana prostopadle do tekstury na danym trójkącie - to
tak dla krótkiego przypomnienia. Do kompletu danych brakuje nam jeszcze dwóch, bardzo ważnych elementów naszej
układanki. Będą to oczywiście Pixel jak i Vertex Shader. Wprawdzie omawiany dzisiaj efekt dałoby się przy pewnych
założeniach co do możliwości sprzętu osiągnąć także bez Pixel Shadera, no ale my, aby nabierać dobrych przyzwyczajeń już
teraz zawsze będziemy się nimi posługiwać, aby nabierać wprawy, która potem na pewno się przyda. No i zakładając, że
wszystko nam się poprawnie załadowało, shadery są poprawnie napisane, urządzenia zainicjalizowane i wszystko dosłownie
"gra i buczy" możemy przystąpić do renderingu. Aby dokładnie sobie zobrazować kolejne fazy tworzenia naszego efektu
nasza przykładowa aplikacja została skonstruowana w ten sposób, że umożliwia po prostu pokazywanie kolejnych etapów
powstawania wypukłego obiektu. Wszystko, co ważne rozegra się dla nas tak naprawdę w sferze shaderów, bo to one będą
decydowały co się będzie działo z danymi a szczególną rolę w naszym przykładzie odegra niewątpliwie Vertex Shader:
vs.1.1
dcl_position v0
dcl_normal v3
dcl_color v5
dcl_texcoord0 v7
dcl_texcoord1 v8
dcl_texcoord2 v9
dcl_texcoord3 v10
dcl_texcoord4 v11
; c0-c3 - world transform matrix
; c4-c7 - view*proj transform matrix
; c8 - simple light
def c8, 0.0f, 0.0f,-1.0f, 0.0f
def c9, 0.0f, 0.25f, 0.5f, 1.0f
; transform position and normal into camera-space
m4x4 r0, v0, c0 ; vertex in the world space
m3x3 r1, v3, c0 ; normal in the world space
mov r1.w, c9.z
; move u to world space
dp3 r2.x, v9, c0
dp3 r2.y, v9, c1
dp3 r2.z, v9, c2
; move v to world space
dp3 r3.x, v10, c0
dp3 r3.y, v10, c1
dp3 r3.z, v10, c2
; move v to world space
dp3 r4.x, v11, c0
dp3 r4.y, v11, c1
dp3 r4.z, v11, c2
; move light to texture space
dp3 r1.x, r2.xyz, c8
dp3 r1.y, r3.xyz, c8
dp3 r1.z, r4.xyz, c8
; normalize light in texture space
dp3 r1.w, r1, r1
rsq r1.w, r1.w
mul r1, r1, r1.w
; output
mad oD0, r1, c9.z, c9.z
mov oD0.w, c9.w
5
DirectX ▪ Dot3
m4x4 oPos, r0, c4
mov oT0, v7
mov oT1, v8
Na samym początku oczywiście numer wersji shadera (od wersji DX9.0 najniższa możliwa to 1.1) a następnie deklaracja
używanego formatu wierzchołka:
dcl_position v0
dcl_normal v3
dcl_color v5
dcl_texcoord0 v7
dcl_texcoord1 v8
dcl_texcoord2 v9
dcl_texcoord3 v10
dcl_texcoord4 v11
Jak widać tradycyjnie już pozycja wierzchołka będzie wchodzić do shadera poprzez rejestr
v0
, normalna poprzez
v3
itd.
Widać także, że pomimo użycia różnej ilości ważnych danych dla poszczególnych poziomów tekstur dla shadera nie ma to
żadnego znaczenia - on i tak domyślnie pobiera z każdego poziomu wszystkie cztery dostępne dane - a co my z tym w
shaderze zrobimy i czy będzie to poprawne to już nasze zmartwienie. Oczywiście do shadera przekazujemy także macierze
przekształceń poprzez rejstry pamięci stałej. Jak zwykle będą to: macierz świata oraz pomnożone przez siebie dla
oszczędności miejsca macierze widoku i projekcji. W shaderze także zdefiniujemy sobie stałą, która będzie nam mówiła o
położeniu światła (w naszym przykładzie będzie ono stałe) oraz drugą stałą zawierającą kilka potrzebnych liczb.
; transform position and normal into camera-space
m4x4 r0, v0, c0 ; vertex in the world space
m3x3 r1, v3, c0 ; normal in the world space
mov r1.w, c9.z
Pierwsze co robimy to mnożymy współrzędne zarówno wierzchołków jak i normalnych aby przenieść je w świecie. To już
standard w naszych shaderach i to działanie jest chyba najzupełniej zrozumiałe - ono po prostu umożliwia nam animację
naszego obiektu, jeśli znajduje się on w ruchu lub umożliwia odpowiednie ułożenie w świecie.
; move u to world space
dp3 r2.x, v9, c0
dp3 r2.y, v9, c1
dp3 r2.z, v9, c2
; move v to world space
dp3 r3.x, v10, c0
dp3 r3.y, v10, c1
dp3 r3.z, v10, c2
; move v to world space
dp3 r4.x, v11, c0
dp3 r4.y, v11, c1
dp3 r4.z, v11, c2
W wektorach wejściowych shadera
v9
,
v10
i
v11
umieściliśmy to, co nas najbardziej dzisiaj interesuje - czyli układy
współrzędnych określające jak zmienia się mapa normalnych w danym wierzchołku. Te układy współrzędnych posłużą do
tego, aby przenieść do nich padający na dany wierzchołek wektor światła co będzie w ostateczności stanowiło przeniesienie
wektora światła do przestrzeni tekstury normalnej - co jest naszym ostaecznym celem. Ponieważ wierzchołki naszego obiektu
mnożyliśmy przez macierz świata a wiemy, że istnieje możliwość poruszania się naszego obiektu po scenie więc trzeba
zrobić to samo z wektorami lokalnych układów w wierzchołkach. Jeśli obiekt się będzie na przykład obracał to wiadomo, że
tekstury też się obracają (no chyba, że zależy nam na jakimś efekcie bardzo specjalnym). Załóżmy jednak, że mapa
wypukłości nie porusza się po obiekcie a jest niejako "statyczna" - to znaczy jeśli obiekt się porusza to mapa normalnych
także. Dziwnie w sumie by wyglądała ziemia z wędrującymi Himalajami po powierzchni ;P. Jeśli obiekt się obróci to na
pewno zmieni kąt padania światła na wierzchołek a co za tym idzie i ułożenie lokalnego układu współrzędnych tekstury w
stosunku do światła. Tak więc cały nasz lokalny układ w postaci trzech osi obracamy wraz z obiektem. Dziwić was może
tutaj inny sposób mnożenia wektorów przez macierz świata (umieszczona w stałych
c0
-
c3
). Ale zapewniam was, że jest to
dokładnie to samo co użycie makra
m3x3
i jeśli chcecie to możecie sobie to zastąpić. Dla mnie to jest jak na razie bardziej
przejrzyste i lepiej widać przynajmniej na początku o co chodzi.
6
DirectX ▪ Dot3
; move light to texture space
dp3 r1.x, r2.xyz, c8
dp3 r1.y, r3.xyz, c8
dp3 r1.z, r4.xyz, c8
; normalize light in texture space
dp3 r1.w, r1, r1
rsq r1.w, r1.w
mul r1, r1, r1.w
No i teraz następuje to, o czym zaczęliśmy mówić jeszcze w części teoretycznej - czyli przenosimy wektor światła do
przestrzeni tekstury. Wystarczy, podobnie jak w przypadku obracania wektorów lokalnego układu współrzędnych tekstury
przez macierz świata, tak samo wektor światła mnożymy przez lokalną macierz tekstury. Z tym, że nasza macierz tekstury
jest przekształcona przez macierz świata i znajduje się w tymczasowych rejestrach
r2
,
r3
oraz
r4
. Po pomnożeniu wektora
światła przez te trzy wektory (definiującą macierz obrotu) mamy wektor światła w przestrzeni tekstury - więc prawie sukces.
Prawie, ponieważ jak to w przypadku większości operacji na wektorach należy jeszcze wynikowy wektor znormalizować aby
uniknąć kłopotów w przyszłości. Robimy więc to za pomocą naszej ulubionej sztuczki, którą także mieliśmy okazję już nie
raz omawiać.
; output
mad oD0, r1, c9.z, c9.z
mov oD0.w, c9.w
m4x4 oPos, r0, c4
mov oT0, v7
mov oT1, v8
No i na zakończenie należy uzupełnić rejestry wyjściowe, ponieważ inaczej oczywiście nie otrzymamy oczekiwanych
efektów na ekranie. Co może zaskakiwać na samym początku to zawartość rejestru
oD0
, czyli tego, który przechowuje w
normalnym trybie kolor diffuse wierzchołków. Tutaj natomiast na pewno nie będzie on posiadał tej wartości, która w naszym
przypadku po prostu zostanie zgubiona. I powinniśmy sobie przy okazji powiedzieć, że tak naprawdę to czas już powoli się
odzwyczajać od koloru wierzchołków, którego nie powinno się używać do definiowania wyglądu bryły - o tym powinna
decydować tekstura. Ale jak ktoś się uprze to trzeba by przekazać ten kolor po prostu jako wektor pamięci stałej i coś w tym
względzie kombinować. Ale wracając do naszego przykładu - w rejestrze
oD0
znajdzie natomiast pewna kombinacja.
Znormalizowany wektor światła przeniesiony do układu tekstury znajduje się w rejestrze tymczasowym
r1
. Znormalizowany
wektor może posiadać wartości od -1 do 1. My jednak będziemy potrzebowali tych wartości w zakresie od 0 do 1 -
oD0
przecież zawiera kolory właśnie w takim zakresie. Żeby dokonać takiej konwersji wystarczy podzielić wartości z rejestru
r1
przez 2 (pomnożyć przez 0.5) i dodać do tego także 0.5. Robimy to za pomocą instrukcji
mad
, która najpierw mnoży rejestr
r1
przez 0.5 a następnie dodaje tę samą wartość do niego po wymnożeniu i umieszcza wynik w rejestrze wyjściowym
oD0
.
Aby uzupełnić cały rejestr do części
w
rejestru wkładamy cokolwiek, nie będzie to miało znaczenia. Rejestry pozostałe
uzupełniamy jak zwykle - wyjściową pozycję mnożymy przez macierz widoku i projekcji a rejestry tekstur po prostu
przepisujemy z wyjścia, ponieważ akurat współrzędnych tekstur nie zmieniamy. No i w zasadzie jesteśmy już na samym
końcu, ale jednocześnie jeszcze bardzo, bardzo daleko. Aby nam się jaśniej zrobiło po tych wszystkich kombinacjach teraz
zilustrujemy sobie poszczególne etapy. Pierwszą i najważniejszą rzeczą było przeniesienie wektora światła do przestrzeni
tekstury. Jak pamiętamy, umieściliśmy sobie ten wektor jako składowe koloru diffuse. Zobaczmy więc, jak to będzie
wyglądało na bryle. W tym celu wyłączymy sobie wszystkie tekstury i inne kombinacje:
case eDiffuse:
{
// shaders settings
g_lpD3DDevice->SetPixelShader( NULL );
g_lpD3DDevice->SetVertexShader( g_lpVertexShader );
// texture settings - pass diffuse to final stage
g_lpD3DDevice->SetTextureStageState( 0, D3DTSS_COLOROP, D3DTOP_SELECTARG1 );
g_lpD3DDevice->SetTextureStageState( 0, D3DTSS_COLORARG1, D3DTA_DIFFUSE );
g_lpD3DDevice->SetTextureStageState( 1, D3DTSS_COLOROP, D3DTOP_SELECTARG1 );
g_lpD3DDevice->SetTextureStageState( 1, D3DTSS_COLORARG1, D3DTA_CURRENT );
}
break;
Jak widać z podanego kawałka kodu nie mamy używanego tutaj Pixel Shadera. To powoduje, że znaczenia nabierają
ustawienia poszczególnych poziomów tekstur. Jak pamiętamy na poziomie nr 0 mamy umieszczoną teksturę normalnych,
natomiast na poziomie nr 1 teksturę właściwą obiektu. Na poziomie zerowym widać z ustawień, że wyjściowym kolorem
7
DirectX ▪ Dot3
będzie kolor diffuse a pamiętamy z shadera, że ten kolor oznacza nic innego, jak tylko przeniesiony wektor światła do układu
tekstury. Z ustawień poziomu nr 1 wynika, że kolor wyjściowy będzie po prostu przeniesiony z poziomu 0, czyli nic nie
zmieniamy. Czyli w końcowym efekcie dostaniemy kolor diffuse, który wychodzi z shadera. A wygląda to mniej więcej tak:
Tak więc w każdym wierzchołku wektor światła jest obrócony do przestrzeni tekstury zgodnie z naszym życzeniem, resztę
załatwia sposób wygładzania (Gourauda). Aby się jeszcze upewnić, czy tekstury są poprawnie umieszczone na poziomach i
aby zobaczyć czy się poprawnie nakładają
case eNormalMap:
{
// shaders settings
g_lpD3DDevice->SetPixelShader( NULL );
g_lpD3DDevice->SetVertexShader( g_lpVertexShader );
// texture settings - pass normal map to final stage
g_lpD3DDevice->SetTextureStageState( 0, D3DTSS_COLOROP, D3DTOP_SELECTARG2 );
g_lpD3DDevice->SetTextureStageState( 0, D3DTSS_COLORARG2, D3DTA_TEXTURE );
g_lpD3DDevice->SetTextureStageState( 1, D3DTSS_COLOROP, D3DTOP_SELECTARG1 );
g_lpD3DDevice->SetTextureStageState( 1, D3DTSS_COLORARG1, D3DTA_CURRENT );
}
break;
Aby zobaczyć nałożoną teksturę normalnych to trzeba z poziomu nr 0 jako wyjściowy kolor "wypuścić" kolor tekstury (nie
mieszając go z kolorem diffuse wierzchołków ani z żadnym innym) a na poziomie nr 1 po prostu przepuścić to, co
przychodzi z poziomu nr 0. A tak to wygląda na ekranie:
case eDiffuseMap:
8
DirectX ▪ Dot3
{
// shaders settings
g_lpD3DDevice->SetPixelShader( NULL );
g_lpD3DDevice->SetVertexShader( g_lpVertexShader );
// texture settings - pass diffuse map to final stage
g_lpD3DDevice->SetTextureStageState( 1, D3DTSS_COLOROP, D3DTOP_SELECTARG2 );
g_lpD3DDevice->SetTextureStageState( 1, D3DTSS_COLORARG2, D3DTA_TEXTURE );
}
break;
A teksturę właściwą obiektu można zobaczyć jeszcze prościej - po prostu z poziomu nr 1 wypuszczamy kolor tekstury, bez
względu na to, co mieliśmy na poziomie nr 0. I na ekranie otrzymujemy:
No i skoro wiemy, że wektor światła mamy przeniesiony, tekstury zarówno normalna jak właściwa dla obiektu nakładają się
poprawnie nadszedł czas aby zobaczyć w końcu coś wypukłego na ekranie. Ale aby to zobaczyć do pracy musimy tym razem
zaprzęgnąć do pracy Pixel Shader:
ps.1.1
; texture instructions
tex t0
tex t1
; arithmetic instructions
dp3_sat r1, v0_bx2, t0_bx2
mul r0, r1, t1
Można by rzecz, że zbyt okazały to on nie jest, ale jak już mieliśmy się okazję przekonać cała moc tkwi w jego rozkazach, bo
za ich pomocą można robić naprawdę cuda. No ale popatrzmy, co będzie się tutaj działo. Na sam początek oczywiście
tradycyjnie wersja, która podobnie jak w przypadku wierzchołków poprawna może być 1.1 (najniższa). Następnie
deklarujemy rejestry wejściowe - w naszym przypadku rejestry tekstur, które będą wprowadzały do shadera dane tekstur z
poziomów nr 0 i nr 1. Następnie mamy dosyć skomplikowany twór, który jest kombinacją kilku możliwych do zastosowania
technik w Pixel Shaderze. Instrukcja jest oczywista - skoro wykorzystujemy technikę dot3 na kolorach, więc zastosowanie tej
instrukcji właśnie tutaj jest jak najbardziej na miejscu. Jako parametry tej instrukcji mamy: - rejestr tymczasowy
r1
, w
którym umieszczamy rezultat operacji. - rejestr wejściowy
v0
, w który umieszczony jest kolor diffuse, pochodzący z
obliczeń w Vertex Shaderze. - rejestr tekstur
t0
, w którym będą umieszczane kolejne wartości pochodzące z tekstury na
poziomie nr 0, czyli mapy normalnych. Zanim jednak operacja dot3 zacznie działać w Pixel Shaderze należy odpowiednio
przygotować dla niej dane. Jak widzimy na każdy z rejestrów wejściowych przed dokonaniem operacji jest nałożony
modyfikator, który jak wiemy powoduje zmianę danych wchodzących do operacji. W obydwu przypadkach modyfikator jest
ten sam -
bx2
. Powoduje on rozkład wartości w rejestrze z przedziału ( 0, 1 ) na przedział (-1, 1 ), a dokładniej mówiąc
odejmuje od każdej wartości w rejestrze wartość 0.5 i mnoży otrzymany wynik przez 2. Pamiętamy również, że odwrotnej
operacji dokonywaliśmy w Vertex Shaderze, przed wpisaniem znormalizowanego wektora światła do rejestru
oD0
. Dlaczego
więc takie kombinacje? Otóż wszystko przez operację dot3 a dokładniej mówiąc przez rozkaz
dp3
. Aby otrzymać poprawne
wyniki musimy mieć dane kolorów w zakresie (-1, 1 ) a kolory wchodzące do Pixel Shadera jak wiemy są w zakresie ( 0, 1 ).
W Vertex Shaderze musieliśmy zamienić na zakres ( 0, 1 ), ponieważ tak pracuje rejestr
oD0
. Tutaj po prostu robimy
9
DirectX ▪ Dot3
odwrotnie, czyli wracamy do początku. Mając kolor diffuse i teksturę w zakresie (-1, 1 ) robimy to, na co czekaliśmy tyle
czasu - czyli dokonujemy operacji dot3 na kolorach. Da nam to w wyniku oczywiście jakiś kolor wyjściowy, który tak
naprawdę będzie wektorem. Ale tak jak mówiliśmy od początku części teoretycznej - w mapie normalnych mamy zapisany
wektor mówiący jaka w danym pikselu bryły jest normalna do powierzchni. Kolor diffuse przechowuje wektor światła w tej
samej przestrzeni co mapa normalnych. I tak jak w prostym Vertex Shaderze liczącym oświetlenie wyliczaliśmy jego
natężenie poprzez operację dot3 pomiędzy wektorem światła a normalną tak samo czynimy tutaj - tylko po wielu
kombinacjach. Ale za to nie na poziomie wierzchołków a pikseli. Po dokonaniu operacji również mamy modyfikator, tylko
trochę inny. Dokonuje on obcięcia wyjściowej wartości do przedziału ( 0, 1 ). W wyniku dostaniemy poziom koloru od
czarnego do białego, bez żadnych niespodzianek.
Ale to jeszcze nie koniec, ponieważ jak pamiętamy o dyspozycji mamy dwa poziomy tekstur. Jeden już wykorzystaliśmy,
ponieważ dokonaliśmy operacji dot3 pomiędzy pikselami a kolorem diffuse wierzchołków, który zawiera odpowiednio
przekształcony wektor światła. Po tej operacji będziemy mieli już wypukły obiekt - zobaczymy to zaraz na przykładowych
rysunkach. Aby pokryć wypukły obiekt końcową teksturą wystarczy pomnożyć rezultat poprzedniej operacji (mamy już ten
rezultat w przedziale ( 0, 1 ) ) przez drugi rejestr tekstur (wtedy je zmieszamy), umieścić rezultat w rejestrze wyjściowym
Pixel Shadera
r0
i to w zasadzie wszystko. Żeby jednak jeszcze się nie zgubić zobaczmy jak to będzie wyglądało:
case eBump:
case eFinal:
{
// shaders settings
g_lpD3DDevice->SetPixelShader( g_lpPixelShader );
g_lpD3DDevice->SetVertexShader( g_lpVertexShader );
}
break;
Oczywiście aby zobaczyć końcowy efekt musimy włączyć używanie Pixel Shadera. Inne ustawienia urządzenia w tym
ustawienia poziomów tekstur co do typów operacji i argumentów w zasadzie nie mają znaczenia, bo o wszystkim decyduje
tutaj Pixel Shader. Aby pokazać efekt zarówno samego obiektu wypukłego jak i z nałożoną teksturą właściwą posłużę się
pewną sztuczką z teksturami. Aby za dużo nie mieszać i posługiwać się tylko jednym Pixel Shaderem, w przypadku
pokazywania tylko obiektu wypukłego zamiast tekstur końcowych mieszam obiekt wypukły w teksturą dokładnie białą, co
zupełnie nie zmienia rezultatu mnożenia. Wtedy widać ładnie na ekranie tylko obiekt wypukły, o właśnie tak:
No a końcowy obiekt wypukły z nałożoną teksturą finalną wygląda... hmm... trochę chyba mniej efektownie no bo tekstura
końcowa "zagłusza" nieco tę wypukłość. Jest bardzo kolorowa no i efekt nie jest taki, jakiego można by oczekiwać. Można
by to regulować nieco natężeniem wypukłości lub próbować nieco głębszej mapy wypukłości no ale to już jak komu potrzeba
i się podoba. No ale chyba coś tam widać na końcowym ekranie:
10
DirectX ▪ Dot3
I to byłoby wszystko, jeśli chodzi o mapowanie wypukłości z użyciem operacji dot3. Nie muszę chyba mówić jak ogromną
oszczędność w ilości użytych wierzchołków daje ta metoda - nadrabiamy czasami nawet tysiące wierzchołków za pomocą
jednej, nawet niewielkiej tekstury! Jeśli mamy zdolnego grafika, który wie jak wykorzystać moc map normalnych to
naprawdę efekty mogą być oszałamiające. Mam nadzieję, że zrozumiałe jest dla was większość przykładu, jeśli nie wszystko
;P. Jeśli macie jakieś wątpliwości to oczywiście nie wahajcie się pytać a postaram się wszystko wyjaśnić. W przykładowej
aplikacji możecie się pobawić klawiszami zgodnie z opisem aby zobaczyć wszystkie omawiane przeze mnie fazy i zobaczyć
wszystkie pokazywane dzisiaj obrazki.