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:
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:
- Kąt widzenia w pionie, wyrażony w stopniach. Dla najlepszego wrażenia powinien teoretycznie odpowiadać rzeczywistemu kątowi pod jakim widzimy viewport. Dla typowej sytuacji, gdy viewport zajmuje cały ekran monitora, a nasza głowa znajduje się w odległości dwa razy większej niż wysokość ekranu, kąt widzenia w pionie wyniesie 28 stopni. Zmniejszając kąt widzenia odniesiemy wrażenie patrzenia przez lornetkę, zwiększając, otrzymamy efekt obiektywu szerokokątnego.
- Proporcje prostokąta viewportu, stosunek jego szerokości w pikselach do wysokości. Manipulując tym parametrem otrzymamy widok rozciągnięty lub ściśnięty w poziomie.
- Bliski i daleki „kres świata”. W warunkach naturalnych nasze widzenie świata rozciąga się od jakichś 15 cm od oka do – w zasadzie – nieskończoności (patrząc w nocne niebo gołym okiem jesteśmy w stanie widzieć obiekty odległe o 2,5 miliona lat świetlnych...). Oczywiście wizualizacja komputerowa ma swoje ograniczenia, więc widziana przez OpenGL przestrzeń ma swój kres bliższy i dalszy. Wszystko, co jest przed bliskim kresem świata, oraz poza dalekim kresem świata, zostanie zignorowane. Te dwie płaszczyzny kontrolują też pracę tzw. bufora głębokości odpowiadającego za sortowanie ścianek według ich odległości od obserwatora. Z powodu sposobu działania tego bufora, rozstawienie kresów świata zbyt daleko od siebie wpływa negatywnie na jego precyzję. Z tego samego powodu bliski kres świata nie może znajdować się przed ekranem, ani pokrywać się z ekranem (z = 0). Formalnie więc obie płaszczyzny muszą mieć ujemną współrzędną z, niemniej GLUPerspective() przyjmuje je ze znakiem dodatnim. W naszym przykładzie bliski kres znajduje się tuż za ekranem, daleki w pewnej sporej odległości.
Wszystkie te parametry podawane są jako liczby zmiennoprzecinkowe pojedynczej precyzji. Ponieważ kompilator domyślnie traktuje zmiennoprzecinkowe 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:
- Ilość współrzędnych na wierzchołek. Zazwyczaj są trzy, ale w specyficznych zastosowaniach mogą być też dwie albo cztery.
- Odstęp w bajtach między danymi dwóch kolejnych wierzchołków. Specjalna wartość 0 oznacza, że dane wierzchołków następują kolejno po sobie, bez przerw. Tak jest najczęściej i tak jest w przykładowym programie. Zamiast 0, dla przykładowej tablicy scena[] moglibyśmy równie dobrze podać 12 (bo dane każdego wierzchołka zajmują 12 bajtów). Gdyby np. po danych każdego wierzchołka umieszczona była jedna, dodatkowa liczba zmiennoprzecinkowa, podalibyśmy odstęp 16.
- Adres początku tablicy.
Wreszcie GLDrawArrays() odpala rysowanie. Parametry tej funkcji doprecyzowują tablicę:
- Rodzaj rysowanych figur (punkty, linie, trójkąty, czworokąty).
- Numer początkowego wierzchołka. Jeżeli jest większy od zera, TinyGL opuści wskazaną ilość wierzchołków z początku tablicy.
- Ilość wierzchołków (nie figur!) do narysowania. Jeżeli tablica zawiera trójkąty, to wypadałoby, żeby dzieliła się przez trzy.
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
- Strona firmy RastPort nie zapisuje ciasteczek ani innych danych na Twoim komputerze.
- Strona nie gromadzi żadnych danych odwiedzających osób.
- Serwer WWW operatora hostingu prowadzi standardowe logi zawierające godzinę dostępu, adres IP, identyfikator przeglądarki (User-Agent), adres URL zapytania, jego wynik i liczbę przesłanych bajtów. Firma RastPort nie przetwarza tych danych, mogą one być przetwarzane przez operatora hostingu do celów statystycznych, utrzymania ruchu i w przypadku ataków na infrastrukturę operatora.