1 DirectX Vertex shader 1

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ś bie...
8 downloads 1 Views 279KB Size
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...