Tech Blog – MorphOS: TinyGL, dwa trójkąty

Uwaga! Artykuł pochodzi z 2014 roku i może być częściowo nieaktualny.

Przygodę z OpenGL zaczniemy od rzeczy najprostszych, a więc narysowania statycznych trójkątów. Przy okazji dowiemy się jak zainicjalizować TinyGL pod MorphOS-em i jak stworzyć viewport, na razie w systemowym oknie. Na zachętę końcowy efekt uzykany omawianym programem:

Dwa kolorowe trójkąty.

Ponieważ TinyGL jest zwykłą systemową biblioteką, otwieramy ją typowo, pamiętając jednak o tym, że nie jest otwierana automatycznie, więc w każdym wypadku przyjdzie nam użyć OpenLibrary(). Kolejnym krokiem będzie otworzenie systemowego okna i tu sięgniemy po bibliotekę intuition.library:

struct Window *win; win = OpenWindowTags(NULL, WA_InnerWidth, 640, /* szerokość wnętrza w pikselach */ WA_InnerHeight, 480, /* wysokość wnętrza w pikselach */ WA_IDCMP, IDCMP_CLOSEWINDOW, /* odbierane komunikaty systemowe */ WA_Title, (IPTR)"CzyDe", /* tytuł okna */ WA_ScreenTitle, (IPTR)"CzyDe 1.0", /* tytuł ekranu */ WA_DragBar, TRUE, /* belka do przesuwania */ WA_CloseGadget, TRUE, /* gadżet zamknięcia */ WA_DepthGadget, TRUE, /* gadżet zmiany głębokości */ TAG_END);

Tym kodem otwieramy zwyczajne systemowe okienko o rozmiarach wnętrza 640×480 pikseli, życzymy sobie mieć systemowy gadżet zamykania, systemowy gadżet do wyciągania okna na wierzch i posyłania pod spód, oraz standardową belkę z tytułem, za którą można okno przesuwać. Oprócz tego ustawiamy tytuł okna, oraz tytuł ekranu wyświetlany w czasie, gdy okno jest aktywne. Słowem, podstawowy zestaw właściwości typowego okna. Oprócz tego tagiem WA_IDCMP wybieramy kategorie systemowych zdarzeń, o których życzymy sobie wiedzieć. Na razie interesuje nas tylko jedna – żądanie zamknięcia okna.

Przejdźmy teraz do głównej pętli programu, w której na razie zajmiemy się wyłącznie oczekiwaniem na zamknięcie okna. Pętla wygląda następująco:

BOOL running = TRUE; struct IntuiMessage *m; ULONG signals, portmask; portmask = 1 << win->UserPort->mp_SigBit; while (running) { signals = Wait(portmask | SIGBREAKF_CTRL_C); if (signals & portmask) { while (m = (struct IntuiMessage*)GetMsg(win->UserPort)) { if (m->Class == IDCMP_CLOSEWINDOW) running = FALSE; ReplyMsg(&m->ExecMessage); } } if (signals & SIGBREAKF_CTRL_C) running = FALSE; }

Pętla pozwala również na przerwanie programu sygnałem CTRL-C wysłanym np. z konsoli. Obsługa tego sygnału jest niejako obowiązkiem każdego w zgodzie z wytycznymi napisanego programu. Każde okno w systemie posiada MsgPort, do którego nadchodzą wiadomości o zdarzeniach systemowych. Najpierw na bazie numeru bitu sygnału przypisanego do portu okna wyliczam maskę bitową, następnie sumuję ją logicznie z maską sygnału CTRL-C. Po odebraniu sygnału (albo sygnałów) sprawdzam które nadeszły i podejmuję odpowiednie akcje. Zwróćmy uwagę na sposób obsługi sygnału z portu – jego otrzymanie oznacza, że w kolejce wiadomości portu znajduje się zero lub więcej wiadomości, stąd dodatkowa pętla while. Założenie „jeden sygnał – jedna wiadomość” to szkolny błąd popełniany przez początkujących programistów. Każda wiadomość systemowa po jej obsłużeniu powinna być zwrócona do nadawcy przez wywołanie ReplyMsg().

Pętli głównej poświęciłem sporo uwagi. Chociaż nie jest ona bezpośrednio związana z zagadnieniami 3D, staje się później szkieletem programu, zapewniając obsługę knowań użytkownika (mysz, klawiatura), oraz prawidłowy rozwój zdarzeń w czasie (animacja). Warto zauważyć, że nasz program wyświetlający dwa trójkąty, po ich narysowaniu po prostu odda procesor systemowi, oczekując na zamknięcie okienka, lub sygnał przerywający. Warto, aby każdy program zachowywał się w podobny sposób. Oczywiście rozbudowane programy 3D mogą mieć na tyle duże zapotrzebowanie na procesor, że nie będzie już co systemowi zostawić. Jakże często jednak widzi się proste gry, które niezależnie od mocy sprzętu obciążają całkowicie procesor, albo zupełnie zbędnie renderując 300 ramek na sekundę, albo – o zgrozo – czekając na następną ramkę animacji w zamkniętej pętli (ang. busy loop).

Kontekst okna

Czas wreszcie przejść do TinyGL. Mając już pod ręką okno można uczynić je viewportem tworząc najpierw kontekst OpenGL, a następnie związany z okienkiem, ekranem, na którym się ono znajduje i sterownikiem karty graficznej kontekst GLA. Robi się to następująco:

struct TagItem inits[] = { { TGL_CONTEXT_WINDOW, 0 }, { TAG_END, 0 } }; inits[0].ti_Data = (IPTR)win; context = GLInit(); GLAInitializeContext(context, inits);

Kontekst OpenGL tworzymy wywołując GLInit(), następnie dokładamy mu „do środka” kontekst GLA, który nie ma, jak widać, jawnego wskaźnika. Tak czy inaczej wnętrzności kontekstów nie powinny nas zbytnio interesować. Zabawa z taglistą wynika z faktu, że twórcy TinyGL zapomnieli o wygenerowaniu wersji funkcji ze zmienną ilością argumentów. Tag TGL_CONTEXT_WINDOW oznacza, że chcemy powiązać kontekst z oknem, jego wartością jest wskaźnik na strukturę Window. Oczywiście w programie trzeba sprawdzać zarówno wynik GLInit() (powinien być niezerowy), jak i wynik GLAInitializeContext(), ten powinien być logiczną wartością TRUE. W programie przykładowym zaimplementowałem podstawową obsługę błędów.

Przygotowanie do rysowania

Zanim zasypiemy widok trójkątami, trzeba wykonać kilka czynności przygotowawczych. Zacznijmy od poinformowania TinyGL o rozmiarach viewportu:

GLViewport(context, win->BorderLeft, win->BorderTop, 640, 480);

Podajemy współrzędne lewego górnego rogu viewportu w odniesieniu do górnego lewego rogu okna, co odpowiada szerokości lewej i górnej ramki. Oczywiście można mieć viewport nie obejmujący całego wnętrza okna, wtedy odpowiednio zmieniamy współrzędne. Dwa ostatnie argumenty to szerokość i wysokość viewportu w pikselach. Teraz trochę bardziej zaawansowana sprawa, mianowicie ustawienie dwóch macierzy: macierzy transformacji obiektu (ang. modelview matrix) i macierzy projekcji (ang. projection matrix). Zachowajmy jednak spokój. Oczywiście za tymi macierzami stoi odpowiednio skomplikowana matematyka, ale chwilowo nie będziemy się nią zajmowali. Macierz transformacji obiektu określa jak się ma układ współrzędnych w którym definiujemy nasze śliczne trójkąciki, do układu współrzędnych kamery (czy też, jak kto woli, obserwatora). Macierz projekcji zaś określa perspektywę widoku. Najpierw zobaczmy jak to wygląda w kodzie:

GLMatrixMode(context, GL_MODELVIEW) GLLoadIdentity(context); GLMatrixMode(context, GL_PROJECTION); GLLoadIdentity(context); GLUPerspective(context, 60.0f, 1.333333333f, 0.01f, 200.0f);

Funkcją GLMatrixMode() ustalamy, którą macierz będziemy definiować. Dla każdej z nich OpenGL tworzy stos przekształceń. Możemy tworzyć kombinacje przekształceń (najczęściej przesunięć i obrotów), co odpowiada mnożeniu przez siebie ich macierzy. Zazwyczaj rozpoczynamy ten stos przekształceń od macierzy jednostkowej, która oznacza przekształcenie tożsamościowe (czyli nie wnoszące żadnych zmian). Tak jest i tutaj, na dno stosu transformacji obiektów wrzucamy macierz jednostkową używając funkcji GLLoadIdentity(). I na tym koniec w naszym przykładzie. Oznacza to, że wierzchołki naszych trójkątów będziemy definiować w układzie współrzędnych obserwatora, pokazanym w poprzednim artykule. Zatem układ ten ma środek dokładnie na środku viewportu, a oś z celuje nam strzałką w oko. Osoby co nieco zorientowane już w sprawach 3D zauważą, że takie rozwiązanie ma szereg wad, do których dojdziemy, gdy zajmiemy się animacją obiektów. Na razie jednak zostawmy to, a przejdźmy (przełączając stos przekształceń funkcją GLMatrixMode()) do macierzy projekcji. Nie wdając się w zawiłe rozważania, macierz ta określa perspektywę widoku. Tak jak w fotografii, możemy mieć płaską i przybliżającą perspektywę teleobiektywu, możemy też mieć szerokokątną perspektywę „rybiego oka”. Bezpośrednie manipulowanie elementami tej macierzy (o rozmiarach 4×4) pozwala na uzyskiwanie dziwacznych nieraz efektów, jednak wyliczenie tych elementów w celu uzyskania w miarę normalnego widoku oznacza babranie się we wzorach. Na szczęście na ratunek przybywa biblioteka GL Utility wraz z funkcją GLUPerspective(), która sama obliczy macierz projekcji bazując na znacznie bardziej zrozumiałych parametrach. A są to po kolei:

Parametry perspektywy: kąt widzenia w pionie, bliski i daleki kres świata.

Wszystkie te parametry podawane są jako liczby zmienno­przecinkowe pojedynczej precyzji. Ponieważ kompilator domyślnie traktuje zmienno­przecinkowe stałe liczbowe z precyzją podwójną, litera f na końcu uświadamia mu, żeby tego nie robił. Pojedyncza precyzja jest powszechnie stosowana w OpenGL więc przyrostek f będzie nam nieustannie towarzyszył.

Jak widać na rysunku, przestrzeń pomiędzy krańcami świata OpenGL ma kształt ostrosłupa ściętego o podstawie prostokąta i często jest określana angielskim słowem frustum. W tej przestrzeni powinna się zmieścić cała nasza scena 3D.

Pora coś nabazgrać

Zanim przejdziemy w końcu do trójkątów, czeka nas jeszcze prozaiczna czynność, czyli wyczyszczenie viewportu. W programach ćwiczebnych tło jest zazwyczaj czarne, kolor tła ustawiamy funkcją GLClearColor():

GLClearColor(context, 0.0f, 0.0f, 0.0f, 1.0f);

Trzy pierwsze wartości, to składowe RGB, czwarta to przezroczystość (0.0 = całkowicie przezroczysty, 1.0 = zupełnie nieprzezroczysty). Pora zetrzeć tablicę:

GLClear(context, GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

Flaga GL_COLOR_BUFFER_BIT oznacza czyszczenie viewportu, GL_DEPTH_BUFFER_BIT czyszczenie bufora głębokości, który co prawda jeszcze tym razem nam się nie przyda, ale niech mu będzie. Nadeszła wiekopomna chwila rysowania trójkątów. Tu zazwyczaj kursy OpenGL opisują bezpośrednią metodę rysowania, czyli glBegin(), wysyłanie wierzchołków po jednym i glEnd(). To jest być może metoda najprostsza, ale bardzo niewydajna i – nawet jak na morphosową TinyGL – antyczna. Dlatego pominiemy ją i od razu zajmiemy się rysowaniem za pomocą tablic wierzchołków (ang. vertex arrays). Dlaczego metoda bezpośrednia jest zła? Prosty sześcian składa się z 12 trójkątów (po 2 na ściankę), co oznacza 36 wierzchołków. Pykanie nimi po jednym w stronę OpenGL to 36 wywołań funkcji, a dwa razy tyle gdy będziemy chcieli sześcian pokolorować. Gdy natomiast umieścimy wierzchołki w tablicy, wystarczy jedno wywołanie (opcjonalnie drugie na kolory). Zatem do pracy, zaczniemy od tablic:

float scena [18] = { 0.0f, 0.0f, -5.0f, 2.0f, 0.0f, -5.0f, 0.0f, 2.0f, -5.0f, /* pierwszy trójkąt */ -3.0f, -3.0f, -8.0f, -3.0f, 1.0f, -7.0f, 0.0f, -3.0f, -8.0f /* drugi trójkąt */ };

Jak widać w vertex array nie ma żadnej magii, to po prostu trójki współrzędnych (x, y, z) wierzchołków kolejnych trójkątów. Z reguły współrzędne wierzchołków podaje się jako liczby zmiennoprzecinkowe pojedynczej precyzji. Warto jednak wiedzieć, że TinyGL akceptuje również inne typy: całkowite liczby 16-bitowe ze znakiem, całkowite liczby 32-bitowe ze znakiem i liczby zmiennoprzecinkowe podwójnej (double) precyzji. Oczywiście jeżeli wybierzemy liczby całkowite, to wierzchołki nie mogą mieć niecałkowitych współrzędnych, więc z tej możliwości nieczęsto się korzysta. W zasadzie jedyną korzyścią z użycia WORD-ów jest o połowę mniejszy rozmiar tablicy. Jeżeli obiekty zbudowane są na regularnej siatce, to może mieć jakiś sens. Z drugiej jednak strony trzeba się liczyć z tym, że korzystanie tu z innych typów, niż float obniży wydajność działania TinyGL. Załadujmy teraz wierzchołki i narysujmy je:

GLEnableClientState(context, GL_VERTEX_ARRAY); /* aktywacja tablicy wierzchołków */ GLVertexPointer(context, 3, GL_FLOAT, 0, scena); /* załadowanie tablicy */ GLDrawArrays(context, GL_TRIANGLES, 0, 6); /* rysujemy */ GLDisableClientState(context, GL_VERTEX_ARRAY); /* deaktywacja tablicy wierzchołków */

OpenGL przy rysowaniu może korzystać z kilku różnych tablic, oczywiście ta z wierzchołkami jest najważniejsza. Żeby było wiadomo, które z tablic będą w użyciu, każdą z nich włącza się funkcją GLEnableClientState(), a po zakończonym rysowaniu wyłącza przez GLDisableClientState(). Funkcja GLVertexPointer() definiuje samą tablicę, a więc kolejno:

Wreszcie GLDrawArrays() odpala rysowanie. Parametry tej funkcji doprecyzowują tablicę:

Odpalamy zatem kod. I co? I nic! Okienko nadal straszy systemową szarością. Gdzie są nasze trójkąty? Wyjaśnieniem tej tajemnicy jest podwójne buforowanie. Jeżeli kontekst GLA jest kontekstem okna lub ekranu, TinyGL automatycznie stosuje podwójne buforowanie, przy czym rysowanie zawsze odbywa się w tym buforze, który nie jest wyświetlany. Po narysowaniu sceny trzeba więc przełączyć bufory:

GLASwapBuffers(context);

Pełen sukces. Co prawda trójkąty są jednolicie białe, ale jeszcze ich nie kolorowaliśmy, a domyślnym kolorem wierzchołków jest właśnie biały.

Kolorowanie proste

Sprawa jest w zasadzie dość prosta, trzeba się tylko przyzwyczaić, że w OpenGL kolory mają nie figury, a ich wierzchołki. Zatem trójkąt ma trzy kolory, a nie jeden. Oczywiście jeżeli chcemy mieć ściankę w jednolitym kolorze, po prostu nadajemy go wszystkim wierzchołkom. W przykładzie banalnie nadałem wierzchołkom jednego trójkąta kolory RGB, a wierzchołkom drugiego kolory dopełniającej triady CMY. Operując bardziej zbliżonymi do siebie kolorami można uzyskać znacznie bardziej eleganckie cieniowania... Kolory wierzchołków umieszczamy w tablicy analogicznej do samych wierzchołków, również używając liczb zmiennoprzecinkowych pojedynczej precyzji, w zakresie od 0.0 (brak składowej) do 1.0 (maksymalna intensywność składowej):

float kolory[18] = { 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, /* pierwszy trójkąt: niebieski, zielony, czerwony */ 1.0f, 1.0f. 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 1.0f /* drugi trójkąt: żółty, cyjan, magenta */ };

Zmodyfikowany kod rysujący wygląda następująco:

GLEnableClientState(context, GL_VERTEX_ARRAY); /* aktywacja tablicy wierzchołków */ GLEnableClientState(context, GL_COLOR_ARRAY); /* aktywacja tablicy kolorów */ GLVertexPointer(context, GL_FLOAT, 3, 0, scena); /* załadowanie tablicy wierzchołków*/ GLColorPointer(context, GL_FLOAT, 3, 0, kolory); /* załadowanie tablicy kolorów*/ GLDrawArrays(context, GL_TRIANGLES, 0, 6); /* rysujemy */ GLDisableClientState(context, GL_VERTEX_ARRAY); /* deaktywacja tablicy wierzchołków */ GLDisableClientState(context, GL_COLOR_ARRAY); /* deaktywacja tablicy kolorów */

Zwróćmy uwagę na fakt, że dana tablica musi być aktywna przed ładowaniem jej funkcją GL[...]Pointer, oraz, że obie muszą być aktywne przed wywołaniem GLDrawArrays(). Parametry GLColorPointer() są analogiczne do GLVertexPointer(), z tym, że przy kolorach TinyGL akceptuje więcej typów danych: całkowite liczby 8-, 16- i 32-bitowe bez znaku i ze znakiem, oraz zmiennoprzecinkowe float i double. Szybkościowo znów najlepsze są „floaty”.

Tym razem trójkąty są już odpowiednio pstrokate, o czym można się przekonać uruchamiając kod przykładowy. Zachęcam do pobawienia się kodem, aby go skompilować, wystarczy wydać w konsoli polecenie make.

Ciasteczka i dane osobowe