Programowanie Gier -> Wykład: OpenGL

Konspekt do wykładu (aka "Michalis-woli-wiki-od-slajdów" :) ).

  1. Jak działa OpenGL?

    • Karmiony wierzchołkami 3D (vertex), na każdym wierzchołku wykonujemy pewne operacje (lighting, fog, vertex shader). Wierzchołek jest transformowany do przestrzeni ekranu (2D, z głębokością do DEPTH_TEST), rasterizer rysuje trójkąt, interpolując kolor (Gouraud shading; w przpadku shaderów, interpoluje zadane zmienne varying). Na kązdym pixelu ("fragmencie" w terminologii OpenGLa) wykonujemy test głębokości, jeżeli Ok --- to teksturujemy (albo używamy fragment shadera) i obliczony kolor rysujemy na ekranie.
    • Pipeline, stages (przetwarzanie potokowe, etapy potoku)

    • Zmiany stanów
    • Rysowanie prymitywów: szybkie bo wiele wierzchołków (a potem i fragmentów (aka pixeli)) można przetwarzać równolegle
    • Zmiany stanów bolesne -> bo trzeba skończyć przetwarzanie istniejących potoków (nie można trzymać danych o stanie w zbyt wielu miejscach, bo to z kolei powodowałoby kopiowanie stanu wzdłuż potoku, uniemożliwiałoby uzyskanie maksymalnej szybkości potoku)

    • Zmiany primitywu (na inny albo nawet ten sam, glEnd + glBegin(GL_TRIANGLES)) bolesne na tej samej zasadzie
    • Demo: gl_primitive_performance: zmiany prymitywów (wiele GL_POLYGON, wiele GL_TRIANGLE_FAN, bardziej kosztowne na nowych GPU niż GL_TRIANGLES (mimo że GL_TRIANGLES duplikuje indeksy bardziej!))
    • Demo: "thrashing" stanu (niepotrzebne komendy zmieniające stan, nawet na taki sam) zabijają efektywność. Nie róbcie glMaterial przed każdym polygonem, nie ustawiajcie tekstury przed każdym polygonem. Generalnie: grupujcie prymitywy pod kątem wspólnego użycia materiałów, tekstur etc., i starajcie się "pchać" możliwie dużo rzeczy bez zmian stanu.
    • Żeby zminimalizować zmiany tekstur: texture atlas, do prostych gier można zrobić to samemu, są też http://developer.nvidia.com/object/texture_atlas_tools.html

    • Nie wszystkie zmiany stanów są bolesne. Bolesne są tylko zmiany danych które nie są przekazywane w potoku (ich zmiana wymusza "flush" potoków). Np. glNormal, glTexCoord są oczywiście bezbolesne, wiadomo że zmieniamy je ~raz na vertex. Inne ciekawsze rzeczy: glColor. Dlatego glColorMaterial (kolor materiału śledzi glColor) jest niezwykle ważne: zmiany glColor są tanie, zmiany glMaterial są kosztowne.
  2. Jak optymalizować?

    • Warto optymalizować tylko bottleneck. To jest ogólna prawda przy każdej optymalizacji, ale w przypadku GPU optymalizacja stages które nie są bottleneck naprawdę nie da kompletnie nic. Nawiasem mówiąc, można też patrzeć z drugiej strony: stage które nie są bottleneckiem możemy do pewnego stopnia bezkarnie obciążać.
    • Jak znaleźć bottleneck? Można "ręcznie":
      • Application stage (CPU): Naturalnie, spróbuj wyłączyć "the usual suspects" i zobacz czy pomogło, np. wyłącz kolizje. Porównaj FPS mierzone faktycznie (ile ramek na rzeczywistą sekundę) z FPS mierzone jako "ile ramek mielibyśmy gdyby program wykonywał tylko display" (to zakłada że komendy OpenGLa nie są przesadnie cache'owane, w praktyce działa całkiem Ok). Zamień wszystkie glVertex / glNormal na glColor: to sprawi że OpenGL w zasadzie nie będzie robił nic, zobacz czy poprawił się stan.
      • Geometry stage: Wyłącz światła OpenGLa, vertex shaders, fog, texture coord generation. Albo na odwrót, włącz, i zobacz czy znacząco zwolniłeś program. Generalnie, geometry stage jest najtrudniejsze --- w praktyce często działa sposób "jeżeli bottleneck nie jest w application ani rasterizer, to widocznie jest w geometry stage".
      • Rasterizer stage: zmniejsz rozmiar okienka.
    • Można też używać OpenGL debuggera/profilera. TODO: notes o gDEBugger
    • See also: Kurs SIGGRAPH '97: OpenGL Performance Optimization (dość stary, większość uwag ma ciągle znaczenie, ale niekiedy mniejsze niż sugerują...).

  3. Display lists:

    Oryginalnie dość dość przyjemna własność OpenGLa, pozwalała GPU zoptymalizować wiele rzeczy. W praktyce, obecnie konkretne przypadki optymalizacji mają swoje własne obiekty (obiekty tekstur, obiekty wierzchołków z najczęściej używanych), ale display listy mogą być Ok w szczególnych sytuacjach.

    Konkretne przykłady i omówienie API.
    Zalety:

    • Optymalizują (potencjalnie!) wszystko. Potencjalnie nawet eliminują niepotrzebne zmiany stanów (w praktyce --- niepewne, zależy od GPU), potencjalnie robią coś jak locked vertex array / vertex buffer object (w praktyce --- tak, ale są bardziej ograniczone).

    • Trywialne w użyciu.
    Wady:
    • Właściwie nie wiadomo (nie ma żadnych gwarancji) co optymalizują a czego nie.
    • Nie są edytowalne. Powody oczywiste --- display lista zapamiętuję wszystkie komendy OpenGLa, mechanizm edycji ciągu komend OpenGLa byłby koszmarny i nie pozwoliłby na optymalizację danych (różnego rodzaju kompresje danych i poleceń) w środku display listy.
    • Chociaż można uzyskać namiastkę "edytowalności" przez nested display lists: kiedy wywołamy display listę X w drugiej display liście Y, możemy potem przebudowywać zawartość listy X bez potrzeby uaktualniania listy Y. Innymi słowy, glCallList nie jest "rozwijane" w momencie wkładania go do innej display listy.
    Inne uwagi:
    • Tworzenie display listy z GL_COMPILE_AND_EXECUTE tworzy kiepsko (ale szybko) zoptymalizowaną wersję display listy. Jeżeli zamierzacie wywołać display listę naprawdę wiele razy, lepiej zbudować listę z GL_COMPILE i zaraz potem ją wywołać.
  4. Jak optymalizować rysowanie prymitywów

    1. Intuicja: vertex sharing/caching. Vertexy typowo są współdzielone przez ~2-4 prymitywy. Optymalnie, geometry stage powinno przetwarzać tylko raz każdy vertex 3D naszej sceny. Od początku o tym wiedziano, stąd wszystkie TRIANGLE_STRIP, TRIANGLE_FAN, QUAD_STRIP etc. w OpenGLu. Gdy używamy vertex arrays lub vertex buffer objects, podajemy OpenGLowi indeksy do zadanej wcześniej tablicy danych, więc GPU może cache'ować wyniki geometry stage dla kilkuset ostatnich vertexów. W ten sposób nawet TRIANGLES mogą mieć bardzo duże vertex sharing, nie ma już potrzeby na optymalizowanie swoich modeli 3D pod kątem triangle stripów (jak to było kiedyś). Acha, pamiętajcie: optymalizacje o jakich opowiadamy sobie tutaj na wykładzie są dość nisko-poziomowe. Potrafią dać niezłego "kopa", ale nie zastąpią należytych optymalizacji wysoko-poziomowych (które znają układ sceny 3D), jak frustum culling (najlepiej przy pomocy drzew) albo portali. (Opowiemy o nich trochę na następnym wykładzie.)
    2. Historia: client vertex arrays

      • Przykłady i opis API. Vertex arrays "klasyczne" są przechowywane w normalnej pamięci. GPU nie może ich nigdzie skopiować, zoptymalizować, bo zawartość pamięci może zmienić się w każdej chwili. Vertex arrays sprawiają że komunikacja jest mniejsza, w przypadku rysowania "batchami" (glDrawElements, glDrawRangeElements) GPU jest wydajniej karmione vertexami.

        Rozszerzenie EXT_compiled_vertex_array (implementowane praktycznie przez wszystkie istniejące GPU) pozwala "zalokować" vertex array, co znaczy tyle że informujemy GPU że zawartość tablicy jest stała i może swobodnie ją skopiować do bardziej odpowiedniego miejsca i robić cache wartości geometry stage. Jeżeli już używacie klasycznych vertex arrays (chociaż nie powinniście --- VBO są lepsze) to koniecznie używajcie lokowania vertex arrays.

        Ukazało się jeszcze kilka innych rozszerzeń do vertex arrays, patrz np. tutaj. Ostatecznie, to czego chcemy obecnie używać to vertex buffer objects.

    3. Teraźniejszość: vertex buffer objects

      • Przykłady i opis API. Zauważcie że polecenia jak glVertexPointer ciągle istnieją. Ale działają inaczej kiedy mamy zbindowany jakiś vertex buffer object: ostatni parametr (wskaźnik na dane w klasycznych vertex arrays, kiedy nie mam zbindowanego VBO) oznacza teraz tylko przesunięcie wzgledem bufora, zazwyczaj podajemy zwyczajne NULL (można dzięki temu łatwo upakować wiele tablic, każda indeksowana od zera, w jednym VBO). Zalety:

        • dynamic (editable)
        • GPU dostaje hint jak będziemy używać VBO (GL_STATIC_DRAW_ARB, GL_DYNAMIC_DRAW_ARB, GL_STREAM_DRAW_ARB) i może skopiować dane do najlepszego rodzaju pamięci (np. GL_STATIC_DRAW_ARB zapewne spowoduje kopię danych na GPU, dając max szybkość)
        • standard (od OpenGL 1.5 w specyfikacji, każdy sensowny GPU je implementuje)
        Wady: brak? Demo: szybkość direct vs vertex arrays vs vertex buffer objects w gl_primitive_performance.
  5. Testowy programik gl_primitive_performance

    Przygotowałem prosty programik do testowania różnych sposobów przekazywania wierzchołków do OpenGLa. Pobawimy się nim trochę w trakcie wykładu, żeby zobaczyć że optymalizacje o których opowiadamy rzeczywiście mają drastyczny wpływ na szybkość programu. Można też ściągnąć, skompilować i pobawić się samemu: sam kod źródłowy jest tutaj, chociaż wymaga reszty moich bibliotek do odczytu modeli 3D. Żeby samemu skompilować:

    $ svn checkout https://vrmlengine.svn.sourceforge.net/svnroot/vrmlengine/trunk/kambi_vrml_game_engine/
    $ cd kambi_vrml_game_engine/examples/vrml/
    $ ./gl_primitive_performance_compile.sh
    $ ./gl_primitive_performance $NAZWA_MODELU [$NUMER_SIATKI]

    Wymagany jest FPC. (Jeżeli komuś nie podoba się język programowania w którym napisany jest mój programik, niech najpierw zaimplementuje sam bibliotekę od odczytu wszystkich formatów 3D jak X3D/VRML, Collada, Wavefront, 3DS, MD3 etc. :) )

  6. Krótko: co to są shadery?

    Od czasu implementacji T&L na GPU (GPU wykonuje wszystko począwszy od vertexów 3D) komplikacja fixed function rosła w straszliwym tempie. Praktycznie każdy element przetwarzania stawał się konfigurowalny. Texturowanie jest jednym z ekstremalnych przykładów: najpierw jedna tekstura, potem texture objects, potem multi-texturing, początkowo kombinowane prosto, potem kombinowane za pomocą GL_EXT_texture_env_combine. Nagle z prostego systemu w którym możemy tylko "nałożyć teksturę na obiekt" mamy system w którym możemy powiedzieć "weż tekstury X, Y, Z, i wykonaj na nich (X+Y)*Z". Żadne normalne API nie pozwoli zapisać tego krótko --- to co jest potrzebne to specjalny język w którym będzie można zapisać wyrażenia na teksturach. Stąd pomysł na shadery. Shader = specjalny język, rozumiany przez GPU.

    • Prosty język, dający mnóstwo nowych możliwości a jednocześnie znacznie krótszy zapis.
    • Shadery zastępują fixed function operation. Shadery na nowych GPU mogą być nawet szybsze od fixed-function: bo shadery są dostosowane do programu, nie muszą implementować całego fixed-function, które zawiera wiele elementów rzadko używanych.

    Demo shaderów: trywialny shader GLSL wyświetlający negację tekstury. Nie będziemy teraz dokładnie mówić o języku shaderów, ani o tym jakie polecenia OpenGLa inicjują shader. To co jest ważne, to że nasz program zwyczajnie "podaje" treść shadera jako string do OpenGLa. (Przynajmniej dopóki mówimy o GLSL, języku shaderów wbudowanym w OpenGLa.) GPU (zazwyczaj część software'owa, bo nie ma sensu tego optymalizować) umie taki język sparsować i "skompilować" do czegokolwiek do czego będzie mu wygodnie (w praktyce, do ciągu wewnętrznych, "asemblerowych" poleceń na GPU.). Kiedy wyświetlamy nasz model 3D, możemy nakazać wykonanie shadera, wtedy OpenGL zamiast fixed-function pipeline będzie wykonywał shader (vertex and fragment, są też geometry w nowych GPU ale o tym kiedy indziej.) Optymalnie, shader jest wykonywany przez GPU w hardwarze --- czyli GPU działa już trochę jak procesor, chociaż o innej architekturze (pipeline, stages równoległość są używane przy wykonywaniu shaderów tak samo jak przy fixed-function, po prostu niektóre elementy zamieniają się teraz w mini-procesorki języka shaderów.)

    Przyszłość? Już jest: Używanie GPU do bardziej ogólnych obliczeń, których wyniki muszą wrócić na CPU. Patrz http://www.nvidia.com/cuda i kilka innych nowinek NVidii.

  7. OpenGL 3.0

    • Potrzeba zmian: chcemy usunąć wiele rzeczy z fixed-function, uprościć implementację OpenGLa. Producenci powinni skupić się na optymalizowaniu i rozwijaniu shaderów. Wszystko co jest osiągalne shaderami wyrzucamy z fixed-function.
    • Wyrzucamy proste glVertex i większość jego stanu. Trzeba używać VBO żeby renderować dane.
    • Wyrzucamy przy okazji naprawdę stare rzeczy, jak tryb indeksowany (ktoś tego kiedykolwiek używał?)
    • Przygotowujemy się przy okazji do przyszłych zmian: nazwy tekstur muszą być generowane przez glGenTextures etc.
    • Wyrzucamy też operacje na macierzach OpenGLa (macierze można mnożyć w shaderach), alpha test (można go wykonać w shaderach), bufor akumulacji (nie sprawdził się, anti-aliasing jest wykonywany obecnie zazwyczaj przez FSAA, FBO powinien przejąć jego możliwości w sposób bardziej elastyczny?)
    • Uh... i jeszcze wyrzucamy trochę fajnych rzeczy :)

    Przyszłość jest jasna: shadery, a także VBO i FBO (Frame Buffer Object).
  8. Przypomnienie: kilka starych sztuczek z buforami OpenGLa

    1. Stencil buffer Bufor który pozwala swobodnie maskować renderowanie kawałków ekranu (per-pixel). glStencilOp, glStencilFunc. Klasyczne łatwe zastosowanie: plane mirror. (O co najmniej jednym inne klasycznym zastosowaniu, shadow volumes, opowiemy ze szczegółami za kilka wykładów). Proste omówienie techniki. Skrót:

      • Aby narysować scenę w lustrze, zastosuj glScene(1, 1, -1)
      • Pamiętaj że ściany CW zmieniają się na CCW --- zazwyczaj najłatwiej jest zrobić na chwilę glFrontFace(GL_CW)
      • Aby obiekty nie wychodziły przed lustro, przytnij je (glClipPlane)
      • Aby obiekty były widoczne tylko za lustem, narysuj je tylko tam gdzie widoczna jest powierzchnia lusta. Tu właśnie mamy prosty przykład użycia stencil buffora.
      • Dla ładnego efektu (żeby lustro "barwiło" na jakiś kolor) dobrze jest narysować powierzchnię lustra z blending.
      • Ładna sztuczka to także nałożenie mgły volumetrycznej (EXT_FOG_COORD, albo shaderami) na elementy widoczne w lustrze.
      • Zalety techniki: Dość łatwa (przy niewielkiej ostrożności, można użyć tego samego kodu do renderowania obiektów w lustrze i normalnych; ponadto zachowujemy możliwość np. zrobienia frustum culling albo innego przycinania (np. w/g zakresu mgły) dla obiektów w lustrze). Efekt bardzo ładny.
      • Wady: Dodatkowy rendering sceny na każdą płaszczyznę lustra (obciążenie na fragment stage niekoniecznie duże, ale na geometry stage w zasadzie dwukrotnie większe). Technika bezużyteczna jeżeli myślimy o bardzo dużej ilości luster widocznych jednocześnie, albo skomplikowanym lustrzanym obiekcie (do tego są lepsze metody --- environment mapping).
    2. Accumulation buffer Bufor RGB do/z którego możemy skopiować bufor kolorów. Bardzo ograniczone możliwości skalowania i dodawania buforów, użyteczny do różnego rodzaju sztuczek z rozmywaniem wybranych części obrazu. Klasyczne użycia: depth of field, motion blur. Kiedyś także anti-aliasing, chociaż bardzo kosztowny --- w praktyce wszyscy używają FSAA zamiast accumulation buffer.)

Przy okazji tekstur dopowiemy sobie o Frame Buffer Object.