Zdążyliśmy się już przyzwyczaić do tego, że w programowaniu grafiki bardzo często wykorzystujemy zastane mechanizmy w zupełnie inny sposób niż ten formalnie założony. I tak: tekstury nie muszą być tylko bitmapami nakładanymi na geometrię, ich rzekome współrzędne mogą tak naprawdę przechowywać pozycję 3D lub normalne, a piksele nie muszą wcale zawierać danych o kolorach.
Analogicznie potok renderowania nie musi też produkować pikseli do wyświetlenia na ekranie! Zamiast tego możliwe jest, aby dokonywał on najpierw wyliczania pewnych właściwości materiałów dla każdego piksela na powierzchni ekranu i to właśnie te informacje zapisywał jako wynikowy “kolor” w teksturze typu Render Target. Później byłyby one wykorzystywane do obliczeń związanych np. z oświetleniem i cieniowaniem sceny. Zaletą takiego podejścia (znanego powszechnie jako Deferred Shading) jest między innymi oszczędzenie sobie wielokrotnego przetwarzania wierzchołków przez vertex shader, np. wtedy, gdy potrzebnych jest wiele przebiegów dla wielu świateł. Z drugiej strony w ten sposób znacznie bardziej obciąża się jednostki Pixel Shader, których liczba w starszych karta jest stała i niezbyt duża. Korzyścią jest jednak uproszczenie całego procesu renderowania, nawet jeśli z powyższego opisu nieszczególnie to wynika ;-)
Pozostaje jeszcze pewien drobny szkopuł. Otóż materiały mają wiele parametrów, które często są złożone, i nie da się ich wszystkich upakować w pojedynczym pikselu, składającym się jedynie z czterech wartości zmiennoprzecinkowych. Dlatego też stosuje się tutaj “wielokrotne cele renderowania” (Multiple Render Targets – MRT), a więc produkowanie przez potok więcej niż jednej wartości koloru naraz. Zwykle po prostu każdy parametr materiału jest zapisywany osobno, do innej powierzchni typu Render Target. Rozróżnienie odbywa się na poziomie pixel shadera. Zamiast zwracać jedną wartość o semantyce COLOR0
(która domyślnie trafia do bufora ramki, czyli – w przybliżeniu – na ekran), może on wypluć także COLOR1
, COLOR2
i tak dalej:
Rezultaty te chcielibyśmy rzecz jasna odebrać i zachować, ale w DirectX wystarcza do tego zwykła metoda SetRenderTarget
, której podajemy powierzchnię działającą jako Render Target o danym indeksie. Są tutaj oczywiście pewne obostrzenia (łącznie z tym najbardziej oczywistym – rozmiaru powierzchni).
Największym mankamentem jest jednak to, że jedynie stosunkowo nowe karty (np. nVidii od serii 6) obsługują MRT. Można to sprawdzić, czytając wartość pola NumSimultaneousRTs
struktury D3DCAPS
, które da nam – niezbyt imponującą, bo wynoszącą zwykle 4 lub 8 – maksymalną liczbę Render Targets podpiętych jednocześnie. W tak niewielkiej ilości (zwłaszcza, jeśli równa jest ona nawet mniej, czyli… 1 :]) może być niełatwo zmieścić wszystkie potrzebne informacje o materiałach.
Ale w programowaniu grafiki zwykle bywa tak, że najlepiej jest wybierać techniki, które jeszcze wydają się nowe. Wtedy bowiem jest całkiem prawdopodobne, że w chwili kończenia projektu wsparcie dla nich będzie już powszechne. Zważywszy na to, że w moim przypadku ciężko jest powiedzieć, kiedy pisanie właściwego potoku renderowania zdołam chociaż zacząć – o zakończeniu nie wspominając – techniki typu deferred zdają się być całkiem rozsądnym wyborem do rozważenia :)
Wszyscy wiedzą, że przesiadka na Vistę ma jedną niezaprzeczalną zaletę. Jeśli mianowicie dysponujemy odpowiednią kartą graficzną, to możemy cieszyć się całym bogactwem Shader Model 4 oraz dziesiątej wersji DirectX. Nadal jednak niewiele gier posiada jakieś efekty (oczywiście w(y)łączalne) uzyskiwane przy pomocy nowej wersji biblioteki. Najwyraźniej Microsoft nieumyślnie wpadł tutaj w rodzaj błędnego koła, które zapobiega powszechnemu wykorzystaniu DirectX 10.
Nie pomaga tu też za bardzo specjalna wersja “dziewiątki”, czyli tak zwany Direct3D 9Ex. Cóż to za zwierz?…
Jest to mianowicie pewne rozszerzenie znanej i lubianej wersji biblioteki, które wprowadza nowe możliwości, częściowo zbieżne z tym, czego można doświadczyć w D3D10. Wśród nich mamy chociażby:
pSharedHandle
. Był on opisany jako zarezerwowany i należało w jego miejsce przekazywać NULL
. Rezerwacja ta jednak nie przepadła i w 9Ex jest on wykorzystywany do współdzielenia zasobów między urządzeniami – nawet takimi, które są w oddzielnych procesach. Mogę sobie wyobrazić zastosowanie tego mechanizmu chociażby do przełączania trybów graficznych bez konieczności ponownego tworzenia wszystkich tekstur, buforów, itp.IDirect3DDevice9Ex
. Jest wśród nich na przykład WaitForVBlank
, służąca do ręcznej synchronizacji pionowej.Wszystkie te możliwości prezentują się całkiem nieźle. Żeby jednak z nich korzystać, muszą być spełnione dwa warunki. Po pierwsze, sprzęt musi wspierać tak zwany WDDM (Windows Device Driver Model), co w przypadku popularnych kart graficznych dotyczy z grubsza tych, które udostępniają Shader Model 3.
A drugi warunek?… Niejako wynika on z pierwszego, bowiem WDDM jest częścią systemu Windows Vista. Aplikacje wykorzystujące Direct3D 9Ex będą więc działały wyłącznie na tym systemie. To pewnie trochę zniechęcające, prawda? Ale cóż, Microsoft próbuje jak może ;-)
Kiedy uczyłem się biblioteki DirectX, miałem dość spore kłopoty z kwestią właściwej kolejności przekształceń opisanych przez macierze. Jak wiadomo, w grafice każdą transformację możemy opisać macierzą, a złożenie takich przekształceń możemy być reprezentowane przez odpowiedni iloczyn macierzy. Wówczas pomnożenie wektora (odpowiednio rozszerzonego o czwartą współrzędną) przez taką macierz skutkuje zastosowaniem do niego tych wszystkich przekształceń. Może być ich bardzo wiele, lecz wymagana jest tylko jedna macierz i jedno mnożenie przezeń wektora. Jest to więc efektywne, jeśli mamy dużą ilość geometrii do przetworzenia – czyli, co tu ukrywać, w zasadzie zawsze :)
Rzeczone macierze opisujące przekształcenia są kwadratowe; w przypadku grafiki 3D mają rozmiar 4×4. Dlatego też możliwe jest ich mnożenie w dowolnej kolejności. Wiemy jednak, że operacja mnożenia macierzy nie jest przemienna. Odpowiada to zresztą temu, iż przy przekształcaniu punktów w przestrzeni też liczy się kolejność: obrót, a potem przesunięcie to nie to samo, co przesunięcie, a potem obrót.
I tu się zaczyna problem, bowiem w bardzo wielu źródłach wprowadzone jest niezłe zamieszanie, jeśli chodzi o kolejność mnożenia macierzy opisujących geometryczne przekształcenia. Najczęściej pomieszane są konwencje tego, jaki porządek jest poprawny w danej bibliotece graficznej, a jaki “w matematyce”. Ostatecznie więc nie wiadomo, czy trzeba iloczyn macierzy zapisywać w kolejności, w jakiej chcemy aplikować przekształcenia, które reprezentują – czy może na odwrót. Dość prosto można oczywiście sprawdzić, jak to jest akurat w naszej bibliotece graficznej, lecz to nie mówi nic o istocie problemu…
Właściwie to dopiero niedawno dowiedziałem się, gdzie jest tu pies pogrzebany. Otóż matematycy z pewnych przyczyn lubią traktować wektory jako kolumnowe, tj. jako macierze Nx1 (N wierszy, 1 kolumna). Przy takiej interpretacji tylko iloczyn w postaci:
macierz1 * wektor_kolumnowy
daje w wyniku wektor (także kolumnowy, rzecz jasna). W tym przypadku będzie on przekształcony przez macierz1. Jeżeli teraz zechcemy dodać drugie przekształcenie, to mnożenie przez odpowiednią macierz również musimy zapisać z przodu:
macierz2 * (macierz1 * wektor_kolumnowy)
Ale mnożenie jest oczywiście łączne, więc:
(macierz2 * macierz1) * wektor_kolumnowy = macierz * wektor_kolumnowy
a wynikowa macierz = macierz2 * macierz1 opisuje złożenie naszych przekształceń. Jak widać wyżej, najpierw jest stosowane to opisane przez macierz1, a dopiero potem to z macierzy2 – mimo że są one mnożone w porządku odwrotnym. Tak bowiem wygląda sprawa kolejności przekształceń dla wektorów kolumnowych.
Twórcy DirectX uznali prawdopodobnie, że jest to nieintuicyjne dla nie-matematyków i dokonali pewnego “triku”. Opiera się on na tym, że gdy w dwóch macierzach zamienimy ze sobą wiersze i kolumny – czyli dokonamy transpozycji – pomnożymy je przez siebie, a następnie transponujemy wynik, to rezultat będzie taki, jakbyśmy mnożyli wyjściowe macierze w odwrotnej kolejności. Wyjątkowo trzeba tutaj przyznać, że wzór mówi więcej niż jego opis, więc spójrzmy na ten wzór :)
(A * B)T = BT * AT
W DirectX dokonano więc transpozycji wszystkich macierzy opisujących przekształcenia. Przykładowo, funkcja D3DXMatrixTranslation
zwraca macierz z wartościami przesunięć wpisanych w ostatnim wierszu, podczas gdy w wersji “matematycznej” powinny być one w ostatniej kolumnie. Podobnie jest ze wszystkimi innymi macierzami… ale także z wektorami!
Chociaż wektory z programistycznego punktu widzenia to cztery składowe i nic więcej, to w DirectX należy je traktować jako wektory wierszowe, czyli macierze 1xN. Dla nich zaś sensownym sposobem mnożenia przez macierz jest tylko następujący:
wektor_wierszowy * macierz1
Dodając kolejne przekształcenie, mamy:
(wektor_wierszowy * macierz1) * macierz2
i znów opierając się na łączności mnożenia otrzymujemy ostatecznie:
wektor_wierszowy * (macierz1 * macierz2) = wektor_wierszowy * macierz
Tutaj z kolei widać wyraźnie, że przekształcenia są stosowane w takiej samej kolejności, w jakiej odpowiadające im macierze występują w iloczynie.
Ponieważ, jak wspomniałem wyżej, cała sprawa jest kwestią czysto arbitralną (wystarczy transpozycja, aby odwrócić porządek), powinniśmy tym bardziej zwrócić na nią uwagę. A jeśli programujemy w DirectX, nie należy dopuścić do tego, by matematycy wmawiali nam ‘właściwą’ kolejność :P
Obecnie biblioteki graficzne umieją już bardzo, bardzo dużo. W narzędziach takich jak DirectX czy OpenGL mamy ogromne możliwości efektywnego wyświetlania grafiki trójwymiarowej, a jednocześnie ich obsługa staje się z kolejnymi wersjami coraz łatwiejsza. Należy oczywiście poznać nieco matematycznych podstaw stojących za całą grafiką 3D: przekształceniami, oświetleniem, buforem głębi, i tak dalej. Zasadnicza część – czyli cały potok renderowania oraz rasteryzacja – jest już jednak wykonana za nas.
Powiedzmy jednak, że nie zadowala nas ten stan rzeczy i ze względów edukacyjnych – lub możliwych czynników zewnętrznych :) – chcemy sami dowiedzieć (w sposób najbardziej praktyczny, czyli poprzez implementację), jak to wszystko działa. Wówczas do szczęścia potrzebujemy właściwie tylko jednej operacji: wyświetlenia pojedynczego piksela w określonym kolorze. I jest tylko jedno ale: aby to wszystko działało w jakikolwiek sensowny sposób, operacja ta musi być diabelnie szybka.
Niestety (a może właśnie ‘stety’) czasy, w których łatwo możemy pisać bezpośrednio po pamięci ekranu, najwyraźniej już dawno się skończyły. Nowoczesny system operacyjny nie pozwala po prostu na takie niskopoziomowe operacje żadnym programom użytkownika. Z drugiej strony korzystanie z tego, co w zakresie wyświetlania grafiki ów system oferuje, może być niewystarczająco – pod względem wydajnościowym, rzecz jasna.
Dlatego wydaje się, że do eksperymentów dobrze nadają istniejące biblioteki graficzne – tyle że ograniczone do jednej funkcjonalności: renderowania sprite’ów punktowych (point sprites). Polega ona na rysowaniu quadów (kwadratów złożonych z dwóch trójkątów) w taki sposób, że zawsze są one zwrócone przodem do kamery. Biorąc pod uwagę to, że wielkość takich punktów możemy określić, ustawienie jej na równą jednemu pikselowi sprawi, że wyświetlanie sprite’ów punktowych będzie dobrym substytutem “stawiania” pojedyncznych pikseli.
Gdzie w zakamarkach bibliotek graficznych ukryta jest tego rodzaju funkcjonalność? Otóż:
D3DRS_POINTSCALEENABLE
na FALSE
(co zresztą jest domyślną wartością) oraz D3DRS_POINTSIZE
na 1.0f
, czyli wielkość odpowiadającą jednemu pikselowi. Co ciekawe, trzeci o nazwie D3RS_POINTSPRITEENABLE
wbrew pozorom lepiej jest zostawić ustawiony domyślnie na FALSE
. Nie dotyczy on bowiem (co jest dość mylące) samej możliwości rysowania sprite’ów punktowych, a jedynie sposobu mapowania współrzędnych tekstur – co w przypadku symulowania pikseli nie jest nam potrzebne.D3DFVF_XYZRHW
oraz D3DFVF_DIFFUSE
przy pomocy dowolnych wywołań DrawPrimitive[UP]
z typem prymitywów D3DPT_POINTLIST
.1.0f
). Jeśli zaś chodzi o samo rysowanie punktów to jednym ze sposobów (i pewnie nie najlepszym :)) jest ustawienie wszystkich macierzy na jednostkowe i podawanie współrzędnych punktów jako wektorów 4D z ustaloną współrzędną Z (np. 0) oraz W równą 1, do funkcji glVertex4*
. Musi być ona umieszczona oczywiście między glBegin
(z parametrem GL_POINTS
) a glEnd
, zaś kolor punktu-piksela określić możemy przez glColor*
.Jak widać, mechanizmy wspomagające nas w ręcznej, pikselowej robocie są nieco ukryte. Co więcej, w DirectX 10 na przykład sprite’y punktowe zostaną wyeliminowane z biblioteki i konieczne będzie samodzielne tworzenie odpowiednich quadów, jeśli zechcemy uzyskać podobną funkcjonalność. To prawdopodobnie słuszna decyzja, bowiem nawet w swoim głównym zastosowaniu – czyli systemach cząsteczkowych – są one zbyt ograniczone (choćby dlatego, że są cały czas zwrócone do kamery, co wyklucza możliwość obrotu cząstek). A przyznajmy, że samodzielne stawianie “pikseli” jest dość egzotyczne – co nie znaczy, że nie warto wiedzieć, jak to się robi, jeśli kiedykolwiek będzie to nam potrzebne…
Mijający rok (jak zresztą kilka poprzednich) to czas zwiększającej się wydajności układów graficznych. Obecnie są one już nieporównywalnie szybsze niż tradycyjne procesory w komputerach. Charakteryzują się jednak o wiele mniejszą uniwersalnością (wyspecjalizowaniem we współbieżnych obliczeniach na złożonych danych), co jest oczywiście całkiem zrozumiałe – w końcu głównym zadaniem układów GPU jest, jak sama nazwa wskazuje, efektywne przetwarzanie grafiki.
Mimo tego pojawiają się coraz śmielsze pomysły, by zaprząc karty graficzne także do innych zadań. Od kiedy funkcjonalność shaderów, jakie można na nich uruchamiać, stała się stosunkowo dużo, jest to zupełnie możliwe. Zastosowania tzw. GPGPU – czyli wykonywania ogólnych (niekoniecznie graficznych) obliczeń na GPU obejmują przetwarzanie dźwięku, analizę sygnałów (np. szybką tranformatę Fouriera), sieci neuronowe czy nawet operacje na bazach danych.
Ten trend zdaje się być wspomagany przez głównych graczy na rynku. I tak nVidia na początku roku przedstawiła technologię o wdzięcznej nazwie CUDA (Compute Unified Device Architecture). W skrócie jest to zestaw narzędzi, które umożliwiają programowanie układów graficznych (jak na razie tylko kart nVidii serii 8) w języku C. Obejmuje on kompilatory, debuggery i biblioteki matematyczne, które mają na celu wspomaganie tego procesu. Wszystko to wygląda całkiem obiecująco, zwłaszcza w programowaniu gier (wydaje się np. całkiem możliwe liczenie fizyki wielu małych obiektów w sposób współbieżny na GPU), chociaż na razie jest to raczej melodia przyszłości.
Jeszcze bardziej mgliście wygląda sprawa z przyszłą wersją DirectX, oznaczoną numerem 11. Pojawiła się plotka, że pojawi się w niej nowy (czwarty już) typ shadera, czyli compute shader. Miałby on służyć właśnie do takich ogólnych obliczeń na GPU w sposób ustalony całkowicie przez programistę. Widać więc, że jeśli producenci kart chcą dotrzymywać kroku w zgodności z wiodącym graficznym API, takie CUDA jak u nVidii będą wkrótce nie cudowne, ale zupełnie zwyczajne :)
Dobrą zasadą programowania obiektowego jest to, że każdy obiekt powinien w miarę możliwości dbać wyłącznie o siebie. Nazywamy to enkapsulacją. W przypadku obiektów na scenie chcielibyśmy więc, aby potrafiły one odrysować się same bez konieczności zakładania czegokolwiek choćby o stanie urządzenia przed wywołaniem metody Render
danego węzła sceny.
Obiekt do samodzielnego narysowania się może jednak potrzebować ustawienia wielu parametrów: stanów renderowania, tekstur czy nawet shaderów. Z jednej strony część z nich może się powtarzać, a z drugiej pobieranie ustawień bezpośrednio od urządzenia przed każdą potencjalną zmianą kosztowałoby o wiele za dużo.
Prostym rozwiązaniem jest tutaj tzw. menedżer stanów, czyli warstwa pośrednicząca. Obiekt ten zachowuje wszystkie już ustawione stany urządzenia i kontroluje każde żądanie zmiany z zapisanymi wcześniej wartościami. Faktyczny stan urządzenia jest modyfikowany tylko wówczas, gdy nowa wartość jest rzeczywiście różna od starej.
Stworzenie takiego menedżera pozwala więc odciążyć nieco urządzenie zajęte ciągłym przełączaniem stanów w przypadku, gdy zgodnie ze słuszną filozofią OOPu każdy węzeł sceny renderuje się, dbając wyłącznie o siebie. Jedyną niedogodnością jest to, że w naszym menedżerze należy skopiować większość interfejsu urządzenia DirectX – a przynajmniej tę jego część, którą używamy. Jest to jednak potrzebne, aby w połączeniu z sortowaniem fragmentów geometrii sceny względem materiałów uzyskać jako taką wydajność całości.
W programowaniu też trzeba często podejmować różne trudne decyzje; zwykle dotyczą one kwestii projektowych. Szczególnie złożone robią się one tam, gdzie idee OOPu muszą się spotkać ze sprowadzeniem na ziemię przez wymagania wydajnościowe obecne w programowaniu grafiki i interfejs graficznych API takich jak DirectX.
Na co więc tak narzekam? Otóż chodzi o odpowiednie opakowanie systemu materiałów. Jak wiadomo, materiał to jest taka cecha geometrii, która nie jest zapisana w danych o wierzchołkach – czyli właściwie wszystko, co może być w geometrii interesujące :) Materiał określa więc cechy powierzchni (gładka, bump-mapowana), właściwości świetlne albo nawet tak fundamentalne cechy jak półprzezroczystość lub renderowanie jako siatki (wireframe).
Zakodowanie takiego systemu materiałów zgodnie z regułami programowania obiektowego oznaczałoby przede wszystkim to, że część potoku odpowiedzialna za rysowanie nie musiałaby nawet wiedzieć, z jakiego dokładnie materiału korzysta dany fragment geometrii. Za pomocą metod wirtualnych można by bowiem albo pobrać odpowiednie ustawienia stanów renderowania, albo wręcz kazać materiałowi, aby sam je ustawił.
To da się zrobić. Sęk w tym, że wszystkie te dane trzeba przekazać do shadera, który już taki sprytny nie jest. W rzeczy samej, dopiero od niezbyt w sumie długiego czasu shadery można pisać w czymś, co przypomina język wysokiego poziomu, a o OOPie czy tym bardziej polimorfizmie nawet nie ma co marzyć. Chociaż… w HLSL już teraz do ewentualnego przyszłego użycia zarezerwowano słowo kluczowe class
:)
Na teraz trzeba jednak zdecydować się na rozwiązanie pośrednie, które mniej więcej zadowoli obie strony – elastyczną obiektowość i “sztywny” shader. Osobiście widzę trzy rozwiązania dla organizacji klas(y) materiałów w silniku:
Zapewne to ostatnie rozwiązanie jest najbardziej elastyczne, rzeźnickie i w ogóle “naj” – tyle że jest też najbardziej skomplikowane i zdecydowanie nie chciałbym się za nie zabierać już za pierwszym razem. Dlatego, jak sądzę, powinienem wybrać coś między opcją pierwsza a drugą. Obie są właściwie pewnymi odcieniami tego samego wariantu, w którym możliwe cechy materiału są po prostu wpisane na sztywno w kodzie – zarówno samej aplikacji, jak i shaderów.
W przypadku drugiej opcji istnieje naturalnie tradycyjny dylemat: czy dana cecha zasługuje tylko na osobne pole, czy może wymaga już nowej klasy pochodnej. Myślę jednak, że tutaj można zastosować dość prostą zasadę związania klasy z shaderem. Jeśli więc dwa materiały należą do tej samej klasy, to różnią się tylko wartościami stanów renderowania. Natomiast materiały z różnych klas różnią się samym zestawem stanów, jakie można dla nich ustawiać; potrzebują zatem innych shaderów.