« Optymalizacja, lekcja »
Rozdział 31. Podstawowe zasady optymalizji programów w OpenGL; weryfikacja błędów, podstawowe zasady konstrukcji programów, diagnoza wąskiego gardła wydajności, metody redukcji obciążenia przetwarzania pikseli, metody redukcji obciążenia przetwarzania wierzchołków. (lekcja)
Panel użytkownika
Nazwa użytkownika:
Hasło:
Nie masz jeszcze konta?
Zarejestruj się!
Autor: Janusz Ganczarski
Kurs OpenGL, C++

Optymalizacja

[lekcja] Rozdział 31. Podstawowe zasady optymalizji programów w OpenGL; weryfikacja błędów, podstawowe zasady konstrukcji programów, diagnoza wąskiego gardła wydajności, metody redukcji obciążenia przetwarzania pikseli, metody redukcji obciążenia przetwarzania wierzchołków.
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:
C/C++
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:
C/C++
#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:
  • inicjalizacja bufora ramki
  • przygotowanie stałych elementów sceny (tekstury, listy wyświetlania, tablice wierzchołków, programy cieniowania itp.),
  • zdefiniowanie parametrów sceny (bryła widzenia, kolor tła itp.),
  • generowanie kolejnych ramek sceny.
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 z przetwarzaniem pikseli (ang. fill-limited bottleneck),
  • problem z przetwarzaniem wierzchołków (ang. transform-limited bottleneck),
  • problem z wydajnością aplikacji (ang. application bottleneck).
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:
C/C++
#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

magistralaszybkość transferu danych
ISA 8-bit
ISA 16-bit
8 MB/s
16 MB/s
MCA66 MB/s
EISA33 MB/s
VLB133 MB/s
PCI133 MB/s
AGP 1x
AGP 2x
AGP 4x
AGP 8x
266 MB/s
533 MB/s
1066 MB/s (1 GB/s)
2133 MB/s (2 GB/s)
PCI Express x1
PCI Express x2
PCI Express x4
PCI Express x8
PCI Express x16
PCI Express x32
256 MB/s
512 MB/s
1 GB/s
2 GB/s
4 GB/s
8 GB/s
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

modelGPUprzepustowość pamięci GB/swydajność MT/s
GeForce 6500
GeForce 6600 GT
GeForce 6800 Ultra
NV43
NV43
NV45
4,2
16,0
33,6
1400
4000
6400
X700
X800
X850 XT
RV410
R430
R480
9,6
22,4
34,6
3200
4800
8320
GeForce 7300 GT
GeForce 7600 GT
GeForce 7900 GTX
G73
G73
G71
10,67
22,4
51,2
2800
6720
15600
X1300
X1800 XL
X1950 XTX
R515
R520
R580+
8,0
32,0
64,0
1800
8000
10400
GeForce 8500 GT
GeForce 8600 GTS
GeForce 8800 GTX
G86
G84
G80
12,8
32,0
86,4
3600
10800
36800
HD 2600 Pro
HD 2900 XT
RV630
R60
16,0
105,60
2400
11888
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:
C/C++
glClear( GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT );
będzie szybciej wykonane niż dwa odrębne czyszczenia:
C/C++
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.
Rysunek 1. Jedenaście siatek sześcianu
Rysunek 1. Jedenaście siatek sześcianu
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:
C/C++
glBegin([ tt ] GL_TRIANGLE[ / tt ] );
for( int i = 0; i < 300; i++ )
     glVertex3fv( & vertex[ i ] );

glEnd();
oraz
C/C++
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:
C/C++
glBegin( GL_TRIANGLE );
for( int i = 0; i < 300; i++ )
{
    if( lighting )
    glNormal3fv( & normal[ i ] )
         glVertex3fv( & vertex[ i ] );
   
}
glEnd();
lub szybciej:
C/C++
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:
C/C++
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:
C/C++
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:
  • w miarę możliwości zastosowanie śledzenia kolorów i funkcji glColorMaterial, zamiast funkcji z grupy glMaterial,
  • zamiana świateł kierunkowych na światła punktowe,
  • nieużywane ujemnych wartości składowych kolorów materiałów i składowych kolorów światła,
  • nie zmienianie domyślnej wartości parametru GL_LIGHT_MODEL_LOCAL - VIEWER modelu oświetlenia,
  • minimalizacji ilości zmian wartości GL_SHININESS materiału,
  • redukcja ilości używanych źródeł światła; trzeba także pamiętać, że nie wszystkie źródła światła dostępne w danej implementacji OpenGL muszą być obsługiwane sprzętowo,
  • zamiana tradycyjnego modelu oświetlenia na oświetlenie wykorzystujące tekstury i mapy świetlne; niestety może się to wiązać z pojawieniem się (lub powiększeniem) problemu z przetwarzaniem pikseli.

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.
Rysunek 2. gDEBugger podczas pracy z przykładowym projektem
Rysunek 2. gDEBugger podczas pracy z przykładowym projektem
Poprzedni dokumentNastępny dokument
GLSLZmienne stanu