1
DirectX ▪ Vertex shader 1
No cóż. Przykłady sypią się jak ulęgałki z drzewa, więc nie ma na co czekać. Pędzimy do przodu niczym rakieta i dziś
bierzemy się za nową, zupełnie odlotową i kosmiczną rzecz, czyli vertex shader! Może wielu z Was powie, że powinienem
najpierw napisać coś o mapowaniu środowiskowym (ang. environment mapping) czy mapowaniu wypukłości (ang. bump
mapping), ale doszedłem do wniosku, że jednak lepiej będzie najpierw powiedzieć coś o vertex shaderze a dopiero potem
brać się za kolejne techniki.
Czymże zatem jest ta rzecz o kosmicznie brzmiącej nazwie? Microsoft tłumaczy, że vertex shader kontroluje ładowanie i
przetwarzanie wierzchołków. Ale co to znaczy? Jeśli czytaliście dokumentację do Direct3D, pewnie nie raz spotkaliście się z
terminem "rendering pipeline". Cóż to jest takiego? Mi kojarzy się to z taśmociągiem, po którym jadą wierzchołki - z
pamięci, w której są przechowywane, na ekran. Na tym taśmociągu są jednak przystanki, w których wierzchołki te
przechodzą pewne przekształcenia, aby w końcowej fazie można ujrzeć na ekranie coś fajnego. Teraz przetłumaczę kawałek
SDK, ale myślę, że nikt się nie obrazi ;-). Tworzymy nasz świat jako zbiór wierzchołków. Świat ten zawiera definicje
wielkości obiektów, ich wzajemne położenie w przestrzeni oraz położenie obserwatora. Direct3D przekształca ten opis na
zbiór pikseli na ekranie. Ten pierwszy etap - przekształcania opisu świata na płaski obraz na ekranie - nazywa się (tutaj
załóżmy dla uproszczenia, że "pipeline" to nie rurociąg a raczej taśmociąg ;-) taśmociągiem geometrii, nazywanym również
taśmociągiem przekształceń. Po przejściu przez ten taśmociąg, dane pikseli są użyte do przeprowadzenia takich operacji jak
multiteksturowanie (ang. multitexturing) czy mieszanie (ang. blending). Są przeprowadzane różne operacje wykorzystujące
rozmaite bufory, ale będzie jeszcze okazja to omówić. My natomiast teraz zajmiemy się bardziej szczegółowo taśmociągiem
geometrii.
W Direct3D mamy dwa rodzaje taśmociągów geometrii. Jeden, dobrze nam już znany, to taśmociąg o określonej z góry
kolejności (ang. fixed) dokonywania przekształceń. Wielu z Was zna doskonale pokazany powyżej rysunek. Pokazuje on ten
taśmociąg oraz kolejne przekształcenia dokonywane na jadących w nim wierzchołkach. Direct3D określa obiekty i
obserwatora w świecie, przeprowadza rzutowanie na ekran oraz dokonuje obcinania wychodzących poza obszar widoku
wierzchołków. Na tym taśmociągu dokonywane są też obliczenia dotyczące oświetlenia, aby móc określić kolor oraz ilość
odbijanego światła przez wierzchołki. Mówiliśmy już sobie o tym, ale niezbyt dokładnie, więc teraz dowiemy się, co się
dzieje po kolei z naszymi wierzchołkami, które my każemy przetwarzać naszemu Direct3D. Na samym początku wrzucamy
na taśmociąg nasze wierzchołki. Po chwilowej jeździe, wierzchołki te napotykają na trzy podstawowe przekształcenia, które
my już sobie omawialiśmy - są to przekształcenia świata, widoku i rzutowania. Następnie wierzchołki dojeżdżają do
przystanku zwanego "obcinanie", który odrzuca wierzchołki niewidoczne (nie mieszczące się na ekranie) i po jego minięciu,
wierzchołki trafiają prosto do rasteryzera - czyli urządzenia, które spowoduje, że pojawią się one na ekranie. Teraz w
szczegółach: kiedy wrzucamy wierzchołki na taśmę, są one umieszczone w swoim własnym, lokalnym układzie
współrzędnych, mają własną orientację i położenie. Dane te nazywane są współrzędnymi modelu a jego położenie i
orientacja nazywana jest przestrzenią modelu. Na pierwszym przystanku na taśmie wszystkie dane wierzchołków są
przekształcane ze swojego układu współrzędnych, na układ używany przez wszystkie obiekty na scenie. Proces tego
przekształcania nazywany jest przekształceniem świata (ang. world transformation). Każdy wierzchołek od teraz nie używa
już swojego własnego układu współrzędnych czy orientacji - wszystko jest umieszczone przestrzeni wspólnego świata i
wszystkie wierzchołki mają współrzędne pasujące do tego ogólnego świata. Po tym przekształceniu wierzchołki udają się w
dalszą podróż po taśmie. Napotykają na swojej drodze kolejny przystanek, jakim jest przekształcenie widoku. Tutaj nasz
świat jest orientowany (ustawiany) względem kamery obecnej na scenie. Ponieważ mamy punkt widzenia, więc wszelkie
nasze wierzchołki są przemieszczane i obracane wokół widoku kamery i są przenoszone niejako z przestrzeni świata do
przestrzeni kamery. Po obejrzeniu sobie naszych wierzchołków przez kamerę, jedziemy dalej po taśmie no i trafiamy w
bardzo nieprzyjemne miejsce. Na tym przystanku, pomimo usilnych protestów naszych kochanych milusińskich, zostają one
stłoczone z trzech wymiarów do dwóch, czyli do płaskiego, zupełnie nieciekawego świata. Obiekty zostają przeskalowane ze
względu na swoją odległość od obserwatora, aby osiągnąć w ostatecznym rozrachunku złudzenie głębi. Bliższe obiekty
wydają się być większe, leżące dalej są mniejsze. To się nazywa rzutowanie a całe zamieszanie to przekształcenie projekcji
(ang. projection transformation). Na ostatnim przystanku wszystkie wierzchołki, które niestety nie miały szczęścia znaleźć
się w polu widzenia naszej kamery, są usuwane i rasteryzer nie liczy dla nich kolorów czy cieniowania. Nie ma sensu
2
DirectX ▪ Vertex shader 1
poświęcać czasu dla czegoś, co i tak nie będzie widoczne. Ten proces nazywa się obcinaniem (ang. clipping). Po fazie
obcinania, pozostałe wierzchołki są skalowane według parametrów widoku i są przekształcane na współrzędne na ekranie. W
efekcie, kiedy scena jest malowana przez rasteryzer, na ekranie otrzymujemy tylko widoczne wierzchołki.
DirectX 8.0 wprowadza nam zupełnie nową jakość, jeśli chodzi o taśmociąg geometrii. Od tej wersji będziemy mieli o wiele
większą kontrolę nad tym, co się dzieje z naszymi wierzchołkami. Będziemy mogli ustalać sobie, jakie będziemy mieć
przystanki i w jakiej kolejności. Poniższy rysunek, zresztą też doskonale Wam znany zapewne, pokazuje istotę takiej filozofii
działania. Nas w tym momencie interesuje tylko pierwsza część tego rysunku...
tam gdzie mamy taśmociąg geometrii. Jak widać, obok typowych operacji, jakie są przeprowadzane na wierzchołkach, mamy
tam taki mały, niewinnie wyglądający prostokącik noszący dumnie nazwę "vertex shader". Co to takiego w zasadzie jest? W
DirectX 8 proceduralnie (czyli my możemy o tym decydować) określane mogą być wszystkie operacje, jakie mają miejsce na
taśmociągu geometrii i oświetlenia a także na taśmociągu, na którym dokonywane jest mieszanie pikseli. Takie podejście do
sprawy, kiedy w sposób programowalny możemy określić zachowanie się naszego urządzenia ma oczywiście wiele zalet.
Po pierwsze - umożliwia bardziej ogólną składnię programu do określania zwykle przeprowadzanych operacji. Model o
ustalonej kolejności przetwarzania musi definiować modele, flagi oraz inne rzeczy dla rosnącej ciągle liczby operacji, które
muszą być wykonane. Co gorsze, wraz z rosnącą mocą naszego sprzętu - więcej kolorów, tekstur, strumieni wierzchołków i
całej reszty, operacje, które muszą zostać pomnożone przez przyrost danych, stają się coraz bardziej skomplikowane. W
przeciwieństwie do tego, model programowalny umożliwia przeprowadzanie prostych operacji, takich jak pobieranie
kolorów czy tekstur w bardziej bezpośredni sposób. Nie musimy się już przedzierać przez wszystkie możliwe tryby pracy
urządzenia, aby znaleźć ten właściwy. My musimy się tylko dowiedzieć, jak działa nasz sprzęt i zażądać od niego, aby
przeprowadził on zadany przez nas algorytm. Co możemy robić za pomocą takiego programowalnego przetwarzania? Oto
kilka dobrze znanych nam rzeczy:
• podstawowe przekształcenia geometryczne,
• proste oświetlanie obiektów,
• mieszanie wierzchołków (co to jest, dowiemy się kiedyś),
• morfing wierzchołków (pamiętacie delfina z przykładów?),
• przekształcenia tekstur,
• generowanie tekstur,
• mapowanie środowiskowe (ang. environment mapping).
Po drugie - programowalne podejście umożliwia łatwą implementację nowych operacji (naszych chorych pomysłów).
Programiści często podczas pracy dowiadują się, że muszą zrobić coś, ale konkretne API nie posiada akurat tej rzeczy. I
największy ból to ten, że to nie brak możliwości urządzenia, ale właśnie ograniczenia posiadanego przez programistę API
uniemożliwiają mu realizację jego zamiarów. Ogólnie rzecz biorąc, programowalne operacje są o wiele prostsze niż próby
ich przeprowadzenia z wykorzystaniem ustalonego taśmociągu. To, co będziemy mogli robić już w niedalekiej przyszłości,
to:
3
DirectX ▪ Vertex shader 1
• animacja postaci żywych,
• oświetlenie anizotropowe - teraz możemy robić tylko za pomocą tekstur,
• realistyczne modelowanie skóry, różnych rozciągliwych powłok,
• światła, które mogą wnikać pod powierzchnię,
• geometria proceduralna (np. mięśnie poruszające się pod skórą),
• modyfikowanie siatki na podstawie mapy bitowej (ang. displace).
Po trzecie - skalowalność i ewolucja. Jak widać, sprzęt na przestrzeni ostatnich kilku lat, rozwija się bardzo gwałtownie i
takie programowalne podejście pozwala dostosować posiadane API do możliwości sprzętu. Nowe właściwości i cechy mogą
być dodane na rosnącą ciągle ilość sposobów poprzez:
• dodawanie nowych instrukcji,
• dodawanie nowych wejść dla danych,
• dodawanie nowych właściwości dla ustalonego trybu przetwarzania jak i programowalnego.
Po czwarte - podejście proceduralne oferuje programistom coś bliższego skóry. Wiadomo, że bardziej znają się na
programowaniu niż na sprzęcie. API, które w pełni zaspokoi potrzeby programistów, powinno móc przenieść funkcjonalność
sprzętu na dostępny kod.
Po piąte - podejście proceduralne to krok w stronę renderingu fotorealistycznego. Przez wiele lat stosowano programowalne
shadery w takim sposobie renderingu. Ogólnie mówiąc, ten sposób nie jest ograniczony przez wydajność sprzętu, więc takie
programowalne podejście staje się na dziś celem ostatecznym, jeśli chodzi o techniki renderingu.
Po szóste i ostatnie - podejście proceduralne umożliwia bezpośrednie przeniesienie kodu na sprzęt. Większość obecnego
dzisiaj sprzętu 3D może być w jakiś określony sposób programowana, jeśli chodzi o przekształcanie wierzchołków.
Możliwość programowania urządzenia za pomocą API umożliwia przeniesienie aplikacji bezpośrednio na sprzęt. Umożliwia
nam zarządzanie zasobami sprzętu według wymagań aplikacji. Za pomocą ograniczonego zbioru rejestrów lub instrukcji
może zostać to wykonane. Natomiast trudniej jest zrobić funkcję o określonym przebiegu, która mogłaby wykonywać
wszystkie operacje niezależnie. Jeśli włączymy sobie do pakietu zbyt wiele funkcji wymagających zasobów shadera, mogą
one przestać działać i będą powodować różne dziwne zachowania. Model programowalnego API jest kontynuacją tradycji
DirectX-a, która miała na celu eliminowanie problemów poprzez umożliwienie programiście zwrócenie się bezpośrednio do
sprzętu i powodując zniesienie takich ograniczeń.
Jeśli włączymy nasz vertex shader, standardowy moduł do przeprowadzania transformacji i oświetlenia w Direct3D zostaje
przez niego zastąpiony na taśmociągu geometrii. W efekcie standardowe informacje (ustawienia) odnośnie transformacji i
oświetlenia są ignorowane przez Direct3D. Kiedy nasz shader wyłączymy i funkcja o ustalonej kolejności jest na powrót
włączona, wszystkie aktualne ustawienia są oczywiście przywracane. Na wyjściu vertex shader musi wystawiać współrzędne
wierzchołków w jednorodnym układzie obcinania. Mogą być oczywiście generowane dodatkowe dane takie, jak:
współrzędne teksturowania, kolory, współczynniki mgły i podobne. Taśmociąg geometrii zawierający nasz shader powinien
wykonywać następujące zadania:
• przetwarzanie prymitywów,
• obcinanie ze względu na ustalone punkty widzenia i płaszczyzny obcinania,
• skalowanie widoku,
• jednorodne dzielenie,
• obcinanie tylnych powierzchni i widoku,
• ustawienia trójkątów,
• rasteryzacja.
Programowalna geometria jest jednym z trybów Direct3D API. Jeśli jest włączona, zastępuje częściowo taśmociąg po którym
jadą wierzchołki. Kiedy jest wyłączona, API ma normalną kontrolę nad tym co się dzieje z danymi tak, jak miało to miejsce
w DirectX 6.0 i 7.0. Wykonywanie vertex shaderów nie powoduje zmian wewnątrz samego Direct3D a żaden stan z wnętrza
Direct3D nie jest dostępny dla shadera.
4
DirectX ▪ Vertex shader 1
No dobra. Wszystko to brzmi tak strasznie naukowo i okropnie. Jak wielu się słusznie domyśla, jest żywcem wręcz zerżnięte
z dokumentacji do SDK. A o co tak naprawdę tutaj chodzi? Popatrzmy chwilę na kolejny rysunek. Mamy coś jakby procesor
(ALU - ang. Arithmetic Logic Unit), który posiada pewne wejście i wyjście. Cały bajer polega na tym, że ten procesor
możemy teraz programować. Dawniej wrzucaliśmy do tego procesorka odpowiednie dane i on sam już nam się z tym
załatwiał. Miał jakiś ustalony z góry program i wykonywał grzecznie po kolei zaprogramowaną z góry sekwencję rozkazów.
Teraz dostaliśmy do ręki możliwość zmiany tego programu. Manipulując odpowiednio rozkazami i danymi, będziemy mogli
osiągać trochę bardziej "rozrywkowe" efekty niż do tej pory, co najlepiej sobie obejrzeć w NVEffectBrowserze panów z
dobrzej nam znanej firmy. No ale wracając do sedna sprawy. Skoro mamy procesor, to muszą być rejestry i rozkazy, prawda?
Tak też jest w istocie. Procesor posiada cały zestaw bardzo pożytecznych rozkazów, które nie raz będzie okazja omówić.
Zawierają się w nich wszelkie operacje arytmetyczne oraz kilka przydatnych operacji znanych z grafiki 3D w "klasycznym"
wykonaniu (dla przykładu iloczyn skalarny). Mamy rozkazy, więc czas na rejestry. Procesor geometrii ma ich kilka
zestawów zgromadzonych w pewne grupy. Jak widać na rysunku, są to rejestry wejściowe, tymczasowe, rejestry zawierające
pewne stałe oraz rejestr adresu. Obecny jest także tak zwany wektor wyjściowy, złożony z komórek, które przechowują
konkretne dane dla wierzchołków, które idą sobie dalej po przekształceniu przez nasz procesorek. Każdy vertex shader
definiuje funkcję, która jest aplikowana każdemu wierzchołkowi po kolei na scenie. Nie ma takiej sytuacji, że wierzchołki są
przetwarzane równolegle. Po prostu jeden wierzchołek wchodzi, jest obrabiany a następnie sobie wychodzi. Vertex shader
nie dokonuje operacji projekcji i ustawiania wierzchołków w widoku. Obcinanie wierzchołków i składanie ich w bryły też
nie jest przez niego dokonywane. Wszystko dzieje się już po samym fakcie zadziałania shadera.
Jak już wspominałem, w Direct3D 8 mamy dwa rodzaje taśmociągu geometrii. Jeden o ustalonej z góry kolejności operacji,
który ma taką samą funkcjonalność jak ten, obecny w DirectX 7.0, który zawiera transformacje, oświetlenie, mieszanie
wierzchołków oraz generację współrzędnych mapowania. W przeciwieństwie do vertex shadera, gdzie operacje wykonywane
na wierzchołkach są definiowane w jego obrębie, przetwarzanie wierzchołków w ustalonej kolejności jest kontrolowane
poprzez stan urządzenia renderującego za pomocą metod jego obiektu. Ustawiają one oświetlenie, transformacje i wszystko
co potrzebne. Wektor wejściowy dla przetwarzania o stałej kolejności przekształceń ma z góry ustaloną składnię. Dlatego też
deklaracje wierzchołków określają je za pomocą współrzędnych, koloru, normalnej i tak dalej. Dane wyjściowe dla
wierzchołków, które są przetwarzane przez funkcję o ustalonej kolejności przetwarzania, zawsze zawierają na wyjściu
współrzędne, kolor, wszystkie współrzędne teksturowania, które są wymagane przez aktualny stan urządzenia.
Programowalny vertex shader ma funkcję przetwarzającą, zdefiniowaną jako tablicę instrukcji, która to tablica jest
aplikowana każdemu wierzchołkowi podczas przetwarzania. Zależność pomiędzy tymi danymi, które przychodzą z aplikacji
do rejestrów wejściowych vertex shadera, jest zdefiniowana poprzez tzw. "deklarację shadera", ale dane te nie mają jakiegoś
ściśle określonego formatu. Zinterpretowanie danych nadchodzących należy tylko i wyłącznie do instrukcji zawartych w
vertex shaderze. Dane wyjściowe są wpisywane rejestrów wyjściowych także poprzez instrukcje zawarte w shaderze.
"Deklaracja vertex shadera" - definiuje zewnętrzny interfejs shadera, który będzie połączony z nadchodzącymi danymi. Taka
deklaracja zawiera między innymi:
• połączenie strumienia danych do rejestrów wejściowych shadera. Informacja ta definiuje typ i rejestr wejściowy dla
każdego elementu zawartego w strumieniu danych. Typ określa po prostu rodzaj danych - czy jest to liczba
całkowita, zmiennoprzecinkowa czy może wektor oraz, co za tym idzie, ich rozmiar. Wszystkie elementy
strumienia, które są mniejsze od czterech (mają mniej niż cztery elementy - np. współrzędne to trzy wartości) są
uzupełniane do czterech przez wartości 0 i 1.
5
DirectX ▪ Vertex shader 1
• łączy wejściowe rejestry vertex shadera z danymi niejawnymi pochodzącymi od takiego wewnętrznego urządzonka,
zwanego teselatorem prymitywów. Urządzonko to powoduje podział większych figur na pojedyncze trójkąty
strawne dla shadera. To umożliwia kontrolę ładowania danych wierzchołków, które nie pochodzą bezpośrednio z
bufora wierzchołków, ale tworzonych na przykład podczas teselacji (podziału) prymitywów.
• deklaracja ładuje pewne wartości do stałej pamięci, kiedy shader jest ustawiany jako bieżący. Każdy element
ładowany do takiej stałej pamięci zawiera wartości zapisywane do jednego lub wielu, ciągłych (występujących po
sobie) stałych rejestrów o wielkości 4 słów (
DWORD
) każdy.
Wróćmy na chwilkę do naszego obrazka z pseudo procesorkiem. Widzimy tam zestaw rejestrów wejściowych, które są
wiązane za pomocą deklaratora z danymi wejściowymi, Są także rejestry tymczasowe, które będą służyć do przechowywania
jakiś naszych tymczasowych obliczeń oraz dokonywania operacji, które mogą być przeprowadzane tylko na rejestrach.
Widzimy równie rejestr adresowy (nas na razie nie interesuje, ale wspomnimy o nim później) oraz rejestry pamięci stałej, o
których była mowa przed momentem. Na wyjściu, które niełatwo przegapić, otrzymujemy dane, które wykorzystamy do
naszych niecnych celów. Powiedziałem trochę o deklaratorze, dowiedzmy się więc jak to działa w praktyce.
Jak napisałem wyżej, deklarator łączy napływający strumień danych z rejestrami wejściowymi. Co to oznacza? Załóżmy, że
płynie sobie strumyczek bajtów, które niosą sobie tylko wiadomą informację. Deklarator umożliwi shaderowi określenie,
które spośród tych danych posłużą mu do poszczególnych obliczeń. Przyjrzyjmy się może deklaratorowi, ktorego my
użyjemy w naszym pierwszym vertex shaderze:
// first vertex shader
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()
};
Jak nietrudno zauważyć, deklarator to tablica wartości typu
DWORD
(0xFFFFFFFF). Wartości te są w jakiś tam sposób
przydatne shaderowi, który na ich podstawie będzie potrafił przypisać określonemu rejestrowi wejściowemu, którąś z danych
w nadchodzącym strumieniu. Jak też nietrudno zauważyć, w tablicy tej nie mamy wartości (przynajmniej na razie), ale
wywołania pewnych, tajemniczych makr, zwanych makrami deklaratora. Cóż będą oznaczać poszczególne z nich?
D3DVSD_STREAM(0)
Domyśleć się jest bardzo łatwo. Po prostu będzie to numer strumienia (jako argument makra), z którego vertex shader będzie
pobierał dane na swoje potrzeby i przetwarzał je zgodnie ze swoim programem. Makro to weźmie numer naszego strumienia,
zmiesza to z czymś i umieści w tablicy deklaratora jako pewną liczbę. Shader widząc taką liczbę, będzie wiedział, że to
nakazuje mu pobierać dane ze strumienia o numerze 0.
D3DVSD_REG( D3DVSDE_POSITION, D3DVSDT_FLOAT3 )
D3DVSD_REG( D3DVSDE_DIFFUSE, D3DVSDT_D3DCOLOR ),
D3DVSD_REG( D3DVSDE_TEXCOORD0, D3DVSDT_FLOAT2 ),
Te makra mówią shaderowi, do którego rejestru wejściowego ma pobrać dane lecące strumieniem oraz ile tych danych ma
być. W tym przypadku będziemy mieć trzy różne wartości. Gdybyśmy popatrzyli na strumień danych pędzący po ścieżkach
naszej karty, widzielibyśmy tylko ciąg bajtów. Shader w zasadzie widzi tak samo, dla niego jest to tylko strumień nic nie
znaczących bajtów. Ale dzięki tym makrom dowie się on teraz, jak z tego chaosu wyłowić, coś co się nada do dalszych
obliczeń. Nie powiedzieliśmy sobie jeszcze o znaczeniu rejestrów, ale o nich może za chwilę dokładniej. My musimy
podeprzeć się tutaj naszą wyobraźnią, aby jakoś wybrnąć, ale obiecuję, że już za moment wszystko będzie jasne.
Przedstawione powyżej pierwsze makro mówi shaderowi tak: słuchaj, weź 3 wartości typu
float
(stała
D3DVSDT_FLOAT3
) z nadchodzącego strumienia i umieść je w rejestrze, w którym powinny być współrzędne
wierzchołków (stała
D3DVSDE_POSITION
). Następne makro sugeruje shaderowi, aby wziął cztery bajty (
D3DVSDT_D3DCOLOR
) i umieścił je w rejestrze, w którym przechowuje kolor wierzchołka. Na samym końcu widzimy
makro, które w ciągu bajtów znajduje współrzędne mapowania tekstury na poziomie 0 (
D3DVSDE_TEXCOORD0
), które
będą potrzebne shderowi. Wszystkie te konstrukcje, dzięki makrom, zostaną zastąpione liczbami
DWORD
(bo taką mamy
tablicę!) i tam też zmagazynowane. Shader dostając taką tablicę, sobie tylko znanymi sposobami będzie potrafił
zinterpretować odpowiednio znajdujące się tam informacje a nasze wierzchołki zaczną się zachowywać w nader przedziwne
sposoby. No ale to już będzie zależało już tylko i wyłącznie od naszej wyobraźni.
Funkcja.
Powiedzieliśmy sobie trochę o deklaratorze, więc trzeba coś napomknąć o funkcji vertex shadera. Co to znowu jest takiego.
Pamiętacie rozważania o taśmociągach geometrii? Na taśmociągu o ustalonej kolejności przetwarzania wszystkie operacje
były z góry zaprogramowane i były wykonywane w określonej kolejności. Nasz taśmociąg, z programowanym
przetwarzaniem wierzchołków będzie musiał poradzić sobie sam, bez pomocy funkcji, która jest stosowana w standardowych
przypadkach. Ale to nic złego a może nawet lepiej! My napiszemy sobie własną, o wiele, wiele lepszą, która pozwoli nam
rozwinąć naszą wyobraźnię w sposób dotychczas niespotykany! Ale zanim zajmiemy się tworzeniem funkcji shadera,
musimy powiedzieć sobie sporo o instrukcjach i rejestrach.
6
DirectX ▪ Vertex shader 1
Rejestry.
Tak w zasadzie trudno powiedzieć czym tak naprawdę jest vertex shader. Oglądając obrazki i czytając ten artykuł, możemy
odnieść dwojakie wrażenie. Z jednej strony jest to pewien program (jeśli myślimy o jego użyciu). Deklarujemy wierzchołki,
uruchamiamy urządzenia, robimy całą potrzebną inicjalizację, uruchamiamy nasz vertex shader i działamy. Z drugiej strony
myślimy o nim jako o procesorze, który można zaprogramować w określony sposób, Teorię tę potwierdza między innymi ten
fragment, w którym omówimy sobie rejestry. Rejestry nierozerwalnie kojarzą się nam z kostką krzemu, która będzie
wykonywać co tylko nam się zachce. Przestawmy więc nasze myślenie na właśnie takie to może łatwiej nam to do głowy
wejdzie. Jak zwykle też zachęcam do spojrzenia na nasz słynny już rysuneczek przedstawiający ideę vertex shadera. Rejestry
wejściowe już wiemy do czego służą (przynajmniej tak połowicznie). Po pierwsze - odbierają ze strumienia dane, w sposób
ustalony w deklaratorze, czyli do określonego rejestru trafia określona część strumienia. Co się potem z takimi danymi
dzieje? Gdybyśmy mieli taśmociąg z ustaloną kolejnością, dane zostałyby pobrane z tych rejestrów, wykonane zostałyby na
nich pewne operacje, które są kontrolowane przez stan urządzenia renderującego i zostałyby wyrzucone jako wektor
wyjściowy, gotowe do dalszego przetwarzania. My nie będziemy korzystać z ustalonego trybu przetwarzania, więc... musimy
napisać sobie własną funkcję przetwarzania. Aby jednak to zrobić, trzeba coś zrobić z danymi wejściowymi i gdzieś podziać
dane wyjściowe. Do tego celu użyjemy właśnie rejestrów. Mamy cztery rodzaje rejestrów - wejściowe, wyjściowe,
tymczasowe i rejestry pamięci stałej. Istnieje jeszcze rejestr adresowy, ale o nim na końcu. Rejestry wejściowe służą do
pochwycenia ze strumienia odpowiedniej ilości bajtów i dane będą potem przekształcone za pomocą naszej funkcji. Tak w
zasadzie to rejestrami wejściowymi nazywane są także rejestry tymczasowe, rejestry pamięci stałej oraz rejestr adresowy i
żeby sobie niepotrzebnie nie mieszać w głowie pozostańmy przy takiej terminologii. Czym tak w ogóle jest rejestr? Mieścił
on będzie zawsze cztery liczby typu
float
, dlatego, aby możliwe było przechwycenie i przechowanie odpowiedniej ilości
danych. Macierze używane w Direct3D do przekształceń będą miały rozmiar 4x4, kolor będziemy podawać jako 4 składowe
(
RGBA
), współrzędne wierzchołków zawierać będą trzy składowe (x, y, z) oraz wartość do przeliczania na współrzędne
jednorodne, właściwie prawie wszystkie dane będą w takim właśnie formacie. Jeśli jakaś dana będzie miała mniej
składowych (na przykład współrzędne tekstury), to po wpisaniu do rejestru pozostałe składowe zostaną dopełnione wartością
0.0 lub 1.0, ale tym to już nie musimy się przejmować. W zależności od tego, do czego służą rejestry, mają one odpowiednie
oznaczenia. Rejestry pobierające dane ze strumienia zaczynają się od "v", rejestry tymczasowe to "r", rejestry pamięci stałej
to "c". Jedynym rejestrem nie będącym wektorem czterech wartości
float
jest rejestr adresowy oznaczony jako "a", który
zawiera jedną wartość całkowitą. Direct3D ma także pewne ograniczenie, dotyczące udziału rejestrów w poszczególnych
rozkazach. W zależności od rodzaju rejestru, w jednej instrukcji może wystąpić od jednego do kilku rejestrów. Na przykład
rejestry oznaczone jak "vn" (gdzie n to kolejna liczba), mogą wystąpić w instrukcji tylko raz. Nie jest więc dopuszczalna
instrukcja na przykład
add r0, v0, v1
, ponieważ mamy tu już dwa rejestry "vn". Na pierwszy ogień omówmy sobie
rejestry oznaczone jako "v". Kolejne rejestry będą oznaczone jako "v" + kolejna liczba, czyli v0, v1, v2. Rejestrów tych
mamy 16 i każdy z nich jest tylko do odczytu, zapamiętajmy sobie dobrze - tych rejestrów nie można zapisywać! Każdy z
nich ma predefiniowane przeznaczenie i poniższa tabela zawiera to zestawienie:
opis rejestr
pozycja wierzchołka
v0
waga mieszana
v1
indeksy mieszania
v2
normalna
v3
wielkość punktu
v4
kolor wierzchołka (diffuse)
v5
kolor odbicia (specular)
v6
współrzędne tekstury 0
v7
współrzędne tekstury 1
v8
współrzędne tekstury 2
v9
współrzędne tekstury 3
v10
współrzędne tekstury 4
v11
współrzędne tekstury 5
v12
współrzędne tekstury 6
v13
współrzędne tekstury 7
v14
pozycja wierzchołka 2
v15
normalna 2
v16
Teraz czas na rejestry tymczasowe, oznaczone jako "r" + liczba. Rejestrów tych jest 12 i można je w każdej chwili zapisywać
i odczytywać. Mogą one wystąpić jako wszystkie trzy argumenty instrukcji. Nie mają one jakiegoś specjalnego
przeznaczenia, służą do przechowywania wyników operacji i przekazywania ich do następnych.
7
DirectX ▪ Vertex shader 1
Rejestry pamięci stałej, oznaczone jako "cn". Mamy maksymalnie 96 czteroelementowych (liczby
float
jak wiemy) rejestrów
pamięci stałej, w których możemy zawrzeć dosłownie wszystko co nam się podoba. Nie jest to jednak prawda na każdej
karcie, musimy sprawdzić strukturę
D3DCAPS8
w celu zbadania ile ich tak naprawdę mamy do dyspozycji, ale na pewno i
tak nam to wystarczy do przechowywania potrzebnych nam danych. Co takiego będziemy mogli przechowywać w tych
rejestrach? Poszczególne wiersze macierzy przekształceń, jakieś wartości potrzebne do obliczeń (na przykład liczbę PI),
dobrym pomysłem jest zapisać sobie rozwinięcie sinusa i cosinusa (z szeregu Taylora), ponieważ w asemblerze nie ma
rozkazów do obliczania funkcji trygonometrycznych, czy jakiekolwiek, inne niezbędne do działania naszego shadera
wartości. Kiedy piszemy funkcję vertex shadera, rejestry stałe są tylko do odczytu i mogą wystąpić tylko raz w instrukcji. Ale
wcześniej musimy mieć przecież możliwość zapisu do tych rejestrów pewnych, potrzebnych nam wartości. Jak to robić,
powiem przy okazji omawiania naszego programu, a właściwie jego elementów dotyczących vertex shadera.
Rejestr adresowy, oznaczony jako "a" jest jednym, malutkim biednym rejestrem przechowującym pewną wartość, która
oznacza przesunięcie. Używany on jest do odczytu rejestrów pamięci stałej - "cn". Zmieniając zawartość rejestru a można
"przesuwać" się po pamięci stałej i odczytywać jakieś określone miejsca z rejestrów pamięci stałej. Jeśli zajdzie konieczność
użycia czegoś takiego, to Wam opiszę przy omawianiu programu shadera, na razie nie musimy zawracać sobie tym głowy.
Omówiliśmy sobie rejestry wejściowe, czas więc na rejestry wyjściowe. Jak sama nazwa wskazuje, będą one przechowywać
wartości przeznaczone do wyrzucenia na ekran. Te rejestry są inaczej nazywane wejściami rasteryzera, ponieważ stamtąd już
mają naprawdę niedaleko na ekran. Wygenerowane przez nasz shader dane są zapisywane do zbioru rejestrów wyjściowych,
które mają oczywiście atrybut "tylko do zapisu", nie można z nich czytać danych, no bo i po co, lepiej żeby nas nie kusiło.
Rejestry wyjściowe mają nieco inną koncepcję nazewnictwa. Zaczynają się na "o", zapewne od "output" (czyli wyjście),
potem następuje wielka litera oznaczająca co dany rejestr przechowuje a następnie, jeśli jest to możliwe, numer rejestru
danego typu. W wersji 8.0 Direct3D mamy do dyspozycji co następuje:
•
oDn
- są to rejestry, które są przeznaczone do przesłania do nich koloru wierzchołków. Załóżmy dla przykładu, że
piszemy nowe, super oświetlenie. Będzie tam na pewno trzeba obliczać kolory wierzchołków na podstawie pewnych
danych. Te właśnie obliczone kolory będziemy umieszczać w rejestrach oDn, a zwłaszcza
oD0
, bo z drugiego to nie
wiem, czy kiedykolwiek zdarzy się nam korzystać.
•
oFog
- rejestr odpowiedzialny za mgłę. Wpisywać do niego będziemy współczynnik mgły, na podstawie którego
będzie ona obliczana i tworzona mgła tablicowana. Jest to rejestr, który przechowuje tylko jedną wartość typu
float
.
•
oPos
- rejestr, który będzie zawierał pozycję wierzchołka po przetworzeniu go przez shader. Jeśli na przykład
napiszemy shadera, który zrobi to samo co standardowa funkcja o ustalonej kolejności przetwarzania, czyli tylko
pomnoży go przez macierze przekształceń, to wynik ma się znaleźć tutaj, w innym przypadku po prostu nic nie
zobaczymy na ekranie.
•
oPts
- rejestr przechowuje rozmiar punku stawianego na ekranie (wierzchołka), podobnie jak w przypadku rejestru
oFog
zawiera on tylko pojedynczą wartość.
•
oTn
- rejestry przechowujące poszczególne pary współrzędnych tekstur. Są to cztery rejestry, każdy podejrzewam
przechowujący po dwie pary, ale za to ręki uciąć sobie nie dam. Najlepiej sprawdzić na własnej skórze.
Jak widzimy, rejestrów wyjściowych jest bardzo niewiele, ale to powinno nas tylko cieszyć. Odpowiednia manipulacja
nadchodzącymi danymi, jakiś niebanalny pomysł na shader i zobaczycie, że naprawdę będzie można robić cudeńka.
Wystarczy ściągnąć sobie ze strony NVidii NVEffectBrowser i pooglądać co ludzie potrafią wymyślać. I jak tak sobie myślę
to największym problemem wcale nie jest "jak?", tylko "co?". No ale to tylko taka moja filozofia.
Instrukcje.
Wracając zaś do rzeczywistości - mamy już omówione deklaratory i rejestry, więc przyszedł czas na instrukcje. Właściwie
powinienem Was odesłać do dokumentacji, no ale skoro już zaczęliśmy, to brnijmy w to dalej, aż do samego końca, kto wie
co z tego potem będzie? ;-) Program vertex shadera może zawierać nie więcej niż 128 instrukcji. Dlaczego takie
ograniczenie? Nie wiem szczerze mówiąc, najprawdopodobniej jest to ograniczone pojemnością jakiejś pamięci, ale może
ktoś rozbierał kartę i wie coś na ten temat, to się podzieli. Mogę za to zapewnić, że nawet relatywnie tak mała liczba
zapewnia nam spore możliwości i na razie nie ma się co stresować, no chyba tylko tym, że nie wystarczy nam wyobraźni,
aby tyle możliwości wykorzystać. Instrukcje mogą przyjmować maksymalnie 3 argumenty do operacji, choć zależy to
oczywiście od rozkazu. Dokładny opis wszystkich instrukcji znajdziecie oczywiście w dokumentacji, ja napiszę tylko, co tak
orientacyjnie mogą one robić a resztę doczytacie sobie sami, albo jeśli chcecie, to zrobimy jakiś reference online po polsku z
opisem co do czego. Jeśli tak - czekam na odzew. Instrukcje dostępne w asemblerze możemy podzielić na takie trzy kategorie
- instrukcje ogólne, definiowanie wersji i stałych oraz bardzo pomocne makra. Zaczniemy od instrukcji ogólnego
przeznaczenia:
•
add
- nic prostszego, dodanie dwóch argumentów,
•
dp3
- iloczyn skalarny wektorów złożonych z trzech wartości,
•
dp4
- to samo, ale dla wektorów posiadających cztery współrzędne,
•
dst
- oblicza odległość wektorów,
•
expp
- podnoszenie do potęgi,
8
DirectX ▪ Vertex shader 1
•
lit
- instrukcja pomocna przy obliczaniu oświetlenia,
•
loqp
- obliczanie logarytmu,
•
mad
- pomnożenie i dodanie
•
max
- maksymalna wartość,
•
min
- minimalna wartość,
•
mov
- przesłanie wartości,
•
mul
- pomnożenie wartości,
•
rcp
- rozkaz przydatny przy przeliczaniu na współrzędne jednorodne,
•
rsq
- to samo, tylko jeszcze obliczany jest pierwiastek kwadratowy,
•
sqe
- rozkazy do porównywania, ustaw jeśli większy lub równy,
•
slt
- ustaw, jeśli mniejszy,
•
sub
- odejmowanie.
Na każdym operandzie źródłowym może być dokonana zamiana jego składowych (potem wytłumaczę) oraz negacja podczas
odczytu. Zapis do rejestrów wynikowych może zawierać maskowanie poszczególnych składowych, tak że tylko określone
składowe wektora mogą zostać zmienione. Nie można dokonywać zamiany i negacji składowych wektorów podczas zapisu.
Ale o tym wszystkim powiem przy okazji omawiania programu.
Następną grupą instrukcji jest bardzo mały zbiorek zawierający tylko dwie. Grupa ta służy do definiowania stałych (na
przykład dla rejestrów pamięci stałej) oraz opisu wersji kodu shadera, którego będziemy używać. Instrukcja dotycząca wersji
jest wymagana na początku kodu każdego shadera natomiast instrukcje definiujące stałe muszą być wywoływane po
instrukcji dotyczącej wersji, ale przed wszelkimi innymi. Może też oczywiście ich nie być w ogóle, jeśli nie potrzebujemy
żadnych stałych. Mamy więc:
•
def
- instrukcja definiująca stałą,
•
vs
- instrukcja określająca wersję naszego shadera - musi ona być obecna w kodzie każdego shadera i musi być
wywoływana jako pierwsza.
Ostatnią rzeczą, o jakiej musimy się dowiedzieć, zanim przystąpimy do pisania shadera, są makra. Makra, podobnie jak w
każdym języku, są złożone najczęściej z kilku prostszych instrukcji i są bardzo pożyteczne. Nie inaczej jest w naszym
przypadku. Bardzo ważną rzeczą, o której trzeba wspomnieć, jest ilość instrukcji, które zostaną wykonane po wywołaniu
makra. W celu zagwarantowania tego, że nie przekroczymy regulaminowego rozmiaru 128 instrukcji, Direct3D gwarantuje
nam, że makra nie rozwiną się w więcej instrukcji, niż tyle, ile jest wymienione w ich szczegółowym opisie w dokumentacji.
Jeśli coś będzie się Wam nie zgadzać i przekroczycie dozwolony rozmiar, to poszukajcie winy być może właśnie w makrach.
A cóż możemy znaleźdź wśród naszych milusińskich? Spójrzmy:
•
exp
- makro liczy potęgę liczby 2 z dużą dokładnością,
•
frc
- zwraca część ułamkową argumentu wejściowego,
•
log
- liczy logarytm przy podstawie 2,
•
m3x2
- mnożenie macierzy 3x2,
•
m3x3
- mnożenie macierzy 3x3,
•
m3x4
- to samo dla macierzy 3x4,
•
m4x3
- chyba nie muszę tłumaczyć :-),
•
m4x4
- będziemy to na pewno często używać.
No i pozostały nam już do omówienia jeszcze tak zwane modyfikatory. Są to rzeczy o których wspominałem już wyżej.
Pamiętacie jak pisałem o zamianie składowych wektora, maskowaniu wartości przy zapisie do rejestru wyjściowego i negacji
wektora wejściowego? No więc proszę, oto szczegóły. Zacznijmy od negacji, bo jest najprostsza.
•
-r
- no i tyle wystarczy, aby wszystko odwrócić, czyli zmienić wartości składowych na znak przeciwny, niż mają.
Plus staje się minusem, minus plusem. Można już zacząć kombinować z naszą sceną. Poniżej przykład:
mov r0, -r1
•
r.{x}{y}{z}{w}
- maskowanie składowych. Wystarczy zaznaczyć, które chcemy mieć zmienione i gotowe.
Przykład? Proszę bardzo (zapisanie tylko składowej x i w w rejestrze r0, składowa y pozostaje bez zmian):
9
DirectX ▪ Vertex shader 1
mov r0.xw, r1
•
r.[xyzw][xywz][xywz][xywz]
- teraz postaram się wytłumaczyć ten straszliwie wyglądający stwór. Otóż
wspominałem o zamienianiu składowych wektora. Jak to działa? Proszę, oto przykład (i pierwszy sprawdzian tego,
czy rozumiemy co to jest vertex shader ;-). Mamy rozkaz:
mov r1, r2
czyli przesłanie zawartości rejestru tymczasowego r2 do rejestru r1. Po wykonaniu takiej instrukcji oba rejestry będą
zawierać taką samą wartość. Cóż można by zrobić, żeby jednak sobie to trochę urozmaicić? Ano wykorzystajmy zamianę
składowych i zróbmy tak:
mov r1, r2.xzyw
wygląda pięknie, tylko co to nam zrobi? Popatrzmy na poniższy schemat a wiele nam się powinno wyjaśnić.
Jak widzimy, podczas przesyłania wektora z r2 do r1 następuje niejako zamiana składowej y ze składową z, tylko, że ten
zmieniony wektor znajduje się już w rejestrze r1. Jeśli będziecie mieć jakieś bardziej skomplikowane bryły niż tylko
sześcian, to być może osiągniecie dzięki temu jakieś fajne efekty. Tutaj ilustruję Wam tylko na czym polega podmianka
współrzędnych w wektorze.
No i cóż. Omówiliśmy sobie praktycznie całą teorię, która będzie nam niezbędna do zaprogramowania najprostszego vertex
shadera! Teraz możemy przystąpić do próby stworzenia naszego pierwszego shadera. Czy coś nam z tego wyjdzie?
Zobaczymy już w następnej lekcji...