Paradoksalnie jedna z głównych zalet biblioteki OpenGL, czyli przenośność, jest jest jednocześnie najważniejszym problemem przy optymalizacji programów napisanych z jej użyciem. Każdy procesor graficzny ma odmienną konstrukcję co wpływa na wydajność poszczególnych elementów biblioteki OpenGL. Poważnym problemem przy optymalizacji programów w OpenGL jest także różnorodność implementowanych wersji biblioteki, może się bowiem okazać, że wybór nowszych i zarazem szybszych rozwiązań drastycznie ogranicza ilość użytkowników mogących uruchomić program. Jak do tego wszystkiego dodamy częstą praktykę autorów sterowników wprowadzających optymalizację do konkretnych programów (najczęściej gier), to zauważamy, że zagadnienie optymalizacji jest bardzo trudne.
Jak w każdej dziedzinie programowania, także optymalizacja ma swoje granice zarówno możliwości jak i sensowności. Zawsze trzeba dobrze przemyśleć, czy próby przyspieszania działania programu generującego kilkudziesięciokrotnie większą ilość ramek sceny niż częstotliwość odtwarzania obrazu na monitorze, mają znaczenie dla odbiorcy programu. Zwłaszcza, że różnica w efekcie końcowym nie będzie zauważalna.
Poniżej skupimy się na wybranych technikach optymalizacji niezależnych od zagadnień sprzętowych. Jednak wyraźnie należy zaznaczyć, że nie jest to w żadnym razie lista wyczerpująca tematykę, ale raczej skromne wprowadzenie.
Pomiary szybkości
W pierwszej kolejności musimy wbudować w funkcję generującą pojedynczą ramkę sceny mechanizmy zliczające czas rysowania. Jedno z możliwych rozwiązań wykorzystywane było w wielu z przykładowych programów kursu OpenGL. Wykorzystano w nich funkcję clock ze standardowej biblioteki języka C, ale można także wykorzystać funkcję glutGet z parametrem
GLUT_ELAPSED_TIME:
int frame = 0, time, timebase = 0;
...frame++;
time = glutGet( GLUT_ELAPSED_TIME );
if( time - timebase > 1000 )
{
float fps = frame * 1000.0 /( float )( time - timebase ) );
timebase = time;
frame = 0;
}
Do pomiaru czasu można także wykorzystać bardziej precyzyjne mechanizmy udostępniane przez konkretne systemy operacyjne.
Weryfikacja błędów
Biblioteka OpenGL ma stosunkowo dużą tolerancję na błędy. Najczęstszą reakcją na błąd jest, poza jego zgłoszeniem, ignorowanie nieprawidłowej instrukcji. Błędy generowane przez bibliotekę OpenGL można obsługiwać na wiele sposobów. Jednym z nich jest wykorzystanie poniższego lub zbliżonego makra:
#define OPENGL_CHECK_ERROR (str) \
{ \ GLenum error; \ while ((error = glGetError ()) != GL_NO_ERROR) \
printf ("Error: %s (%s)\n",gluErrorString (error),str); \
}
Zasadniczo niezależnie od przyjętego modelu obsługi błędów w bibliotece OpenGL wykorzystywane będą dwie użyte także w powyższym makrze funkcje: glGetError i gluErrorString. Zauważmy także, że w przypadku obsługi błędów niektórych elementów biblioteki GLU niezbędna będzie także użycie innych, specjalizowanych funkcji tej biblioteki. Również obsługa błędów generowanych w trybie selekcji obiektów i sprzężenia zwrotnego wymaga zastosowania innych mechanizmów. Podobnie sprawa wygląda w przypadku korzystania z programów cieniowania.
Podstawowe zasady konstrukcji programów
Przykładowe program zawarte w kursie biblioteki OpenGL nie były one budowane z myślą o optymalizacji, ale celem nadrzędnym było zaprezentowanie kodu przejrzystego i możliwie łatwego do zrozumienia. Stąd tak charakterystyczna blokowa budowa, choć zgodna z ogólną ideą budowy aplikacji korzystających z biblioteki OpenGL nie była jednak pozbawiona wad z punktu widzenia szybkości.
Przypomnijmy zatem cztery główne etapy działania programu wykorzystującego bibliotekę OpenGL:
Generalną celem optymalizacji jest maksymalne skrócenie czasu działania czwartego etapu programu.
W przykładowych programach często elementy etapu drugiego i trzeciego znajdowały się w czwartym etapie. Nie trzeba w żaden specjalny sposób wyjaśniać, że niejednokrotnie miało to istotny wpływ na szybkość działania programów. Wydaje się, że dobrym ćwiczeniem dla Czytelnika będzie próba modyfikacji przykładowych programów i sprawdzenie czy tak proste metody pozwalają na zwiększenie szybkości ich działania.
Diagnozowanie wąskiego gardła wyda jności
Potok graficzny w bibliotece OpenGL, niezależnie od tego czy jest to potok klasyczny, czy też potok programowalny, składa się ze ściśle po sobie następujących operacji. Pierwszym etapem optymalizacji jest wykrycie który z elementów potoku stanowi „wąskie gardło” (ang. bottleneck) mające w konsekwencji wpływ na wydajność całego programu.
Przypomnijmy, że potok renderingu omówiliśmy w odcinku kursu poświęconemu językowi GLSL. Analizując jego przebieg można wyróżnić trzy podstawowe wąskie gardła aplikacji OpenGL:
Problem wydajności przetwarzania pikseli oznacza, że karta graficzna nie jest w stanie wykonać operacji na fragmentach/tekselach w zakładanym czasie. Te wąskie gardło wydajności jest stosunkowo łatwe do zdiagnozowania. Generalnie wystarczy zbadać wydajność aplikacji w zależności od wielkości obszaru renderingu (wielkości okna lub rozdzielczości ekranu). Jeżeli wydajność programu rośnie proporcjonalnie do spadającej ilości pikseli obrazu, mamy do czynienia z ograniczeniem wydajnościowym na etapie przetwarzania pikseli. Jeżeli korzystamy z programów cieniowania pikseli testy wykrywające powyższy problem można przeprowadzić przy użyciu uproszczonego programu cieniowania.
W przypadku, gdy redukcja rozmiarów obszaru renderingu nie ma wpływu na wydajność programu, mamy do czynienia z jednym z dwóch pozostałych wąskich gardeł wydajności. Jednym ze sposobów sprawdzenia czy mamy do czynienia z ograniczeniami w przetwarzaniu wierzchołków jest taka modyfikacja programu, aby w określonym stopniu zmniejszyć obciążenie GPU przetwarzaniem wierzchołków nie zmieniając jednocześnie ilości przetwarzanych danych. Przykładowo można zamienić wszystkie wywołania funkcji z grupy glVertex na funkcje mniej obciążające GPU - np. na funkcje glNormal. Podobną technikę można zastosować w stosunku do innych funkcji, przykładowo wywołania glDrawPixels i glBitmap można zamienić na funkcje glRasterPos. W przypadku, gdy aplikacja korzysta z programów cieniowania wierzchołków, trzeba odpowiednio zmniejszyć złożoność wykonywanego przez nie kodu.
Poniżej przedstawiamy przykładowe makra zamieniające wszystkie wywołania funkcji glVertex na wywołania funkcji glNormal:
#define glVertex2d (x,y) glNormal3d (x,y,0)
#define glVertex2f (x,y) glNormal3f (x,y,0)
#define glVertex2i (x,y) glNormal3i (x,y,0)
#define glVertex2s (x,y) glNormal3s (x,y,0)
#define glVertex3d (x,y,z) glNormal3d (x,y,z)
#define glVertex3f (x,y,z) glNormal3f (x,y,z)
#define glVertex3i (x,y,z) glNormal3i (x,y,z)
#define glVertex3s (x,y,z) glNormal3s (x,y,z)
#define glVertex4d (x,y,z,w) glNormal3d (x,y,z)
#define glVertex4f (x,y,z,w) glNormal3f (x,y,z)
#define glVertex4i (x,y,z,w) glNormal3i (x,y,z)
#define glVertex4s (x,y,z,w) glNormal3s (x,y,z)
#define glVertex2dv (v) glNormal3d (v[0],v[1],0)
#define glVertex2fv (v) glNormal3f (v[0],v[1],0)
#define glVertex2iv (v) glNormal3i (v[0],v[1],0)
#define glVertex2sv (v) glNormal3s (v[0],v[1],0)
#define glVertex3dv (v) glNormal3dv (v)
#define glVertex3fv (v) glNormal3fv (v)
#define glVertex3iv (v) glNormal3iv (v)
#define glVertex3sv (v) glNormal3sv (v)
#define glVertex4dv (v) glNormal3dv (v)
#define glVertex4fv (v) glNormal3fv (v)
#define glVertex4iv (v) glNormal3iv (v)
#define glVertex4sv (v) glNormal3sv (v)
W przypadku, gdy testy zmodyfikowanego programu wykażą wzrost wydajności jego działania proporcjonalny do zmniejszonego obciążenia GPU, problem wydajnościowy znajduje się w ilości przetwarzanych wierzchołków.
Jeżeli przeprowadzone testy wykazują, że problem nie leży po stronie przetwarzania pikseli ani po stronie przetwarzania wierzchołków najprawdopodobniej mamy do czynienia z problemem ogólnej wydajności aplikacji, która nie jest w stanie odpowiednio szybko dostarczyć danych dla GPU. Jest to sytuacja coraz częściej spotykana, zwłaszcza w trakcie programowana gier, bowiem współczesne procesory GPU są znacznie szybsze od procesorów CPU. W takim przypadku pozostają klasyczne metody profilowania programu, analiza i przebudowa struktur danych i inne techniki których opis wykracza poza ramy naszych zainteresowań.
Ograniczenia sprzętowe
Programista aplikacji działających na komputerach PC rzadko ma wiedzę na jakim sprzęcie uruchamiane będą programy (w tym wypadku można tylko pozazdrościć autorom gier na konsole). Stąd tworząc program korzystający z akceleratora graficznego trzeba brać pod uwagę wydajność różnych urządzeń znajdujących się na rynku oraz w komputerach użytkowników.
Generalnie na szybkość grafiki mają bezpośredni wpływ trzy elementy komputera: CPU, GPU oraz magistrala danych. Przepustowość tych ostatnich ciągle rośnie, a wśród standardów przedstawionych na rysunku 1, nie braku także opracowywanych specjalnie na potrzeby kart graficznych. W momencie pisania tych słów aktualnym standardem jest PCI Express 1.1, ale jest już także zatwierdzona specyfikacja jego następcy - magistrali PCI Express 2.0.
Tabela 1: Porównanie szybkości magistrali graficznych
Wymagania współczesnych kart graficznych w zakresie transferu danych są wprawdzie mniejsze niż możliwości magistrali PCI Express, ale w przeszłości zdarzały się sytuacje, gdy wąskim gardłem grafiki w komputerach PC były stosowane magistrale danych. W ocenie szybkości obecnych magistral trzeba także wziąć pod uwagę, że podane w tabeli 1 wartości wynikają z obliczeń teoretycznych. Rzeczywiste osiągane wyniki są oczywiście niższe.
Zestawienie wybranych przedstawicieli trzech najnowszych generacji GPU produkcji firm ATI/AMD i NVIDIA przedstawiono w tabeli 2. Wskaźnik przepustowości pamięci (ang. bandwidth) dotyczy oczywiście pamięci karty graficznej. Wydajność procesora można oceniać różnymi kryteriami, wskazana w tabeli wydajność (ang. fillrate) mierzona jest w MT/s - teoretycznej ilości przetwarzanych mega tekseli na sekundę.
Tabela 2: Porównanie wydajności procesorów graficznych
Ten nieco przydługi, historyczny wstęp miał na celu uświadomienie Czytelnikowi, że każda architektura komputerowa posiada pewne ograniczenia, które mają wpływ zarówno na szybkość przesyłania danych pomiędzy CPU a GPU, jak i na szybkość generowania grafiki trójwymiarowej. Jeżeli zatem z szacunków wynika, że wymagania aplikacji przekraczają możliwości techniczne aktualnych komputerów, to albo trzeba poczekać na kolejną generację kart graficznych, albo zmodyfikować wymagania i architekturę programu, w czym przydatne mogą okazać się umieszczone dalej wskazówki.
Obserwacja aktualnych trendów budowy procesorów graficznych wskazuje na systematyczną rozbudowę możliwości ich programowania (najnowszy trend to zunifikowane jednostki cieniowania), zwiększenie przepustowości pamięci (pamięci GDDR3/GDDR4) oraz powracające po kilkuletniej przerwie zwielokrotnienie procesorów (technologie Cross Fire i SLI). Stąd najbardziej perspektywicznym kierunkiem rozwoju aplikacji graficznej jest jak najszersze wykorzystanie możliwości obliczeniowych GPU i pamięci karty graficznej - obiektów buforowych wierzchołków (VBO), obiektów buforowych pikseli (PBO) oraz programów cieniowania fragmentów i programów cieniowania wierzchołków.
Metody redukcji obciążenia przetwarzania pikseli
Poniżej przedstawimy wybrane metody pozwalające na redukcję obciążenia związanych z przetwarzaniem pikseli.
Zmniejszenie wielkości obszaru renderingu
Zmniejszenie wielkości obszaru renderingu to najprostsza i w pełni skuteczna metoda redukcji ilości pikseli przetwarzanych przez aplikację. Niestety nie zawsze jest ona możliwa do zastosowania zarówno ze względów technicznych jak i oczekiwań użytkowników.
Redukcja ilości rysowanych ramek
To druga prosta metoda, która także nie zawsze może zostać zastosowana w praktyce. Teoretycznie wrażenie płynnej animacji powstaje w ludzkim oku już przy wyświetlaniu 25-30 ramek na sekundę. Jednak przy wyświetlaniu dynamicznie zmieniających się scen potrzebna jest jeszcze pewna rezerwa wydajności, tak aby zachować płynność animacji.
Zauważmy, że tę i poprzednią metodę powszechnie stosują programiści gier komputerowych. Użytkownik ma zazwyczaj do wyboru kilka rozdzielczości ekranu, a wybór uzależnia od wydajności komputera.
Zmniejszenie ilości bitów koloru
Stosunkowo najbardziej radykalną metodą zmniejszenia obciążenia związanego z przetwarzaniem pikseli jest redukcja ilości bitów przypadających na jedną składową koloru. Bez zauważalnej utraty jakości obrazu można np. zamiast 24 bitów na piksel użyć 15 bitów. Jednak metoda ta może wiązać się z ogólnym obniżeniem szybkości renderingu, bowiem współczesne procesory graficzne konstruowane są pod kątem optymalnej obsługi 24 lub 32 bitowego koloru.
Zmiana metody cieniowania
Zmiana metody cieniowania z cieniowana gładkiego na cieniowanie płaskie jest metodą optymalizacji, której zastosowanie może mieć bardzo znaczące konsekwencje dla jakości renderingu. Stąd jej stosowanie jest ograniczone do sytuacji, gdzie jakość generowanej grafiki ma znaczenie drugorzędne.
Nierysowanie tylnych stron wielokątów
Bardziej naturalną techniką, którą już bardzo dobrze poznaliśmy, jest włączenie nierysowania tylnych stron wielokątów. Trzeba jednak pamiętać, że jej zastosowanie ogranicza się co do zasady do zamkniętych brył lub innych obiektów, których tylne strony wielokątów nigdy nie są widoczne na scenie.
Wybór metody filtracji tekstur
Najlepsze jakościowe metody filtracji tekstur (np. filtracja trójliniowa) mają znaczny wpływ na ilość przetwarzanych pikseli, a tym samym na generowane obciążenie. Zmiana filtracji na mniej obciążającą potok przetwarzania pikseli, w sytuacjach, gdy jest to akceptowalne po względem uzyskanego efektu wizualnego, pozwala na zdecydowane przyspieszenie programu.
Zmniejszenie ilości testów bufora głębokości
Bufor głębokości jest bardzo prostą i efektywną w realizacji sprzętowej techniką ukrywania niewidocznych fragmentów sceny. Jednak każde test bufora głębokości wymaga określonego czasu pracy GPU. Zmniejszenie obciążenia bufora głębokości wymaga użycia dodatkowych technik. Może nią być np. obsługiwany sprzętowo test zasłaniania lub inne techniki redukujące ilość obiektów sceny jeszcze przed przystąpieniem do ich właściwego przetwarzania. Test głębokości nie musi być także wykonywany dla stałych i niezmiennych elementów sceny, które wystarczy narysować w odpowiedniej kolejności na początku rysowania sceny. W teorii można oczywiście całkowicie wyłączyć bufor głębokości, ale opłacalność takiego rozwiązania, poza szczególnymi sytuacjami, wydaje się mocno wątpliwa.
Wybór rodzaju mgły
Podobnie jak w przypadku filtracji tekstur wybór jakości mgły ma bezpośredni wpływ na szybkość przetwarzania pikseli. Przypomnijmy, że sposób obliczania mgły - dla wierzchołka lub dla piksela - umożliwia funkcja glHint z pierwszym parametrem równym
GL_FOG_HINT.
Wyłączenie roztrząsania kolorów
Domyślnie roztrząsanie kolorów (ang. dithering) w bibliotece OpenGL jest włączone. Jednak w przypadku, gdy korzystamy z 24 lub 32 bitowego odwzorowania pikseli stosowanie tej techniki jest zupełnie nieprzydatne.
Redukcja operacji specjalnych na pikselach
Wszystkie operacje specjalne wykonywane na pikselach, np. mieszanie kolorów, operacje logiczne, testy składowej alfa, test bufor szablonowego, nakładanie wzorów, zwiększają czas renderingu.
Łączenie czyszczenia buforów
Jeżeli czyścimy jednocześnie kilka buforów (np. bufor koloru i bufor szablonowy), to wykonanie tego zadania w jednym wywołaniu funkcji:
glClear( GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT );
będzie szybciej wykonane niż dwa odrębne czyszczenia:
glClear( GL_COLOR_BUFFER_BIT );
glClear( GL_STENCIL_BUFFER_BIT );
Ograniczenie obszaru czyszczenia buforów
Czyszczenie buforów należy przeprowadzać wyłącznie w sytuacji, gdy jest to niezbędne. Jeżeli systematycznego czyszczenia wymaga tylko pewien fragment obszaru renderingu, można skorzystać z funkcji glScissor, która ogranicza ten obszar.
Wybór formatu danych tekstur
Tekstury mogą być przechowywane w różnych formatach. Jednak niektóre formaty wewnętrzne są szybciej obsługiwane od innych. Niestety uzyskanie dokładnych informacji w tym zakresie najprawdopodobniej będzie wymagało przeprowadzenie testów różnych procesorów GPU. Generalnie należy się jednak spodziewać większej efektywności obsługi tekstur w formatach 16 i 32 bity na piksel, np.
GL_UNSIGNED_SHORT_5_6_5,
GL_UNSIGNED_SHORT_1 - 5 5 5 REV,
GL_UNSIGNED_SHORT_4_4_4_4_REV,
GL_UNSIGNED_INT_8_8_8_8_REV i
GL_UNSIGNED_INT_8_8_8_8 (formaty wewnętrzne to odpowiednio
GL_RGB i
GL_RGBA).
Do zastosowań specjalnych można także wykorzystać tekstury w formatach
GL_LUMINANCE,
GL_ALPHA i
GL_LUMINANCE_ALPHA, które potrzebuję relatywnie niewielkich ilości pamięci. Warto także pamiętać, że w wiele procesorów graficznych wewnętrznie przechowuje 24 bitowe dane tekseli w 32 bitach, co daje możliwość wykorzystania tej dodatkowej przestrzeni bez straty wydajności aplikacji.
Użycie obiektów tekstur
Wprowadzone w wersji 1.1 biblioteki OpenGL obiekty tekstur są bardzo użytecznym narzędziem przy optymalizacji programu. Użycie obiektów tekstur pozwala na jednokrotną konwersję danych tekstury z formatu zewnętrznego do formatu wewnętrznego. Jednocześnie należy wspomagać sterownik OpenGL określając priorytety tekstur odpowiednio do stopnia częstotliwości ich wykorzystywania. Dodatkowo po wczytaniu tekstur można sprawdzić, czy wszystkie są teksturami rezydentnymi, czyli znajdują się w pamięci karty graficznej.
Pamiętajmy także o dostępnych także od wersji 1.1 biblioteki OpenGL teksturach zastępczych (proxy), których użycie pozwala na ocenę możliwości obsługi tekstury przez daną implementację OpenGL.
Wyłączenie wczytywania tekstur do odrębnego wątku
Biblioteka OpenGL nie jest przystosowana do obsługi wielowątkowości. Nie należy jednak tego mylić z możliwością obsługi wielu kontekstów renderingu jednocześnie. Uchwyty do obiektów tekstur nie są związane z kontekstem renderingu stąd, można do odrębnego wątku wydzielić fragment programu wczytującego tekstury. Wątek ten będzie korzystał z tymczasowego kontekstu renderingu, a działający w tym samym czasie wątek obsługujący główny kontekst będzie mógł kontynuować rendering oczekują na wczytanie wszystkich tekstur.
Wykorzystanie kompresji tekstur
Kompresja tekstur została wprowadzona w wersji 1.3 biblioteki OpenGL, ale pełną swobodę korzystania z tej techniki ogranicza niezdefiniowanie w standardzie żadnego algorytmu kompresji i formatu skompresowanych danych. Jednak pomimo braku standardowych technik kompresji większość implementacji obsługuje rozszerzenie EXT texture compression s3tc, a sama technika wydaje sie godna zainteresowania, szczególnie w przypadku, gdy tekstury mają duże rozmiary i występują problemy ze zmieszczeniem ich w pamięci karty graficznej.
Aktualizacja części danych tekstury
Do zmiany części lub nawet całości danych zawartych w teksturze (np. w przypadku animacji) najlepiej użyć funkcji z grupy glTexSubImage zamiast funkcji glTexImage. Analogiczna zasada dotyczy stosowania funkcji z grupy glTexCopyTexSubImage zamiast glTexCopyTexImage. Przypomnijmy jednocześnie, że część w wymienionych funkcji zostało wprowadzonych w wersji 1.1 i 1.2 biblioteki OpenGL.
Określenie ilości potrzebnych mipmap
W niektórych sytuacjach możliwe jest dokładne ustalenie jakiej wielkości mipmapy są potrzebne do prawidłowego pokrycia obiektu teksturami. W szczególności może do tego wystarczyć jedna lub dwie mipmapy. W wersji 1.2 biblioteki OpenGL wprowadzono mechanizm pozwalający na określenie zakresu poziomów generowanych mipmap. Technika ta wydaje się szczególnie atrakcyjna w połączeniu z przechowywaniem danych tekstury w pamięci karty graficznej, bowiem pozwala na zmniejszenie zapotrzebowania na powyższą pamięć.
Wybór trybu mieszania tekstur
Najszybszym trybem mieszania tekstur jest
GL_REPLACE, w którym następuje zamiana składowych fragmentów przez składowe tekseli. Zmniejsza to znacząco ilość operacji w porównaniu do domyślnego trybu mieszania
GL_MODULATE, gdzie składowe tekseli są mieszane ze składowymi fragmentów.
Wieloteksturowanie zamiast teksturowania wieloprzebiegowego
Wieloteksturowanie wprowadzone w wersji 1.3 biblioteki OpenGL dzięki sprzętowej obsłudze jest znacznie szybsze od teksturowania wieloprzebiegowego. Dodatkowo zmniejszeniu ulega ilość przetwarzanych wierzchołków, bowiem przy teksturowaniu wieloprzebiegowym geometria obiektu musi być przetworzona tyle razy ile tekstur nakładamy.
Operacje na mapach bitowych i mapach pikselowych
Operacje na mapach bitowych i mapach pikselowych przebiegają szybciej, gdy dane map są typu
GL_UNSIGNED_BYTE oraz wyłączone są wszelkie zbędne operacje na składowych pikseli. Wpływ na szybkość powyższych operacji ma także sposób ułożenie składowych kolorów w mapach pikselowych. Potencjalnie najszybsze BGRA/RGBA/ABGR, ale niestety jest to w znacznym stopniu zależne od konstrukcji GPU. Ostatnim czynnikiem wpływającym na szybkość transferu danych map pikselowych i bitowych jest ułożenie tych danych w pamięci operacyjnej komputera. W zależności od systemu operacyjnego (32 lub 64 bitowego) oraz architektury najszybszy transfer może być osiągnięty przy wyrównywaniu danych do 2, 4 lub nawet 8 bajtów.
Korzystanie z obiektów buforowych pikseli
Jeżeli intensywnie korzystamy z map bitowych lub map pikselowych warto przechowywać ich dane w pamięci karty graficznej, co umożliwia wprowadzona w wersji 2.1 biblioteki OpenGL technika obiektów buforowych pikseli. Wpłynie to na przyspieszenie transferu danych (pamięć karty graficznej jest zazwyczaj znacznie szybsza od pamięci operacyjnej) oraz w efekcie zmniejszy obciążenie magistrali systemowej.
Uproszczenie programów cieniowania fragmentów
Jeżeli program intensywnie korzysta z programowego przetwarzania pikseli (fragmentów) jednym ze sposobów zwiększenia wydajności będzie uproszczenie lub optymalizacja programów cieniowania pikseli. Jeżeli funkcjonalność realizowana przez program cieniowania jest możliwa do realizacji przez klasyczne elementy potoku OpenGL można także sprawdzić, czy w ten sposób nie będą one szybciej wykonywane.
Metody redukcji obciążenia przetwarzania wierzchołków
Po pierwszej porcji informacji przedstawimy wybrane metody pozwalające na redukcję obciążenia związanych z przetwarzaniem wierzchołków. Sprowadzają się one do dwóch zasadniczych kierunków: redukcji ilości operacji wykonywanych na każdy wierzchołek oraz zmniejszenia ilości przetwarzanych wierzchołków oczywiście w granicach zakładanej jakości obrazu.
Wybór właściwych prymitywów graficznych
Biblioteka OpenGL obsługuje kilka typów prymitywów graficznych. Jednak uwagę skupimy głównie na trójkątach, bowiem procesory graficzne optymalizowane są pod kątem przetwarzania właśnie tych prymitywów. Przypomnijmy, że trójkąty możemy rysować na trzy różne sposoby: normalne trójkąty (
GL_TRIANGLES), wstęgi trójkątów (
GL_TRIANGLE_STRIP) oraz wachlarze trójkątów (
GL_TRIANGLE_FAN). Łatwo można obliczyć, że stosowanie wstęgi i wachlarzów trójkątów zmniejsza ilość przetwarzanych wierzchołków.
Wstęgi i wachlarze trójkątów posiadają jednak poważną wadę. Nie jest możliwe opisanie nawet stosunkowo prostych obiektów jako jednego wachlarza lub wstęgi trójkątów. Popatrzmy na rysunek 1 przedstawiający wszystkie możliwe siatki sześcianu. Korzystając z siatek numer 1 i 8 możemy zbudować sześcian z dwóch wstęg trójkątów (każda wstęga po sześć trójkątów). Natomiast jeżeli wykorzystamy siatki numer 3, 4, 6, 7, 10 lub 11 sześcian możemy zbudować z dwóch wachlarzy trójkątów (każdy wachlarza także po sześć trójkątów).
A teraz małe obliczenia. Do narysowanie dwóch wstęg trójkątów, w których każda ma sześć trójkątów, potrzebujemy łącznie 16 wierzchołków. Taka sama ilość wierzchołków będzie przetwarzana, gdy sześcian narysujemy przy użyciu dwóch wachlarzy trójkątów, każdy składający się z sześciu trójkątów.
Jest to znaczna oszczędność w porównaniu do najprostszej realizacji sześcianu korzystającej z 12 trójkątów opisywanych przez łącznie 36 wierzchołków.
Podobne oszczędności w ilości przetwarzanych wierzchołków można uzyskać korzystając z wstęg czworokątów (
GL_QUAD_STRIP) zamiast definiowania zwykłych czworokątów (
GL_QUAD). W przypadku sześcianu można do tego celu skorzystać z siatek numer 1 i 8.
Ograniczenie ilości kodu pomiędzy glBegin/glEnd
W przypadku, gdy program korzysta z trybu bezpośredniego istotne znaczenie dla wydajności przetwarzania wierzchołków ma organizacja kodu wewnątrz par funkcji glBegin/glEnd.
Podstawową zasadą jest minimalizacja ilości samego występowania tych funkcji. Typowym przykładem jest rysowanie w pętli kolejnych trójkątów (lub innych prymitywów), które można zapisać na dwa równoważne sposoby:
glBegin([ tt ] GL_TRIANGLE[ / tt ] );
for( int i = 0; i < 300; i++ )
glVertex3fv( & vertex[ i ] );
glEnd();
oraz
for( int i = 0; i < 300; i += 3 )
{
glBegin([ tt ] GL_TRIANGLE[ / tt ] ); glVertex3fv( & vertex[ i + 0 ] ); glVertex3fv( & vertex[ i + 1 ] ); glVertex3fv( & vertex[ i + 2 ] ); glEnd();
}
Różnica w ilości wywołań glBegin/glEnd jest wyraźnie widoczna. Dodatkowo w drugim prezentowanym przykładzie musimy zmienić organizację pętli tak, aby przy każdej iteracji rysowane były trzy wierzchołki trójkąta.
Drugą zasadą optymalizacji glBegin/glEnd jest ograniczanie wszelkiego dodatkowego kodu wewnątrz tych funkcji. Jeżeli w programie mamy opcje rysownia sceny z różnym efektami, to ich sprawdzanie nie powinno odbywać się pomiędzy wywołaniami glBegin/glEnd.
Wracając do przykładowych trójkątów, jeżeli opcją jest włączenie lub wyłączenie oświetlenia, to przy włączonym oświetleniu dane współrzędnych kolejnych wierzchołków trójkątów musimy uzupełnić o współrzędne ich wektorów normalnych. Można to zrobić np. w następujący sposób:
glBegin( GL_TRIANGLE );
for( int i = 0; i < 300; i++ )
{
if( lighting )
glNormal3fv( & normal[ i ] )
glVertex3fv( & vertex[ i ] );
}
glEnd();
lub szybciej:
if( lighting )
{
glBegin( GL_TRIANGLE );
for( int i = 0; i < 300; i++ )
{
glNormal3fv( & normal[ i ] )
glVertex3fv( & vertex[ i ] );
}
glEnd();
else
{
glBegin( GL_TRIANGLE );
for( int i = 0; i < 300; i++ )
glVertex3fv( & vertex[ i ] );
glEnd();
}
Kolejną zasadę poznaliśmy właśnie w poprzednim przykładzie. Jest nią eliminacja niepotrzebnych danych wierzchołków. Jeżeli np. nie korzystamy z tekstur i współrzędnych mgły, to nie ma powodów wykonywania funkcji z grup glTexCoord i glFogCoord.
Jeżeli jest to możliwe trzeba także ograniczyć liczbę wywołań funkcji definiujących dane wierzchołków. Przykładowo do narysowanie serii czerwonych trójkątów nie musimy umieszczać wywołania funkcji z grupy glColor wewnątrz pary glBegin/glEnd:
glBegin([ tt ] GL_TRIANGLE[ / tt ] );
glColor3f( 1.0, 0.0, 0.0 );
for( int i = 0; i < 300; i++ )
glVertex3fv( & vertex[ i ] );
glEnd();
ale wystarczy ustawić kolor przed funkcją glBegin:
glColor3f( 1.0, 0.0, 0.0 );
glBegin([ tt ] GL_TRIANGLE[ / tt ] );
for( int i = 0; i < 300; i++ )
glVertex3fv( & vertex[ i ] );
glEnd();
Na koniec warto jeszcze wspomnieć, że przy przekazywaniu danych wierzchołków w trybie bezpośrednim generalnie szybsze powinny być te wersje funkcji glVertex, glNormal itd., które korzystają ze wskaźników do tablic.
Wstępnie przetworzone współrzędne wierzchołków
Jeżeli w generowanej scenie znajdują się obiekty, których położenie i wymiary nie zmieniają się w trakcie działania programu, można uniknąć dynamicznego przekształcania ich wierzchołków wykonując wcześniej jednorazowo wszystkie niezbędne obliczenia.
Używanie wybranych operacji na macierzach
Biblioteka OpenGL zawiera szereg operacji na macierzach. Jednak część z tych operacji może być w specjalny sposób optymalizowana przez implementację biblioteki, a pozostałe wykonywane z relatywnie mniejszą szybkością. Potencjalnie najszybsze będą operacje wykonywane przy użyciu funkcji: glRotated, glRotatef, glScaled, glScalef, glTranslatef, glTranslatex oraz glLoadIdentity. Do grupy potencjalnie mniej wydajnych funkcji można zaliczyć: glLoadMatrixd, glLoadMatrixf, glMultMatrixd, glMultMatrixf oraz ich wersje operujące na macierzach transponowanych: glLoadTransposeMatrixf, glLoadTransposeMatrixd, glMultTransposeMatrixf i glMultTransposeMatrixd.
Wyłączenie automatycznej normalizacji wektorów normalnych
Przypomnijmy, że jedynym przekształceniem, które zmienia długość wektorów normalnych jest skalowanie. Zatem jeżeli przy przetwarzaniu wierzchołków (a tym samym i wektorów normalnych) nie występuje skalowanie macierzy modelowania, stosowanie automatycznej normalizacji wektorów normalnych nie jest potrzebne. W wersji 1.2 biblioteki OpenGL wprowadzono technikę automatycznego skalowania wektorów normalnych, która rozwiązuje problem zmiany długości wektora normalnego przy operacji skalowania. Oczywiście stosowanie powyższych technik wymaga wstępnej normalizacji wektorów normalnych.
Grupowanie prymitywów ze względu na zmienne stanu
Wprawdzie ta wskazówka nie prowadzi bezpośrednio do zmniejszenia liczby przetwarzanych wierzchołków, ale ma znaczący wpływ na szybkość działania całego programu. Zmiana wartości zmiennych maszyny stanów biblioteki OpenGL jest relatywnie kosztowną operacją, a ponadto niektóre implementacje mogą zawierać optymalizacje obejmujące grupę działań na określonych zmiennych stanu. Stąd z jednej strony należy do minimum ograniczyć zmiany zmiennych stanu, a jeżeli takie zmiany są niezbędne rysować poszczególne prymitywy pogrupowane pod względem wartości zmiennych maszyny stanów.
Wyłączenie automatycznego generowania współrzędnych tekstur
Mechanizm automatycznego generowania współrzędnych tekstur znacząco obniża szybkość przetwarzania geometrii (zwłaszcza, gdy jest wykonywany dynamicznie). Jeżeli jest to tylko możliwe automatyczne generowania współrzędnych tekstur należy zastąpić współrzędnymi statycznymi.
Uproszczenie oświetlenia
Efekty związane z oświetleniem sceny mają znaczący wpływ na wydajność przetwarzania wierzchołków. Redukcja tego obciążenia może sprowadzać się do zastosowania następujących technik:
Wyłączenie dwustronnego oświetlenia
Dla zamkniętych obiektów oraz innych obiektów, których tylne ściany nie są widoczne można wyłączyć dwustronne oświetlenie. Jeżeli obiektów wymagających dwustronnego oświetlenia jest bardzo niewiele, to pamiętając jedną z poprzednich wskazówek dotyczącą grupowania prymitywów ze względu na zmienne stanu, można sprawdzić wydajność rozwiązania, które za cenę dodatkowych wierzchołków (ściany widoczne obustronnie należałoby rysować dwukrotnie) umożliwiłoby działanie całego programu w jednym modelu oświetlenia.
Testy zasłaniania
Sprzętowe wspomaganie testów zasłaniania obiektów zostało wprowadzone w wersji 1.5 biblioteki OpenGL. Ten prosty mechanizm pozwala na eliminację z potoku przetwarzania nawet bardzo dużych ilości wierzchołków. Jeżeli nie mamy dostępu do sprzętowej realizacji testu zasłaniania można zastosować inne techniki redukujące ilość przetwarzanych obiektów, np. drzewa BSP, portale itp.
Korzystanie z list wyświetlenia
Listy wyświetlania są swoistym agregatem ciągu poleceń OpenGL. Faktyczne korzyści wynikające z ich stosowania (poza wygodą w pisaniu programu) zależą od konkretnej implementacji oraz od możliwości sterownika. W każdym razie użycie list wyświetlania nie powinno mieć negatywnego wpływu na wydajność przy przetwarzaniu wierzchołków.
Korzystanie z tablic wierzchołków
Tablice wierzchołków zostały wprowadzone w wersji 1.1 biblioteki OpenGL i stanowią bardzo ważną alternatywę w stosunku do stosowania trybu bezpośredniego. Przy ich wykorzystaniu warto tak zorganizować struktury danych, aby ułatwić transfer danych pomiędzy pamięcią operacyjną i pamięcią karty graficznej.
Korzystanie z indeksowych tablic wierzchołków
Indeksowe tablice wierzchołków pozwalają na zmniejszenie ilości przetwarzanych wierzchołków (każdy wierzchołek obiektu przetwarzany jest tylko raz), co niestety dzieje się kosztem dodatkowych danych opisujących indeksy wierzchołków. Jest to jednak koszt możliwy do zaakceptowania, zwłaszcza, że operacje na indeksowanych tablicach wierzchołków mogą być dodatkowo optymalizowane przez procesor graficzny.
Korzystanie z obiektów buforów wierzchołków
Wprowadzone w wersji 1.5 biblioteki OpenGL obiekty buforowe wierzchołków są ściśle związane z tablicami wierzchołków i indeksowymi tablicami wierzchołków. Możliwość umieszczenia danych tablic wierzchołków bezpośrednio w pamięci karty graficznej eliminuje czasochłonny transfer danych pomiędzy aplikacją i kartą graficzną i umożliwia GPU pełną swobodę w optymalizacji renderingu.
Uproszczenie geometrii obiektów
Uproszczenie geometrii obiektów w większości przypadków bedzie wiązało się z pogorszeniem jakości renderingu. Jednak przy dynamicznych scenach albo w większej odległości od obserwatora można zastosować obiekty zbudowane z mniejszej ilości wielokątów, bez ryzyka widocznego pogorszenia jakości obrazu.
Uproszczenie programów cieniowania wierzchołków
Podobnie jak w przypadku programów cieniowania fragmentów, także intensywnie wykorzystywane programy cieniowania wierzchołków mają wpływ na wydajność renderingu. Jeżeli nie jest możliwa dalsza redukcja ilości przetwarzanych wierzchołków, uzyskanie zakładanej wydajności będzie wymagało uproszczenia oraz optymalizacji programów cieniowania wierzchołków.
Optymalizacja z punktu widzenia producentów GPU
Jest sprawą dość oczywistą, że procesory graficzne różnych producentów różnią się architekturą. Z różną częstotliwością i niestety nie w do każdej rodziny GPU producenci publikują informacje dotyczące optymalizacji programów pisanych przy użyciu OpenGL. Informacje tego typu często łączone są z opisem budowy procesorów, listą obsługiwanych rozszerzeń oraz wykazem operacji nie wspieranych przez sprzęt. Czytelnik powinien mieć jednak świadomość, że tego rodzaju materiały nie zawierają pełnej prawdy o słabych punktach architektury danego GPU, ale mimo wszystko są to przydatne informacje.
Warto także zwrócić uwagę na dostarczane przez producentów procesorów graficznych materiały dotyczące kolejnych wydań sterowników. Mogą znaleźć się tam przykładowo informacje o poprawieniu błędów lub przyspieszeniu niektórych operacji.
Aplikacje wspomaga jące optymalizację programów w OpenGL
Jedną z najciekawszych aplikacji wspomagających optymalizację programów korzystających z OpenGL i OpenGL_ES jest komercyjny program gDEBugger firmy Graphic Remedy (http://www.gremedy.com). gDEBugger umożliwia m.in. detekcję błędów, różnego rodzaju statystyki oraz określenie wąskiego gardła aplikacji. Pracę ułatwia wbudowany edytor kodu źródłowego programu oraz kodu źródłowego programów cieniowania. Dodatkową zaletą jest obsługa wielu rozszerzeń OpenGL oraz wsparcie dla najważniejszych architektur sprzętowych - procesorów firm NVIADIA i ATI/AMD. Ogólny wygląd aplikacji przedstawiono na rysunku 2.