Własny moduł grafiki 2D [PL]

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.

Sens tworzenia własnego modułu 2D

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.

Podstawy

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.

Określenie sposobu

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.

Określenie celu

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.

Klasa obrazka

Jak wyświetlić na ekranie dwuwymiarowy obrazek, posługując się biblioteką do grafiki 3D?… “Normalnie” oczekiwalibyśmy istnienia funkcji w rodzaju:

  1. void DrawImage(CImage* pImg, int iX, int iY);

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:

  • Częste przełączanie tekstur jest niezbyt efektywne. Gdyby było dokonywane przy rysowaniu każdego sprite’a, zmuszałoby to użytkownika naszego modułu do zwracania bacznej uwagi na to, w jakiej kolejności rysuje grafiki. Inaczej możemy dużo stracić na efektywności rysowania.
  • Wciąż zaleca się, aby wymiary tekstur były potęgami dwójki, chociaż wszystkie współczesne karty potrafią już sobie radzić z teksturami o dowolnych wymiarach (byle nie za dużych, rzecz jasna). W przypadku gdy każdy obrazek jest umieszczany na osobnej teksturze, nie można oczywiście postępować zgodnie z tym zaleceniem.
  • W pamięci karty z każdą teksturą są zawsze związane pewne dodatkowe dane, które czasami bywają nawet większe niż ona sama. Dlatego posiadanie dużej ilości małych tekstur – zwłaszcza jeśli wszystkie są w puli 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 z wieloma obrazkami
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.

Składowe klasy

Możemy już przejść do kodu klasy obrazka, którą nazwałem CImage. Wygląda on następująco:

  1. // domyślne koordynaty tekstury, obejmujące ją całą
  2. const D3DXVECTOR2 DEFAULT_TEX_COORDS = { { 0.0f, 0.0f }, // lewy górny
  3.                                       { 1.0f, 0.0f }, // prawy górny
  4.                                       { 1.0f, 1.0f }, // prawy dolny
  5.                                       { 0.0f, 1.0f }, // lewy dolny
  6.                                     };
  7.  
  8. // klasa dwuwymiarowego obrazka
  9. class CImage
  10. {
  11.    private:
  12.       LPDIRECT3DTEXTURE9 m_pTex;  // tekstura obrazka
  13.       D3DXVECTOR2 m_TexCoords[4];   // koordynaty tekstury dla quada
  14.  
  15.       // wymiary obrazka w pikselach
  16.       float m_fWidth, m_fHeight;
  17.  
  18.    public:
  19.       // konstruktory
  20.       explicit CImage(LPDIRECT3DTEXTURE9 pTex)
  21.          : m_pTex(pTex), m_TexCoords(DEFAULT_TEX_COORDS)
  22.       {
  23.            // pobieramy wymiary obrazka (patrz opis drugiego konstruktora)
  24.            D3DSURFACE_DESC d3dsd;
  25.            m_pTex->GetLevelDesc (0, &d3dsd);
  26.            m_fWidth = (float)d3dsd.Width;
  27.            m_fHeight = (float)d3dsd.Height;
  28.       }
  29.       CImage(LPDIRECT3DTEXTURE9 pTex, const RECT& rc);
  30.  
  31.       // pobiera współrzędne tekstury dla wierzchołka
  32.       const D3DXVECTOR2& GetTexCoords(int i) const { return m_TexCoords[i]; }
  33.  
  34.       // pobiera teksturę
  35.       LPDIRECT3DTEXTURE9 GetTexture() const { return m_pTex; }
  36.  
  37.       // pobiera wymiary
  38.       float GetWidth() const { return m_fWidth; }
  39.       float GetHeight() const { return m_fHeight; }
  40. };

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.

Implementacja

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:

  1. CImage::CImage(LPDIRECT3DTEXTURE9 pTex, const RECT& rc)
  2.    : m_pTex(pTex)
  3. {
  4.    // pobieramy wymiary tekstury
  5.    D3DSURFACE_DESC d3dsd;
  6.    m_pTex->GetLevelDesc (0, &d3dsd);
  7.    float fInvTexWidth = 1.0f / (float)d3dsd.Width;
  8.    float fInvTexHeight = 1.0f / (float)d3dsd.Height;
  9.  
  10.    // wierzchołek [0] - lewy górny
  11.    m_TexCoords[0].x = rc.left * fInvTexWidth;
  12.    m_TexCoords[0].y = rc.top * fInvTexHeight;
  13.  
  14.    // wierzchołek [1] - prawy górny
  15.    m_TexCoords[1].x = rc.right * fInvTexWidth;
  16.    m_TexCoords[1].y = m_TexCoords[0].y;
  17.  
  18.    // wierzchołek [2] - prawy dolny
  19.    m_TexCoords[2].x = m_TexCoords[1].x
  20.    m_TexCoords[2].y = rc.bottom * fInvTexHeight;
  21.  
  22.    // wierzchołek [3] - lewy dolny
  23.    m_TexCoords[3].x = m_TexCoords[0].x;
  24.    m_TexCoords[3].y = m_TexCoords[2].y;
  25.  
  26.    // obliczamy wymiary obrazka
  27.    m_fWidth = rc.right - rc.left;
  28.    m_fHeight = rc.bottom - rc.top;
  29. }

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ć.

Przygotowania do renderingu

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.

Format wierzchołków, czyli XYZ vs. XYZRHW

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:

  1. // wierzchołek renderowany przez moduł 2D
  2. struct VERTEX_2D
  3. {
  4.    D3DXVECTOR3 vPos;   // pozycja
  5.    D3DCOLOR clDiffuse;   // kolor rozproszenia
  6.    D3DXVECTOR2 vTex0;   // współrzędne tekstury
  7.  
  8.    // deklaracja FVF
  9.    static DWORD FVF = D3DFVF_XYZ | D3DFVF_DIFFUSE | D3DFVF_TEX1 | D3DFVF_TEXCOORD2(0);
  10. };

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.

Ustawienie przekształceń i kwestia rozdzielczości

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.

Rzut perspektywiczny
Ź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.

Rzut ortogonalny
Ź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:

  • Pierwszym jest utożsamienie puntu z pikselem i określenie projekcji tak, aby wierzchołek o danych współrzędnych X i Y znalazł się ostatecznie na ekranie w miejscu piksela o współrzędnych X i Y. Zaletą tego rozwiązania jest wygoda, zaś wadą przywiązanie do jednej rozdzielczości ekranu (albo raczej zależność rysowania grafiki od rozdzielczości).
  • Drugim wyjściem jest ustawienie zakresów na 0..1. Wówczas obiekty mogłyby być proporcjonalnie skalowane do zmieniającej się rozdzielczości.

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:

  1. // pobranie wymiarów okna
  2. RECT rc;
  3. GetClientRect (hWnd, &rc);
  4. float fWidth = (float)(rc.right - rc.left);
  5. float fHeight = (float)(rc.bottom - rc.top);
  6.  
  7. // obliczenie macierzy projekcji
  8. D3DXMATRIX mtxProj;
  9. D3DXMatrixOrthoOffCenterLH (&mtxProj, 0.0f, fWidth,  // zakres X
  10.    fHeight, 0.0f,   // zakres Y - odwrócony!
  11.    0.0f, 1.0f);   // zakres Z

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.

O buforowaniu

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:

  • użytkownik zechce narysować obrazek wymagający zmiany tekstury, lub
  • zostanie wywołana funkcja opróżniająca bufor – nazwijmy ją Flush, na przykład pod koniec renderowania sceny

Takie 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.

Główna klasa modułu 2D

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.

Składowe klasy

Definicja klasy CCanvas przedstawia się następująco:

  1. // główna klasa modułu 2D
  2. class CCanvas
  3. {
  4.    // maksymalna liczba buforowanych quadów
  5.    static UINT MAX_BUFFERED_QUADS = 2048;
  6.  
  7.    private:
  8.       LPDIRECT3DDEVICE9 m_pDev;  // urządzenie
  9.  
  10.       // bufory na rysowane quady
  11.       LPDIRECT3DVERTEXBUFFER9 m_pVB;
  12.       LPDIRECT3DINDEXBUFFER9 m_pIB;
  13.       DWORD m_dwNextLockFlags;
  14.  
  15.       // pozostałe dane
  16.       UINT m_uQuads;  // liczba quadów w buforze
  17.       LPDIRECT3DTEXTURE9 m_pTex;   // aktualna tekstura dla quadów
  18.       D3DXMATRIX m_mtxProj;   // macierz projekcji
  19.  
  20.       // ustawia macierz projekcji
  21.       void SetupProjection(float fWidth, fHeight)
  22.          { D3DXMatrixOrthoOffCenterLH (&m_mtxProj, 0.0f, fWidth,
  23.                    fHeight, 0.0f, 0.0f, 1.0f); }
  24.  
  25.       // ustawia bufory
  26.       void SetupBuffers()
  27.       {
  28.          // VB
  29.          m_pDev->CreateVertexBuffer (MAX_BUFFERED_QUADS * sizeof(VERTEX_2D),  // wielkość bufora
  30.              D3DUSAGE_DYNAMIC | D3DUSAGE_WRITEONLY, // flagi użycia VB - patrz niżej
  31.              VERTEX_2D::FVF, D3DPOOL_DEFAULT, &m_pVB);
  32.  
  33.         // IB
  34.         m_pDev->CreateIndexBuffer (MAX_BUFFERED_QUADS * 6,  // wielkość - zob. niżej
  35.              D3DUSAGE_DYNAMIC | D3DUSAGE_WRITEONLY,
  36.              D3DFMT_INDEX16, D3DPOOL_DEFAULT, &m_pIB);
  37.       }
  38.  
  39.    public:
  40.       CCanvas(LPDIRECT3DDEVICE9 pDev, float fWidth, float fHeight)
  41.          : m_pDev(pDev), m_pTex(0), m_uQuads(0), m_dwNextLockFlags(D3DLOCK_DISCARD)
  42.       {
  43.           SetupProjection (fWidth, fHeight);
  44.           SetupBuffers();
  45.       }
  46.  
  47.       CCanvas(LPDIRECT3DDEVICE9 pDev, HWND hWnd)   // tworzy canvas do rysowania w oknie
  48.          : m_pDev(pDev), m_pTex(0), m_uQuads(0), m_dwNextLockFlags(D3DLOCK_DISCARD)
  49.       {
  50.          RECT rc; GetClientRect (hWnd, &rc);
  51.          SetupProjection (rc.right - rc.left, rc.bottom - rc.top);
  52.          SetupBuffers();
  53.       }
  54.  
  55.       // opróżnia bufory i rysuje wszystkie quady
  56.       void Flush();
  57.  
  58.       // 'rysuje' obrazek
  59.       void DrawImage(CImage*, float fX, float fY, D3DCOLOR = 0xffffffff);
  60. };

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.

Słówko o dynamicznych buforach wierzchołków

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.

Funkcja ‘rysująca’ obrazki

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:

  1. // 'rysuje' podany obrazek w pozycji X,Y zmodulowany podanym kolorem
  2. void CCanvas::DrawImage(CImage* pImg, float fX, float fY, D3DCOLOR cl)
  3. {
  4.     if (!pImg) return;  // jakaś tam kontrolka błędów ;)
  5.  
  6.     // sprawdzamy teksturę podanego obrazka; jeżeli jest różna od aktualnej,
  7.     // to opróżniamy bufor rysując jego zawartość
  8.     if (pImg->GetTexture() != m_pTex && m_uQuads > 0)
  9.         Flush();
  10.  
  11.     // blokujemy VB i wypełniamy dane quada
  12.     VERTEX_2D* pQuad;
  13.     if (FAILED(m_pVB->Lock(m_uQuads * 4 * sizeof(VERTEX_2D),  // offset w buforze
  14.                             4 * sizeof(VERTEX_2D), // rozmiar blokowanej porcji
  15.                             (void**)&pQuad,  // adres wskaźnik na wynikową pamięć
  16.                             m_dwNextLockFlags)))  // sławetne flagi
  17.         return;
  18.  
  19.     // kolor i współrzędne tekstur
  20.     for (int i = 0; i < 4; ++i)
  21.     {
  22.         pQuad&#91;i].clDiffuse = cl;
  23.         pQuad&#91;i].vTex0 = pImg->GetTexCoords(i);
  24.     }
  25.  
  26.     // pozycja
  27.     pQuad[0].vPos = D3DXVECTOR3(fX, fY, 0.5f);
  28.     pQuad[1].vPos = D3DXVECTOR3(fX + pImg->GetWidth(), fY, 0.5f);
  29.     pQuad[2].vPos = D3DXVECTOR3(fX + pImg->GetWidth(), fY + pImg->GetHeight(), 0.5f);
  30.     pQuad[3].vPos = D3DXVECTOR3(fX, fY + pImg->GetHeight(), 0.5f);
  31.  
  32.     // odblokowujemy VB
  33.     m_pVB->Unlock();
  34.  
  35.     // wypełniamy bufor indeksów
  36.     static const WORD QUAD_INDICES_CCW[] = { 0, 1, 3,      1, 2, 3 };
  37.     WORD* pInd;
  38.     if (FAILED(m_pIB->Lock(m_uQuads * 6 * sizeof(WORD),  // offset w buforze
  39.                             6 * sizeof(WORD), // rozmiar blokowanej porcji (6 indeksów)
  40.                             (void**)&pInd,  // adres wskaźnik na wynikową pamięć
  41.                             m_dwNextLockFlags)))  // sławetne flagi
  42.         return;
  43.     for (int i = 0; i < 6; ++i)
  44.         pInd&#91;i] = 4 * m_uQuads + QUAD_INDICES_CCW[i];   // wpisujemy odpowiedni indeks wierzchłka
  45.     m_pIB->Unlock();
  46.  
  47.     // OK
  48.     m_uQuads++;
  49.     m_pTex = pImg->GetTexture();
  50.  
  51.     // ustawiamy flagę następnego blokowania
  52.     m_dNextLockFlags = D3DLOCK_NOOVERWRITE;
  53. }

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ę :)

Funkcja renderująca i krótki przegląd blokowania

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:

  1. // renderuje zawartość buforów canvasa
  2. void CCanvas::Flush()
  3. {
  4.     // ustawiamy teksturę oraz tryb mieszania kolorów i kanału alfa
  5.     m_pDev->SetTexture (0, m_pTex);
  6.     m_pDev->SetTextureStageState (0, D3DTSS_COLORARG1, D3DTA_CURRENT);
  7.     m_pDev->SetTextureStageState (0, D3DTSS_COLORARG2, D3DTA_TEXTURE);
  8.     m_pDev->SetTextureStageState (0, D3DTSS_COLOROP, D3DTOP_MODULATE);
  9.     m_pDev->SetTextureStageState (0, D3DTSS_ALPHAARG1, D3DTA_CURRENT);
  10.     m_pDev->SetTextureStageState (0, D3DTSS_ALPHAARG2, D3DTA_TEXTURE);
  11.     m_pDev->SetTextureStageState (0, D3DTSS_ALPHAOP, D3DTOP_MODULATE);
  12.  
  13.     // ustawiamy stany renderowania
  14.     m_pDev->SetRenderState (D3DRS_ALPHABLENDENABLE, TRUE);  // alpha blending
  15.     m_pDev->SetRenderState (D3DRS_SRCBLEND, D3DBLEND_SRCALPHA);
  16.     m_pDev->SetRenderState (D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHA);
  17.     m_pDev->SetRenderState (D3DRS_CULLMODE, D3DCULL_NONE);  // culling
  18.  
  19.     // ustawiamy przekształcenia
  20.     D3DXMATRIX mtxId;  D3DXMatrixIdentity (&mtxId);
  21.     m_pDev->SetTransform (D3DTS_WORLD, &mtxId);
  22.     m_pDev->SetTransform (D3DTS_VIEW, &mtxId);
  23.     m_pDev->SetTransform (D3DTS_PROJECTION, &m_mtxProj);  // projekcja
  24.  
  25.     // ustawiamy bufory
  26.     m_pDev->SetStreamSource (0, m_pVB, 0, sizeof(VERTEX_2D));
  27.     m_pDev->SetFVF (VERTEX_2D::FVF);
  28.     m_pDev->SetIndices (m_pIB);
  29.  
  30.     // rysujemy!
  31.     m_pDev->DrawIndexedPrimitive (D3DPT_TRIANGLELIST, // typ prymitywów
  32.                 0,  // baza indeksów w IB - 0 = indeksy bez żadnych modyfikacji
  33.                 0,  // minimalny indeks, 0 = indeksy mogą odwoływać się do całego VB
  34.                 m_uQuads * 4,  // liczba wierzchołków
  35.                 0,  // offset w buforze indeksów, 0 = rysujemy od początku
  36.                 m_uQuads * 2); // liczba prymitywów (trójkątów)
  37.  
  38.     // resetujemy
  39.     m_uQuads = 0;
  40.     m_pTex = 0;
  41.  
  42.     // ustawiamy flagi blokowania
  43.     m_dwNextLockFlags = D3DLOCK_DISCARD;
  44. }

“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 DrawImagekolor 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 :)

Podsumowanie

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.

Przykład wykorzystania modułu 2D

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.

Rozbudowa

Stworzoną w tym artykule klasę CCanvas możemy łatwo rozszerzyć przynajmniej o dwie dodatkowe metody:

  • przeciążoną wersję 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.
  • metodę 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.

Optymalizacje

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ć:

  • Zamiast blokować bufory przy każdym rysowanym quadzie, moglibyśmy najpierw zbierać po kilka (np. 4 lub 8) w pamięci operacyjnej. Dopiero po zapełnieniu się tego ‘przedbufora’ (lub przy wywołaniu 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.
  • Obecnie funkcja 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 Flushu; 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.
  • Można próbować innego sposobu grupowania, który uwzględnia nakładanie się quadów na siebie. Wówczas jeżeli np. najpierw rysujemy obrazki z tekstury A, potem B, a potem znowu A, możliwe by było załatwienie tego dwoma, a nie trzema rysowaniami. Takie rozwiązanie może jednak wymagać sortowania quadów względem ich kolejności albo użycia bufora Z, co oczywiście niezbyt współpracuje z alpha blendingiem
  • Jeżeli trochę skomplikujemy interfejs klasy 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.
  • W końcu można dać użytkownikowi możliwość rezygnacji z najbardziej czasochłonnej części procesu rysowania, czyli alpha blendingu. W przypadku, gdy potrzebujemy dla każdego piksela tylko biało-czarnej informacji: przezroczysty-nieprzezroczysty, powinniśmy zamiast tego zastosować znacznie szybszy 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.

Be Sociable, Share!
 


© 2023 Karol Kuczmarski "Xion". Layout by Urszulka. Powered by WordPress with QuickLaTeX.com.