Chociaż obecnie w grach niepodzielnie króluje grafika trójwymiarowa, nigdy nie zastąpi ona całkowicie tej wykorzystującej o jeden wymiar mniej. Nawet w grze 3D zawsze znajdzie się potrzeba, by wyświetlać płaskie obrazki – choćby to było tylko menu, logo producenta czy jedynie HUD widoczny w czasie gry. Dlatego każdy silnik graficzny powinien oferować wsparcie także dla grafiki dwuwymiarowej.
W przypadku gier 3D posiadanie wydzielonej części zajmującej się grafiką dwuwymiarową jest zazwyczaj najrozsądniejszym i najbardziej elastycznym rozwiązaniem. Oszczędzamy sobie w ten sposób ewentualnych kłopotów związanych z integracją już istniejących bibliotek – takich jak np. SDL – z naszym własnym kodem renderowania. Takie połączenie w niektórych sytuacjach mogłoby być trudne lub wręcz niemożliwe. Ponadto fakt, iż dwuwymiarowe wielokąty przechodzą przez ten sam potok renderowania pozwala na zastosowanie dowolnych efektów. Nie ma na przykład problemu, by zastosować prosty pixel shader, który spowoduje rysowanie całego ekranu – łącznie z ewentualnym płaskim interfejsem – w odcieniach szarości.
Gdy mówimy o grach 2D sprawa nie jest już aczkolwiek tak oczywista. Może się wtedy wydawać, że nie opłaca się pisać kodu odpowiedzialnego z same płaskie sprite’y przy użyciu DirectX czy OpenGL, jeżeli można od razu zastosować SDL, DirectDraw, Allegro czy podobne biblioteki. Rzeczywiście często jest to podejście jak najbardziej słuszne. Jednakże gdy zaprzęgniemy do pracy bardziej zaawansowane narzędzie, otrzymane rezultaty też najczęściej będą lepsze. Typowym przykładem jest alpha blending, który zwykł sprawiać problemy wielu bibliotekom 2D. W DirectX i OpenGL obsługa półprzezroczystych, dwuwymiarowych wielokątów sprowadza się do kilku dodatkowych linijek kodu.
W tym artykule zajmiemy się konstrukcją z prostego modułu graficznego 2D opartego o bibliotekę DirectX w wersji 9. Dlatego też zakładam, że nieobce są ci podstawy użytkowania tej biblioteki. W szczególności mam nadzieję, że znasz pojęcia formatu wierzchołka, bufora wierzchołków, bufora indeksów oraz posiadasz podstawową wiedzę w zakresie przekształceń i teksturowania. Będziemy tutaj posługiwali domyślnym potokiem renderowania (fixed pipeline), więc nie wykorzystamy żadnych shaderów.
Wszystkie fragmenty kodów są napisane w języku C++ z użyciem zwykłej (niezarządzanej) wersji DirectX.
Zaawansowane biblioteki 2D w rodzaju GDI posiadają całe mnóstwo funkcji służących rysowaniu różnych obiektów, nazywanych prymitywami graficznymi. Należą do nich figury geometryczne otwarte (linie, łuki, odcinki, …), zamknięte (prostokąty, elipsy, wielokąty), tekst, bitmapy, i tak dalej. Ceną za taką funkcjonalność jest naturalnie wydajność, o czym mógł przekonać się każdy, kto próbował zaprogramować jakiś bardziej skomplikowany efekt graficzny przy użyciu GDI.
Rozwój kart graficznych potoczył się tak, że współcześnie nie są one przystosowane do szybkiego, bezpośredniego przetwarzania pikseli na ekranie. Są one raczej zoptymalizowane pod kątem transformacji i rasteryzacji trójkątów, z których składają się zarówno proste, jak i złożone obiekty w grach i demach. Dlatego nasz moduł 2D też musi się na tym opierać. Dawne czasy bezpośredniego odwoływania się do pamięci ekranu (lub bufora tylnego) odeszły bowiem bezpowrotnie. Nadal oczywiście można tak robić, lecz takie postępowanie natychmiast spowodowałoby, że liczba klatek na sekundę wykona ostry zjazd w dół. Wynika stąd, że dwuwymiarową geometrię należy renderować tak samo jak trójwymiarową: dając karcie graficznej porcje wielokątów złożonych z wierzchołków.
Zanim przystąpi do kodowania, warto też odpowiedzieć sobie na pytanie: czego potrzebujemy? Kopiowanie całej lub prawie całej funkcjonalności biblioteki takiej jak GDI byłoby niezwykle czasochłonną, trudną i prawdopodobnie niepotrzebną pracą. Na pewno nie potrzebujemy tego wszystkiego.
Tak naprawdę tym, co interesuje nas najbardziej, jest wyświetlanie gotowych obrazków oraz ich części – czyli tak zwanych sprite’ów. To podstawa i jednocześnie jak najbardziej wystarczająca na początek część modułu 2D. Nią właśnie zajmiemy się w tym artykule. Jednocześnie jednak postaramy się, aby otrzymany kod był łatwo rozszerzalny i umożliwiał dodawanie rysowania kolejnych elementów. Pod koniec wspomnę zresztą o możliwych kierunkach dalszego jego rozwoju.
Jak wyświetlić na ekranie dwuwymiarowy obrazek, posługując się biblioteką do grafiki 3D?… “Normalnie” oczekiwalibyśmy istnienia funkcji w rodzaju:
Nie uświadczymy jej jednak w samym DirectX, lecz nic straconego – niedługo sami ją napiszemy :) Póki co musimy znaleźć nieco bardziej “pierwotny” sposób na rozwiązanie problemu.
Nie jest on specjalny trudny. Jak już wspomniałem kilkakrotnie, do dyspozycji mamy renderowanie wielokątów, a te – jak powszechnie wiadomo – mogą być oteksturowane. I tak właśnie rysuje się dwuwymiarowe obrazki: jako prostokąty (rzadziej innej wielokąty) pokryte teksturą. Nazywa się je quadami, jako że są one wyznaczone przez 4 wierzchołki.
A zatem okazuje się, że obrazek == tekstura? No cóż, nie do końca. Taki odwzorowanie ma niestety kilka wad, z których jak zwykle zdecydowana większość dotyczy wydajności. Mianowicie:
D3DPOOL_DEFAULT
– może powodować problemy z ilością dostępnej pamięci karty,Z tych powodów (można też pewnie wymienić kilka innych) grafiki dla sprite’ów powinno się grupować, umieszczając po kilka na jednej teksturze. Powszechną praktyką jest na przykład zawarcie wszystkich klatek wszystkich animacji danego obiektu w jednej teksturze zapisanej w jednym pliku graficznym. Wówczas nawet jeśli na ekranie znajduje się kilkadziesiąt obiektów w różnych stadiach animacji, można je narysować jednym wywołaniem.
Tekstura podzielona na wiele małych obrazków
Obrazek powinien zatem reprezentować fragment tekstury. Prawdopodobniej najwygodniej jest użyć w tym celu opisującego ów fragment prostokąta. Na jego podstawie – oraz na podstawie wymiarów tekstury obrazka – będziemy liczyli koordynaty tekstury, które następnie powiążemy z wierzchołkami rysowanego quada. Dzięki temu będzie on pokryty odpowiednim fragmentem tekstury naszego obrazka.
Możemy już przejść do kodu klasy obrazka, którą nazwałem CImage
. Wygląda on następująco:
Mamy tam więc dwie części, o których mówiłem wcześniej: teksturę oraz zestaw współrzędnych, określających jej fragment. Przy okazji trzeba wspomnieć o kwestii numerowania wierzchołków quada, dzięki któremu wiemy, którym wierzchołkom przypisać które współrzędne. Można je wybrać w dowolny sposób; tutaj przyjąłem, że wierzchołek zerowy jest w lewym górnym rogu, a kolejne są numerowanie zgodnie z ruchem wskazówek zegara. Widać to przy deklaracji tablicy DEFAULT_TEX_COORDS
.
Domyślny konstruktor CImage
tworzy obrazek rozciągający się na całą teksturę. Współrzędnymi teksturowania są wtedy wektory o składowych równych 0 lub 1. Gdy jednak obrazek obejmuje tylko część tekstury, wówczas trzeba je obliczyć, posługując się podanym prostokątem:
W tym celu musimy najpierw pobrać wymiary naszej tekstury, a dokładniej wymiary jej zerowej (największej) powierzchni w łańcuchu mipmap. Czynimy to funkcją GetLevelDesc
w sposób podany powyżej. Następnie dzieląc dane o prostokącie przez rzeczone wymiary, otrzymujemy odpowiednie koordynaty. Musimy jeszcze zachować pikselowe wymiary obrazki, gdyż będą nam one potrzebne przy rysowaniu.
W ten sposób zaopatrzyliśmy się w klasę obrazka, rozumianego jako dowolny fragment tekstury. Do pełni szczęścia brakuje nam jeszcze “tylko” jednego – musimy nauczyć się go wyświetlać.
Podobno 2D to tak naprawdę tylko jedno D mniej i gdy korzystamy z biblioteki graficznej, tak właśnie należy to traktować. Ta efektowna sentencja z praktycznego punktu widzenia jest jednak mało przydatna. Przejdźmy więc do konkretów.
W DirectX nim narysujemy cokolwiek, musimy zastanowić się nad formatem lub deklaracją wierzchołków. W tym pierwszym przypadku mamy do dyspozycji tzw. elastyczny format wierzchołków (FVF), która pozwala podawać dane w formie struktury o postaci opisanej przez flagi rozpoczynające się od D3DFVF_
.
U nas wierzchołki potrzebują w zasadzie tylko dwóch informacji. Pierwszą z nich jest pozycja, a drugą sławetne koordynaty tekstury, którym poświęciliśmy ostatnio tyle czasu. Dodamy do tego jeszcze kolor rozproszenia (diffuse), który pozwoli na modulację kolorów oraz rysowanie półprzezroczystych sprite’ów (niezależnie od kanału alfa tekstury). Ostatecznie struktura naszego wierzchołka oraz odpowiadająca mu deklaracja FVF będą następujące:
Być może dziwisz się, dlaczego używamy stałej D3DFVF_XYZ
(wierzchołki nietransformowane) zamiast D3DFVF_XYZRHW
(wierzchołki transformowane), skoro zależy nam na rysowaniu dwuwymiarowych sprite’ów. Jednak dzięki temu w przyszłości możemy bardzo łatwo dodać możliwość wykonywania dodatkowych prostych przekształceń, na czele z obrotami wokół wszystkich trzech osi układu współrzędnych.
Ceną za tę dodatkową funkcjonalność jest tylko konieczność ustawieniach odpowiednich przekształceń macierzowych, aby nasza grafika była renderowana rzeczywiście “płasko”. Najważniejsze jest tu ustawienie odpowiedniego rzutu przestrzeni trójwymiarowej na płaszczyznę, czyli projekcji.
Źródło: http://robotics.ee.uwa.edu.au
W scenach 3D najczęściej stosowany jest rzut perspektywiczny, w którym obszar widzenia jest ściętym ostrosłupem. Ten sposób rzutowania sprawia, że bliższe obiekty wydają się większe niż ten dalsze, co jest zgodnie z naszym sposobem postrzegania rzeczywistości. Dzięki tej projekcji sceny trójwymiarowe wyglądają realistycznie.
Źródło: http://robotics.ee.uwa.edu.au
Dla płaskiej geometrii ten rzut jest jednak nieodpowiedni, gdyż powodowałby jej zniekształcenia. My chcemy, aby nasze sprite’y były płaskie, dlatego zastosujemy rzut ortogonalny (prostopadły).
DirectX oferuje kilka funkcji przeznaczonych do tworzenia macierzy reprezentujących ten rodzaj projekcji. Najwygodniejsza jest oczywiście ta najbardziej zaawansowana :) Mowa tu o D3DXMatrixOrthoOffCenterLH
. Przyrostek LH
znaczy, jak zapewne wiesz, że funkcja tworzy macierz przeznaczoną dla lewoskrętnego układu współrzędnych, w którym oś Z jest zwrócona od obserwatora wgłąb ekranu i którym posługuje się DirectX.
Parametrami funkcji są zakresy współrzędnych X, Y, Z. Wszystkie wierzchołki o pozycji mieszczącej się w podanych zakresach znajdą się w prostopadłościanie widzenia i w konsekwencji zostaną wyświetlone ekranie. Sposób określenia tych zakresów dla DirectX nie ma żadnego znaczenia; spotyka się tutaj generalnie dwa rozwiązania:
Tak naprawdę funkcja D3DXMatrixOrthoOffCenterLH
pozwala nie tylko ustalić przedział dla osi układu współrzędnych, ale nawet zmienić ich zwrot! Wiemy na przykład, że domyślnie w DirectX oś Y zwrócona jest w górę. Jeżeli chcielibyśmy ją odwrócić (w bibliotekach 2D niemal zawsze jest ona zwrócona w dół), wystarczy podać minimalną wartość Y jako większą od maksymalnej.
W dalszej części będziemy posługiwali się rozwiązaniem pierwszym, czyli “odwzorowaniem pikselowym”. Oto jak możemy ustawić projekcję ortogonalną, aby poczuć się jak w domu: z punktem (0,0) w lewym górnym rogu okna, osiami X,Y zwróconymi w prawo i w dół, i z odpowiedniością jeden punkt – jeden piksel:
Zakres współrzędnej Z nie ma naturalnie żadnego znaczenia w przypadku renderingu 2D, o ile tylko współrzędne wierzchołków naszych quadów się w nim zawierają. U nas zakresem jest 0..1, a wszystkie rysowane wierzchołki będą miały trzecią składową pozycji równą 0.5f. To nam zapewnia, że zawsze będą widoczne o ile pozostałe X i Y też są w wyznaczonych zakresach.
W tym momencie może się wydawać, że od narysowania czegoś na ekranie dzieli nas już bardzo niewiele, może tylko jedno wywołanie Draw*
… Teoretycznie tak, ale w praktyce powinniśmy od razu pomyśleć o odpowiedniej optymalizacji tego procesu.
Podstawową zasadą programowania grafiki jest grupowanie wywołań funkcji renderujących tak, by było i ich jak najmniej, a jednocześnie między nimi następowało jak najmniej zmian stanu urządzenia (faz tekstur, ustawień mieszania, shaderów, itp. itd.). Dość często oba te cele są ze sobą sprzeczne i naszym zadaniem jest wtedy znalezienie rozsądnego optimum.
Przezroczyste opakowanie modułu 2D – tak, aby użytkownik nie musiał wiedzieć, co tak naprawdę siedzi w środku – wymaga, by przed każdym rysowaniem ustawić kilka określonych opcji renderowania. Jedną z nich jest choćby wspomniana macierz projekcji; o reszcie powiemy sobie w następnym paragrafie. Ze względu na koszt takiego ustawiania chcielibyśmy więc, żeby takich rysowań było jako mniej. Dążymy zatem do minimalizacji wywołań funkcji Draw*
.
Zastosujemy tutaj efektywne, ale wciąż w miarę proste rozwiązanie grupowania według tekstury. Polega ona na buforowaniu wszystkich przeznaczonych do rysowania quadów i renderowaniu ich dopiero wtedy, gdy:
Flush
, na przykład pod koniec renderowania scenyTakie postępowanie daje spore korzyści. Wyobraźmy sobie grę z mapą podzieloną na kafle, z których każdy ma przypisaną grafikę. Jeśli tylko wszystkie te obrazki są zapisane na jednej teksturze, można w sposób całkowicie niewidoczny na zewnątrz załatwić rysowanie całej mapy jednym wywołaniem DrawPrimitive
. W punktu widzenia kodu użytkownika każdy kafel będzie rysowany osobno, lecz naprawdę nasza funkcja DrawImage
nie będzie niczego rysowała, a jedynie dodawała quady do bufora. Dopiero wywołanie Flush
opróżni bufor i “wyrzuci” wszystkie zawarte w nim quady na ekran.
Możemy już przystąpić do tworzenia głównej klasy naszej biblioteki grafiki 2D, którą nazwałem CCanvas
. Jest to obiekt, którego zadaniem jest zarządzanie całym procesem renderowania i jako taki może być z powodzeniem porównany do kontekstu urządzenia (HDC
) z GDI, obiektu klasy Graphics
z GDI+ czy TCanvas
z VCL (Delphi). Aby coś narysować, należy po prostu wywołać odpowiednią metodę tego obiektu, podając mu wymagane dane.
Definicja klasy CCanvas
przedstawia się następująco:
Oprócz rzeczy niezbędnie oczywistej – czyli wskaźnika na urządzenie Direct3D – widzimy tutaj m.in. bufor wierzchołków i indeksów, w których umieścimy dane o rysowanych quadach. W funkcji SetupBuffers
tworzymy oba bufory i należy koniecznie zauważyć, z jakimi flagami to czynimy. D3DUSAGE_WRITEONLY
oznacza, jak nietrudno się domyślić, że nasz bufor będzie przeznaczony wyłącznie do zapisu, co pozwala DirectX umieścić go w odpowiednio zoptymalizowanym bloku pamięci. Z kolei D3DUSAGE_DYNAMIC
pozwala na dodatkowe optymalizacje czynione w czasie blokowania buforów. Jest z tym także związane pewnie dość tajemnicze pole m_dwNextLockFlags
.
Dalej jest już nieco przyjemniej. Mamy tam prosty licznik quadów znajdujących się aktualnie w buforze, wskaźnik na aktualną teksturę oraz macierz projekcji ortogonalnej, o której mówiliśmy sobie wcześniej. W częście public
mamy dość oczywiste konstruktory, które inicjalizują to wszystko. Jak widać możemy do nich podać zarówno uchwyt okna, w którym będziemy rysować, jak i wymiary naszego canvasa
w sposób bezpośredni. Pamiętajmy tylko, że jeśli nie będą się one zgadzały z wielkością docelowego okna, obraz zostanie odpowiednio przeskalowany.
Zanim przejdziemy dalej, wypadałoby wyjaśnić te tajemnicze flagi związane z używanym buforem wierzchołków. Otóż VB dzielą się na dwa rodzaje: statyczne oraz dynamiczne. Różnica polega głównie na sposobie ich użycia (usage! :)) oraz na zawartości i częstotliwości jej modyfikacji.
Bufory statyczne są najczęściej wykorzystywane do przechowywania geometrii, która się nie zmienia (z mniejszą lub większa dokładnością). Typowo trafiają do nich wierzchołki wczytywanego modelu, które potem są poddawane transformacjom w celu wyświetlenia ich na scenie w określonej pozycji, wielkości, itd. Takie bufory są zwykle wypełniane raz, w całości, najczęściej za jednym zablokowaniem.
Z kolei bufory dynamiczne są wykorzystywane znacznie intensywniej. Ich zawartość zmienia się bardzo często. Zazwyczaj są one najpierw wypełniane danymi o wierzchołkach, rysowane raz (jednym wywołaniem DrawPrimitive*
), a następnie opróżniane i wypełniane od początku. Dodawanie wierzchołków do bufora odbywa się też w wielu wywołaniach metody Lock
.
Zastosowań buforów dynamicznych jest całkiem sporo. Bardzo klasycznym jest wykorzystanie ich do rysowania cząsteczek, czyli małych fragmentów geometrii, które występują w dużych ilościach i potrafią dawać realistyczne złudzenie np. deszczu lub dymu. Przy umiejętnym stosowaniu taki bufor może być blokowany i wypełniany nowymi danymi w czasie gdy karta graficzna zajmuje się rysowaniem poprzednich wierzchołków. Dzięki temu procesor i karta są zawsze zajęte i ich możliwości są maksymalnie wykorzystane (przynajmniej w teorii).
My oczywiście nie będziemy rysowali tak wielkiej ilości prymitywów (typowo ilość cząsteczek liczy się w tysiącach), lecz użycie dynamicznego VB da i nam spore korzyści. Ponieważ będziemy do niego tylko dodawali nowe quady, możemy zastosować odpowiednie flagi podczas blokowania bufora. Właśnie do tego służy pole m_dwNextLockFlags
– przechowuje ono odpowiednie opcje dla kolejnych wywołań metod Lock
naszych buforów.
Pora na przyjrzenie się upragnionej funkcji DrawImage
. Powiedzieliśmy sobie aczkolwiek, że metoda ta naprawdę nie będzie niczego rysowała, a tylko wypełniała bufory odpowiednimi danymi. Czynność ta nie jest jednak taka prosta i dlatego nasza funkcja jest całkiem długa:
Zaczynamy od sprawdzenia, z jakiej tekstury korzysta obrazek, który właśnie chcemy ‘narysować’. Jeżeli jest ona taka sama, jak quadów znajdujących się już w buforze, to wszystko jest w porządku. W przeciwnym razie będziemy musieli ją zmienić i dlatego wywołujemy wtedy metodę Flush
, która zajmuje się renderowaniem (i którą napiszemy za chwilę).
Następnie trzeba zająć się buforami. Najpierw blokujemy bufor wierzchołków, a dokładniej jego czterowierzchołkową porcję. Zwróćmy uwagę, że do określenia offsetu w buforze używamy licznika m_uQuads
, zaś jako flagi przekazujemy wartość pola m_dNextLockFlags
. Wkrótce wyjaśnimy sobie dokładnie jak ono faktycznie działa. Po zablokowaniu wypełniamy dane czterech wierzchołków tworzących nasz quad. Korzystamy przy tym koordynatów tekstury obliczonych w klasie CImage
oraz ustalonej konwencji numerowania wierzchołków quada. Kolor rozproszenia jest domyślnie ustawiony na biały; będzie on neutralny przy wybranych przez nas później stanach faz tekstur. Po zakończeniu pracy z VB oczywiście go odblokowujemy.
Nieco bardziej skomplikowana jest sprawa z buforem indeksów. Prostokątne quady można rysować z użyciem wielu rodzajów prymitywów – zarówno list trójkątów, jak i ich pasów (strips) i wachlarzy (fans). Wybraliśmy to pierwsze rozwiązanie, z którego wynika, że potrzebujemy 6 indeksów na opis dwóch trójkątów każdego quada. Ich bazowe wartości są zawarte w tablicy QUAD_INDICES_CCW
i są przeznaczone dla trybu eliminacji niewidocznych trójkątów (cullmode) ustawionemu na domyślną wartość D3DCULL_CCW
. Wybór tego trybu generalnie nie ma znaczenia, bowiem w funkcji Flush()
i tak wyłączymy culling :)
Po zablokowaniu bufora indeksów (bardzo podobnie do bufora wierzchołków) wypełniamy go odpowiednimi wartościami. Indeksy z tablicy musimy mianowicie przesunąć o wartość 4 * m_uQuads
, aby wskazywały na nowododane wierzchołki. Na koniec oczywiście odblokowujemy bufor.
Funkcję kończymy dwoma oczywistymi czynnościami – zwiększeniem licznika quadów oraz ustawieniem aktualnej tekstury – oraz jedną tajemniczą. Tę drugą wyjaśnimy sobie za chwilę :)
W końcu przyszła pora, aby coś rzeczywiście narysować. Czyni to poniższa funkcja Flush
. Jej zadaniem jest wyświetlenie całej zawartości naszych buforów na ekranie:
“Aha!”, stwierdziłeś zapewne, “więc tutaj jest cały kod!” ;) Rzeczywiście w tej metodzie jest aż gęsto od wywołań funkcji Direct3D. Większość z nich ustawia przeróżne parametry urządzenia, dzięki którym nasze obrazki będą renderowały się prawidłowy. Żeby było śmieszniej, większość tych ustawień domyślnie jest inicjowana przez DX pożądanymi przez nas wartościami. Jeśli jednak jawnie je ustawiamy, wówczas nie ma problemów z współpracą naszego modułu 2D z innymi elementami sceny.
Zaczynamy od ustawienia tekstury oraz kilku jej stanów, odpowiedzialnych za sposób jej mieszania z kolorem rozproszenia wierzchołków. Te sześć linijek sprawia, że podając do DrawImage
kolor inny niż nieprzezroczysty biały możemy uzyskać dodatkowe efekty. I tak kolor biały z wartością alfy różną od 0xFF pozwoli na uzyskanie półprzezroczystego obrazka. Z kolei modyfikacja innych kanałów umożliwia koloryzację, dzięki czemu możliwe jest rysowanie tego samego obrazka w różnych odcieniach. Polecam poeksperymentowanie i przyjrzenie się efektom, mogą być interesujące :)
Dalsze trzy stany renderowania są klasycznym sposobem na włączenie alpha blendingu, który jest oczywiście niezbędny, by uzyskać jakiekolwiek efekty półprzezroczystości. Czwartym stanem jest culling, o którym wspomniałem przy okazji wypełniania bufora indeksów.
Następnie zajmujemy się przekształceniami. Jednym jakiego potrzebujemy jest projekcja ortogonalna, dlatego pozostałe dwa (świata i widoku) ustawia na identycznościowe (“nic nierobiące”). Na koniec informujemy DX, skąd ma brać dane do rysowania, czyli ustawiamy źródło wierzchołków, FVF oraz indeksy.
No i… rysujemy :) DrawIndexedPrimitive
to jedna z tych funkcji, które wymagają dłuższego zastanowienia się, gdy przychodzi do napisania ich wywołania. Pierwszy z jej parametrów to naturalnie identyfikator typu używanych prymitywów – u nas będą to listy trójkątów. Drugi to liczba dodawana do każdego indeksu przed jego użyciem; nasze indeksy są od razu w porządku, więc wpisujemy tu zero. Trzeci argument to minimalna wartość indeksu wierzchołka, a czwarty to liczba wierzchołków użytych w wywołaniu. Oba mają znaczenie czysto optymalizacyjne, lecz powinny być określone przynajmniej z grubsza poprawnie. My na szczęście znamy ich dokładne wartości :) Piąty parametr to offset w buforze indeksów, od którego chcemy zacząć rysowanie; zaczynamy od początku, więc wpisujemy tu też zero. W końcu szósty parametr to liczba rysowanych prymitywów. Jako ze quad wyznaczają dwa trójkąty, wpisujemy tam wartość odzwierciedlającą ten fakt.
Po narysowaniu quadów musimy zresetować naszego canvasa, czyli wyzerować wskaźnik tekstury i “opróżnić” bufory. Tak naprawdę jednak niczego nie będziemy z nich usuwali, gdyż wszystko załatwi odpowiedni sposób ich blokowania. Jest to w gruncie rzeczy całkiem proste.
Na początku – czyli tuż po stworzeniu obiektu CCanvas
lub zaraz po wywołaniu Flush
– funkcja DrawImage
będzie blokowała bufory z flagą D3DLOCK_DISCARD
. Spowoduje ona, że DirectX uzna całą zawartość bufora za nieważną i pozwoli na jej wypełnienie nowymi danymi. Jest to ważne, gdyż kolejne wywołania DrawImage
korzystają już z innej flagi: D3DLOCK_NOOVERWRITE
. Jak wskazuje jej nazwa, zabrania ona nadpisywania raz wypełnionej części bufora. Wszystko jest jednak w porządku, ponieważ wypełniamy go sekwencyjnie, quad po quadzie, kolejnymi wywołaniami DrawImage
. Dopiero gdy przychodzi czas na wywołanie Flush
i geometria jest rysowana, przychodzi czas na zmianę flagi z powrotem na D3DLOCK_DISCARD
. Kolejne blokowanie unieważnia cały bufor, my go znów wypełniamy – i tak dalej.
Cała ta procedura ma oczywiście jeden cel: uczynienie pracy z buforem jak najbardziej efektywną. W rzeczywistości tę technikę można by nawet jeszcze trochę ulepszyć, ale odbyłoby to się rzecz jasna kosztem skomplikowania kodu – który i tak zbyt prosty nie jest :)
I tak oto dotarliśmy do epilogu. Udało nam się napisać moduł grafiki 2D, który w efektywny sposób potrafi rysować oteksturowane quady. Wyposażyliśmy go też w interfejs, który pozwala rysować sprite’y, czyli obrazki – u nas będące prostokątnymi fragmentami tekstur. Przy okazji zahaczyliśmy o różne tematy związane z używaniem Direct3D i omówiliśmy w miarę dokładnie korzystanie z dynamicznych buforów wierzchołków.
Należy też wspomnieć, że podany sposób nie jest jedynym na rysowanie dwuwymiarowych sprite’ów w DirectX. Biblioteka D3DX dysponuje przeznaczoną do tego celu klasą ID3DXSprite
, która stosuje buforowanie w podobny sposób jak CCanvas
.
Stworzoną w tym artykule klasę CCanvas
możemy łatwo rozszerzyć przynajmniej o dwie dodatkowe metody:
DrawImage
, która zamiast współrzędnych X,Y przyjmuje cały prostokąt, w który zostanie wpasowany obrazek. Zmiany w stosunku do aktualnej wersji są tu czysto kosmetyczne.FillRect
, rysującą prostokąt wypełniony danym kolorem i wykorzystującą nową wersję DrawImage
. Wystarczy zapewnić, że w przypadku podania parametru pImg
równego zeru, wyzerowany będzie też wskaźnik na aktualną teksturę m_pTex
((Uwaga: to teoretycznie nie musi działać – patrz: tutaj)).Dalej możemy też dodać rysowanie poziomych i pionowych linii, prostokątnych ramek czy nawet bardziej skomplikowanych figur geometrycznych. Jestem pewien, że masz też mnóstwo własnych pomysłów na rozszerzenie funkcjonalności stworzonego tu modułu.
Oprócz dodawania nowych funkcji, sporo można jeszcze poprawić :) W tym wypadku oznacza to rzecz jasna, że kod można zoptymalizować i przyspieszyć. Oto propozycje tego, co można zmienić:
Flush
) jego zawartość kopiowalibyśmy do VB i IB. Zysk byłby tym większy, im więcej obrazków korzystających z jednej tekstury rysowalibyśmy naraz.Flush
rysuje cały bufor, więc następujące dalej wywołanie DrawImage
– a dokładniej blokowanie bufora – musi poczekać, aż karta graficzna skończy pracę. Przy zastosowaniu pierwszej rady czas oczekiwania by się zmniejszył, ale można go zredukować też w inny sposób. Wystarczy nie opróżniać buforów przy każdym Flush
u; niech następne quady dodają się po prostu za tymi już narysowanymi. Wymaga to dodatkowego pola kontrolującego, która część bufora została już posłana do renderingu, a która nie.CCanvas
, można pozbyć się ustawiania wszystkich stanów renderowania w funkcji Flush
i umieścić je w dodatkowej metodzie (np. Begin
), którą użytkownik musiałby wywoływać przed narysowaniem czegokolwiek. Przy odrobienie dobrej woli można zrobić też tak, by Flush
wykrywał, czy Begin
zostało wywołane czy nie, i w razie potrzeby ustawiał wszystko tak jak teraz.alpha test
. Widać tutaj prostą prawidłowość: im bardziej agresywna jest optymalizacja, tym większą pracę zrzuca ona na końcowego użytkownika modułu. Podobnie jest niestety w prawie każdym zagadnieniu związanym z programowaniem grafiki. Sztuką jest odpowiednio zbalansować wygodny interfejs i efektywność działania.