1
DirectX ▪ Vertex shader 2
Witam w dzisiejszej, mam nadzieję bardzo ciekawej lekcji. Od teorii do praktyki czyli w sumie prawidłowo przejdziemy
sobie dzisiaj na przykładzie najprostszego vertex shadera. Teorię mamy już mam nadzieję w małym palcu i doskonale
rozumiemy o co w tym wszystkim chodzi, więc czas przystąpić do poważnego działania. Kod w większości oczywiście
doskonale znamy, więc naprawdę szkoda czasu na powtarzanie tego samego w kółko. Tradycyjnie więc opiszemy tylko to,
co będzie dla nas ważne.
// A structure for our custom vertex type
struct CUSTOMVERTEX
{
FLOAT x;
FLOAT y;
FLOAT z;
DWORD color;
FLOAT tx;
FLOAT ty;
};
#define D3DFVF_CUSTOMVERTEX ( D3DFVF_XYZ | D3DFVF_DIFFUSE | D3DFVF_TEX1 )
To oczywiście wiemy doskonale czym jest, ale ponieważ ma to ogromne znaczenie dla naszych późniejszych działań, więc
warto zaznaczyć. Deklarujemy strukturę, która będzie określała, jakie dane będziemy przekazywać w naszym wierzchołku. U
nas będą to: współrzędne (konieczne oczywiście), kolor wierzchołków (wskazany) no i współrzędne tekstur (dla bajeru).
Następnie dyrektywą
#define
składamy nasz typ z typów podstawowych.
// vertex shader declarator
DWORD dwDecl[] =
{
D3DVSD_STREAM(0),
D3DVSD_REG( D3DVSDE_POSITION, D3DVSDT_FLOAT3 ),
D3DVSD_REG( D3DVSDE_DIFFUSE, D3DVSDT_D3DCOLOR ),
D3DVSD_REG( D3DVSDE_TEXCOORD0, D3DVSDT_FLOAT2 ),
D3DVSD_END()
};
Powiedzieliśmy sobie w poprzednim tutorialu o deklaratorze vertex shadera, który określa powiązanie strumienia danych z
rejestrami wejściowymi. Deklarator powinien oczywiście ściśle odpowiadać deklaracji naszego typu dla wierzchołków.
Znaczy to, że vertex shader powinien pobierać ze strumienia wszystkie dane dla danego wierzchołka, które przychodzą
taśmociągiem. Dane płyną jednym strumieniem, więc, aby rozróżnić poszczególne wierzchołki, koniecznym staje się
odbieranie paczek ściśle odpowiadających rozmiarowi naszej struktury do wierzchołków. Tak naprawdę to deklarator
omówiliśmy sobie już poprzednim razem, więc tylko małe przypomnienie.
D3DVSD_STREAM(0)
Makro, które wygeneruje taką liczbę
DWORD
i umieści ją w tablicy deklaratora, że shader analizując tę tablicę zobaczy:
dane napływać będą strumieniem numer 0, więc ustaw wejścia, tak żeby stamtąd pobierać poszczególne paczki i wrzucaj
odpowiednie wartości do odpowiednich rejestrów wejściowych.
D3DVSD_REG( D3DVSDE_POSITION, D3DVSDT_FLOAT3 ),
D3DVSD_REG( D3DVSDE_DIFFUSE, D3DVSDT_D3DCOLOR ),
D3DVSD_REG( D3DVSDE_TEXCOORD0, D3DVSDT_FLOAT2 ),
Te trzy makra powiążą nam rejestry wejściowe z danymi płynącymi strumieniem. Zanalizujmy szczegółowiej. Kombinacja
stałych
D3DVSDE_POSITION
i
D3DVSDT_FLOAT3
mówi tak: słuchaj shader, płynie sobie strumień. Ty się zaczaj i
kiedy pojawi się paczka danych, pobierz z niej tyle bajtów, aby wystarczyło na trzy liczby float i skopiuj te bajty do rejestru
numer 0. Ponieważ w naszej strukturze te pierwsze trzy liczby to
float
i są to współrzędne wierzchołka a rejestr 0 jest to
rejestr odpowiedzialny za pozycję wierzchołków, więc właściwe dane we właściwe miejsce - ujmując krótko i treściwie.
Następna para stałych
D3DVSDE_DIFFUSE
i
D3DVSDT_D3DCOLOR
instruuje shader w sposób następujący. Cztery
bajty ze strumienia weź i umieść je w rejestrze numer 4. Patrzymy w naszą strukturę - faktycznie,
DWORD
czyli cztery bajty
popłyną strumieniem i będą one oznaczać kolor wierzchołków, należy je więc umieścić w rejestrze odpowiedzialnym za
kolor. Ostatnia para stałych z tej serii, czyli
D3DVSDE_TEXCOORD0
i
D3DVSDT_FLOAT2
to, jak łatwo się już
domyśleć, umieszczenie bajtów odpowiedzialnych za współrzędne tekstury w rejestrze numer 6. Dokładny opis stałych i ich
przypisanie do rejestrów znajdziecie w dokumentacji do makra
D3DVSD_REG
.
D3DVSD_END()
2
DirectX ▪ Vertex shader 2
Ponieważ tyle bajtów, ile już zadeklarowaliśmy, w pełni zapełnia naszą paczkę, czas zakończyć nasz deklarator. To makro
tworzy specjalną wartość (0xFFFFFFFF), która oznacza dla vertex shadera koniec deklaracji i po tym pozostaje nam już
tylko zamknąć naszą tablicę. Wrzucania wierzchołków do bufora, inicjalizacji urządzeń i tym podobnych rzeczy nie
wałkujemy zgodnie z umową i przystępujemy od razu do działania, czyli tworzymy nasz vertex shader.
bool CreateVertexShader()
{
LPD3DXBUFFER pCode;
LPD3DXBUFFER pError;
HRESULT hError;
hError = D3DXAssembleShaderFromFile( "shader.vsh", 0, NULL, &pCode, &pError );
// plik << (char*)( pError->GetBufferPointer() ) << endl;
g_pd3dDevice->CreateVertexShader( dwDecl, (DWORD*)pCode->GetBufferPointer(),
&VertexShader, 0 );
pCode->Release();
return true;
}
Aby utworzyć vertex shader, posłużymy się jedną z metod urządzenia renderującego, widoczną we fragmencie kodu
przedstawionym powyżej. Metoda
CreateVertexShader()
utworzy nam długo już oczekiwany obiekt, ale pod kilkoma
ważnymi warunkami. Jak widać, liczbą parametrów przyjmowanych nie odbiega ona od standardu Direct3D ;-) i nie będzie
nam łatwo. Pierwszym parametrem tej metody będzie tablica wartości
DWORD
, która określa deklarator naszego shadera.
Co to deklarator już doskonale wiemy i rozumiemy, wałkowaliśmy to mam nadzieję wystarczająco długo? Następnym
parametrem jest wskaźnik do miejsca w pamięci, gdzie znajdują się wywołania instrukcji vertex shadera. Tłumacząc to na
nasze, jest to miejsce w pamięci, gdzie znajduje się nasz skompilowany kod. Trzeci parametr to zmienna, która będzie
oznaczać naszego shadera - nic prostszego, natomiast czwarty określa sposób działania naszego shadera - jak na razie jest
dostępny tylko jeden, domyślny, więc wpiszmy tam zero. My natomiast zajmijmy się teraz kodem...
Ponieważ już za momencik ujrzymy źródło naszego pierwszego shadera, musimy powiedzieć o jeszcze jednej rzeczy.
Ponieważ mamy kod źródłowy, więc nieuniknione jest to, że będziemy musieli go kiedyś skompilować. Pytanie tylko - skąd
wziąć kompilator??? Otóż odpowiedź jest bardzo prosta... kompilatora nie ma. Ktoś zapyta - no to co teraz? Ano, jak zwykle
rozwiązanie jest bliżej niż się wydaje. Zawołajmy na pomoc nasz wspaniały pakiet i jego ogromnie pożyteczną bibliotekę
D3DX
. I cóż takiego możemy tam znaleźć? Szperając w dokumentacji możemy się natknąć na kilka funkcji, które...
kompilują nasz kod! Strasznie to wszystko pokręcone, ale nie martwmy się. Musimy popatrzeć na to z trochę wyższego
poziomu abstrakcji. Tak naprawdę kompilator mamy. Nie będzie to jednak taki, jaki znamy z naszej codziennej praktyki. Dla
nas kompilator to wbudowana w Direct3D funkcja, która pobierając nasz kod źródłowy będzie potrafiła sprawić, że pojawi
się on gdzieś w pamięci jako coś użytecznego dla naszego shadera. Za pomocą biblioteki
D3DX
można kompilować nasz
kod na dwa sposoby. W pierwszym trzeba gdzieś w pamięci umieścić nasze źródło, określić miejsce gdzie on jest i do dzieła.
Proponuję przyjrzeć się bliżej funkcji
D3DXAssembleShader()
. Ponieważ miotanie się z kodem i błąkanie się z nim po
pamięci jest trochę niewygodne, możemy wykonać sobie inny manewr. Możemy sobie napisać naszego shadera w zupełnie
oddzielnym pliku! Biblioteka daje nam do ręki funkcję, która nie chce miejsca w pamięci, gdzie znajduje się kod, ona chce
mieć kod czarno na białym, wypisany w pliku. Funkcją tą jest, jak widzicie,
D3DXAssembleShaderFromFile()
. Ona też
powoduje kompilację naszego kodu, tylko tak, jak napisałem wcześniej, ma tę zaletę, że robi to bezpośrednio z pliku
tekstowego. Dlaczego jest to zaleta? Załóżmy, że mamy ogromny projekt (tysiące plików) i mnóstwo różnych shaderów,
których używamy dosyć gęsto. Jeśli napiszemy kod naszych shaderów bezpośrednio w kodzie źródłowym aplikacji, to
będziemy musieli:
• po pierwsze błądzić w gąszczu plików i katalogów, jeśli jesteśmy bałaganiarzami a jesteśmy nimi na pewno ;-),
• po drugie, pliki źródłowe aplikacji mogą być ogromnie długie i szukanie po kodzie naszego interesującego kodu nie
jest zbyt frapującym zajęciem.
Popatrzmy teraz co będzie jeśli dla przykładu wszystkie nasze shadery zgromadzimy w oddzielnym katalogu. Znajdziemy je
od razu, bo wiemy, gdzie są. Poprawek dokonamy nie szarpiąc się z tysiącami linii kodu (przypominam, że każdy shader to
maksimum 128 instrukcji!). Po prostu żyć nie umierać. To rozwiązanie ma jednak pewną, dosyć istotną wadę. Plik z
shaderem musi zostać dołączony jako jeden z plików rozprowadzanym wraz z aplikacją i niewątpliwie ktoś będzie mógł
przeanalizować sobie nasz shader. Jeśli umieścimy kod naszego shadera w programie, to po jego skompilowaniu kod binarny
jest oczywiście umieszczany w kodzie aplikacji, więc nie musimy dołączać żadnych plików do naszego programu
wykonywalnego. Ponieważ my na razie się uczymy, napiszemy oddzielny plik i będziemy go kompilować funkcją
D3DXAssembleShaderFromFile()
. Pobiera ona pięć argumentów. Pierwszym z nich jest ścieżka do naszego pliku z kodem
shadera - chyba nie wymaga komentarza? Druga określa sposób kompilacji naszego dzisiejszego bohatera. Otóż wiadomo, że
jak w każdym kodzie źródłowym nie uda się od razu napisać doskonałego programu bez pomyłek. Aplikacje kompiluje się w
fazie powstawania w trybie debuggera, umożliwiającym śledzenie programu krok po kroku i wychwytywanie błędów. Ale
chyba o posługiwaniu się debuggerem nie muszę Wam pisać? Aby nie być gorszym, kompilator shaderów też umożliwia
wstawienie do kompilowanego kodu informacji, które mogą być pomocne przy debugowaniu. A jak go debugować to może
3
DirectX ▪ Vertex shader 2
później ;-). Trzy ostatnie parametry są typu
LPD3DXBUFFER
. Typ ten jest, jak łatwo zapewne się domyśleć, rodzajem
bufora, w którym można składować dane... ot, taki kawałek pamięci zarządzany przez obiekt. Obiekt ten ma metody
pozwalające na odczyt danych i pobieranie rozmiaru bufora. Ale o tym to przy okazji. Wracając do kompilatora, pierwszy z
trzech buforów zawierać powinien deklaracje stałych dla shadera (umieszczanych w rejestrach pamięci stałej). My jednak na
razie odpuścimy go sobie i ustawimy go na
NULL
- w takim przypadku będzie po prostu ignorowany. Druga zmienna tego
typu w kolejności określa bufor, gdzie znajdował się będzie poszukiwany przez nas długo i wytrwale kod binarny
(skompilowany). Jak nietrudno zauważyć, ta sama zmienna posłuży nam przy wywołaniu metody do tworzenia shadera. Kod,
który jest kompilowany, zostanie umieszczony w buforze w pamięci i stamtąd będzie pobierany w czasie wykonywania sie
funkcji shadera. Jak doskonale wiemy, podczas kompilacji mogą wystąpić błędy składni - zwłaszcza, że jesteśmy jeszcze
bardzo niedoświadczeni, jeśli chodzi o ten wynalazek. Ponieważ nasz kompilator działa w dosyć specyficzny sposób, więc
nie mamy bezpośrednio widocznych rezultatów kompilacji. Ale wcale nie oznacza to, że stoimy na straconej pozycji.
Ostatnim argumentem funkcji, która przeprowadza kompilację kodu shadera jest wskaźnik do bufora, który, jak sama nazwa
wskazuje, będzie zawierał łańcuchy znaków (komunikaty), które zostaną nam dostarczone podczas kompilacji przez naszą
funkcję. Załóżmy, że kod skompilował nam się bez błędów. Jego binarna postać, gotowa do wykorzystania znajduje się w
buforze, którym zarządzamy dzięki obiektowi
LPD3DXBUFFER
. Jak dobrać się do naszego kodu, aby móc przekazać go
do funkcji tworzącej shader? Przyjrzyjmy się jej wywołaniu jeszcze raz:
g_pd3dDevice->CreateVertexShader( dwDecl, (DWORD*)pCode->GetBufferPointer(),
&VertexShader, 0 );
Wszelkie instrukcje wchodzące w skład funkcji shadera po kompilacji mają postać 4-bajtowych bloczków, podobnie jak
wszelkie deklaracje w jego deklaratorze. Jako drugi parametr metody urządzenia tworzącej shader na podstawie
skompilowanego kodu podawać się powinno wskaźnik do pamięci, w której znajdują się takie czterobajtowe bloczki. U nas
pamięć ta jest obsługiwana poprzez obiekt typu
LPD3DXBUFFER
o nazwie pCode. Ale podanie adresu samego obiektu
wcale nie oznacza, że dobierzemy się do obsługiwanej przez niego pamięci! Jak już nadmieniałem wcześniej, obiekt bufora
obecny w bibliotece
D3DX
zawiera pewne metody, które pozwolą nam na dostęp do tej pamięci. I jak na dłoni widać, co to
za metoda. Wynikiem działania metody
GetBufferPointer()
jest adres do początku pamięci (bufora), gdzie znajdują się
obsługiwane przez niego dane, czyli w tym przypadku nasz kod.
Ufff! Mam nadzieję, że wszystko jasne ;-). W zasadzie można by przystąpić do omawiania kodu, ale musimy powiedzieć
sobie o jeszcze jednej, ważnej rzeczy. Będzie nią wypełnianie rejestrów pamięci stałej. Pisałem wcześniej, że w rejestrach
tych będziemy mogli wstawić sobie cokolwiek, co będzie nam potrzebne do działania naszego shadera. Teraz właśnie
powiem, co my wstawimy sobie u nas. To, że użyjemy nowego, specyficznego vertex shadera wcale nie zwalnia nas od
przeprowadzenia transformacji przez macierze, a wręcz przeciwnie - nakłada na nas obowiązek przeprowadzenia tego
wyjątkowo uważnie! Jakoś więc trzeba będzie przekazać naszemu shaderowi nasze macierze, które obliczymy bardzo dobrze
znanymi nam funkcjami z biblioteki
D3DX
. Jak już łatwo się domyśleć, przekazanie tych wartości odbędzie się za pomocą
rejestrów pamięci stałej. Po kompilacji, shader będzie się odwoływał podczas wykorzystywania go (w funkcji Render()) do
rejestrów pamięci stałej, które możemy zmieniać w trakcie działania naszego programu. Kompilacja wcale nie oznacza, że
zostaną w tych rejestrach umieszczone wartości na stałe i już ich nie będzie można zmieniać. Będziemy mogli to robić za
pomocą metody urządzenia zwanej
SetVertexShaderConstant()
. Cóż takiego będzie robić ta metoda? Jako pierwszy
parametr przyjmuje ona numer rejestru, od którego zapisujemy określoną wartość. Jaka to będzie wartość i od którego
rejestru to już zależy tylko i wyłącznie od nas, tutaj panuje całkowita swoboda. Dlaczego napisałem "od" a nie "do"? Jak
pisałem wcześniej, rejestry przechowują wektory po cztery liczby typu
float
. Jako drugi argument nasza metoda przyjmuje
adres pamięci, w której znajdują się dane, które mamy zamiar zapisać do rejestru, jako parametr trzeci przekazujemy zaś
ilość stałych jakie mamy zamiar zapisać w naszych rejestrach. Wartość 1 oznaczać będzie jedną stałą, ale w postaci jednego
wektora zawierającego cztery liczby
float
! Zapamiętajmy to bardzo dokładnie, bo wiele razy będziemy z tego korzystać. Jeśli
więc na przykład wywołamy naszą metodę w następujący sposób:
g_pd3dDevice->SetVertexShaderConstant( 0, adres, 4 );
Oznaczać to będzie, że ładujemy dane od rejestru numer 0, z pamięci o adresie zawartym w zmiennej "adres" i stałych będzie
cztery, czyli cztery wektory po cztery liczby typu
float
. Jeśli teraz wywołalibyśmy naszą metodę kolejny raz i jako rejestr
startowy podali 1, to oczywiste jest chyba, że nadpiszemy sobie wartości w rejestrze numer 1, które zostały zapisane
poprzednim wywołaniem tej metody. Jeśli zapisujemy więcej niż jedną stałą (w postaci wektora liczb), to zostanie zapisane
tyle kolejnych rejestrów w pamięci stałej, ile wektorów podajemy jako stałe. Tak więc jeśli podamy cztery, to zostaną
zapisane rejestry o oznaczeniach
c0
,
c1
,
c2
i
c3
. Aby nic nam się nie pokręciło, należy następne wywołanie metody
SetVertexShaderConstant()
zacząć od numeru rejestru numer 4 a nie od 1. Jeśli będziemy zapisywać po jednej stałej, nic
takiego oczywiście nam nie grozi. Dlaczego o tym piszę? Otóż nader często zdarzy nam się zapisywać do rejestrów stałych
macierze przekształceń (świata, widoku i rzutowania) i, aby sobie nie zaciemniać kodu oraz oszczędzać instrukcje asemblera,
będziemy to robić za jednym zamachem. Tak też będzie w naszym przykładzie, co zobaczymy zaraz w kodzie naszego
shadera.
A co będzie jeśli w kodzie naszego shadera wystąpią błędy składniowe, uniemożliwiające kompilację? Wtedy, jeśli nasz
program będzie miał postać z naszego przykładu, na pewno się wywali. To może i nawet lepiej bo od razu będziemy
wiedzieć, że coś jest nie tak. A jeśli tak, to zmienna pError będzie obiektem, który będzie zarządzał buforem, w którym to z
4
DirectX ▪ Vertex shader 2
kolei znajdować się będą komunikaty o błędach, jak wspomniałem wyżej. O funkcji
GetBufferPointer()
już zdążyłem
wspomnieć, więc wystarczy tylko popatrzeć teraz na zakomentowane wywołanie naszej funkcji w kodzie. W naszym
przykładzie wrzucimy sobie wszystkie komunikaty do pliku. Wy, jeśli chcecie, możecie napisać sobie bardziej
skomplikowaną obsługę błędów, możecie je na przykład wyświetlać na ekranie w postaci okienka a może nawet uda Wam
się je wrzucić gdzieś w kompilator główny? (specjaliści od VC mają pole do popisu ;-). No ale w naszym kodzie na pewno
nie ma błędów i bez obaw możemy przystąpić do naszej analizy.
Nadszedł więc długo wyczekiwany moment, teraz ujrzycie najprostszy shader, który jeśli sądzić po efektach wizualnych...
nie będzie robił w zasadzie nic szczególnego. Wszystko będzie wyglądać po staremu. Dlaczego tak? Chcę Wam pokazać
ogólne idee pisania a na efekty na pewno przyjdzie czas. Ale żeby Waszej cierpliwości nie wystawiać już na dłuższą próbę,
przystąpmy do działania. Oto nasze arcydzieło:
vs.1.0
; c0 - c4 = world matrix
; c4 - c8 = view matrix
; c8 - c12 = projection matrix
; -----------------------
; vertex transformations
; -----------------------
m4x4 r0, v0, c0 ; world matrix
m4x4 r1, r0, c4 ; view matrix
m4x4 r2, r1, c8 ; projection matrix
; -----------------------
; effects
; -----------------------
; -----------------------
; output result to screen
; -----------------------
mov oPos, r2 ; Emit the output
mov oD0, v5 ; The constant color
mov oT0, v7 ; Output texture coordinates
I co powiecie? Strasznie wygląda? Nie da się ukryć, że jest to asembler. A że jest asembler, to też Ci, którzy kiedyś pisali w
tym świetnym języku wiedzą, że średnik na początku linii oznacza komentarz. I tak naprawdę jeśli się przyjrzeć naszemu
shaderowi i wyrzucić linie z komentarzem to zostanie raptem... 7 linijek. Jak na coś, co ma zrewolucjonizować całą grafikę
3D, to trochę mało, prawda? ;-) A skoro mało, to nie ma na co czekać i przystępujemy od razu do omawiania:
vs.1.0
Znamy już rozkazy, więc wiemy, że akurat ten służy do definiowania numeru wersji naszego shadera. Rozkaz ten musi być
obecny w każdej funkcji i musi także występować jako pierwszy, przed wszystkimi innymi. Ponieważ jest to nasz pierwszy
shader, więc pozwoliłem sobie nadać mu roboczą wersję 1.0.
; c0 - c3 = world matrix
; c4 - c7 = view matrix
; c8 - c11 = projection matrix
Zapewne ktoś zapyta po co omawiam komentarz. Otóż dobry zwyczajem i to w każdym języku a szczególnie w asemblerze
jest komentowanie kodu. Nie musimy spędzać potem godzin nad naszymi wypocinami i domyślać się, o co chodziło
autorowi (czyli nam :-). W vertex shaderach przyda nam się to szczególnie mocno, ponieważ będziemy stosować tutaj wiele
różnych podchwytliwych sztuczek, bez opisu których nie bylibyśmy w stanie się domyślić o co chodzi. Ale wracając do
sprawy... Przed rozpoczęciem omawiania kodu napisałem o ustawianiu rejestrów pamięci stałej. Wprawdzie jeszcze nie było
kodu, który to robi, ale będziemy wiedzieć po co i jak to robimy. Musimy posłużyć się tutaj trochę naszą wyobraźnią.
Ponieważ, jak nadmieniłem, wcale nie jesteśmy zwolnieni z obowiązku transformowania naszych wierzchołków przez
macierze przekształceń, więc gdzieś tam w programie my sobie nasze macierze utworzymy (jak zawsze z resztą). Tylko, że
tym razem nie przekażemy ich urządzeniu, ale naszemu shaderowi, aby on sam mógł dokonać na nich przekształceń. I jak
wiemy, macierze te przekażemy mu za pomocą metody
SetVertexShaderConstant()
. Jak zobaczymy później, w naszym
programie, którego omawianie będziemy na pewno kontynuować, macierze te policzymy sobie w tradycyjny sposób i
przekażemy do shadera, jak pisałem wyżej. Tutaj, w komentarzu mamy napisane, jaka macierz znajduje się w jakich
5
DirectX ▪ Vertex shader 2
rejestrach. I tak mamy po kolei: cztery wiersze macierzy świata znajdują się jako cztery stałe w rejestrach od
c0
do
c3
,
macierz widoku to stałe w rejestrach
c4
do
c7
no i macierz projekcji to
c8
do
c11
.
m4x4 r0, v0, c0 ; world matrix
m4x4 r1, r0, c4 ; view matrix
m4x4 r2, r1, c8 ; projection matrix
Wróćmy teraz na chwilę do naszego deklaratora. Tam przypisaliśmy rejestrom wejściowym odpowiednie dane ze strumienia.
Powiedzieliśmy sobie, że współrzędne wierzchołków będą znajdować się w rejestrze wejściowym odpowiedzialnym za
pozycję nazywanym
v0
. Trąbiliśmy już także o przekształcaniu naszych wierzchołków. Pamiętamy na czym polegało takie
działanie? Jeśli nie, to przypominam - mnożyliśmy współrzędne każdego wierzchołka przez trzy macierze, właśnie te, o
których cały czas teraz mówimy. Cóż nam więc pozostało innego? Skoro będziemy mieć współrzędne wierzchołków w
rejestrze
v0
a macierze w dobrze nam znanych już (z komentarza choćby), możemy przystąpić do mnożenia:
m4x4 r0, v0, c0 ; world matrix
Jak pamiętamy, instrukcja
m4x4
to makro, którego rozwinięcie znajdziecie w dokumentacji do SDK. Ja powiem tylko tyle, że
służy ono do pomnożenia naszego wektora (czterech wartości typu
float
) przez macierz 4x4. Wektor wejściowy podajemy
jako argument nr 2. Macierz wejściowa to parametr nr 3. Wynik znajdzie się w rejestrze podanym jako argument pierwszy. I
jak widzimy, wektorem wejściowym będzie rejestr
v0
czyli de facto współrzędne naszego wierzchołka. Macierzą, przez
którą mnożymy - macierz znajdująca się w rejestrach od
c0
do
c3
, czyli macierz świata. Wynik znajdzie się w rejestrze
tymczasowym
r0
. Dlaczego tam? Moglibyśmy w zasadzie umieścić go w dowolnym rejestrze oznaczonym jako r + numer.
Wykorzystujemy po prostu pierwszy wolny. Ktoś może zadać pytanie czemu nie do rejestru wyjściowego
oPos
. Jak
pamiętacie, rejestr ten jest tylko do zapisu - nie można z niego odczytywać! Jeśli więc tam zapiszemy naszą wartość, to
utknie tam już na zawsze i nie będziemy jej mogli użyć do dalszych działań. A przecież pomnożenie przez macierz świata
współrzędnych naszego wierzchołka zupełnie nam nie wystarcza, prawda? Trzeba przepuścić to, co wyjdzie z działania
Pozycja * macierz świata przez następne przekształcenia. Robimy więc to dalej, mając naszą tymczasową daną w rejestrze
r0
:
m4x4 r1, r0, c4 ; view matrix
czyli mnożymy wierzchołek przekształcony przez macierz świata (w
r0
) przez macierz widoku, która, wedle naszej wiedzy
wyniesionej z komentarza i programu, znajduje się w rejestrach
c4
do
c7
. Wynik umieszczamy dla porządku w rejestrze
r1
.
Wywołania ostatniej instrukcji już chyba nie muszę Wam tłumaczyć ? W każdym razie, ostateczny wynik znajdzie się na
razie w rejestrze
r2
. Znowu ktoś zapyta, dlaczego nie w
oPos
? Przecież już przekształciliśmy sobie wierzchołek przez
macierze i to w zasadzie koniec. Jeśli chce, oczywiście, może sobie umieścić rezultat w
oPos
, nikt mu nie zabroni. My
jesteśmy eleganccy, mamy nadmiar instrukcji, więc zrobimy to na samym końcu.
mov oPos, r2 ; Emit the output.
mov oD0, v5 ; The constant color.
mov oT0, v7 ; Output texture coordinates
Ponieważ na razie nie będziemy wymyślać żadnych bajeranckich efektów (to Wasze zadanie ;-), więc w zasadzie
moglibyśmy wyświetlić nasze dane na ekranie, zobaczymy co z tego wyjdzie. Aby Direct3D mógł cokolwiek wyświetlić,
należy oddać mu przekształcone przez verex shader wierzchołki oczywiście. Robimy to wrzucając nasze dane do rejestrów
wyjściowych. Rejestr
oPos
powinien zawierać policzone pozycje wierzchołków. U nas, w programie shadera na razie
znajdują się one w rejestrze
r2
. Wrzucamy je więc do rejestru
oPos
intrukcją
mov oPos, r2
. Z definicji deklaratora
pobieramy ze strumienia również kolor wierzchołka. Jest on pobierany i umieszczany w rejestrze wejściowych oznaczonym
jako
v5
. Ponieważ nic nie będziemy robić na razie z kolorem wierzchołków, więc my po prostu przepiszemy go sobie z
wejścia na wyjście, będzie on "przepuszczony" niejako przez shader bez czepiania się go zupełnie. Podobnie uczynimy także
ze współrzędnymi tekstury. Ze strumienia trafią one do rejestru oznaczonego jako
v7
a stamtąd prosto na wyjście, do rejestru
oT0
. I to w zasadzie koniec. Prawda, że było to proste? Jak uruchomić program, to w zasadzie nie widać żadnej różnicy w
stosunku do tego, co robiliśmy do tej pory. I prawidłowo, bo tak jak sobie powiedzieliśmy, my z wierzchołkami nie robimy
nic prócz tego, że przekształcamy je przez macierze. Ktoś spostrzegawczy oczywiście zaraz zawoła - przecież nasz sześcian
się obraca! Więc coś jednak musi się dziać. I będzie miał całkowitą rację. Sześcian obraca się, a dlaczego?
void SetupFrame()
{
D3DXMATRIX mat;
D3DXMatrixRotationX( &matWorldX, timeGetTime()/1500.0f );
D3DXMatrixRotationY( &matWorldY, timeGetTime()/1500.0f );
mat = matWorldX * matWorldY;
g_pd3dDevice->SetVertexShaderConstant(0, D3DXMatrixTranspose(&mat, &mat), 4);
6
DirectX ▪ Vertex shader 2
g_pd3dDevice->SetVertexShaderConstant(4, D3DXMatrixTranspose(&mat, &matView), 4);
g_pd3dDevice->SetVertexShaderConstant(8, D3DXMatrixTranspose(&mat, &matProj), 4);
}
Cóż to za funkcja. Ano będzie ona wywoływana za każdym wywołaniem funkcji Render(). Będzie ona służyć do... no
właściwie do czego? Jak nie wiemy, to omawiamy. Ponieważ, jak widać w przykładowym programie, sześcian się obraca,
więc czymś to trzeba robić. W poprzednich przykładach obracaliśmy cały świat za pomocą zmiany macierzy świata. Nie
inaczej będzie i w tym przypadku. Będziemy sobie obracać macierz świata o odpowiedni kąt. Kąt ten będzie się zmieniał
wraz z czasem. Numer ten jest nam oczywiście doskonale znany i dosyć oczywisty, więc nie będziemy mu poświęcać zbyt
wiele uwagi. Ciekawsze natomiast będzie ładowanie rejestrów pamięci stałej naszego shadera.
g_pd3dDevice->SetVertexShaderConstant(0, D3DXMatrixTranspose(&mat, &mat), 4);
g_pd3dDevice->SetVertexShaderConstant(4, D3DXMatrixTranspose(&mat, &matView), 4);
g_pd3dDevice->SetVertexShaderConstant(8, D3DXMatrixTranspose(&mat, &matProj), 4);
Metodę już opisywałem i wiemy doskonale jak działa. Niepokój może budzić jedynie wywołanie groźnie wyglądającej
funkcji z biblioteki
D3DX
-
D3DXMatrixTranspose()
. Po cóż nam to takiego. Funkcja ta dokonuje transpozycji macierzy,
czyli zamiany kolumn z wierszami, wiersze stają się kolumnami, kolumny wierszami. Dlaczego to robimy? Całe zamieszanie
bierze się ze sposobu, w jaki przedstawiamy macierz, wektor i jak działa pewna instrukcja
m4x4
shadera. Chodzi o to, aby po
przekazaniu shaderowi macierzy oraz wierzchołka znajdowały się one w odpowiedniej do siebie relacji jeśli chodzi o układ
kolumn i wierszy w obu przypadkach. No i oczywiście o instrukcję shadera. A tak się składa, że omawiana instrukcja do
mnożenia wierzchołka przez macierz wymaga, aby macierz, przez którą mnożymy wierzchołek, była przetransponowana
ponieważ, tak jak przedstawiona jest w strukturach samego Direct3D, shaderowi po prostu nie odpowiada. Instrukcja musi
mieć po prostu podaną macierz w inny sposób niż jesteśmy przyzwyczajeni i koniec, nic na to nie poradzimy. Dlatego przed
przekazaniem shaderowi, musimy dokonać prostej operacji zamiany kolumn na wiersze i odwrotnie. Tutaj warto sobie
zapamiętać, żeby dokładnie analizować działanie shaderowych instrukcji, zawłaszcza pod kątem sposobu wykonania operacji
(w dokumentacji znajdziemy zawsze kawałek pseudokodu, który pokazuje jak się odbywa działanie na poszczególnych
składowych). Unikniemy w ten sposób na pewno wielu frustracji i będziemy mieli jasność dlaczego tak a nie inaczej.
Ale wracając co naszego kodu. Jak widzimy, przekazujemy do rejestrów pamięci stałej trzy macierze. Pierwszą jest
zmieniona macierz świata (jest mnożona za każdym razem, tak aby cały świat się obracał) oraz macierze widoku i projekcji.
Jak są wykorzystane w vertex shaderze to już wiemy. Funkcja
D3DXMatrixTranspose()
transponuje macierz podaną jako
drugi parametr i umieszcza wynik w dwóch miejscach - pierwszy to pierwszy argument wywołania tej funkcji, drugim jest
wartość zwracana przez samą funkcję. Dlatego też możemy użyć wywołania tej funkcji jako argument naszych metod
urządzenia do ustawiania wartości rejestrom pamięci stałej. No i to w zasadzie tyle. Za każdym wywołaniem funkcji
Render() wywołuje się funkcja SetupFrame() a co za tym idzie zmienia się macierz świata i nasz sześcian a w zasadzie
wszystko co umieścimy na scenie będzie się obracać. Spójrzmy jeszcze tylko na wywołanie funkcji Render(). W zasadzie
wszystko wygląda tak samo prócz jednej linijki.
g_pd3dDevice->SetVertexShader( VertexShader );
Co to oznacza, chyba już nie muszę nikomu tłumaczyć. Zastąpimy sobie standardowy taśmociąg geometrii naszym własnym,
który właśnie zdefiniowaliśmy sobie w naszym vertex shaderze. To, że wygląda wszystko tak samo jak wcześniej jest
zasługą tylko i wyłącznie kodu naszego shadera. Jeśli tylko spróbowalibyśmy w kodzie wpłynąć w jakiś "widoczny" sposób
na nasze wierzchołki czy to zmieniając kolor, pozycję czy współrzędne tekstury uwidoczni się to natychmiast. I nie będziemy
musieli zupełnie nic kombinować w kodzie naszego programu! Teraz tylko wszystko zależy od Waszej wyobraźni i chęci
nauczenia się, że o zrozumieniu nie wspomnę. No i cóż, lekcja była ciężka, dosyć trudna trzeba przyznać, ale sami
powiedzcie czyż nie ciekawa? Jak dla mnie, przy niej wszystkie poprzednie to się chowają. Oczywiście aby zobaczyć czy
program przykładowy to aby ten sam i nie oszukuję - macie obrazek poniżej.