Spis treści O autorze
7
O recenzentach
9
Wstęp
11
Rozdział 1. Wprowadzenie do nowoczesnego OpenGL
17
Wstęp Instalacja rdzennego profilu OpenGL 3.3 w Visual Studio 2013 przy użyciu bibliotek GLEW i freeglut Projektowanie klasy shadera w GLSL Renderowanie kolorowego trójkąta za pomocą shaderów Wykonanie deformatora siatki przy użyciu shadera wierzchołków Dynamiczne zagęszczanie podziału płaszczyzny przy użyciu shadera geometrii Dynamiczne zagęszczanie podziału płaszczyzny przy użyciu shadera geometrii i renderingu instancyjnego Rysowanie obrazu 2D przy użyciu shadera fragmentów i biblioteki SOIL
Rozdział 2. Wyświetlanie i wskazywanie obiektów 3D Wstęp Implementacja wektorowego modelu kamery z obsługą ruchów w stylu gier FPS Implementacja kamery swobodnej Implementacja kamery wycelowanej Ukrywanie elementów spoza bryły widzenia Wskazywanie obiektów z użyciem bufora głębi Wskazywanie obiektów na podstawie koloru Wskazywanie obiektów na podstawie ich przecięć z promieniem oka
17 18 26 29 38 46 53 57
63 63 64 68 70 74 79 83 85
OpenGL. Receptury dla programisty
Rozdział 3. Rendering pozaekranowy i mapowanie środowiska Wstęp Implementacja filtra wirowego przy użyciu shadera fragmentów Renderowanie sześcianu nieba metodą statycznego mapowania sześciennego Implementacja lustra z renderowaniem pozaekranowym przy użyciu FBO Renderowanie obiektów lustrzanych z użyciem dynamicznego mapowania sześciennego Implementacja filtrowania obrazu (wyostrzania, rozmywania, wytłaczania) metodą splotu Implementacja efektu poświaty
Rozdział 4. Światła i cienie Wstęp Implementacja oświetlenia punktowego na poziomie wierzchołków i fragmentów Implementacja światła kierunkowego na poziomie fragmentów Implementacja zanikającego światła punktowego na poziomie fragmentów Implementacja oświetlenia reflektorowego na poziomie fragmentów Mapowanie cieni przy użyciu FBO Przygotowania Mapowanie cieni z filtrowaniem PCF Wariancyjne mapowanie cieni
Rozdział 5. Formaty modeli siatkowych i systemy cząsteczkowe
89 89 90 93 97 101 106 109
115 115 116 122 124 128 130 131 136 141
151
Wstęp Modelowanie terenu przy użyciu mapy wysokości Wczytywanie modeli 3ds przy użyciu odrębnych buforów Wczytywanie modeli OBJ przy użyciu buforów z przeplotem Wczytywanie modeli w formacie EZMesh Implementacja prostego systemu cząsteczkowego
151 152 156 166 171 178
Rozdział 6. Mieszanie alfa i oświetlenie globalne na GPU
189
Wstęp Implementacja przezroczystości techniką peelingu jednokierunkowego Implementacja przezroczystości techniką peelingu dualnego Implementacja okluzji otoczenia w przestrzeni ekranu (SSAO) Implementacja metody harmonik sferycznych w oświetleniu globalnym Śledzenie promieni realizowane przez GPU Śledzenie ścieżek realizowane przez GPU
189 190 197 203 210 216 221
Rozdział 7. Techniki renderingu wolumetrycznego bazujące na GPU
227
Wstęp Implementacja renderingu wolumetrycznego z cięciem tekstury 3D na płaty Implementacja renderingu wolumetrycznego z jednoprzebiegowym rzucaniem promieni
4
227 228 236
Spis treści
Pseudoizopowierzchniowy rendering w jednoprzebiegowym rzucaniu promieni Rendering wolumetryczny z użyciem splattingu Implementacja funkcji przejścia dla klasyfikacji objętościowej Implementacja wydzielania wielokątnej izopowierzchni metodą maszerujących sześcianów Wolumetryczne oświetlenie oparte na technice cięcia połówkowokątowego
Rozdział 8. Animacje szkieletowe i symulacje fizyczne na GPU Wstęp Implementacja animacji szkieletowej z paletą macierzy skinningowych Implementacja animacji szkieletowej ze skinningiem wykonanym przy użyciu kwaternionu dualnego Modelowanie tkanin z użyciem transformacyjnego sprzężenia zwrotnego Implementacja wykrywania kolizji z tkaniną i reagowania na nie Implementacja systemu cząsteczkowego z transformacyjnym sprzężeniem zwrotnym
Skorowidz
241 245 252 255 262
269 269 270 280 287 296 301
311
5
OpenGL. Receptury dla programisty
Zespół oryginalnego wydania Autor Muhammad Mobeen Movania
Koordynator projektu Rahul Dixit
Recenzenci Bastien Berthe Dimitrios Christopoulos Oscar Ripolles Mateu
Korektorzy Stephen Silk Lauren Tobon
Redaktor inicjujący Erol Staveley Redaktor zamawiający Shreerang Deshpande Główny redaktor techniczny Madhuja Chaudhari Redaktorzy techniczni Jeeten Handu Sharvari H. Baet Ankita R. Meshram Priyanka Kalekar
6
Indekser Tejal R. Soni Grafik Abhinash Sahu Kierownik produkcji Aparna Bhagat Autor okładki Aparna Bhagat
O autorze Muhammad Mobeen Movania zrobił doktorat z zaawansowanej grafiki komputerowej i wizualizacji na Uniwersytecie Technologicznym Nanyang (NTU) w Singapurze. Licencjat z informatyki ze specjalnością grafika komputerowa zdobył na Uniwersytecie Iqra w Karaczi. Przed wstąpieniem na NTU był młodszym programistą grafiki w Data Communication and Control (DCC) Pvt. Ltd. w Karaczi. Pracował tam nad wytwarzaniem interaktywnych i działających w czasie rzeczywistym symulatorów taktycznych oraz zintegrowanych dynamicznych symulatorów szkoleniowych, a podstawowym jego narzędziem programistycznym były interfejsy DirectX i OpenGL. Jego zainteresowania badawcze obejmują techniki renderowania wolumetrycznego z wykorzystaniem procesorów graficznych, technologiczne aspekty takich procesorów, symulacje zjawisk z udziałem ciał plastycznych, wykrywanie kolizji i wyznaczanie na nie reakcji oraz hierarchizację struktur danych geometrycznych. Napisał jeden z rozdziałów najnowszej książki poświęconej interfejsowi OpenGL (OpenGL Insights, wyd. A K Peters/CRC Press). Jest także autorem projektu OpenCloth (http://code.google.com/p/opencloth), będącego zbiorem implementacji rozmaitych algorytmów symulujących dynamikę tkanin za pomocą OpenGL. W swoim blogu (http://mmmovania.blogspot.com) daje mnóstwo użytecznych rad i wskazówek na temat programowania grafiki komputerowej. Gdy nie zajmuje się grafiką, komponuje muzykę lub gra w squasha. Obecnie pracuje w instytucie badawczym w Singapurze.
Serdeczne podziękowania składam mojej rodzinie: rodzicom, żonie (Tanveer Taji), braciom i siostrom (Muhammad Khalid Movania, Azra Saleem, Sajida Shakir i Abdul Majid Movania) oraz ich dzieciom, a także mojej niedawno narodzonej córeczce (Muntaha Movania).
OpenGL. Receptury dla programisty
8
O recenzentach Bastlen Berthe jest młodym i zapalonym programistą 3D. Od dziecka interesował się grami wideo i grafiką 3D. Po kilku latach studiowania we Francji przeniósł się do Kanady, gdzie na uniwersytecie Sherbrooke skończył podyplomowe studia informatyczne w zakresie systemów czasu rzeczywistego, wizualizacji 3D i tworzenia gier komputerowych. Od 2012 roku pracuje w CAE (Montreal, Quebec) jako specjalista ds. grafiki 3D, a dokładniej — uczestniczy w pracach nad nowym systemem wizualizacji w symulatorach budowanym głównie w oparciu o OpenSceneGraph i OpenGL. CAE (http://www.cae.com) jest światowym liderem w budowaniu modeli i symulatorów służących do celów szkoleniowych w lotnictwie cywilnym, wojskowości, lecznictwie i górnictwie. Dimitrios Christopoulos studiował informatykę i budowę komputerów na Uniwersytecie Patraskim w Grecji, a tytuł magistra w dziedzinie grafiki komputerowej i wirtualnej rzeczywistości zdobył na Uniwersytecie Hull w Wielkiej Brytanii. Gry zaczął programować już w latach 80., a z biblioteki OpenGL korzysta od 1997 roku. Nie tylko tworzy gry, ale również uczestniczy w projektach badawczych Unii Europejskiej, wykonuje prezentacje wystaw muzealnych i kreuje rozmaite dzieła przedstawiające rzeczywistość wirtualną. Jego zainteresowania badawcze obejmują rzeczywistość wirtualną, interakcję między człowiekiem a komputerem, grafikę komputerową i gry. Ma na koncie wiele wystąpień na rozmaitych konferencjach i mnóstwo artykułów w czasopismach branżowych. Jest współautorem książki More OpenGL Game Programming (wyd. Cengage Learning PTR) i uczestniczył w pracach nad książką OpenGL Game Programming. Obecnie pracuje jako twórca programów służących do kreowania trójwymiarowych grafik i wirtualnej rzeczywistości, a także gier komputerowych, aplikacji edukacyjnych i wirtualnych instalacji prezentujących obiekty dziedzictwa kulturowego.
OpenGL. Receptury dla programisty
Oscar Ripolles uzyskał tytuł inżyniera informatyki w 2004 roku na Uniwersytecie Jakuba I w Castellón w Hiszpanii. Tam też zrobił doktorat z informatyki w 2009 roku. Prace badawcze prowadził również na Uniwersytecie w Limoges we Francji i na Politechnice w Walencji w Hiszpanii. Obecnie pracuje w dziale neuroobrazowania barcelońskiej firmy Neuroelectrics. Z racji swojej profesji interesuje się zagadnieniami związanymi z wielorozdzielczym modelowaniem, optymalizacją geometrii, oprogramowywaniem sprzętu i obrazowaniem medycznym.
10
Wstęp Książka ta dotyczy nowoczesnej biblioteki OpenGL w wersji 3.3 lub nowszej. Opisuję w niej mnóstwo zagadnień, począwszy od tak podstawowych jak modele kamer i wyznaczanie bryły widzenia aż po tak zaawansowane jak skinning przy użyciu kwaternionów dualnych czy techniki symulacyjne bazujące na GPU. Tematy są prezentowane w formie przepisów ze szczegółowo opisanymi etapami wykonywania określonych zadań, po czym następuje pogłębiona analiza zasady działania użytych technik. Początek stanowi delikatne wprowadzenie do biblioteki OpenGL. Potem jest pokazane przygotowanie podstawowej aplikacji shaderowej. Po omówieniu spraw związanych z konfiguracją środowiska programistycznego następuje prezentacja wszystkich etapów cieniowania, a odpowiednio dobrane przykłady praktyczne mają ułatwić czytelnikowi zrozumienie zasad funkcjonowania poszczególnych faz toku pracy nowoczesnego procesora graficznego. Po takim rozdziale wstępnym następuje prezentacja modelu patrzenia za pomocą wektorowej kamery — omawiane są dwa typy kamer: wycelowana (target camera) i swobodna (free camera). Opisany jest także sposób zaznaczania obiektów z wykorzystaniem bufora głębi, bufora koloru i zapytań o części wspólne elementów sceny. W aplikacjach symulacyjnych, a szczególnie w grach, bardzo przydaje się obiekt o nazwie sześcian nieba (skybox). Jego implementacja jest omówiona w przystępny sposób. Ze szczegółami opisane są techniki renderowania do tekstury przy użyciu FBO i dynamicznego mapowania sześciennego pomocne w opracowywaniu obiektów odbijających światło. Poza kwestiami graficznymi prezentowane są implementacje cyfrowych filtrów konwolucyjnych służących do przetwarzania obrazów i wykorzystujących shadery fragmentów. Opisane jest również przekształcenie skręcania obrazu. Ponadto objaśniona jest realizacja efektów takich jak poświata. Aplikacje 3D bez możliwości ustawienia oświetlenia należą raczej do rzadkości. Światło odgrywa ważną rolę w oddawaniu nastroju sceny. W książce omawiane są światła punktowe, kierunkowe i reflektorowe z zanikaniem, przy czym uwzględnione jest podejście zarówno wierzchołkowe, jak i fragmentowe. Omawiane są również techniki mapowania cieni włącznie z filtrowaniem PCF i mapowaniem wariacyjnym.
OpenGL. Receptury dla programisty
W wielu aplikacjach stosuje się rozbudowane modele przygotowane za pomocą wyspecjalizowanych pakietów modelarskich i przechowywane w zewnętrznych plikach. Opracujemy dwie techniki wczytywania takich modeli przy użyciu obiektów buforowych odrębnych i z przeplotem. Na konkretnych przykładach zostanie przeprowadzona analiza składniowa formatów modelowych 3DS i OBJ. Formaty te obsługują większość atrybutów modeli, włącznie z materiałami. Modele szkieletowe wprowadzimy w nowym formacie animacyjnym EZMesh. Zobaczymy, jak można wczytywać takie modele z animacją, używając skinningu opartego bądź to na palecie macierzy, bądź na dualnym kwaternionie. Wszędzie tam, gdzie jest to możliwe, receptury zawierają wskaźniki do zewnętrznych bibliotek i adresy internetowe prowadzące do miejsc z dodatkowymi informacjami. Przy tworzeniu efektów specjalnych często wprowadza się obiekty rozmyte, takie jak dym czy mgła. Zazwyczaj tworzy się je za pomocą systemów cząsteczkowych. Poznamy dokładnie taki system w wydaniu zarówno bezstanowym, jak i zachowującym stany. Podczas wyświetlania scen o skomplikowanej głębi zwykłe techniki mieszania alfa nie zdają egzaminu. W takich sytuacja stosowana jest metoda peelingu głębi (depth peeling), która umożliwia renderowanie geometrii z właściwą kolejnością głębi i właściwym mieszaniem. Przyjrzymy się implementacji zarówno tradycyjnego peelingu od przodu do tyłu, jak i nowego podejścia z peelingiem dualnym. Wszystkie istotne etapy takiego procesu będą szczegółowo opisane. Grafika komputerowa wciąż wymusza nowe rozwiązania sprzętowe, aby uzyskiwane renderingi były jeszcze bardziej zbliżone do rzeczywistości. Jednym z czynników, które mają duży wpływ na realizm przedstawianych scen, jest światło. Niestety symulacja w czasie rzeczywistym oświetlenia, jakie obserwujemy w życiu codziennym, nie jest na razie możliwa. Dlatego graficy komputerowi wymyślają rozmaite metody przybliżonego modelowania efektów świetlnych. Metody te są włączane do technik realizacyjnych oświetlenia globalnego. W recepturach zostaną zaprezentowane dwa popularne podejścia możliwe do realizacji na nowoczesnych GPU: harmonik sferycznych i okluzji otoczenia w przestrzeni ekranu. Omówione też będą dwie metody renderowania scen, a konkretnie metoda śledzenia promieni (ray tracing) i śledzenia ścieżek (path tracing). Oczywiście pokazana będzie praktyczna implementacja każdej z nich z przeznaczeniem dla nowoczesnego procesora graficznego. Grafika komputerowa ma duży wpływ na rozwój szeregu innych dziedzin, począwszy od filmowych efektów specjalnych, a na biomedycznych i technicznych symulacjach skończywszy. Szczególnie ta ostatnia dziedzina mocno zaangażowała metody grafiki 3D do wykonywania i wizualizowania projektów. Współczesne procesory graficzne dysponują wielkimi mocami obliczeniowymi i są w stanie realizować nawet bardzo zaawansowane techniki wizualizacyjne, a jedną z nich jest rendering wolumetryczny. Rozpatrzymy kilka algorytmów takiego renderingu, w tym cięcie tekstury 3D na plastry zgodne z widokiem, jednoprzebiegowe generowanie cieni, rendering pseudoizopowierzchni, splatting, wydobywanie wielokątnej izopowierzchni przy użyciu metody maszerującego czworościanu (marching tetrahedra)1 i cięcie na plastry pod kątem połówkowym dla oświetlenia wolumetrycznego.
1
Autor posługuje się tutaj nazwą Marching Tetrahedra (maszerujące czworościany), ale tak naprawdę prezentuje algorytm o nazwie Marching Cubes (maszerujące sześciany) — przyp. tłum.
12
Wstęp
Symulacje oparte na prawach fizyki stanowią ważną klasę algorytmów umożliwiających wyznaczanie ruchów obiektów przez aproksymowanie modeli fizycznych. Rozpracujemy nowy mechanizm transformacyjnego sprzężenia zwrotnego (transform feedback) i użyjemy go do przeprowadzenia dwóch symulacji, angażując wyłącznie procesor graficzny. Najpierw będzie to symulacja zachowań tkaniny (z wykrywaniem kolizji i reagowaniem na nie), a potem wykonamy symulację ruchów cząstek. Podsumowując: książka zawiera mnóstwo informacji na różne tematy. Pisanie jej sprawiło mi wiele radości, a przy okazji sam też wielu rzeczy się nauczyłem. Mam nadzieję, że będzie ona dla innych źródłem przydatnej wiedzy jeszcze przez wiele lat.
Tematyka książki Rozdział 1., „Wprowadzenie do nowoczesnego OpenGL”, zawiera opis instalacji rdzennego profilu biblioteki OpenGL 3.3 w środowisku Visual Studio 2013 Professional. Rozdział 2., „Wyświetlanie i wskazywanie obiektów 3D”, pokazuje, jak można zaimplementować wektorowy model kamery dla systemu wyświetlania widoku sceny. Objaśniane są tu dwa typy kamer oraz sposoby ukrywania elementów spoza bryły widzenia. Rozdział kończy opis metody wskazywania (zaznaczania) obiektów. Rozdział 3., „Rendering pozaekranowy i mapowanie środowiska”, objaśnia stosowanie obiektu bufora klatki (FBO) w renderingu pozaekranowym. Omawiana jest implementacja mapowań lustrzanego i dynamicznego sześciennego. Poza tym pokazane są przykłady przetwarzania obrazów przy użyciu cyfrowej konwolucji i odwzorowywania środowiska za pomocą statycznego mapowania sześciennego. Rozdział 4., „Światła i cienie”, opisuje implementacje świateł punktowego, reflektorowego i kierunkowego ze stopniowym zanikaniem. Ponadto szczegółowo omawiane są metody renderowania cieni dynamicznych, takie jak mapowanie cieni, stosowanie map filtrowanych metodą PCF i mapowanie wariacyjne. Rozdział 5., „Formaty modeli siatkowych i systemy cząsteczkowe”, pokazuje, jak należy wczytywać dane zapisane w klasycznych formatach modelowych 3DS i OBJ, używając buforów odrębnych lub z przeplotem. Opisane są również wczytywanie animacji szkieletowych w formacie EZMesh i implementacja prostego systemu cząsteczkowego. Rozdział 6., „Mieszanie alfa i oświetlenie globalne na GPU”, pokazuje przykład implementacji przezroczystości niezależnej od kolejności obiektów i z peelingiem głębi od przodu do tyłu i dualnym. Omawiane są również zagadnienia takie jak okluzja otoczenia w przestrzeni ekranu (SSAO) i metoda harmonik sferycznych oświetlenia bazującego na obrazach. Na koniec prezentowane są alternatywne metody renderowania geometrii, takie jak realizowane przez GPU śledzenie promieni i ścieżek.
13
OpenGL. Receptury dla programisty
Rozdział 7., „Techniki renderingu wolumetrycznego bazujące na GPU”, zawiera przykłady implementacji kilku algorytmów renderingu wolumetrycznego w OpenGL włącznie z cięciem tekstury 3D na plastry zgodne z widokiem, jednoprzebiegowym generowaniem cieni, splattingiem i opartym na metodzie maszerującego czworościanu renderingiem pseudoizopowierzchni i izopowierzchni wielokątnych. Szczegółowo omawiane są również klasyfikacja wolumetryczna i wolumetryczne oświetlenie oparte na technice cięcia połówkowokątowego. Rozdział 8., „Animacje szkieletowe i symulacje fizyczne na GPU”, pokazuje sposób implementacji animacji szkieletowej ze skinningiem opartym na palecie macierzy lub dualnym kwaternionie. Pokazuje też, jak należy używać transformacyjnego sprzężenia zwrotnego przy implementowaniu systemów cząsteczkowych i symulacji tkanin z wykrywaniem kolizji.
Czego potrzebujesz oprócz samej książki? Przystępując do napisania tej książki, założyłem, że czytelnik będzie miał przynajmniej podstawową wiedzę na temat użytkowania interfejsu programistycznego OpenGL. Przykładowe kody rozpowszechniane wraz z książką zostały opracowane za pomocą Visual Studio 2013 w wersji Professional. Do ich kompilowania i konsolidowania potrzebne będą biblioteki freeglut, GLEW, GLM i SOIL. Wszystkie kody zostały przetestowane na platformie Windows 7 z kartą graficzną NVIDIA i następującymi wersjami wymienionych bibliotek: freeglut 2.8.0 (najnowszą wersję można pobrać z http://freeglut.sourceforge.net), GLEW v1.9.0 (najnowszą wersję można pobrać z http://glew.sourceforge.net), GLM v0.9.4.0 (najnowszą wersję można pobrać z http://glm.g-truc.net), SOIL (najnowszą wersję można pobrać z http://www.lonesock.net/soil.html).
Najnowsze wersje tych bibliotek nie powinny sprawiać żadnych problemów przy kompilacji i wykonywaniu prezentowanych kodów.
Dla kogo jest ta książka? Książka ta została napisana z myślą o średnio zaawansowanych programistach grafiki trójwymiarowej, którzy mają już pewne doświadczenie w stosowaniu jakiegokolwiek interfejsu do programowania aplikacji graficznych, przy czym znajomość OpenGL będzie zdecydowanie pomocna. Wskazana byłaby też choćby podstawowa wiedza na temat procesorów graficznych i shaderów. Zarówno książka, jak i prezentowane w niej kody były pisane z nastawieniem na maksymalną prostotę. Zależało mi, aby wszystko było łatwe do zrozumienia. Opisałem szeroki wachlarz tematów, a implementację każdej techniki przedstawiłem krok po kroku. Zamieściłem też dodatkowe wyjaśnienia trudniejszych kwestii, co powinno ułatwić zrozumienie prezentowanych treści.
14
Wstęp
Konwencje W książce zastosowano kilka stylów tekstowych, aby odróżnić poszczególne rodzaje informacji. Oto kilka przykładów z objaśnieniami znaczenia tych stylów. Fragmenty kodu umieszczone w tekście wyglądają następująco: „Maksymalną liczbę przyłączeń kolorów w danym GPU można uzyskać za pomocą pola GL_MAX_COLOR_ATTACHMENTS”. Blok kodu jest zapisywany w sposób następujący: for(int i=0;i<16;i++) { float indexA = (random(vec4(gl_FragCoord.xyx, i))*0.25); float indexB = (random(vec4(gl_FragCoord.yxy, i))*0.25); sum += textureProj(shadowMap, vShadowCoords + vec4(indexA, indexB, 0, 0)); }
Części kodu wymagające baczniejszej uwagi zostały wyróżnione czcionką pogrubioną: void main() { vEyeSpacePosition = (MV*vec4(vVertex,1)).xyz; vEyeSpaceNormal = N*vNormal; vShadowCoords = S*(M*vec4(vVertex,1)); gl_Position = MVP*vec4(vVertex,1); }
Nowe lub ważne pojęcia są zapisywane czcionką pogrubioną. Wyrażenia wyświetlane na ekranie, np. w menu lub w oknach dialogowych, przyjmują w tekście postać taką jak w zdaniu: „Wybierz polecenie Properties z menu Project”. Wskazówki, sugestie i ważne informacje pojawiać się będą w takich ramkach.
Dodatkowe materiały pomocnicze Jako szczęśliwy posiadacz wydanej przez nas książki masz prawo do skorzystania z kilku dodatkowych możliwości, dzięki którym będziesz mógł pełniej wykorzystać swój nabytek.
15
OpenGL. Receptury dla programisty
Przykłady kodu do pobrania Pliki z przykładami kodu można pobrać ze strony wydawnictwa Helion pod adresem ftp://ftp.helion.pl/przyklady/openrp.zip.
Kolorowe ilustracje Oferujemy Ci również możliwość pobrania pliku pdf z rysunkami (zrzutami ekranu i grafikami), jakie zostały zamieszczone w książce. Kolorowe rysunki z pewnością ułatwią Ci zrozumienie omawianych zagadnień. Plik możesz pobrać ze strony http://www.packtpub.com/sites/default/ files/downloads/5046OT_ColoredImages.pdf.
Errata Dołożyliśmy wszelkich starań, aby treść tej książki była jak najwyższej jakości, ale niestety błędy zdarzają się każdemu. Jeśli znajdziesz błąd w tej książce — np. w tekście albo kodzie źródłowym — będziemy Ci wdzięczni za poinformowanie nas o tym. Skorzystają na tym inni czytelnicy oraz wydawnictwo, które będzie mogło poprawić błędy w następnych wydaniach książki. Erratę można zgłaszać za pomocą formularza na stronie http://helion.pl/erraty.htm. Sprawdzimy przesłane informacje i jeśli przyznamy Ci rację, opublikujemy stosowane sprostowanie na naszej stronie internetowej. Informacje o już znalezionych błędach zamieszczone są na stronie książki, pod adresem www.helion.pl/ksiazki/openrp.htm.
Piractwo Piractwo materiałów chronionych prawami autorskimi jest plagą wszystkich mediów. Wydawnictwo Helion traktuje tę kwestię bardzo poważnie. Jeśli znajdziesz nielegalne kopie naszych publikacji w jakiejkolwiek formie w internecie, prześlij nam adres albo nazwę witryny internetowej, abyśmy mogli dochodzić swoich praw. Informacje na temat łamania praw autorskich można wysyłać na adres
[email protected]. Dziękujemy za wszelką pomoc w ochronie praw naszych autorów i dostarczaniu cennej treści czytelnikom.
Pytania W razie napotkania jakichkolwiek problemów w związku z naszymi książkami wyślij zapytanie na adres
[email protected], a zrobimy wszystko, co w naszej mocy, by te problemy rozwiązać.
16
1 Wprowadzenie do nowoczesnego OpenGL W tym rozdziale: Instalacja rdzennego profilu OpenGL 3.3 w Visual Studio 2013 przy użyciu bibliotek
GLEW i freeglut Projektowanie klasy shadera w GLSL Renderowanie kolorowego trójkąta za pomocą shaderów Wykonanie deformatora siatki przy użyciu shadera wierzchołków Dynamiczne zagęszczanie podziału płaszczyzny przy użyciu shadera geometrii Dynamiczne zagęszczanie podziału płaszczyzny przy użyciu shadera geometrii
i renderingu instancyjnego Rysowanie obrazu 2D przy użyciu shadera fragmentów i biblioteki SOIL
Wstęp Interfejs programistyczny OpenGL był wielokrotnie zmieniany od czasu swych narodzin w 1992 roku. W każdej kolejnej wersji dodawano nowe funkcje i coraz więcej operacji przeznaczano do wykonania sprzętowego poprzez stosowne rozszerzenia. Do wersji 2.0 (która ukazała się w roku 2004) funkcje potoku graficznego były ściśle ustalone i nie było możliwości ich
OpenGL. Receptury dla programisty
modyfikowania. W wersji 2.0 po raz pierwszy pojawiły się obiekty shaderów, co dało programistom możliwość kształtowania potoku. Służyły do tego celu programy zwane shaderami, które można było pisać w specjalnie opracowanym języku GLSL (OpenGL Shading Language). Następne znaczące zmiany przyniosła wersja 3.0. Wprowadziła ona podział biblioteki na dwa profile: rdzenny (ang. core profile) i zgodnościowy (ang. compatibility profile). Profil rdzenny zawiera wszystkie funkcje nowoczesne, a w profilu zgodnościowym zgromadzono te, które uznano za przestarzałe, ale jeszcze potrzebne ze względu na konieczność zachowania kompatybilności z wersjami poprzednimi. W chwili pisania tej książki, czyli w roku 2012, dostępna jest już wersja 4.3, ale nie różni się ona od wersji 3.0 tak bardzo, jak ta różniła się od wersji 2.0. W tym rozdziale przeanalizujemy trzy fazy cieniowania dostępne w rdzennym profilu OpenGL 3.3, czyli shaderowe przetwarzanie wierzchołków, geometrii i fragmentów. Wspomnę tylko, że w wersji 4.0 wprowadzono dwie dodatkowe fazy realizowane za pomocą shaderów kontroli (ang. tessellation control) i ewaluacji teselacji (ang. tessellation evaluation), które można zastosować pomiędzy shaderami wierzchołków a shaderami geometrii.
Instalacja rdzennego profilu OpenGL 3.3 w Visual Studio 20131 przy użyciu bibliotek GLEW i freeglut Rozpoczniemy od bardzo prostego przykładu polegającego na utworzeniu pustego okna i wypełnieniu go kolorem czerwonym. Naszym głównym zadaniem będzie jednak przygotowanie środowiska pracy z rdzennym profilem interfejsu OpenGL w wersji 3.3. OpenGL, jak każdy graficzny interfejs programistyczny, wymaga okna, w którym grafika mogłaby być wyświetlana. Okna takie przygotowuje się za pomocą kodu specyficznego dla konkretnej platformy. Dawniej można też było używać do tego celu biblioteki GLUT, która działała w sposób niezależny od platformy, ale jej aktualizacje nie nadążały za coraz nowszymi wersjami OpenGL. Na szczęście pojawił się inny niezależny projekt, freeglut, oferujący podobne możliwości (a niekiedy nawet większe) w zakresie przygotowywania okien na wszystkich platformach. Pomaga on również w tworzeniu kontekstu dla obu profili interfejsu OpenGL. Najnowszą wersję biblioteki freeglut można pobrać ze strony http://freeglut.sourceforge.net. Kody prezentowane w książce zostały opracowane przy użyciu tej biblioteki w wersji 2.8.02. Po pobraniu trzeba ją skompilować w celu uzyskania plików lib i dll.
1
Autor używał pakietu Visual Studio w wersji 2010, ale w polskiej edycji wszystkie opisy tego środowiska programistycznego, a także pliki towarzyszące książce zostały zaktualizowane do wersji 2013 — przyp. tłum.
2
W polskiej edycji użyto biblioteki freeglut w wersji 2.8.1 — przyp. tłum.
18
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
Mechanizm rozszerzeń w OpenGL nadal istnieje. Jako pomoc w uzyskiwaniu właściwych wskaźników funkcji używana jest biblioteka GLEW. Jej najnowszą wersję można pobrać ze strony http://glew.sourceforge.net. Przy tworzeniu kodów źródłowych prezentowanych w książce korzystałem z tej biblioteki w wersji 1.9.03. Po pobraniu trzeba ją skompilować w celu uzyskania plików lib i dll. Można ją też pobrać w formie gotowych plików binarnych. W wersjach wcześniejszych niż 3.0 obsługa macierzy była realizowana w OpenGL poprzez stosy macierzowe odrębne dla przekształceń widoku i modelu, rzutowania oraz tekstur. Poza tym dostępne były funkcje transformacji takich jak przesunięcie, obrót, skalowanie i rzutowanie. Możliwe było też korzystanie z trybu renderingu natychmiastowego pozwalającego programistom na bezpośrednie przekazywanie informacji o wierzchołkach do sterowników sprzętowych. W wersji 3.0 wszystko to zostało usunięte z profilu rdzennego, ale dla zachowania kompatybilności wstecznej nadal jest dostępne w profilu zgodnościowym. Jeśli używamy profilu rdzennego (co jest podejściem zalecanym), musimy sami zadbać o zaimplementowanie niezbędnych mechanizmów włącznie z obsługą macierzy i przekształceń. Na szczęście istnieje biblioteka o nazwie glm, która dostarcza odpowiednich klas związanych z matematyką wektorów i macierzy. Wszędzie tam, gdzie tego typu klasy okażą się potrzebne, będziemy z tej biblioteki korzystać. Jako że ma ona charakter pliku nagłówkowego, nie wymaga konsolidacji z żadnymi innymi bibliotekami. Najnowszą wersję można pobrać ze strony http://glm.g-truc.net. W przykładach zamieszczonych w książce używana była wersja 0.9.4.04. Do zapisywania obrazów jest używanych wiele rozmaitych formatów. Napisanie programu, który musi wczytać jakiś obraz, nie jest więc rzeczą trywialną. Są jednak biblioteki, dzięki którym można to zrobić bardzo łatwo. Umożliwiają one także zapisywanie obrazów w najróżniejszych formatach. Jedną z nich jest biblioteka SOIL. W najnowszej wersji jest dostępna na stronie http://www.lonesock.net/soil.html. Po pobraniu biblioteki SOIL należy wypakować plik na dysk twardy, a następnie wskazać jego lokalizację w ustawieniach ścieżek z dołączanymi katalogami i bibliotekami środowiska Visual Studio. Na moim komputerze ścieżka z katalogami przyjęła postać D:\Biblioteki\Simple OpenGL Image Library\src, a ścieżka z bibliotekami — D:\Biblioteki\Simple OpenGL Image Library\ projects\VC9\Debug. Oczywiście w Twoim systemie mogą one wyglądać inaczej, ale zawsze powinny prowadzić do wskazanych tu folderów. Wszystko to ma na celu skonfigurowanie naszego środowiska pracy. Wszystkie receptury prezentowane w książce zostały opracowane przy użyciu Visual Studio 2013 w wersji Professional. Nic nie stoi na przeszkodzie, by użyć darmowej wersji Express lub jakiejkolwiek innej (Ultimate lub Enterprise). Kod opracowany dla pierwszej receptury znajduje się w folderze Rozdział1\Zaczynamy.
3
W polskiej edycji użyto biblioteki GLEW w wersji 1.10.0 — przyp. tłum.
4
W polskiej edycji użyto biblioteki glm w wersji 0.9.5.4 — przyp. tłum.
19
OpenGL. Receptury dla programisty
Pobieranie przykładowego kodu Pliki z kodami do książki można pobrać ze strony internetowej książki pod adresem:
ftp://ftp.helion.pl/przyklady/openrp.zip.
Jak to zrobić? Przygotuj środowisko programistyczne, wykonując następujące czynności: 1. Po pobraniu niezbędnych bibliotek skonfiguruj ustawienia pakietu Visual Studio.
2. Najpierw utwórz nowy projekt Aplikacja konsoli Win32 (Win32 Console Application), tak jak na powyższym rysunku. Następnie ustaw parametry tego projektu zgodnie z poniższym rysunkiem. 3. Potem zdefiniuj ścieżki dołączanych katalogów i bibliotek. W tym celu rozwiń menu Projekt (Project) i wybierz polecenie Właściwości (Properties). W oknie dialogowym, które się otworzy, kliknij najpierw Właściwości konfiguracji (Configuration Properties), a następnie Katalogi VC++ (VC++ Directories). 4. W panelu po prawej stronie odszukaj pole Dołącz katalogi (Include Directories) i dodaj w nim ścieżki do podfolderów include bibliotek GLEW i freeglut.
20
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
5. Podobnie w polu Katalogi biblioteki (Library Directories) dodaj ścieżki do podfolderów lib dla obu bibliotek, tak jak na rysunku poniżej5.
5
Do uruchomienia programów korzystających z wymienionych bibliotek środowisko Visual Studio potrzebuje jeszcze plików freeglut.dll i glew32.dll. Kopie tych plików należy umieścić w folderze C:\Windows\ System32 (dla systemu Windows 32-bitowego) lub C:\Windows\SysWOW64 (dla systemu Windows 64-bitowego) — przyp. tłum.
21
OpenGL. Receptury dla programisty
6. Następnie dodaj do projektu nowy plik .cpp i nazwij go main.cpp. Będzie to główny plik źródłowy. Gotowy taki plik, ze wszystkimi ustawieniami, znajdziesz w materiałach dołączonych do książki — Rozdział1\Zaczynamy\Zaczynamy\main.cpp. 7. Przeanalizujmy poszczególne fragmenty tego pliku. #include
#include #include
W powyższych wierszach wymienione są pliki, które będą dołączane do wszystkich naszych projektów. W pierwszym jest plik nagłówkowy biblioteki GLEW, w drugim — biblioteki freeglut, a w trzecim — standardowej biblioteki wejścia i wyjścia. 8. W Visual Studio można dodawać niezbędne biblioteki konsolidatora na dwa sposoby. W pierwszym wykorzystujemy środowisko pakietu programistycznego — służy do tego polecenie Właściwości (Properties) w menu Projekt (Project). Otwiera ono okno z właściwościami projektu, gdzie po rozwinięciu kategorii Konsolidator (Linker) klikamy pozycję Wejście (Input). Pierwsze pole w panelu po prawej stronie nosi nazwę Dodatkowe zależności (Additional Dependencies) i to w nim możemy dodać potrzebną bibliotekę, tak jak to zostało pokazane na rysunku poniżej.
9. Sposób drugi polega na dodaniu pliku glew32.lib do ustawień konsolidatora metodą programową. Można to zrobić za pomocą następującej dyrektywy pragma: #pragma comment(lib, "glew32.lib")
10. Kolejny wiersz zawiera dyrektywę using udostępniającą funkcje w standardowej przestrzeni nazw. Umieszczenie tej dyrektywy nie jest konieczne, ale dzięki niej nie będziemy musieli dodawać przedrostka std:: do każdej standardowej funkcji bibliotecznej z pliku nagłówkowego iostream.
22
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
using namespace std;
11. W następnych wierszach ustalane są ekranowe wymiary okna — jego szerokość i wysokość. Po tych deklaracjach następują definicje pięciu funkcji. Funkcja OnInit() służy do inicjalizacji wszelkich stanów lub obiektów OpenGL, OnShutdown() likwiduje obiekt OpenGL, OnResize() obsługuje zdarzenie zmiany wymiarów okna, OnRender() umożliwia obsługę zdarzenia malowania zawartości okna, a main() stanowi właściwy początek aplikacji. Zacznijmy od definicji funkcji main(). const int WIDTH = 1280; const int HEIGHT = 960; int main(int argc, char** argv) { glutInit(&argc, argv); glutInitDisplayMode(GLUT_DEPTH | GLUT_DOUBLE | GLUT_RGBA); glutInitContextVersion (3, 3); glutInitContextFlags (GLUT_CORE_PROFILE | GLUT_DEBUG); glutInitContextProfile(GLUT_FORWARD_COMPATIBLE); glutInitWindowSize(WIDTH, HEIGHT);
12. W pierwszym wierszu funkcja gluInit inicjalizuje środowisko GLUT. Do niej przekazywane są argumenty pobrane przy uruchomieniu aplikacji. Następnie konfigurujemy tryb wyświetlania dla naszej aplikacji. W tym przypadku zmuszamy mechanizmy GLUT do zapewnienia obsługi bufora głębi, podwójnego buforowania obrazu (z buforami przednim i tylnym, których cykliczne zamiany zapobiegają migotaniu obrazu podczas renderowania) i ustawienia formatu bufora ramki jako RGBA (czyli z kanałami kolorów czerwonego, zielonego i niebieskiego oraz z kanałem alfa). Potem za pomocą funkcji glutInitContextVersion ustalamy właściwą wersję kontekstu OpenGL. Pierwszy parametr określa główny numer tej wersji, a drugi podaje numer poboczny. Przykładowo, jeśli chcemy utworzyć kontekst dla OpenGL w wersji 4.3, wpisujemy glutInitContextVersion(4, 3). Kolejne funkcje ustalają znaczniki i profil kontekstu OpenGL. glutInitContextFlags (GLUT_CORE_PROFILE | GLUT_DEBUG); glutInitContextProfile(GLUT_FORWARD_COMPATIBLE); W OpenGL 4.3 możemy zarejestrować funkcję zwrotną wywoływaną w chwili pojawienia się błędu związanego z OpenGL. Przekazanie znacznika GLUT_DEBUG do funkcji glutInitContextFlags tworzy kontekst w trybie debugowania wymaganym do zwrotnego wywoływania funkcji przez komunikaty generowane podczas debugowania programu.
13. W OpenGL 3.3, podobnie jak i we wszystkich wersjach późniejszych, są dostępne dwa profile: rdzenny (oparty wyłącznie na shaderach i pozbawiony klasycznych, stałych mechanizmów dawnej biblioteki) i zgodnościowy (obsługujący te dawne mechanizmy). Wszystkie funkcje związane z obsługą macierzy, takie jak glMatrixMode(*), glTranslate*, glRotate* czy glScale*, oraz wywoływane w trybie bezpośrednim, np. glVertex*, glTexCoord* czy glNormal*, są teraz dostępne tylko
23
OpenGL. Receptury dla programisty
w profilu zgodnościowym. My będziemy chcieli zachować zgodność z aktualnymi i przyszłymi wersjami biblioteki, a zatem nie będziemy używać żadnych stałych mechanizmów, mimo że są jeszcze dostępne. 14. Następnie ustawiamy wymiary okna i tworzymy je. glutInitWindowSize(WIDTH, HEIGHT); glutCreateWindow("Zaczynamy pracę z OpenGL 3.3");
15. Potem następuje inicjalizacja biblioteki GLEW. Ważne jest, aby ta inicjalizacja miała miejsce po utworzeniu kontekstu OpenGL. Jeśli funkcja zwraca GLEW_OK, to znaczy, że inicjalizacja przebiegła pomyślnie, a każda inna wartość oznacza błąd. glewExperimental = GL_TRUE; GLenum err = glewInit(); if (GLEW_OK != err) { cerr<<"Błąd: "<
Globalny przełącznik glewExperimental umożliwia bibliotece GLEW sprawdzanie, czy rozszerzenie jest obsługiwane przez sprzęt, ale nie jest obsługiwane przez sterowniki eksperymentalne bądź rozwojowe. Po inicjalizacji biblioteki wypisywane są podstawowe informacje diagnostyczne, takie jak wersja biblioteki GLEW, producent karty graficznej, renderer OpenGL i wersja języka GLSL. 16. Na koniec wywołujemy funkcję inicjalizującą OnInit(), po czym przekazujemy zamykającą funkcję OnShutdown() jako argument metody glutCloseFunc, aby została zwrotnie wywołana, gdy pojawi się sygnał o zamykaniu okna. Podobnie rejestrowane są pozostałe funkcje wywoływane zwrotnie w reakcji na zdarzenia renderowania i zmiany wymiarów okna. Główna funkcja kończy się wywołaniem funkcji glutMainLoop(), która uruchamia główną pętlę aplikacji. OnInit() { glutCloseFunc(OnShutdown); glutDisplayFunc(OnRender); glutReshapeFunc(OnResize); glutMainLoop(); return 0; }
24
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
I jeszcze jedno… Pozostałe funkcje są zdefiniowane następująco: void OnInit() { glClearColor(1,0,0,0); cout<<"Inicjalizacja powiodła się"<
W tym prostym przykładzie ustawiliśmy kolor czyszczenia jako czerwony (R = 1, G = 0, B = 0, A = 0). Pierwsze trzy wartości odnoszą się do kanałów barw czerwonej, zielonej i niebieskiej, a ostatnia dotyczy kanału przezroczystości, który jest wykorzystywany w procesie zwanym mieszaniem alfa. Trochę bardziej złożona jest funkcja OnRender() wywoływana zwrotnie w reakcji na zdarzenie renderowania obrazu. Jej działanie zaczyna się od wyczyszczenia buforów koloru i głębi. Bufor głębi funkcjonuje podobnie jak bufor koloru. Jego wartość czyszczącą można ustalić za pomocą funkcji glClearDepth. Jest używany w realizowanym sprzętowo procesie usuwania niewidocznych powierzchni. Po prostu przechowuje głębię pierwszego napotkanego fragmentu. Głębia analizowanego fragmentu jest wpisywana do bufora w miejsce wartości dotychczasowej, jeśli spełnia warunki testu określone przez funkcję glDepthFunc. Przy ustawieniach domyślnych nowa wartość jest zapisywana tylko wtedy, gdy jest mniejsza od dotychczasowej.
Potem wywoływana jest funkcja glutSwapBuffers, która ustawia tylny bufor obrazu jako przedni w celu wyświetlenia jego zawartości na ekranie monitora. Operacja taka jest niezbędna w aplikacji z podwójnym buforowaniem. Uruchomienie tego prostego programu powinno dać rezultat pokazany na rysunku na następnej stronie.
25
OpenGL. Receptury dla programisty
Projektowanie klasy shadera w GLSL Zobaczmy teraz, jak należy obchodzić się z shaderami. Shadery są to specjalne programy uruchamiane na procesorze graficznym. Występują w różnych odmianach, z których każda służy do sterowania innym etapem programowalnego potoku graficznego. Są więc shadery wierzchołków (które odpowiadają za wyznaczanie położenia wierzchołków w bryle widzenia), shadery kontroli teselacji (odpowiedzialne za określanie poziomu teselacji przetwarzanej łaty), shadery ewaluacji teselacji (wyznaczające interpolowane wartości położenia i innych atrybutów wynikające z przeprowadzanej teselacji), shadery geometrii (które przetwarzają obiekty podstawowe i w razie potrzeby mogą dodawać kolejne tego typu obiekty lub wierzchołki) oraz shadery fragmentów (które zamieniają rasteryzowane fragmenty na kolorowe piksele z określoną głębią). Na rysunku na następnej stronie zostały przedstawione poszczególne etapy nowoczesnego potoku graficznego z uwzględnieniem różnych kategorii shaderów. Shadery kontroli i ewaluacji teselacji są dostępne tylko tam, gdzie sprzęt obsługuje funkcje biblioteki OpenGL w wersji 4.0 lub nowszej. Zasadnicze czynności związane z obsługą poszczególnych shaderów w aplikacjach korzystających z biblioteki OpenGL są bardzo podobne, więc zapoznamy się z nimi, opracowując jedną, wspólną dla nich klasę o nazwie GLSLShader.
26
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
Przygotowania Klasa GLSLShader jest zdefiniowana w plikach GLSLShader.h i GLSLShader.cpp. W pierwszych wierszach zawiera deklaracje konstruktora i destruktora, które inicjalizują zmienne składowe. Następne trzy funkcje, LoadFromString, LoadFromFile i CreateAndLinkProgram, realizują kompilację shadera oraz tworzenie i konsolidację programu shaderowego. Kolejne dwie funkcje, Use i UnUse, podłączają i odłączają program. Do przechowywania atrybutów i uniformów służą dwie struktury danych std::map. Przyjmują one jako klucz nazwę atrybutu lub uniformu, a jako wartość zapisują lokalizację. Ma to zapobiegać zbędnym pobieraniom lokalizacji atrybutu lub uniformu dla każdej klatki lub gdy ta lokalizacja jest wymagana do pozyskania atrybutu bądź uniformu. Funkcje AddAttribute i AddUniform dodają lokalizacje atrybutów i uniformów do odpowiednich struktur std::map (_attributeList i _uniformLocationList). class GLSLShader { public: GLSLShader(void); ~GLSLShader(void); void LoadFromString(GLenum whichShader, const string& source); void LoadFromFile(GLenum whichShader, const string& filename); void CreateAndLinkProgram(); void Use(); void UnUse(); void AddAttribute(const string& attribute); void AddUniform(const string& uniform); GLuint operator[](const string& attribute); GLuint operator()(const string& uniform); void DeleteShaderProgram(); private: enum ShaderType{VERTEX_SHADER,FRAGMENT_SHADER,GEOMETRY_SHADER}; GLuint _program; int _totalShaders; GLuint _shaders[3]; map _attributeList; map _uniformLocationList; };
Aby ułatwić sobie pozyskiwanie lokalizacji atrybutów i uniformów z ich map, wprowadzamy deklaracje dwóch indekserów. Dla atrybutów przeciążamy nawiasy kwadratowe [], a dla
27
OpenGL. Receptury dla programisty
uniformów przeciążamy operator nawiasów okrągłych (). Na koniec deklarujemy funkcję Delete ShaderProgram, której zadaniem będzie usunięcie obiektu programu shaderowego. Za deklaracjami funkcji następują deklaracje pól.
Jak to zrobić? W typowej aplikacji shaderowej obiekt klasy GLSLShader powinien być używany w sposób następujący: 1. Utwórz obiekt GLSLShader albo na stosie (np. GLSLShader shader;), albo na stercie (np. GLSLShader* shader=new GLSLShader();). 2. Wywołaj LoadFromFile z referencją do obiektu GLSLShader. 3. Wywołaj CreateAndLinkProgram z referencją do obiektu GLSLShader. 4. Wywołaj Use z referencją do obiektu GLSLShader, aby związać obiekt shadera. 5. Wywołaj AddAttribute (AddUniform), aby przechować lokalizacje wszystkich atrybutów (uniformów) shadera. 6. Wywołaj UnUse z referencją do obiektu GLSLShader, aby odwiązać obiekt shadera. Zauważ, że powyższe operacje są wymagane tylko na etapie inicjalizacji. Wartości uniformów, które pozostają niezmienne podczas działania aplikacji, możemy ustalić w podanym wyżej bloku Use/UnUse. Na etapie renderowania dostajemy się do uniformów, jeśli są wśród nich takie, które zmieniają się z każdą ramką (np. macierze przekształceń modelu i widoku). Najpierw dołączamy shader przez wywołanie funkcji GLSLShader::Use, a następnie ustawiamy uniform za pomocą funkcji glUniform{*}, uruchamiamy renderowanie przez wywołanie funkcji glDraw{*} i na koniec odwiązujemy shader (GLSLShader::UnUse). Zauważ, że wywołanie glDraw{*} przekazuje atrybuty do GPU.
Jak to działa? W typowej aplikacji OpenGL kolejność wykonywania funkcji shaderowych jest następująca: glCreateShader glShaderSource glCompileShader glGetShaderInfoLog
W rezultacie tych czterech funkcji powstaje obiekt shadera. Następny krok to utworzenie obiektu programu shaderowego, a do tego służą kolejne cztery funkcje, które należy wywołać w następującej kolejności:
28
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
glCreateProgram glAttachShader glLinkProgram glGetProgramInfoLog Po skonsolidowaniu programu shaderowego obiekt shadera można śmiało usunąć.
I jeszcze jedno… W klasie GLSLShader pierwsze cztery etapy są realizowane przez funkcję LoadFromString, a za realizację czterech następnych odpowiada funkcja CreateAndLinkProgram. Po utworzeniu obiektu programu shaderowego możemy ten program włączyć do wykonania przez GPU. Proces ten nazywamy wiązaniem shadera (shader binding). Wykonuje go funkcja glUseProgram wywoływana w klasie GLSLShader przez funkcje Use i UnUse. Aby umożliwić komunikację między aplikacją a shaderem, wprowadzono do shadera dwa różne rodzaje pól. Pierwszy rodzaj to atrybuty (attributes), które mogą się zmieniać podczas działania programu shaderoweg na różnych etapach procesu cieniowania. Do tej kategorii należą wszystkie atrybuty dotyczące wierzchołków. Drugi rodzaj stanowią uniformy (uniforms), które pozostają stałe przez cały czas działania tego programu. Typowymi przykładami są macierze przekształceń modelu i widoku oraz samplery tekstur. Aplikacja może się komunikować z programem shaderowym, jeśli po związaniu programu uzyska lokalizację atrybutu (uniformu). Lokalizacja jednoznacznie identyfikuje atrybut (uniform). W klasie GLSLShader ze względów praktycznych lokalizacje atrybutów i uniformów są składowane w dwóch oddzielnych obiektach std::map. Dostęp do lokalizacji atrybutów i uniformów w klasie GLSLShader umożliwia wprowadzony tam indekser. W przypadkach, gdy na etapie kompilacji lub konsolidacji pojawiają się błędy, stosowne komunikaty są wyświetlane w oknie konsoli. Przykładowo załóżmy, że mamy obiekt klasy GLSLShader, który nazywa się shader i zawiera uniform o nazwie MVP. Możemy ten uniform najpierw dodać do mapy uniformów, wywołując funkcję shader.AddUniform("MVP"), a następnie, gdy będziemy chcieli go użyć, możemy skorzystać z bezpośredniego wywołania shader("MVP"), które zwróci jego lokalizację.
Renderowanie kolorowego trójkąta za pomocą shaderów Teraz zrobimy użytek z klasy GLSLShader, implementując ją w aplikacji wyświetlającej na ekranie prosty kolorowy trójkąt. 29
OpenGL. Receptury dla programisty
Przygotowania Do wykonania tego przykładu potrzebny nam będzie pusty projekt Win32 z rdzennym profilem OpenGL 3.3, którego wykonanie jest opisane w recepturze pierwszej. Pełny kod przykładu znajduje się w folderze Rozdział1\ProstyTrójkąt. We wszystkich przykładowych kodach z tej książki napotkasz wielokrotnie występujące makro o nazwie GL_CHECK_ERRORS. Sprawdza ono bit bieżącego błędu w każdym błędzie, jaki może wystąpić w wyniku przekazania niewłaściwego argumentu do funkcji OpenGL lub gdy w maszynie stanu OpenGL zdarzy się coś niezwykłego. Makro przechwytuje każdy taki błąd i generuje debugową asercję, sygnalizując, że maszyna stanu napotkała błąd. Gdy wszystko przebiega prawidłowo, żadne sygnały nie są generowane. Makro swoim działaniem ma pomagać w identyfikacji błędów. Ponieważ wywołuje ono w asercji funkcję glGetError, jest usuwane z wersji finalnej.
Przyjrzyjmy się teraz różnym fazom transformacji, przez które przechodzi wierzchołek, zanim zostanie wyrenderowany i wyświetlony na ekranie. Najpierw jednak musi być określone jego położenie w tzw. przestrzeni obiektu (object space). Jest to ta przestrzeń, w której wyznaczane są położenia wszystkich wierzchołków danego obiektu. Współrzędne wierzchołka określone w przestrzeni obiektu poddajemy przekształceniom modelu, mnożąc je przez odpowiednią macierz (skalowania, obrotu, przesunięcia itp.). W rezultacie otrzymujemy współrzędne tegoż wierzchołka w przestrzeni świata (world space). Wymnożenie tych współrzędnych przez macierz przekształcenia widoku (kamery) daje położenie wierzchołka w przestrzeni widoku (oka lub kamery). W OpenGL oba przekształcenia, modelu i widoku, są połączone i do ich realizacji służy jedna macierz modelu i widoku (modelview matrix). Współrzędne w przestrzeni widoku są następnie poddawane przekształceniu rzutowania, w rezultacie czego otrzymujemy położenie wierzchołka w przestrzeni przycięcia (clip space). Po znormalizowaniu współrzędne te stają się współrzędnymi w znormalizowanej przestrzeni urządzenia, która stanowi kanoniczną bryłę widzenia (ze współrzędnymi x, y i z przyjmującymi wartości z przedziałów odpowiednio [–1,1], [–1,1] i [0,1]). Na koniec przekształcenie okna widokowego przenosi wierzchołek do przestrzeni ekranu (screen space).
Jak to zrobić? Realizację tej receptury zaczniemy od następujących czynności: 1. Zdefiniuj shader wierzchołków (shadery\shader.vert), aby przekształcić współrzędne wierzchołka z przestrzeni obiektu do przestrzeni przycięcia. #version 330 core layout(location = 0) in vec3 vVertex; layout(location = 1) in vec3 vColor; smooth out vec4 vSmoothColor; uniform mat4 MVP;
30
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
void main() { vSmoothColor = vec4(vColor,1); gl_Position = MVP*vec4(vVertex,1); }
2. Zdefiniuj shader fragmentów (shadery\shader.frag), aby przekazać gładko interpolowany kolor z shadera wierzchołków do bufora ramki. #version 330 core layout(location=0) out vec4 vFragColor; smooth in vec4 vSmoothColor; void main() { vFragColor = vSmoothColor; }
3. Wczytaj oba shadery w funkcji OnInit(), używając metod klasy GLSLShader. shader.LoadFromFile(GL_VERTEX_SHADER, "shadery/shader.vert"); shader.LoadFromFile(GL_FRAGMENT_SHADER, "shadery/shader.frag"); shader.CreateAndLinkProgram(); shader.Use(); shader.AddAttribute("vVertex"); shader.AddAttribute("vColor"); shader.AddUniform("MVP"); shader.UnUse();
4. Utwórz geometrię i topologię. Atrybuty będą przechowywane wszystkie razem w formacie wierzchołków z przeplotem, to znaczy, że atrybuty wierzchołka będą umieszczana w strukturze zawierającej dwa atrybuty, położenie i kolor. vertices[0].color=glm::vec3(1,0,0); vertices[1].color=glm::vec3(0,1,0); vertices[2].color=glm::vec3(0,0,1); vertices[0].position=glm::vec3(-1,-1,0); vertices[1].position=glm::vec3(0,1,0); vertices[2].position=glm::vec3(1,-1,0); indices[0] = 0; indices[1] = 1; indices[2] = 2;
5. Umieść geometrię i topologię w obiekcie (obiektach) bufora. Parametr kroku (stride) określa liczbę bajtów, które należy przeskoczyć, aby osiągnąć kolejny element tego samego rodzaju. W formacie z przeplotem jest to zazwyczaj rozmiar struktury z danymi wierzchołka wyrażony w bajtach, czyli wartość, jaką zwraca funkcja sizeof(Vertex). glGenVertexArrays(1, &vaoID); glGenBuffers(1, &vboVerticesID); glGenBuffers(1, &vboIndicesID);
31
OpenGL. Receptury dla programisty
GLsizei stride = sizeof(Vertex); glBindVertexArray(vaoID); glBindBuffer (GL_ARRAY_BUFFER, vboVerticesID); glBufferData (GL_ARRAY_BUFFER, sizeof(vertices), &vertices[0], GL_STATIC_DRAW); glEnableVertexAttribArray(shader["vVertex"]); glVertexAttribPointer(shader["vVertex"], 3, GL_FLOAT, GL_FALSE,stride,0); glEnableVertexAttribArray(shader["vColor"]); glVertexAttribPointer(shader["vColor"], 3, GL_FLOAT, GL_FALSE,stride, (const GLvoid*)offsetof(Vertex, color)); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vboIndicesID); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), &indices[0], GL_STATIC_DRAW);
6. Przygotuj obsługę zdarzenia zmiany wymiarów okna, żeby w razie potrzeby zaktualizować macierz rzutowania. void OnResize(int w, int h) { glViewport (0, 0, (GLsizei) w, (GLsizei) h); P = glm::ortho(-1,1,-1,1); }
7. Zorganizuj proces renderowania z wiązaniem shadera, przekazywaniem uniformów i rysowaniem geometrii. void OnRender() { glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); shader.Use(); glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(P*MV)); glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_SHORT, 0); shader.UnUse(); glutSwapBuffers(); }
8. Usuń shader i inne obiekty OpenGL. void OnShutdown() { shader.DeleteShaderProgram(); glDeleteBuffers(1, &vboVerticesID); glDeleteBuffers(1, &vboIndicesID); glDeleteVertexArrays(1, &vaoID); }
Jak to działa? W tym prostym przykładzie używamy wyłącznie shaderów wierzchołków (shadery/shader.vert) i fragmentów (shadery/shader.frag). Pierwszy wiersz w definicji shadera określa numer wersji GLSL. Począwszy od OpenGL 3.0 liczby te są skorelowane z wersjami biblioteki OpenGL.
32
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
Przy bibliotece 3.3 wersja języka GLSL jest oznaczona jako 330. Obecne w tym samym wierszu słowo kluczowe core oznacza, że definiowany shader będzie zgodny z rdzennym profilem biblioteki. Warto zwrócić uwagę także na kwalifikator layout, który służy tutaj do związania całkowitoliczbowego indeksu atrybutowego z konkretnym atrybutem wierzchołka. Kolejność lokalizacji atrybutów można ustawiać dowolnie, ale my będziemy konsekwentnie (we wszystkich prezentowanych przykładach) rozpoczynać od 0 dla położenia, 1 dla normalnych, 2 dla współrzędnych tekstur itd. Obecność tego kwalifikatora czyni zbędnym wywoływanie funkcji glBindAttrib Location, ponieważ indeks lokalizacji określony w shaderze ma priorytet względem wszystkich wywołań tej funkcji. Shader wierzchołków przekazuje na wyjście kolor wierzchołka (vSmoothColor). Atrybuty interpolowane w trakcie działania shaderów są nazywane atrybutami zmieniającymi się (varying attributes). Shader wyznacza także położenie wierzchołka w przestrzeni przycięcia jako iloczyn jego współrzędnych (vVertex) i połączonej macierzy modelu, widoku oraz rzutowania (MPV). vSmoothColor = vec4(vColor,1); gl_Position = MVP*vec4(vVertex,1); Kwalifikator smooth poprzedzający atrybut wyjściowy nakazuje shaderowi przeprowadzenie gładkiej i zgodnej z zasadami perspektywy interpolacji wartości tegoż atrybutu i dopiero po takim zabiegu przekazanie jej do następnego etapu przetwarzania. Dostępne są również kwalifikatory flat (płasko, bez interpolacji) i nonperspective (bez uwzględnienia perspektywy, liniowo). Brak jakiegokolwiek kwalifikatora powoduje zastosowanie domyślnego, czyli smooth.
Shader fragmentów zapisuje wejściowy kolor (vSmoothColor) do wyjściowego bufora ramki (vFragColor). vFragColor = vSmoothColor;
I jeszcze jedno… W naszym przykładowym kodzie aplikacji renderującej kolorowy trójkąt obiekt klasy GLSLShader ma zasięg globalny, więc możemy mieć do niego dostęp z poziomu każdej funkcji. Dodajmy zatem do funkcji OnInit() następujące wiersze: shader.LoadFromFile(GL_VERTEX_SHADER, "shadery/shader.vert"); shader.LoadFromFile(GL_FRAGMENT_SHADER, "shadery/shader.frag"); shader.CreateAndLinkProgram(); shader.Use(); shader.AddAttribute("vVertex"); shader.AddAttribute("vColor"); shader.AddUniform("MVP"); shader.UnUse();
33
OpenGL. Receptury dla programisty
W pierwszych dwóch wierszach tworzone są shadery określonych typów, a odbywa się to przez wczytanie zawartości plików o określonych nazwach. We wszystkich recepturach pliki z shaderami wierzchołków będą miały rozszerzenie .vert, z shaderami geometrii — .geom, a z shaderami fragmentów — .frag. W wierszu trzecim wywoływana jest funkcja GLSLShader::Create AndLinkProgram, która tworzy program shaderowy. Potem następuje wiązanie programu i zapisanie lokalizacji atrybutów i uniformów. Przekazujemy dwa atrybuty wierzchołkowe, którymi są położenie wierzchołka i jego kolor. Aby ułatwić przesyłanie danych do GPU, tworzymy prostą strukturę Vertex o następującej budowie: struct Vertex { glm::vec3 position; glm::vec3 color; }; Vertex vertices[3]; GLushort indices[3];
Następnie tworzymy tablicę trzech wierzchołków, która będzie miała zasięg globalny. Tworzymy też globalną tablicę z indeksami wierzchołków trójkąta. Inicjalizację tych tablic przeprowadzamy później w funkcji OnInit(). Pierwszemu wierzchołkowi przypisujemy kolor czerwony, drugiemu zielony, a trzeciemu niebieski. vertices[0].color=glm::vec3(1,0,0); vertices[1].color=glm::vec3(0,1,0); vertices[2].color=glm::vec3(0,0,1); vertices[0].position=glm::vec3(-1,-1,0); vertices[1].position=glm::vec3(0,1,0); vertices[2].position=glm::vec3(1,-1,0); indices[0] = 0; indices[1] = 1; indices[2] = 2;
Następnie podajemy położenia wierzchołków. Pierwszy umieszczamy w punkcie, który w przestrzeni obiektu ma współrzędne (-1,-1,0), drugi w punkcie o współrzędnych (0,1,0), a trzeci w punkcie (1,-1,0). W tym przykładzie zastosujemy rzutowanie ortogonalne z polem widzenia (–1,1,–1,1). Ustalamy też trzy indeksy w porządku liniowym. W OpenGL począwszy od wersji 3.3 informacje o geometrii zazwyczaj przechowujemy w obiektach buforów, które to obiekty są liniowymi tablicami pamięci zarządzanej przez GPU. Aby ułatwić sobie obsługę takiego obiektu (obiektów) podczas renderowania, wprowadzamy obiekt tablicy wierzchołków (VAO — vertex array object), który przechowuje referencje do obiektów buforów. Korzyść ze stosowania VAO polega na tym, że po związaniu VAO nie musimy już wiązać obiektów buforów.
34
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
W prezentowanym tu przykładzie deklarujemy trzy zmienne o zasięgu globalnym: vaoID do obsługi VAO oraz vboVerticesID i vboIndicesID do obsługi obiektu bufora. Obiekt VAO tworzymy za pomocą funkcji glGenVertexArrays. Obiekty buforów generujemy przy użyciu funkcji glGenBuffers. Pierwszym parametrem obu tych funkcji jest liczba potrzebnych obiektów, a drugim — referencja do miejsca, w którym przechowywany jest uchwyt obiektu. Funkcje te są wywoływane wewnątrz funkcji OnInit(). glGenVertexArrays(1, &vaoID); glGenBuffers(1, &vboVerticesID); glGenBuffers(1, &vboIndicesID); glBindVertexArray(vaoID);
Po wygenerowaniu VAO wiążemy go z bieżącym kontekstem OpenGL i odtąd wszystkie wywołania będą się odnosiły właśnie do tego związanego obiektu. Po związaniu VAO wiążemy obiekt bufora przechowującego wierzchołki (vboVerticesID) do punktu wiązania GL_ARRAY_BUFFER i robimy to za pomocą funkcji glBindBuffer(). Następnie przekazujemy dane do obiektu bufora przy użyciu funkcji glBufferData. Funkcja ta również wymaga podania punktu wiązania, którym jest, tak jak poprzednio, GL_ARRAY_BUFFER. Drugim parametrem jest rozmiar tablicy wierzchołków przesyłanej do GPU. Parametr trzeci wskazuje początek pamięci CPU. Przekazujemy adres globalnej tablicy wierzchołków. Ostatni parametr to informacja dla GPU o przewidywanym użytkowaniu danych — w tym przypadku mówi ona, że dane nie będą modyfikowane zbyt często. glBindBuffer (GL_ARRAY_BUFFER, vboVerticesID); glBufferData (GL_ARRAY_BUFFER, sizeof(vertices), &vertices[0], GL_STATIC_DRAW);
Informacje o użytkowaniu danych składają się z dwóch części. Pierwsza mówi o częstotliwości, z jaką dane przechowywane w buforze będą modyfikowane: jednorazowo (STATIC), od czasu do czasu (DYNAMIC) lub przy każdym użyciu (STREAM). Część druga określa sposób, w jaki te dane będą używane: zapis bez odczytu (DRAW), tylko odczyt (READ), bez zapisu i bez odczytu (COPY). Z tych dwóch informacji konstruujemy odpowiedni kwalifikator. Przykładowo, jeśli dane mają być modyfikowane tylko raz, stosujemy GL_STATIC_DRAW, a jeśli modyfikacje mają występować sporadycznie, wpisujemy GL_DYNAMIC_DRAW. Informacje tego typu pozwalają procesorowi graficznemu i sterownikowi zoptymalizować procesy zapisywania i odczytywania danych w pamięci. Kilka następnych funkcji ma za zadanie włączyć odpowiednie atrybuty wierzchołka. Każda z nich wymaga argumentu w postaci lokalizacji właściwego atrybutu. Aby tę lokalizację uzyskać, stosujemy konstrukcję GLSLShader::operator[], przekazując jej nazwę atrybutu, który nas interesuje. Następnie za pomocą funkcji glVertexAttribPointer informujemy GPU, jak dużo jest tam elementów i jaki jest ich typ, czy atrybut jest znormalizowany, ile wynosi krok (czyli liczba bajtów, które należy pominąć, aby osiągnąć następny element; w naszym przykładzie, jako że atrybuty są zapisywane w postaci struktury Vertex, krok jest równy rozmiarowi tej struktury) i jaki jest wskaźnik do atrybutu w danej tablicy. Ostatni parametr wymaga objaśnienia, jeśli atrybuty są przeplatane (a z takimi mamy do czynienia). Otóż operator offsetof zwraca wyrażoną w bajtach wielkość przesunięcia danego atrybutu wewnątrz struktury. Dla atrybutu vVertex przesunięcie to wynosi 0, a to oznacza, że interesujący nas element jest na samym początku struktury. Drugi atrybut, vColor, jest przesunięty w stosunku do początku struktury o 12 bajtów.
35
OpenGL. Receptury dla programisty
glEnableVertexAttribArray(shader["vVertex"]); glVertexAttribPointer(shader["vVertex"], 3, GL_FLOAT, GL_FALSE,stride,0); glEnableVertexAttribArray(shader["vColor"]); glVertexAttribPointer(shader["vColor"], 3, GL_FLOAT, GL_FALSE,stride, (const GLvoid*)offsetof(Vertex, color));
Podobnie jak z danymi wierzchołków postępujemy z indeksami — również używamy funkcji glBindBuffer i glBufferData, ale z innym punktem wiązania, a mianowicie GL_ELEMENT_ARRAY_ BUFFER. Pozostałe argumenty są niemal identyczne jak poprzednio — zmienia się tylko obiekt bufora na vboIndicesID i do funkcji glBufferData przekazywana jest tablica indeksów, a nie wierzchołków. glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vboIndicesID); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), &indices[0], GL_STATIC_DRAW);
Po wygenerowaniu obiektu w funkcji OnInit()potrzebny jest jeszcze kod, który by ten obiekt usunął. Realizację tego zadania powierzymy funkcji OnShutdown(). Najpierw każemy jej usunąć program shaderowy i w tym celu wywołujemy funkcję GLSLShader::DeleteShaderProgram. Następnie usuwamy oba obiekty buforów (vboVerticesID i vboIndicesID) i na koniec pozbywamy się obiektu tablicy wierzchołków (vaoID). void OnShutdown() { shader.DeleteShaderProgram(); glDeleteBuffers(1, &vboVerticesID); glDeleteBuffers(1, &vboIndicesID); glDeleteVertexArrays(1, &vaoID); } Usuwamy program shaderowy, ponieważ nasz obiekt klasy GLSLShader jest zaalokowany globalnie i jego destruktor zostanie wywołany po wyjściu z głównej funkcji. Jeśli nie usuniemy obiektu w tym miejscu, program shaderowy będzie istniał nadal i doprowadzimy do wycieku pamięci graficznej.
Kod renderujący dla naszego prostego przykładu wygląda następująco: void OnRender() { glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); shader.Use(); glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(P*MV)); glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_SHORT, 0); shader.UnUse(); glutSwapBuffers(); }
W kodzie tym najpierw czyszczone są bufory koloru i głębi, po czym następuje związanie programu shaderowego przez wywołanie funkcji GLSLShader::Use. Następnie za pomocą funkcji glUniformMatrix4fv przekazywane są do GPU połączone macierze modelu, widoku i rzutowania.
36
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
Pierwszym parametrem jest tutaj lokalizacja uniformu zwracana przez funkcję GLSLShader:: operator(), do której z kolei przekazujemy nazwę interesującego nas uniformu. Parametr drugi określa liczbę macierzy, które chcemy przekazać. Trzeci jest wartością logiczną sygnalizującą, czy macierz ma być transponowana, a ostatni jest wskaźnikiem do obiektu macierzy. Wartość tego wskaźnika zwraca nam funkcja glm::value_ptr. Zauważ, że macierze są tu łączone w kolejności od lewej do prawej, ponieważ jest to zgodne z prawoskrętnością układu współrzędnych przy kolumnowym zapisie macierzy. Macierz rzutowania jest zatem po lewej, a macierz modelu i widoku po prawej. W omawianym przykładzie macierz modelu i widoku (MV) jest macierzą jednostkową. Po tych przygotowaniach następuje wywołanie funkcji glDrawElements. Jako że nasz obiekt VAO (vaoID) jest ciągle związany, ustawiamy ostatni parametr tej funkcji na 0. W ten sposób informujemy GPU, że ma używać referencji do punktów wiązania GL_ELEMENT_ARRAY_BUFFER i GL_ARRAY_ BUFFER związanego VAO. Dzięki temu nie musimy ponownie wiązać w sposób jawny obiektów buforów vboVerticesID i vboIndicesID. Następny krok to odwiązanie programu shaderowego przez wywołanie funkcji GLSLShader::UnUse(). Na koniec wywołujemy funkcję glutSwapBuf fers(), aby wyświetlić tylny bufor ekranu. W wyniku skompilowania i uruchomienia całego programu otrzymujemy obraz taki jak na poniższym rysunku.
37
OpenGL. Receptury dla programisty
Dowiedz się więcej Zapoznaj się z lekcjami programowania grafiki trójwymiarowej autorstwa Jasona L. McKessona, dostępnymi pod adresem: http://www.arcsynthesis.org/gltut/Basics/Basics.html.
Wykonanie deformatora siatki przy użyciu shadera wierzchołków W tej recepturze zdeformujemy płaską siatkę za pomocą shadera wierzchołków. Wiemy już, że shader ten odpowiada za przeliczanie położeń wierzchołków z przestrzeni obiektu do przestrzeni przycięcia. W ramach tej konwersji możemy zastosować pośrednie przekształcenie modelujące przy wyznaczaniu położeń w przestrzeni świata.
Przygotowania Do realizacji tej receptury będzie potrzebna znajomość zagadnień omawianych poprzednio przy tworzeniu programu wyświetlającego kolorowy trójkąt. Gotowy kod do bieżącej receptury znajduje się w folderze Rozdział1\Falowanie.
Jak to zrobić? Shader falujący możemy zaimplementować w sposób następujący: 1. Zdefiniuj shader wierzchołków zmieniający położenie wierzchołka w przestrzeni obiektu. #version 330 core layout(location=0) in vec3 vVertex; uniform mat4 MVP; uniform float time; const float amplitude = 0.125; const float frequency = 4; const float PI = 3.14159; void main() { float distance = length(vVertex); float y = amplitude*sin(-PI*distance*frequency+time); gl_Position = MVP*vec4(vVertex.x, y, vVertex.z,1); }
2. Zdefiniuj shader fragmentów, który po prostu da na wyjściu stały kolor.
38
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
#version 330 core layout(location=0) out vec4 vFragColor; void main() { vFragColor = vec4(1,1,1,1); }
3. Wewnątrz funkcji OnInit() załaduj oba shadery za pomocą odpowiednich metod klasy GLSLShader. shader.LoadFromFile(GL_VERTEX_SHADER, "shadery/shader.vert"); shader.LoadFromFile(GL_FRAGMENT_SHADER, "shadery/shader.frag"); shader.CreateAndLinkProgram(); shader.Use(); shader.AddAttribute("vVertex"); shader.AddUniform("MVP"); shader.AddUniform("time"); shader.UnUse();
4. Utwórz geometrię i topologię. int count = 0; int i=0, j=0; for( j=0;j<=NUM_Z;j++) { for( i=0;i<=NUM_X;i++) { vertices[count++] = glm::vec3(((float(i)/(NUM_X-1)) *2-1)* HALF_SIZE_X, 0, ((float(j)/(NUM_Z-1))*2-1)*HALF_SIZE_Z); } } GLushort* id=&indices[0]; for (i = 0; i < NUM_Z; i++) { for (j = 0; j < NUM_X; j++) { int i0 = i * (NUM_X+1) + j; int i1 = i0 + 1; int i2 = i0 + (NUM_X+1); int i3 = i2 + 1; if ((j+i)%2) { *id++ = i0; *id++ = i2; *id++ = i1; *id++ = i1; *id++ = i2; *id++ = i3; } else { *id++ = i0; *id++ = i2; *id++ = i3; *id++ = i0; *id++ = i3; *id++ = i1; } } }
5. Umieść geometrię i topologię w obiekcie (obiektach) bufora. glGenVertexArrays(1, &vaoID); glGenBuffers(1, &vboVerticesID); glGenBuffers(1, &vboIndicesID);
39
OpenGL. Receptury dla programisty
glBindVertexArray(vaoID); glBindBuffer (GL_ARRAY_BUFFER, vboVerticesID); glBufferData (GL_ARRAY_BUFFER, sizeof(vertices), &vertices[0], GL_STATIC_DRAW); glEnableVertexAttribArray(shader["vVertex"]); glVertexAttribPointer(shader["vVertex"], 3, GL_FLOAT, GL_FALSE,0,0); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vboIndicesID); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), &indices[0], GL_STATIC_DRAW);
6. Przygotuj macierz rzutowania dla obsługi zdarzenia zmiany wymiarów okna. P = glm::perspective(45.0f, (GLfloat)w/h, 1.f, 1000.f);
7. Napisz kod z wiązaniem shadera klasy GLSLShader, przekazywaniem uniformów i rysowaniem geometrii. void OnRender() { time = glutGet(GLUT_ELAPSED_TIME)/1000.0f * SPEED; glm::mat4 T=glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, dist)); glm::mat4 Rx= glm::rotate(T, rX, glm::vec3(1.0f, 0.0f, 0.0f)); glm::mat4 MV= glm::rotate(Rx, rY, glm::vec3(0.0f, 1.0f, 0.0f)); glm::mat4 MVP= P*MV; shader.Use(); glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(MVP)); glUniform1f(shader("time"), time); glDrawElements(GL_TRIANGLES,TOTAL_INDICES,GL_UNSIGNED_SHORT,0); shader.UnUse(); glutSwapBuffers(); }
8. Usuń shader i inne obiekty OpenGL. void OnShutdown() { shader.DeleteShaderProgram(); glDeleteBuffers(1, &vboVerticesID); glDeleteBuffers(1, &vboIndicesID); glDeleteVertexArrays(1, &vaoID); }
Jak to działa? W tej recepturze jedynym przekazywanym atrybutem jest położenie wierzchołka (vVertex). Uniformy są dwa: połączona macierz modelu, widoku i rzutowania (MVP) oraz bieżący czas (time). Uniform time będzie nam potrzebny do pokazania rozwoju deformatora — abyśmy mogli obserwować ruch fali. Po deklaracjach atrybutu i uniformów następują definicje trzech stałych, a są to: amplitude (amplituda określająca maksymalne odchylenie od zerowego poziomu bazowego),
40
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
frequency (częstotliwość określająca całkowitą liczbę fal) i PI (stała występująca w matematycznym wzorze fali). Warto w tym miejscu zauważyć, że stałe te można zastąpić uniformami i modyfikować je z poziomu aplikacji.
Najważniejsza praca jest wykonywana w głównej funkcji. Najpierw wyznaczamy odległość danego wierzchołka od punktu początkowego. Używamy do tego wbudowanej w GLSL funkcji o nazwie length. Następnie tworzymy zwykłą sinusoidę. Ogólny wzór na falę sinusoidalną wygląda następująco:
Tutaj A oznacza amplitudę fali, f — częstotliwość, t — czas, φ — fazę. Żeby nasza fala zaczęła się rozchodzić z punktu będącego początkiem układu współrzędnych, zapiszemy jej wzór w sposób następujący:
A zatem najpierw obliczamy odległość (d) wierzchołka od początku układu współrzędnych, posługując się wzorem z geometrii euklidesowej. Tak też działa wspomniana już funkcja length. Następnie mnożymy tę odległość przez częstotliwość (f) i liczbę pi (π), a uzyskaną wartość wstawiamy do funkcji sin. W wersji shaderowej wzoru fali zamiast fazy (φ) wstawiamy czas (time). #version 330 core layout(location=0) in vec3 vVertex; uniform mat4 MVP; uniform float time; const float amplitude = 0.125; const float frequency = 4; const float PI = 3.14159; void main() { float distance = length(vVertex); float y = amplitude*sin(-PI*distance*frequency+time); gl_Position = MVP*vec4(vVertex.x, y, vVertex.z,1); }
Po wyliczeniu nowej wartości y mnożymy nowe położenie wierzchołka przez połączoną macierz modelu, widoku i rzutowania (MVP). Jeśli chodzi o shader fragmentów, to jego zadaniem jest tylko zaopatrywanie każdego fragmentu w jeden ustalony kolor — tym razem jest to kolor biały vec4(1,1,1,1). #version 330 core layout(location=0) out vec4 vFragColor; void main() { vFragColor = vec4(1,1,1,1); }
41
OpenGL. Receptury dla programisty
I jeszcze jedno… Podobnie jak w poprzedniej recepturze deklarujemy obiekt klasy GLSLShader o zasięgu globalnym, bo to daje nam maksimum swobody. Następnie inicjalizujemy ten obiekt w funkcji OnInit(). shader.LoadFromFile(GL_VERTEX_SHADER, "shadery/shader.vert"); shader.LoadFromFile(GL_FRAGMENT_SHADER,"shadery/shader.frag"); shader.CreateAndLinkProgram(); shader.Use(); shader.AddAttribute("vVertex"); shader.AddUniform("MVP"); shader.AddUniform("time"); shader.UnUse();
Jedyną nowością w tej recepturze jest dodatkowy uniform (time). Generujemy płaską siatkę na płaszczyźnie XZ. Geometrię umieszczamy w globalnej tablicy wierzchołków. Liczba wierzchołków wzdłuż osi X jest przechowywana w globalnej zmiennej NUM_X, a liczba wierzchołków wzdłuż osi Z jest przechowywana w globalnej zmiennej NUM_Z. Rozmiar siatki w przestrzeni świata jest przechowywany w dwóch stałych globalnych SIZE_X i SIZE_Z, a połówki tych wartości znajdują się w stałych HALF_SIZE_X i HALF_SIZE_Z. Przez zmianę tych stałych możemy zmieniać rozdzielczość i wymiary siatki w przestrzeni świata. Pętla wyznaczająca współrzędne wierzchołków jest wykonywana (NUM_X+1)*(NUM_Z+1)razy i rzutuje indeksy bieżącego wierzchołka na przedziały od –1 do 1, a otrzymane wartości mnoży przez stałe, odpowiednio, HALF_SIZE_X i HALF_SIZE_Z, aby ostatecznie uzyskać współrzędne z przedziałów od –HALF_SIZE_X do HALF_SIZE_X i od –HALF_SIZE_Z do HALF_SIZE_Z. Topologia siatki jest przechowywana w globalnej tablicy indeksów. Istnieje kilka sposobów generowania topologii siatki, ale my przyjrzymy się tylko dwóm najbardziej popularnym. W pierwszym zachowana jest stała metoda podziału czworokątów na trójkąty, co daje rezultat taki jak na poniższym rysunku.
42
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
Tego typu topologię można wygenerować za pomocą następującego kodu: GLushort* id=&indices[0]; for (i = 0; i < NUM_Z; i++) { for (j = 0; j < NUM_X; j++) { int i0 = i * (NUM_X+1) + j; int i1 = i0 + 1; int i2 = i0 + (NUM_X+1); int i3 = i2 + 1; *id++ = i0; *id++ = i2; *id++ = i1; *id++ = i1; *id++ = i2; *id++ = i3; } }
W sposobie drugim podział czworokątów na trójkąty odbywa się inaczej w iteracjach parzystych i nieparzystych, co daje lepiej wyglądającą siatkę (patrz rysunek poniżej).
Aby zmienić kierunkowość trójkątów, ale bez zmiany ich kolejności, utworzymy dwie różne kombinacje — jedną dla iteracji parzystych i drugą dla nieparzystych. Można to zrobić następująco: GLushort* id=&indices[0]; for (i = 0; i < NUM_Z; i++) { for (j = 0; j < NUM_X; j++) { int i0 = i * (NUM_X+1) + j; int i1 = i0 + 1; int i2 = i0 + (NUM_X+1); int i3 = i2 + 1; if ((j+i)%2) { *id++ = i0; *id++ = i2; *id++ *id++ = i1; *id++ = i2; *id++ } else { *id++ = i0; *id++ = i2; *id++ *id++ = i0; *id++ = i3; *id++
= i1; = i3; = i3; = i1;
43
OpenGL. Receptury dla programisty
} } }
Po wypełnieniu tablic wierzchołków i indeksów przekazujemy zawarte w nich dane do pamięci GPU. Najpierw jednak musimy utworzyć obiekt tablicy wierzchołków (vaoID) oraz dwa obiekty buforów — jeden związany z punktem GL_ARRAY_BUFFER dla wierzchołków i drugi związany z GL_ELEMENT_ARRAY_BUFFER dla indeksów. Wszystko to wygląda bardzo podobnie do tego, co robiliśmy w poprzedniej recepturze. Jedyna różnica polega na tym, że teraz mamy do czynienia z tylko jednym atrybutem wierzchołka — jego położeniem (vVertex). Bez zmiany pozostaje także funkcja OnShutdown(). Zmienia się natomiast kod renderujący. Zaczynamy od pobrania z biblioteki freeglut bieżącego czasu, który będzie potrzebny do pokazania ruchu fal. Następnie czyścimy bufory koloru i głębi i przygotowujemy macierze przekształceń, do czego wykorzystujemy funkcje z biblioteki glm. glm::mat4 T=glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, dist)); glm::mat4 Rx= glm::rotate(T, rX, glm::vec3(1.0f, 0.0f, 0.0f)); glm::mat4 MV= glm::rotate(Rx, rY, glm::vec3(0.0f, 1.0f, 0.0f)); glm::mat4 MVP= P*MV;
Zauważ, że mnożenie macierzy w bibliotece glm odbywa się od prawej do lewej, a zatem generowane przez nas przekształcenia zostaną zastosowane w odwrotnej kolejności. Połączona macierz modelu i widoku będzie wyliczana jako MV = (T*(Rx*Ry)). Wartości przesunięcia dist oraz obrotów rX i rY są wyznaczane na podstawie ruchów myszą wykonywanych przez użytkownika. Gdy znana jest już macierz modelu i widoku, następuje wyliczanie połączonej macierzy modelu, widoku i rzutowania (MVP). Potrzebna do tego macierz rzutowania jest wyznaczana w ramach funkcji OnResize() obsługującej zdarzenie zmiany wymiarów okna. Tym razem jest to macierz rzutowania perspektywicznego z czterema parametrami: kątem widzenia w pionie, proporcjami obrazu i odległościami do płaszczyzn odcinania przedniej i tylnej. Po związaniu obiektu klasy GLSLShader dwa uniformy, MVP i time, są przekazywane do programu shaderowego. Następnie, podobnie jak w poprzedniej recepturze, wywoływana jest funkcja glDrawElements, odwiązywany jest obiekt klasy GLSLShader i zamieniane są bufory obrazu. W głównej funkcji programu podpinamy dwie nowe funkcje zwrotne: glutMouseFunc obsługiwaną w ramach funkcji OnMouseDown i glutMotionFunc obsługiwaną w funkcji OnMouseMove. Funkcje obsługujące zdarzenia związane z myszą są zdefiniowane następująco: void OnMouseDown(int button, int s, int x, int y) { if (s == GLUT_DOWN) { oldX = x; oldY = y; } if(button == GLUT_MIDDLE_BUTTON)
44
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
state = 0; else state = 1; }
Funkcja OnMouseDown jest wywoływana za każdym razem, gdy użytkownik kliknie w obrębie okna aplikacji. Parametr pierwszy informuje o tym, który przycisk myszy został naciśnięty (GLUT_LEFT_BUTTON oznacza przycisk lewy, GLUT_MIDDLE_BUTTON to przycisk środkowy, a GLUT_ RIGHT_BUTTON wskazuje na przycisk prawy). Parametr drugi określa, czy przycisk został wciśnięty (GLUT_DOWN) czy zwolniony (GLUT_UP). Ostatnie dwa parametry, x i y, są współrzędnymi ekranowymi punktu, w którym kliknięto. W naszym przykładzie rejestrujemy punkt kliknięcia i ustawiamy zmienną state w zależności od tego, czy wciśnięty został przycisk środkowy, czy nie. Definicja funkcji wywoływanej w reakcji na zmianę położenia myszy wygląda następująco: void OnMouseMove(int x, int y) { if (state == 0) dist *= (1 + (y - oldY)/60.0f); else { rY += (x - oldX)/5.0f; rX += (y - oldY)/5.0f; } oldX = x; oldY = y; glutPostRedisplay(); }
Funkcja ta ma tylko dwa parametry, a są nimi współrzędne określające bieżące położenie wskaźnika myszy na ekranie. Zdarzenie wywołujące tę funkcję zachodzi, gdy wskaźnik myszy zmienia swoje położenie w obrębie okna aplikacji. W zależności od wartości zmiennej state ustawionej przez funkcję OnMouseDown obliczana jest wielkość zbliżenia widoku (dist) (jeśli wciśnięty jest przycisk środkowy) lub wielkość obrotu (rX i rY). Następnie aktualizowane są wartości zmiennych oldX i oldY, w których przechowywane są współrzędne wskaźnika myszy potrzebne przy następnym wystąpieniu zdarzenia. Na koniec wywoływana jest funkcja glutPost Redisplay(), która wymusza odświeżenie zawartości okna aplikacji, a to w konsekwencji oznacza ponowne wyrenderowanie sceny. Aby ułatwić obserwację rozchodzących się fal, włączamy tryb renderowania szkieletowego. W tym celu wywołujemy w funkcji OnInit() funkcję glPolygonMode(GL_FRONT_AND_BACK, GL_LINE). Są dwie rzeczy, na które trzeba uważać przy używaniu funkcji glPolygonMode. Przede wszystkim pierwszy parametr musi mieć w rdzennym profilu OpenGL postać GL_FRONT_AND_BACK. Natomiast przy drugim parametrze trzeba uważać, by zamiast GL_LINE nie wpisać GL_LINES, jak to się robi w przypadku funkcji glDraw*. Zmiana wartości tego parametru z GL_LINE na GL_FILL spowoduje wyłączenie renderingu szkieletowego i powrót do domyślnego trybu z renderowniem pełnych powierzchni.
45
OpenGL. Receptury dla programisty
Uruchomienie opisanej aplikacji spowoduje wyświetlenie siatki deformowanej przez rozchodzącą się koncentrycznie falę, tak jak na poniższym rysunku. Mam nadzieję, że receptura ta wyjaśniła, jak należy posługiwać się shaderami przy wykonywaniu przekształceń na poszczególnych wierzchołkach.
Dynamiczne zagęszczanie podziału płaszczyzny przy użyciu shadera geometrii Następnym po shaderze wierzchołków etapem w graficznym potoku OpenGL 3.3 jest shader geometrii. Jego danymi wejściowymi są dane wytwarzane przez shader wierzchołków. Na etapie shadera geometrii można te dane pozostawić bez zmian i przekazać do dalszych etapów, ale można też dodawać, usuwać i modyfikować zarówno wierzchołki, jak i całe prymitywy. Podstawowa różnica między shaderami wierzchołków a shaderami geometrii jest taka, że te pierwsze mają dostęp do jednego tylko wierzchołka z przetwarzanego prymitywu, a te drugie mają informacje o całym prymitywie — o wszystkich jego wierzchołkach.
46
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
Za pomocą shaderów geometrii możemy na bieżąco dodawać prymitywy i je usuwać. W przeciwieństwie do shaderów wierzchołków, które operują wyłącznie na pojedynczych wierzchołkach, mamy tutaj dostęp do wszystkich wierzchołków przetwarzanego prymitywu. Ograniczona jest tylko liczba nowych wierzchołków, jakie możemy wygenerować, ale o tym decydują możliwości naszego sprzętu. Ograniczony jest też dostęp do prymitywów sąsiednich. W tej recepturze posłużymy się shaderem geometrii, aby dynamicznie zagęścić podział płaszczyzny.
Przygotowania Do realizacji tej receptury będzie potrzebna umiejętność renderowania zwykłego trójkąta przy użyciu shaderów wierzchołków i fragmentów w rdzennym profilu OpenGL 3.3. Tym razem wyrenderujemy cztery płaskie siatki, które umieszczone obok siebie utworzą jedną dużą płaską powierzchnię. Każdą z nich poddamy zagęszczaniu, używając tego samego shadera geometrii. Gotowy kod do bieżącej receptury znajduje się w folderze Rozdział1\ ZagęszczającyShaderGeometrii.
Jak to zrobić? Aby zaimplementować shader geometrii, wykonaj następujące czynności: 1. Zdefiniuj shader wierzchołków (shadery/shader.vert), który będzie jedynie przekazywał dalej położenia wierzchołków w przestrzeni obiektu. #version 330 core layout(location=0) in vec3 vVertex; void main() { gl_Position = vec4(vVertex, 1); }
2. Zdefiniuj shader geometrii (shadery/shader.geom), który będzie przeprowadzał podział czworokąta. Objaśnienie jego działania znajdziesz w następnej części rozdziału. #version 330 core layout (triangles) in; layout (triangle_strip, max_vertices=256) out; uniform int sub_divisions; uniform mat4 MVP; void main() { vec4 v0 = gl_in[0].gl_Position; vec4 v1 = gl_in[1].gl_Position; vec4 v2 = gl_in[2].gl_Position; float dx = abs(v0.x-v2.x)/sub_divisions; float dz = abs(v0.z-v1.z)/sub_divisions; float x=v0.x; float z=v0.z;
47
OpenGL. Receptury dla programisty
for(int j=0;j
3. Zdefiniuj shader fragmentów, który po prostu da na wyjściu stały kolor. #version 330 core layout(location=0) out vec4 vFragColor; void main(){ vFragColor = vec4(1,1,1,1); }
4. Wewnątrz funkcji OnInit() załaduj oba shadery za pomocą odpowiednich metod klasy GLSLShader. shader.LoadFromFile(GL_VERTEX_SHADER, "shadery/shader.vert"); shader.LoadFromFile(GL_GEOMETRY_SHADER,"shadery/shader.geom"); shader.LoadFromFile(GL_FRAGMENT_SHADER,"shadery/shader.frag"); shader.CreateAndLinkProgram(); shader.Use(); shader.AddAttribute("vVertex"); shader.AddUniform("MVP"); shader.AddUniform("sub_divisions"); glUniform1i(shader("sub_divisions"), sub_divisions); shader.UnUse();
5. Utwórz geometrię i topologię. vertices[0] = glm::vec3(-5,0,-5); vertices[1] = glm::vec3(-5,0,5); vertices[2] = glm::vec3(5,0,5); vertices[3] = glm::vec3(5,0,-5); GLushort* id=&indices[0]; *id++ = 0; *id++ = 1; *id++ = 2;
48
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
*id++ = 0; *id++ = 2; *id++ = 3;
6. Umieść geometrię i topologię w obiekcie (obiektach) bufora. Włącz także tryb wyświetlania linii. glGenVertexArrays(1, &vaoID); glGenBuffers(1, &vboVerticesID); glGenBuffers(1, &vboIndicesID); glBindVertexArray(vaoID); glBindBuffer (GL_ARRAY_BUFFER, vboVerticesID); glBufferData (GL_ARRAY_BUFFER, sizeof(vertices), &vertices[0], GL_STATIC_DRAW); glEnableVertexAttribArray(shader["vVertex"]); glVertexAttribPointer(shader["vVertex"], 3, GL_FLOAT, GL_FALSE,0,0); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vboIndicesID); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), &indices[0], GL_STATIC_DRAW); glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
7. Napisz kod z wiązaniem shadera klasy GLSLShader, przekazywaniem uniformów i rysowaniem geometrii. void OnRender() { glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); glm::mat4 T = glm::translate( glm::mat4(1.0f), glm::vec3(0.0f,0.0f, dist)); glm::mat4 Rx=glm::rotate(T,rX,glm::vec3(1.0f, 0.0f, 0.0f)); glm::mat4 MV=glm::rotate(Rx,rY, glm::vec3(0.0f,1.0f,0.0f)); MV=glm::translate(MV, glm::vec3(-5,0,-5)); shader.Use(); glUniform1i(shader("sub_divisions"), sub_divisions); glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(P*MV)); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); MV=glm::translate(MV, glm::vec3(10,0,0)); glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(P*MV)); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); MV=glm::translate(MV, glm::vec3(0,0,10)); glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(P*MV)); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); MV=glm::translate(MV, glm::vec3(-10,0,0)); glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(P*MV)); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0);
49
OpenGL. Receptury dla programisty
shader.UnUse(); glutSwapBuffers(); }
8. Usuń shader i inne obiekty OpenGL. void OnShutdown() { shader.DeleteShaderProgram(); glDeleteBuffers(1, &vboVerticesID); glDeleteBuffers(1, &vboIndicesID); glDeleteVertexArrays(1, &vaoID); cout<<"Shutdown successfull"<
Jak to działa? Przeanalizujmy shader geometrii. #version 330 core layout (triangles) in; layout (triangle_strip, max_vertices=256) out;
Wiersz pierwszy zawiera numer wersji GLSL. Następne dwa wiersze są o tyle ważne, że informują procesor o rodzaju prymitywów na wejściu i wyjściu shadera. W tym przypadku na wejściu będą trójkąty (triangles), a na wyjściu — paski trójkątów (triangle_strip). Poza tym musimy podać jeszcze maksymalną liczbę wierzchołków na wyjściu shadera geometrii (max_vertices). Liczba ta zależy od używanego przez nas sprzętu. Sprzęt użyty przy opracowywaniu tego przykładu dopuszczał 256 wierzchołków. Informację tę można uzyskać przez zapytanie o wartość pola GL_MAX_GEOMETRY_OUTPUT_VERTICES, a otrzymana odpowiedź będzie uzależniona od rodzaju użytych prymitywów i liczby atrybutów przechowywanych dla każdego wierzchołka. uniform int sub_divisions; uniform mat4 MVP;
Następnie deklarujemy dwa uniformy: liczbę podziałów (sub_divisions) oraz połączoną macierz modelu, widoku i rzutowania (MVP). void main() { vec4 v0 = gl_in[0].gl_Position; vec4 v1 = gl_in[1].gl_Position; vec4 v2 = gl_in[2].gl_Position;
Zasadnicza praca odbywa się w głównej funkcji shadera. Shader geometrii jest uruchamiany raz dla każdego trójkąta przekazywanego przez aplikację. Położenia wierzchołków takiego trójkąta są pobierane z atrybutu gl_Position, który jest przechowywany we wbudowanej tablicy gl_in. Do shadera geometrii wszystkie atrybuty wejściowe mają postać tablicy. Położenia wierzchołków wprowadzanego trójkąta umieszczamy w zmiennych lokalnych v0, v1 i v2. 50
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
Potem następuje obliczanie rozmiaru najmniejszego czworokąta dla danego podziału z uwzględnieniem rozmiaru trójkąta bazowego i ogólnej liczby wymaganych podziałów. float dx = abs(v0.x-v2.x)/sub_divisions; float dz = abs(v0.z-v1.z)/sub_divisions; float x=v0.x; float z=v0.z; for(int j=0;j
Zaczynamy od pierwszego wierzchołka i zapisujemy jego położenie w lokalnych zmiennych x i z. Potem następuję pętla wykonywana N*N razy, gdzie N oznacza liczbę wymaganych podziałów. Przykładowo, jeśli potrzebujemy podzielić siatkę trzy razy wzdłuż obu osi, pętla będzie wykonywana 9 razy i tyle też powstanie czworokątów. Po wyznaczeniu położenia każdego z czterech wierzchołków jest ono wysyłane przez funkcję EmitVertex()do bieżącego prymitywu w strumieniu wyjściowym. Po czwartym wierzchołku wywoływana jest funkcja EndPrimitive(), która sygnalizuje koniec prymitywu w zmiennej triangle_strip. Po tych obliczeniach lokalna zmienna x jest zwiększana o dx. Gdy licznik przebiegów pętli osiąga wartość będącą wielokrotnością liczby podziałów (sub_divisions), zmiennej x jest przywracana wartość równa współrzędnej x pierwszego wierzchołka i jednocześnie zwiększana jest wartość zmiennej z. Shader fragmentów produkuje stały kolor biały (vec4(1,1,1,1)).
I jeszcze jedno… Kod aplikacji jest podobny do tych, które tworzyliśmy do tej pory. Nowością jest na pewno shader geometrii (shadery/shader.geom), który tak jak pozostałe trzeba wczytać z pliku. shader.LoadFromFile(GL_VERTEX_SHADER, "shadery/shader.vert"); shader.LoadFromFile(GL_GEOMETRY_SHADER,"shadery/shader.geom"); shader.LoadFromFile(GL_FRAGMENT_SHADER,"shadery/shader.frag"); shader.CreateAndLinkProgram(); shader.Use(); shader.AddAttribute("vVertex");
51
OpenGL. Receptury dla programisty
shader.AddUniform("MVP"); shader.AddUniform("sub_divisions"); glUniform1i(shader("sub_divisions"), sub_divisions); shader.UnUse();
Istotne dodatki zostały tutaj wyróżnione, a są nimi nowy shader geometrii i dodatkowy uniform określający liczbę wymaganych podziałów (sub_dicisions). Inicjalizacja tego uniformu ma miejsce w funkcji inicjalizującej OpenGL. Obsługa obiektu bufora wygląda tutaj podobnie jak w recepturze z kolorowym trójkątem. Większe różnice pojawiają się dopiero w funkcji renderującej, gdzie występują pewne dodatkowe przekształcenia (przesunięcia) modelu już po przekształceniach widoku. Funkcja OnRender() rozpoczyna się od wyczyszczenia buforów koloru i głębi. Potem obliczane są przekształcenia widoku, tak jak w poprzedniej recepturze. void OnRender() { glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); glm::mat4 T = glm::translate( glm::mat4(1.0f), glm::vec3(0.0f,0.0f, dist)); glm::mat4 Rx=glm::rotate(T,rX,glm::vec3(1.0f, 0.0f, 0.0f)); glm::mat4 MV=glm::rotate(Rx,rY, glm::vec3(0.0f,1.0f,0.0f)); MV=glm::translate(MV, glm::vec3(-5,0,-5));
Jako że nasze płaskie siatki są ułożone w początku układu współrzędnych, rozciągając się wzdłuż osi X i Z od -5 do 5, musimy je poprzesuwać w inne miejsca, aby się nie pokrywały. Następny krok zaczynamy od wiązania programu shaderowego. Potem przekazujemy uniformy, takie jak sub_divisions i MVP (macierz modelu, widoku i rzutowania). W końcu wywołujemy funkcję glDrawElements, aby narysować pierwszą płaszczyznę. Następnie wprowadzamy przesunięcie i przygotowujemy nową macierz modelu i widoku dla następnej płaszczyzny. Wszystko to powtarzamy trzy razy, aby uzyskać właściwe rozmieszczenie czterech płaskich powierzchni w przestrzeni świata. Tym razem obsługujemy również zdarzenia związane z klawiaturą, aby użytkownik mógł w trakcie działania aplikacji zmieniać poziom zagęszczenia siatek. Przede wszystkim podpinamy funkcję obsługującą zdarzenia klawiaturowe (OnKey) do glutKeyboardFunc. Funkcję OnKey definiujemy następująco: void OnKey(unsigned char key, int x, int y) { switch(key) { case ',': sub_divisions--; break; case '.': sub_divisions++; break; } sub_divisions = max(1,min(8, sub_divisions)); glutPostRedisplay(); }
52
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
Do zmiany poziomu zagęszczenia, czyli liczby podziałów, przeznaczamy klawisze , (przecinek) i . (kropka). Następnie sprawdzamy, czy liczba podziałów mieści się w dopuszczalnych granicach. Na koniec wywołujemy funkcję glutPostRedisplay(), aby odświeżyć zawartość okna i wyświetlić siatkę z nowym stopniem zagęszczenia. Po skompilowaniu i uruchomieniu zaprezentowanego programu na ekranie ukażą się cztery płaskie siatki. Wciśnięcie klawisza , spowoduje zmniejszenie ich zagęszczenia, a za pomocą klawisza . będzie można ten poziom zwiększyć. Rezultaty działania shadera geometrii przy kilku różnych liczbach podziałów są pokazane na rysunku poniżej.
Dowiedz się więcej Zapoznaj się z dwuczęściowym wykładem na temat shadera geometrii zamieszczonym w serwisie Geeks3D http://www.geeks3d.com/20111111/simple-introduction-to-geometry-shaders-glslopengl-tutorial-part1/, http://www.geeks3d.com/20111117/simple-introduction-to-geometry-shader-in-glsl-part-2/.
Dynamiczne zagęszczanie podziału płaszczyzny przy użyciu shadera geometrii i renderingu instancyjnego Aby uniknąć wielokrotnego przesyłania tych samych danych, możemy wykorzystać funkcje renderingu instancyjnego. Zobaczymy teraz, jak można zastąpić znane z poprzedniej receptury wielokrotne wywoływanie funkcji glDrawElements pojedynczym wywołaniem funkcji glDrawEle mentsInstanced.
53
OpenGL. Receptury dla programisty
Przygotowania Przed przystąpieniem do realizacji tej receptury należy zapoznać się z funkcjonowaniem shadera geometrii w rdzennym profilu Open GL 3.3. Pełny kod omawianej tutaj aplikacji znajduje się w folderze Rozdział1\RenderingInstancyjny.
Jak to zrobić? Aby przystosować kod z poprzedniej receptury do wymogów renderowania instancyjnego, wykonaj następujące czynności: 1. Zmień shader wierzchołków, aby obsługiwał instancyjną macierz modelu i dawał położenia w przestrzeni świata (shadery/shader.vert). #version 330 core layout(location=0) in vec3 vVertex; uniform mat4 M[4]; void main() { gl_Position = M[gl_InstanceID]*vec4(vVertex, 1); }
2. Zmień shader geometrii, zastępując w nim macierz MVP macierzą PV (shadery/shader.geom). #version 330 core layout (triangles) in; layout (triangle_strip, max_vertices=256) out; uniform int sub_divisions; uniform mat4 PV; void main() { vec4 v0 = gl_in[0].gl_Position; vec4 v1 = gl_in[1].gl_Position; vec4 v2 = gl_in[2].gl_Position; float dx = abs(v0.x-v2.x)/sub_divisions; float dz = abs(v0.z-v1.z)/sub_divisions; float x=v0.x; float z=v0.z; for(int j=0;j
54
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
z+=dz; } } }
3. Zainicjalizuj instancyjne macierze modelu (M). void OnInit() { //przygotowanie macierzy modelu dla instancji M[0] = glm::translate(glm::mat4(1), glm::vec3(-5,0,-5)); M[1] = glm::translate(M[0], glm::vec3(10,0,0)); M[2] = glm::translate(M[1], glm::vec3(0,0,10)); M[3] = glm::translate(M[2], glm::vec3(-10,0,0)); shader.Use(); shader.AddAttribute("vVertex"); shader.AddUniform("PV"); shader.AddUniform("M"); shader.AddUniform("sub_divisions"); glUniform1i(shader("sub_divisions"), sub_divisions); glUniformMatrix4fv(shader("M"), 4, GL_FALSE, glm::value_ptr(M[0])); shader.UnUse();
4. Wyrenderuj instancje, używając funkcji glDrawElementInstanced. void OnRender() { glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); glm::mat4 T =glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, dist)); glm::mat4 Rx=glm::rotate(T,rX,glm::vec3(1.0f, 0.0f, 0.0f)); glm::mat4 V =glm::rotate(Rx,rY,glm::vec3(0.0f, 1.0f,0.0f)); glm::mat4 PV = P*V; shader.Use(); glUniformMatrix4fv(shader("PV"),1,GL_FALSE,glm::value_ptr(PV)); glUniform1i(shader("sub_divisions"), sub_divisions); glDrawElementsInstanced(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0, 4); shader.UnUse(); glutSwapBuffers(); }
Jak to działa? Najpierw musimy gdzieś zapisać macierze modelu dla każdej instancji oddzielnie. Ponieważ mamy cztery instancje, utworzymy dla tych macierzy tablicę czteroelementową M[4]. Następnie mnożymy położenie każdego wierzchołka bieżącej instancji przez jej macierz modelu (M[gl_InstanceID]).
55
OpenGL. Receptury dla programisty
Wbudowany atrybut glInastanceID otrzymuje indeks bieżącej instancji w sposób automatyczny w chwili, gdy wywoływana jest funkcja glDrawElementsInstanced. Atrybut ten jest dostępny wyłącznie w shaderze wierzchołków.
Macierz MVP usuwamy z shadera geometrii, ponieważ teraz wejściowe położenia wierzchołków są podawane we współrzędnych światowych. Muszą być zatem mnożone przez połączoną macierz widoku i rzutowania (PV). Po stronie aplikacji usuwamy także macierz MV, a zamiast niej tworzymy tablicę z macierzami modelu dla poszczególnych instancji (glm::mat4 M[4]). Macierze te są inicjalizowane w funkcji OnInit() za pomocą następujących instrukcji: M[0] M[1] M[2] M[3]
= = = =
glm::translate(glm::mat4(1), glm::vec3(-5,0,-5)); glm::translate(M[0], glm::vec3(10,0,0)); glm::translate(M[1], glm::vec3(0,0,10)); glm::translate(M[2], glm::vec3(-10,0,0));
Funkcja renderująca, OnRender(), tworzy połączoną macierz widoku i rzutowania (PV), po czym wywołuje funkcję glDrawElementsInstanced. Jej pierwsze cztery parametry są podobne do tych z funkcji glDrawElements, a ostatni podaje liczbę instancji, które mają być wyrenderowane. Rendering instancyjny jest wydajnym mechanizmem renderowania wielu egzemplarzy tej samej geometrii, w którym wiązania GL_ARRAY_BUFFER i GL_ELEMENT_ARRAY_BUFFER są współdzielone przez wszystkie instancje, a to z kolei pozwala procesorowi graficznemu na efektywniejsze korzystanie z niezbędnych zasobów. void OnRender() { glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); glm::mat4 T = glm::translate(glm::mat4(1.0f),glm::vec3(0.0f, 0.0f, dist)); glm::mat4 Rx = glm::rotate(T, rX, glm::vec3(1.0f, 0.0f, 0.0f)); glm::mat4 V = glm::rotate(Rx, rY, glm::vec3(0.0f, 1.0f, 0.0f)); glm::mat4 PV = P*V; shader.Use(); glUniformMatrix4fv(shader("PV"),1,GL_FALSE,glm::value_ptr(PV)); glUniform1i(shader("sub_divisions"), sub_divisions); glDrawElementsInstanced(GL_TRIANGLES,6,GL_UNSIGNED_SHORT,0, 4); shader.UnUse(); glutSwapBuffers(); }
Zawsze istnieje ograniczenie co do liczby macierzy, jakie można uzyskać z shadera wierzchołków, i to też może mieć wpływ na wydajność renderowania. Pewną poprawę można osiągnąć przez zastąpienie macierzy wektorami skali i przesunięcia oraz kwaternionami obrotu i dopiero potem konwertować to wszystko na odpowiednie macierze.
56
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
Dowiedz się więcej Więcej informacji na omawiane tu tematy znajdziesz w oficjalnym serwisie wiki poświęconym bibliotece OpenGL, dostępnym pod adresem: http://www.opengl.org/wiki/Built-in_Variable_ %28GLSL%29. Samouczek instancyjnego renderingu opracowany przez OGLDev znajdziesz pod adresem: http://ogldev.atspace.co.uk/www/tutorial33/tutorial33.html.
Rysowanie obrazu 2D przy użyciu shadera fragmentów i biblioteki SOIL Kończymy rozdział przepisem na prostą przeglądarkę obrazów, jaką można wykonać przy użyciu rdzennego profilu OpenGL 3.3 i biblioteki SOIL.
Przygotowania Do wykonania zapowiedzianej przeglądarki potrzebne będzie środowisko programistyczne Visual Studio z dołączoną biblioteką SOIL. Pełny kod aplikacji znajduje się w folderze Rozdział1\ RysowanieObrazu.
Jak to zrobić? Prostą przeglądarkę obrazów można wykonać w następujący sposób: 1. Wczytaj obraz, korzystając z funkcji biblioteki SOIL. Ponieważ tak wczytany obraz jest zawsze odwrócony w pionie, należy mu przywrócić właściwą orientację przez odwrócenie względem osi Y. int texture_width = 0, texture_height = 0, channels=0; GLubyte* pData = SOIL_load_image(filename.c_str(), &texture_width, &texture_height, &channels, SOIL_LOAD_AUTO); if(pData == NULL) { cerr<<"Cannot load image: "<
57
OpenGL. Receptury dla programisty
for( i = texture_width * channels; i > 0; --i ) { GLubyte temp = pData[index1]; pData[index1] = pData[index2]; pData[index2] = temp; ++index1; ++index2; } }
2. Przygotuj w OpenGL obiekt tekstury i zwolnij zasoby zaalokowane przez bibliotekę SOIL. glGenTextures(1, &textureID); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, textureID); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, texture_width, texture_height, 0, GL_RGB, GL_UNSIGNED_BYTE, pData); SOIL_free_image_data(pData);
3. Ustaw shader wierzchołków na zwracanie położeń w przestrzeni przycięcia (shadery/shader.vert). #version 330 core layout(location=0) in vec2 vVertex; smooth out vec2 vUV; void main() { gl_Position = vec4(vVertex*2.0-1,0,1); vUV = vVertex; }
4. Przygotuj shader fragmentów, który będzie próbkował teksturę, czyli wczytany obraz (shadery/shader.frag). #version 330 core layout (location=0) out vec4 vFragColor; smooth in vec2 vUV; uniform sampler2D textureMap; void main() { vFragColor = texture(textureMap, vUV); }
58
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
5. Załaduj shadery i utwórz program shaderowy, używając stosownych metod klasy GLSLShader. shader.LoadFromFile(GL_VERTEX_SHADER, "shadery/shader.vert"); shader.LoadFromFile(GL_FRAGMENT_SHADER,"shadery/shader.frag"); shader.CreateAndLinkProgram(); shader.Use(); shader.AddAttribute("vVertex"); shader.AddUniform("textureMap"); glUniform1i(shader("textureMap"), 0); shader.UnUse();
6. Przygotuj geometrię i topologię, a następnie przekaż dane do GPU, używając obiektów bufora. vertices[0] = glm::vec2(0.0,0.0); vertices[1] = glm::vec2(1.0,0.0); vertices[2] = glm::vec2(1.0,1.0); vertices[3] = glm::vec2(0.0,1.0); GLushort* id=&indices[0]; *id++ =0; *id++ =1; *id++ =2; *id++ =0; *id++ =2; *id++ =3; glGenVertexArrays(1, &vaoID); glGenBuffers(1, &vboVerticesID); glGenBuffers(1, &vboIndicesID); glBindVertexArray(vaoID); glBindBuffer (GL_ARRAY_BUFFER, vboVerticesID); glBufferData (GL_ARRAY_BUFFER, sizeof(vertices), &vertices[0], GL_STATIC_DRAW); glEnableVertexAttribArray(shader["vVertex"]); glVertexAttribPointer(shader["vVertex"], 2, GL_FLOAT, GL_FALSE,0,0); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vboIndicesID); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), &indices[0], GL_STATIC_DRAW);
7. Wyrenderuj geometrię. void OnRender() { glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); shader.Use(); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); shader.UnUse(); glutSwapBuffers(); }
59
OpenGL. Receptury dla programisty
8. Zwolnij zaalokowane zasoby. void OnShutdown() { shader.DeleteShaderProgram(); glDeleteBuffers(1, &vboVerticesID); glDeleteBuffers(1, &vboIndicesID); glDeleteVertexArrays(1, &vaoID); glDeleteTextures(1, &textureID); }
Jak to działa? Biblioteka SOIL dostarcza wielu funkcji, ale tym razem interesuje nas tylko jedna, a mianowicie SOIL_load_image. int texture_width = 0, texture_height = 0, channels=0; GLubyte* pData = SOIL_load_image(filename.c_str(), &texture_width, &texture_height, &channels, SOIL_LOAD_AUTO); if(pData == NULL) { cerr<<"Cannot load image: "<
Pierwszym parametrem tej funkcji jest nazwa pliku z obrazem. Następne trzy zwracają szerokość i wysokość tekstury oraz liczbę kanałów koloru w obrazie. Dane te są potrzebne do wygenerowania obiektu tekstury. Ostatni parametr jest znacznikiem służącym do sterowania dalszym przetwarzaniem obrazu. Tym razem użyjemy znacznika SOIL_LOAD_AUTO, który zachowuje domyślne ustawienia wczytywania. Jeśli funkcja kończy swoje działanie z powodzeniem, zwraca unsigned char* do danych obrazu. Jeśli pojawia się błąd, zwracaną wartością jest NULL (0). Do odwrócenia obrazu wczytanego przez funkcję SOIL użyjemy dwóch zagnieżdżonych pętli. int i,j; for( j = 0; j*2 < texture_height; ++j ) { int index1 = j * texture_width * channels; int index2 = (texture_height - 1 - j) * texture_width * channels; for( i = texture_width * channels; i > 0; --i ) { GLubyte temp = pData[index1]; pData[index1] = pData[index2]; pData[index2] = temp; ++index1; ++index2; } }
Po wczytaniu obrazu generujemy obiekt tekstury i przekazujemy dane obrazowe do pamięci tekstury. 60
Rozdział 1. • Wprowadzenie do nowoczesnego OpenGL
glGenTextures(1, &textureID); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, textureID); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, texture_width, texture_height, 0, GL_RGB, GL_UNSIGNED_BYTE, pData); SOIL_free_image_data(pData);
Tak jak w przypadku każdego innego obiektu OpenGL musimy najpierw wywołać funkcję glGen Textures. Jej pierwszym parametrem jest liczba obiektów tekstury, które potrzebujemy, a w drugim przechowywany jest identyfikator (ID) wygenerowanego obiektu tekstury. Po wygenerowaniu takiego obiektu wskazujemy aktywną jednostkę teksturującą, a czynimy to, wywołując funkcję glActiveTexture(GL_TEXTURE0). Potem za pomocą funkcji glBindTextures(GL_TEXTURE_ 2D, &textureID) przywiązujemy teksturę do aktywnej jednostki. Następnie ustawiamy parametry tekstury, takie jak filtrowanie przy pomniejszaniu i powiększaniu oraz tryb zawijania wzdłuż współrzędnych S i T. Po tym wszystkim przekazujemy wczytane dane obrazowe do funkcji glTexImage2D. Dopiero funkcja glTexImage2D przeprowadza właściwą alokację obiektów tekstury. Jej pierwszym parametrem jest rodzaj tekstury docelowej (u nas jest to GL_TEXTURE_2D). Parametr drugi określa poziom mipmapy i jemu nadajemy wartość 0. Parametr trzeci wyznacza wewnętrzny format tekstury. Możemy go określić, zaglądając do właściwości obrazu. Parametry czwarty i piąty zawierają, odpowiednio, szerokość i wysokość tekstury. Parametr szósty może przyjmować wartość 0 dla tekstury bez ramki lub 1 dla tekstury z ramką. Siódmy określa format obrazu, ósmy — typ wskaźnika danych obrazowych, a ostatni, dziewiąty, jest wskaźnikiem do danych obrazowych. Po tej funkcji można już zwolnić zasoby zaalokowane dla obrazu przez bibliotekę SOIL i właśnie do tego służy funkcja SOIL_free_image_data(pData).
I jeszcze jedno… W tej recepturze używamy dwóch shaderów: wierzchołków i fragmentów. Pierwszy z nich oblicza położenia wierzchołków w przestrzeni przycięcia przez wykonanie prostych obliczeń arytmetycznych na położeniach wejściowych (vVertex). Potem na podstawie wyników tych obliczeń ustala współrzędne tekstury (vUV) potrzebne w procesie próbkowania przeprowadzanym przez shader fragmentów. gl_Position = vec4(vVertex*2.0-1,0,1); vUV = vVertex;
Shader fragmentów otrzymuje gładko interpolowane współrzędne tekstury, które po wyjściu z shadera wierzchołków przechodzą jeszcze przez rasteryzer. Wczytany obraz jest przekazywany do samplera tekstur (uniform sampler2D textureMap), gdzie następuje próbkowanie na podstawie
61
OpenGL. Receptury dla programisty
wejściowych współrzędnych teksturowych (vFragColor = texture(textureMap, vUV)). Dopiero po wykonaniu tych wszystkich zabiegów obraz jest wyświetlany na ekranie. Z punktu widzenia całej aplikacji kod jest bardzo podobny do tych, jakie tworzyliśmy w poprzednich przykładach. Jedną z ważniejszych zmian jest wprowadzenie uniformu samplera textureMap. shader.Use(); shader.AddAttribute("vVertex"); shader.AddUniform("textureMap"); glUniform1i(shader("textureMap"), 0); shader.UnUse();
Jako że ten uniform nie zmienia się podczas działania aplikacji, inicjalizujemy go tylko raz. Pierwszym parametrem funkcji glUniform1i jest lokalizacja uniformu. Wartość uniformu samplera ustawiamy na aktywną jednostkę teksturującą, z którą związana jest tekstura. W naszym przypadku jest to jednostka o numerze 0, czyli GL_TEXTURE0. Dlatego przekazujemy do uniformu wartość 0. Gdyby tekstura była związana z GL_TEXTURE1, przekazalibyśmy wartość 1. Funkcja OnShutdown()wygląda podobnie jak w poprzednich recepturach. Nowością jest usuwanie obiektu tekstury. Kod renderujący jak zwykle najpierw czyści bufory koloru i głębi, a następnie wiąże program shaderowy i wywołuje funkcję glDrawElement w celu wyrenderowania trójkątów. Na koniec program shaderowy jest odwiązywany i funkcja glutSwapBuffers zamienia miejscami bufory ekranu, aby wyświetlić wyrenderowany obraz. Po skompilowaniu i uruchomieniu tego kodu na ekranie powinien ukazać się obraz taki jak na poniższym rysunku.
Za pomocą biblioteki z funkcjami wczytywania obrazu, takiej jak SOIL, i shadera fragmentów można wykonać prostą przeglądarkę graficzną. Bardziej wyszukane efekty można uzyskać, stosując techniki opisane w pozostałych rozdziałach książki.
62
2 Wyświetlanie i wskazywanie obiektów 3D W tym rozdziale: Implementacja wektorowego modelu kamery z obsługą ruchów w stylu gier FPS Implementacja kamery swobodnej Implementacja kamery wycelowanej Ukrywanie elementów spoza bryły widzenia Wskazywanie obiektów z użyciem bufora głębi Wskazywanie obiektów na podstawie koloru Wskazywanie obiektów na podstawie ich przecięć z promieniem oka
Wstęp W tym rozdziale przyjrzymy się recepturom realizującym zadania wyświetlania widoku trójwymiarowej sceny i wskazywania obecnych w niej obiektów. W każdej symulacji działającej w czasie rzeczywistym, w każdej grze komputerowej i w każdej aplikacji służącej do tworzenia trójwymiarowych grafik potrzebna jest wirtualna kamera pokazująca scenę z określonego punktu widzenia. Kamera sama jest obiektem umieszczonym w trójwymiarowej przestrzeni i ustawionym zgodnie z kierunkiem zwanym kierunkiem patrzenia. W ujęciu programistycznym jest ona zestawem przesunięć i obrotów zapisanym w macierzy widoku.
OpenGL. Receptury dla programisty
Ustawienia rzutowania dla wirtualnej kamery mają wpływ na to, czy pokazywane obiekty będą widziane na ekranie jako duże czy małe. W świecie rzeczywistym coś takiego osiągamy przez zmianę ogniskowej obiektywu w aparacie fotograficznym lub kamerze. W OpenGL po prostu dobieramy odpowiednią macierz rzutowania. Zastosowanie wirtualnej kamery pozwala także ograniczyć ilość geometrii przekazywanej do GPU. Jest to wynik procesu zwanego ukrywaniem elementów spoza bryły widzenia (view frustum culling). W rezultacie procesor graficzny nie musi renderować wszystkich obiektów w scenie, a jedynie te, które są „widziane” przez kamerę. Rozwiązanie takie znacząco poprawia wydajność aplikacji.
Implementacja wektorowego modelu kamery z obsługą ruchów w stylu gier FPS Na początek zaprojektujemy prostą klasę, która będzie nam służyła do definiowania konkretnych kamer. W typowej aplikacji OpenGL operacje widokowe mają na celu pokazanie wirtualnego obiektu na ekranie monitora. Szczegóły techniczne wszystkich potrzebnych do tego transformacji zostawimy tekstom bardziej zaawansowanym, takim jak te podane w punkcie „Dowiedz się więcej”. Na razie skupimy się na zaprojektowaniu maksymalnie uniwersalnej klasy pozwalającej na zaimplementowanie rozmaitych kamer. Klasie tej nadamy nazwę CAbstract Camera i na jej podstawie zaimplementujemy później dwie kamery potomne: CFreeCamera (swobodna) i CTargetCamera (wycelowana). Hierarchiczne zależności między tymi strukturami pokazuje poniższy rysunek.
Przygotowania Gotowy kod dla omawianej receptury znajduje się folderze Rozdział2/src. Definicja klasy CAbstractCamera zajmuje pliki AbstractCamera.h i AbstractCamera.cpp. class CAbstractCamera { public: CAbstractCamera(void); ~CAbstractCamera(void); void SetupProjection(const float fovy, const float aspectRatio, const float near=0.1f, const float far=1000.0f);
64
Rozdział 2. • Wyświetlanie i wskazywanie obiektów 3D
virtual void Update() = 0; virtual void Rotate(const float yaw, const float pitch, const float roll); const glm::mat4 GetViewMatrix() const; const glm::mat4 GetProjectionMatrix() const; void SetPosition(const glm::vec3& v); const glm::vec3 GetPosition() const; void SetFOV(const float fov); const float GetFOV() const; const float GetAspectRatio() const; void CalcFrustumPlanes(); bool IsPointInFrustum(const glm::vec3& point); bool IsSphereInFrustum(const glm::vec3& center, const float radius); bool IsBoxInFrustum(const glm::vec3& min, const glm::vec3& max); void GetFrustumPlanes(glm::vec4 planes[6]); glm::vec3 farPts[4]; glm::vec3 nearPts[4]; protected: float yaw, pitch, roll, fov, aspect_ratio, Znear, Zfar; static glm::vec3 UP; glm::vec3 look; glm::vec3 up; glm::vec3 right; glm::vec3 position; glm::mat4 V; //macierz widoku glm::mat4 P; //macierz rzutowania CPlane planes[6]; //płaszczyzny odcinania };
Najpierw deklarujemy konstruktora i destruktora. Następnie ustalamy funkcję odpowiedzialną za ustawienia rzutowania dla danej kamery. Potem następują deklaracje funkcji aktualizujących macierze kamery przy jej obrotach. Za nimi mamy definicje metod dostępowych. Ostatnie funkcje to te związane z ukrywaniem elementów spoza bryły widzenia. Na końcu są deklarowane pola klasy. Klasy potomne muszą zawierać implementację wirtualnej funkcji Update (do przeliczania macierzy i wektorów kierunkowych). Do wyznaczania ruchów kamery służą trzy wektory kierunkowe, a mianowicie: look (kierunek patrzenia), up (w górę) i right (w prawo).
Jak to zrobić? Przy opracowywaniu aplikacji wymagających użycia kamery, co będzie tematem następnych receptur, nie będziemy korzystać z klasy CAbstractCamera, lecz z CFreeCamera lub CTargetCamera. W tej recepturze zajmiemy się sterowaniem kamerą za pomocą myszy i klawiatury. Aby obsłużyć zdarzenia związane z klawiaturą, w funkcji wywoływanej przez sygnał bezczynności procesora umieścimy następujący ciąg procedur:
65
OpenGL. Receptury dla programisty
1. Sprawdzenie, czy wystąpiło zdarzenie wciśnięcia klawisza. 2. Jeśli wciśnięto klawisz W lub S, kamera jest przesuwana w kierunku wektora look. if( GetAsyncKeyState(VK_W) & 0x8000) cam.Walk(dt); if( GetAsyncKeyState(VK_S) & 0x8000) cam.Walk(-dt); Jeśli wciśnięto klawisz A lub D, kamera jest przesuwana w kierunku wektora right. if( GetAsyncKeyState(VK_A) & 0x8000) cam.Strafe(-dt); if( GetAsyncKeyState(VK_D) & 0x8000) cam.Strafe(dt); Jeśli wciśnięto klawisz Q lub Z, kamera jest przesuwana w kierunku wektora up. if( GetAsyncKeyState(VK_Q) & 0x8000) cam.Lift(dt); if( GetAsyncKeyState(VK_Z) & 0x8000) cam.Lift(-dt);
Do obsługi zdarzeń związanych z myszą użyjemy dwóch funkcji zwrotnych. Jedna będzie obsługiwała ruch myszy, a druga kliknięcia. 3. Definiujemy obsługę zdarzeń związanych z ruchem myszy i kliknięciami. 4. W procedurze obsługującej kliknięcie przyciskiem myszy określamy, który parametr (zbliżenie czy obrót) ma być regulowany ruchem myszy. if(button == GLUT_MIDDLE_BUTTON) state = 0; else state = 1;
5. Jeśli wybrano zbliżenie, obliczamy kąt widzenia (fov) w zależności od długości ruchu myszy, po czym ustalamy macierz rzutowania dla kamery. if (state == 0) { fov += (y - oldY)/5.0f; cam.SetupProjection(fov, cam.GetAspectRatio()); }
6. Jeśli wybrano obrót, obliczamy nowe ułożenie kamery (pochylenie i odchylenie). W zależności od tego, czy włączone jest wygładzanie (filtrowanie) ruchów myszy, stosujemy współrzędne wygładzone albo bezpośrednie. else { rY += (y - oldY)/5.0f; rX += (oldX-x)/5.0f; if(useFiltering) filterMouseMoves(rX, rY); else { mouseX = rX;
66
Rozdział 2. • Wyświetlanie i wskazywanie obiektów 3D
mouseY = rY; } cam.Rotate(mouseX,mouseY, 0); }
I jeszcze jedno… Zawsze lepiej jest stosować wygładzone współrzędne myszy, ponieważ uzyskuje się wtedy płynniejszy ruch sterowanego obiektu. My będziemy stosować proste filtrowanie polegające na ważonym uśrednianiu ostatnich 10 odczytów położenia myszy, przy czym największą wagę otrzymuje odczyt ostatni, a kolejne tym mniejszą, im są starsze. Filtrowanie takie można zrealizować w sposób przedstawiony na poniższym listingu. void filterMouseMoves(float dx, float dy) { for (int i = MOUSE_HISTORY_BUFFER_SIZE - 1; i > 0; --i) { mouseHistory[i] = mouseHistory[i - 1]; } mouseHistory[0] = glm::vec2(dx, dy); float averageX = 0.0f, averageY = 0.0f, averageTotal = 0.0f, currentWeight = 1.0f; for (int i = 0; i < MOUSE_HISTORY_BUFFER_SIZE; ++i) { glm::vec2 tmp=mouseHistory[i]; averageX += tmp.x * currentWeight; averageY += tmp.y * currentWeight; averageTotal += 1.0f * currentWeight; currentWeight *= MOUSE_FILTER_WEIGHT; } mouseX = averageX / averageTotal; mouseY = averageY / averageTotal; } Przy wygładzaniu współrzędnych myszy należy koniecznie wypełnić bufor historii odpowiednimi wartościami początkowymi, bo inaczej w pierwszych klatkach mogą być zauważalne nagłe nieciągłości w ruchu sterowanych obiektów.
Dowiedz się więcej Przeczytaj odpowiedzi Paula Nettle’a na pytania dotyczące wygładzania ruchów myszy, dostępne pod adresem: http://www.flipcode.com/archives/Smooth_Mouse_Filtering.shtml. Zajrzyj do książki Tomasa Akenine-Mollera, Erica Hainesa i Naty’ego Hoffmana Real-Time Rendering. Third Edition, wydanej przez A K Peters/CRC Press w 2008 roku.
67
OpenGL. Receptury dla programisty
Implementacja kamery swobodnej Jako pierwszą zaimplementujemy kamerę swobodną, czyli taką, która nie ma ustalonego celu. Ma tylko ustalone położenie, a patrzeć może w dowolnym kierunku.
Przygotowania Kamera swobodna jest pokazana na poniższym rysunku. Gdy ją obracamy, jej położenie się nie zmienia. Zmienia się jedynie kierunek, w którym jest zwrócona. Gdy ją przesuwamy, zmienia się jej położenie, a kierunek patrzenia pozostaje stały.
Kod źródłowy dla tej receptury znajduje się w folderze Rozdział2/KameraSwobodna. Klasa CFreeCamera jest zdefiniowana w plikach FreeCamera.h i FreeCamera.cpp umieszczonych w folderze Rozdział2/src. Interfejs tej klasy wygląda następująco: class CFreeCamera : public CAbstractCamera { public: CFreeCamera(void); ~CFreeCamera(void); void Update(); void Walk(const float dt); void Strafe(const float dt); void Lift(const float dt); void SetTranslation(const glm::vec3& t);
68
Rozdział 2. • Wyświetlanie i wskazywanie obiektów 3D
glm::vec3 GetTranslation() const; void SetSpeed(const float speed); const float GetSpeed() const; protected: float speed; //szybkość kamery w m/s glm::vec3 translation; };
Jak to zrobić? Aby zaimplementować kamerę swobodną, wykonaj następujące czynności: 1. Zdefiniuj klasę CFreeCamera i dodaj jej wektor przechowujący bieżące przesunięcie. 2. W metodzie Update oblicz nową macierz kierunku (obrotu), uwzględniając bieżące wartości odchylenia, pochylenia i przechylenia. glm::mat4 R = glm::yawPitchRoll(yaw,pitch,roll); Upewnij się, że wartości odchylenia (yaw), pochylenia (pitch) i przechylenia (roll) są wyrażone w radianach.
3. Zmień położenie kamery o wektor przesunięcia. position+=translation;
Jeśli chcesz, by kamera zatrzymywała się powoli, a nie nagle, skracaj stopniowo wektor przesunięcia. W tym celu dodaj poniższy fragment kodu na końcu obsługi zdarzenia wywoływanego wciśnięciem klawisza. glm::vec3 t = cam.GetTranslation(); if(glm::dot(t,t)>EPSILON2) { cam.SetTranslation(t*0.95f); }
Jeśli stopniowe wyhamowywanie kamery nie jest potrzebne, wyzeruj wektor przesunięcia w funkcji CFreeCamera::Update po wykonaniu przekształcenia. translation = glm::vec3(0);
4. Wyznacz wektory look i up na podstawie bieżącej macierzy obrotu, a potem na ich podstawie wyznacz wektor right. look = glm::vec3(R*glm::vec4(0,0,1,0)); up = glm::vec3(R*glm::vec4(0,1,0,0)); right = glm::cross(look, up);
5. Wyznacz punkt, na który kamera jest wycelowana. glm::vec3 tgt = position+look;
69
OpenGL. Receptury dla programisty
6. Za pomocą funkcji glm::lookat wyznacz nową macierz widoku, uwzględniając aktualne wartości położenia, celu i wektora up. V = glm::lookAt(position, tgt, up);
I jeszcze jedno… Funkcja Walk po prostu przesuwa kamerę w kierunku wektora look. void CFreeCamera::Walk(const float dt) { translation += (look*dt); }
Funkcja Strafe przesuwa kamerę w kierunku wektora right. void CFreeCamera::Strafe(const float dt) { translation += (right*dt); }
Funkcja Lift przesuwa kamerę w kierunku wektora up. void CFreeCamera::Lift(const float dt) { translation += (up*dt); }
Uruchomienie przykładowej aplikacji powoduje wyrenderowanie nieskończonej płaszczyzny pokrytej szachownicą (patrz rysunek poniżej). Swobodną kamerę można przesuwać w różne strony za pomocą klawiszy W, S, A, D, Q i Z. Przeciąganie myszą przy wciśniętym lewym przycisku obraca kamerę bez zmiany jej położenia, a przeciąganie przy wciśniętym prawym przycisku przybliża lub oddala widok.
Dowiedz się więcej Przyjrzyj się realizacji następujących przykładów zamieszczonych w serwisie DHPOWare: OpenGL camera demo, Part 1 (http://www.dhpoware.com/demos/glCamera1.html), OpenGL camera demo, Part 2 (http://www.dhpoware.com/demos/glCamera2.html), OpenGL camera demo, Part 3 (http://www.dhpoware.com/demos/glCamera3.html).
Implementacja kamery wycelowanej Kamera wycelowana działa inaczej niż swobodna. W tym przypadku cel pozostaje nieruchomy i tylko kamera porusza się i obraca wokół niego. Tylko w niektórych ujęciach, takich jak panoramowanie, cel i kamera wspólnie zmieniają swoje położenia.
70
Rozdział 2. • Wyświetlanie i wskazywanie obiektów 3D
Przygotowania Poniższy rysunek przedstawia kamerę wycelowaną. Położenie celu wyznacza mały sześcian. Kod źródłowy dla tej receptury znajduje się w folderze Rozdział2/KameraWycelowana. Klasa CTargetCamera jest zdefiniowana w plikach TargetCamera.h i TargetCamera.cpp umieszczonych w folderze Rozdział2/src. Interfejs tej klasy wygląda następująco: class CTargetCamera : public CAbstractCamera { public: CTargetCamera(void); ~CTargetCamera(void); void Update(); void Rotate(const float yaw, const float pitch, const float roll); void SetTarget(const glm::vec3 tgt); const glm::vec3 GetTarget() const; void Pan(const float dx, const float dy); void Zoom(const float amount ); void Move(const float dx, const float dz); protected: glm::vec3 target;
71
OpenGL. Receptury dla programisty
float minRy, maxRy; float distance; float minDistance, maxDistance; };
Jak to zrobić? Aby zaimplementować kamerę wycelowaną, wykonaj następujące czynności: 1. Zdefiniuj klasę CTargetCamera z położeniem celu (target), granicznymi wartościami obrotu (minRy i maxRy), odległością między kamerą a jej celem (distance) i granicznymi wartościami tej odległości (minDistance i maxDistance). 2. W metodzie Update oblicz nową macierz kierunku (obrotu), uwzględniając bieżące wartości odchylenia, pochylenia i przechylenia. glm::mat4 R = glm::yawPitchRoll(yaw,pitch,roll);
3. Na podstawie odległości od celu wyznacz wektor przesunięcia, a następnie pomnóż go przez bieżącą macierz obrotu. glm::vec3 T = glm::vec3(0,0,distance); T = glm::vec3(R*glm::vec4(T,0.0f));
4. Wyznacz nowe położenie kamery przez dodanie wektora przesunięcia do położenia celu. position = target + T;
5. Wyznacz ortogonalną bazę i określ w niej macierz widoku.
72
Rozdział 2. • Wyświetlanie i wskazywanie obiektów 3D
look = glm::normalize(target-position); up = glm::vec3(R*glm::vec4(UP,0.0f)); right = glm::cross(look, up); V = glm::lookAt(position, target, up);
I jeszcze jedno… Funkcja Move przesuwa jednakowo kamerę i jej cel wzdłuż wektorów look i right. void CTargetCamera::Move(const float dx, const float dy) { glm::vec3 X = right*dx; glm::vec3 Y = look*dy; position += X + Y; target += X + Y; Update(); }
Funkcja Pan przesuwa jednakowo kamerę i jej cel wzdłuż wektorów up i right. void CTargetCamera::Pan(const float dx, const float dy) { glm::vec3 X = right*dx; glm::vec3 Y = up*dy; position += X + Y; target += X + Y; Update(); }
Funkcja Zoom przesuwa kamerę w kierunku wektora look. void CTargetCamera::Zoom(const float amount) { position += look * amount; distance = glm::distance(position, target); Distance = std::max(minDistance, std::min(distance, maxDistance)); Update(); }
Przykładowa aplikacja, podobnie jak w poprzedniej recepturze, renderuje nieskończoną szachownicę, co widać na rysunku na następnej stronie.
Dowiedz się więcej Przyjrzyj się realizacji następujących przykładów zamieszczonych w serwisie DHPOWare: OpenGL camera demo, Part 1 (http://www.dhpoware.com/demos/glCamera1.html), OpenGL camera demo, Part 2 (http://www.dhpoware.com/demos/glCamera2.html), OpenGL camera demo, Part 3 (http://www.dhpoware.com/demos/glCamera3.html).
73
OpenGL. Receptury dla programisty
Ukrywanie elementów spoza bryły widzenia Gdy liczba wielokątów w scenie staje się duża, wówczas należy przesyłać do procesora graficznego tylko te, które rzeczywiście powinny być wyrenderowane. Istnieje kilka technik takiego zarządzania sceną, np. przeglądanie drzewa czwórkowego, ósemkowego lub bsp. Ułatwiają one sortowanie geometrii w zależności od jej widzialności, co z kolei pozwala na odpowiednie sortowanie obiektów (z ewentualnym wykluczeniem z wyświetlania). Dzięki temu zmniejsza się obciążenie pracą procesora graficznego. Niezależnie od tych technik często stosuje się dodatkowy zabieg polegający na ukrywaniu elementów nienależących do bryły widzenia. Jeśli jakiś obiekt leży poza bryłą widzenia, jest od razu wykluczany z procesu renderowania. Zasada jest prosta: jeśli coś jest niewidoczne, to nie powinno być przetwarzane. Bryła widzenia to nic innego jak ścięty ostrosłup z wierzchołkiem w punkcie położenia kamery i podstawą na dalszej płaszczyźnie odcinania. Bliższa płaszczyzna odcinania wyznacza ścięcie ostrosłupa (patrz rysunek na następnej stronie). Wszystko, co znajduje się wewnątrz takiej bryły, jest brane pod uwagę w dalszych przygotowaniach do renderingu.
74
Rozdział 2. • Wyświetlanie i wskazywanie obiektów 3D
Przygotowania Dla potrzeb tej receptury utworzymy siatkę punktów, które będą przemieszczane zgodnie z sinusoidalną falą przez prosty shader wierzchołków. Shader geometrii ukryje te wierzchołki, które w danym momencie znajdą się poza bryłą widzenia. Wyznaczaniem tej bryły zajmie się procesor graficzny i będzie to robił na podstawie parametrów rzutowania przypisanych kamerze. Tym razem zastosujemy podejście geometryczne. Gotowy kod dla tej receptury znajduje się w folderze Rozdział2/BryłaWidzenia.
Jak to zrobić? Aby zaimplementować ukrywanie elementów spoza bryły widzenia, wykonaj następujące czynności: 1. Zdefiniuj shader wierzchołków, który będzie zmieniał położenia wierzchołków w przestrzeni obiektu zgodnie z równaniem fali sinusoidalnej. #version 330 core layout(location = 0) in vec3 vVertex; uniform float t; const float PI = 3.141562; void main() { gl_Position=vec4(vVertex,1)+vec4(0,sin(vVertex.x*2*PI+t),0,0); }
2. Zdefiniuj shader geometrii, który będzie przeprowadzał obliczenia związane z ukrywaniem elementów spoza bryły widzenia. Obliczenia te będą wykonywane dla każdego wierzchołka, jaki zostanie pobrany z shadera wierzchołków.
75
OpenGL. Receptury dla programisty
#version 330 core layout (points) in; layout (points, max_vertices=3) out; uniform mat4 MVP; uniform vec4 FrustumPlanes[6]; bool PointInFrustum(in vec3 p) { for(int i=0; i < 6; i++) { vec4 plane=FrustumPlanes[i]; if ((dot(plane.xyz, p)+plane.w) < 0) return false; } return true; } void main() { //przygotowanie wierzchołków do renderowania for(int i=0;i
3. Żeby renderowane punkty wyglądały na okrągłe, dodaj proste obliczenia trygonometryczne, w wyniku których zostaną odrzucone wszystkie fragmenty niemieszczące się w kuli o określonym promieniu. #version 330 core layout(location = 0) out vec4 vFragColor; void main() { vec2 pos = (gl_PointCoord.xy-0.5); if(0.25
4. Po stronie CPU wywołaj funkcję CAbstractCamera::CalcFrustumPlanes() w celu wyznaczenia ścian bryły widzenia. Za pomocą funkcji CAbstractCamera::GetFrustumPlanes()umieść ściany w tablicy glm::vec4, po czym prześlij tę tablicę do shadera. W składnikach x, y i z zostaną umieszczone wektory normalne poszczególnych ścian, a składniki w będą zawierały odległości tych ścian. Po tym wszystkim można przystąpić do rysowania punktów.
76
Rozdział 2. • Wyświetlanie i wskazywanie obiektów 3D
pCurrentCam->CalcFrustumPlanes(); glm::vec4 p[6]; pCurrentCam->GetFrustumPlanes(p); pointShader.Use(); glUniform1f(pointShader("t"), current_time); glUniformMatrix4fv(pointShader("MVP"), 1, GL_FALSE, glm::value_ptr(MVP)); glUniform4fv(pointShader("FrustumPlanes"), 6, glm::value_ptr(p[0])); glBindVertexArray(pointVAOID); glDrawArrays(GL_POINTS,0,MAX_POINTS); pointShader.UnUse();
Jak to działa? W prezentowanej recepturze można wyróżnić dwie zasadnicze części: wyznaczanie ścian bryły widzenia i sprawdzanie, czy dany punkt należy do tej bryły. Pierwsza część jest realizowana przez funkcję CAbstractCamera::CalcFrustumPlanes()zdefiniowaną w pliku Rozdział2/src/ AbstractCamera.cpp. Funkcja ta realizuje podejście geometryczne, w którym najpierw wyznaczanych jest osiem narożników bryły. Całą teorię z tym związaną znajdziesz w materiałach podanych w punkcie „Dowiedz się więcej”. Gdy już wszystkie narożniki są znane, następuje wyznaczanie ścian — dla każdej potrzebne są trzy kolejne narożniki. Zadanie to wykonuje funkcja CPlane::From Points, która potrafi na podstawie trzech punktów wygenerować obiekt płaszczyzny (CPlane). W ten sposób jest wyznaczana każda z sześciu ścian bryły widzenia. Sprawdzanie, czy dany punkt zawiera się w bryle widzenia, jest wykonywane przez funkcję PointInFrustum zdefiniowaną w shaderze geometrii w sposób następujący: bool PointInFrustum(in vec3 p) { for(int i=0; i < 6; i++) { vec4 plane=FrustumPlanes[i]; if ((dot(plane.xyz, p)+plane.w) < 0) return false; } return true; }
Funkcja ta wykonuje testy kolejno dla wszystkich sześciu ścian i sprawdza, jaka jest odległość danego punktu od każdej z nich. Wyznaczenie tej odległości sprowadza się do obliczenia iloczynu skalarnego wektora normalnego danej ściany i wektora położenia badanego punktu oraz dodania odległości ściany. Jeśli odległość punktu od którejkolwiek ściany jest ujemna, znaczy to, że jest on poza bryłą widzenia i można go śmiało wykluczyć z dalszego przetwarzania. Jeśli wszystkie te odległości są dodatnie, punkt znajduje się wewnątrz bryły widzenia. Zwróć uwagę, że wszystkie ściany bryły widzenia są tak zorientowane, by ich normalne były zwrócone do wewnątrz.
77
OpenGL. Receptury dla programisty
I jeszcze jedno… W tej przykładowej implementacji zastosujemy dwie kamery: lokalną (o numerze 1), która będzie pokazywała falę, i globalną (o numerze 2), która pokaże całą scenę włącznie z bryłą widzenia kamery lokalnej. Widoki z tych kamer będzie można przełączać za pomocą klawiszy 1 (kamera nr 1) i 2 (kamera nr 2). Przy włączonej kamerze nr 1 przeciąganie myszą z wciśniętym lewym przyciskiem spowoduje obrót sceny i zaktualizowanie wyświetlanej na pasku tytułowym liczby punktów objętych bryłą widzenia. Przy włączonej kamerze nr 2 będzie w takiej sytuacji widać, jak obraca się kamera nr 1 i jaki obszar sceny obejmuje jej bryła widzenia. Żeby uzyskać wiedzę na temat liczby wierzchołków, które jako widoczne zostały wyemitowane przez shader geometrii, sformułujemy odpowiednie zapytanie. Po prostu całość kodu renderującego ujmiemy w klamry BeginQuery i EndQuery, tak jak na poniższym listingu: glBeginQuery(GL_PRIMITIVES_GENERATED, query); pointShader.Use(); glUniform1f(pointShader("t"), current_time); glUniformMatrix4fv(pointShader("MVP"), 1, GL_FALSE, glm::value_ptr(MVP)); glUniform4fv(pointShader("FrustumPlanes"), 6, glm::value_ptr(p[0])); glBindVertexArray(pointVAOID); glDrawArrays(GL_POINTS,0,MAX_POINTS); pointShader.UnUse(); glEndQuery(GL_PRIMITIVES_GENERATED);
Wynik zapytania uzyskamy za pomocą następujących instrukcji: GLuint res; glGetQueryObjectuiv(query, GL_QUERY_RESULT, &res);
Jeśli nie wystąpią żadne błędy, otrzymamy całkowitą liczbę wierzchołków wyemitowanych przez shader geometrii, czyli liczbę wierzchołków wewnątrz bryły widzenia. W widoku z kamery nr 2 emitowane są wszystkie wierzchołki, a zatem na pasku tytułowym widoczna jest liczba wszystkich punktów falującej siatki.
Gdy aktywna jest kamera nr 1, widzimy zbliżenie fali, która przemieszcza punkty w kierunku osi Y (patrz rysunek na następnej stronie). W tym widoku punkty są renderowane w kolorze niebieskim. Na pasku tytułowym okna aplikacji jest wyświetlana liczba widocznych aktualnie punktów. Jest tam również liczba klatek animacji wyświetlanych w ciągu sekundy (FPS), więc można zobaczyć, jakie korzyści daje ukrywanie elementów spoza bryły widzenia. Gdy aktywna jest kamera nr 2 (patrz kolejny rysunek), można, przeciągając myszą przy wciśniętym lewym przycisku, obracać kamerą nr 1. Umożliwia to obserwację, jak wraz ze zmianą
78
Rozdział 2. • Wyświetlanie i wskazywanie obiektów 3D
ustawienia bryły widzenia tej kamery zmienia się liczba widzianych przez nią punktów. W tym widoku punkty objęte bryłą widzenia kamery nr 1 są renderowane w kolorze magenty, a pozostałe, tak jak poprzednio, w kolorze niebieskim. Sama bryła widzenia ma kolor czerwony.
Dowiedz się więcej Zapoznaj się z artykułem na temat bryły widzenia zamieszczonym pod adresem: http://www. lighthouse3d.com/tutorials/view-frustum-culling/geometric-approach-extracting-the-planes/.
Wskazywanie obiektów z użyciem bufora głębi Często przy pisaniu programów potrzebne są rozwiązania umożliwiające wskazywanie obiektów na ekranie monitora. W wersjach OpenGL wcześniejszych niż 3.0 służył do tego celu bufor selekcji, ale w rdzennym profilu wersji 3.3 już go nie ma. Musimy więc wybrać jedną z metod alternatywnych i na początek wybierzemy technikę opartą na wykorzystaniu bufora głębi.
79
OpenGL. Receptury dla programisty
Przygotowania Gotowy kod dla tej receptury znajduje się w folderze Rozdział2/Wskazywanie_BuforGłębi, a niezbędne pliki źródłowe są w folderze Rozdział2/src.
Jak to zrobić? Aby zaimplementować wskazywanie obiektów z użyciem bufora głębi, wykonaj następujące czynności: 1. Włącz testowanie głębi. glEnable(GL_DEPTH_TEST);
2. W procedurze obsługi kliknięcia przyciskiem myszy odczytaj za pomocą funkcji glReadPixels wartość zapisaną w buforze głębi dla punktu, w którym wystąpiło kliknięcie. glReadPixels( x, HEIGHT-y, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, &winZ);
80
Rozdział 2. • Wyświetlanie i wskazywanie obiektów 3D
3. Wykonaj rzutowanie wsteczne punktu 3D, vec3(x,HEIGHT-y,winZ), aby od współrzędnych ekranowych punktu, w którym wystąpiło kliknięcie, (x, y) z dołączoną wartością głębi (winZ), przejść do współrzędnych w przestrzeni obiektu. Nie zapomnij przy tym odwrócić współrzędnej y przez odjęcie jej od wysokości okna (HEIGHT)1. glm::vec3 objPt = glm::unProject(glm::vec3 (x,HEIGHT-y,winZ), MV, P, glm::vec4(0,0,WIDTH, HEIGHT));
4. Sprawdź odległości wszystkich obiektów w scenie od wyznaczonego wcześniej punktu w przestrzeni obiektu (objPt). Zachowaj indeks obiektu leżącego najbliżej tego punktu. size_t i=0; float minDist = 1000; selected_box=-1; for(i=0;i<3;i++) { float dist = glm::distance(box_positions[i], objPt); if( dist<1 && dist
5. Zmień kolor obiektu o wybranym indeksie, aby go wyróżnić jako wskazany (zaznaczony). glm::mat4 T = glm::translate(glm::mat4(1), box_positions[0]); cube->color = (selected_box==0)?glm::vec3(0,1,1):glm::vec3(1,0,0); cube->Render(glm::value_ptr(MVP*T)); T = glm::translate(glm::mat4(1), box_positions[1]); cube->color = (selected_box==1)?glm::vec3(0,1,1):glm::vec3(0,1,0); cube->Render(glm::value_ptr(MVP*T)); T = glm::translate(glm::mat4(1), box_positions[2]); cube->color = (selected_box==2)?glm::vec3(0,1,1):glm::vec3(0,0,1); cube->Render(glm::value_ptr(MVP*T));
Jak to działa? Przykładowa aplikacja renderuje trzy kostki w kolorach czerwonym, zielonym i niebieskim. Gdy użytkownik kliknie którąś z nich, z bufora głębi zostanie pobrana wartość odpowiadająca punktowi, w którym wystąpiło kliknięcie. Następnie na podstawie współrzędnych tego punktu (x,HEIGHT-y, winZ) jest wyznaczany odpowiadający mu punkt w przestrzeni obiektu (funkcja 1
Niestety wielkość HEIGHT jest stała i konsekwencją tego jest niepoprawne działanie aplikacji przy zmianie wymiarów okna, a szczególnie jego wysokości — przyp.tłum.
81
OpenGL. Receptury dla programisty
glm::unProject). W następującej potem pętli wybierany jest obiekt leżący najbliżej wyznaczo-
nego właśnie punktu w przestrzeni obiektu. Indeks tego obiektu jest zachowywany do dalszego wykorzystania.
I jeszcze jedno… Gdy użytkownik kliknie kostkę, ta zmieni kolor na cyjanowy, co będzie oznaczało, że została wskazana (patrz rysunek poniżej).
Dowiedz się więcej Zapoznaj się z artykułem na temat zaznaczania obiektów zamieszczonym pod adresem: http://ogldev.atspace.co.uk/www/tutorial29/tutorial29.html.
82
Rozdział 2. • Wyświetlanie i wskazywanie obiektów 3D
Wskazywanie obiektów na podstawie koloru W świecie 3D stosuje się również metodę wskazywania obiektów na podstawie ich kolorów. W tej recepturze posłużymy się tą samą sceną co w poprzedniej.
Przygotowania Gotowy kod dla tej receptury znajduje się w folderze Rozdział2/Wskazywanie_BuforKoloru, a niezbędne pliki źródłowe są w folderze Rozdział2/src.
Jak to zrobić? Aby zaimplementować wskazywanie obiektów z użyciem bufora koloru, wykonaj następujące czynności: 1. Wyłącz roztrząsanie kolorów (dithering). Jest to konieczne, aby nie było pomyłek przy porównywaniu kolorów. glDisable(GL_DITHER);
2. W procedurze obsługi kliknięcia przyciskiem myszy odczytaj za pomocą funkcji glReadPixels wartość zapisaną w buforze koloru dla punktu, w którym wystąpiło kliknięcie. GLubyte pixel[4]; glReadPixels(x, HEIGHT-y, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel);
3. Porównaj kolor klikniętego piksela z kolorami wszystkich obiektów, aby ustalić, który z nich został wskazany. selected_box=-1; if(pixel[0]==255 && pixel[1]==0 && pixel[2]==0) { cout<<"picked box 1"<
83
OpenGL. Receptury dla programisty
Jak to działa? Metoda ta jest łatwa do zaimplementowania. Po prostu sprawdzamy kolor klikniętego piksela. Jako że roztrząsanie kolorów może generować rozmaite wartości, wyłączymy tę funkcję. Następnie przez porównywanie wartości r, g i b piksela z odpowiadającymi im wartościami poszczególnych obiektów ustalamy, który z nich został wskazany. Wartościom tym można by nadać typ zmiennoprzecinkowy, GL_FLOAT, lecz ze względu na związane z tym typem zaokrąglenia moglibyśmy otrzymywać niezbyt precyzyjne rezultaty. Dlatego posługujemy się liczbami całkowitymi typu GL_UNSIGNED_BYTE.
I jeszcze jedno… W tej aplikacji scena wygląda tak samo jak w poprzedniej. I podobnie, gdy użytkownik kliknie kostkę, ta zmienia kolor na cyjanowy, tak jak na poniższym rysunku.
84
Rozdział 2. • Wyświetlanie i wskazywanie obiektów 3D
Dowiedz się więcej Zapoznaj się z artykułem na temat zaznaczania obiektów zamieszczonym pod adresem: http://www.lighthouse3d.com/opengl/picking/index.php3?color1.
Wskazywanie obiektów na podstawie ich przecięć z promieniem oka Ostatnia metoda wskazywania obiektów, jaką przeanalizujemy, polega na wysyłaniu promienia w kierunku sceny i za jego pomocą określaniu, który obiekt jest najbliżej kamery. Wykorzystamy tę samą scenę co poprzednio, z trzema kostkami (czerwoną, zieloną i niebieską) umieszczonymi blisko początku układu współrzędnych.
Przygotowania Gotowy kod dla tej receptury znajduje się w folderze Rozdział2/Wskazywanie_PromieńOka, a niezbędne pliki źródłowe są w folderze Rozdział2/src.
Jak to zrobić? Aby zaimplementować wskazywanie obiektów z użyciem promienia oka, wykonaj następujące czynności: 1. Wykonaj rzutowanie wsteczne do przestrzeni obiektu dwóch punktów, które mają te same współrzędne ekranowe (x, HEIGHT-y), ale różne współrzędne z (0 i 1). glm::vec3 start = glm::unProject(glm::vec3(x,HEIGHT-y,0), MV, P, glm::vec4(0,0,WIDTH,HEIGHT)); glm::vec3 end = glm::unProject(glm::vec3(x,HEIGHT-y,1), MV, P, glm::vec4(0,0,WIDTH,HEIGHT));
2. Ustaw bieżące położenie kamery jako początek promienia (eyeRay.origin), a jako jego kierunek (eyeRay.direction) ustaw znormalizowaną różnicę punktów end i start wyznaczonych w poprzednim punkcie. eyeRay.origin = cam.GetPosition(); eyeRay.direction = glm::normalize(end-start);
3. Dla każdego obiektu w scenie znajdź punkt przecięcia promienia z osiowo wyrównanym prostopadłościanem otaczającym (AABB). Zachowaj indeks obiektu z najbliższym punktem przecięcia.
85
OpenGL. Receptury dla programisty
float tMin = numeric_limits::max(); selected_box = -1; for(int i=0;i<3;i++) { glm::vec2 tMinMax = intersectBox(eyeRay, boxes[i]); if(tMinMax.x
Jak to działa? W omawianej tu metodzie najpierw wysyłany jest promień z kamery w kierunku kliknięcia, a następnie wyznaczane są punkty przecięć tego promienia z prostopadłościanami otaczającymi poszczególne obiekty. Zadanie sprowadza się więc do wyznaczenia kierunku promienia i jego przecięć z prostopadłościanami AABB. Na początek przeanalizujemy sposób wyznaczania kierunku promienia. Wiemy już, że po rzutowaniu współrzędne x i y przyjmują wartości z przedziału od –1 do 1. Współrzędna z, czyli głębia, może mieć wartość z przedziału od 0 do 1, przy czym wartość 0 występuje na bliższej płaszczyźnie odcinania, a 1 na dalszej. Tworzymy więc pierwszy punkt, nadając mu współrzędne ekranowe piksela klikniętego i umieszczając go na bliższej płaszczyźnie odcinania. Po zrzutowaniu wstecznym otrzymamy współrzędne tego punktu w przestrzeni obiektu. Analogicznie wyznaczamy drugi punkt, tylko że umieszczamy go na dalszej płaszczyźnie odcinania. Odjęcie punktu pierwszego od drugiego daje nam poszukiwany kierunek promienia. Początek promienia (współrzędne kamery) umieszczamy w zmiennej eyeRay.origin, a kierunek w zmiennej eyeRay.direction. Po wyznaczeniu parametrów promienia ustalamy punkty jego przecięcia z prostopadłościanami otaczającymi poszczególne obiekty w scenie. Jeśli prostopadłościan jest przecinany przez promień i jest to przecięcie najbliższe obserwatora, zapisujemy indeks obiektu. Funkcja inter sectBox wyznaczająca punkty przecięcia jest zdefiniowana w sposób następujący: glm::vec2 intersectBox(const Ray& ray, const Box& cube) { glm::vec3 inv_dir = 1.0f/ray.direction; glm::vec3 tMin = (cube.min - ray.origin) * inv_dir; glm::vec3 tMax = (cube.max - ray.origin) * inv_dir; glm::vec3 t1 = glm::min(tMin, tMax); glm::vec3 t2 = glm::max(tMin, tMax); float tNear = max(max(t1.x, t1.y), t1.z); float tFar = min(min(t2.x, t2.y), t2.z); return glm::vec2(tNear, tFar); }
86
Rozdział 2. • Wyświetlanie i wskazywanie obiektów 3D
I jeszcze jedno… Funkcja intersectBox zaczyna od wyznaczenia punktów przecięcia z parami równoległych płaszczyzn zawierających przeciwległe ścianki prostopadłościanu i wyznaczenia dla każdej pary wartości tNear i tFar. Przecięcie promienia z prostopadłościanem występuje tylko wtedy, gdy dla każdej pary tNear jest mniejsze od tFar. Wyszukiwana jest więc najmniejsza wartość tFar i największa tNear. Jeśli pierwsza z nich jest mniejsza od drugiej, promień nie przecina prostopadłościanu. Więcej informacji na ten temat zawiera materiał wymieniony w punkcie „Dowiedz się więcej”. Omawiana tu aplikacja wykorzystuje tę samą scenę co dwie poprzednie i tak samo jak w tamtych kliknięcie kostki lewym przyciskiem myszy powoduje zaznaczenie tej kostki, co przejawia się zmianą jej koloru na cyjanowy, tak jak na poniższym rysunku.
Dowiedz się więcej Zajrzyj na stronę: http://www.siggraph.org/education/materials/HyperGraph/raytrace/rtinter3.htm.
87
OpenGL. Receptury dla programisty
88
3 Rendering pozaekranowy i mapowanie środowiska W tym rozdziale: Implementacja filtra wirowego przy użyciu shadera fragmentów Renderowanie sześcianu nieba metodą statycznego mapowania sześciennego Implementacja lustra z renderowaniem pozaekranowym przy użyciu FBO Renderowanie obiektów lustrzanych z użyciem dynamicznego mapowania
sześciennego Implementacja filtrowania obrazu (wyostrzania, rozmywania, wytłaczania)
metodą splotu Implementacja efektu poświaty
Wstęp Możliwość renderowania pozaekranowego jest niezwykle ważną cechą każdego nowoczesnego graficznego interfejsu programistycznego. W nowej bibliotece OpenGL zostało to zaimplementowane przy użyciu obiektów bufora ramki (FBO). Wśród zastosowań takiego renderingu można
OpenGL. Receptury dla programisty
wymienić tworzenie efektów postprodukcyjnych, takich jak poświata, dynamiczne mapowanie sześcienne, generowanie odbić, techniki renderingu odroczonego, przetwarzanie obrazów itd. Obecnie niemal w każdej grze funkcja ta jest używana do generowania wspaniałych efektów wizualnych o niezwykłej jakości i szczegółowości. Dzięki obiektom FBO rendering pozaekranowy stał się dużo łatwiejszy, ponieważ programista może używać tych obiektów tak jak każdych innych obiektów z biblioteki OpenGL. W tym rozdziale skoncentrujemy się na wykorzystaniu FBO do realizacji efektów postprodukcyjnych. Będą wśród nich efekty z implementacją cyfrowego splotu, a także odbicia, dynamiczne mapowanie sześcienne i poświata.
Implementacja filtra wirowego przy użyciu shadera fragmentów Zasadniczą część implementacji filtra, czyli deformacje obrazu, umieścimy w shaderze fragmentów, a zatem wszystko będzie wykonywane przez GPU.
Przygotowania Tym razem wykorzystamy kod z receptury poświęconej rysowaniu obrazu zamieszczonej w rozdziale 1. Pełny kod z implementacją filtra znajduje się w folderze Rozdział3/FiltrWirowy.
Jak to zrobić? Aby zaimplementować filtr wirowy, wykonaj następujące czynności: 1. Wczytaj obraz, tak jak w projekcie RysowanieObrazu z rozdziału 1. Ustaw tryb zawijania tekstury na GL_CLAMP_TO_BORDER. int texture_width = 0, texture_height = 0, channels=0; GLubyte* pData = SOIL_load_image(filename.c_str(), &texture_width, &texture_height, &channels, SOIL_LOAD_AUTO); int i,j; for( j = 0; j*2 < texture_height; ++j ) { int index1 = j * texture_width * channels; int index2 = (texture_height - 1 - j) * texture_width * channels; for( i = texture_width * channels; i > 0; --i ) { GLubyte temp = pData[index1]; pData[index1] = pData[index2]; pData[index2] = temp; ++index1; ++index2; }
90
Rozdział 3. • Rendering pozaekranowy i mapowanie środowiska
} glGenTextures(1, &textureID); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, textureID); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, texture_width, texture_height, 0, GL_RGB, GL_UNSIGNED_BYTE, pData); SOIL_free_image_data(pData);
2. Przygotuj prosty shader wierzchołków, który podobnie jak w projekcie RysowanieObrazu da na wyjściu współrzędne tekstury potrzebne dla shadera fragmentów. void main() { gl_Position = vec4(vVertex*2.0-1,0,1); vUV = vVertex; }
3. W shaderze fragmentów najpierw przesuń środek współrzędnych tekstury na środek obrazu, potem poddaj je przekształceniom wirowym i na koniec przywróć im pierwotne położenie środka. void main() { vec2 uv = vUV-0.5; float angle = atan(uv.y, uv.x); float radius = length(uv); angle+= radius*twirl_amount; vec2 shifted = radius* vec2(cos(angle), sin(angle)); vFragColor = texture(textureMap, (shifted+0.5)); }
4. Wyrenderuj dwuwymiarowy czworokąt w przestrzeni ekranu i zastosuj dwa shadery podobnie jak w recepturze RysowanieObrazu z rozdziału 1. void OnRender() { glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); shader.Use(); glUniform1f(shader("twirl_amount"), twirl_amount); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); shader.UnUse(); glutSwapBuffers(); }
91
OpenGL. Receptury dla programisty
Jak to działa? Wir jest prostym przekształceniem deformującym wygląd obrazu. We współrzędnych biegunowych można je zapisać za pomocą następującego wzoru:
Zmienna t oznacza tutaj moc wiru, jakiemu poddawany jest obraz f. W praktyce nasze obrazy są dwuwymiarowymi funkcjami wyrażonymi we współrzędnych kartezjańskich. Aby przejść od współrzędnych kartezjańskich (x, y) do biegunowych ( , r), trzeba dokonać następującego przekształcenia:
W shaderze fragmentów najpierw przesuwamy współrzędne tekstury, tak aby ich początek znalazł się w centrum obrazu. Następnie wyznaczamy kąt i promień r. void main() { vec2 uv = vUV-0.5; float angle = atan(uv.y, uv.x); float radius = length(uv);
Potem zwiększamy kąt o wartość zależną od bieżącego promienia i przechodzimy z powrotem do współrzędnych kartezjańskich. angle+= radius*twirl_amount; vec2 shifted = radius* vec2(cos(angle), sin(angle));
Na koniec przywracamy pierwotne położenie początku współrzędnych tekstury i odtwarzamy obraz. vFragColor = texture(textureMap, (shifted+0.5)); }
I jeszcze jedno… Przykładowa aplikacja po otwarciu wyświetla obraz niezdeformowany. Za pomocą klawiszy + (plus) i – (minus) można zmieniać stopień deformacji (patrz rysunek na następnej stronie). Wybranie trybu GL_CLAMP_TO_BORDER dla zawijania tekstury spowodowało, że wszystkie piksele spoza obrazu otrzymują kolor czarny. W tej recepturze zastosowaliśmy filtr na całej powierzchni obrazu, ale zachęcam do wykonania ćwiczenia polegającego na ograniczeniu efektu do określonego obszaru, powiedzmy koła o promieniu 150 pikseli i środku w centrum obrazu. Podpowiedź: ogranicz promień wiru do podanej wartości.
92
Rozdział 3. • Rendering pozaekranowy i mapowanie środowiska
Renderowanie sześcianu nieba metodą statycznego mapowania sześciennego W tej recepturze zobaczysz, jak można wyrenderować sześcian nieba (skybox), posługując się statycznym mapowaniem sześciennym. Mapowanie sześcienne (cube mapping) jest prostą techniką generowania środowiska sceny. Istniejące metody generowania to: kopuła nieba (sky dome) wykorzystująca geometrię sfery, sześcian nieba (skybox) z geometrią sześcianu i płaszczyzna nieba (skyplane) z geometrią płaszczyzny. Tym razem wybierzemy sześcian nieba ze statyczną odmianą mapowania sześciennego. W procesie mapowania sześciennego potrzebnych jest sześć obrazów umieszczonych na poszczególnych ścianach sześcianu. Sześcian nieba to nic innego jak bardzo duży sześcian, który porusza się wraz z kamerą, ale się z nią nie obraca.
Przygotowania Gotowy kod dla tej receptury znajduje się w folderze Rozdział3/Skybox.
93
OpenGL. Receptury dla programisty
Jak to zrobić? Ogólny plan działania przedstawia się następująco: 1. Przygotuj tablicę wierzchołków i obiekty buforów, aby zapisać w nich geometrię sześcianu. 2. Wczytaj obrazy nieba. W tym celu użyj odpowiedniej biblioteki, np. SOIL. int texture_widths[6]; int texture_heights[6]; int channels[6]; GLubyte* pData[6]; cout<<"Wczytywanie obrazów nieba: ..."<
3. Wygeneruj obiekt tekstury z mapowaniem sześciennym i do jego sześciu punktów wiązania GL_TEXTURE_CUBE_MAP podłącz wczytane wcześniej obrazy nieba. Zadbaj również, aby dane obrazowe po zapisaniu ich w teksturze zostały usunięte. glGenTextures(1, &skyboxTextureID); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTextureID); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); GLint format = (channels[0]==4)?GL_RGBA:GL_RGB; for(int i=0;i<6;i++) { glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, format,texture_widths[i], texture_heights[i], 0, format, GL_UNSIGNED_BYTE, pData[i]); SOIL_free_image_data(pData[i]); }
4. Przygotuj shader wierzchołków (patrz Rozdział3/Skybox/shadery/skybox.vert), który da współrzędne tekstury równe wejściowym współrzędnym wierzchołka w przestrzeni obiektu.
94
Rozdział 3. • Rendering pozaekranowy i mapowanie środowiska
smooth out vec3 uv; void main() { gl_Position = MVP*vec4(vVertex,1); uv = vVertex; }
5. Do shadera fragmentów dodaj sampler mapy sześciennej. Do próbkowania użyj współrzędnych tekstury pochodzących z shadera wierzchołków (patrz Rozdział3/ Skybox/shadery/skybox.frag). layout(location=0) out vec4 vFragColor; uniform samplerCube cubeMap; smooth in vec3 uv; void main() { vFragColor = texture(cubeMap, uv); }
Jak to działa? Receptura ma dwie części. Pierwsza, polegająca na przygotowaniu sześciennej tekstury, jest stosunkowo prosta: wczytujemy sześć obrazów i wiążemy je z sześcioma punktami wiązania kubicznej tekstury odpowiadającymi poszczególnym ścianom sześcianu. Punkty te mają następujące nazwy: GL_TEXTURE_CUBE_MAP_POSITIVE_X, GL_TEXTURE_CUBE_MAP_POSITIVE_Y, GL_TEXTURE_ CUBE_MAP_POSITIVE_Z, GL_TEXTURE_CUBE_MAP_NEGATIVE_X, GL_TEXTURE_CUBE_MAP_NEGATIVE_Y i GL_TEXTURE_CUBE_MAP_NEGATIVE_Z. Ponieważ są one generowane liniowo jeden po drugim, możemy się do nich odwoływać w pętli iteracyjnej przez dodawanie do pierwszego kolejnych wartości zmiennej licznikowej, tak jak w poniższym listingu: for(int i=0;i<6;i++) { glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, format,texture_widths[i], texture_heights[i], 0, format, GL_UNSIGNED_BYTE, pData[i]); SOIL_free_image_data(pData[i]); }
Część druga to shader realizujący próbkowanie tekstury. Jest nim shader fragmentów (Rozdział3/ Skybox/shadery/skybox.frag). W funkcji renderującej ustawiamy macierz MVP i przekazujemy ją do metody renderującej obiektu skybox w celu wyrenderowania sześcianu nieba. glm::mat4 T = glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f,dist)); glm::mat4 Rx = glm::rotate(glm::mat4(1), rX, glm::vec3(1.0f, 0.0f, 0.0f)); glm::mat4 MV = glm::rotate(Rx, rY, glm::vec3(0.0f, 1.0f, 0.0f)); glm::mat4 S = glm::scale(glm::mat4(1),glm::vec3(1000.0)); glm::mat4 MVP = P*MV*S; skybox->Render( glm::value_ptr(MVP));
95
OpenGL. Receptury dla programisty
Do pobierania próbek z właściwych miejsc tekstury sześciennej musimy mieć odpowiednio zdefiniowany wektor. Mogą to być współrzędne położenia wierzchołka w przestrzeni obiektu, które są przekazywane do shadera wierzchołków. Potem trafiają one do shadera fragmentów jako współrzędne tekstury uv. W tej recepturze sześcian nieba wykonujemy przez skalowanie sześcianu jednostkowego. Oczywiście można też utworzyć od razu sześcian o właściwych rozmiarach. Niezależnie od wybranej metody trzeba uważać, żeby nie utworzyć bryły sięgającej poza dalszą płaszczyznę odcinania, bo wtedy nasze niebo zostanie odcięte.
I jeszcze jedno… Przykładowa aplikacja wyświetla statycznie mapowany sześcian nieba, który jest widoczny we wszystkich kierunkach (aby zmienić kierunek patrzenia, należy przeciągnąć myszą z wciśniętym lewym przyciskiem). W ten sposób można stworzyć wrażenie, jakby scena została wkomponowana w rozległe i otaczające ją ze wszystkich stron środowisko (patrz rysunek poniżej).
96
Rozdział 3. • Rendering pozaekranowy i mapowanie środowiska
Implementacja lustra z renderowaniem pozaekranowym przy użyciu FBO Teraz użyjemy FBO, aby wyrenderować obiekt o lustrzanej powierzchni. W typowej aplikacji z renderingiem pozaekranowym najpierw przygotowujemy obiekty bufora ramki, wywołując w tym celu funkcję glGenFramebuffers i przekazując jej jako argument liczbę potrzebnych obiektów. Drugi parametr to tablica, w której zostaną zapisane zwrócone identyfikatory tych obiektów. Po wygenerowaniu identyfikator FBO musi być powiązany z typem obiektu GL_FRAME BUFFER, GL_DRAW_FRAMEBUFFER lub GL_READ_FRAMEBUFFER. Wtedy dopiero można podłączyć teksturę do przyłącza koloru w FBO, a służy do tego funkcja glFramebufferTexture2D. FBO może mieć więcej niż jedno przyłącze koloru (GL_COLOR_ATTACHMENT), a konkretną ich liczbę dla danego GPU można uzyskać, odczytując pole GL_MAX_COLOR_ATTACHMENTS. Dla przyłączanej tekstury trzeba określić jej typ i wymiary, przy czym te ostatnie nie muszą się pokrywać z wymiarami okna. Jednak we wszystkich przyłączach koloru w danym FBO obowiązują takie same wymiary tekstur. W danej chwili może istnieć tylko jeden FBO dla operacji rysowania i jeden dla operacji odczytu. FBO ma także przyłącza głębi i szablonu. Schematycznie przedstawia to poniższy rysunek.
Jeśli wymagane jest testowanie głębi, należy wygenerować także identyfikator bufora renderingu i powiązać go z obiektem takiego bufora — trzeba więc wywołać kolejno funkcje glGenRender buffers i glBindRenderbuffer. Dla bufora renderingu z informacjami o głębi trzeba określić format tych informacji wymiary bufora. Po tym wszystkim przyłączamy bufor renderingu do bufora ramki za pomocą funkcji glFramebufferRenderbuffer.
97
OpenGL. Receptury dla programisty
Po ustawieniu obiektów buforów ramki i renderingu należy sprawdzić status kompletności bufora ramki przez wywołanie funkcji glCheckFramebufferStatus z parametrem określającym typ bufora. Funkcja sprawdza kompletność bufora i zwraca jego status. Jeśli zwrócona wartość różni się od GL_FRAMEBUFFER_COMPLETE, to znaczy, że bufor jest niekompletny. Nigdy nie zapominaj o sprawdzeniu kompletności FBO po przyłączeniu do niego innych buforów.
Podobnie jak w przypadku innych obiektów OpenGL obiekty bufora ramki i renderingu, a także wszelkie obiekty tekstur użyte do renderingu pozaekranowego, gdy nie są już potrzebne, należy usunąć za pomocą funkcji glDeleteFramebuffers i glDeleteRenderbuffers. Tak w skrócie wygląda proces renderowania pozaekranowego w nowoczesnym OpenGL.
Przygotowania Gotowy kod dla tej receptury znajduje się w folderze Rozdział3/LustroFBO.
Jak to zrobić? Aby zaimplementować lustro przy użyciu FBO, wykonaj następujące czynności: 1. Zainicjalizuj przyłącza koloru i głębi w obiektach buforów, odpowiednio, ramki i renderingu. Bufor renderingu jest potrzebny, bo musimy przeprowadzać test głębi dla renderingu pozaekranowego. Precyzję głębi ustalamy za pomocą funkcji glRenderbufferStorage. glGenFramebuffers(1, &fboID); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fboID); glGenRenderbuffers(1, &rbID); glBindRenderbuffer(GL_RENDERBUFFER, rbID); glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT32,WIDTH, HEIGHT);
2. Wygeneruj pozaekranową teksturę, do której FBO będzie renderował. Ostatniemu parametrowi funkcji glTexImage2D nadaj wartość NULL, ponieważ na razie nie ma jeszcze żadnej zawartości, ale zarezerwuj nowy blok pamięci GPU dla danych, które się pojawią, gdy rozpocznie się renderowanie. glGenTextures(1, &renderTextureID); glBindTexture(GL_TEXTURE_2D, renderTextureID); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, L_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, WIDTH, HEIGHT, 0, GL_BGRA, GL_UNSIGNED_BYTE, NULL);
98
Rozdział 3. • Rendering pozaekranowy i mapowanie środowiska
3. Przyłącz bufor renderingu do bufora ramki i sprawdź kompletność tego ostatniego. glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D, renderTextureID, 0); glFramebufferRenderbuffer(GL_DRAW_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, rbID); GLuint status = glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER); if(status==GL_FRAMEBUFFER_COMPLETE) { printf("FBO setup succeeded."); } else { printf("Error in FBO setup."); }
4. Odwiąż obiekt bufora ramki w sposób następujący: glBindTexture(GL_TEXTURE_2D, 0); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
5. Przygotuj geometrię dla czworokątnego lustra. mirror = new CQuad(-2);
6. W zwykły sposób wyrenderuj scenę z punktu widzenia kamery. Żeby lustrzane odbicie kolorowej kostki było lepiej widoczne, przed renderowaniem przesuń ją ze środka układu współrzędnych w stronę dodatnich wartości Y. glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); grid->Render(glm::value_ptr(MVP)); localR[3][1] = 0.5; cube->Render(glm::value_ptr(P*MV*localR));
7. Zapisz bieżącą macierz modelu i widoku, a następnie zmień ją tak, aby kamera znalazła się w tym samym miejscu co obiekt lustra. I koniecznie odwróć ją, wykonując skalowanie ze współczynnikiem –1 względem osi X. glm::mat4 oldMV = MV; glm::vec3 target; glm::vec3 V = glm::vec3(-MV[2][0], -MV[2][1], -MV[2][2]); glm::vec3 R = glm::reflect(V, mirror->normal); MV = glm::lookAt(mirror->position, mirror->position + R, glm::vec3(0,1,0)); MV = glm::scale(MV, glm::vec3(-1,1,1));
8. Zwiąż FBO, ustaw bufor rysowania jako przyłącze koloru (GL_COLOR_ATTACHMENT0) lub jakiekolwiek inne, do którego można przyłączyć teksturę, i wyczyść bufory głębi i koloru. Funkcja glDrawbuffer umożliwia kierowanie rezultatów rysowania do określonego przyłącza koloru w FBO. W naszym przykładzie mamy jedno takie przyłącze, więc ustawiamy je jako bufor rysowania. glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fboID); glDrawBuffer(GL_COLOR_ATTACHMENT0); glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);
99
OpenGL. Receptury dla programisty
9. Ponownie wyrenderuj scenę z nową macierzą widoku, ale tylko po jasnej stronie lustra. if(glm::dot(V,mirror->normal)<0) { grid->Render(glm::value_ptr(P*MV)); cube->Render(glm::value_ptr(P*MV*localR)); }
10. Rozwiąż FBO i przywróć domyślny bufor rysowania (GL_BACK_LEFT). glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); glDrawBuffer(GL_BACK_LEFT); Zauważ, że tylny bufor może przyjmować różne nazwy. Ten najczęściej używany tak naprawdę nazywa się GL_BACK_LEFT, ale bywa też określany jako GL_BACK. Domyślny bufor ramki ma aż cztery bufory koloru o nazwach GL_FRONT_LEFT, GL_FRONT_RIGHT, GL_BACK_LEFT i GL_BACK_RIGHT. Jeśli nie jest włączone renderowanie stereo, aktywne są tylko bufory lewe, czyli GL_FRONT_LEFT (jako przedni bufor koloru) i GL_BACK_LEFT (jako tylny bufor koloru).
11. Na koniec wyrenderuj czworokąt lustra, używając zapisanej wcześniej matrycy modelu i widoku. MV = oldMV; glBindTexture(GL_TEXTURE_2D, renderTextureID); mirror->Render(glm::value_ptr(P*MV));
Jak to działa? Algorytm lustra zastosowany w tej recepturze jest bardzo prosty. Najpierw na podstawie macierzy widoku wyznaczamy wektor kierunku patrzenia (V). Tworzymy jego lustrzane odbicie względem wektora prostopadłego do powierzchni lustra (N). Następnie przesuwamy kamerę za lustro. Na koniec skalujemy macierz modelu i widoku względem osi X, stosując współczynnik skali o wartości -1. Wszystko to sprawia, że obraz jest odwrócony dokładnie tak jak w lustrze. Szczegółowy opis tego algorytmu można znaleźć w materiałach przytoczonych w punkcie „Dowiedz się więcej”.
I jeszcze jedno… Szczegółowy opis obiektu bufora ramki zawiera jego specyfikacja (patrz punkt „Dowiedz się więcej”). Przykładowy rezultat działania omawianej aplikacji przedstawia rysunek na następnej stronie.
100
Rozdział 3. • Rendering pozaekranowy i mapowanie środowiska
Dowiedz się więcej Oficjalna specyfikacja FBO jest dostępna pod adresem: http://www.opengl.org/
registry/specs/EXT/framebuffer_object.txt. Zajrzyj do książki Richarda S. Wrighta pod tytułem OpenGL. Księga eksperta.
Wydanie V, wydanej przez Helion w 2011 roku. Artykuł Songa Ho Ahna poświęcony zastosowaniom FBO, zamieszczony
pod adresem: http://www.songho.ca/opengl/gl_fbo.html.
Renderowanie obiektów lustrzanych z użyciem dynamicznego mapowania sześciennego Teraz zastosujemy dynamiczne mapowanie sześcienne, aby w czasie rzeczywistym wyrenderować scenę do mapy sześciennej. Technika ta umożliwia tworzenie powierzchni lustrzanych, które odbijają swoje otoczenie. W nowoczesnym OpenGL takie renderowanie pozaekranowe (zwane również renderowaniem do tekstury) jest realizowane za pomocą obiektów bufora ramki. 101
OpenGL. Receptury dla programisty
Przygotowania W tym przykładzie wyrenderujemy kulę z otaczającymi ją cząstkami. Gotowy kod znajduje się w folderze Rozdział3/DynamicznaMapaSześcienna.
Jak to zrobić? Zacznij w sposób następujący: 1. Utwórz obiekt tekstury sześciennej. glGenTextures(1, &dynamicCubeMapID); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_CUBE_MAP, dynamicCubeMapID); glTexParameterf(GL_TEXTURE_CUBE_MAP,GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameterf(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameterf(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameterf(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameterf(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); for (int face = 0; face < 6; face++) { glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA,CUBEMAP_SIZE, CUBEMAP_SIZE, 0, GL_RGBA, GL_FLOAT, NULL); }
2. Utwórz FBO z przyłączem dla tekstury sześciennej. glGenFramebuffers(1, &fboID); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fboID); glGenRenderbuffers(1, &rboID); glBindRenderbuffer(GL_RENDERBUFFER, rboID); glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT, CUBEMAP_SIZE, CUBEMAP_SIZE); glFramebufferRenderbuffer(GL_DRAW_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, fboID); glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X, dynamicCubeMapID, 0); GLenum status = glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER); if(status != GL_FRAMEBUFFER_COMPLETE) { cerr<<"Błąd ustawienia obiektu bufora ramki."<
102
Rozdział 3. • Rendering pozaekranowy i mapowanie środowiska
3. Ustaw wymiary okna widokowego takie jak dla tekstury pozaekranowej i używając FBO, wyrenderuj scenę (bez obiektu lustrzanego) sześć razy do sześciu warstw tekstury sześciennej. glViewport(0,0,CUBEMAP_SIZE,CUBEMAP_SIZE); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fboID); glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X, dynamicCubeMapID, 0); glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); glm::mat4 MV1 = glm::lookAt(glm::vec3(0),glm::vec3(1,0,0), glm::vec3(0,-1,0)); DrawScene( MV1*T, Pcubemap); glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_NEGATIVE_X, dynamicCubeMapID, 0); glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); glm::mat4 MV2 = glm::lookAt(glm::vec3(0),glm::vec3(-1,0,0), glm::vec3(0,-1,0)); DrawScene( MV2*T, Pcubemap); ...//podobnie dla pozostałych ścian glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
4. Przywróć pierwotne wymiary okna widokowego i wyrenderuj scenę w zwykły sposób. glViewport(0,0,WIDTH,HEIGHT); DrawScene(MV, P);
5. Przygotuj shader mapy sześciennej i wyrenderuj obiekt błyszczący. glBindVertexArray(sphereVAOID); cubemapShader.Use(); T = glm::translate(glm::mat4(1), p); glUniformMatrix4fv(cubemapShader("MVP"), 1, GL_FALSE, glm::value_ptr(P*(MV*T))); glUniform3fv(cubemapShader("eyePosition"), 1, glm::value_ptr(eyePos)); glDrawElements(GL_TRIANGLES,indices.size(), GL_UNSIGNED_SHORT, 0); cubemapShader.UnUse();
Jak to działa? W dynamicznym mapowaniu sześciennym scena jest renderowana sześciokrotnie przez sześć kamer umieszczonych w miejscu zajmowanym przez obiekt lustrzany. Renderowanie do tekstury sześciennej wykonuje się przy użyciu FBO wyposażonego w przyłącze dla takiej tekstury. Jej warstwa GL_TEXTURE_CUBE_MAP_POSITIVE_X jest łączona z przyłączem koloru GL_COLOR_ATTACHMENT0 w FBO. Ostatni parametr funkcji glTexImage2D ma wartość NULL, ponieważ to wywołanie ma na celu jedynie zarezerwowanie pamięci dla renderingu pozaekranowego, a rzeczywiste dane znajdą się tam dopiero wtedy, gdy FBO stanie się celem renderingu.
103
OpenGL. Receptury dla programisty
Następnie scena jest renderowana do tekstury sześciennej przez sześć kamer ustawionych w miejscu zajmowanym przez obiekt lustrzany i zwróconych w sześciu kierunkach (sam obiekt lustrzany jest pomijany w tym renderingu). Macierz rzutowania dla mapy sześciennej (Pcubmap) ma ustawiony kąt widzenia 90°. Pcubemap = glm::perspective(90.0f,1.0f,0.1f,1000.0f);
Dla każdej strony tekstury sześciennej wyznaczana jest nowa macierz MVP będąca iloczynem wspomnianej wyżej macierzy rzutowania i nowej macierzy MV (uzyskanej za pomocą funkcji glm::lookAt). Po wyrenderowaniu wszystkich sześciu stron tekstury następuje zwykłe renderowanie sceny, a na koniec renderowany jest obiekt lustrzany z użyciem wygenerowanej tekstury sześciennej, co daje efekt odbijania otoczenia. Sześciokrotne pozaekranowe renderowanie każdej ramki spowalnia działanie aplikacji — tym mocniej, im bardziej skomplikowana jest scena — więc należy raczej ostrożnie stosować tę technikę. Shader wierzchołków mapy sześciennej wyznacza położenia i wektory normalne wierzchołków w przestrzeni obiektu. #version 330 core layout(location=0) in vec3 vVertex; layout(location=1) in vec3 vNormal; uniform mat4 MVP; smooth out vec3 position; smooth out vec3 normal; void main() { position = vVertex; normal = vNormal; gl_Position = MVP*vec4(vVertex,1); }
Shader fragmentów mapy sześciennej wykorzystuje te położenia wierzchołków do wyznaczenia wektora kierunku patrzenia. Następnie wyznaczany jest wektor odbicia jako lustrzane odbicie wektora patrzenia względem wektora normalnego. #version 330 core layout(location=0) out vec4 vFragColor; uniform samplerCube cubeMap; smooth in vec3 position; smooth in vec3 normal; uniform vec3 eyePosition; void main() { vec3 N = normalize(normal); vec3 V = normalize(position-eyePosition); vFragColor = texture(cubeMap, reflect(V,N)); }
104
Rozdział 3. • Rendering pozaekranowy i mapowanie środowiska
I jeszcze jedno… Przykładowa implementacja powyższej receptury renderuje lustrzaną kulę i osiem pulsujących kostek wokół niej, co widać na poniższym rysunku.
W tej recepturze moglibyśmy również zastosować rendering warstwowy, zmuszając shader geometrii do kierowania danych wyjściowych na różne warstwy obiektu bufora ramki. Aby to osiągnąć, należy kierować wyjście shadera geometrii do odpowiedniego atrybutu gl_Layer i stosować odpowiednie transformacje widoku. Zachęcam Czytelnika do samodzielnego wykonania takiego ćwiczenia.
Dowiedz się więcej Poczytaj o renderingu warstwowym w serwisie OpenGL wiki, pod adresem:
http://www.opengl.org/wiki/Geometry_Shader#Layered_rendering. Przejrzyj artykuł Songa Ho Ahna na temat FBO, zamieszczony pod adresem:
http://www.songho.ca/opengl/gl_fbo.html.
105
OpenGL. Receptury dla programisty
Implementacja filtrowania obrazu (wyostrzania, rozmywania, wytłaczania) metodą splotu Zobaczmy teraz, jak przeprowadza się obszarowe filtrowanie, czyli splot (konwolucję) dwuwymiarowego obrazu w celu uzyskania efektów takich jak wyostrzenie, rozmycie czy wytłoczenie. Istnieje kilka metod wykonywania splotu obrazu w dziedzinie przestrzennej. Najprostsza polega na zastosowaniu pętli przebiegającej przez wszystkie piksele określonego fragmentu obrazu i obliczającej sumę iloczynów jasności obrazu i jądra splotu. Metodą wydajniejszą, przynajmniej z implementacyjnego punktu widzenia, jest konwolucja separowalna, w której splot dwuwymiarowy jest rozbijany na dwa sploty jednowymiarowe. Takie podejście wymaga jednak dodatkowego przebiegu.
Przygotowania Niniejsza receptura stanowi rozwinięcie receptury wczytującej obraz, która była omawiana w rozdziale 1. Jeśli nie pamiętasz, prześledź ją jeszcze raz, bo wtedy łatwiej zrozumiesz to, co teraz będziemy robić. Gotowy kod znajdziesz w folderze Rozdział3/Splot. Najważniejsze partie są zawarte w shaderze fragmentów.
Jak to zrobić? Rozpocznij w sposób następujący: 1. Przygotuj prosty shader wierzchołków, który na wyjściu da położenie wierzchołka w przestrzeni przycięcia i współrzędne tekstury potrzebne shaderowi fragmentów. #version 330 core in vec2 vVertex; out vec2 vUV; void main() { gl_Position = vec4(vVertex*2.0-1,0,1); vUV = vVertex; }
2. W shaderze fragmentów zadeklaruj stałą tablicę o nazwie kernel, w której będzie przechowywane jądro splotu. Od zawartości tego jądra będzie zależał wyjściowy rezultat splotu. Jako domyślne wprowadź tam wartości odpowiadające filtrowi wyostrzania. Szczegóły znajdziesz w pliku Rozdział3/Splot/shadery/shader_ convolution.frag.
106
Rozdział 3. • Rendering pozaekranowy i mapowanie środowiska
const float kernel[]=float[9] (-1,-1,-1, -1, 8,-1, -1,-1,-1);
3. Następnie utwórz zagnieżdżoną pętlę przebiegającą najbliższe otoczenie bieżącego piksela i mnożącą wartości jądra przez wartości odpowiednich pikseli. Wszystko to powinno się odbywać w obszarze o wymiarach n×n, gdzie n jest szerokością (i wysokością) jądra. for(int j=-1;j<=1;j++) { for(int i=-1;i<=1;i++) { color += kernel[index--] * texture(textureMap, vUV+(vec2(i,j)*delta)); } }
4. Potem podziel uzyskaną wartość koloru przez liczbę wartości w jądrze. Dla jądra o wymiarach 3×3 liczba ta wynosi 9. Na koniec dodaj splecioną wartość do bieżącej wartości rozpatrywanego piksela. color/=9.0; vFragColor = color + texture(textureMap, vUV);
Jak to działa? W wyniku konwolucji o jądrze h(x,y) dwuwymiarowy obraz f(x,y) zmienia się w obraz g(x,y) zdefiniowany w sposób następujący:
Da każdego piksela po prostu sumujemy z określonego jego otoczenia iloczyny wartości poszczególnych pikseli i odpowiadających im wartości jądra. Bardziej szczegółowe informacje na temat jądra splotu i jego współczynników można znaleźć w wielu tekstach poświęconych przetwarzaniu obrazów cyfrowych, jak chociażby w tych, które wymieniam w punkcie „Dowiedz się więcej”. Ogólny algorytm wygląda następująco: Przygotowujemy FBO do renderingu pozaekranowego. Renderujemy obraz nie do tylnego bufora ekranu, ale do pozaekranowego celu renderingu w FBO. Teraz obraz jest w jednym z przyłączy FBO. Na tym kończy się etap pierwszy, a jego rezultat (czyli wyrenderowany obraz w przyłączu FBO) jest przekazywany na wejście shadera konwolucji i tu rozpoczyna się etap drugi. W buforze tylnym renderujemy pełnoekranowy czworokąt i poddajemy go działaniu shadera konwolucji. Tym samym operacja splotu jest przeprowadzana na obrazie wejściowym. Na koniec zamieniamy bufory ekranu i wyświetlamy uzyskany rezultat. Po wczytaniu obrazu i wygenerowaniu tekstury renderujemy dopasowany do ekranu czworokąt, aby shader fragmentów mógł działać na całej powierzchni ekranu. W shaderze tym dla każdego
107
OpenGL. Receptury dla programisty
fragmentu obliczana jest suma iloczynów odpowiadających sobie wartości z jądra splotu i z obrazu. Następnie suma ta jest dzielona przez liczbę współczynników jądra i w końcu dodawana do aktualnej wartości piksela. Jądro splotu może przyjmować różne postaci, a te, które wykorzystujemy w naszej przykładowej aplikacji, są podane w poniższej tabeli. Wynik splotu zależy również od przyjętego trybu zawijania tekstury, np. GL_CLAMP lub GL_REPEAT. W trybie GL_CLAMP piksele spoza obrazu nie są brane pod uwagę, a w trybie GL_REPEAT wartości takich pikseli są wyznaczane zgodnie z przyjętą metodą zawijania.
Efekt Wyostrzenie
Rozmycie (wygładzanie jednorodne)
Rozmycie gaussowskie 3×3
Wytłoczenie w kierunku północno-zachodnim
Wytłoczenie w kierunku północno-wschodnim
Wytłoczenie w kierunku południowo-wschodnim
Wytłoczenie w kierunku południowo-zachodnim
108
Macierz jądra
Rozdział 3. • Rendering pozaekranowy i mapowanie środowiska
I jeszcze jedno… Temat splotu obrazu cyfrowego został jedynie zasygnalizowany. Więcej informacji znajdziesz w publikacjach wymienionych w punkcie „Dowiedz się więcej”. W aplikacji przykładowej użytkownik może sam zdefiniować jądro splotu, aby po wciśnięciu klawisza spacji zobaczyć przefiltrowany obraz. Ponowne wciśnięcie spacji przywraca widok obrazu oryginalnego.
Dowiedz się więcej Zapoznaj się z następującymi publikacjami: Rafael C. Gonzalez i Richard E. Woods, Digital Image Processing. Third Edition,
Prentice Hall. Artykuł Songa Ho Ahna poświęcony FBO, zamieszczony pod adresem:
http://www.songho.ca/opengl/gl_fbo.html.
Implementacja efektu poświaty Skoro wiemy już, jak przeprowadza się rendering pozaekranowy i jak można rozmyć obraz, spróbujmy wykorzystać tę wiedzę do zaimplementowania efektu poświaty. Gotowy kod znajduje się w folderze Rozdział3/Poświata. Tym razem wyrenderujemy zbiór punktów otaczających kostkę. Po każdych 50 klatkach animacji inna czwórka cząstek będzie emitować poświatę.
Jak to zrobić? Rozpocznij w sposób następujący: 1. W zwykły sposób wyrenderuj scenę z kostką i cząstkami. Zadaniem shadera cząstek (particleShader) jest renderowanie cząstek w postaci kółek (domyślnie są czworokątami). grid->Render(glm::value_ptr(MVP)); cube->Render(glm::value_ptr(MVP)); glBindVertexArray(particlesVAO); particleShader.Use(); glUniformMatrix4fv(particleShader("MVP"), 1, GL_FALSE, glm::value_ptr(MVP*Rot)); glDrawArrays(GL_POINTS, 0, 8);
Cząsteczkowy shader wierzchołków wygląda następująco: #version 330 core layout(location=0) in vec3 vVertex; uniform mat4 MVP;
109
OpenGL. Receptury dla programisty
smooth out vec4 color; const vec4 colors[8]=vec4[8](vec4(1,0,0,1), vec4(0,1,0,1), vec4(0,0,1,1),vec4(1,1,0,1), vec4(0,1,1,1), vec4(1,0,1,1), vec4(0.5,0.5,0.5,1), vec4(1,1,1,1)) ; void main() { gl_Position = MVP*vec4(vVertex,1); color = colors[gl_VertexID/4]; }
Cząsteczkowy shader fragmentów wygląda następująco: #version 330 core layout(location=0) out vec4 vFragColor; smooth in vec4 color; void main() { vec2 pos = gl_PointCoord-0.5; if(dot(pos,pos)>0.25) discard; else vFragColor = color; }
2. Przygotuj FBO z dwoma przyłączami koloru — jednym do renderowania elementów z poświatą i drugim do rozmywania obrazu. glGenFramebuffers(1, &fboID); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fboID); glGenTextures(2, texID); glActiveTexture(GL_TEXTURE0); for(int i=0;i<2;i++) { glBindTexture(GL_TEXTURE_2D, texID[i]); glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameterf(GL_TXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, RENDER_TARGET_WIDTH, RENDER_TARGET_HEIGHT, 0, GL_RGBA,GL_UNSIGNED_BYTE, NULL); glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER,GL_COLOR_ATTACHMENT0+i, GL_TEXTURE_2D,texID[i],0); } GLenum status = glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER); if(status != GL_FRAMEBUFFER_COMPLETE) { cerr<<"Blad ustawienia bufora ramki."<
110
Rozdział 3. • Rendering pozaekranowy i mapowanie środowiska
3. Zwiąż FBO, ustaw wymiary okna widokowego równe wymiarom przyłączonej tekstury, ustaw bufor rysowania (Drawbuffer) na renderowanie do pierwszego przyłącza koloru (GL_COLOR_ATTACHMENT0) i wyrenderuj tę część sceny, w której ma wystąpić poświata. glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fboID); glViewport(0,0,RENDER_TARGET_WIDTH,RENDER_TARGET_HEIGHT); glDrawBuffer(GL_COLOR_ATTACHMENT0); glClear(GL_COLOR_BUFFER_BIT); glDrawArrays(GL_POINTS, offset, 4); particleShader.UnUse();
4. Ustaw bufor rysowania na renderowanie do drugiego przyłącza koloru (GL_COLOR_ ATTACHMENT1) i zwiąż teksturę FBO przyłączoną do pierwszego przyłącza koloru. Przygotuj shader rozmycia z prostym filtrem wygładzającym. glDrawBuffer(GL_COLOR_ATTACHMENT1); glBindTexture(GL_TEXTURE_2D, texID[0]);
5. Wyrenderuj pełnoekranowy czworokąt i zastosuj shader rozmycia do rezultatu renderingu z pierwszego przyłącza koloru w FBO. Wynik jest zapisywany do drugiego przyłącza koloru. blurShader.Use(); glBindVertexArray(quadVAOID); glDrawElements(GL_TRIANGLES,6,GL_UNSIGNED_SHORT,0);
6. Wyłącz renderowanie FBO, przywróć domyślny bufor rysowania (GL_BACK_LEFT) i okno widokowe, zwiąż teksturę przyłączoną do drugiego przyłącza koloru w FBO, narysuj pełnoekranowy czworokąt i zmieszaj rozmyty obraz z bieżącą sceną, stosując mieszanie addytywne. glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); glDrawBuffer(GL_BACK_LEFT); glBindTexture(GL_TEXTURE_2D, texID[1]); glViewport(0,0,WIDTH, HEIGHT); glEnable(GL_BLEND); glBlendFunc(GL_ONE, GL_ONE); glDrawElements(GL_TRIANGLES,6,GL_UNSIGNED_SHORT,0); glBindVertexArray(0); blurShader.UnUse(); glDisable(GL_BLEND);
Jak to działa? Efekt poświaty jest tu realizowany przez wyrenderowanie wytypowanych elementów do oddzielnego celu renderingu, rozmycie wyrenderowanego obrazu za pomocą filtra wygładzającego, a następnie zmieszanie go addytywnie z bieżącym renderingiem sceny w buforze ramki, tak jak to zostało pokazane na rysunku na następnej stronie.
111
OpenGL. Receptury dla programisty
Mieszanie moglibyśmy włączyć również w shaderze fragmentów. Zakładając, że oba obrazy przeznaczone do zmieszania są związane z jednostkami teksturującymi, a ich samplery shaderowe to texture1 i texture2, kod shadera wykonującego mieszanie addytywne wyglądałby następująco: #version 330 core uniform sampler2D texture1; uniform sampler2D texture2; layout(location=0) out vec4 vFragColor; smooth in vec2 vUV; void main() { vec4 color1 = texture(texture1, vUV); vec4 color2 = texture(texture2, vUV); vFragColor = color1+color2; }
Moglibyśmy także zastosować konwolucję separowalną, ale to wymaga dwóch przebiegów. Potrzebne są do tego trzy przyłącza koloru. Najpierw renderujemy scenę w zwykły sposób na pierwszym przyłączu, a obiekty z poświatą renderujemy na przyłączu drugim. Następnie ustawiamy jako cel renderingu przyłącze trzecie, a jako źródło danych wejściowych — przyłącze drugie. Renderujemy pełnoekranowy czworokąt z zastosowaniem shadera wygładzającego obraz w kierunku pionowym (iteracja odbywa się tylko po rzędach pikseli). Rezultat tego renderingu jest zapisywany do przyłącza trzeciego.
112
Rozdział 3. • Rendering pozaekranowy i mapowanie środowiska
Potem ustawiamy jako wyjście przyłącze drugie, a dane wejściowe pobieramy z przyłącza trzeciego, gdzie są zapisane rezultaty wygładzania pionowego. Wtedy uruchamiamy shader z rozmywaniem poziomym, który działa na kolumnach pikseli. Rozmyty w ten sposób obraz jest zapisywany do przyłącza drugiego. Na koniec shader mieszający łączy zawartość przyłącza pierwszego z zawartością przyłącza drugiego. Taki sam efekt można by uzyskać, stosując dwa oddzielne obiekty bufora ramki — renderujący i filtrujący. Zyskalibyśmy przy tym możliwość próbkowania w dół filtrowanego obrazu i wykorzystania sprzętowego filtrowania liniowego. Technika ta została użyta w recepturze „Implementacja wariancyjnego mapowania cieni” opisanej w rozdziale 4.
I jeszcze jedno… Przykładowa aplikacja wyświetla zwykłą kostkę otoczoną przez osiem cząstek, z których pierwsze cztery mają kolor czerwony, a pozostałe — zielony. Początkowo poświatę emitują cząstki czerwone, a po 50 klatkach animacji następuje zmiana i świecić zaczynają cząstki zielone. Cykl taki powtarza się przez cały czas działania aplikacji. Jedna z klatek animacji jest pokazana na poniższym rysunku.
113
OpenGL. Receptury dla programisty
Dowiedz się więcej Przestudiuj dodatkowo: Przykład implementacji poświaty w NVIDIA OpenGL SDK v10. Artykuł Songa Ho Ahna poświęcony FBO, zamieszczony pod adresem:
http://www.songho.ca/opengl/gl_fbo.html.
114
4 Światła i cienie W tym rozdziale: Implementacja oświetlenia punktowego na poziomie wierzchołków i fragmentów Implementacja światła kierunkowego na poziomie fragmentów Implementacja zanikającego światła punktowego na poziomie fragmentów Implementacja oświetlenia reflektorowego na poziomie fragmentów Mapowanie cieni przy użyciu FBO Mapowanie cieni z filtrowaniem PCF Wariancyjne mapowanie cieni
Wstęp Tak jak w świecie rzeczywistym również w wirtualnym nie zobaczymy nic, jeśli nie będzie w nim jakiegoś oświetlenia. Każda aplikacja wizualna, aby była kompletna, musi zapewnić obecność wirtualnych źródeł światła. Mogą być rozmaite, np. punktowe, kierunkowe, reflektorowe itp. Wszystkie źródła mają pewne cechy wspólne, takie jak położenie, ale mają też właściwości specyficzne dla konkretnego rodzaju, np. reflektory mają określony kierunek emisji światła i współczynnik rozkładu intensywności. W tym rozdziale zajmiemy się implementowaniem poszczególnych źródeł światła bądź to na etapie cieniowania wierzchołków, bądź na etapie cieniowania fragmentów. Symulowanie samego światła to jeszcze nie wszystko. W świecie rzeczywistym przywykliśmy do tego, że przedmioty oświetlone rzucają cienie, których brak sprawiłby, iż scena wyglądałaby nienaturalnie. Poza tym brak cieni utrudnia ocenę odległości między przedmiotami. Dlatego też dość szczegółowo przeanalizujemy rozmaite techniki generowania cieni, począwszy od klasycznego mapowania na podstawie testu głębi aż po zaawansowane mapowanie wariancyjne.
OpenGL. Receptury dla programisty
Wszystko to będziemy implementować w ramach biblioteki OpenGL 3.3, a dokładne wskazówki umożliwią Czytelnikowi samodzielne wykonanie każdego przykładu.
Implementacja oświetlenia punktowego na poziomie wierzchołków i fragmentów Aby zwiększyć realizm trójwymiarowych scen, wprowadzamy do nich oświetlenie. W dawnym potoku graficznym OpenGL oświetlenie było wyznaczane na poziomie wierzchołków (w wersji 3.3 i późniejszych zostało to wycofane). Za pomocą shaderów możemy nie tylko odtworzyć tamten sposób oświetlania, ale także pójść dalej i zaimplementować oświetlenie na poziomie fragmentów. Pierwsza metoda jest znana jako cieniowanie Gourauda, a druga jako cieniowanie Phonga. Lecz żeby nie tracić czasu na przydługie wstępy, zabierzmy się do pracy.
Przygotowania Tym razem wyrenderujemy sferę i kilka kostek. Wszystkie te obiekty zostaną wygenerowane i umieszczone w buforach. Szczegóły zawierają funkcje CreateSphere i CreateCube zdefiniowane w pliku Rozdział4/OświetlanieWierzchołków/main.cpp. Generują one nie tylko położenia wierzchołków, ale również ich normalne, tak bardzo potrzebne przy obliczaniu oświetlenia. Wszystkie obliczenia oświetleniowe w recepturze oświetlenia wierzchołkowego (Rozdział4/ OświetlanieWierzchołków/) są przeprowadzane przez shader wierzchołków, a w recepturze oświetlenia fragmentowego (Rozdział4/OświetlanieFragmentów/) wykonuje je shader fragmentów.
Jak to zrobić? Zacznij od następujących prostych czynności: 1. Przygotuj shader wierzchołków wykonujący obliczenia związane z oświetleniem w przestrzeni widoku (oka). Po tych obliczeniach powinien być wygenerowany kolor wierzchołka. #version 330 core layout(location=0) in vec3 vVertex; layout(location=1) in vec3 vNormal; uniform mat4 MVP; uniform mat4 MV; uniform mat3 N; uniform vec3 light_position; //położenie światła w przestrzeni obiektu uniform vec3 diffuse_color; uniform vec3 specular_color; uniform float shininess; smooth out vec4 color;
116
Rozdział 4. • Światła i cienie
const vec3 vEyeSpaceCameraPosition = vec3(0,0,0); void main() { vec4 vEyeSpaceLightPosition = MV*vec4(light_position,1); vec4 vEyeSpacePosition = MV*vec4(vVertex,1); vec3 vEyeSpaceNormal = normalize(N*vNormal); vec3 L = normalize(vEyeSpaceLightPosition.xyz – vEyeSpacePosition.xyz); vec3 V = normalize(vEyeSpaceCameraPosition.xyzvEyeSpacePosition.xyz); vec3 H = normalize(L+V); float diffuse = max(0, dot(vEyeSpaceNormal, L)); float specular = max(0, pow(dot(vEyeSpaceNormal, H), shininess)); color = diffuse*vec4(diffuse_color,1) + specular*vec4(specular_color, 1); gl_Position = MVP*vec4(vVertex,1); }
2. Przygotuj shader fragmentów, który będzie pobierał kolor z shadera wierzchołków i po interpolacji przekaże go do wyjścia jako bieżący kolor fragmentu. #version 330 core layout(location=0) out vec4 vFragColor; smooth in vec4 color; void main() { vFragColor = color; }
3. W kodzie renderującym uruchom shader i wyrenderuj obiekty, przekazując shaderowi ich macierze modelu i widoku oraz rzutowania jako uniformy. shader.Use(); glBindVertexArray(cubeVAOID); for(int i=0;i<8;i++) { float theta = (float)(i/8.0f*2*M_PI); glm::mat4 T = glm::translate(glm::mat4(1), glm::vec3(radius*cos(theta), 0.5,radius*sin(theta))); glm::mat4 M = T; glm::mat4 MV = View*M; glm::mat4 MVP = Proj*MV; glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(MVP)); glUniformMatrix4fv(shader("MV"), 1, GL_FALSE, glm::value_ptr(MV)); glUniformMatrix3fv(shader("N"), 1, GL_FALSE, glm::value_ptr(glm::inverseTranspose(glm::mat3(MV)))); glUniform3fv(shader("diffuse_color"),1, &(colors[i].x)); glUniform3fv(shader("light_position"),1,&(lightPosOS.x)); glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_SHORT, 0); } glBindVertexArray(sphereVAOID); glm::mat4 T = glm::translate(glm::mat4(1), glm::vec3(0,1,0));
117
OpenGL. Receptury dla programisty
glm::mat4 M = T; glm::mat4 MV = View*M; glm::mat4 MVP = Proj*MV; glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(MVP)); glUniformMatrix4fv(shader("MV"), 1, GL_FALSE, glm::value_ptr(MV)); glUniformMatrix3fv(shader("N"), 1, GL_FALSE, glm::value_ptr(glm::inverseTranspose(glm::mat3(MV)))); glUniform3f(shader("diffuse_color"), 0.9f, 0.9f, 1.0f); glUniform3fv(shader("light_position"),1, &(lightPosOS.x)); glDrawElements(GL_TRIANGLES, totalSphereTriangles, GL_UNSIGNED_SHORT, 0); shader.UnUse(); glBindVertexArray(0); grid->Render(glm::value_ptr(Proj*View));
Jak to działa? Obliczenia oświetleniowe możemy przeprowadzać w przestrzeni, jakiej tylko chcemy — obiektu, świata lub widoku (oka). Podobnie jak w dawnym stałym potoku graficznym OpenGL w tej recepturze też wykonamy je w przestrzeni oka. Dlatego już na wstępie shadera wierzchołków wyznaczamy położenia wierzchołków i źródeł światła w przestrzeni oka. Uzyskujemy je przez wymnożenie bieżących położeń wierzchołka i źródła światła przez macierz modelu i widoku (MV). vec4 vEyeSpaceLightPosition = MV*vec4(light_position,1); vec4 vEyeSpacePosition = MV*vec4(vVertex,1);
Podobnie postępujemy z normalnymi wierzchołków, ale do tego przekształcenia używamy odwrotności transponowanej macierzy modelu i widoku, która jest przechowywana w macierzy normalnej (N). vec3 vEyeSpaceNormal = normalize(N*vNormal); W wersjach OpenGL wcześniejszych niż 3.0 macierz normalna była przechowywana w shaderowym uniformie gl_NormalMatrix, który jest odwrotnością transponowanej macierzy modelu i widoku. Wektory normalne transformujemy inaczej niż położenia, ponieważ skalowanie może doprowadzić do tego, że wektory normalne nie będą już znormalizowane. Mnożenie ich przez odwrotność transponowanej macierzy modelu i widoku gwarantuje, że będą tylko obracane, a ich jednostkowa długość pozostanie bez zmiany.
Następnie wyznaczamy wektor łączący źródło światła z wierzchołkiem w przestrzeni oka i obliczamy jego iloczyn skalarny z wektorem normalnym rozpatrywanego wierzchołka. W ten sposób otrzymujemy składową oświetlenia symulującą światło rozproszone. vec3 L = normalize(vEyeSpaceLightPosition.xyzv-EyeSpacePosition.xyz); float diffuse = max(0, dot(vEyeSpaceNormal, L));
118
Rozdział 4. • Światła i cienie
Obliczamy też dwa dodatkowe wektory: widoku (V) i pośredni (H) leżący pomiędzy wektorami światła i widoku. vec3 V = normalize(vEyeSpaceCameraPosition.xyzv-EyeSpacePosition.xyz); vec3 H = normalize(L+V);
Są one potrzebne do obliczenia składowej odblaskowej oświetlenia w modelu Blinna-Phonga. Składową tę wyznaczamy za pomocą funkcji pow(dot(N,H), ), gdzie oznacza jasność — im większa, tym bardziej skupiony odblask. float specular = max(0, pow(dot(vEyeSpaceNormal, H), shininess));
Ostateczny kolor wyznaczamy, mnożąc wartość rozproszenia przez kolor rozproszenia i wartość odblasku przez kolor odblasku. color = diffuse*vec4( diffuse_color, 1) + specular*vec4(specular_color, 1);
Shader fragmentów w oświetleniu wierzchołkowym po prostu wyprowadza na wyjście, jako kolor bieżącego fragmentu, kolor interpolowany przez rasteryzer. smooth in vec4 color; void main() { vFragColor = color; }
Jeśli przeniesiemy obliczenia oświetleniowe do shadera fragmentów, otrzymamy przyjemniejszy dla oka wynik renderingu, ale odbędzie się to kosztem większej złożoności całego procesu. W szczegółach wygląda to następująco: przekształcanie położeń wierzchołków i źródeł światła oraz wektorów normalnych do przestrzeni oka wykonujemy w shaderze wierzchołków. #version 330 core layout(location=0) in vec3 vVertex; layout(location=1) in vec3 vNormal; uniform mat4 MVP; uniform mat4 MV; uniform mat3 N; smooth out vec3 vEyeSpaceNormal; smooth out vec3 vEyeSpacePosition; void main() { vEyeSpacePosition = (MV*vec4(vVertex,1)).xyz; vEyeSpaceNormal = N*vNormal; gl_Position = MVP*vec4(vVertex,1); }
Pozostałe obliczenia, włącznie z wyznaczaniem składowych rozproszeniowej i odblaskowej, przeprowadzamy w shaderze fragmentów.
119
OpenGL. Receptury dla programisty
#version 330 core layout(location=0) out vec4 vFragColor; uniform vec3 light_position; //położenie światła w przestrzeni obiektu uniform vec3 diffuse_color; uniform vec3 specular_color; uniform float shininess; uniform mat4 MV; smooth in vec3 vEyeSpaceNormal; smooth in vec3 vEyeSpacePosition; const vec3 vEyeSpaceCameraPosition = vec3(0,0,0); void main() { vec3 vEyeSpaceLightPosition=(MV*vec4(light_position,1)).xyz; vec3 N = normalize(vEyeSpaceNormal); vec3 L = normalize(vEyeSpaceLightPosition-vEyeSpacePosition); vec3 V = normalize(vEyeSpaceCameraPosition.-xyzvEyeSpacePosition.xyz); vec3 H = normalize(L+V); float diffuse = max(0, dot(N, L)); float specular = max(0, pow(dot(N, H), shininess)); vFragColor = diffuse*vec4(diffuse_color,1) + specular*vec4(specular_color, 1); }
Przeanalizujmy oświetleniowy shader fragmentów instrukcja po instrukcji. Najpierw wyznaczane jest położenie światła w przestrzeni oka. Potem obliczamy w tej samej przestrzeni wektor łączący źródło światła z przetwarzanym wierzchołkiem. Wyznaczamy też wektory widoku (V) i pośredni (H). vec3 vec3 vec3 vec3 vec3
vEyeSpaceLightPosition = (MV * vec4(light_position,1)).xyz; N = normalize(vEyeSpaceNormal); L = normalize(vEyeSpaceLightPosition-vEyeSpacePosition); V = normalize(vEyeSpaceCameraPosition.xyzv-EyeSpacePosition.xyz); H = normalize(L+V);
Następnie obliczamy składową rozproszeniową jako iloczyn skalarny z wektorem normalnym w przestrzeni oka. float diffuse = max(0, dot(vEyeSpaceNormal, L));
Składową odblaskową obliczamy tak samo jak w oświetleniu wierzchołkowym. float specular = max(0, pow(dot(N, H), shininess));
Na koniec mnożymy składową rozproszeniową przez kolor światła rozproszonego i składową odblaskową przez kolor odblasku, po czym sumujemy oba iloczyny, aby uzyskać wypadkowy kolor fragmentu. vFragColor = diffuse*vec4(diffuse_color,1) + specular*vec4(specular_color, 1);
120
Rozdział 4. • Światła i cienie
I jeszcze jedno… Przykładowa aplikacja zbudowana na podstawie powyższej receptury renderuje kulę i osiem kostek wykonujących promieniste ruchy wahadłowe. Rezultat obliczania oświetlenia na poziomie wierzchołków widać na poniższym rysunku. Zwróć uwagę na wyraźnie widoczne linie grzbietowe na powierzchni kuli. Są to linie łączące wierzchołki, dla których były przeprowadzane obliczenia. Zauważ też, że odblaski są widoczne głównie w wierzchołkach.
A teraz zobaczmy rezultat uzyskany za pomocą aplikacji z oświetleniem wyznaczanym na poziomie fragmentów (patrz rysunek na następnej stronie). Zauważ, że teraz oświetlenie jest lepiej wycieniowane niż poprzednio. Również odblask jest wyraźniej zarysowany.
Dowiedz się więcej Zapoznaj się z III częścią książki Jasona L. McKessona zatytułowanej Learning Modern 3D Graphics Programming, dostępnej pod adresem: http://www.arcsynthesis.org/gltut/Illumination/ Illumination.html.
121
OpenGL. Receptury dla programisty
Implementacja światła kierunkowego na poziomie fragmentów Tematem tej receptury będzie implementacja światła kierunkowego. Różni się ono od światła punktowego jedynie tym, że nie da się określić położenia jego źródła, a znany jest tylko kierunek, w którym się rozchodzi — różnicę tę ilustruje poniższy rysunek.
122
Rozdział 4. • Światła i cienie
W przypadku oświetlenia punktowego (lewa strona rysunku) wektor światła zmienia się wraz ze zmianą położenia wierzchołka względem źródła. Gdy oświetlenie jest kierunkowe (prawa strona rysunku), wszystkie wektory światła zaczepione w wierzchołkach są takie same i wskazują kierunek padania promieni.
Przygotowania Przy opracowywaniu kodu dla tej receptury będziemy bazować w dużej mierze na tym, co stworzyliśmy w ramach przykładowej implementacji oświetlenia punktowego na poziomie fragmentów, tyle że zamiast ośmiu pulsujących kostek wyrenderujemy teraz sferę z jedną tylko kostką. Gotowy kod znajduje się w folderze Rozdział4/ŚwiatłoKierunkowe. Implementacja oświetlenia kierunkowego na poziomie wierzchołków będzie wyglądała tak samo.
Jak to zrobić? Zacznij od następujących prostych czynności: 1. Wyznacz kierunek światła w przestrzeni oka i przekaż go do shadera pod postacią uniformu. Ponieważ jest to zwykły wektor wskazujący kierunek, jego czwarta współrzędna wynosi 0. lightDirectionES = glm::vec3(MV*glm::vec4(lightDirectionOS,0));
2. Na wyjście shadera wierzchołków wyprowadź wektor normalny we współrzędnych oka. #version 330 core layout(location=0) in vec3 vVertex; layout(location=1) in vec3 vNormal; uniform mat4 MVP; uniform mat3 N; smooth out vec3 vEyeSpaceNormal; void main() { vEyeSpaceNormal = N*vNormal; gl_Position = MVP*vec4(vVertex,1); }
3. W shaderze fragmentów wyznacz składową rozproszenia, obliczając w tym celu iloczyn skalarny wektora wskazującego kierunek światła w przestrzeni oka i wektora normalnego wyrażonego również we współrzędnych oka. Otrzymany wynik pomnóż przez kolor światła rozpraszanego, aby uzyskać kolor fragmentu. Zauważ, że tutaj wektor światła jest niezależny od położenia wierzchołka w przestrzeni oka. #version 330 core layout(location=0) out vec4 vFragColor; uniform vec3 light_direction; uniform vec3 diffuse_color; smooth in vec3 vEyeSpaceNormal;
123
OpenGL. Receptury dla programisty
void main() { vec3 L = (light_direction); float diffuse = max(0, dot(vEyeSpaceNormal, L)); vFragColor = diffuse*vec4(diffuse_color,1); }
Jak to działa? Jedyna różnica między tą recepturą a poprzednią polega na tym, że tym razem przekazujemy do shadera fragmentów kierunek światła, a nie położenie jego źródła. Reszta obliczeń pozostaje bez zmian. Jeśli zechcemy wprowadzić stopniowe wygaszanie światła, możemy dodać stosowne fragmenty kodu z tamtej receptury.
I jeszcze jedno… Aplikacja będąca przykładem implementacji omawianej receptury wyświetla sferę i sześcian. Kierunek światła jest oznaczony fragmentem linii prostej zaczepionym w środku układu współrzędnych. Można ten kierunek zmieniać przez poruszanie myszą przy wciśniętym prawym przycisku. Jeden z możliwych rezultatów działania tej aplikacji jest pokazany na rysunku na następnej stronie.
Dowiedz się więcej Przeanalizuj recepturę „Implementacja oświetlenia punktowego na poziomie
wierzchołków i fragmentów”. Przestudiuj rozdział 9., „Lights On”, z książki Learning Modern 3D Graphics
Programming Jasona L. McKessona, dostępnej pod adresem: http://www.arcsynthesis. org/gltut/Illumination/Tutorial%2009.html.
Implementacja zanikającego światła punktowego na poziomie fragmentów W poprzedniej recepturze mieliśmy do czynienia ze światłem kierunkowym niezanikającym. Teraz podejmiemy próbę zasymulowania punktowego światła zanikającego. Zaczniemy od implementacji światła punktowego na poziomie fragmentów, tak jak to robiliśmy w recepturze „Implementacja oświetlenia punktowego na poziomie wierzchołków i fragmentów”.
124
Rozdział 4. • Światła i cienie
Przygotowania Pełny kod znajduje się w folderze Rozdział4/ŚwiatłoPunktowe.
Jak to zrobić? Implementacja światła punktowego wygląda następująco: 1. Z shadera wierzchołków wyprowadź położenie wierzchołka i jego normalną w przestrzeni oka. #version 330 core layout(location=0) in vec3 vVertex; layout(location=1) in vec3 vNormal; uniform mat4 MVP; uniform mat4 MV; uniform mat3 N; smooth out vec3 vEyeSpaceNormal; smooth out vec3 vEyeSpacePosition;
125
OpenGL. Receptury dla programisty
void main() { vEyeSpacePosition = (MV*vec4(vVertex,1)).xyz; vEyeSpaceNormal = N*vNormal; gl_Position = MVP*vec4(vVertex,1); }
2. W shaderze fragmentów wyznacz położenie światła we współrzędnych oka, po czym oblicz współrzędne wektora biegnącego od wierzchołka do źródła światła — również w przestrzeni oka. Zapisz długość tego wektora zanim go znormalizujesz. #version 330 core layout(location=0) out vec4 vFragColor; uniform vec3 light_position; //położenie światła w przestrzeni obiektu uniform vec3 diffuse_color; uniform mat4 MV; smooth in vec3 vEyeSpaceNormal; smooth in vec3 vEyeSpacePosition; const float k0 = 1.0; //wygaszanie stałe const float k1 = 0.0; //wygaszanie liniowe const float k2 = 0.0; //wygaszanie kwadratowe void main() { vec3 vEyeSpaceLightPosition = (MV*vec4(light_position,1)).xyz; vec3 L = (vEyeSpaceLightPosition-vEyeSpacePosition); float d = length(L); L = normalize(L); float diffuse = max(0, dot(vEyeSpaceNormal, L)); float attenuationAmount = 1.0/(k0 + (k1*d) + (k2*d*d)); diffuse *= attenuationAmount; vFragColor = diffuse*vec4(diffuse_color,1); }
3. Wprowadź osłabianie światła zależne od odległości elementu rozpraszającego od źródła światła. float attenuationAmount = 1.0/(k0 + (k1*d) + (k2*d*d)); diffuse *= attenuationAmount;
4. Pomnóż składową rozproszenia przez kolor rozproszenia i wynik ustaw jako wyjściowy kolor fragmentu. vFragColor = diffuse*vec4(diffuse_color,1);
Jak to działa? Receptura ta jest skonstruowana podobnie jak „Implementacja światła kierunkowego na poziomie fragmentów”, z tym że dodatkowo przeprowadzane są tu obliczenia związane z zanikaniem światła wraz ze wzrostem odległości od źródła. Zanikanie to określa następujący wzór:
126
Rozdział 4. • Światła i cienie
We wzorze tym d jest odległością od źródła światła, a k1, k2 i k3 są współczynnikami zanikania stałego, liniowego i kwadratowego. Szczegółowe informacje na temat wartości tych współczynników i ich wpływu na poziom oświetlenia można znaleźć w publikacjach wymienionych w punkcie „Dowiedz się więcej”.
I jeszcze jedno… Rezultat działania aplikacji zbudowanej na podstawie prezentowanej receptury przedstawia poniższy rysunek. Wyrenderowane zostały sfera i sześcian. Położenie źródła światła wyznacza punkt przecięcia trzech odcinków. Położenie kamery można zmieniać przez przesuwanie myszy z wciśniętym lewym przyciskiem, a przy wciśniętym prawym przycisku można zmieniać położenie źródła światła. Obracanie rolką do przewijania powoduje zmianę odległości źródła światła od oświetlanych obiektów.
Dowiedz się więcej Przeczytaj książkę Real-Time Rendering. Third Edition Tomasa Akenine-Mollera,
Erica Hainesa i Naty’ego Hoffmana, wydaną przez A K Peters/CRC Press.
127
OpenGL. Receptury dla programisty
Przestudiuj rozdział 10., „Plane Lights”, z książki Learning Modern 3D Graphics
Programming Jasona L. McKessona, dostępnej pod adresem: http://www.arcsynthesis. org/gltut/Illumination/Tutorial%2010.html.
Implementacja oświetlenia reflektorowego na poziomie fragmentów Teraz zaimplementujemy na poziomie fragmentów światło reflektorowe. Jest to specyficzne światło punktowe rozchodzące się tylko w obrębie stożkowego wycinka przestrzeni. Rozwartość tego stożka określa parametr zwany kątem odcięcia (patrz rysunek poniżej). Szybkość zanikania światła poza powierzchnią boczną stożka reguluje wykładnik tłumienia kątowego. Im większa jest jego wartość, tym szybciej światło słabnie.
Przygotowania Gotowy kod dla tej receptury znajduje się w folderze Rozdział4/Reflektor. Shader wierzchołków jest tutaj taki sam jak w recepturze ze światłem punktowym. Shader fragmentów oblicza składową rozproszenia tak samo jak w recepturze „Implementacja oświetlenia punktowego na poziomie wierzchołków i fragmentów”.
Jak to zrobić? Rozpocznij od następujących prostych czynności: 1. Na podstawie położeń źródła światła i obiektu oświetlanego w przestrzeni oka wyznacz kierunek światła reflektorowego. spotDirectionES = glm::normalize(glm::vec3(MV*glm::vec4(spotPositionOSlightPosOS,0)));
128
Rozdział 4. • Światła i cienie
2. W shaderze fragmentów oblicz składową rozproszenia tak samo jak dla światła punktowego. Określ też efekt reflektorowy, wyznaczając kąt między kierunkiem światła a kierunkiem reflektora. vec3 L = (light_position.xyz-vEyeSpacePosition); float d = length(L); L = normalize(L); vec3 D = normalize(spot_direction); vec3 V = -L; float diffuse = 1; float spotEffect = dot(V,D);
3. Jeśli kąt ten jest większy od kąta odcięcia, zastosuj tłumienie kątowe i dopiero potem wyznacz kolor rozproszenia dla bieżącego fragmentu. if(spotEffect > spot_cutoff) { spotEffect = pow(spotEffect, spot_exponent); diffuse = max(0, dot(vEyeSpaceNormal, L)); float attenuationAmount = spotEffect/(k0 + (k1*d) + (k2*d*d)); diffuse *= attenuationAmount; vFragColor = diffuse*vec4(diffuse_color,1); } else { vFragColor = vec4(0,0,0,0); }
Jak to działa? Reflektor jest specyficznym źródłem światła, ponieważ emituje światło tylko w obrębie określonego stożka. Rozwartość tego stożka i jego ostrość na brzegach można regulować za pomocą parametrów zwanych, odpowiednio, kątem odcięcia i wykładnikiem tłumienia kątowego. Podobnie jak w przypadku światła punktowego najpierw obliczamy składową rozproszenia. Zamiast wektora L zwróconego w stronę źródła światła stosujemy tym razem wektor V wskazujący kierunek rozchodzenia się światła (V=-L). Następnie sprawdzamy, czy kąt między kierunkiem reflektora a kierunkiem światła mieści się w granicach kąta odcięcia. Jeśli tak jest, obliczamy składową rozproszenia. W przeciwnym razie ustalamy ostrość na brzegu stożka światła przez wprowadzenie tłumienia kątowego, które pozwala uzyskać przyjemny dla oka efekt gładkiego przejścia od jasnej plamy światła do całkowitej ciemności.
I jeszcze jedno… Aplikacja stanowiąca implementację tej receptury renderuje scenę taką samą jak ta z pokazu światła punktowego. Aby zmienić kierunek reflektora, należy przeciągnąć myszą z wciśniętym prawym przyciskiem. Przykład wyrenderowanego obrazu jest pokazany na rysunku na następnej stronie.
129
OpenGL. Receptury dla programisty
Dowiedz się więcej Przeczytaj książkę Real-Time Rendering. Third Edition Tomasa Akenine-Mollera,
Erica Hainesa i Naty’ego Hoffmana, wydaną przez A K Peters/CRC Press. Zapoznaj się z artykułem na temat światła reflektorowego w GLSL zamieszczonym
w serwisie Ozone3D: http://www.ozone3d.net/tutorials/glsl_lighting_phong_p3.php.
Mapowanie cieni przy użyciu FBO Cienie wnoszą istotną informację do wizualnej oceny wzajemnego usytuowania obiektów w scenie. Istnieje mnóstwo technik ich generowania, np. bryły cienia, mapy cienia, kaskadowe mapy cienia itd. Opisy wielu z nich można znaleźć w literaturze podanej w punkcie „Dowiedz się więcej”. Na razie spróbujemy wykonać proste mapowanie cieni przy użyciu FBO.
130
Rozdział 4. • Światła i cienie
Przygotowania W tej recepturze wykorzystamy tę samą scenę co poprzednio, z tym że zamiast siatki wstawimy podłoże w postaci płaszczyzny, aby wygenerowane cienie mogły być widoczne. Gotowy kod znajduje się w folderze Rozdział4/MapowanieCieni.
Jak to zrobić? Zacznij od następujących prostych czynności: 1. Utwórz obiekt tekstury, który będzie naszą mapą cienia. Nie zapomnij ustawić trybu zawijania tekstury na GL_CLAMP_TO_BORDER, koloru obrzeża na {1,0,0,0}, trybu porównywania na GL_COMPARE_REF_TO_TEXTURE i funkcji porównującej na GL_LEQUAL. Wewnętrzny format tekstury ustaw na GL_DEPTH_COMPONENT24. glGenTextures(1, &shadowMapTexID); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, shadowMapTexID); GLfloat border[4]={1,0,0,0}; glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_NEAREST); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_NEAREST); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_COMPARE_MODE,GL_COMPARE_REF_TO_ TEXTURE); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_COMPARE_FUNC,GL_LEQUAL); glTexParameterfv(GL_TEXTURE_2D,GL_TEXTURE_BORDER_COLOR,border); glTexImage2D(GL_TEXTURE_2D,0,GL_DEPTH_COMPONENT24,SHADOWMAP_WIDTH, SHADOWMAP_HEIGHT,0,GL_DEPTH_COMPONENT,GL_UNSIGNED_BYTE,NULL);
2. Przygotuj FBO i ustaw teksturę mapy cienia jako pojedyncze przyłącze głębi. Tutaj zostanie zapisana głębia sceny wyznaczona z punktu widzenia światła. glGenFramebuffers(1,&fboID); glBindFramebuffer(GL_FRAMEBUFFER,fboID); glFramebufferTexture2D(GL_FRAMEBUFFER,GL_DEPTH_ATTACHMENT,GL_TEXTURE_2D, shadowMapTexID,0); GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); if(status == GL_FRAMEBUFFER_COMPLETE) { cout<<"Ustawienie FBO udalo sie."<
3. Na podstawie położenia i kierunku światła wyznacz macierz cienia (S) przez połączenie macierzy modelu i widoku dla światła (MV_L), rzutowania (P_L) i przesunięcia (B).
131
OpenGL. Receptury dla programisty
Aby ograniczyć liczbę obliczeń wykonywanych w czasie działania programu, połączoną macierz rzutowania i przesunięcia (BP) zapisujemy na etapie inicjalizacji. MV_L = glm::lookAt(lightPosOS,glm::vec3(0,0,0), glm::vec3(0,1,0)); P_L = glm::perspective(50.0f,1.0f,1.0f, 25.0f); B = glm::scale(glm::translate(glm::mat4(1), glm::vec3(0.5,0.5,0.5)), glm::vec3(0.5,0.5,0.5)); BP = B*P_L; S = BP*MV_L;
4. Zwiąż FBO i wyrenderuj scenę z punktu widzenia źródła światła. Nie zapomnij przy tym o włączeniu ukrywania ścianek przednich (glEnable(GL_CULL_FACE) i glCullFace(GL_FRONT)), żeby renderowane były wartości głębi odpowiadające ściankom tylnym. W przeciwnym razie otrzymasz cień z licznymi artefaktami. Do wyrenderowania sceny dla tekstury głębi można użyć prostego shadera. Można też wyłączyć zapisywanie do bufora koloru (glDrawBuffer(GL_NONE)), a następnie włączyć je dla zwykłego renderingu. Dodatkowo w celu zminimalizowania artefaktów cieni można dodać do kodu shadera stosowne przesunięcie. glBindFramebuffer(GL_FRAMEBUFFER,fboID); glClear(GL_DEPTH_BUFFER_BIT); glViewport(0,0,SHADOWMAP_WIDTH, SHADOWMAP_HEIGHT); glCullFace(GL_FRONT); DrawScene(MV_L, P_L); glCullFace(GL_BACK);
5. Wyłącz FBO, przywróć domyślne okno widokowe i wyrenderuj scenę w zwykły sposób z punktu widzenia kamery. glBindFramebuffer(GL_FRAMEBUFFER,0); glViewport(0,0,WIDTH, HEIGHT); DrawScene(MV, P, 0 );
6. W shaderze wierzchołków pomnóż położenie wierzchołka w przestrzeni świata (M*vec4(vVertex,1)) przez macierz cienia ( S), aby uzyskać współrzędne cienia. Będą one potrzebne do wybierania wartości głębi z tekstury shadowmap w shaderze fragmentów. #version 330 core layout(location=0) in vec3 vVertex; layout(location=1) in vec3 vNormal; uniform uniform uniform uniform uniform
132
mat4 mat4 mat4 mat3 mat4
MVP; MV; M; N; S;
//macierz rzutowania, modelu i widoku //macierz modelu i widoku //macierz modelu //macierz normalna //macierz cienia
Rozdział 4. • Światła i cienie
smooth out vec3 vEyeSpaceNormal; smooth out vec3 vEyeSpacePosition; smooth out vec4 vShadowCoords; void main() { vEyeSpacePosition = (MV*vec4(vVertex,1)).xyz; vEyeSpaceNormal = N*vNormal; vShadowCoords = S*(M*vec4(vVertex,1)); gl_Position = MVP*vec4(vVertex,1); }
7. W shaderze fragmentów użyj współrzędnych cienia do wybierania wartości głębi w samplerze mapy cienia, który jest typu sampler2Dshadow i może być używany z funkcją textureProj do przeprowadzania porównania. Wynik porównania wykorzystaj do przyciemnienia składowej rozproszenia, aby zasymulować cienie. #version 330 core layout(location=0) out vec4 vFragColor; uniform sampler2DShadow shadowMap; uniform vec3 light_position; //położenie światła w przestrzeni oka uniform vec3 diffuse_color; smooth in vec3 vEyeSpaceNormal; smooth in vec3 vEyeSpacePosition; smooth in vec4 vShadowCoords; const float k0 = 1.0; //tłumienie stałe const float k1 = 0.0; //tłumienie liniowe const float k2 = 0.0; //tłumienie kwadratowe uniform bool bIsLightPass; //przejście bez cieni void main() { if(bIsLightPass) return; vec3 L = (light_position.xyz-vEyeSpacePosition); float d = length(L); L = normalize(L); float attenuationAmount = 1.0/(k0 + (k1*d) + (k2*d*d)); float diffuse = max(0, dot(vEyeSpaceNormal, L)) * attenuationAmount; if(vShadowCoords.w>1) { float shadow = textureProj(shadowMap, vShadowCoords); diffuse = mix(diffuse, diffuse*shadow, 0.5); } vFragColor = diffuse*vec4(diffuse_color, 1); }
133
OpenGL. Receptury dla programisty
Jak to działa? Algorytm mapowania cieni działa w dwóch przejściach. W pierwszym scena jest renderowana z punktu widzenia źródła światła i zawartość bufora głębi jest zapisywana w postaci tekstury o nazwie shadowmap. Używamy do tego celu jednego FBO z przyłączem głębi. Niezależnie od typowego pomniejszającego lub powiększającego filtrowania tekstury ustawiamy jej tryb zawijania na GL_CLAMP_TO_BORDER, co zapewnia zamknięcie jej wartości ściśle kolorem brzegu. Ustawienie tego trybu na GL_CLAMP lub GL_CLAMP_TO_EDGE spowodowałoby wystąpienie widocznych artefaktów. Tekstura shadowmap ma dodatkowych kilka parametrów. Pierwszym jest GL_TEXTURE_COMPARE_ MODE i jemu przypisujemy wartość GL_COMPARE_REF_TO_TEXTURE. Umożliwia to wykorzystanie tekstury w shaderze do porównywania głębi. Następnie ustalamy parametry trybu porównywania na GL_TEXTURE_COMPARE_FUNC i funkcji porównującej na GL_LEQUAL. W ten sposób wymuszamy porównanie wartości bieżącej współrzędnej interpolowanej tekstury (r) z próbką wartości tekstury głębi (D). Jeśli r<=D, funkcja porównująca zwraca wartość 1, a w innych przypadkach wartością zwracaną jest 0. A zatem jeśli głębokość bieżącej próbki jest mniejsza lub równa głębokości zapisanej w teksturze shadowmap, próbka jest oświetlona, a gdy jest inaczej, próbka pozostaje w cieniu. Porównywanie wykonuje shaderowa funkcja textureProj i to ona zwraca 0 lub 1 w zależności od tego, czy dany punkt jest w cieniu, czy nie. Tak wyglądają parametry tekstury shadowmap. Aby uniknąć niepożądanych artefaktów, włączamy ukrywanie ścianek przednich (glEnable(GL_ CULL_FACE) i glCullFace(GL_FRONT)), dzięki czemu do tekstury shadowmap zapisywane są głębie wyłącznie ścianek tylnych. W drugim przebiegu renderowanie odbywa się zwyczajnie z punktu widzenia kamery, a mapa cieni jest rzutowana na scenę przy użyciu shaderów. Do wyrenderowania sceny z punktu widzenia źródła światła potrzebne są następujące macierze: modelu i widoku dla światła (MV_L), rzutowania światła (P_L) i przesunięcia (B). Po wymnożeniu przez macierz rzutowania współrzędne są w przestrzeni przycięcia (czyli mają wartości z przedziału od [–1,–1,–1] do [1,1,1]). Macierz przesunięcia przenosi je do przedziału od [0,0,0] do [1,1,1] i dopiero wtedy możliwe jest odnoszenie się do wartości zapisanych w teksturze cienia. Jeśli współrzędne wierzchołka w przestrzeni obiektu oznaczymy przez Vobj, to współrzędne potrzebne do porównania z mapą cienia (UVproj) można otrzymać w wyniku mnożenia macierzy cienia (S) przez położenie wierzchołka w przestrzeni świata (M*Vobj). Pełny ciąg transformacji wygląda następująco:
B oznacza tutaj macierz przesunięcia, PL jest macierzą rzutowania dla światła, a MVL to macierz modelu i widoku dla światła. Jako że wartości macierzy przesunięcia i rzutowania dla światła są niezmienne, wyznaczamy je tylko raz i w ten sposób przyspieszamy działanie całej aplikacji.
134
Rozdział 4. • Światła i cienie
W zależności od zmian wprowadzonych przez użytkownika modyfikowana jest macierz modelu i widoku dla światła, a następnie na nowo obliczana jest macierz cienia i to ona jest przekazywana do shadera. W shaderze wierzchołków współrzędne tekstury shadowmap są uzyskiwane w wyniku mnożenia współrzędnych wierzchołka w przestrzeni świata (M*Vobj) przez macierz cienia (S). W shaderze fragmentów mapa cienia jest przeszukiwana według współrzędnych tekstury rzutowanej w celu stwierdzenia, czy bieżący fragment znajduje się w cieniu. Jeszcze przed przeszukiwaniem mapy cienia sprawdzamy wartość współrzędnej w dla tekstury rzutowanej i przeprowadzamy dalsze obliczenia tylko wtedy, gdy jest ona większa od 1. Mamy wówczas pewność, że w grę wchodzi wyłącznie rzutowanie do przodu, a wsteczne jest odrzucane. Usuń na próbę ten warunek, a zobaczysz, co mam na myśli. Przeszukiwaniem mapy cienia w celu stwierdzenia, czy bieżący fragment znajduje się w cieniu, zajmuje się funkcja textureProj. Rezultatem jej działania jest wartość 1 lub 0. Wartość ta jest potem uwzględniana przy obliczaniu jasności danego fragmentu. W świecie rzeczywistym cienie nigdy nie są tak czarne jak smoła, więc za pomocą funkcji mix jedynie mieszamy uzyskany cień z rezultatami zwykłych obliczeń.
I jeszcze jedno… Przykładowa aplikacja stworzona według powyższej receptury wyświetla płaszczyznę, kostkę i kulę. Jest tam również punktowe źródło światła, które można obracać za pomocą myszy z wciśniętym prawym przyciskiem. Aby je oddalić od oświetlanej sceny lub do niej przybliżyć, należy użyć rolki do przewijania. Jeden z możliwych renderingów, jakie można uzyskać za pomocą tej aplikacji, jest pokazany na rysunku na następnej stronie. W tej recepturze mamy do czynienia z jednym źródłem światła, a każde kolejne wymaga dłuższego czasu przetwarzania i pochłania więcej zasobów pamięciowych.
Dowiedz się więcej Zapoznaj się z następującymi publikacjami na temat generowania cieni: Elmar Eisemann, Michael Schwarz, Ulf Assarsson i Michael Wimmer, Real-Time
Shadows, A K Peters/CRC Press. David Wolff, OpenGL 4.0 Shading Language Cookbook, Packt Publishing, rozdział 10.,
„Shadows”. Fabien Sanglard, ShadowMapping with GLSL, http://www.fabiensanglard.net/
shadowmapping/index.php.
135
OpenGL. Receptury dla programisty
Mapowanie cieni z filtrowaniem PCF Algorytm mapowania cieni jest łatwy w implementacji, ale często tworzy artefakty aliasingowe wynikające z ograniczonej rozdzielczości mapy cienia. Poza tym cienie utworzone tą techniką są zawsze ostre. Pewną poprawę można uzyskać przez zwiększenie rozdzielczości mapy cienia lub pobieranie większej liczby próbek. To drugie podejście nosi nazwę filtrowania PCF (percent closer filtering — filtrowanie typu bliższy odsetek1) i polega na ustalaniu, czy dany fragment jest w cieniu, na podstawie uśrednionej zawartości większej liczby próbek pobranych z najbliższego otoczenia. W tym trybie zamiast pojedynczej próbki badany jest obszar mapy cienia o powierzchni n×n.
1
Taka, a nie inna nazwa wynika stąd, że ta metoda filtrowania sprowadza się do obliczania, jaka część (jaki odsetek) powierzchni z rozpatrywanego sąsiedztwa bieżącego fragmentu jest położona bliżej źródła światła i w związku z tym nie należy do obszaru cienia — przyp. tłum.
136
Rozdział 4. • Światła i cienie
Przygotowania Przykładowy kod z implementacją tej receptury znajduje się w folderze Rozdział4/ MapowanieCieniPCF. Jest on rozszerzeniem kodu z poprzedniej receptury, „Mapowanie cieni przy użyciu FBO”. Wykorzystamy tę samą scenę i zastosujemy tę samą technikę generowania cieni, a jedynie wzbogacimy ją o filtrowanie PCF.
Jak to zrobić? Zobaczmy, jak można rozszerzyć proste mapowanie cieni o filtrowanie PCF. 1. Zmień tryby pomniejszającego i powiększającego filtrowania tekstury shadowmap na GL_LINEAR. Tym razem wykorzystamy filtracyjne możliwości GPU do zredukowania aliasingowych artefaktów powstających podczas próbkowania mapy cienia. Nawet przy filtrowaniu liniowym musimy uwzględnić dodatkowe próbki, jeśli chcemy zminimalizować możliwość ujawnienia się wspomnianych artefaktów. glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
2. W shaderze fragmentów zamiast pojedynczej próbki tekstury, jak w poprzedniej recepturze, pobierz ich kilka. W GLSL istnieje funkcja textureProjOffset, która umożliwia pobieranie próbek z miejsc określonych przez wektor przesunięcia względem bieżącego punktu mapy. Tym razem pobierz próbki z obszaru o wymiarach 3×3 wokół punktu bieżącego. Zastosuj więc maksymalne przesunięcie o wartości bezwzględnej 2. To powinno wystarczyć do wyraźnego zredukowania niepożądanych artefaktów. if(vShadowCoords.w>1) { float sum = 0; sum += textureProjOffset(shadowMap, vShadowCoords, sum += textureProjOffset(shadowMap, vShadowCoords, sum += textureProjOffset(shadowMap, vShadowCoords, sum += textureProjOffset(shadowMap, vShadowCoords, sum += textureProjOffset(shadowMap, vShadowCoords, sum += textureProjOffset(shadowMap, vShadowCoords, sum += textureProjOffset(shadowMap, vShadowCoords, sum += textureProjOffset(shadowMap, vShadowCoords, sum += textureProjOffset(shadowMap, vShadowCoords, float shadow = sum/9.0; diffuse = mix(diffuse, diffuse*shadow, 0.5); }
ivec2(-2,-2)); ivec2(-2, 0)); ivec2(-2, 2)); ivec2( 0,-2)); ivec2( 0, 0)); ivec2( 0, 2)); ivec2( 2,-2)); ivec2( 2, 0)); ivec2( 2, 2));
137
OpenGL. Receptury dla programisty
Jak to działa? W celu zaimplementowania techniki PCF musimy najpierw ustawić liniowe filtrowanie tekstury. Umożliwi to bilinearne interpolowanie wartości cienia przez GPU. W rezultacie otrzymamy gładsze krawędzie cieni, bo sprzęt już wykona filtrowanie PCF. Dla naszych celów jednak jest ono zbyt słabe i dlatego musimy zwiększyć liczbę pobieranych próbek. Na szczęście mamy do dyspozycji funkcję textureProjOffset, która przyjmuje jako parametr wektor przesunięcia dodawany do bieżących współrzędnych tekstury z mapą cienia. Zwracam uwagę, że współrzędne tego wektora muszą być podane w formie literałów. Nie możemy więc zastosować pętli z dynamicznym pobieraniem kolejnych próbek, tylko musimy każdą pobrać indywidualnie. Stosujemy przesunięcie o dwie jednostki, ale tak naprawdę powinniśmy zastosować wartość 1,5. Niestety, funkcja textureProjOffset nie akceptuje wartości ułamkowych, więc musimy naszą wartość zaokrąglić do najbliższej liczby całkowitej. Za pomocą wektora przesunięcia wskazujemy kolejne próbki, aż całe otoczenie w obszarze 3×3 zostanie sprawdzone. Następnie uśredniamy wartości tych próbek i wynik włączamy do obliczeń oświetleniowych, aby w odpowiednim stopniu przyciemnić rozpatrywany fragment, jeśli jest on w obszarze nieoświetlonym. Nawet jednak uwzględnienie dodatkowych próbek nie gwarantuje całkowitego wyeliminowania artefaktów. Dalszą poprawę można uzyskać przez wybieranie próbek w sposób losowy. W tym celu musimy najpierw zdefiniować pseudolosową funkcję w GLSL. float random(vec4 seed) { float dot_product = dot(seed, vec4(12.9898,78.233, 45.164, 94.673)); return fract(sin(dot_product) * 43758.5453); }
A następnie używamy jej do przenoszenia próbkowanego punktu w przypadkowo wybrane miejsca. Robimy to w sposób następujący: for(int i=0;i<16;i++) { float indexA = (random(vec4(gl_FragCoord.xyx, i))*0.25); float indexB = (random(vec4(gl_FragCoord.yxy, i))*0.25); sum += textureProj(shadowMap, vShadowCoords + vec4(indexA, indexB, 0, 0)); } shadow = sum/16.0;
W przykładowym kodzie do tej receptury są zdefiniowane trzy makra: STRATIFIED_3x3 (do próbkowania warstwowego w obszarze 3×3), STRATIFIED_5x5 (do próbkowania warstwowego w obszarze 5×5) i RANDOM_SAMPLING_4x4 (do próbkowania losowego w obszarze 4×4).
138
Rozdział 4. • Światła i cienie
I jeszcze jedno… Wprowadzenie powyższych zmian pozwoliło uzyskać znacznie lepszy rezultat, co widać na rysunku. Gdybyśmy uwzględnili jeszcze większy obszar próbkowania, rezultat byłby jeszcze lepszy, ale również koszty obliczeniowe byłyby większe.
Na rysunku na następnej stronie pokazane jest porównanie mapowania cieni z filtrowaniem PCF (po prawej) z mapowaniem zwykłym (po lewej). Jak widać, filtrowanie PCF daje łagodniejsze cienie z mniejszymi artefaktami aliasingowymi. Kolejny rysunek na następnej stronie przedstawia porównanie rezultatów warstwowego filtrowania PCF (po lewej) i losowego (po prawej). Nietrudno dostrzec, że filtrowanie losowe daje znacznie lepsze rezultaty.
Dowiedz się więcej Przeczytaj rozdział 11., „Shadow Map Antialiasing”, książki Michaela Bunnella i Fabio
Pellaciniego zatytułowanej GPU Gems, dostępnej pod adresem: http://http.developer. nvidia.com/GPUGems/gpugems_ch11.html.
139
OpenGL. Receptury dla programisty
140
Rozdział 4. • Światła i cienie
Przestudiuj samouczek nr 16 pod tytułem Shadow Mapping, zamieszczony pod
adresem: http://www.opengl-tutorial.org/intermediate-tutorials/tutorial-16-shadow-mapping/.
Wariancyjne mapowanie cieni W tej recepturze zastosujemy technikę, która daje lepsze rezultaty, jest bardziej wydajna i przy tym łatwa do zaimplementowania. Technika ta nosi nazwę wariacyjnego mapowania cieni. W zwykłym mapowaniu cieni z filtrowaniem PCF porównujemy wartość głębi bieżącego fragmentu z uśrednioną wartością głębi z pewnego obszaru mapy cienia i na podstawie uzyskanego wyniku odpowiednio zacieniamy rozpatrywany fragment. W przypadku mapowania wariancyjnego obliczamy i zapisujemy średnią wartość głębi (tzw. pierwszy moment) oraz jej średni kwadrat (drugi moment). Następnie wyznaczamy wariancję. Do jej obliczenia potrzebne są obie zapisane wcześniej wartości. Na podstawie wariancji obliczamy prawdopodobieństwo zacienienia rozpatrywanej próbki i porównujemy je z prawdopodobieństwem maksymalnym, aby ostatecznie określić, czy bieżąca próbka ma być zacieniona.
Przygotowania Do zbudowania aplikacji ilustrującej tę recepturę wykorzystamy kod z receptury „Mapowanie cieni przy użyciu FBO”. Gotowy kod przykładowej aplikacji znajduje się w folderze Rozdział4/ WariancyjneMapowanieCieni.
Jak to zrobić? Rozpocznij od następujących prostych czynności: 1. Przygotuj teksturę shadowmap tak jak w przykładzie z mapowaniem cieni, ale tym razem nie włączaj trybu porównywania głębi (glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE) i glTexParameteri(GL_ TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL)). Format tekstury ustaw na GL_RGBA32F i włącz dla niej generowanie mipmap. Mipmapy są filtrowanymi teksturami o różnych skalach, które pozwalają uzyskać cienie o mniejszej zawartości błędów aliasingowych. Wystarczy nam pięć poziomów mipmap (maksymalny poziom ustawimy na 4). glGenTextures(1, &shadowMapTexID); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, shadowMapTexID); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR;
141
OpenGL. Receptury dla programisty
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR_MIPMAP_ LINEAR); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_BORDER); glTexParameterfv(GL_TEXTURE_2D,GL_TEXTURE_BORDER_COLOR,border; glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA32F,SHADOWMAP_WIDTH,SHADOWMAP_HEIGHT, 0,GL_RGBA,GL_FLOAT,NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 4); glGenerateMipmap(GL_TEXTURE_2D);
2. Przygotuj dwa FBO: jeden dla generowania map cieni i drugi dla ich filtrowania. Pierwszy powinien mieć przyłączony bufor renderingu (renderbuffer), a drugi niech będzie bez takiego bufora, ale za to z dwoma przyłączami dla tekstur. glGenFramebuffers(1,&fboID); glGenRenderbuffers(1, &rboID); glBindFramebuffer(GL_FRAMEBUFFER,fboID); glBindRenderbuffer(GL_RENDERBUFFER, rboID); glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT32, SHADOWMAP_WIDTH, SHADOWMAP_HEIGHT); glFramebufferTexture2D(GL_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_ TEXTURE_2D,shadowMapTexID,0); glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, rboID); GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); if(status == GL_FRAMEBUFFER_COMPLETE) { cout<<"Ustawienie FBO powiodlo sie."<
142
Rozdział 4. • Światła i cienie
status = glCheckFramebufferStatus(GL_FRAMEBUFFER); if(status == GL_FRAMEBUFFER_COMPLETE) { cout<<"Ustawienie filtrujacego FBO powiodlo sie."<
3. Zwiąż pierwszy FBO, ustaw wymiary okna widokowego zgodne z wymiarami tekstury shadowmap i wyrenderuj scenę z punktu widzenia źródła światła, tak jak w recepturze „Mapowanie cieni przy użyciu FBO”. Jednak tym razem zamiast zapisywać wartości głębi zdefiniuj własny shader fragmentów (Rozdział4/ WariancyjneMapowanieCieni/shadery/firststep.frag), który będzie wyprowadzał w kanałach red i green koloru wyjściowego wartość głębi (depth) i jej kwadratu (depth*depth). glBindFramebuffer(GL_FRAMEBUFFER,fboID); glViewport(0,0,SHADOWMAP_WIDTH, SHADOWMAP_HEIGHT); glDrawBuffer(GL_COLOR_ATTACHMENT0); glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); DrawSceneFirstPass(MV_L, P_L); Kod shadera wygląda następująco: #version 330 core layout(location=0) out vec4 vFragColor; smooth in vec4 clipSpacePos; void main() { vec3 pos = clipSpacePos.xyz/clipSpacePos.w; //od -1 do 1 pos.z += 0.001; //przesunięcie likwidujące artefakty cienia float depth = (pos.z +1)*0.5; // od 0 do 1 float moment1 = depth; float moment2 = depth * depth; vFragColor = vec4(moment1,moment2,0,0); }
4. Zwiąż drugi FBO (filtrujący), aby przefiltrować teksturę mapy cienia wygenerowaną w pierwszym przebiegu. Użyj do tego wygładzających filtrów gaussowskich, bo są wydajniejsze i skuteczniejsze. Najpierw uruchom shader z wygładzaniem pionowym (Rozdział4/WariancyjneMapowanieCieni/shadery/GaussV.frag), a wynik poddaj wygładzaniu poziomemu (Rozdział4/WariancyjneMapowanieCieni/shadery/ GaussH.frag). glBindFramebuffer(GL_FRAMEBUFFER,filterFBOID); glDrawBuffer(GL_COLOR_ATTACHMENT0); glBindVertexArray(quadVAOID); gaussianV_shader.Use(); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); glDrawBuffer(GL_COLOR_ATTACHMENT1); gaussianH_shader.Use(); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0);
143
OpenGL. Receptury dla programisty
glBindFramebuffer(GL_FRAMEBUFFER,0); Kod shadera z wygładzaniem poziomym wygląda następująco: #version 330 core layout(location=0) out vec4 vFragColor; smooth in vec2 vUV; uniform sampler2D textureMap; const float kernel[]=float[21] (0.000272337, 0.00089296, 0.002583865, 0.00659813, 0.014869116, 0.029570767, 0.051898313, 0.080381679, 0.109868729, 0.132526984, 0.14107424, 0.132526984, 0.109868729, 0.080381679, 0.051898313, 0.029570767, 0.014869116, 0.00659813, 0.002583865, 0.00089296, 0.000272337); void main() { vec2 delta = 1.0/textureSize(textureMap,0); vec4 color = vec4(0); int index = 20; for(int i=-10;i<=10;i++) { color += kernel[index--]*texture(textureMap, vUV + (vec2(i*delta.x,0))); } vFragColor = vec4(color.xy,0,0); }
W shaderze z wygładzaniem pionowym inna jest tylko instrukcja w pętli, a reszta pozostaje bez zmian. color += kernel[index--]*texture(textureMap, vUV + (vec2(0,i*delta.y)));
5. Odwiąż FBO, przywróć domyślne wymiary okna widokowego i wyrenderuj scenę w zwykły sposób. glDrawBuffer(GL_BACK_LEFT); glViewport(0,0,WIDTH, HEIGHT); DrawScene(MV, P);
Jak to działa? Technika wariancyjnego mapowania cieni usiłuje znaleźć taką reprezentację danych głębokościowych, w której mogą one być filtrowane w sposób liniowy. Oprócz samej głębi (depth) w zmiennoprzecinkowej teksturze zapisywany jest jeszcze jej kwadrat (depth*depth). Tekstura jest potem filtrowana w celu odtworzenia pierwszego i drugiego momentu rozkładu głębi. Na podstawie tych momentów obliczana jest wariancja w filtrującym sąsiedztwie. Następnie, korzystając z nierówności Czebyszewa, wyznaczamy prawdopodobieństwo, że fragment o określonej głębi jest zasłonięty. Po dalsze szczegóły matematyczne odsyłam Czytelnika do publikacji wymienionych w punkcie „Dowiedz się więcej”.
144
Rozdział 4. • Światła i cienie
Z implementacyjnego punktu widzenia receptura jest podobna do zwykłego mapowania cieni i też wymaga dwóch przebiegów. W pierwszym renderujemy scenę z punktu widzenia źródła światła, z tym że zamiast samej głębi (depth) zapisywany jest również jej kwadrat (depth* depth). Realizację tego zadania powierzamy specjalnie po to napisanemu shaderowi fragmentów (Rozdział4/WariancyjneMapowanieCieni/shadery/firststep.frag). Shader wierzchołków przesyła do shadera fragmentów położenie wierzchołka w przestrzeni przycięcia i na tej podstawie wyznaczana jest głębokość rozpatrywanego fragmentu. Aby ograniczyć samozacienianie, dodajemy do współrzędnej z niewielkie przesunięcie. vec3 pos = clipSpacePos.xyz/clipSpacePos.w; pos.z += 0.001; float depth = (pos.z +1)*0.5; float moment1 = depth; float moment2 = depth * depth; vFragColor = vec4(moment1,moment2,0,0);
Wygenerowaną w pierwszym przebiegu teksturę z mapą cienia poddajemy wygładzaniu za pomocą filtra gaussowskiego. Najpierw stosujemy filtrację pionową, a potem poziomą. Tekstura jest przy tym nakładana na pełnoekranowy czworokąt i zmieniane jest za każdym razem przyłącze koloru w FBO. Zauważ, że tekstura shadowmap jest związana z jednostką teksturującą nr 0, a tekstury przefiltrowane są związane z jednostkami o numerach 1 (przyłączona do GL_COLOR_ ATTTACHMENT0 w filtrującym FBO) i 2 (przyłączona do GL_COLOR_ATTTACHMENT1 w filtrującym FBO). glBindFramebuffer(GL_FRAMEBUFFER,fboID); glViewport(0,0,SHADOWMAP_WIDTH, SHADOWMAP_HEIGHT); glDrawBuffer(GL_COLOR_ATTACHMENT0); glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); DrawSceneFirstPass(MV_L, P_L); glBindFramebuffer(GL_FRAMEBUFFER,filterFBOID); glDrawBuffer(GL_COLOR_ATTACHMENT0); glBindVertexArray(quadVAOID); gaussianV_shader.Use(); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); glDrawBuffer(GL_COLOR_ATTACHMENT1); gaussianH_shader.Use(); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); glBindFramebuffer(GL_FRAMEBUFFER,0); glDrawBuffer(GL_BACK_LEFT); glViewport(0,0,WIDTH, HEIGHT);
W drugim przebiegu scena jest renderowana z punktu widzenia kamery. Wygładzona mapa cieni służy tutaj jako tekstura, z której mają być pobierane próbki (patrz shadery VarianceShadowMap.vert i VarianceShadowMap.frag w folderze Rozdział4/WariancyjneMapowanieCieni/
145
OpenGL. Receptury dla programisty
shadery). Shader wierzchołków mapowania wariancyjnego (VarianceShadowMap.vert) wyprowadza na wyjście współrzędne tekstury cienia, tak jak w recepturze ze zwykłym mapowaniem cieni. #version 330 core layout(location=0) in vec3 vVertex; layout(location=1) in vec3 vNormal; uniform mat4 MVP; //macierz modelu i widoku oraz rzutowania uniform mat4 MV; //macierz modelu i widoku uniform mat4 M; //macierz modelu uniform mat3 N; //macierz normalna uniform mat4 S; //macierz cienia smooth out vec3 vEyeSpaceNormal; smooth out vec3 vEyeSpacePosition; smooth out vec4 vShadowCoords; void main() { vEyeSpacePosition = (MV*vec4(vVertex,1)).xyz; vEyeSpaceNormal = N*vNormal; vShadowCoords = S*(M*vec4(vVertex,1)); gl_Position = MVP*vec4(vVertex,1); }
Shader fragmentów (VarianceShadowMap.frag) działa inaczej. Najpierw sprawdzamy, czy współrzędne cienia leżą po przedniej stronie światła (aby uniknąć projekcji wstecznej), a więc czy shadowCoord.w>1. Następnie wartości shadowCoords.xyz dzielimy przez współrzędną jednorodną w, aby uzyskać wartość głębi (depth). if(vShadowCoords.w>1) { vec3 uv = vShadowCoords.xyz/vShadowCoords.w; float depth = uv.z;
Współrzędne tekstury po podzieleniu przez w są używane do próbkowania mapy cienia przechowującej oba momenty. Momenty te z kolei służą do wyznaczania wariancji, a ta, po obcięciu, jest używana do obliczania prawdopodobieństwa zasłonięcia. Na koniec w oparciu o wyliczone prawdopodobieństwo modyfikowana jest składowa rozproszenia koloru badanego fragmentu. vec4 moments = texture(shadowMap, uv.xy); float E_x2 = moments.y; float Ex_2 = moments.x*moments.x; float var = E_x2-Ex_2; var = max(var, 0.00002); float mD = depth-moments.x; float mD_2 = mD*mD; float p_max = var/(var+ mD_2); diffuse *= max(p_max, (depth<=moments.x)?1.0:0.2); }
146
Rozdział 4. • Światła i cienie
Podsumowując: pełny kod shadera fragmentów wariancyjnego mapowania cieni wygląda następująco: #version 330 core layout(location=0) out vec4 vFragColor; uniform sampler2D shadowMap; uniform vec3 light_position; //położenie światła w przestrzeni obiektu uniform vec3 diffuse_color; uniform mat4 MV; smooth in vec3 vEyeSpaceNormal; smooth in vec3 vEyeSpacePosition; smooth in vec4 vShadowCoords; const float k0 = 1.0; //tłumienie stałe const float k1 = 0.0; //tłumienie liniowe const float k2 = 0.0; //tłumienie kwadratowe void main() { vec4 vEyeSpaceLightPosition = (MV*vec4(light_position,1)); vec3 L = (vEyeSpaceLightPosition.xyz-vEyeSpacePosition); float d = length(L); L = normalize(L); float attenuationAmount = 1.0/(k0 + (k1*d) + (k2*d*d)); float diffuse = max(0, dot(vEyeSpaceNormal, L)) * attenuationAmount; if(vShadowCoords.w>1) { vec3 uv = vShadowCoords.xyz/vShadowCoords.w; float depth = uv.z; vec4 moments = texture(shadowMap, uv.xy); float E_x2 = moments.y; float Ex_2 = moments.x*moments.x; float var = E_x2-Ex_2; var = max(var, 0.00002); float mD = depth-moments.x; float mD_2 = mD*mD; float p_max = var/(var+ mD_2); diffuse *= max(p_max, (depth<=moments.x)?1.0:0.2); } vFragColor = diffuse*vec4(diffuse_color, 1); }
I jeszcze jedno… Wariancyjne mapowanie cieni jest pomysłem niezwykle interesującym. Jego wadą jest generowanie artefaktów objawiających się wyciekaniem światła do obszarów, które powinny być zacienione. Pojawiło się już wiele ulepszeń techniki podstawowej, jak chociażby wariancyjne mapy cienia z sumowaniem obszarów, warstwowe wariancyjne mapy cienia czy najnowsze mapy cienia z rozkładem próbek. Informacje na ich temat można znaleźć w publikacjach podanych
147
OpenGL. Receptury dla programisty
w punkcie „Dowiedz się więcej”. Zachęcam Czytelnika, aby po zapoznaniu się z podstawową wersją mapowania wariacyjnego spróbował samodzielnie zaimplementować inne warianty tego algorytmu. Pomocnych wskazówek można szukać we wspomnianych wyżej publikacjach. Przykładowa aplikacja wyświetla, tak jak poprzednio, trzy obiekty (płaszczyznę, kulę i kostkę) oświetlone światłem punktowym. Przeciąganie myszy z wciśniętym prawym przyciskiem obraca źródło światła wokół obiektów. Rezultat działania tej aplikacji jest pokazany na poniższym rysunku.
Porównując ten obraz z rezultatami poprzednich receptur, widzimy, że technika wariancyjna daje znacznie lepsze rezultaty niż konwencjonalne mapowanie cieni nawet wzbogacone filtrowaniem PCF. I wcale nie potrzebuje do tego większej liczby próbek. Żeby jakąkolwiek inną techniką osiągnąć coś podobnego, trzeba by zastosować próbkowanie dużego obszaru z dużą liczbą próbek. Wariancyjne mapowanie cieni doskonale nadaje się do stosowania w aplikacjach czasu rzeczywistego, np. w grach.
148
Rozdział 4. • Światła i cienie
Dowiedz się więcej Więcej szczegółów na temat wariancyjnego mapowania cieni znajdziesz w następujących publikacjach: William Donnelly i Andrew Lauritzen, Variance Shadow Maps, materiały z sympozjum
poświęconego interaktywnej grafice 3D i grom komputerowym, 2006, s. 161 – 165. Andrew Lauritzen, GPU Gems 3, rozdział 8., „Summed-Area Variance Shadow Maps”,
http://http.developer.nvidia.com/GPUGems3/gpugems3_ch08.html. Andrew Lauritzen i Michael McCool, Layered Variance Shadow Map, materiały
z konferencji Graphics Interface 2008, s. 139 – 146. Andrew Lauritzen, Marco Salvi i Aaron Lefohn, Sample Distribution Shadow Maps,
materiały z sympozjum ACM SIGGRAPH poświęconego interaktywnej grafice 3D i grom komputerowym, luty 2011.
149
OpenGL. Receptury dla programisty
150
5 Formaty modeli siatkowych i systemy cząsteczkowe W tym rozdziale: Modelowanie terenu przy użyciu mapy wysokości Wczytywanie modeli 3ds przy użyciu odrębnych buforów Wczytywanie modeli OBJ przy użyciu buforów z przeplotem Wczytywanie modeli w formacie EZMesh Implementacja prostego systemu cząsteczkowego
Wstęp W prostych aplikacjach pokazowych można poprzestać na obiektach podstawowych, takich jak sześcian czy sfera, ale w aplikacjach użytkowych i grach najczęściej używane są modele siatkowe wygenerowane w specjalnie do tego celu stworzonych programach typu 3ds Max czy Maya. W przypadku gier modele te po wygenerowaniu są eksportowane do formatów specyficznych dla poszczególnych gier i dopiero wtedy tam są wczytywane. Wśród wielu istniejących formatów jedne są bardziej popularne, inne mniej. Do tych pierwszych z pewnością należy 3ds, opracowany przez firmę Autodesk, i OBJ, stworzony przez firmę Wavefront. W tym rozdziale przedstawię receptury na wczytywanie modeli w takich formatach. Pokażę, jak geometrię przechowywaną w zewnętrznych plikach umieścić w buforze
OpenGL. Receptury dla programisty
wierzchołków funkcjonującym w pamięci procesora graficznego. Zobaczymy też, jak należy wczytywać materiały i tekstury niezbędne do tego, by modele wyglądały bardziej realistycznie. Często ważnym elementem sceny jest plenerowe otoczenie modeli i dlatego zajmiemy się także modelowaniem terenu. Na koniec zaimplementujemy prosty system cząstek, który umożliwi nam symulację takich zjawisk jak ogień i dym. Każdą z omówionych tu technik można zaimplementować w ramach rdzennego profilu biblioteki OpenGL w wersji 3.3 lub nowszej.
Modelowanie terenu przy użyciu mapy wysokości Wiele aplikacji wymaga renderowania terenu. Zobaczmy więc, jak można to zrobić w nowym wydaniu OpenGL. Najpierw za pomocą biblioteki SOIL wczytamy mapę wysokości zawierającą informacje o przemieszczeniach siatki terenu. Następnie wygenerujemy dwuwymiarową siatkę o gęstości dopasowanej do wymaganej rozdzielczości terenu i za pomocą shadera wierzchołków zdeformujemy ją zgodnie z informacjami zapisanymi w mapie przemieszczeń. W razie potrzeby możemy wartości przemieszczeń przeskalować, aby deformację terenu zwiększyć lub zmniejszyć.
Przygotowania Siatka, jaką musimy wygenerować, powinna mieć rozdzielczość dopasowaną do ukształtowania terenu. Procedura generowania tego typu geometrii była już omawiana w rozdziale 1., w recepturze „Wykonanie deformatora siatki przy użyciu shadera wierzchołków”. Pełny kod modelowania terenu znajduje się w folderze Rozdział5/WczytywanieTerenu.
Jak to zrobić? Rozpocznij od wykonania następujących prostych czynności: 1. Wczytaj mapę wysokości, używając do tego celu biblioteki SOIL, a następnie wygeneruj na jej podstawie teksturę. Filtrowanie tekstury ustaw na GL_NEAREST, ponieważ potrzebne będą dokładne wartości z pobranej mapy. Zastosowanie filtrowania typu GL_LINEAR dałoby wartości interpolowane. Ze względu na to, że mapy wysokości nie można powtarzać w układzie kafelkowym, ustaw tryb zawijania GL_CLAMP. int texture_width = 0, texture_height = 0, channels=0; GLubyte* pData = SOIL_load_image(filename.c_str(), &texture_width, &texture_height, &channels, SOIL_LOAD_L); //odwrócenie obrazu w pionie for( j = 0; j*2 < texture_height; ++j ) { int index1 = j * texture_width ;
152
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
int index2 = (texture_height - 1 - j) * texture_width ; for( i = texture_width ; i > 0; --i ) { GLubyte temp = pData[index1]; pData[index1] = pData[index2]; pData[index2] = temp; ++index1; ++index2; } } glGenTextures(1, &heightMapTextureID); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, heightMapTextureID); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP); glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, texture_width, texture_height, 0, GL_RED, GL_UNSIGNED_BYTE, pData); SOIL_free_image_data(pData);
2. Przygotuj geometrię terenu przez wygenerowanie zbioru punktów w płaszczyźnie XZ. Niech parametr TERRAIN_WIDTH określa liczbę wierzchołków wzdłuż osi X, a parametr TERRAIN_DEPTH — liczbę wierzchołków wzdłuż osi Z. for( j=0;j
3. Napisz shader wierzchołków, który będzie przemieszczał siatkę terenu. Szczegóły znajdziesz w pliku Rozdział5/WczytywanieTerenu/shadery/shader.vert. Wielkości przemieszczeń są pobierane z mapy wysokości. Następnie są one dodawane do bieżących położeń wierzchołków, po czym następuje mnożenie przez połączoną macierz modelu, widoku i rzutowania (MVP), aby przejść do przestrzeni przycięcia. Uniform HALF_TERRAIN_SIZE zawiera połowę wierzchołków wzdłuż osi X i połowę wzdłuż osi Z, czyli HALF_TERRAIN_SIZE = ivec2(TERRAIN_WIDTH/2, TERRAIN_DEPTH/2). Z kolei uniform scale służy do skalowania wysokości pobranej z mapy terenu. Uniformy half_scale i HALF_TERRAIN_SIZE są potrzebne do ustawienia siatki w środku układu współrzędnych. #version 330 core layout (location=0) in vec3 vVertex; uniform mat4 MVP; uniform ivec2 HALF_TERRAIN_SIZE; uniform sampler2D heightMapTexture; uniform float scale;
153
OpenGL. Receptury dla programisty
uniform float half_scale; void main() { float height = texture(heightMapTexture, vVertex.xz).r*scale - half_scale; vec2 pos = (vVertex.xz*2.0-1)*HALF_TERRAIN_SIZE; gl_Position = MVP*vec4(pos.x, height, pos.y, 1); }
4. Wczytaj shadery oraz odpowiednie lokalizacje uniformów i atrybutów. Na etapie inicjalizacji ustaw również wartości uniformów niezmiennych przez cały czas działania aplikacji. shader.LoadFromFile(GL_VERTEX_SHADER, "shadery/shader.vert"); shader.LoadFromFile(GL_FRAGMENT_SHADER, "shadery/shader.frag"); shader.CreateAndLinkProgram(); shader.Use(); shader.AddAttribute("vVertex"); shader.AddUniform("heightMapTexture"); shader.AddUniform("scale"); shader.AddUniform("half_scale"); shader.AddUniform("HALF_TERRAIN_SIZE"); shader.AddUniform("MVP"); glUniform1i(shader("heightMapTexture"), 0); glUniform2i(shader("HALF_TERRAIN_SIZE"), TERRAIN_WIDTH>>1, TERRAIN_DEPTH>>1); glUniform1f(shader("scale"), scale); glUniform1f(shader("half_scale"), half_scale); shader.UnUse();
5. W kodzie renderingu włącz shader i po przekazaniu mu w charakterze uniformów macierzy modelu, widoku i rzutowania wyrenderuj teren. shader.Use(); glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(MVP)); glDrawElements(GL_TRIANGLES,TOTAL_INDICES, GL_UNSIGNED_INT, 0); shader.UnUse();
Jak to działa? Renderowanie terenu jest stosunkowo proste do zaimplementowania. Najpierw GPU generuje geometrię i zapisuje ją w swoich buforach. Następnie wczytywana jest mapa wysokości, po czym następuje jej przekazanie do shadera wierzchołków jako samplera tekstury. W shaderze wierzchołków wysokość wierzchołka jest wyznaczana przez odczytanie wartości z tekstury w miejscu odpowiadającym położeniu wierzchołka. Ostateczne jego współrzędne są połączeniem współrzędnych wejściowych z odczytaną wysokością. Uzyskany w ten sposób wektor położenia jest mnożony przez macierz modelu, widoku i rzutowania, co daje położenie
154
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
w przestrzeni przycięcia. Technikę przemieszczania wierzchołków można wykorzystać również do urealistycznienia wyglądu modelu wykonanego w niskiej rozdzielczości przez wzbogacenie jej drobnymi szczegółami powierzchni. Przykładowa aplikacja zbudowana zgodnie z przedstawioną recepturą renderuje siatkowy teren (patrz rysunek poniżej).
Mapa wysokości użyta do wygenerowania tego terenu wygląda jak na rysunku na następnej stronie.
I jeszcze jedno… Zaprezentowana w tej recepturze metoda generowania terenu polega na podnoszeniu wierzchołków o wartość pobraną z mapy wysokości. Do tworzenia takich map służą rozmaite programy. Jednym z nich jest Terragen (http://planetside.co.uk/). Przydatnym narzędziem jest również World Machine (http://world-machine.com/). Ogólne informacje o generowaniu wirtualnych terenów można znaleźć na stronie http://vterrain.org/. Wirtualne tereny można rzeźbić również metodami proceduralnymi, takimi jak fraktalowe generowanie terenu. Stosowane są także metody szumowe.
155
OpenGL. Receptury dla programisty
Dowiedz się więcej Więcej informacji na temat programowego generowania terenów znajdziesz w następujących publikacjach: Trent Polack, Focus on 3D Terrain Programming, Premier Press, 2002. David Luebke, Level of Detail for 3D Graphics, Morgan Kaufmann Publishers,
2003, rozdział 7., „Terrain Level of Detail”.
Wczytywanie modeli 3ds przy użyciu odrębnych buforów Utworzymy teraz moduł umożliwiający wczytanie modelu zapisanego w formacie 3ds, który choć prosty, jest niezwykle wydajnym formatem binarnym służącym do zapisywania cyfrowych obiektów.
156
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
Przygotowania Gotowy kod zbudowany na podstawie tej receptury znajduje się w folderze Rozdział5/ Przeglądarka3ds. Do wczytywania tekstur dla modelu 3ds wykorzystamy rozwiązanie pokazane w recepturze „Rysowanie obrazu 2D przy użyciu shadera fragmentów i biblioteki SOIL” z rozdziału 1.
Jak to zrobić? Aby zaimplementować wczytywanie zawartości plików 3ds, wykonaj następujące czynności: 1. Utwórz obiekt klasy C3dsLoader. Następnie wywołaj metodę C3dsLoader::Load3DS, przekazując jej nazwę pliku z siatką i zestaw wektorów do przechowania siatek składowych, wierzchołków, normalnych, współrzędnych uv, ścianek, indeksów i materiałów. if(!loader.Load3DS(mesh_filename.c_str( ), meshes, vertices, normals, uvs, faces, indices, materials)) { cout<<"Nie moge wczytac siatki 3ds"<
2. Po wczytaniu siatki weź listę przypisanych do niej materiałów i załaduj wszystkie występujące tam tekstury do obiektu tekstury typowego dla OpenGL. for(size_t k=0;ktextureMaps.size();m++) { GLuint id = 0; glGenTextures(1, &id); glBindTexture(GL_TEXTURE_2D, id); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); int texture_width = 0, texture_height = 0, channels=0; const string& filename = materials[k]->textureMaps[m]->filename; std::string full_filename = mesh_path; full_filename.append(filename); GLubyte* pData = SOIL_load_image(full_filename.c_str(), &texture_width, &texture_height, &channels, SOIL_LOAD_AUTO); if(pData == NULL) { cerr<<"Nie moge wczytac obrazu: "<< full_filename.c_str()<
157
OpenGL. Receptury dla programisty
for( j = 0; j*2 < texture_height; ++j ) { int index1 = j * texture_width * channels; int index2 = (texture_height - 1 - j) * texture_width * channels; for( i = texture_width * channels; i > 0; --i ){ GLubyte temp = pData[index1]; pData[index1] = pData[index2]; pData[index2] = temp; ++index1; ++index2; } } GLenum format = GL_RGBA; switch(channels) { case 2: format = GL_RG32UI; break; case 3: format = GL_RGB; break; case 4: format = GL_RGBA; break; } glTexImage2D(GL_TEXTURE_2D, 0, format, texture_width, texture_height, 0, format, GL_UNSIGNED_BYTE, pData); SOIL_free_image_data(pData); textureMaps[filename]=id; } }
3. Przekaż wczytane atrybuty poszczególnych wierzchołków, czyli położenia (vertices), współrzędne tekstury (uvs), normalne (normals) i indeksy trójkątów (indices), do pamięci GPU, przydzielając każdemu atrybutowi oddzielny bufor. W celu łatwiejszego zarządzania obiektami tych buforów najpierw zwiąż z bieżącym kontekstem obiekt tablicy wierzchołków (vaoID). glBindVertexArray(vaoID); glBindBuffer (GL_ARRAY_BUFFER, vboVerticesID); glBufferData (GL_ARRAY_BUFFER, sizeof(glm::vec3)*vertices.size(), &(vertices[0].x), GL_STATIC_DRAW); glEnableVertexAttribArray(shader["vVertex"]); glVertexAttribPointer(shader["vVertex"], 3, GL_FLOAT, GL_FALSE,0,0); glBindBuffer (GL_ARRAY_BUFFER, vboUVsID); glBufferData (GL_ARRAY_BUFFER, sizeof(glm::vec2)*uvs.size(), &(uvs[0].x), GL_STATIC_DRAW); glEnableVertexAttribArray(shader["vUV"]); glVertexAttribPointer(shader["vUV"], 2, GL_FLOAT, GL_FALSE,0, 0); glBindBuffer (GL_ARRAY_BUFFER, vboNormalsID); glBufferData (GL_ARRAY_BUFFER, sizeof(glm::vec3)*normals.size(), &(normals[0].x), GL_STATIC_DRAW); glEnableVertexAttribArray(shader["vNormal"]); glVertexAttribPointer(shader["vNormal"], 3, GL_FLOAT, GL_FALSE, 0, 0);
4. Jeśli w pliku 3ds jest tylko jeden materiał, umieść indeksy ścianek w GL_ELEMENT_ARRAY_ BUFFER, aby umożliwić wyrenderowanie całej siatki za jednym razem. Jednak przy większej liczbie materiałów zwiąż każdą siatkę składową oddzielnie. Funkcja
158
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
glBufferData alokuje pamięć GPU, ale jej nie inicjalizuje. Aby to zrobić, użyj funkcji glMapBuffer w celu uzyskania bezpośredniego wskaźnika do tej pamięci. Dzięki temu będziesz mógł tę pamięć zapisywać. Zamiast glMapBuffer możesz użyć funkcji glBufferSubData, która od razu kopiuje zawartość bufora do zaalokowanej pamięci. if(materials.size()==1) { glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vboIndicesID); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(GLushort)*3*faces.size(), 0, GL_STATIC_DRAW); GLushort* pIndices = static_cast(glMapBuffer(GL_ELEMENT_ARRAY_BUFFER, GL_WRITE_ONLY)); for(size_t i=0;i
5. Przygotuj shader wierzchołków dający na wyjściu położenie w przestrzeni przycięcia i współrzędne tekstury dla danego wierzchołka. Współrzędne tekstury, jako vUVout, będą potem interpolowane przez rasteryzer w shaderze fragmentów. #version 330 core layout(location = 0) in vec3 vVertex; layout(location = 1) in vec3 vNormal; layout(location = 2) in vec2 vUV; smooth out vec2 vUVout; uniform mat4 P; uniform mat4 MV; uniform mat3 N; smooth out vec3 vEyeSpaceNormal; smooth out vec3 vEyeSpacePosition; void main() { vUVout=vUV; vEyeSpacePosition = (MV*vec4(vVertex,1)).xyz; vEyeSpaceNormal = N*vNormal; gl_Position = P*vec4(vEyeSpacePosition,1); }
6. Przygotuj shader fragmentów, który będzie przeglądał teksturę, używając interpolowanych współrzędnych z rasteryzera. Jeśli siatka składowa ma przypisaną teksturę, zastosuj liniową interpolację między kolorem tekstury a rozproszonym kolorem materiału. Użyj do tego celu funkcji mix.
159
OpenGL. Receptury dla programisty
#version 330 core uniform sampler2D textureMap; uniform float hasTexture; uniform vec3 light_position;//położenie światła w przestrzeni obiektu uniform mat4 MV; smooth in vec3 vEyeSpaceNormal; smooth in vec3 vEyeSpacePosition; smooth in vec2 vUVout; layout(location=0) out vec4 vFragColor; const float k0 = 1.0;//tłumienie stałe const float k1 = 0.0;//tłumienie liniowe const float k2 = 0.0;//tłumienie kwadratowe void main() { vec4 vEyeSpaceLightPosition = (MV*vec4(light_position,1)); vec3 L = (vEyeSpaceLightPosition.xyz-vEyeSpacePosition); float d = length(L); L = normalize(L); float diffuse = max(0, dot(vEyeSpaceNormal, L)); float attenuationAmount = 1.0/(k0 + (k1*d) + (k2*d*d)); diffuse *= attenuationAmount; vFragColor = diffuse*mix(vec4(1), texture(textureMap, vUVout), hasTexture); }
7. W kodzie renderującym uruchom program shaderowy, ustaw uniformy i wyrenderuj siatkę, uzależniając to od liczby przypisanych do niej materiałów. Jeśli materiał jest tylko jeden, zrób to w jednym wywołaniu funkcji glDrawElement, wykorzystując indeksy przyłączone do punktu wiązania GL_ELEMENT_ARRAY_BUFFER. glBindVertexArray(vaoID); { shader.Use(); glUniformMatrix4fv(shader("MV"), 1, GL_FALSE, glm::value_ptr(MV)); glUniformMatrix3fv(shader("N"), 1, GL_FALSE, glm::value_ptr(glm::inverseTranspose(glm::mat3(MV)))); glUniformMatrix4fv(shader("P"), 1, GL_FALSE, glm::value_ptr(P)); glUniform3fv(shader("light_position"),1, &(lightPosOS.x)); if(materials.size()==1) { GLint whichID[1]; glGetIntegerv(GL_TEXTURE_BINDING_2D, whichID); if(textureMaps.size()>0) { if(whichID[0] != textureMaps[materials[0]->textureMaps[0]-> filename]) { glBindTexture(GL_TEXTURE_2D, textureMaps[materials[0]->textureMaps[0]->filename]); glUniform1f(shader("hasTexture"),1.0);
160
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
} } else { glUniform1f(shader("hasTexture"),0.0); glUniform3fv(shader("diffuse_color"),1, materials[0]->diffuse); } glDrawElements(GL_TRIANGLES, meshes[0]->faces.size()*3, GL_UNSIGNED_SHORT, 0); }
8. Jeśli materiałów jest więcej niż jeden, musisz z każdego pobrać teksturę (jeśli jest) lub kolor światła rozpraszanego. Przekaż też zapisaną w materiale tablicę sub_indices do funkcji glDrawElements, aby wczytać same indeksy. else { for(size_t i=0;itextureMaps.size()>0) { if(whichID[0] != textureMaps[materials[i]->textureMaps[0]-> filename]) { glBindTexture(GL_TEXTURE_2D, textureMaps[materials[i]-> textureMaps[0]->filename]); } glUniform1f(shader("hasTexture"),1.0); } else { glUniform1f(shader("hasTexture"),0.0); } glUniform3fv(shader("diffuse_color"), 1, materials[i]->diffuse); glDrawElements(GL_TRIANGLES, materials[i]->sub_indices.size(), GL_UNSIGNED_SHORT, &(materials[i]->sub_indices[0])); } } shader.UnUse();
Jak to działa? Głównym składnikiem w tej recepturze jest funkcja C3dsLoader::Load3DS. Plik 3ds zawiera dane binarne zebrane w uporządkowane hierarchicznie bloki (chunks). Pierwsze dwa bajty bloku określają jego identyfikator (ID), a następne cztery — długość (wyrażoną w bajtach). Odczytujemy więc kolejne bloki i zapisujemy zawarte w nich dane do odpowiednich wektorów (zmiennych), dopóki nie napotkamy końca pliku. Lista najczęściej spotykanych bloków jest pokazana na rysunku na następnej stronie. Zauważ, że gdy chcemy wczytać jakiś blok pochodny, musimy też odczytać blok nadrzędny, aby przesunąć wskaźnik pliku we właściwe miejsce. Procedura wczytująca najpierw znajduje całkowity rozmiar pliku z siatką 3ds wyrażony w bajtach. Następnie w pętli while sprawdza, czy
161
OpenGL. Receptury dla programisty
bieżąca wartość wskaźnika pliku nie przekracza rozmiaru pliku. Jeśli nie, odczytywane są dwa bajty (ID bloku) i cztery następne (długość bloku). while(infile.tellg() < fileSize) { infile.read(reinterpret_cast(&chunk_id), 2); infile.read(reinterpret_cast(&chunk_length), 4);
Potem zaczyna się długa instrukcja switch…case ze wszystkimi identyfikatorami prowadzącymi do interesujących nas bloków i odczytywaniem ich zawartości. switch(chunk_id) { case 0x4d4d: break; case 0x3d3d: break; case 0x4000: { std::string name = ""; char c = ' '; while(c!='\0') { infile.read(&c,1); name.push_back(c); } pMesh = new C3dsMesh(name); meshes.push_back(pMesh);
162
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
} break; …//pozostałe bloki }
Wszystkie nazwy (obiektu, materiału czy mapy) muszą być odczytywane bajt po bajcie aż do napotkania znaku końca (\0). Przy wczytywaniu wierzchołków najpierw odczytujemy dwa bajty zawierające całkowitą liczbę wierzchołków (N). Skoro są to tylko dwa bajty, to znaczy, że siatka nie może zawierać więcej wierzchołków niż 65 536. Potem następuje odczyt porcji danych — sizeof(glm::vec3)*N bajtów — wprost do wierzchołków siatki. case 0x4110: { unsigned short total_vertices=0; infile.read(reinterpret_cast(&total_vertices), 2); pMesh->vertices.resize(total_vertices); infile.read(reinterpret_cast(&pMesh->vertices[0].x), sizeof(glm::vec3) *total_vertices); }break;
Podobnie jak w przypadku wierzchołków informacje o ściankach przechowywane są w postaci trzech krótkich liczb całkowitych bez znaku zawierających indeksy trójkąta i jednej dodatkowej takiej liczby ze znacznikami ścianki. A zatem dla siatki złożonej z M trójkątów musimy odczytać 4*M krótkich liczb całkowitych bez znaku. Dla wygody najpierw zapisujemy te liczby w strukturze Face, a dopiero potem pobieramy z nich odpowiednie informacje. case 0x4120: { unsigned short total_tris=0; infile.read(reinterpret_cast(&total_tris), 2); pMesh->faces.resize(total_tris); infile.read(reinterpret_cast(&pMesh->faces[0].a), sizeof(Face)*total_tris); }break;
W taki sam sposób odbywa się odczytywanie identyfikatorów materiałów ścianki i współrzędnych tekstury — najpierw odczytywane są wartości ogólne, a dopiero potem są pobierane z pliku odpowiednie liczby bajtów z właściwymi informacjami. Zauważ, że blok koloru (np. 0xa010, 0xa020 lub 0xa030) zawiera informacje o kolorze w bloku podrzędnym (o identyfikatorze z zakresu od 0x0010 do 0x0013) zależnym od typu danych zastosowanych w bloku nadrzędnym. Po wczytaniu informacji o siatce i materiałach generujemy globalne wektory wierzchołków (vertices), współrzędnych tekstury (uvs) i indeksów (indices). Ułatwia nam to renderowanie siatek składowych w funkcji renderującej. size_t total = materials.size(); for(size_t i=0;iface_ids.size()==0) materials.erase(materials.begin()+i); } for(size_t i=0;ivertices.size();j++)
163
OpenGL. Receptury dla programisty
vertices.push_back(meshes[i]->vertices[j]); for(size_t j=0;juvs.size();j++) uvs.push_back(meshes[i]->uvs[j]);
}
for(size_t j=0;jfaces.size();j++) { faces.push_back(meshes[i]->faces[j]); }
Zauważ, że w formacie 3ds nie ma wprost zapisanych wektorów normalnych dla poszczególnych wierzchołków. Są zapisane tylko grupy wygładzania mówiące nam, które ścianki mają wspólne wektory normalne. Mając położenie wierzchołka i informacje o sąsiadujących z nim ściankach, możemy wyznaczyć jego wektor normalny przez uśrednienie wektorów normalnych tychże ścianek. Realizujący to zadanie fragment kodu z pliku 3ds.cpp jest pokazany na poniższym listingu. Najpierw rezerwujemy miejsce dla wierzchołkowych wektorów normalnych. Następnie wyznaczamy wektor normalny dla ścianki, obliczając w tym celu iloczyn wektorowy dwóch krawędzi. Uzyskany wektor dodajemy do odpowiedniego indeksu wierzchołka i na koniec przeprowadzamy normalizację wszystkich wyznaczonych wektorów. normals.resize(vertices.size()); for(size_t j=0;j
Gdy już mamy wszystkie atrybuty wierzchołków i informacje o ściankach, możemy przystąpić do grupowania trójkątów według materiałów. Tworzymy pętlę przebiegającą wszystkie materiały i poszerzamy ich identyfikatory ściankowe o trzy identyfikatory wierzchołków dla każdej ścianki. for(size_t i=0;iface_ids.size();j++) { pMat->sub_indices.push_back(faces[pMat->face_ids[j]].a); pMat->sub_indices.push_back(faces[pMat->face_ids[j]].b); pMat->sub_indices.push_back(faces[pMat->face_ids[j]].c); } }
164
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
I jeszcze jedno… Rezultat działania aplikacji zbudowanej na podstawie zaprezentowanej receptury jest pokazany na poniższym rysunku. Renderuje ona trzy kostki leżące na czworokątnej płaszczyźnie. Położenie kamery można zmieniać przez przesuwanie myszy z wciśniętym lewym przyciskiem. Wciśnięcie prawego przycisku umożliwia zmianę położenia punktowego źródła światła. Każda kostka ma przypisanych sześć tekstur, natomiast płaszczyzna nie ma żadnej i dlatego wykorzystuje kolor światła rozpraszanego.
Zaprezentowana przeglądarka plików 3ds nie uwzględnia wcale grup wygładzania. Zainteresowanym zbudowaniem bardziej zaawansowanej przeglądarki polecam bibliotekę lib3ds, która zawiera funkcje obsługujące grupy wygładzania, ścieżki animacyjne, kamery, światła, klatki kluczowe itd.
Dowiedz się więcej Więcej informacji na temat wczytywania modeli 3ds znajdziesz pod następującymi adresami:
165
OpenGL. Receptury dla programisty
Lib3ds: http://code.google.com/p/lib3ds/. Przeglądarka plików 3ds według Damiano Vitulliego: http://www.spacesimulator.net/
wiki/index.php?title=Tutorials:3ds_Loader. Szczegóły formatu 3ds w Wikipedii: http://en.wikipedia.org/wiki/.3ds.
Wczytywanie modeli OBJ przy użyciu buforów z przeplotem
re.
pl
W tej recepturze zaimplementujemy wczytywanie modeli zapisanych w formacie OBJ opracowanym w firmie Wavefront. Inaczej niż w poprzedniej recepturze, nie będziemy używać oddzielnych buforów do załadowania położeń wierzchołków, normalnych i współrzędnych tekstury, lecz załadujemy wszystko do jednego bufora z przeplotem danych. Jako że powiązane ze sobą dane będą blisko siebie, dostęp do nich będzie łatwiejszy i szybszy.
ha
Przygotowania
Fr
Jak to zrobić?
ikS
Gotowy kod dla tej receptury znajduje się w folderze Rozdział5/PrzeglądarkaOBJ.
ww w.
Rozpocznij od następujących prostych czynności: 1. Utwórz globalną referencję do obiektu ObjLoader. Wywołaj funkcję ObjLoader::Load, przekazując jej nazwę pliku OBJ. Przekaż też wektory do przechowywania siatek, wierzchołków, indeksów i materiałów zawartych we wczytywanym pliku. ObjLoader obj; if(!obj.Load(mesh_filename.c_str(), meshes, vertices, indices, materials)) { cout<<"Cannot load the 3ds mesh"<
2. Dla każdego materiału, jeśli zawiera mapę tekstury, wygeneruj obiekt teksturowy OpenGL za pomocą biblioteki SOIL. for(size_t k=0;kmap_Kd != "") { GLuint id = 0; glGenTextures(1, &id); glBindTexture(GL_TEXTURE_2D, id); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
166
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); int texture_width = 0, texture_height = 0, channels=0; const string& filename = materials[k]->map_Kd; std::string full_filename = mesh_path; full_filename.append(filename); GLubyte* pData = SOIL_load_image(full_filename.c_str(), &texture_width, &texture_height, &channels, SOIL_LOAD_AUTO); if(pData == NULL) { cerr<<"Nie moge wczytac obrazu: "<
3. Przygotuj shadery i wygeneruj obiekt bufora, który posłuży do przechowywania pobieranych z pliku danych w pamięci GPU. Przygotowanie shadera odbywa się podobnie jak w poprzedniej recepturze. glGenVertexArrays(1, &vaoID); glGenBuffers(1, &vboVerticesID); glGenBuffers(1, &vboIndicesID); glBindVertexArray(vaoID); glBindBuffer (GL_ARRAY_BUFFER, vboVerticesID); glBufferData (GL_ARRAY_BUFFER, sizeof(Vertex)*vertices.size(), &(vertices[0].pos.x), GL_STATIC_DRAW); glEnableVertexAttribArray(shader["vVertex"]); glVertexAttribPointer(shader["vVertex"], 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),0); glEnableVertexAttribArray(shader["vNormal"]); glVertexAttribPointer(shader["vNormal"], 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),(const GLvoid*)(offsetof(Vertex, normal)) ); glEnableVertexAttribArray(shader["vUV"]); glVertexAttribPointer(shader["vUV"], 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)(offsetof(Vertex, uv)) ); if(materials.size()==1) {
167
OpenGL. Receptury dla programisty
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vboIndicesID); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(GLushort)*indices.size(), &(indices[0]), GL_STATIC_DRAW); }
4. Zwiąż obiekt tablicy wierzchołków stowarzyszony z siatką, włącz shader i przekaż shaderowe uniformy, czyli macierze modelu i widoku ( MV), rzutowania (P) oraz normalną (N), położenie światła itd. glBindVertexArray(vaoID); { shader.Use(); glUniformMatrix4fv(shader("MV"), 1, GL_FALSE, glm::value_ptr(MV)); glUniformMatrix3fv(shader("N"), 1, GL_FALSE, glm::value_ptr(glm::inverseTranspose(glm::mat3(MV)))); glUniformMatrix4fv(shader("P"), 1, GL_FALSE, glm::value_ptr(P)); glUniform3fv(shader("light_position"),1, &(lightPosOS.x));
5. Aby narysować siatkę (lub siatkę składową), przejrzyj wszystkie przypisane jej materiały i dla tych, które zawierają mapę tekstury, zwiąż teksturę z punktem GL_TEXTURE_2D. Jeśli materiał nie ma mapy tekstury, zastosuj kolor domyślny. Na koniec wywołaj funkcję glDrawElements, aby wyrenderować całą siatkę (lub siatkę składową). for(size_t i=0;imap_Kd !="") { glUniform1f(shader("useDefault"), 0.0); GLint whichID[1]; glGetIntegerv(GL_TEXTURE_BINDING_2D, whichID); if(whichID[0] != textures[i]) glBindTexture(GL_TEXTURE_2D, textures[i]); } else glUniform1f(shader("useDefault"), 1.0); if(materials.size()==1) glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_SHORT, 0); else glDrawElements(GL_TRIANGLES, pMat->count, GL_UNSIGNED_SHORT, (const GLvoid*)(& indices [pMat->offset])); } shader.UnUse();
Jak to działa? Głównym składnikiem w tej recepturze jest funkcja ObjLoader::Load zdefiniowana w pliku Obj.cpp. Plik zapisany w formacie OBJ jest plikiem tekstowym z różnymi tekstowymi deskryptorami poszczególnych składników siatki. Zazwyczaj na początku jest geometria siatki, czyli jej wierzchołki. Każdy wierzchołek jest reprezentowany przez trzy liczby zmiennoprzecinkowe
168
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
poprzedzone literą v. Jeśli są wektory normalne, to ich definicje składają się z trzech liczb zmiennoprzecinkowych poprzedzonych literami vn. Współrzędne tekstury to dwie liczby zmiennoprzecinkowe poprzedzone literami vt. Wiersze komentarzy rozpoczynają się od znaku # i są całkowicie pomijane. Po geometrii określana jest topologia. Wiersz zaczyna się od litery f i po niej następują indeksy wierzchołków wielokąta. W przypadku trójkąta są to trzy grupy indeksów, przy czym w każdej z nich na pierwszym miejscu jest indeks położenia wierzchołka, potem indeks współrzędnych tekstury (jeśli występują) i na końcu indeks normalnej (jeśli występuje). Przypominam, że wartości indeksów rozpoczynają się od 1, a nie od 0. Przykładowo załóżmy, że mamy geometrię czworokątną z czterema indeksami położenia, czterema (1,2,3,4) indeksami współrzędnych tekstury (5,6,7,8) i czterema indeksami normalnych (1,1,1,1). Jej topologia byłaby więc zapisana w sposób następujący: f 1/5/1 2/6/1 3/7/1 4/8/1
Gdyby siatka była zbudowana z trójkątów mających przypisane indeksy położenia wierzchołków (1,2,3), współrzędnych tekstury (7,8,9) i normalnych (4,5,6), jej topologia wyglądałaby tak: f 1/7/4 2/8/5 3/9/6
Jeśli w pierwszym przykładzie brakłoby współrzędnych tekstury, topologia siatki przybrałaby następującą postać: f 1//1 2//1 3//1 4//1
W formacie OBJ informacje materiałowe są zapisywane w odrębnym pliku (.mtl), który zawiera podobne deskryptory poszczególnych materiałów z ich kolorami światła otaczającego, rozpraszanego i odbijanego, mapami tekstur itd. Szczegółowy opis tego wszystkiego znajduje się w specyfikacji formatu. Plik materiałowy stowarzyszony z danym plikiem OBJ jest deklarowany za pomocą słowa kluczowego mtllib i następującej po nim nazwy pliku .mtl. Zazwyczaj oba pliki są umieszczane w tym samym folderze. Definicję wielokąta poprzedzają słowo kluczowe usemtl i nazwa materiału przypisanego do danego wielokąta. Definicje wielokątów mogą być grupowane przez umieszczenie na początku nazwy grupy (lub obiektu) z przedrostkiem g (lub o). Funkcja najpierw odnajduje bieżący przedrostek, a następnie przechodzi do sekcji danych właściwych dla tego przedrostka. Po rozszyfrowaniu końcowych łańcuchów pobrane dane trafiają do odpowiednich wektorów. Ze względu na wydajność indeksy są grupowane według materiałów, bo to przyśpiesza późniejsze sortowanie i renderowanie siatek składowych. Plik materiałowy (.mtl) jest wczytywany za pomocą funkcji ReadMaterialLibrary. Szczegóły znajdziesz w pliku Obj.cpp. Pierwszym elementem procesu jest analiza składniowa pliku. Potem następuje przenoszenie danych do pamięci GPU. W tej recepturze użyjemy bufora z przeplotem, czyli zamiast składować poszczególne atrybuty wierzchołków w odrębnych buforach umieścimy je naprzemiennie w jednym buforze. Najpierw położenie wierzchołka, potem normalna, a na końcu współrzędne
169
OpenGL. Receptury dla programisty
tekstury. Aby to uzyskać, musimy wcześniej zdefiniować własny format zapisu atrybutów każdego wierzchołka. Określimy więc strukturę Vertex (wierzchołek), zgodnie z którą będzie budowany wektor vertices (wierzchołki). struct Vertex { glm::vec3 pos, normal; glm::vec2 uv; };
Wygenerujemy najpierw obiekt tablicy wierzchołków, a potem obiekt bufora wierzchołków. Następnie zwiążemy obiekt bufora, przekazując mu nasze wierzchołki. Dla każdego atrybutu określimy miejsce jego występowania w strumieniu danych: glBindBuffer (GL_ARRAY_BUFFER, vboVerticesID); glBufferData (GL_ARRAY_BUFFER, sizeof(Vertex)*vertices.size(), &(vertices[0].pos.x), GL_STATIC_DRAW); glEnableVertexAttribArray(shader["vVertex"]); glVertexAttribPointer(shader["vVertex"], 3, GL_FLOAT, GL_FALSE,sizeof(Vertex),0); glEnableVertexAttribArray(shader["vNormal"]); glVertexAttribPointer(shader["vNormal"], 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)(offsetof(Vertex, normal)) ); glEnableVertexAttribArray(shader["vUV"]); glVertexAttribPointer(shader["vUV"], 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)(offsetof(Vertex, uv)) );
Jeśli siatka będzie miała tylko jeden materiał, prześlemy jej indeksy do punktu wiązania GL_ELEMENT_ARRAY_BUFFER. W przeciwnym razie będziemy renderować siatki składowe według materiałów. if(materials.size()==1) { glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vboIndicesID); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(GLushort) * indices.size(), &(indices[0]), GL_STATIC_DRAW); }
W funkcji renderującej, jeśli materiał jest tylko jeden, renderujemy całą siatkę, a w przeciwnym razie renderujemy siatkę składową, której przypisano materiał bieżący. if(materials.size()==1) glDrawElements(GL_TRIANGLES,indices.size(),GL_UNSIGNED_SHORT,0); else glDrawElements(GL_TRIANGLES, pMat->count, GL_UNSIGNED_SHORT, (const GLvoid*)(&indices[pMat->offset]));
170
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
I jeszcze jedno… Przykładowa aplikacja zbudowana na podstawie zaprezentowanej receptury renderuje trzy kostki leżące na czworokątnej płaszczyźnie. Położenie kamery można zmieniać przez przesuwanie myszy z wciśniętym lewym przyciskiem. Położenie źródła światła oznaczone trzema prostopadłymi odcinkami można zmieniać przez przesuwanie myszy z wciśniętym prawym przyciskiem. Rezultat działania aplikacji jest pokazany na poniższym rysunku.
Dowiedz się więcej Specyfikację formatu OBJ znajdziesz również w Wikipedii pod adresem: http://en.wikipedia. org/wiki/Wavefront_.obj_file.
Wczytywanie modeli w formacie EZMesh W tej recepturze pokażę, jak wczytać i wyrenderować model zapisany w formacie EZMesh. Istnieje kilka formatów do zapisywania animacji szkieletowych, np. stosowany w grze Quake format md2 (.md2), opracowany w firmie Autodesk format FBX (.fbx) czy Collada (.dae), ale są one
171
OpenGL. Receptury dla programisty
zbyt skomplikowane, aby je stosować do zapisu prostej animacji szkieletowej. W takich przypadkach w zupełności wystarczy format EZMesh (.ezm).
Przygotowania Gotowy kod dla tej receptury znajduje się w folderze Rozdział5/PrzeglądarkaEZMesh. Do analizy zawartości pliku z modelem EZMesh (.ezm) użyjemy dwóch dodatkowych bibliotek zewnętrznych. Pierwsza nosi nazwę MeshImport i można ją pobrać ze strony http://code.google.com/p/ meshimport/. Z repozytorium svn należy pobrać najnowszy trzon (trunk) kodu, a następnie przejść do podfolderu compiler, w którym znajdują się pliki rozwiązania dla pakietu Visual Studio. Po otwarciu rozwiązania i zbudowaniu projektu należy skopiować pliki MeshImport_x86.dll (MeshImport_ x64.dll) i MeshImportEZM_x86.dll (MeshImportEZM_x64.dll) do folderu z opracowywanym właśnie projektem — wersję x86 lub x64 wybieramy w zależności od konfiguracji komputera. Skopiować należy także pliki MeshImport.h i MeshImport.cpp, które zawierają kilka użytecznych procedur. Ponieważ EZMesh jest formatem zbudowanym w technologii XML do obsługi wczytywania tekstur, będziemy ręcznie analizować plik przy użyciu biblioteki pugixml. Można ją pobrać ze strony http://pugixml.org/, a jako że jest niewielka, można ją dołączyć bezpośrednio do projektu.
Jak to zrobić? Rozpocznij od następujących prostych czynności: 1. Utwórz globalną referencję do obiektu EzmLoader. Wywołaj funkcję EzmLoader::Load, przekazując jej nazwę pliku EZMesh (.ezm). Przekaż też wektory do przechowywania siatek, wierzchołków, indeksów i materiałów zawartych we wczytywanym pliku. Funkcja Load akceptuje także wektory min i max, w których można zapisać prostopadłościan otaczający siatkę. if(!ezm.Load(mesh_filename.c_str(), submeshes, vertices, indices, material2ImageMap, min, max)) { cout<<"Nie moge wczytac siatki EZMesh"<
2. Wykorzystując informacje materiałowe, wygeneruj tekstury OpenGL dla geometrii EZMesh. for(size_t k=0;k
172
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); int texture_width = 0, texture_height = 0, channels=0; const string& filename = materialNames[k]; std::string full_filename = mesh_path; full_filename.append(filename); //wczytywanie obrazu przy użyciu biblioteki SOIL i odwracanie go w pionie //… GLenum format = GL_RGBA; switch(channels) { case 2: format = GL_RG32UI; break; case 3: format = GL_RGB; break; case 4: format = GL_RGBA; break; } glTexImage2D(GL_TEXTURE_2D, 0, format, texture_width, texture_height, 0, format, GL_UNSIGNED_BYTE, pData); SOIL_free_image_data(pData); materialMap[filename] = id ; }
3. Ustaw obiekt bufora z przeplotem podobnie jak w recepturze „Wczytywanie modeli OBJ przy użyciu buforów z przeplotem”. glBindVertexArray(vaoID); glBindBuffer (GL_ARRAY_BUFFER, vboVerticesID); glBufferData (GL_ARRAY_BUFFER, sizeof(Vertex)*vertices.size(), &(vertices[0].pos.x), GL_DYNAMIC_DRAW); glEnableVertexAttribArray(shader["vVertex"]); glVertexAttribPointer(shader["vVertex"], 3, GL_FLOAT, GL_FALSE,sizeof(Vertex),0); glEnableVertexAttribArray(shader["vNormal"]); glVertexAttribPointer(shader["vNormal"], 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)(offsetof(Vertex, normal)) ); glEnableVertexAttribArray(shader["vUV"]); glVertexAttribPointer(shader["vUV"], 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*) (offsetof(Vertex, uv)) );
4. Aby wyrenderować wczytaną siatkę, zwiąż obiekt tablicy wierzchołków, włącz shader i przekaż uniformy. glBindVertexArray(vaoID); { shader.Use(); glUniformMatrix4fv(shader("MV"), 1, GL_FALSE, glm::value_ptr(MV));
173
OpenGL. Receptury dla programisty
glUniformMatrix3fv(shader("N"), 1, GL_FALSE, glm::value_ptr(glm::inverseTranspose(glm::mat3(MV)))); glUniformMatrix4fv(shader("P"), 1, GL_FALSE, glm::value_ptr(P)); glUniform3fv(shader("light_position"),1, &(lightPosES.x));
5. Utwórz pętlę przebiegającą po wszystkich siatkach składowych i dla każdej z nich zwiąż teksturę oraz wywołaj funkcję glDrawEements z indeksami tej siatki. Jeśli jakaś siatka nie ma żadnego materiału, przypisz jej domyślny stały kolor. for(size_t i=0;i0) { GLuint id = materialMap[material2ImageMap [submeshes[i].materialName]]; GLint whichID[1]; glGetIntegerv(GL_TEXTURE_BINDING_2D, whichID); if(whichID[0] != id) glBindTexture(GL_TEXTURE_2D, id); glUniform1f(shader("useDefault"), 0.0); } else { glUniform1f(shader("useDefault"), 1.0); } glDrawElements(GL_TRIANGLES, submeshes[i].indices.size(), GL_UNSIGNED_INT, &submeshes[i].indices[0]); } }
Jak to działa? EZMesh jest formatem zapisu animacji szkieletowych opracowanym w technologii XML. Cała receptura składa się z dwóch części: analizy zawartości wczytywanego pliku i przekazania pobranych danych do obiektu bufora OpenGL. Część pierwszą realizuje funkcja EzmLoader::Load. Poza nazwą pliku jej argumentami są wektory służące do przechowywania siatek, wierzchołków, indeksów i nazw materiałów. Plik EZMesh jest zbiorem elementów XML. Pierwszy z nich, o nazwie MeshSystem, zawiera cztery elementy podrzędne: Skeletons (szkielety), Animations (animacje), Materials (materiały) i Meshes (siatki). Każdy z tych elementów podrzędnych ma atrybut count (liczba), w którym zapisana jest całkowita liczba tego typu elementów w danym pliku. Poszczególne elementy można usuwać. Zazwyczaj cała hierarchia przedstawia się następująco:
174
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
Na razie interesować nas będą tylko dwa ostatnie elementy, czyli Materials i Meshes. Pozostałych będziemy używać podczas tworzenia animacji szkieletowej, ale to będzie dopiero w ostatnim rozdziale książki. Każdy element typu Materials ma przeliczalną liczbę elementów typu Material (materiał), z których każdy przechowuje nazwę materiału (w atrybucie name) i inne informacje na jego temat. Na przykład w atrybucie meta_data przechowywana jest nazwa pliku z mapą tekstury. W funkcji EzmLoader::Load analizujemy zawartość elementów Materials i ich elementów podrzędnych przy użyciu funkcji z biblioteki pugi_xml. Uzyskane informacje, takie jak nazwa materiału i nazwa pliku z jego teksturami, umieszczamy w tzw. mapie materiału. Biblioteka MeshImport zawiera funkcje do odczytu tego typu informacji, ale nie działają. pugi::xml_node mats = doc.child("MeshSystem").child("Materials"); int totalMaterials = atoi(mats.attribute("count").value()); pugi::xml_node material = mats.child("Material"); for(int i=0;i0) { string fullName=""; int index = metadata.find_last_of("\\"); if(index == string::npos) { fullName.append(metadata); } else { std::string fileNameOnly = metadata.substr(index+1, metadata.length()); fullName.append(fileNameOnly); } bool exists = true; if(materialNames.find(name)==materialNames.end() ) exists = false; if(!exists) materialNames[name] = (fullName); material = material.next_sibling("Material"); } }
Po wczytaniu informacji materiałowych wywołujemy funkcję NVSHARE::loadMeshImporters z biblioteki MeshImport i przekazujemy jej jako argument folder z plikami MeshImport_x86.dll (MeshImport_x64.dll) i MeshImportEZM_x86.dll (MeshImportEZM_x64.dll). Gdy wszystko przebiega poprawnie, funkcja zwraca obiekt biblioteczny NVSHARE::MeshImport. Za jego pomocą tworzymy kontener systemu siatek. W tym celu wywołujemy funkcję NVSHARE::MeshImport:: createMeshSystemContainer, która przyjmuje nazwę obiektu i zawartość pliku EZMesh. Rezultatem jej działania jest obiekt MeshSystemContainer, który przekazujemy do funkcji NVSHARE:: MeshImport::getMeshSystem, która z kolei zwraca obiekt NVSHARE::MeshSystem będący reprezentacją węzła MeshSystem w pliku EZMesh.
175
OpenGL. Receptury dla programisty
Mając obiekt MeshSystem, możemy pobierać informacje o wszystkich elementach podrzędnych. Są one zawarte w tym obiekcie jako zmienne składowe. A zatem jeśli chcemy ze wszystkich siatek istniejących we wczytywanym pliku EZMesh pobrać atrybuty wierzchołków i umieścić je w wektorze vertices, robimy po prostu coś takiego: for(size_t i=0;imMeshCount;i++) { NVSHARE::Mesh* pMesh = ms->mMeshes[i]; vertices.resize(pMesh->mVertexCount); for(size_t j=0;jmVertexCount;j++) { vertices[j].pos.x = pMesh->mVertices[j].mPos[0]; vertices[j].pos.y = pMesh->mVertices[j].mPos[1]; vertices[j].pos.z = pMesh->mVertices[j].mPos[2]; vertices[j].normal.x = pMesh->mVertices[j].mNormal[0]; vertices[j].normal.y = pMesh->mVertices[j].mNormal[1]; vertices[j].normal.z = pMesh->mVertices[j].mNormal[2]; vertices[j].uv.x = pMesh->mVertices[j].mTexel1[0]; vertices[j].uv.y = pMesh->mVertices[j].mTexel1[1]; } }
W pliku EZMesh indeksy są sortowane według materiałów i grupowane w siatki składowe. Tworzymy więc pętlę przebiegającą wszystkie takie siatki i kopiujemy nazwy ich materiałów oraz zestawy indeksów do naszego kontenera. submeshes.resize(pMesh->mSubMeshCount); for(size_t j=0;jmSubMeshCount;j++) { NVSHARE::SubMesh* pSubMesh = pMesh->mSubMeshes[j]; submeshes[j].materialName = pSubMesh->mMaterialName; submeshes[j].indices.resize(pSubMesh->mTriCount * 3); memcpy(&(submeshes[j].indices[0]), pSubMesh->mIndices, sizeof(unsigned int) * pSubMesh->mTriCount * 3); }
Po wyłuskaniu z pliku EZMesh danych wierzchołkowych przystępujemy do wygenerowania OpenGL-owych tekstur na podstawie listy materiałów zawartej w tym pliku. Następnie umieszczamy identyfikatory tych tekstur w mapie materiałowej, która pozwoli dotrzeć do właściwej tekstury poprzez nazwę materiału. for(size_t k=0;k
176
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
const string& filename = materialNames[k]; std::string full_filename = mesh_path; full_filename.append(filename); GLubyte* pData = SOIL_load_image(full_filename.c_str(), &texture_width, &texture_height, &channels, SOIL_LOAD_AUTO); if(pData == NULL) { cerr<<"Nie mogę wczytac obrazu: "<
Po materiałach wczytujemy shadery, tak jak w poprzednich recepturach. Następnie przenosimy do GPU dane wierzchołkowe, używając do tego celu obiektów tablicy i bufora wierzchołków. Tym razem stosujemy bufor z przeplotem. glGenVertexArrays(1, &vaoID); glGenBuffers(1, &vboVerticesID); glGenBuffers(1, &vboIndicesID); glBindVertexArray(vaoID); glBindBuffer (GL_ARRAY_BUFFER, vboVerticesID); glBufferData (GL_ARRAY_BUFFER, sizeof(Vertex)*vertices.size(), &(vertices[0].pos.x), GL_DYNAMIC_DRAW); glEnableVertexAttribArray(shader["vVertex"]); glVertexAttribPointer(shader["vVertex"], 3, GL_FLOAT, GL_FALSE,sizeof(Vertex),0); glEnableVertexAttribArray(shader["vNormal"]); glVertexAttribPointer(shader["vNormal"], 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)(offsetof(Vertex, normal)) ); glEnableVertexAttribArray(shader["vUV"]); glVertexAttribPointer(shader["vUV"], 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)(offsetof(Vertex, uv)) );
W celu wyrenderowania siatki najpierw wiążemy obiekt tablicy wierzchołków, włączamy nasz shader i przekazujemy odpowiednie uniformy. Następnie sprawdzamy wszystkie siatki składowe i wiążemy ich tekstury (jeśli je mają) lub wstawiamy domyślny kolor. Na koniec sięgamy po indeksy i za pomocą funkcji glDrawElements rysujemy bieżącą siatkę. glBindVertexArray(vaoID); { shader.Use(); glUniformMatrix4fv(shader("MV"), 1, GL_FALSE, glm::value_ptr(MV)); glUniformMatrix3fv(shader("N"), 1, GL_FALSE, glm::value_ptr(glm::inverseTranspose(glm::mat3(MV))));
177
OpenGL. Receptury dla programisty
glUniformMatrix4fv(shader("P"), 1, GL_FALSE, glm::value_ptr(P)); glUniform3fv(shader("light_position"),1, &(lightPosES.x)); for(size_t i=0;i0) { GLuint id = materialMap[material2ImageMap[submeshes[i].materialName]]; GLint whichID[1]; glGetIntegerv(GL_TEXTURE_BINDING_2D, whichID); if(whichID[0] != id) glBindTexture(GL_TEXTURE_2D, id); glUniform1f(shader("useDefault"), 0.0); } else { glUniform1f(shader("useDefault"), 1.0); } glDrawElements(GL_TRIANGLES, submeshes[i].indices.size(), GL_UNSIGNED_INT, &submeshes[i].indices[0]); } shader.UnUse(); }
I jeszcze jedno… Przykładowa aplikacja zbudowana na podstawie zaprezentowanej receptury renderuje model szkieletowy z teksturami. Położenie punktowego źródła światła można zmieniać przez przesuwanie myszy z wciśniętym prawym przyciskiem. Rezultat działania aplikacji jest pokazany na rysunku na następnej stronie.
Dowiedz się więcej Zajrzyj do zbiorów programistycznych Johna Ractliffa i przeanalizuj przykładową aplikację korzystającą z biblioteki MeshImport (http://codesuppository.blogspot.sg/2009/11/test-application-for-meshimport-library.html).
Implementacja prostego systemu cząsteczkowego Systemy cząsteczkowe to specjalna kategoria obiektów umożliwiających symulowanie takich zjawisk jak ogień i dym. Spróbujemy zaimplementować prosty system, który będzie wyrzucał cząsteczki w określonym tempie z odpowiednio ukierunkowanego emitera. Cząsteczkom tym przypiszemy mapę ognistych kolorów, aby uzyskać efekt płomieni.
178
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
Przygotowania Gotowy kod przykładowej aplikacji znajduje się w folderze Rozdział5/ProsteCząstki. Całość pracy związanej z generowaniem cząstek wykonuje shader wierzchołków.
Jak to zrobić? Zacznij od wykonania następujących czynności: 1. Utwórz shader wierzchołków bez żadnych atrybutów wierzchołkowych. Shader ten ma generować aktualne położenie cząstki i przekazywać do shadera fragmentów odpowiedni kolor. #version 330 core smooth out vec4 vSmoothColor; uniform mat4 MVP; uniform float time; const vec3 a = vec3(0,2,0); //przyspieszenie cząstek //vec3 g = vec3(0,-9.8,0); // przyspieszenie grawitacyjne
179
OpenGL. Receptury dla programisty
const float rate = 1/500.0; //tempo emisji const float life = 2; //czas życia cząstki //stałe const float PI = 3.14159; const float TWO_PI = 2*PI; //kolory ognia const vec3 RED = vec3(1,0,0); const vec3 GREEN = vec3(0,1,0); const vec3 YELLOW = vec3(1,1,0); // generator liczb pseudolosowych float rand(vec2 co){ return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453); } //pseudolosowy kierunek vec3 uniformRadomDir(vec2 v, out vec2 r) { r.x = rand(v.xy); r.y = rand(v.yx); float theta = mix(0.0, PI / 6.0, r.x); float phi = mix(0.0, TWO_PI, r.y); return vec3(sin(theta) * cos(phi), cos(theta), sin(theta) * sin(phi)); } void main() { vec3 pos=vec3(0); float t = gl_VertexID*rate; float alpha = 1; if(time>t) { float dt = mod((time-t), life); vec2 xy = vec2(gl_VertexID,t); vec2 rdm=vec2(0); pos = ((uniformRadomDir(xy, rdm) + 0.5*a*dt)*dt); alpha = 1.0 - (dt/life); } vSmoothColor = vec4(mix(RED,YELLOW,alpha),alpha); gl_Position = MVP*vec4(pos,1); }
2. W shaderze fragmentów wyprowadź interpolowany kolor jako kolor bieżącego fragmentu. #version 330 core smooth in vec4 vSmoothColor; layout(location=0) out vec4 vFragColor; void main() { vFragColor = vSmoothColor; }
180
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
3. Wygeneruj pojedynczy obiekt tablicy wierzchołków i zwiąż go. glGenVertexArrays(1, &vaoID); glBindVertexArray(vaoID);
4. W kodzie renderującym włącz shader i ustaw uniformy, takie jak time z bieżącym czasem czy MVP z połączoną macierzą modelu, widoku i rzutowania. Do tej ostatniej dołącz jeszcze macierz transformacji emitera (emitterXForm) sterującą jego ukierunkowaniem. shader.Use(); glUniform1f(shader("time"), time); glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(P*MV*emitterXForm));
5. Na koniec wyrenderuj wszystkie cząsteczki (ich liczbę zawiera zmienna MAX_PARTICLES) za pomocą funkcji glDrawArrays i odłącz shader. glDrawArrays(GL_POINTS, 0, MAX_PARTICLES); shader.UnUse(); W wersjach OpenGL wcześniejszych niż 3.0 dostępny był specjalny typ cząsteczek o nazwie GL_POINT_ SPRITE. Obecnie w rdzennym profilu biblioteki rolę sprajtów domyślnie pełnią cząstki GL_POINTS.
Jak to działa? Cały kod, począwszy od generowania położeń cząsteczek aż po przypisanie im kolorów i sił, jest zawarty w shaderze wierzchołków. W odróżnieniu od poprzednich receptur nie przechowujemy w tym shaderze żadnych atrybutów wierzchołkowych. Aby wyrenderować cząsteczki, po prostu wywołujemy funkcję glDrawArrays z parametrem MAX_PARTICLES określającym ich liczbę. Funkcja ta wywoła nasz shader dla każdej cząsteczki oddzielnie. Shader wierzchołków zawiera dwa uniformy: połączoną macierz modelu, widoku i rzutowania (MVP) oraz bieżący czas (time). Pozostałe parametry symulacji są zapisane jako wartości stałe. #version 330 smooth out vec4 vSmoothColor; uniform mat4 MVP; uniform float time; const vec3 a = vec3(0,2,0); //przyspieszenie cząstek //vec3 g = vec3(0,-9.8,0); //przyspieszenie grawitacyjne const float rate = 1/500.0; //tempo emisji const float life = 2; //czas życia cząstki const float PI = 3.14159; const float TWO_PI = 2*PI; const vec3 RED = vec3(1,0,0); const vec3 GREEN = vec3(0,1,0); const vec3 YELLOW = vec3(1,1,0);
181
OpenGL. Receptury dla programisty
W funkcji main określamy bieżący czas cząsteczki (t) jako iloczyn identyfikatora wierzchołka (gl_VertexID) i tempa emisji (rate). Identyfikator gl_VertexID jest liczbą całkowitą jednoznacznie identyfikującą dany wierzchołek. Następnie porównujemy czas bieżący (time) z czasem cząsteczki (t). Jeśli jest większy, obliczamy przyrost czasu (dt) i z prostego wzoru kinematycznego wyznaczamy położenie cząsteczki. void main() { vec3 pos=vec3(0); float t = gl_VertexID*rate; float alpha = 1; if(time>t) {
Aby wygenerować cząsteczkę, musimy znać jej prędkość początkową. Tę określamy na bieżąco za pomocą pseudolosowego generatora, dla którego zarodkami są identyfikator wierzchołka i bieżący czas. Sercem generatora jest funkcja uniformRandomDir zdefiniowana następująco: //generator liczb pseudolosowych float rand(vec2 co){ return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453); } // pseudolosowy kierunek vec3 uniformRadomDir(vec2 v, out vec2 r) { r.x = rand(v.xy); r.y = rand(v.yx); float theta = mix(0.0, PI / 6.0, r.x); float phi = mix(0.0, TWO_PI, r.y); return vec3(sin(theta) * cos(phi), cos(theta), sin(theta) * sin(phi)); }
Na podstawie losowej prędkości początkowej i bieżącego czasu wyznaczane jest położenie cząsteczki. Aby umożliwić odradzanie się cząsteczek, przyrost czasu jest obliczany jako reszta z dzielenia (operator mod) różnicy między czasem bieżącym a czasem cząsteczki (time-t) przez czas życia cząsteczki (life). Po wyznaczeniu położenia obliczana jest przezroczystość (alpha), która powinna prowadzić do stopniowego zaniku cząsteczki.
}
float dt = mod((time-t), life); vec2 xy = vec2(gl_VertexID,t); vec2 rdm; pos = ((uniformRadomDir(xy, rdm) + 0.5*a*dt)*dt); alpha = 1.0 - (dt/life);
Parametr alpha jest używany również do mieszania kolorów czerwonego z żółtym za pomocą funkcji mix w celu uzyskania efektu ognia. Na koniec wygenerowane położenie cząsteczki jest mnożone przez połączoną macierz modelu, widoku i rzutowania w celu wyznaczenia położenia w przestrzeni przycięcia.
}
182
vSmoothColor = vec4(mix(RED,YELLOW,alpha),alpha); gl_Position = MVP*vec4(pos,1);
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
Shader fragmentów po prostu przydziela bieżącemu fragmentowi kolor vSmoothColor otrzymany od shadera wierzchołków. #version 330 core smooth in vec4 vSmoothColor; layout(location=0) out vec4 vFragColor; void main() { vFragColor = vSmoothColor; }
Teksturowanie cząsteczek wymaga wprowadzenia zmian tylko w shaderze fragmentów. Sprajty punktowe mają współrzędne gl_PointCoord, które mogą posłużyć do próbkowania tekstury i właśnie to jest pokazane w shaderze cząsteczek teksturowanych (Rozdział5/ProsteCząstki/ shadery/textured.frag). #version 330 core smooth in vec4 vSmoothColor; layout(location=0) out vec4 vFragColor; uniform sampler2D textureMap; void main() { vFragColor = texture(textureMap, gl_PointCoord) * vSmoothColor.a; }
Aplikacja wczytuje teksturę dla cząsteczek i na jej podstawie generuje OpenGL-owy obiekt tekstury. GLubyte* pData = SOIL_load_image(texture_filename.c_str(), &texture_width, &texture_height, &channels, SOIL_LOAD_AUTO); if(pData == NULL) { cerr<<"Nie mogę wczytac obrazu: "< 0; --i ) { GLubyte temp = pData[index1]; pData[index1] = pData[index2]; pData[index2] = temp; ++index1; ++index2; } }
183
OpenGL. Receptury dla programisty
GLenum format = GL_RGBA; switch(channels) { case 2: format = GL_RG32UI; break; case 3: format = GL_RGB; break; case 4: format = GL_RGBA; break; } glGenTextures(1, &textureID); glBindTexture(GL_TEXTURE_2D, textureID); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); glTexImage2D(GL_TEXTURE_2D, 0, format, texture_width, texture_height, 0, format, GL_UNSIGNED_BYTE, pData); SOIL_free_image_data(pData);
Następnie jednostka teksturująca, do której przywiązano teksturę, jest przekazywana do shadera. texturedShader.LoadFromFile(GL_VERTEX_SHADER, "shadery/shader.vert"); texturedShader.LoadFromFile(GL_FRAGMENT_SHADER, "shadery/textured.frag"); texturedShader.CreateAndLinkProgram(); texturedShader.Use(); texturedShader.AddUniform("MVP"); texturedShader.AddUniform("time"); texturedShader.AddUniform("textureMap"); glUniform1i(texturedShader("textureMap"),0); texturedShader.UnUse();
Na koniec cząsteczki są renderowane za pomocą funkcji glDrawArrays, tak samo jak poprzednio.
I jeszcze jedno… Przykładowa aplikacja zbudowana na podstawie powyższej receptury renderuje cząsteczki generowane przez punktowy emiter, które imitują płomienie wydobywające się z dyszy silnika odrzutowego. Za pomocą klawisza spacji można włączyć tryb wyświetlania cząsteczek teksturowanych. Widok można obracać i przybliżać przez przeciąganie myszy z wciśniętym przyciskiem lewym lub środkowym. Rezultat działania tej aplikacji jest pokazany na rysunku na następnej stronie. Po włączeniu shadera teksturującego cząstki otrzymujemy taki rezultat jak na kolejnym rysunku na następnej stronie. Położenie i orientację emitera ustala macierz jego transformacji (emitterXForm). Przez jej modyfikację możemy przemieszczać i obracać emiter w trójwymiarowej przestrzeni.
184
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
185
OpenGL. Receptury dla programisty
Omawiany shader wierzchołków generuje cząsteczki z emitera punktowego. Aby uzyskać emiter prostokątny, należy zmienić linie kodu odpowiedzialne za wyznaczanie położenia cząsteczek. Oto właściwy fragment: pos = ( uniformRadomDir(xy, rdm) + 0.5*a*dt)*dt; vec2 rect = (rdm*2.0 - 1.0); pos += vec3(rect.x, 0, rect.y) ;
Otrzymujemy wtedy coś takiego:
Przez ograniczenie położeń emitowanych cząsteczek do tych, które mieszczą się w kole o zadanym promieniu, uzyskamy emiter o kształcie koła. Odpowiednia modyfikacja kodu wygląda następująco: pos = ( uniformRadomDir(xy, rdm) + 0.5*a*dt)*dt; vec2 rect = (rdm*2.0 - 1.0); float dotP = dot(rect, rect); if(dotP<1) pos += vec3(rect.x, 0, rect.y);
186
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe
Aplikacja da wtedy następujący rezultat:
Możemy również dodawać rozmaite siły, takie jak opór powietrza, wiatr, zawirowanie itp., przez dodawanie odpowiedniego członu do przyspieszenia lub prędkości emitowanych cząsteczek. Kolejna modyfikacja może polegać na wprawieniu w ruch samego emitera — można go zmusić do poruszania się po określonej ścieżce, np. po krzywej b-sklejanej. Można też ustawić ekrany odbijające strumień cząsteczek albo tworzyć cząsteczki, które będą same generowały cząsteczki potomne — jest to technika często stosowana w systemach symulujących ogień. Systemy cząsteczkowe stanowią niezwykle interesującą dziedzinę grafiki komputerowej i pozwalają łatwo tworzyć zadziwiające efekty. Zaprezentowana receptura pokazuje, jak można taki system zaimplementować całkowicie na GPU. Przykładowy system jest bardzo prosty, ale i tak może posłużyć do wykonania rozmaitych efektów. Bardziej rozbudowane przykłady można znaleźć w publikacjach podanych w części „Dowiedz się więcej”.
187
OpenGL. Receptury dla programisty
Dowiedz się więcej Aby dowiedzieć się więcej na temat systemów cząsteczkowych w grafice komputerowej, zajrzyj do następujących pozycji: Real-time particle systems on the GPU in Dynamic Environment, SIGGRAPH 2007,
http://developer.amd.com/wordpress/media/2012/10/Drone-Real-Time_Particles_ Systems_on_the_GPU_in_Dynamic_Environments%28Siggraph07%29.pdf. GPU Gems 3, rozdział 23., „High speed offscreen particles”, http://http.developer.nvidia.
com/GPUGems3/gpugems3_ch23.html. Lutz Latta, Building a million particle system, http://www.gamasutra.com/view/
feature/130535/building_a_millionparticle_system.php?print=1. The Cg Tutorial, rozdział 6., http://http.developer.nvidia.com/CgTutorial/
cg_tutorial_chapter06.html.
188
6 Mieszanie alfa i oświetlenie globalne na GPU W tym rozdziale: Implementacja przezroczystości techniką peelingu jednokierunkowego Implementacja przezroczystości techniką peelingu dualnego Implementacja okluzji otoczenia w przestrzeni ekranu (SSAO) Implementacja metody harmonik sferycznych w oświetleniu globalnym Śledzenie promieni realizowane przez GPU Śledzenie ścieżek realizowane przez GPU
Wstęp Nawet przy najlepiej dobranym oświetleniu nasza wirtualna scena będzie wyglądać mało realistycznie. Dzieje się tak, ponieważ zjawiska optyczne zachodzące na powierzchni obiektów są tu tylko symulowane z dużym uproszczeniem. Aby zniwelować tę różnicę między oświetleniem wirtualnym a rzeczywistym, stosuje się specjalne algorytmy zwane technikami oświetlenia globalnego. Jednak są one zbyt złożone, aby mogły znaleźć zastosowanie w grafice interaktywnej, a zatem trzeba było znaleźć sposób na uzyskanie podobnego efektu, ale prostszymi metodami. Jedną z nich jest metoda harmonik sferycznych, w której do oświetlania sceny wykorzystuje się obrazy HDR zamiast źródeł światła. Pomysł polega na pozyskiwaniu informacji oświetleniowej z obrazu przedstawiającego rzeczywiste środowisko danej sceny.
OpenGL. Receptury dla programisty
Problematyczne jest również renderowanie obiektów przezroczystych, ponieważ wymaga dokładnego określenia głębi poszczególnych elementów sceny. W miarę jak scena się rozrasta, coraz trudniejsze staje się porządkowanie elementów według ich głębi i rośnie ogólna złożoność obliczeniowa całego procesu. Żeby te problemy ominąć, zastosujemy techniki renderowania przezroczystości, w których nie jest potrzebne wyznaczanie kolejności obiektów widzianych przez kamerę. Zaimplementujemy najpierw metodę jednokierunkowego peelingu głębi od przodu ku tyłowi sceny, a potem użyjemy jeszcze wydajniejszego peelingu dualnego. Wszystkie implementacje wykonamy w ramach rdzennego profilu OpenGL 3.3.
Implementacja przezroczystości techniką peelingu jednokierunkowego Jeśli w renderowanej scenie znajdują się obiekty przezroczyste, np. szyba w oknie, należy zadbać o to, by geometria sceny była renderowana w odpowiedniej kolejności: najpierw obiekty nieprzezroczyste, a dopiero po nich obiekty przepuszczające światło. Niestety to wymaga zaangażowania głównego procesora w ustalanie kolejności obiektów. Poza tym wynik mieszania uzyskanych obrazów będzie prawidłowy tylko dla jednego kierunku patrzenia, a dla innych już nie, czego przykład został pokazany na rysunku poniżej. Obraz z lewej strony przedstawia scenę widzianą w kierunku osi Z. Nie ma tu żadnego mieszania. Ale jeśli spojrzymy na tę samą scenę od strony przeciwnej, zobaczymy prawidłowy rezultat mieszania alfa.
Jednym z rozwiązań tego problemu jest technika peelingu głębi (depth peeling). Polega ona na renderowaniu sceny warstwowo, przy czym kolejne warstwy są renderowane jedna po drugiej od przodu w głąb sceny, aż wszystkie jej elementy zostaną przetworzone. Schematycznie ilustruje to rysunek na następnej stronie, na którym zostało pokazane działanie peelingu dla sceny z poprzedniego rysunku.
190
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
Liczba warstw koniecznych do wyrenderowania zależy od złożoności sceny. Zobaczmy więc, jak można zaimplementować metodę peelingu w nowoczesnym OpenGL.
Przygotowania Gotowy kod przykładowej aplikacji znajduje się w folderze Rozdział6/PeelingPrzódTył.
Jak to zrobić? Zacznij od wykonania następujących czynności: 1. Utwórz dwa obiekty bufora ramki (FBO) z dwoma przyłączami koloru i dwoma przyłączami głębi. Na potrzeby naszej aplikacji ustaw prostokątny rodzaj tekstur (GL_TEXTURE_RECTANGLE), ponieważ łatwiejsza jest obsługa takich obrazów (samplerów) w shaderze fragmentów. W przypadku tekstury prostokątnej możemy pobierać z niej wartości, używając bezpośrednio współrzędnych pikselowych. Jeśli jest to zwykła tekstura dwuwymiarowa (GL_TEXTURE_2D), trzeba znormalizować jej współrzędne. glGenFramebuffers(2, fbo); glGenTextures (2, texID); glGenTextures (2, depthTexID); for(int i=0;i<2;i++) { glBindTexture(GL_TEXTURE_RECTANGLE, depthTexID[i]); //ustaw parametry tekstur, takie jak format, wymiary itp. glTexImage2D(GL_TEXTURE_RECTANGLE , 0, GL_DEPTH_COMPONENT32F, WIDTH, HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL); glBindTexture(GL_TEXTURE_RECTANGLE,texID[i]);
191
OpenGL. Receptury dla programisty
// ustaw parametry tekstur, takie jak format, wymiary itp. glTexImage2D(GL_TEXTURE_RECTANGLE , 0,GL_RGBA, WIDTH, HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL); glBindFramebuffer(GL_FRAMEBUFFER, fbo[i]); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_RECTANGLE, depthTexID[i], 0); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_RECTANGLE, texID[i], 0); } glGenTextures(1, &colorBlenderTexID); glBindTexture(GL_TEXTURE_RECTANGLE, colorBlenderTexID); // ustaw parametry tekstur, takie jak format, wymiary itp. glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_RGBA, WIDTH, HEIGHT, 0, GL_RGBA, GL_FLOAT, 0);
2. Utwórz kolejny obiekt bufora ramki dla mieszania kolorów i sprawdź jego kompletność. Obiekt ten będzie używał tekstury z przyłącza głębi pierwszego FBO, ponieważ do właściwego mieszania kolorów potrzebuje informacji o głębi z pierwszego etapu. glGenFramebuffers(1, &colorBlenderFBOID); glBindFramebuffer(GL_FRAMEBUFFER, colorBlenderFBOID); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_RECTANGLE, depthTexID[0], 0); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_RECTANGLE, colorBlenderTexID, 0); GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); if(status == GL_FRAMEBUFFER_COMPLETE ) printf("Ustawienie FBO powiodlo sie !!! \n"); else printf("Problem z ustawieniem FBO"); glBindFramebuffer(GL_FRAMEBUFFER, 0);
3. W funkcji renderującej ustaw mieszający FBO jako bieżący cel renderingu i w zwykły sposób wyrenderuj scenę przy włączonym testowaniu głębi. glBindFramebuffer(GL_FRAMEBUFFER, colorBlenderFBOID); glDrawBuffer(GL_COLOR_ATTACHMENT0); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT ); glEnable(GL_DEPTH_TEST); DrawScene(MVP, cubeShader);
4. Następnie zwiąż pozostałą parę FBO, wyczyść cel renderingu i włącz testowanie głębi, ale wyłącz mieszanie alfa. Teraz ma być wyrenderowana pozaekranowo najbliższa powierzchnia sceny. Liczba przejść renderujących zależy od liczby warstw peelingowych, na jakie scena została podzielona. Im więcej warstw, tym lepszy rezultat. W przykładowej aplikacji ustaliłem, że będzie 6 przebiegów renderujących. Wszystko jednak zależy od złożoności sceny. Jeśli użytkownik będzie chciał, może sprawdzić, ile fragmentów jest modyfikowanych na danym etapie peelingu, włączając kwerendę widoczności (za pomocą zmiennej bUseOQ), i na tej podstawie ustalić liczbę przejść.
192
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
int numLayers = (NUM_PASSES - 1) * 2; for (int layer = 1; bUseOQ || layer < numLayers; layer++) { int currId = layer % 2; int prevId = 1 - currId; glBindFramebuffer(GL_FRAMEBUFFER, fbo[currId]); glDrawBuffer(GL_COLOR_ATTACHMENT0); glClearColor(0, 0, 0, 0); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glDisable(GL_BLEND); glEnable(GL_DEPTH_TEST); if (bUseOQ) { glBeginQuery(GL_SAMPLES_PASSED_ARB, queryId); }
5. Zwiąż teksturę głębi z pierwszego etapu, aby najbliższy fragment mógł trafić do przyłączonych shaderów przedniego peelingu, i wyrenderuj scenę. Shadery te, front_peel.vert i front_peel.frag, znajdziesz w folderze Rozdział6/PeelingPrzódTył/ shadery. Jeśli kwerenda widoczności została włączona, teraz należy ją zakończyć. glBindTexture(GL_TEXTURE_RECTANGLE, depthTexID[prevId]); DrawScene(MVP, frontPeelShader); if (bUseOQ) { glEndQuery(GL_SAMPLES_PASSED_ARB); }
6. Ponownie zwiąż mieszający FBO, wyłącz testowanie głębi i włącz mieszanie addytywne. Zastosuj jednak mieszanie odrębne dla kanałów koloru i kanału alfa. Na koniec zwiąż wyjście renderingu z etapu 5. i zmieszaj całą scenę przy użyciu pełnoekranowego czworokąta i shaderów blend.vert oraz blend.frag (patrz Rozdział6/ PeelingPrzódTył/shadery). glBindFramebuffer(GL_FRAMEBUFFER, colorBlenderFBOID); glDrawBuffer(GL_COLOR_ATTACHMENT0); glDisable(GL_DEPTH_TEST); glEnable(GL_BLEND); glBlendEquation(GL_FUNC_ADD); glBlendFuncSeparate(GL_DST_ALPHA, GL_ONE,GL_ZERO, GL_ONE_MINUS_SRC_ALPHA); glBindTexture(GL_TEXTURE_RECTANGLE, texID[currId]); blendShader.Use(); DrawFullScreenQuad(); blendShader.UnUse(); glDisable(GL_BLEND);
7. W ostatnim kroku przywróć domyślny bufor ekranu (GL_BACK_LEFT) oraz wyłącz mieszanie alfa i testowanie głębi. Za pomocą pełnoekranowego czworokąta i shadera finalnego (Rozdział6/PeelingPrzódTył/shadery/final.vert) połącz wyjście mieszającego FBO z kolorem tła.
193
OpenGL. Receptury dla programisty
glBindFramebuffer(GL_FRAMEBUFFER, 0); glDrawBuffer(GL_BACK_LEFT); glDisable(GL_DEPTH_TEST); glDisable(GL_BLEND); glBindTexture(GL_TEXTURE_RECTANGLE, colorBlenderTexID); finalShader.Use(); glUniform4fv(finalShader("vBackgroundColor"), 1, &bg.x); DrawFullScreenQuad(); finalShader.UnUse();
Jak to działa? Jednokierunkowy peeling głębi od przodu ku tyłowi składa się z trzech kroków. Pierwszy to renderowanie sceny w zwykły sposób do przyłącza głębi w FBO i przy włączonym testowaniu głębi. Zarejestrowane wartości głębi trafiają zatem do wspomnianego przyłącza głębi w FBO. W drugim kroku wiążemy FBO i jego przyłącze głębi, a następnie odcinamy kolejne części geometrii za pomocą następującego kodu zawartego w shaderze fragmentów (Rozdział6/ PeelingPrzódTył/shadery/front_peel.frag): #version 330 core layout(location = 0) out vec4 vFragColor; uniform vec4 vColor; uniform sampler2DRect depthTexture; void main() { float frontDepth = texture(depthTexture, gl_FragCoord.xy).r; if(gl_FragCoord.z <= frontDepth) discard; vFragColor = vColor; }
Shader ten po prostu porównuje głębię bieżącego fragmentu z wartością zapisaną w teksturze głębi. Jeśli głębia fragmentu jest mniejsza lub równa wartości pobranej z tekstury, fragment jest odrzucany. W przeciwnym razie wyprowadzany jest stały kolor fragmentu. float frontDepth = texture(depthTexture, gl_FragCoord.xy).r; if(gl_FragCoord.z <= frontDepth) discard;
Następnie wiążemy mieszający FBO, wyłączamy testowanie głębi i włączamy mieszanie alfa w trybie odrębnego mieszania kolorów i wartości alfa. Wybieramy do tego funkcję glBlendFunc tionSeparate, ponieważ umożliwia ona odrębne traktowanie kanałów koloru i alfa, zarówno źródłowych, jak i docelowych. Jej pierwszym argumentem jest źródłowy kolor RGB, któremu przypisujemy wartość alfa piksela z bufora ramki. W ten sposób mieszamy wejściowy fragment z kolorem istniejącym w buforze ramki. Drugi parametr, czyli docelowy kolor RGB, ustawiamy na GL_ONE, co sprawia, że wartość w miejscu docelowym pozostaje bez zmian. Trzeci parametr, ustawiony na GL_ZERO, co likwiduje składnik alfa w źródle — nie jest potrzebny, ponieważ
194
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
wartość alfa już została pobrana z miejsca docelowego jako parametr pierwszy. Ostatni parametr, docelową wartość alfa, ustawiamy na typowe nakładanie, czyli przypisujemy mu wartość GL_ONE_MINUS_SRC_ALPHA. Potem wiążemy teksturę uzyskaną w poprzednim kroku i za pomocą shadera mieszającego (Rozdział6/PeelingPrzódTył/shadery/blend.frag) mieszamy na pełnoekranowym czworokącie bieżące fragmenty z fragmentami istniejącymi w buforze ramki. Shader jest zdefiniowany następująco: #version 330 core uniform sampler2DRect tempTexture; layout(location = 0) out vec4 vFragColor; void main() { vFragColor = texture(tempTexture, gl_FragCoord.xy); }
Sampler tempTexture zawiera rezultaty peelingu głębi zapisane w przyłączu obiektu colorBlen derFBO. Po wykonaniu powyższych czynności wyłączamy mieszanie alfa w sposób pokazany w części „Jak to zrobić?” (punkt 6.). W ostatnim kroku przywracamy domyślny bufor rysowania, wyłączamy mieszanie alfa oraz testowanie głębi i za pomocą prostego shadera fragmentów mieszamy wyjście obiektu colorBlen derFBO z kolorem tła. Kod realizujący to zadanie jest przedstawiony w ostatnim punkcie części „Jak to zrobić?”. Finalny shader fragmentów ma następującą budowę: #version 330 core uniform sampler2DRect colorTexture; uniform vec4 vBackgroundColor; layout(location = 0) out vec4 vFragColor; void main() { vec4 color = texture(colorTexture, gl_FragCoord.xy); vFragColor = color + vBackgroundColor*color.a; }
Shader finalny miesza rezultat peelingu z kolorem tła, wykorzystując do tego wartości alfa z rezultatu peelingu. Przez to nie tylko najbliższy fragment jest brany pod uwagę, lecz wszystkie i w rezultacie otrzymujemy prawidłowe mieszanie.
I jeszcze jedno… Przykładowa aplikacja zbudowana zgodnie z powyższym przepisem renderuje 27 półprzezroczystych kostek rozmieszczonych symetrycznie w centrum sceny. Położenie kamery można zmieniać przez przeciąganie myszą z wciśniętym lewym przyciskiem. Jednokierunkowy peeling, od przodu ku tyłowi, daje efekt pokazany na rysunku na następnej stronie. Zwróć uwagę na powstawanie nowego koloru w miejscach, gdzie jedna kostka przysłania drugą, np. kostki zielona i czerwona tworzą wypadkowy kolor żółty.
195
OpenGL. Receptury dla programisty
Wciśnięcie klawisza spacji wyłącza peeling i pozostaje wtedy tylko zwykłe mieszanie alfa. Rezultat jest widoczny na rysunku na następnej stronie. Tym razem w miejscach nakładania się kostek nie powstają nowe kolory. Rezultat uzyskany techniką peelingu przód-tył jest poprawny, ale wymaga to wielu przejść przez geometrię sceny, co wydłuża czas obliczeń. W następnej recepturze zaprezentuję bardziej wyrafinowaną metodę, zwaną dualnym peelingiem głębi, która przynajmniej częściowo rozwiązuje ten problem.
Dowiedz się więcej Przeczytaj artykuł Cassa Everitta zatytułowany Interactive Order-Independent Transparency, dostępny pod adresem: http://gamedevs.org/uploads/interactive-order-independent-transparency.pdf.
196
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
Implementacja przezroczystości techniką peelingu dualnego Teraz zaimplementujemy technikę dualnego peelingu głębi. Jej główna idea sprowadza się do zdejmowania dwóch warstw głębi jednocześnie, jednej z przodu i jednej z tyłu. Rezultat jest taki sam jak w peelingu jednokierunkowym, ale czas obliczeń znacznie krótszy.
Przygotowania Gotowy kod przykładowej aplikacji znajduje się w folderze Rozdział6/PeelingDualny.
Jak to zrobić? Aby zaimplementować peeling dualny, wykonaj następujące czynności:
197
OpenGL. Receptury dla programisty
1. Utwórz FBO i sześć tekstur — dwie do zapisu bufora przedniego, dwie do zapisu bufora tylnego i dwie do zapisu bufora głębi. glGenFramebuffers(1, &dualDepthFBOID); glGenTextures (2, texID); glGenTextures (2, backTexID); glGenTextures (2, depthTexID); for(int i=0;i<2;i++) { glBindTexture(GL_TEXTURE_RECTANGLE, depthTexID[i]); // ustaw parametry tekstury glTexImage2D(GL_TEXTURE_RECTANGLE , 0, GL_FLOAT_RG32_NV, WIDTH, HEIGHT, 0, GL_RGB, GL_FLOAT, NULL); glBindTexture(GL_TEXTURE_RECTANGLE,texID[i]); // ustaw parametry tekstury glTexImage2D(GL_TEXTURE_RECTANGLE , 0, GL_RGBA, WIDTH, HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL); glBindTexture(GL_TEXTURE_RECTANGLE,backTexID[i]); //ustaw parametry tekstury glTexImage2D(GL_TEXTURE_RECTANGLE , 0, GL_RGBA, WIDTH, HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL); }
2. Dołącz wszystkie sześć tekstur do odpowiednich przyłączy w FBO. glBindFramebuffer(GL_FRAMEBUFFER, dualDepthFBOID); for(int i=0;i<2;i++) { glFramebufferTexture2D(GL_FRAMEBUFFER, attachID[i], GL_TEXTURE_RECTANGLE, depthTexID[i], 0); glFramebufferTexture2D(GL_FRAMEBUFFER, attachID[i]+1, GL_TEXTURE_RECTANGLE, texID[i], 0); glFramebufferTexture2D(GL_FRAMEBUFFER, attachID[i]+2, GL_TEXTURE_RECTANGLE, backTexID[i], 0); }
3. Utwórz następny FBO do mieszania kolorów i przyłącz do niego nową teksturę. Przyłącz ją również do pierwszego FBO i sprawdź jego kompletność. glGenTextures(1, &colorBlenderTexID); glBindTexture(GL_TEXTURE_RECTANGLE, colorBlenderTexID); // ustaw parametry tekstury glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_RGBA, WIDTH, HEIGHT, 0, GL_RGBA, GL_FLOAT, 0); glGenFramebuffers(1, &colorBlenderFBOID); glBindFramebuffer(GL_FRAMEBUFFER, colorBlenderFBOID); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_RECTANGLE, colorBlenderTexID, 0); glBindFramebuffer(GL_FRAMEBUFFER, dualDepthFBOID); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT6, GL_TEXTURE_RECTANGLE, colorBlenderTexID, 0); GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); if(status == GL_FRAMEBUFFER_COMPLETE )
198
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
printf("Ustawienie FBO powiodlo sie !!! \n"); else printf("Problem z ustawieniem FBO"); glBindFramebuffer(GL_FRAMEBUFFER, 0);
4. W funkcji renderującej najpierw wyłącz testowanie głębi i włącz mieszanie, a następnie zwiąż FBO głębi. Zainicjalizuj i przygotuj DrawBuffer do renderowania do tekstur przyłączonych do GL_COLOR_ATTACHMENT1 i GL_COLOR_ATTACHMENT2. glDisable(GL_DEPTH_TEST); glEnable(GL_BLEND); glBindFramebuffer(GL_FRAMEBUFFER, dualDepthFBOID); glDrawBuffers(2, &drawBuffers[1]); glClearColor(0, 0, 0, 0); glClear(GL_COLOR_BUFFER_BIT);
5. Następnie ustaw GL_COLOR_ATTACHMENT0 jako bufor rysowania, włącz mieszanie typu min/max (glBlendEquation(GL_MAX)) i zainicjalizuj przyłącze koloru za pomocą shadera fragmentów (Rozdział6/PeelingDualny/shadery/dual_init.frag). Na tym kończy się pierwszy etap dualnego peelingu głębi, czyli inicjalizacja buforów. glDrawBuffer(drawBuffers[0]); glClearColor(-MAX_DEPTH, -MAX_DEPTH, 0, 0); glClear(GL_COLOR_BUFFER_BIT); glBlendEquation(GL_MAX); DrawScene(MVP, initShader);
6. Potem ustaw GL_COLOR_ATTACHMENT6 jako bufor rysowania, wyczyść je kolorem tła i uruchom pętlę, w której są naprzemiennie zapisywane dwa bufory rysowania i występuje mieszanie typu min/max. Następnie narysuj scenę jeszcze raz. glDrawBuffer(drawBuffers[6]); glClearColor(bg.x, bg.y, bg.z, bg.w); glClear(GL_COLOR_BUFFER_BIT); int numLayers = (NUM_PASSES - 1) * 2; int currId = 0; for (int layer = 1; bUseOQ || layer < numLayers; layer++) { currId = layer % 2; int prevId = 1 - currId; int bufId = currId * 3; glDrawBuffers(2, &drawBuffers[bufId+1]); glClearColor(0, 0, 0, 0); glClear(GL_COLOR_BUFFER_BIT); glDrawBuffer(drawBuffers[bufId+0]); glClearColor(-MAX_DEPTH, -MAX_DEPTH, 0, 0); glClear(GL_COLOR_BUFFER_BIT); glDrawBuffers(3, &drawBuffers[bufId+0]); glBlendEquation(GL_MAX); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_RECTANGLE, depthTexID[prevId]);
199
OpenGL. Receptury dla programisty
glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_RECTANGLE, texID[prevId]); DrawScene(MVP, dualPeelShader, true,true);
7. Na koniec włącz mieszanie addytywne (glBlendFunc(GL_FUNC_ADD)) i za pomocą shadera mieszającego narysuj pełnoekranowy czworokąt. W ten sposób zostaną zdjęte fragmenty zarówno z przedniej, jak i z tylnej warstwy renderowanej geometrii, a rezultat zostanie zmieszany z zawartością bieżącego bufora rysowania. glDrawBuffer(drawBuffers[6]); glBlendEquation(GL_FUNC_ADD); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); if (bUseOQ) { glBeginQuery(GL_SAMPLES_PASSED_ARB, queryId); } glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_RECTANGLE, backTexID[currId]); blendShader.Use(); DrawFullScreenQuad(); blendShader.UnUse();
8. Na etapie końcowym odwiąż FBO i włącz renderowanie do domyślnego bufora tylnego (GL_BACK_LEFT). Następnie skieruj rezultaty etapów peelingu i mieszania do odpowiednich tekstur. Na koniec uruchom shader finalny, aby połączyć oba rezultaty w jeden wypadkowy kolor fragmentu. glBindFramebuffer(GL_FRAMEBUFFER, 0); glDrawBuffer(GL_BACK_LEFT); glBindTexture(GL_TEXTURE_RECTANGLE, colorBlenderTexID); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_RECTANGLE, depthTexID[currId]); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_RECTANGLE, texID[currId]); glActiveTexture(GL_TEXTURE2); glBindTexture(GL_TEXTURE_RECTANGLE, colorBlenderTexID); finalShader.Use(); DrawFullScreenQuad(); finalShader.UnUse();
Jak to działa? Peeling dualny działa podobnie jak jednokierunkowy. Różni się od niego tylko tym, że w jednym przebiegu odrzuca dwie warstwy głębi, z przodu i z tyłu, używając mieszania typu min/max. Najpierw za pomocą shadera fragmentów (Rozdział6/PeelingDualny/shadery/dual_init.frag) i wspomnianego przed chwilą mieszania wyznaczamy wartości głębi dla bieżącego fragmentu. vFragColor.xy = vec2(-gl_FragCoord.z, gl_FragCoord.z);
200
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
To powoduje zainicjalizowanie buforów mieszania. Następnie uruchamiamy pętlę, ale zamiast odrzucać kolejne warstwy w kierunku od przodu do tyłu odrzucamy najpierw warstwę z tyłu, a potem z przodu. Zadanie to wykonuje shader fragmentów (Rozdział6/PeelingDualny/shadery/ dual_peel.frag) w połączeniu z mieszaniem typu max. float fragDepth = gl_FragCoord.z; vec2 depthBlender = texture(depthBlenderTex, gl_FragCoord.xy).xy; vec4 forwardTemp = texture(frontBlenderTex, gl_FragCoord.xy); //inicjalizacja zmiennych … if (fragDepth < nearestDepth || fragDepth > farthestDepth) { vFragColor0.xy = vec2(-MAX_DEPTH); return; } if(fragDepth > nearestDepth && fragDepth < farthestDepth) { vFragColor0.xy = vec2(-fragDepth, fragDepth); return; } vFragColor0.xy = vec2(-MAX_DEPTH); if (fragDepth == nearestDepth) { vFragColor1.xyz += vColor.rgb * alpha * alphaMultiplier; vFragColor1.w = 1.0 - alphaMultiplier * (1.0 - alpha); } else { vFragColor2 += vec4(vColor.rgb,alpha); }
Shader mieszający (Rozdział6/PeelingDualny/shadery/blend.frag) po prostu odrzuca fragmenty o wartości alfa równej zero. Dzięki temu kwerenda widoczności podaje właściwą liczbę próbek użytych w peelingu głębi dla danego fragmentu. vFragColor = texture(tempTexture, gl_FragCoord.xy); if(vFragColor.a == 0) discard;
Ostatni shader mieszający (Rozdział6/PeelingDualny/shadery/final.frag) bierze zmieszane kolory przodu i tyłu oraz odpowiednie tekstury i łączy to wszystko, aby uzyskać ostateczny kolor fragmentu. vec4 frontColor = texture(frontBlenderTex, gl_FragCoord.xy); vec3 backColor = texture(backBlenderTex, gl_FragCoord.xy).rgb; vFragColor.rgb = frontColor.rgb + backColor * frontColor.a;
I jeszcze jedno… Przykładowa aplikacja zbudowana zgodnie z powyższym przepisem jest podobna do poprzedniej. Przy włączonym peelingu dualnym otrzymujemy rezultat taki jak na rysunku na następnej stronie.
201
OpenGL. Receptury dla programisty
Wciśnięcie klawisza spacji włącza (lub wyłącza) peeling dualny. Jeśli go wyłączymy, otrzymamy rezultat taki jak na rysunku na następnej stronie.
Dowiedz się więcej Przeczytaj artykuł Louisa Bavoila i Kevina Myersa zatytułowany Order Independent Transparency with Dual Depth Peeling, przykład w NVIDIA OpenGL 10 sdk, dostępny pod adresem: http://developer.download.nvidia.com/SDK/10/opengl/src/dual_depth_peeling/doc/ DualDepthPeeling.pdf.
202
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
Implementacja okluzji otoczenia w przestrzeni ekranu (SSAO) W poprzednich rozdziałach stosowaliśmy proste instalacje oświetleniowe. Niektóre aspekty światła są w nich niestety mocno uproszczone, a jak już wspomniałem, techniki oświetlenia globalnego są zbyt czasochłonne, aby mogły być stosowane w grafice interaktywnej. Dlatego w ciągu ostatnich lat wypracowano kilka rozwiązań, które mają symulować efekty oświetlenia globalnego. Jednym z nich jest okluzja otoczenia w przestrzeni ekranu (SSAO — screen space ambient occlusion). Jak sugeruje nazwa, jest to metoda działająca w przestrzeni ekranu. Dla każdego punktu ekranu (piksela) można wyznaczyć wielkość okluzji pochodzącej od sąsiednich pikseli na podstawie różnic wartości ich głębi. W celu zminimalizowania artefaktów spowodowanych nieciągłością próbkowania współrzędne próbkowanych pikseli są wybierane w sposób losowy. Jeśli wartości głębi dwóch pikseli są bliskie sobie, to znaczy, że te piksele reprezentują fragmenty
203
OpenGL. Receptury dla programisty
geometrii położone w przestrzeni niedaleko jeden od drugiego. Znając różnicę głębi, można oszacować wartość okluzji. Algorytm takich obliczeń, przedstawiony w pseudokodzie, może wyglądać następująco: Pobierz położenie (p), normalną (n) i głębię (d) bieżącego piksela Dla każdego piksela z sąsiedztwa pobierz położenie (p0) sąsiedniego piksela Wywołaj procedurę ObliczAO(p, p0, n) Koniec pętli Zwróć wartość okluzji otoczenia jako kolor
Procedura obliczania okluzji jest zdefiniowana następująco: const float DEPTH_TOLERANCE = 0.00001; proc CalcAO(p,p0,n) diff = p0-p-DEPTH_TOLERANCE; v = normalize(diff); d = length(diff)*scale; return max(0.1, dot(n,v)-bias)*(1.0/(1.0+d))*intensity; end proc
Mamy tu trzy parametry sterujące: skalę (scale), przesunięcie (bias) oraz intensywność (inten sity). Skala wpływa na rozmiar obszaru okluzji, przesunięcie lokuje ten obszar w innym miejscu, a intensywność steruje siłą symulowanego zjawiska. Stała DEPTH_TOLERANCE została wprowadzona po to, by nie dopuścić do powstawania artefaktów typu z-fighting. Cała receptura wygląda następująco: Wczytujemy model 3D i renderujemy go do pozaekranowej tekstury, stosując FBO. Używamy dwóch FBO ― jednego do przechowywania głębi i normalnych z przestrzeni oka, a drugiego do filtrowania wyników pośrednich. W pierwszym FBO stosujemy zmiennoprzecinkowe formaty zarówno dla przyłączy koloru, jak i głębi. Dla tekstury koloru stosujemy format GL_RGBA32F, a dla tekstury głębi — GL_DEPTH_COMPONENT32F. Formaty zmiennoprzecinkowe zapewniają większą precyzję, bez której mogłyby się pojawiać niepożądane artefakty w renderowanych obrazach. Drugi FBO jest potrzebny do wykonywania gaussowskiego wygładzania, takiego samego jak to, które opisałem w recepturze „Wariancyjne mapowanie cieni” z rozdziału 4. Ten obiekt ma dwa przyłącza koloru w formacie zmiennoprzecinkowym GL_RGBA32F. W funkcji renderującej najpierw odbywa się zwykłe renderowanie sceny. Następnie użyty zostaje pierwszy shader w celu wyznaczenia normalnych w przestrzeni oka. Normalne są zapisywane w przyłączu koloru pierwszego FBO, a wartości głębi trafiają do przyłącza głębi tego samego FBO. Po zakończeniu tego etapu wiązany jest filtrujący obiekt bufora ramki i uruchamiany jest drugi shader, który na podstawie danych pobranych z tekstur głębi i normalnych przyłączonych do pierwszego FBO wyznacza wartość okluzji otoczenia. Ze względu na losowy wybór punktów sąsiednich wyniki zawierają pewien poziom szumu. Aby ten szum usunąć, rezultat obliczeń poddaje się wygładzaniu metodą gaussowską. Na koniec przefiltrowany rezultat łączy się z istniejącym renderingiem przez zwykłe mieszanie alfa.
204
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
Przygotowania Gotowy kod przykładowej aplikacji znajduje się w folderze Rozdział6/SSAO. Wykorzystamy w niej przeglądarkę plików OBJ opisaną w rozdziale 5. Wczytany model OBJ wzbogacimy o efekt SSAO.
Jak to zrobić? Zacznij od wykonania następujących prostych czynności: 1. Utwórz globalną referencję do obiektu ObjLoader. Wywołaj funkcję ObjLoader::Load, przekazując jej nazwę pliku OBJ. Przekaż też wektory do przechowywania siatek, wierzchołków, indeksów i materiałów zawartych we wczytywanym pliku. 2. Utwórz obiekt bufora ramki (FBO) z dwoma przyłączami — jednym do przechowywania normalnych i drugim do przechowywania głębi. Oba niech mają format tekstur zmiennoprzecinkowych (GL_RGBA32F). Utwórz też drugi FBO dla wygładzania rezultatów obliczeń SSAO. Musisz tutaj użyć kilku jednostek teksturujących, ponieważ drugi shader wymaga, aby tekstura z normalnymi była związana z jednostką nr 1, a tekstura zawierająca wartości głębi — z jednostką nr 3. glGenFramebuffers(1, &fboID); glBindFramebuffer(GL_FRAMEBUFFER, fboID); glGenTextures(1, &normalTextureID); glGenTextures(1, &depthTextureID); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_2D, normalTextureID); // ustaw parametry tekstur glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, WIDTH, HEIGHT, 0, GL_BGRA, GL_FLOAT, NULL); glActiveTexture(GL_TEXTURE3); glBindTexture(GL_TEXTURE_2D, depthTextureID); // ustaw parametry tekstur glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT32F, WIDTH, HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, normalTextureID, 0); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthTextureID, 0); glGenFramebuffers(1,&filterFBOID); glBindFramebuffer(GL_FRAMEBUFFER,filterFBOID); glGenTextures(2, blurTexID); for(int i=0;i<2;i++) { glActiveTexture(GL_TEXTURE4+i); glBindTexture(GL_TEXTURE_2D, blurTexID[i]); // ustaw parametry tekstur
205
OpenGL. Receptury dla programisty
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA32F,RTT_WIDTH, RTT_HEIGHT,0,GL_RGBA,GL_FLOAT,NULL); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0+i, GL_TEXTURE_2D,blurTexID[i],0); }
3. W funkcji renderującej wyrenderuj scenę w zwykły sposób. Potem zwiąż pierwszy FBO i uruchom pierwszy program shaderowy. Program ten pobierze położenia i normalne wierzchołków w przestrzeni obiektu, aby na ich podstawie wyznaczyć normalne w przestrzeni oka. glBindFramebuffer(GL_FRAMEBUFFER, fboID); glViewport(0,0,RTT_WIDTH, RTT_HEIGHT); glDrawBuffer(GL_COLOR_ATTACHMENT0); glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); glBindVertexArray(vaoID); { ssaoFirstShader.Use(); glUniformMatrix4fv(ssaoFirstShader("MVP"), 1, GL_FALSE, glm::value_ptr(P*MV)); glUniformMatrix3fv(ssaoFirstShader("N"), 1, GL_FALSE, glm::value_ptr(glm::inverseTranspose(glm::mat3(MV)))); for(size_t i=0;icount, GL_UNSIGNED_SHORT, (const GLvoid*)(&indices[pMat->offset])); } ssaoFirstShader.UnUse(); }
Pierwszy shader wierzchołków (Rozdział6/SSAO/shadery/SSAO_FirstStep.vert) wyznacza normalne w przestrzeni oka zgodnie z poniższym kodem. #version 330 core layout(location = 0) in vec3 vVertex; layout(location = 1) in vec3 vNormal; uniform mat4 MVP; uniform mat3 N; smooth out vec3 vEyeSpaceNormal; void main() { vEyeSpaceNormal = N*vNormal; gl_Position = MVP*vec4(vVertex,1); }
Shader fragmentów (Rozdział6/SSAO/shadery/SSAO_FirstStep.frag) zwraca interpolowaną normalną. #version 330 core smooth in vec3 vEyeSpaceNormal; layout(location=0) out vec4 vFragColor; void main() {
206
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
vFragColor = vec4(normalize(vEyeSpaceNormal)*0.5 + 0.5, 1); }
4. Zwiąż obiekt filtrujący i uruchom drugi shader (Rozdział6/SSAO/shadery/SSAO_ SecondStep.frag). To właśnie ten shader wykonuje właściwe obliczenia SSAO. Danych wejściowych dostarcza mu tekstura normalnych z etapu 3. Shader ten działa na pełnoekranowym czworokącie. glBindFramebuffer(GL_FRAMEBUFFER,filterFBOID); glDrawBuffer(GL_COLOR_ATTACHMENT0); glBindVertexArray(quadVAOID); ssaoSecondShader.Use(); glUniform1f(ssaoSecondShader("radius"), sampling_radius); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); ssaoSecondShader.UnUse();
5. Przefiltruj rezultaty z etapu 4., stosując rozmycie gaussowskie zakodowane w dwóch shaderach fragmentów (Rozdział6/SSAO/shadery/GaussH.frag i Rozdział6/ SSAO/shadery/GaussV.frag). Rozmycie to ma na celu wygładzenie efektu okluzji otoczenia. glDrawBuffer(GL_COLOR_ATTACHMENT1); glBindVertexArray(quadVAOID); gaussianV_shader.Use(); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); glDrawBuffer(GL_COLOR_ATTACHMENT0); gaussianH_shader.Use(); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0);
6. Odwiąż filtrujący obiekt bufora ramki, a następnie przywróć domyślne okno widokowe i domyślny bufor rysowania. Włącz mieszanie alfa i uruchom shader finalny (Rozdział6/SSAO/shadery/final.frag), aby połączyć rezultaty etapów 3. i 5. Shader ten po prostu renderuje ostateczny obraz z etapu 3. na pełnoekranowym czworokącie. glBindFramebuffer(GL_FRAMEBUFFER,0); glViewport(0,0,WIDTH, HEIGHT); glDrawBuffer(GL_BACK_LEFT); glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); finalShader.Use(); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); finalShader.UnUse(); glDisable(GL_BLEND);
Jak to działa? Obliczanie SSAO można podzielić na trzy etapy. W pierwszym przygotowujemy dane wejściowe, czyli wektory normalne i wartości głębi w przestrzeni oka. Normalne są wyznaczane przez pierwszy shader wierzchołków (Rozdział6/SSAO/shadery/SSAO_FirstStep.vert). 207
OpenGL. Receptury dla programisty
vEyeSpaceNormal_Depth = N*vNormal; vec4 esPos = MV*vec4(vVertex,1); gl_Position = P*esPos;
Skojarzony z nim shader fragmentów (Rozdział6/SSAO/shadery/SSAO_FirstStep.frag) przekazuje te wartości dalej. Wartości głębi są pobierane z przyłącza głębi w FBO. Etap drugi to właściwe obliczanie SSAO. Używamy do tego celu drugiego shadera fragmentów (Rozdział6/SSAO/shadery/SSAO_SecondStep.frag), który najpierw renderuje dopasowany do ekranu czworokąt, a następnie pobiera dla każdego fragmentu odpowiadające mu normalną i głębię z tekstury wyrenderowanej w ramach pierwszego etapu. Potem uruchamiamy pętlę porównującą wartości głębi fragmentów sąsiednich, aby na tej podstawie wyznaczyć wartość okluzji. float depth = texture(depthTex, vUV).r; if(depth<1.0) { vec3 n = normalize(texture(normalTex, vUV).xyz*2.0 - 1.0); vec4 p = invP*vec4(vUV,depth,1); p.xyz /= p.w; vec2 random = normalize(texture(noiseTex, viewportSize/random_size * vUV).rg * 2.0 - 1.0); float ao = 0.0; for(int i = 0; i < NUM_SAMPLES; i++) { float npw = (pw + radius * samples[i].x * random.x); float nph = (ph + radius * samples[i].y * random.y); vec2 uv = vUV + vec2(npw, nph); vec4 p0 = invP * vec4(vUV,texture2D(depthTex, uv ).r, 1.0); p0.xyz /= p0.w; ao += calcAO(p0, p, n); //wybierz z sąsiedztwa punkty o podobnej głębi //i oblicz poziom okluzji otoczenia } ao *= INV_NUM_SAMPLES/8.0; vFragColor = vec4(vec3(0), ao); }
W trzecim etapie filtrujemy rezultat obliczeń SSAO, stosując separowalny splot gaussowski. Potem już tylko przywracamy domyślny bufor rysowania i mieszamy przefiltrowany rezultat SSAO ze zwykłym renderingiem.
I jeszcze jedno… Aplikacja ilustrująca powyższą recepturę pokazuje scenę złożoną z trzech kostek leżących na płaskim czworokącie. Po jej uruchomieniu widzimy obraz taki jak na rysunku na następnej stronie.
208
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
Wciśnięcie klawisza spacji wyłącza SSAO, co daje rezultat widoczny na rysunku na następnej stronie. Jak widać, okluzja otoczenia znacznie ułatwia ocenę wzajemnych odległości między obiektami. Aplikacja umożliwia także zmianę promienia obszaru próbkowanego przez wciskanie klawiszy + (plus) i – (minus).
Dowiedz się więcej Zapoznaj się z następującymi publikacjami: Jose Maria Mendez, A Simple and Practical Approach to SSAO, http://www.gamedev.
net/page/resources/_/technical/graphics-programmingand-theory/a-simple-and-practical-approach-to-ssao-r2753. Artykuł na temat SSAO w serwisie GameRendering.com, http://www.gamerendering.
com/category/lighting/ssao-lighting/.
209
OpenGL. Receptury dla programisty
Implementacja metody harmonik sferycznych w oświetleniu globalnym W tej recepturze pokażę, jak można zaimplementować proste oświetlenie globalne z użyciem harmonik sferycznych. Harmoniki sferyczne to sposób aproksymowania wartości funkcji za pomocą iloczynu odpowiednich współczynników i zbioru funkcji elementarnych. Zamiast obliczania dwukierunkowej funkcji rozkładu odbić (bi-directional reflectance distribution function — BRDF) metoda ta wykorzystuje specjalne obrazy HDR/RGBE zawierające informacje oświetleniowe. Jedynym wymaganym przez nią atrybutem wierzchołków są ich normalne. Normalne te są mnożone przez współczynniki harmonik sferycznych wyznaczanych na podstawie obrazów HDR/RGBE. Format RGBE wynalazł Greg Ward. Obrazy tego typu przeznaczają trzy bajty na przechowywanie wartości RGB (kanałów czerwonego, zielonego i niebieskiego), a w czwartym umieszczany jest wykładnik potęgi wspólny dla wszystkich trzech kanałów. Poszerza to znacznie zakres dopuszczalnych wartości i zwiększa ich precyzję do poziomu liczb zmiennoprzecinkowych.
210
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
Szczegóły teorii harmonik sferycznych i formatu RGBE są opisane w literaturze podanej w punkcie „Dowiedz się więcej”. W dużym skrócie receptura niniejsza polega na wyznaczeniu współczynników SH (od C1 do C5) dla konkretnego obrazu HDR. Szczegółowy opis zastosowanej metody rzutowania można znaleźć w literaturze podanej w punkcie „Dowiedz się więcej”. Dla większości dostępnych próbników światła HDR współczynniki harmonik sferycznych (SH) są już wyliczone. Użyjemy ich jako stałych wartości w shaderze wierzchołków.
Przygotowania Pełny kod receptury znajduje się w folderze Rozdział6/HarmonikiSferyczne. Do wczytania przykładowych modeli wykorzystamy omawianą w poprzednim rozdziale przeglądarkę plików OBJ.
Jak to zrobić? Zacznij od wykonania następujących czynności: 1. Podobnie jak w poprzednich recepturach wczytaj siatkę za pomocą obiektu klasy ObjLoader i pobranymi z jej materiału danymi wypełnij bufory i tekstury OpenGL. 2. W shaderze wierzchołków operującym na wczytanej siatce zakoduj obliczanie oświetlenia metodą harmonik sferycznych. Kod tego shadera powinien wyglądać następująco: #version 330 core layout(location = 0) in vec3 vVertex; layout(location = 1) in vec3 vNormal; layout(location = 2) in vec2 vUV; smooth out vec2 vUVout; smooth out vec4 diffuse; uniform mat4 P; uniform mat4 MV; uniform mat3 N; const const const const const const
float float float float float float
C1 C2 C3 C4 C5 PI
= = = = = =
0.429043; 0.511664; 0.743125; 0.886227; 0.247708; 3.1415926535897932384626433832795;
//próbnik światła rynku starego miasta const vec3 L00 = vec3( 0.871297, 0.875222, 0.864470); const vec3 L1m1 = vec3( 0.175058, 0.245335, 0.312891);
211
OpenGL. Receptury dla programisty
const vec3 L10 = vec3( 0.034675, 0.036107, 0.037362); const vec3 L11 = vec3(-0.004629, -0.029448, -0.048028); const vec3 L2m2 = vec3(-0.120535, -0.121160, -0.117507); const vec3 L2m1 = vec3( 0.003242, 0.003624, 0.007511); const vec3 L20 = vec3(-0.028667, -0.024926, -0.020998); const vec3 L21 = vec3(-0.077539, -0.086325, -0.091591); const vec3 L22 = vec3(-0.161784, -0.191783, -0.219152); const vec3 scaleFactor = vec3(0.161784/ (0.871297+0.161784), 0.191783/(0.875222+0.191783), 0.219152/(0.864470+0.219152)); void main() { vUVout=vUV; vec3 tmpN = normalize(N*vNormal); vec3 diff = C1 * L22 * (tmpN.x*tmpN.x tmpN.y*tmpN.y) + C3 * L20 * tmpN.z*tmpN.z + C4 * L00 C5 * L20 + 2.0 * C1 * L2m2*tmpN.x*tmpN.y + 2.0 * C1 * L21*tmpN.x*tmpN.z + 2.0 * C1 * L2m1*tmpN.y*tmpN.z + 2.0 * C2 * L11*tmpN.x + 2.0 * C2 * L1m1*tmpN.y + 2.0 * C2 * L10*tmpN.z; diff *= scaleFactor; diffuse = vec4(diff, 1); gl_Position = P*(MV*vec4(vVertex,1)); }
3. Kolory poszczególnych wierzchołków obliczone przez powyższy shader są interpolowane przez rasteryzer, po czym shader fragmentów ustawia je jako kolory fragmentów. #version 330 core uniform sampler2D textureMap; uniform float useDefault; smooth in vec4 diffuse; smooth in vec2 vUVout; layout(location=0) out vec4 vFragColor; void main() { vFragColor = mix(texture(textureMap, vUVout)*diffuse, diffuse, useDefault); }
212
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
Jak to działa? Technika harmonik sferycznych aproksymuje oświetlenie za pomocą współczynników i bazy SH. Współczynniki są wyznaczane podczas inicjalizacji na podstawie obrazu HDR/RGBE zawierającego informacje oświetleniowe. Zastosowana aproksymacja umożliwia dość wierne odtworzenie w renderowanej scenie oświetlenia zarejestrowanego w obrazie. Sam obraz, z którego są pobierane informacje, nie jest bezpośrednio dostępny dla kodu naszej aplikacji. Potrzebna baza harmonik sferycznych i ich współczynniki zostały wyznaczone wcześniej przez odpowiednie rzutowanie. Jest to proces dość skomplikowany matematycznie, więc nie będę go tutaj omawiał, a zainteresowanych odsyłam do literatury podanej w punkcie „Dowiedz się więcej”. Kod generujący harmoniki jest dostępny w internecie. To za jego pomocą wygenerowałem współczynniki, które wprowadziłem do shadera. Harmoniki sferyczne stanowią częstotliwościową reprezentację obrazu na powierzchni kuli. Jak pokazali Ramamoorthi i Hanrahan, dobre przybliżenie składowej rozproszeniowej światła można uzyskać przy użyciu tylko dziewięciu pierwszych współczynników. Wyznacza się je przez stałą, liniową i kwadratową interpolację wielomianową wektora normalnego oświetlanej powierzchni. W rezultacie otrzymujemy składową rozproszeniową, którą trzeba przeskalować o czynnik będący sumą wszystkich współczynników, tak jak w poniższym listingu. vec3 tmpN = normalize(N*vNormal); vec3 diff = C1 * L22 * (tmpN.x*tmpN.x - tmpN.y*tmpN.y) + C3 * L20 * tmpN.z*tmpN.z + C4 * L00 – C5 * L20 + 2.0 * C1 * L2m2*tmpN.x*tmpN.y + 2.0 * C1 * L21*tmpN.x*tmpN.z + 2.0 * C1 * L2m1*tmpN.y*tmpN.z + 2.0 * C2 * L11*tmpN.x + 2.0 * C2 * L1m1*tmpN.y + 2.0 * C2 * L10*tmpN.z; diff *= scaleFactor;
Wyznaczona dla każdego wierzchołka składowa rozproszeniowa jest następnie przekazywana przez rasteryzer do shadera fragmentów, gdzie jest mnożona przez teksturę powierzchni. vFragColor = mix(texture(textureMap, vUVout)*diffuse, diffuse, useDefault);
I jeszcze jedno… Przykładowa aplikacja zbudowana zgodnie z powyższym przepisem renderuje scenę znaną już z poprzednich receptur. Położenie kamery można zmieniać przez przeciąganie myszą z wciśniętym lewym przyciskiem, a punktowe światło możemy obracać wokół sceny przez przeciąganie myszą z wciśniętym prawym przyciskiem. Wciśnięcie klawisza spacji powoduje na przemian wyłączanie i włączanie harmonik sferycznych. Gdy są włączone, rezultat wygląda następująco:
213
OpenGL. Receptury dla programisty
Bez harmonik sferycznych rezultat wygląda tak:
214
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
Obraz będący próbnikiem światła dla tej sceny jest pokazany na poniższym rysunku.
Zauważ, że ta metoda aproksymuje oświetlenie globalne przez modyfikowanie składowej rozproszenia przy użyciu współczynników harmonik sferycznych. Do tego można dodać konwencjonalny model oświetlenia Blinna-Phonga — trzeba tylko wyznaczyć rozkład światła na podstawie położenia źródła światła i wektorów normalnych, tak jak robiliśmy to w poprzedniej recepturze.
Dowiedz się więcej Zapoznaj się z następującymi publikacjami: Ravi Ramamoorthi i Pat Hanrahan, An Efficient Representation for Irradiance
Environment Maps, http://www1.cs.columbia.edu/~ravir/papers/envmap/ index.html. Randi J. Rost, Mill M. Licea-Kane, Dan Ginsburg, John M. Kessenich, Barthold
Lichtenbelt, Hugh Malan i Mike Weiblen, OpenGL Shading Language. Third Edition, Addison-Wesley Professional, cz. 12.3 „Lighting and Spherical Harmonics”.
215
OpenGL. Receptury dla programisty
Kelly Dempski i Emmanuel Viale, Advanced Lighting and Materials with Shaders,
Jones & Bartlett Publishers, rozdział 8., „Spherical Harmonic Lighting”. Specyfikacja formatu RGBE, http://www.graphics.cornell.edu/online/formats/rgbe/. Próbniki światła HDR Paula Debeveca, http://www.pauldebevec.com/Probes/. Przykład implementacji oświetlenia metodą harmonik sferycznych, http://www.
paulsprojects.net/opengl/sh/sh.html.
Śledzenie promieni realizowane przez GPU Do tej pory renderowaliśmy trójwymiarowe sceny, stosując rasteryzację. Tym razem zaimplementujemy metodę renderowania zwaną śledzeniem promieni (ray tracing). Mówiąc ogólnie, polega ona na wypuszczaniu wirtualnego promienia światła z kamery w głąb sceny i wyznaczaniu jego kolizji z poszczególnymi obiektami. Jej niewątpliwą zaletą jest to, że ostatecznie renderowane są tylko obiekty widoczne. Algorytm śledzenia promieni zapisany w pseudokodzie wygląda następująco: Dla każdego piksela na ekranie Wyznacz początek i kierunek rozchodzenia się promienia z kamery Dla koniecznej liczby śledzonych promieni Wypuść promień Dla każdego obiektu w scenie Sprawdź, czy promień w niego trafia Jeśli tak, Wyznacz punkt trafienia i wektor normalny w tym punkcie Dla każdego źródła światła Wyznacz składowe rozproszenia i odbicia w punkcie trafienia Poprowadź linię cienia z punktu trafienia do źródła światła Koniec pętli Przyciemnij składową rozproszenia zgodnie z wynikami obliczeń cienia Ustanów punkt trafienia początkiem nowego promienia Wyznacz kierunek nowego promienia zgodnie z prawem odbicia Koniec warunku Koniec pętli Koniec pętli Koniec pętli
Przygotowania Gotowy kod przykładowej aplikacji znajduje się w folderze Rozdział6/GPURaytracing.
216
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
Jak to zrobić? Zacznij od wykonania następujących prostych czynności: 1. Za pomocą przeglądarki plików OBJ wczytaj model siatkowy i zapisz jego geometrię w wektorach. W metodzie śledzenia promieni używane będą oryginalne położenia wierzchołków i listy indeksów zapisane w pliku OBJ. vector indices2; vector vertices2; if(!obj.Load(mesh_filename.c_str(), meshes, vertices, indices, materials, aabb, vertices2, indices2)) { cout<<"Nie moge wczytac siatki 3D"<
2. Wczytaj wszystkie mapy tekstur materiałowych do jednej OpenGL-owej tablicy tekstur zamiast, jak w poprzednich recepturach, pojedynczo do oddzielnych tekstur. Zastosowanie tablicy tekstur pozwala uprościć kod shadera, a poza tym nie byłoby sposobu na określenie liczby potrzebnych samplerów, bo ta zależy od tekstur materiałowych wczytywanych wraz z modelem. W poprzednich recepturach zawsze był jeden sampler, który można było dostosować do każdej siatki składowej. for(size_t k=0;kmap_Kd != "") { if(k==0) { glGenTextures(1, &textureID); glBindTexture(GL_TEXTURE_2D_ARRAY, textureID); glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR); //ustaw inne parametry tekstury } //ustal nazwę obrazu GLubyte* pData = SOIL_load_image(full_filename.c_str(), &texture_width, &texture_height, &channels, SOIL_LOAD_AUTO); if(pData == NULL) { cerr<<" Nie moge wczytac obrazu: "<
217
OpenGL. Receptury dla programisty
3. Na potrzeby shadera realizującego śledzenie promieni zapisz położenia wierzchołków w teksturze. Użyj do tego tekstury zmiennoprzecinkowej z wewnętrznym formatem GL_RGBA32F. glGenTextures(1, &texVerticesID); glActiveTexture(GL_TEXTURE1); glBindTexture( GL_TEXTURE_2D, texVerticesID); //ustaw formaty tekstury GLfloat* pData = new GLfloat[vertices2.size()*4]; int count = 0; for(size_t i=0;i
4. Listę indeksów zapisz w teksturze typu całkowitego z wewnętrznym formatem GL_RGBA16I i formatem danych pikselowych GL_RGBA_INTEGER. glGenTextures(1, &texTrianglesID); glActiveTexture(GL_TEXTURE2); glBindTexture( GL_TEXTURE_2D, texTrianglesID); //ustaw formaty tekstury GLushort* pData2 = new GLushort[indices2.size()]; count = 0; for(size_t i=0;i
5. W funkcji renderującej uruchom shader śledzenia promieni, a potem narysuj pełnoekranowy czworokąt, aby shader fragmentów mógł działać na pełnym ekranie.
Jak to działa? Główny kod realizujący śledzenie promieni znajduje się w shaderze fragmentów raytracer.frag (Rozdział6/GPURaytracing/shadery/raytracer.frag). Najpierw ustalamy początek promienia i jego kierunek, wykorzystując do tego celu dane przekazane do shadera w postaci uniformów.
218
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
eyeRay.origin = eyePos; cam.U = (invMVP*vec4(1,0,0,0)).xyz; cam.V = (invMVP*vec4(0,1,0,0)).xyz; cam.W = (invMVP*vec4(0,0,1,0)).xyz; cam.d = 1; eyeRay.dir = get_direction(uv , cam); eyeRay.dir += cam.U*uv.x; eyeRay.dir += cam.V*uv.y;
Po ustawieniu promienia sprawdzamy, czy trafi w ustawiony zgodnie z osiami współrzędnych prostopadłościan otaczający scenę. Jeśli trafia, śledzimy dalej jego bieg. W tym prostym przykładzie stosujemy prymitywną metodę sprawdzania wszystkich trójkątów pod kątem możliwości trafienia przez promień. Podczas śledzenia promienia staramy się znaleźć jego najbliższe trafienie w trójkątną ściankę. Promień jest zadany parametrycznie, tzn. każdy jego punkt jest jednoznacznie określony wartością parametru t. Szukamy więc takiego punktu trafienia, dla którego wartość t będzie najmniejsza. Jeśli znajdziemy taki punkt, zapisujemy jego położenie, a także współrzędne istniejącego w tym miejscu wektora normalnego. Dokładne położenie punktu trafienia wyznacza nam wartość parametru t. vec4 val=vec4(t,0,0,0); vec3 N; for(int i=0;i0 && res.x <= val.x) { val = res; N = normal; } }
Jeśli tę wartość wstawimy do parametrycznego równania promienia, otrzymamy położenie punktu trafienia. Potem wyznaczamy wektor od puntu trafienia do źródła światła. Wektor ten będzie nam potrzebny do obliczenia składowej rozproszenia i poziomu tłumienia światła. if(val.x != t) { vec3 hit = eyeRay.origin + eyeRay.dir*val.x; vec3 jitteredLight = light_position + uniformlyRandomVector(gl_FragCoord.x); vec3 L = (jitteredLight.xyz-hit); float d = length(L); L = normalize(L); float diffuse = max(0, dot(N, L)); float attenuationAmount = 1.0/(k0 + (k1*d) + (k2*d*d)); diffuse *= attenuationAmount;
219
OpenGL. Receptury dla programisty
Śledzenie promieni znakomicie ułatwia wyznaczanie cieni. Po prostu z punktu trafienia wypuszczamy drugi promień, ale w kierunku źródła światła i sprawdzamy, czy na jego drodze są jakieś obiekty. Jeśli są, przyciemniamy kolor wyjściowy, a jeśli nie — pozostawiamy bez zmiany. Aby uniknąć niepożądanych artefaktów, przesuwamy nieznacznie początek tego drugiego promienia. float inShadow = shadow(hit+ N*0.0001, L); vFragColor = inShadow*diffuse*mix(texture(textureMaps, val.yzw), vec4(1), (val.w==255) ); return; }
I jeszcze jedno… Przykładowa aplikacja zbudowana zgodnie z powyższym przepisem renderuje scenę znaną już z poprzednich receptur. Wciśnięcie klawisza spacji powoduje przełączanie między rasteryzacją a śledzeniem promieni. Ten drugi tryb łatwo rozpoznać po wyraźnie widocznych cieniach. Zauważ, że efektywność śledzenia promieni zależy bezpośrednio od odległości obiektów od kamery i od liczby trójkątów w renderowanej siatce. W celu przyspieszenia obliczeń należałoby wprowadzić dodatkowe struktury sortujące, takie jak regularna siatka czy drzewo kd. Z kolei dla silniejszego zmiękczenia cieni należałoby wypuścić więcej promieni w kierunku światła, ale to oczywiście oznacza większe obciążenie dla shadera.
220
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
Dowiedz się więcej Zapoznaj się z następującymi publikacjami: Timothy Purcell, Ian Buck, William R. Mark i Pat Hanrahan, „ACM Transactions
on Graphics” 21 (3), Ray Tracing on Programmable Graphics Hardware, s. 703 – 712, http://graphics.stanford.edu/papers/rtongfx/. Real-Time GPU Ray-Tracer w serwisie Icare3D, http://www.icare3d.org/codes-and-
projects/codes/raytracer_gpu_full_1-0.html.
Śledzenie ścieżek realizowane przez GPU Teraz zaimplementujemy jeszcze inną metodę renderowania geometrii, zwaną śledzeniem ścieżek. Podobnie jak w metodzie śledzenia promieni też są wysyłane promienie, ale nie z kamery, lecz ze źródła (źródeł) światła. Dokładne odtworzenie rzeczywistego oświetlenia jest na ogół bardzo trudne, więc będziemy je aproksymować, stosując schematy całkowania metodą Monte Carlo, gdzie na podstawie odpowiednio dużej liczby losowo wybranych próbek otrzymuje się wynik zbieżny z prawidłowym. Algorytm śledzenia ścieżek można zapisać w pseudokodzie następująco: Dla każdego piksela na ekranie Utwórz promień światła wychodzący ze źródła i skierowany losowo Dla koniecznej liczby śledzonych promieni Dla każdego obiektu w scenie Sprawdź, czy promień w niego trafia Jeśli tak, Wyznacz punkt trafienia i wektor normalny w tym punkcie Wyznacz składowe rozproszenia i odbicia w punkcie trafienia Poprowadź linię cienia z punktu trafienia w kierunku wybranym losowo Przyciemnij składową rozproszenia zgodnie z wynikami obliczeń cienia Ustanów punkt trafienia początkiem nowego promienia Wyznacz kierunek nowego promienia zgodnie z prawem odbicia Koniec warunku Koniec pętli Koniec pętli Koniec pętli
Przygotowania Gotowy kod przykładowej aplikacji znajduje się w folderze Rozdział6/GPUPathtracing.
221
OpenGL. Receptury dla programisty
Jak to zrobić? Zacznij od wykonania następujących prostych czynności: 1. Za pomocą przeglądarki plików OBJ wczytaj model siatkowy i zapisz jego geometrię w wektorach. W metodzie śledzenia ścieżek używane będą oryginalne położenia wierzchołków i listy indeksów zapisane w pliku OBJ, podobnie jak w recepturze poprzedniej. 2. Tak jak w poprzedniej recepturze wczytaj wszystkie mapy tekstur materiałowych do jednej OpenGL-owej tablicy tekstur zamiast pojedynczo do oddzielnych tekstur. 3. Na potrzeby shadera realizującego śledzenie ścieżek zapisz położenia wierzchołków w teksturze, podobnie jak przy śledzeniu promieni. Użyj do tego tekstury zmiennoprzecinkowej z wewnętrznym formatem GL_RGBA32F. glGenTextures(1, &texVerticesID); glActiveTexture(GL_TEXTURE1); glBindTexture( GL_TEXTURE_2D, texVerticesID); //ustal formaty tekstury GLfloat* pData = new GLfloat[vertices2.size()*4]; int count = 0; for(size_t i=0;i
4. Tak jak poprzednio, listę indeksów zapisz w teksturze typu całkowitego z wewnętrznym formatem GL_RGBA16I i formatem danych pikselowych GL_RGBA_INTEGER. glGenTextures(1, &texTrianglesID); glActiveTexture(GL_TEXTURE2); glBindTexture( GL_TEXTURE_2D, texTrianglesID); //ustal formaty tekstury GLushort* pData2 = new GLushort[indices2.size()]; count = 0; for(size_t i=0;i
222
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
5. W funkcji renderującej uruchom shader śledzenia ścieżek, a potem narysuj pełnoekranowy czworokąt, aby shader fragmentów mógł działać na pełnym ekranie. pathtraceShader.Use(); glUniform3fv(pathtraceShader("eyePos"), 1, glm::value_ptr(eyePos)); glUniform1f(pathtraceShader("time"), current); glUniform3fv(pathtraceShader("light_position"), 1, &(lightPosOS.x)); glUniformMatrix4fv(pathtraceShader("invMVP"), 1, GL_FALSE, glm::value_ptr(invMVP)); DrawFullScreenQuad(); pathtraceShader.UnUse();
Jak to działa? Główny kod realizujący śledzenie ścieżek znajduje się w shaderze fragmentów pathtracer.frag (Rozdział6/GPURaytracing/shadery/pathtracer.frag). Najpierw ustalamy początek promienia i jego kierunek, wykorzystując do tego celu dane przekazane do shadera w postaci uniformów. eyeRay.origin = eyePos; cam.U = (invMVP*vec4(1,0,0,0)).xyz; cam.V = (invMVP*vec4(0,1,0,0)).xyz; cam.W = (invMVP*vec4(0,0,1,0)).xyz; cam.d = 1; eyeRay.dir = get_direction(uv , cam); eyeRay.dir += cam.U*uv.x; eyeRay.dir += cam.V*uv.y;
Po ustawieniu promienia sprawdzamy, czy trafi w ustawiony zgodnie z osiami współrzędnych prostopadłościan otaczający scenę. Jeśli trafia, uruchamiamy naszą funkcję śledzenia ścieżki (pathtrace). vec2 tNearFar = intersectCube(eyeRay.origin, eyeRay.dir, aabb); if(tNearFar.x
W funkcji pathtrace wykonywana jest pętla, która w każdym przebiegu sprawdza, czy nastąpiło trafienie promienia w geometrię sceny. Stosujemy prymitywną metodę sprawdzania wszystkich trójkątów pod kątem możliwości trafienia przez promień. Jeśli promień trafia, sprawdzamy, czy jest to trafienie najbliższe, i jeśli to się potwierdza, zapisujemy współrzędne tekstury i wektora normalnego istniejące w punkcie trafienia.
223
OpenGL. Receptury dla programisty
for(int bounce = 0; bounce < MAX_BOUNCES; bounce++) { vec2 tNearFar = intersectCube(origin, ray, aabb); if( tNearFar.x > tNearFar.y) continue; if(tNearFar.y0.001 && res.x < val.x) { val = res; N = normal; } }
Następnie sprawdzamy wartość parametru t, aby znaleźć najbliższe trafienie, i wtedy z tablicy tekstur pobieramy próbkę tekstury w celu określenia wyjściowego koloru dla bieżącego fragmentu. Potem przenosimy punkt początkowy promienia do miejsca trafienia i zmieniamy kierunek na losowo wybrany z całej półkuli roztaczającej się nad trafioną powierzchnią. if(val.x < t) { surfaceColor = mix(texture(textureMaps, val.yzw), vec4(1), (val.w==255) ).xyz; vec3 hit = origin + ray * val.x; origin = hit; ray = uniformlyRandomDirection(time + float(bounce));
Wyznaczamy składową rozproszenia i odpowiednio modyfikujemy kolor. Na zakończenie pętli wyprowadzamy ostatecznie zakumulowany kolor. vec3 jitteredLight = light + ray; vec3 L = normalize(jitteredLight - hit); diffuse = max(0.0, dot(L, N)); colorMask *= surfaceColor; float inShadow = shadow(hit+ N*0.0001, L); accumulatedColor += colorMask * diffuse * inShadow; t = val.x; } } if(accumulatedColor == vec3(0)) return surfaceColor*diffuse; else return accumulatedColor/float(MAX_BOUNCES-1);}
Zauważ, że obrazy wyrenderowane metodą śledzenia ścieżek są zawsze zaszumione i trzeba naprawdę mocno zwiększyć liczbę próbek, aby to zaszumienie zmalało.
224
Rozdział 6. • Mieszanie alfa i oświetlenie globalne na GPU
I jeszcze jedno… Przykładowa aplikacja zbudowana zgodnie z powyższym przepisem renderuje scenę znaną już z poprzednich receptur. Wciśnięcie klawisza spacji powoduje przełączanie między rasteryzacją a śledzeniem ścieżek (patrz rysunek poniżej).
Zauważ, że efektywność śledzenia promieni zależy bezpośrednio od odległości obiektów od kamery oraz od liczby trójkątów w renderowanej siatce. W celu przyspieszenia obliczeń należałoby wprowadzić dodatkowe struktury sortujące, takie jak regularna siatka czy drzewo kd. Poza tym rezultaty renderowania oświetlenia metodą śledzenia ścieżek są na ogół bardziej zaszumione w porównaniu z renderingami wykonanymi metodą śledzenia promieni i trzeba je poddawać filtrowaniu wygładzającemu. Śledzenie promieni słabo symuluje efekty oświetlenia globalnego i miękkie cienie, natomiast śledzenie ścieżek radzi sobie z tym wszystkim znacznie lepiej, ale za to tworzy obrazy mocno zaszumione. Żeby uzyskać dobry rezultat, trzeba zastosować dużo losowych próbek. Istnieją też techniki, takie jak Metropolis light transport, w których wykorzystuje się mechanizmy heurystyczne do odrzucania złych próbek i pozostawiania tylko dobrych. W rezultacie udaje się uzyskać obrazy o mniejszym poziomie szumu.
225
OpenGL. Receptury dla programisty
Dowiedz się więcej Zapoznaj się z następującymi publikacjami: Timothy Purcell, Ian Buck, William R. Mark i Pat Hanrahan, „ACM Transactions
on Graphics” 21 (3), Ray Tracing on Programmable Graphics Hardware, 2002, s. 703 – 712, http://graphics.stanford.edu/papers/rtongfx/. Peter and Karl’s GPU Path Tracer Blog, http://gpupathtracer.blogspot.sg/. Pokazowa aplikacja Brigade działająca w czasie rzeczywistym z konferencji Siggraph
2012, http://raytracey.blogspot.co.nz/2012/08/real-time-path-traced-brigade-demo-at.html.
226
7 Techniki renderingu wolumetrycznego bazujące na GPU W tym rozdziale: Implementacja renderingu wolumetrycznego z cięciem tekstury 3D na płaty Implementacja renderingu wolumetrycznego z jednoprzebiegowym rzucaniem
promieni Pseudoizopowierzchniowy rendering w jednoprzebiegowym rzucaniu promieni Rendering wolumetryczny z użyciem splattingu Implementacja funkcji przejścia dla klasyfikacji objętościowej Implementacja wydzielania wielokątnej izopowierzchni metodą maszerujących
sześcianów Wolumetryczne oświetlenie oparte na technice cięcia połówkowokątowego
Wstęp Techniki renderowania objętościowego znajdują wiele zastosowań w biomedycynie i inżynierii. W biomedycynie są używane do wizualizacji wyników tomografii komputerowej i rezonansu magnetycznego. W inżynierii służą do wizualizacji pośrednich etapów symulacji FEM, przepływów i analiz strukturalnych. Wraz z pojawieniem się procesorów graficznych wszystkie modele i metody wizualizacyjne zostały przeprojektowane pod kątem pełniejszego wykorzystania
OpenGL. Receptury dla programisty
mocy obliczeniowych tych procesorów. W tym rozdziale zaprezentuję kilka algorytmów wizualizacji wolumetrycznej, które można w taki właśnie sposób zrealizować za pomocą funkcji z biblioteki OpenGL w wersji 3.3 lub nowszej. W szczególności będą to trzy najbardziej rozpowszechnione metody polegające na cięciu trójwymiarowej tekstury, jednoprzebiegowym rzucaniu promieni z komponowaniem alfa i renderowaniu izopowierzchni. Po zapoznaniu się z tymi podstawowymi metodami przyjrzymy się technice klasyfikacji objętościowej z odpowiednią funkcją przejścia. Do wydobywania klasyfikowanych obszarów, takich jak ścianki komórek, często stosuje się metody generowania izopowierzchni. Jedną z nich jest metoda maszerującego czworościanu (marching tetrahedra)1. Rendering wolumetryczny to także rozmaite techniki oświetlenia objętościowego. Jedną z popularnych technik jest tu cięcie połówkowokątowe i właśnie to spróbujemy zaimplementować.
Implementacja renderingu wolumetrycznego z cięciem tekstury 3D na płaty Rendering wolumetryczny stanowi specyficzną odmianę algorytmów renderujących, które pozwalają na obrazowanie obiektów i zjawisk o strukturze przestrzennej, takich jak na przykład dym. Algorytmów takich jest wiele, ale nasz przegląd zaczniemy od metody najprostszej, znanej jako cięcie trójwymiarowej tekstury na płaty. Polega ona na aproksymowaniu funkcji opisującej przestrzenny rozkład gęstości przez rozcinanie zbioru danych na płaty w kierunku od przodu ku tyłowi lub od tyłu ku przodowi, a następnie sklejaniu tych płatów przez wspomagane sprzętowo mieszanie. Jako że wszystko to może być realizowane przez sprzęt rasteryzujący, szybkość działania tej metody jest bardzo duża. Pseudokod cięcia trójwymiarowej tekstury na płaty prostopadłe do kierunku patrzenia przedstawia się następująco: 1. Wyznacz wektor kierunkowy bieżącego widoku. 2. Oblicz minimalną i maksymalną odległość wierzchołków jednostkowego sześcianu, mnożąc skalarnie każdy z tych wierzchołków przez wektor kierunkowy widoku. 3. Wyznacz wszystkie wartości parametru λ określającego możliwe przecięcia krawędzi jednostkowego sześcianu przez płaszczyznę prostopadłą do kierunku widoku, począwszy od wierzchołka najbliższego aż do najdalszego. Wykorzystaj do tego odległości minimalną i maksymalną z punktu 1.
1
Autor posługuje się tutaj nazwą Marching Tetrahedra (maszerujące czworościany), ale tak naprawdę prezentuje algorytm o nazwie Marching Cubes (maszerujące sześciany) — przyp. tłum.
228
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
4. Posługując się parametrem λ (z punktu 3.), przesuwaj się zgodnie z kierunkiem widoku i znajdź punkty przecięcia. Powinno ich być od 3 do 6. 5. Zapisz położenia tych punktów we właściwej kolejności i wygeneruj na ich podstawie trójkąty jako zastępczą geometrię. 6. Do obiektu bufora wprowadź nowe wierzchołki.
Przygotowania Pełny kod dla tej receptury znajdziesz w folderze Rozdział7/CięcieTekstury3D.
Jak to zrobić? Zacznij od następujących czynności: 1. Wczytaj dane objętościowe z zewnętrznego pliku i umieść je w OpenGL-owej teksturze. Włącz też sprzętowe generowanie mipmap. Zazwyczaj dane objętościowe są zbiorem skanów wykonanych metodą rezonansu magnetycznego lub tomografii komputerowej. Każdy taki skan jest dwuwymiarowym płatem. Ułożone na stosie w kierunku osi Z tworzą trójwymiarową teksturę, którą można również traktować jak tablicę tekstur dwuwymiarowych. Zapisane w niej wartości określają gęstość prześwietlanej materii, np. gęstości z zakresu od 0 do 20 są typowe dla powietrza. Jeśli są to liczby 8-bitowe bez znaku, możemy je zapisać w tablicy typu GLubyte. Dane 16-bitowe bez znaku zapiszemy w tablicy typu GLushort. W przypadku tekstur 3D oprócz parametrów S i T mamy jeszcze parametr R, który określa bieżący płat tekstury. std::ifstream infile(volume_file.c_str(), std::ios_base::binary); if(infile.good()) { GLubyte* pData = new GLubyte[XDIM*YDIM*ZDIM]; infile.read(reinterpret_cast(pData), XDIM*YDIM*ZDIM*sizeof(GLubyte)); infile.close(); glGenTextures(1, &textureID); glBindTexture(GL_TEXTURE_3D, textureID); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_S, GL_CLAMP); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_T, GL_CLAMP); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_R, GL_CLAMP); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_BASE_LEVEL, 0); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MAX_LEVEL, 4); glTexImage3D(GL_TEXTURE_3D,0,GL_RED,XDIM,YDIM,ZDIM,0,GL_RED,GL_ UNSIGNED_BYTE,pData);
229
OpenGL. Receptury dla programisty
glGenerateMipmap(GL_TEXTURE_3D); return true; } else { return false; }
Parametry filtrowania dla tekstur 3D są podobne do tych, z jakimi mieliśmy do czynienia do tej pory. Mipmapy to zestaw odpowiednio przeskalowanych wersji tej samej tekstury, które stosuje się w zależności od wymaganego poziomu szczegółowości (LOD — level of detail). Gdy teksturowany obiekt znajduje się daleko od widza (kamery), wybierana jest wersja o odpowiednio małych wymiarach, dzięki czemu wzrasta szybkość działania aplikacji. Wartość GL_TEXTURE_MAX_LEVEL określa liczbę takich poziomów szczegółowości, a tym samym liczbę mipmap wygenerowanych z danej tekstury. Poziom podstawowy, czyli numer mipmapy stosowanej przy najmniejszej odległości obiektu od kamery, określa parametr GL_TEXTURE_BASE_LEVEL. Funkcja glGenerateMipMap generuje pochodne tablice teksturowe przez redukujące filtrowanie poprzedniego poziomu. Przykładowo załóżmy, że mamy mieć trzy poziomy mipmap, a na poziomie 0 ma być tekstura 3D o wymiarach 256×256×256. Dla poziomu 1. trzeba więc wygenerować teksturę o wymiarach o połowę mniejszych, czyli 128×128×128. Dla poziomu 2. trzeba znów o połowę zmniejszyć wymiary tekstury z poziomu 1., czyli do wartości 64×64×64. Na poziomie 3. będzie tekstura zredukowana do wymiarów 32×32×32. 2. Przygotuj obiekty tablicy i bufora wierzchołków, w których zapiszesz geometrię zastępczych płatów. Upewnij się, że przeznaczenie obiektu bufora jest określone jako GL_DYNAMIC_DRAW. Pamięć GPU niezbędną do przechowania maksymalnej liczby płatów alokuje funkcja glBufferData. Tablica vTextureSlices jest zdefiniowana globalnie i w niej zapisane są wszystkie wierzchołki wyznaczone w procesie cięcia tekstury na płaty. Wartość zerowa wskaźnika do danych oznacza, że dane te będą wprowadzane do bufora dopiero podczas działania aplikacji. const int MAX_SLICES = 512; glm::vec3 vTextureSlices[MAX_SLICES*12]; glGenVertexArrays(1, &volumeVAO); glGenBuffers(1, &volumeVBO); glBindVertexArray(volumeVAO); glBindBuffer (GL_ARRAY_BUFFER, volumeVBO); glBufferData (GL_ARRAY_BUFFER, sizeof(vTextureSlices), 0, GL_DYNAMIC_DRAW); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,0,0); glBindVertexArray(0);
3. Zaimplementuj cięcie badanego obszaru przestrzeni przez wyznaczanie przecięć jednostkowego sześcianu płatami prostopadłymi do kierunku patrzenia. W naszej aplikacji zadanie to wykonuje funkcja SliceVolume. Stosujemy sześcian jednostkowy,
230
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
ponieważ nasze dane zajmują obszar o takich samych wymiarach względem wszystkich trzech osi (256×256×256). Gdyby te wymiary nie były jednakowe, należałoby odpowiednio przeskalować sześcian jednostkowy. //wyznacz odległości max i min glm::vec3 vecStart[12]; glm::vec3 vecDir[12]; float lambda[12]; float lambda_inc[12]; float denom = 0; float plane_dist = min_dist; float plane_dist_inc = (max_dist-min_dist)/float(num_slices); //wyznacz vecStart i vecDir glm::vec3 intersection[6]; float dL[12]; for(int i=num_slices-1;i>=0;i--) { for(int e = 0; e < 12; e++) { dL[e] = lambda[e] + i*lambda_inc[e]; } if ((dL[0] >= 0.0) && (dL[0] < 1.0)) { intersection[0] = vecStart[0] + dL[0]*vecDir[0]; } //podobnie dla wszystkich punktów przecięcia int indices[]={0,1,2, 0,2,3, 0,3,4, 0,4,5}; for(int i=0;i<12;i++) vTextureSlices[count++]=intersection[indices[i]]; } //uaktualnij obiekt bufora glBindBuffer(GL_ARRAY_BUFFER, volumeVBO); glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(vTextureSlices), &(vTextureSlices[0].x));
4. W funkcji renderującej ustaw mieszanie nakładkowe, zwiąż obiekt tablicy wierzchołków, uruchom shader i wywołaj funkcję glDrawArrays. glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glBindVertexArray(volumeVAO); shader.Use(); glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(MVP)); glDrawArrays(GL_TRIANGLES, 0, sizeof(vTextureSlices)/ sizeof(vTextureSlices[0])); shader.UnUse(); glDisable(GL_BLEND);
231
OpenGL. Receptury dla programisty
Jak to działa? Metoda cięcia tekstury 3D na płaty aproksymuje całkę renderingu wolumetrycznego przez mieszanie alfa poteksturowanych płatów. Pierwszy krok to wczytanie danych wolumetrycznych i umieszczenie ich w teksturze 3D. Potem następuje cięcie obszaru zajmowanego przez te dane na tymczasowe płaty ustawione prostopadle do kierunku patrzenia. W procesie tym wyznaczane są punkty przecięcia tymi płatami jednostkowego sześcianu. Zadanie to wykonuje funkcja SliceVolume. Działa ona tylko wtedy, gdy zmienia się kierunek patrzenia. Najpierw wyznaczamy wektor kierunku patrzenia (viewDir), którego współrzędne stanowią trzecią kolumnę macierzy modelu i widoku. Pierwsza kolumna tej macierzy to wektor zwrócony w prawo, a kolumna druga to wektor zwrócony w górę. Zobaczmy teraz, jak dokładnie działa funkcja SliceVolume. Zaczyna od wyznaczenia maksymalnej i minimalnej odległości do wierzchołków sześcianu jednostkowego w kierunku patrzenia. W tym celu mnoży skalarnie położenie każdego z tych wierzchołków przez wektor kierunku patrzenia. float max_dist = glm::dot(viewDir, vertexList[0]); float min_dist = max_dist; int max_index = 0; int count = 0; for(int i=1;i<8;i++) { float dist = glm::dot(viewDir, vertexList[i]); if(dist > max_dist) { max_dist = dist; max_index = i; } if(dist
Są tylko trzy unikatowe ścieżki wiodące od wierzchołka najbliższego do najdalszego. Każdą z nich dla wszystkich wierzchołków umieszczamy w tablicy krawędzi zdefiniowanej w sposób następujący: int edgeList[8][12]={{0,1,5,6, 4,8,11,9, 3,7,2,10 }, //v0 z przodu {0,4,3,11, 1,2,6,7, 5,9,8,10 }, // v1 z przodu {1,5,0,8, 2,3,7,4, 6,10,9,11}, // v2 z przodu { 7,11,10,8, 2,6,1,9, 3,0,4,5 }, // v3 z przodu { 8,5,9,1, 11,10,7,6, 4,3,0,2 }, // v4 z przodu { 9,6,10,2, 8,11,4,7, 5,0,1,3 }, // v5 z przodu { 9,8,5,4, 6,1,2,0, 10,7,11,3}, // v6 z przodu { 10,9,6,5, 7,2,3,1, 11,4,8,0 } // v7 z przodu
232
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
Następnie wyznaczane są odległości do punktów przecięcia płatów z każdą z 12 krawędzi sześcianu: glm::vec3 vecStart[12]; glm::vec3 vecDir[12]; float lambda[12]; float lambda_inc[12]; float denom = 0; float plane_dist = min_dist; float plane_dist_inc = (max_dist-min_dist)/float(num_slices); for(int i=0;i<12;i++) { vecStart[i]=vertexList[edges[edgeList[max_index][i]][0]]; vecDir[i]=vertexList[edges[edgeList[max_index][i]][1]]-vecStart[i]; denom = glm::dot(vecDir[i], viewDir); if (1.0 + denom != 1.0) { lambda_inc[i] = plane_dist_inc/denom; lambda[i]=(plane_dist-glm::dot(vecStart[i],viewDir))/denom; } else { lambda[i] = -1.0; lambda_inc[i] = 0.0; } }
Na koniec przeprowadzana jest interpolacja punktów przecięć z krawędziami sześcianu, idąc od tyłu ku przodowi w kierunku wyznaczonym przez wektor widoku. Po wygenerowaniu płatów nowe dane są umieszczane w obiekcie bufora wierzchołków. for(int i=num_slices-1;i>=0;i--) { for(int e = 0; e < 12; e++) { dL[e] = lambda[e] + i*lambda_inc[e]; } if ((dL[0] >= 0.0) && (dL[0] < 1.0)) { intersection[0] = vecStart[0] + dL[0]*vecDir[0]; } else if ((dL[1] >= 0.0) && (dL[1] < 1.0)) { intersection[0] = vecStart[1] + dL[1]*vecDir[1]; } else if ((dL[3] >= 0.0) && (dL[3] < 1.0)) { intersection[0] = vecStart[3] + dL[3]*vecDir[3]; } else continue; if ((dL[2] >= 0.0) && (dL[2] < 1.0)){ intersection[1] = vecStart[2] + dL[2]*vecDir[2]; } else if ((dL[0] >= 0.0) && (dL[0] < 1.0)){ intersection[1] = vecStart[0] + dL[0]*vecDir[0]; } else if ((dL[1] >= 0.0) && (dL[1] < 1.0)){ intersection[1] = vecStart[1] + dL[1]*vecDir[1]; } else { intersection[1] = vecStart[3] + dL[3]*vecDir[3]; } //podobnie dla pozostałych krawędzi, aż do intersection[5] int indices[]={0,1,2, 0,2,3, 0,3,4, 0,4,5};
233
OpenGL. Receptury dla programisty
for(int i=0;i<12;i++) vTextureSlices[count++]=intersection[indices[i]]; } glBindBuffer(GL_ARRAY_BUFFER, volumeVBO); glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(vTextureSlices), &(vTextureSlices[0].x));
W funkcji renderującej uruchamiamy odpowiedni program shaderowy. Shader wierzchołków wyznacza położenia wierzchołków w przestrzeni przycięcia, mnożąc ich położenia w przestrzeni obiektu (vPosition) przez połączoną macierz modelu, widoku i rzutowania (MVP). Oblicza też współrzędne tekstury 3D (vUV) dla danych wolumetrycznych. Stosujemy sześcian jednostkowy, a zatem najmniejsze współrzędne wierzchołka będą wynosiły (-0.5, -0.5, -0.5), a największe — (0.5, 0.5, 0.5). Żeby można było ich użyć jako współrzędnych tekstury 3D, trzeba je przesunąć do przedziału od (0, 0, 0) do (1, 1, 1), a więc trzeba je zwiększyć o (0.5, 0.5, 0.5). smooth out vec3 vUV; void main() { gl_Position = MVP*vec4(vVertex.xyz,1); vUV = vVertex + vec3(0.5); }
Shader fragmentów używa tych współrzędnych do próbkowania danych wolumetrycznych (dostępnych teraz poprzez nowy typ samplera dla tekstur trójwymiarowych sampler3D) w celu określenia koloru fragmentu na podstawie odczytanej gęstości. Podczas tworzenia tekstury 3D określiliśmy jej wewnętrzny format jako GL_RED (trzeci parametr funkcji glTexImage3D), a zatem teraz możemy pobierać gęstość z czerwonego kanału samplera tekstury. Aby uzyskać odcień szarości, ustawiamy taką samą wartość w pozostałych kanałach koloru. smooth in vec3 vUV; uniform sampler3D volume; void main(void) { vFragColor = texture(volume, vUV).rrrr; }
We wcześniejszych wersjach OpenGL zapisalibyśmy gęstości wolumetryczne w specjalnie do tego przeznaczonym formacie GL_INTENSITY. Niestety został on usunięty z rdzennego profilu biblioteki w wersji 3.3 i musimy posługiwać się formatami GL_RED, GL_GREEN, GL_BLUE lub GL_ALPHA.
I jeszcze jedno? Przykładowa aplikacja zbudowana na podstawie powyższej receptury wizualizuje dane wolumetryczne fragmentu silnika. Za pomocą klawiszy + (plus) i – (minus) można zmieniać liczbę płatów.
234
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
Zobaczmy teraz, jak powstaje taki obraz, wyświetlając coraz większą liczbę płatów tekstury 3D, począwszy od 8 aż po pełne 256. Rezultaty widać na poniższym rysunku. W górnym rzędzie pokazane są widoki konturowe płatów, a u dołu — rezultaty ich mieszania.
235
OpenGL. Receptury dla programisty
Jak łatwo zauważyć, zwiększanie liczby płatów poprawia wygląd renderowanego obrazu. Jednak po przekroczeniu wartości 256 nie widać już znaczącej poprawy, a powyżej 350 zaczyna być zauważalne spowolnienie działania aplikacji. Przyczyną jest konieczność przesyłania do GPU coraz większych ilości geometrii. Zwróć uwagę na czarną chmurę otaczającą obiekt wolumetryczny. Jej obecność jest wynikiem błędów, jakie wystąpiły w trakcie rejestrowania danych (np. szum aparatury rejestrującej lub zanieczyszczenie powietrza wokół skanowanego obiektu). Artefakty tego typu można usunąć bądź to przez zastosowanie odpowiedniej funkcji przejścia, bądź przez wyeliminowanie ich w shaderze fragmentów, co zrobimy później w recepturze „Wolumetryczne oświetlenie oparte na technice cięcia połówkowokątowego”.
Dowiedz się więcej Real-Time Volume Graphics, A K Peters/CRC Press, rozdział 3. „GPU-based Volume
Rendering”, punkt 3.5.2 „Viewport-Aligned Slices”, s. 73 – 79.
Implementacja renderingu wolumetrycznego z jednoprzebiegowym rzucaniem promieni W tej recepturze pokażę, jak można zaimplementować na GPU rendering wolumetryczny z jednoprzebiegowym rzucaniem promieni. Ogólnie rzucanie promieni może być wieloprzebiegowe lub jednoprzebiegowe. Podejścia te różnią się sposobem ustalania kierunków kroczących promieni. Podejście jednoprzebiegowe korzysta z jednego shadera fragmentów, którego zasadę działania najlepiej wyjaśni poniższy rysunek.
Najpierw wyznaczamy kierunek promienia wysyłanego z kamery. W tym celu odejmujemy położenie kamery od położenia wierzchołka wolumetrycznego. Początkowym położeniem kroczącego
236
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
promienia (punktem wejścia) jest położenie wierzchołka. Następnie przesuwamy promień wzdłuż wyznaczonego kierunku o ustalony krok. W każdym punkcie pobieramy próbkę danych wolumetrycznych. Wędrówkę promienia kończymy, gdy ten wyjdzie poza obszar danych lub kumulowany kolor fragmentu stanie się kompletnie nieprzezroczysty. Próbki zebrane przez wędrujący promień łączymy ze sobą według określonego przepisu. Jeśli stosujemy uśrednianie, to po prostu sumujemy wszystkie próbki i wynik dzielimy przez ich liczbę. Jeśli zaś stosujemy mieszanie alfa w kolejności od przodu do tyłu, to mnożymy pobraną próbkę przez składową alfa zakumulowanego koloru i wynik odejmujemy od pobranej próbki. To daje nam składową alfa z poprzednich kroków. Wartość tę mnożymy przez pobraną próbkę i wynik dodajemy do zakumulowanego koloru, a na koniec dodajemy ją do składowej alfa zakumulowanego koloru. Ostatecznie jako kolor fragmentu wyprowadzamy kolor zakumulowany.
Przygotowania Pełny kod dla tej receptury znajdziesz w folderze Rozdział7/RzucaniePromieni.
Jak to zrobić? Aby zaimplementować na GPU jednoprzebiegowe rzucanie promieni, wykonaj następujące czynności: 1. Podobnie jak w poprzedniej recepturze wczytaj dane wolumetryczne do trójwymiarowej tekstury OpenGL-owej. Dodatkowe objaśnienia tego fragmentu implementacji znajdziesz w definicji funkcji LoadVolume podanej w pliku Rozdział7/ RzucaniePromieni/main.cpp. 2. Przygotuj obiekty tablicy i bufora wierzchołków potrzebne do wyrenderowania jednostkowego sześcianu. Zrób to w sposób następujący: glGenVertexArrays(1, &cubeVAOID); glGenBuffers(1, &cubeVBOID); glGenBuffers(1, &cubeIndicesID); glm::vec3 vertices[8]={ glm::vec3(-0.5f,-0.5f,-0.5f), glm::vec3( 0.5f,-0.5f,-0.5f),glm::vec3( 0.5f, 0.5f,-0.5f), glm::vec3(-0.5f, 0.5f,-0.5f),glm::vec3(-0.5f,-0.5f, 0.5f), glm::vec3( 0.5f,-0.5f, 0.5f),glm::vec3( 0.5f, 0.5f, 0.5f), glm::vec3(-0.5f, 0.5f, 0.5f)}; GLushort cubeIndices[36]={0,5,4,5,0,1,3,7,6,3,6,2,7,4,6,6,4,5,2,1,3,3,1, 0,3,0,7,7,0,4,6,5,2,2,5,1}; glBindVertexArray(cubeVAOID); glBindBuffer (GL_ARRAY_BUFFER, cubeVBOID); glBufferData (GL_ARRAY_BUFFER, sizeof(vertices), &(vertices[0].x), GL_STATIC_DRAW);
237
OpenGL. Receptury dla programisty
glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,0,0); glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, cubeIndicesID); glBufferData (GL_ELEMENT_ARRAY_BUFFER, sizeof(cubeIndices), &cubeIndices[0], GL_STATIC_DRAW); glBindVertexArray(0);
3. W funkcji renderującej uaktywnij program shaderowy rzucania promieni (Rozdział7/ RzucaniePromieni/shadery/raycaster.[vert, frag]) i wyrenderuj sześcian jednostkowy. glEnable(GL_BLEND); glBindVertexArray(cubeVAOID); shader.Use(); glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(MVP)); glUniform3fv(shader("camPos"), 1, &(camPos.x)); glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_SHORT, 0); shader.UnUse(); glDisable(GL_BLEND);
4. Z shadera wierzchołków wyprowadź, poza położeniem wierzchołka w przestrzeni przycięcia, współrzędne trójwymiarowej tekstury potrzebne do jej próbkowania w shaderze fragmentów. Aby uzyskać te współrzędne, po prostu przesuń współrzędne wierzchołka z przestrzeni obiektu o wektor (0.5, 0.5, 0.5). smooth out vec3 vUV; void main() { gl_Position = MVP*vec4(vVertex.xyz,1); vUV = vVertex + vec3(0.5); }
5. W shaderze fragmentów utwórz pętlę przesuwającą promień wzdłuż kierunku wyznaczonego na podstawie położenia kamery i początkowego wierzchołka wolumetrycznego. Zatrzymaj wykonywanie pętli, gdy promień wyjdzie poza obszar danych wolumetrycznych lub zakumulowany kolor stanie się całkowicie nieprzezroczysty. vec3 dataPos = vUV; vec3 geomDir = normalize((vUV-vec3(0.5)) - camPos); vec3 dirStep = geomDir * step_size; bool stop = false; for (int i = 0; i < MAX_SAMPLES; i++) { // przesuń promień o jeden krok dataPos = dataPos + dirStep; // warunek zakończenia stop=dot(sign(dataPos-texMin),sign(texMax-dataPos)) < 3.0; if (stop) break;
6. Skomponuj pobraną próbkę z istniejącym już kolorem i zwróć wypadkową wartość jako kolor fragmentu.
238
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
float sample = texture(volume, dataPos).r; float prev_alpha = sample - (sample * vFragColor.a); vFragColor.rgb = prev_alpha * vec3(sample) + vFragColor.rgb; vFragColor.a += prev_alpha; //wcześniejsze zakończenie pętli if( vFragColor.a>0.99) break; }
Jak to działa? Receptura składa się dwóch zasadniczych części. W pierwszej generujemy i renderujemy geometrię sześcianu, w którym ma działać shader fragmentów. Moglibyśmy użyć pełnoekranowego czworokąta, tak jak to robiliśmy przy implementowaniu wieloprzebiegowego śledzenia promieni, ale dla renderingu wolumetrycznego korzystniejsze jest zastosowanie jednostkowego sześcianu. Część druga odbywa się w shaderach. W shaderze wierzchołków (Rozdział7/RzucaniePromieni/shadery/raycaster.vert) wyznaczane są współrzędne trójwymiarowej tekstury na podstawie położeń wierzchołków sześcianu jednostkowego. Ponieważ sześcian jest położony w środku układu współrzędnych, dodajemy do każdego wierzchołka wektor vec(0.5), aby uzyskać współrzędne tekstury w zakresie od 0 do 1. #version 330 core layout(location = 0) in vec3 vVertex; uniform mat4 MVP; smooth out vec3 vUV; void main() { gl_Position = MVP*vec4(vVertex.xyz,1); vUV = vVertex + vec3(0.5); }
Potem shader fragmentów na podstawie współrzędnych trójwymiarowej tekstury i współrzędnych kamery wyznacza kierunki kroczących promieni. Pętla (pokazana w punkcie 5.) przesuwa promień wzdłuż ustalonego kierunku, pobiera próbki danych wolumetrycznych i zgodnie z wybranym schematem komponuje z nich wypadkowy kolor fragmentu. Proces ten trwa, dopóki promień nie opuści obszaru wolumetrycznego lub składowa alfa zakumulowanego koloru nie osiągnie pełnej swojej wartości. Stałe texMin i texMax mają wartości, odpowiednio, vec3(-1,-1,-1) i vec3(1,1,1). Aby określić, czy promień opuścił obszar danych, używamy funkcji sign, która zwraca -1, jeśli jej argument ma wartość mniejszą od zera, 0, jeśli jest on równy zero, i 1, jeśli jest większy od zera. Zatem dla położeń skrajnych wywołania tej funkcji w postaci sign(dataPos-texMin) i sign(texMax-dataPos) dadzą vec3(1,1,1). Jeśli wymnożymy skalarnie dwa takie wektory, otrzymamy wartość 3. A zatem, jeśli promień będzie w obszarze danych, iloczyn skalarny da wartość mniejszą niż 3. Jeśli wyjdzie więcej, będzie to oznaczało, że promień jest już poza obszarem danych.
239
OpenGL. Receptury dla programisty
I jeszcze jedno… Przykładowa aplikacja renderuje dane wolumetryczne fragmentu silnika, wykorzystując metodę jednoprzebiegowego rzucania promieni. Położenie kamery można zmieniać przez przeciąganie myszą z wciśniętym lewym przyciskiem, a przeciąganie z wciśniętym przyciskiem środkowym powoduje przybliżanie lub oddalanie widoku.
Dowiedz się więcej Zapoznaj się z następującymi publikacjami: Real-Time Volume Graphics, A K Peters/CRC Press, rozdział 7. „GPU-based Ray
Casting”, s. 163 – 184. Single-Pass Raycasting w serwisie The Little Grasshopper, http://prideout.net/blog/
?p=64.
240
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
Pseudoizopowierzchniowy rendering w jednoprzebiegowym rzucaniu promieni Teraz zaimplementujemy renderowanie pseudoizopowierzchni w jednoprzebiegowym rzucaniu promieni. Większość kodu będzie taka sama jak w poprzedniej recepturze, a jedyna różnica będzie polegała na zastosowaniu innego schematu komponowania próbek w shaderze fragmentów rzucającym promienie. Będzie on próbował znaleźć określoną izopowierzchnię i jeśli ją znajdzie, wyznaczy dla niej normalną w punkcie próbkowania, a następnie przeprowadzi obliczenia oświetleniowe dla tego punktu. Rozwiązanie to zapisane w pseudokodzie wygląda następująco: Wyznacz kierunek patrzenia kamery i początkowe położenie promienia Określ długość promienia Dla każdej próbki na drodze promienia Pobierz pierwszą próbkę danych (sample1) z bieżącego położenia promienia Pobierz drugą próbkę (sample2) z następnego położenia promienia Jeśli (sample1-isoValue) < 0 i (sample2-isoValue) > 0 Uściślij położenie punktu przecięcia, stosując metodę bisekcji Wyznacz gradient w punkcie przecięcia Zastosuj cieniowanie Phonga w punkcie przecięcia Przypisz fragmentowi bieżący kolor Przerwij Koniec warunku Koniec pętli
Przygotowania Pełny kod dla tej receptury znajdziesz w folderze Rozdział7/Izoppowierzchnia. Samodzielne tworzenie możesz rozpocząć od skopiowania kodu poprzedniej receptury — z jednoprzebiegowym rzucaniem promieni.
Jak to zrobić? Zacznij od następujących prostych czynności: 1. Podobnie jak w poprzedniej recepturze wczytaj dane wolumetryczne do trójwymiarowej tekstury OpenGL-owej. Dodatkowe objaśnienia znajdziesz w definicji funkcji LoadVolume podanej w pliku Rozdział7/Izopowierzchnia/main.cpp. 2. Przygotuj obiekty tablicy i bufora wierzchołków potrzebne do wyrenderowania jednostkowego sześcianu — tak jak poprzednio. 3. W funkcji renderującej uaktywnij program shaderowy rzucania promieni (Rozdział7/ Izopowierzchnia/shadery/raycaster.[vert, frag]) i wyrenderuj sześcian jednostkowy.
241
OpenGL. Receptury dla programisty
glEnable(GL_BLEND); glBindVertexArray(cubeVAOID); shader.Use(); glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE, glm::value_ptr(MVP)); glUniform3fv(shader("camPos"), 1, &(camPos.x)); glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_SHORT, 0); shader.UnUse(); glDisable(GL_BLEND);
4. Z shadera wierzchołków wyprowadź, poza położeniem wierzchołka w przestrzeni przycięcia, współrzędne trójwymiarowej tekstury potrzebne do jej próbkowania w shaderze fragmentów. Aby uzyskać te współrzędne, po prostu przesuń obiektowe współrzędne wierzchołka w sposób następujący: smooth out vec3 vUV; void main() { gl_Position = MVP*vec4(vVertex.xyz,1); vUV = vVertex + vec3(0.5); }
5. W shaderze fragmentów utwórz pętlę przesuwającą promień wzdłuż kierunku wyznaczonego na podstawie położenia kamery i początkowego wierzchołka wolumetrycznego. Zatrzymaj wykonywanie pętli, gdy promień wyjdzie poza obszar danych lub zakumulowany kolor stanie się całkowicie nieprzezroczysty. vec3 dataPos = vUV; vec3 geomDir = normalize((vUV-vec3(0.5)) - camPos); vec3 dirStep = geomDir * step_size; bool stop = false; for (int i = 0; i < MAX_SAMPLES; i++) { // przesuń promień o jeden krok dataPos = dataPos + dirStep; // warunek zakończenia stop=dot(sign(dataPos-texMin),sign(texMax-dataPos)) < 3.0; if (stop) break;
6. W celu wyznaczenia izopowierzchni bierz po dwie próbki i sprawdzaj, czy przy przejściu od jednej do drugiej promień przeciął izopowierzchnię. Gdy coś takiego stwierdzisz, ustal dokładne miejsce przecięcia, stosując metodę bisekcji. Na koniec zastosuj na izopowierzchni cieniowanie Phonga, przyjmując, że źródło światła znajduje się tam, gdzie kamera. float sample=texture(volume, dataPos).r; float sample2=texture(volume, dataPos+dirStep).r; if( (sample -isoValue) < 0 && (sample2-isoValue) >= 0.0) { vec3 xN = dataPos; vec3 xF = dataPos+dirStep; vec3 tc = Bisection(xN, xF, isoValue);
242
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
vec3 N = GetGradient(tc); vec3 V = -geomDir; vec3 L = V; vFragColor = PhongLighting(L,N,V,250, vec3(0.5)); break; } }
Funkcję Bisection zdefiniuj następująco: vec3 Bisection(vec3 left, vec3 right , float iso) { for(int i=0;i<4;i++) { vec3 midpoint = (right + left) * 0.5; float cM = texture(volume, midpoint).x ; if(cM < iso) left = midpoint; else right = midpoint; } return vec3(right + left) * 0.5; }
Funkcja ta bierze dwie próbki, między którymi leży zadana wartość, i stara się wyznaczyć jej dokładne położenie. W tym celu uruchamia pętlę, w której cyklicznie wyznacza punkt środkowy między próbkami i porównuje jego wartość wolumetryczną z zadaną wartością izopowierzchni. Jeśli wartość w punkcie środkowym jest mniejsza od wartości izopowierzchni, punkt środkowy zastępuje lewą próbkę. Gdy jest inaczej, zastępuje próbkę prawą. W ten sposób szybko zawęża się obszar poszukiwań. Po wykonaniu określonej liczby takich operacji zwracane jest położenie punktu środkowego. Funkcja Gradient wyznacza gradient wartości wolumetrycznych metodą skończonych różnic centralnych. vec3 GetGradient(vec3 uvw) { vec3 s1, s2; //wyznaczanie centralnego ilorazu różnicowego s1.x = texture(volume, uvw-vec3(DELTA,0.0,0.0)).x ; s2.x = texture(volume, uvw+vec3(DELTA,0.0,0.0)).x ; s1.y = texture(volume, uvw-vec3(0.0,DELTA,0.0)).x ; s2.y = texture(volume, uvw+vec3(0.0,DELTA,0.0)).x ; s1.z = texture(volume, uvw-vec3(0.0,0.0,DELTA)).x ; s2.z = texture(volume, uvw+vec3(0.0,0.0,DELTA)).x ; return normalize((s1-s2)/2.0); }
243
OpenGL. Receptury dla programisty
Jak to działa? Większość kodu jest podobna do tego, który napisaliśmy w recepturze z jednoprzebiegowym rzucaniem promieni. Różnica pojawia się dopiero w pętli realizującej ruch promienia poprzez obszar wolumetryczny. Tym razem nie stosujemy żadnego komponowania koloru, lecz wyznaczamy miejsca zerowe funkcji opisującej izopowierzchnię przez sprawdzanie próbek z dwóch kolejnych kroków. Dobrze ilustruje to poniższy rysunek. Jeśli między badanymi próbkami jest miejsce zerowe, precyzujemy jego położenie, stosując metodę bisekcji.
Następnie renderujemy izopowierzchnię, stosując model oświetleniowy Phonga, i opuszczamy pętlę maszerującego promienia. W ten sposób wyrenderujemy izopowierzchnię położoną najbliżej kamery. Gdybyśmy chcieli wyrenderować wszystkie izopowierzchnie o zadanej wartości, musielibyśmy usunąć instrukcję przerywającą wykonywanie pętli.
I jeszcze jedno… Przykładowa aplikacja stanowiąca implementację powyższej receptury renderuje dane wolumetryczne zeskanowanego fragmentu silnika. Po uruchomieniu wyświetla obraz pokazany na rysunku na następnej stronie.
Dowiedz się więcej Advanced Illumination Techniques for GPU-based Volume Rendering, notatki
z konferencji SIGGRAPH 2008, dostępne pod adresem http://www.voreen.org/ files/sa08-coursenotes_1.pdf.
244
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
Rendering wolumetryczny z użyciem splattingu Tym razem zaimplementujemy technikę zwaną splattingiem. Jej algorytm sprowadza się do zamiany wokseli na placki (splats) przez splatanie ich z jądrem filtra gaussowskiego. Gaussowskie jądro wygładzające usuwa wyższe częstotliwości i wygładza krawędzie, przez co wyrenderowany obraz wygląda na lepiej dopracowany.
Przygotowania Gotowy kod receptury znajduje się w folderze Chapter7/Splatting.
245
OpenGL. Receptury dla programisty
Jak to zrobić? Zacznij od następujących prostych czynności: 1. Wczytaj dane wolumetryczne i umieść je w tablicy. std::ifstream infile(filename.c_str(), std::ios_base::binary); if(infile.good()) { pVolume = new GLubyte[XDIM*YDIM*ZDIM]; infile.read(reinterpret_cast(pVolume), XDIM*YDIM*ZDIM*sizeof(GLubyte)); infile.close(); return true; } else { return false; }
2. Utwórz trzy pętle, które będą przebiegały przez całą objętość danych wolumetrycznych, woksel po wokselu. vertices.clear(); int dx = XDIM/X_SAMPLING_DIST; int dy = YDIM/Y_SAMPLING_DIST; int dz = ZDIM/Z_SAMPLING_DIST; scale = glm::vec3(dx,dy,dz); for(int z=0;z
Funkcja SampleVoxel jest zdefiniowana w klasie VolumeSplatter następująco: void VolumeSplatter::SampleVoxel(const int x, const int y, const int z) { GLubyte data = SampleVolume(x, y, z); if(data>isoValue) { Vertex v; v.pos.x = x; v.pos.y = y; v.pos.z = z; v.normal = GetNormal(x, y, z); v.pos *= invDim; vertices.push_back(v); } }
3. W każdym kroku pobierz próbkę wartości wolumetrycznych z bieżącego woksela. Jeśli pobrana wartość jest większa niż wartość określająca izopowierzchnię, zapisz położenie woksela i jego normalną w tablicy wierzchołków.
246
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
GLubyte data = SampleVolume(x, y, z); if(data>isoValue) { Vertex v; v.pos.x = x; v.pos.y = y; v.pos.z = z; v.normal = GetNormal(x, y, z); v.pos *= invDim; vertices.push_back(v); }
Funkcja SampleVolume bierze współrzędne wskazanego punktu i zwraca najbliższą wartość wolumetryczną. Jest ona zdefiniowana w klasie VolumeSplatter w sposób następujący: GLubyte VolumeSplatter::SampleVolume(const int x, const int y, const int z) { int index = (x+(y*XDIM)) + z*(XDIM*YDIM); if(index<0) index = 0; if(index >= XDIM*YDIM*ZDIM) index = (XDIM*YDIM*ZDIM)-1; return pVolume[index]; }
4. Po pobraniu próbek przekaż wygenerowane wierzchołki do obiektu tablicy wierzchołków (VAO) zawierającego obiekt bufora wierzchołków (VBO). glGenVertexArrays(1, &volumeSplatterVAO); glGenBuffers(1, &volumeSplatterVBO); glBindVertexArray(volumeSplatterVAO); glBindBuffer (GL_ARRAY_BUFFER, volumeSplatterVBO); glBufferData (GL_ARRAY_BUFFER, splatter>GetTotalVertices()*sizeof(Vertex), splatter->GetVertexPointer(), GL_STATIC_DRAW); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,sizeof(Vertex), 0); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,sizeof(Vertex), (const GLvoid*) offsetof(Vertex, normal));
5. Przygotuj dwa FBO dla renderingu pozaekranowego. Pierwszego z nich (filterFBOID) użyj do wygładzania gaussowskiego. glGenFramebuffers(1,&filterFBOID); glBindFramebuffer(GL_FRAMEBUFFER,filterFBOID); glGenTextures(2, blurTexID); for(int i=0;i<2;i++) { glActiveTexture(GL_TEXTURE1+i); glBindTexture(GL_TEXTURE_2D, blurTexID[i]); //ustaw parametry tekstury
247
OpenGL. Receptury dla programisty
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA32F,IMAGE_WIDTH,IMAGE_HEIGHT,0, GL_RGBA,GL_FLOAT,NULL); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0+ i,GL_TEXTURE_2D,blurTexID[i],0); } GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); if(status == GL_FRAMEBUFFER_COMPLETE) { cout<<"Ustawienie filtrujacego FBO powiodlo sie."<
6. Drugiego FBO (fboID) użyj do wyrenderowania sceny, która będzie wygładzana po pierwszym przebiegu. Dodaj do tego FBO obiekt bufora renderingu, aby umożliwić testowanie głębi. glGenFramebuffers(1,&fboID); glGenRenderbuffers(1, &rboID); glGenTextures(1, &texID); glBindFramebuffer(GL_FRAMEBUFFER,fboID); glBindRenderbuffer(GL_RENDERBUFFER, rboID); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, texID); //ustaw parametry tekstury glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, IMAGE_WIDTH, IMAGE_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texID, 0); glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, rboID); glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT32, IMAGE_WIDTH, IMAGE_HEIGHT); status = glCheckFramebufferStatus(GL_FRAMEBUFFER); if(status == GL_FRAMEBUFFER_COMPLETE) { cout<<" Ustawienie pozaekranowego FBO powiodlo sie."<
7. W funkcji renderującej najpierw wyrenderuj do tekstury punktowe placki. Użyj do tego celu drugiego FBO (fboID). glBindFramebuffer(GL_FRAMEBUFFER,fboID); glViewport(0,0, IMAGE_WIDTH, IMAGE_HEIGHT); glDrawBuffer(GL_COLOR_ATTACHMENT0); glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); glm::mat4 T = glm::translate(glm::mat4(1), glm::vec3(-0.5,-0.5,-0.5)); glBindVertexArray(volumeSplatterVAO); shader.Use(); glUniformMatrix4fv(shader("MV"), 1, GL_FALSE, glm::value_ptr(MV*T));
248
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
glUniformMatrix3fv(shader("N"), 1, GL_FALSE, glm::value_ptr(glm::inverseTranspose(glm::mat3(MV*T)))); glUniformMatrix4fv(shader("P"), 1, GL_FALSE, glm::value_ptr(P)); glDrawArrays(GL_POINTS, 0, splatter->GetTotalVertices()); shader.UnUse();
Budowa shadera wierzchołków realizującego splatting (Rozdział7/Splatting/ shadery/splatShader.vert) jest podana poniżej. Jest tu wyznaczana normalna w przestrzeni oka. Rozmiar placka jest obliczany na podstawie rozmiarów obszaru wolumetrycznego i próbkowanego woksela. Po uwzględnieniu położenia placka względem kamery jego rozmiar jest zapisywany przez shader w zmiennej gl_PointSize. #version 330 core layout(location = 0) in vec3 vVertex; layout(location = 1) in vec3 vNormal; uniform mat4 MV; uniform mat3 N; uniform mat4 P; smooth out vec3 outNormal; uniform float splatSize; void main() { vec4 eyeSpaceVertex = MV*vec4(vVertex,1); gl_PointSize = 2*splatSize/-eyeSpaceVertex.z; gl_Position = P * eyeSpaceVertex; outNormal = N*vNormal; }
Shader fragmentów biorący udział w realizacji splattingu (Rozdział7/Splatting/ shadery/splatShader.frag) ma budowę następującą: #version 330 core layout(location = 0) out vec4 vFragColor; smooth in vec3 outNormal; const vec3 L = vec3(0,0,1); const vec3 V = L; const vec4 diffuse_color = vec4(0.75,0.5,0.5,1); const vec4 specular_color = vec4(1); void main() { vec3 N; N = normalize(outNormal); vec2 P = gl_PointCoord*2.0 - vec2(1.0); float mag = dot(P.xy,P.xy); if (mag > 1) discard; float diffuse = max(0, dot(N,L)); vec3 halfVec = normalize(L+V); float specular=pow(max(0, dot(halfVec,N)),400); vFragColor = (specular*specular_color) + (diffuse*diffuse_color); }
249
OpenGL. Receptury dla programisty
8. Następnie ustaw filtrujący FBO i rysując pełnoekranowy czworokąt zastosuj gaussowskie wygładzanie najpierw w pionie, a potem w poziomie — tak jak w recepturze z wariancyjnym mapowaniem cieni z rozdziału 4. glBindVertexArray(quadVAOID); glBindFramebuffer(GL_FRAMEBUFFER, filterFBOID); glDrawBuffer(GL_COLOR_ATTACHMENT0); gaussianV_shader.Use(); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); glDrawBuffer(GL_COLOR_ATTACHMENT1); gaussianH_shader.Use(); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0);
9. Odwiąż filtrujący FBO, przywróć domyślny bufor rysowania i wyrenderuj przefiltrowany rezultat na ekranie. glBindFramebuffer(GL_FRAMEBUFFER,0); glDrawBuffer(GL_BACK_LEFT); glViewport(0,0,WIDTH, HEIGHT); quadShader.Use(); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0); quadShader.UnUse(); glBindVertexArray(0);
Jak to działa? Algorytm splattingu polega na renderowaniu wokseli jako gaussowskich plam i rzutowaniu ich na ekran. Aby to zrealizować, najpierw wybieramy z obszaru danych wolumetrycznych odpowiednie woksele. W tym celu przeglądamy cały obszar w poszukiwaniu wokseli o zadanej izowartości. Gdy napotykamy właściwy, zapisujemy jego położenie i normalną w tablicy wierzchołków. Dla własnej wygody wszystkie potrzebne do tego funkcje umieszczamy w klasie VolumeSplatter. Po utworzeniu nowej instancji klasy VolumeSplatter (o nazwie splatter) ustalamy wymiary obszaru z danymi wolumetrycznymi i wczytujemy te dane. Następnie określamy wartość wyznaczającą izopowierzchnię i liczbę próbkowanych wokseli. Na koniec wywołujemy funkcję Volume Splatter::SplatVolume, która dokonuje przeglądu całego obszaru wolumetrycznego woksel po wokselu. splatter = new VolumeSplatter(); splatter->SetVolumeDimensions(256,256,256); splatter->LoadVolume(volume_file); splatter->SetIsosurfaceValue(40); splatter->SetNumSamplingVoxels(64,64,64); std::cout<<"Generuje punktowe placki ..."; splatter->SplatVolume(); std::cout<<"Gotowe."<
250
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
Obiekt splatter umieszcza wygenerowane wierzchołki i ich normalne w tablicy wierzchołków i w wiązanym z nią obiekcie bufora wierzchołków. W funkcji renderującej najpierw rysujemy w jednym przebiegu cały zbiór placków do pozaekranowego celu, a rezultat poddajemy filtrowaniu przez dwa gaussowskie filtry splotowe. Na koniec wyświetlamy przefiltrowany obraz na pełnoekranowym czworokącie. Shader wierzchołków (Rozdział7/Splatting/shadery/splatShader.vert) oblicza rozmiary punktów wyświetlanych na ekranie w zależności od głębokości, na jakiej leży dany placek. Żeby to było możliwe do wykonania w shaderze wierzchołków, musi być włączony stan GL_VERTEX_ PROGRAM_POINT_SIZE, więc wywołujemy funkcję glEnable(GL_VERTEX_PROGRAM_POINT_SIZE). Shader ten wyznacza również normalne dla placków w przestrzeni oka. vec4 eyeSpaceVertex = MV*vec4(vVertex,1); gl_PointSize = 2*splatSize/-eyeSpaceVertex.z; gl_Position = P * eyeSpaceVertex; outNormal = N*vNormal;
Aby nadać plackom okrągłe kształty na ekranie, shader fragmentów (Rozdział7/Splatting/shadery/ splatShader.frag) odrzuca wszystkie fragmenty leżące poza okręgiem o promieniu równym promieniowi wyświetlanego placka. vec3 N; N = normalize(outNormal); vec2 P = gl_PointCoord*2.0 - vec2(1.0); float mag = dot(P.xy,P.xy); if (mag > 1) discard;
Potem shader wyznacza składowe rozproszenia i odblasku, aby po uwzględnieniu jeszcze normalnej wyświetlanego placka podać na wyjście ostateczny kolor bieżącego fragmentu. float diffuse = max(0, dot(N,L)); vec3 halfVec = normalize(L+V); float specular = pow(max(0, dot(halfVec,N)),400); vFragColor = (specular*specular_color) + (diffuse*diffuse_color);
I jeszcze jedno… Przykładowa aplikacja, podobnie jak poprzednie, renderuje dane wolumetryczne zeskanowanego fragmentu silnika. Jak widać na poniższym rysunku, obraz uzyskany metodą splattingu jest nieco rozmyty, a jest to skutek działania wygładzających filtrów gaussowskich. Zaprezentowana receptura umożliwia poznanie metody splattingu, ale zastosowane przez nas rozwiązanie polegające na sprawdzaniu wszystkich wokseli nie jest zbyt wyszukane i w przypadku większego zbioru danych wolumetrycznych należałoby użyć jakiejś struktury, np. drzewa ósemkowego, która pozwoliłaby szybciej zlokalizować woksele o odpowiednich wartościach.
251
OpenGL. Receptury dla programisty
Dowiedz się więcej Zapoznaj się z następującymi projektami: Projekt Qsplat, http://graphics.stanford.edu/software/qsplat/. Prace nad rozwojem splattingu w ETH Zurych, http://graphics.ethz.ch/research/
past_projects/surfels/surfacesplatting/.
Implementacja funkcji przejścia dla klasyfikacji objętościowej W tej recepturze pokażę, jak można zaimplementować klasyfikację danych wolumetrycznych w połączeniu z prezentowaną wcześniej metodą cięcia trójwymiarowej tekstury na płaty. Klasyfikacja będzie polegała na przypisywaniu określonym wartościom wolumetrycznym kolorów pobieranych z wygenerowanej w tym celu jednowymiarowej tekstury. Przydzielanie właściwych kolorów będzie wykonywał specjalny shader fragmentów. Rezultatem jego działania będzie więc kolor fragmentu uzależniony od wartości wolumetrycznej reprezentowanej przez ten fragment.
252
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
Cała reszta receptury wygląda tak samo jak w przypadku cięcia tekstury 3D. Oczywiście klasyfikację danych wolumetrycznych można stosować w połączeniu z dowolnym algorytmem renderowania.
Przygotowania Gotowy kod dla tej receptury znajdziesz w folderze Rozdział7/CięcieTekstury3DKlasyfikacja.
Jak to zrobić? Zacznij od następujących prostych czynności: 1. Wczytaj dane wolumetryczne i ustaw cięcie tekstury tak samo jak w recepturze „Implementacja renderingu wolumetrycznego z cięciem tekstury 3D na płaty”. 2. Dla funkcji przejścia przygotuj zestaw kolorów. Zakoduj tylko niektóre, a wszystkie pośrednie niech zostaną wygenerowane na zasadzie interpolacji w trakcie działania aplikacji. Szczegóły takiego rozwiązania znajdziesz w pliku Rozdział7/ CięcieTekstury3DKlasyfikacja/main.cpp. float pData[256][4]; int indices[9]; for(int i=0;i<9;i++) { int index = i*28; pData[index][0] = jet_values[i].x; pData[index][1] = jet_values[i].y; pData[index][2] = jet_values[i].z; pData[index][3] = jet_values[i].w; indices[i] = index; } for(int j=0;j<9-1;j++) { float dDataR = (pData[indices[j+1]][0] - pData[indices[j]][0]); float dDataG = (pData[indices[j+1]][1] - pData[indices[j]][1]); float dDataB = (pData[indices[j+1]][2] - pData[indices[j]][2]); float dDataA = (pData[indices[j+1]][3] - pData[indices[j]][3]); int dIndex = indices[j+1]-indices[j]; float dDataIncR = dDataR/float(dIndex); float dDataIncG = dDataG/float(dIndex); float dDataIncB = dDataB/float(dIndex); float dDataIncA = dDataA/float(dIndex); for(int i=indices[j]+1;i
253
OpenGL. Receptury dla programisty
pData[i][3] = (pData[i-1][3] + dDataIncA); } }
3. Dla kolorów wygenerowanych w punkcie 1. utwórz jednowymiarową teksturę i zwiąż ją z jednostką teksturującą nr 1 (GL_TEXTURE1). glGenTextures(1, &tfTexID); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_1D, tfTexID); glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexImage1D(GL_TEXTURE_1D,0,GL_RGBA,256,0,GL_RGBA,GL_FLOAT,pData);
4. W shaderze fragmentów dodaj nowy sampler dla tej dodatkowej tekstury. Jako że są teraz dwie tekstury, zwiąż jedną z jednostką teksturującą 0 (GL_TEXTURE0), a drugą — z jednostką 1 (GL_TEXTURE1). shader.LoadFromFile(GL_VERTEX_SHADER, "shaders/textureSlicer.vert"); shader.LoadFromFile(GL_FRAGMENT_SHADER, "shaders/textureSlicer.frag"); shader.CreateAndLinkProgram(); shader.Use(); shader.AddAttribute("vVertex"); shader.AddUniform("MVP"); shader.AddUniform("volume"); shader.AddUniform("lut"); glUniform1i(shader("volume"),0); glUniform1i(shader("lut"),1); shader.UnUse();
5. Na koniec pobierz wartość wolumetryczną z tekstury trójwymiarowej i odszukaj odpowiadający jej kolor w teksturze jednowymiarowej. Kolor ten skieruj do wyjścia jako kolor bieżącego fragmentu. Dokładniejszy opis tej operacji znajdziesz w pliku Rozdział7/CięcieTekstury3DKlasyfikacja/shadery/textureSlicer.frag. uniform sampler3D volume; uniform sampler1D lut; void main(void) { vFragColor = texture(lut, texture(volume, vUV).r); }
Jak to działa? Recepturę można podzielić na dwie części: przygotowanie tekstury dla funkcji przejścia i pobieranie z niej kolorów w shaderze fragmentów. Obie są dość łatwe do zrozumienia. Pierwszą rozpoczynamy od utworzenia niewielkiej tablicy (o nazwie jet_values) z kilkoma podstawowymi kolorami. Definiujemy ją globalnie w sposób następujący:
254
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
const glm::vec4 jet_values[9]={glm::vec4(0,0,0.5,0), glm::vec4(0,0,1,0.1), glm::vec4(0,0.5,1,0.3), glm::vec4(0,1,1,0.5), glm::vec4(0.5,1,0.5,0.75), glm::vec4(1,1,0,0.8), glm::vec4(1,0.5,0,0.6), glm::vec4(1,0,0,0.5), glm::vec4(0.5,0,0,0.0)};
W momencie tworzenia tekstury powiększamy liczbę kolorów do 256, stosując zwykłą interpolację. Po prostu najpierw wyznaczamy różnicę między wartościami sąsiednich kolorów podstawowych i dzielimy ją przez odległość między tymi kolorami. Uzyskany w ten sposób przyrost dodajemy do bieżącego koloru i otrzymujemy interpolowaną wartość koloru pośredniego. Proces ten powtarzamy aż do zapełnienia całej tekstury, którą następnie przekazujemy do shadera fragmentów za pomocą dodatkowego samplera. W shaderze wartość pobrana z przetwarzanej próbki danych wolumetrycznych służy jako indeks wskazujący właściwy kolor w teksturze funkcji przejścia. Kolor ten jest ostatecznie przypisywany bieżącemu fragmentowi.
I jeszcze jedno… Aplikacja ilustrująca powyższą recepturę renderuje fragment silnika w sposób analogiczny do tego, jaki zastosowaliśmy w recepturze z cięciem tekstury 3D, ale teraz wprowadzenie funkcji przejścia spowodowało pokolorowanie wygenerowanego obrazu. Rezultat działania tej aplikacji jest pokazany na rysunku na następnej stronie.
Dowiedz się więcej Real-Time Volume Graphics, A K Peters/CRC Press, rozdział 4. „Transfer Functions”
i rozdział 10. „Transfer Functions Reloaded”.
Implementacja wydzielania wielokątnej izopowierzchni metodą maszerujących sześcianów W recepturze zatytułowanej „Pseudoizopowierzchniowy rendering w jednoprzebiegowym rzucaniu promieni” mieliśmy już do czynienia z izopowierzchnią, ale tamta nie była zbudowana z trójkątnych ścianek i gdybyśmy chcieli jednoznacznie wskazać jakiś konkretny jej obszar, byłoby to raczej niemożliwe. Coś takiego można osiągnąć przez wydzielenie izopowierzchni
255
OpenGL. Receptury dla programisty
metodą maszerujących sześcianów (MC — marching cubes)2. Metoda ta polega na przeczesywaniu całego zbioru danych wolumetrycznych i wstawianiu określonych wielokątów w miejscach spełniających kryterium przecięcia. W rezultacie powstaje wielokątna siatka obrazująca kształt zadanej izopowierzchni.
Przygotowania Pełny kod przykładowej aplikacji znajdziesz w folderze Rozdział7/MaszerująceSześciany. Cały algorytm MT zawiera się tam w klasie o nazwie TetrahedraMarcher.
2
Autor posługuje się tutaj nazwą Marching Tetrahedra (maszerujące czworościany), ale tak naprawdę prezentuje algorytm o nazwie Marching Cubes (maszerujące sześciany) — przyp. tłum.
256
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
Jak to zrobić? Zacznij od następujących prostych czynności: 1. Wczytaj dane wolumetryczne i umieść je w tablicy. std::ifstream infile(filename.c_str(), std::ios_base::binary); if(infile.good()) { pVolume = new GLubyte[XDIM*YDIM*ZDIM]; infile.read(reinterpret_cast(pVolume), XDIM*YDIM*ZDIM*sizeof(GLubyte)); infile.close(); return true; } else { return false; }
2. Uruchom trzy pętle, aby pobrać próbki z całego obszaru wolumetrycznego, woksel po wokselu. vertices.clear(); int dx = XDIM/X_SAMPLING_DIST; int dy = YDIM/Y_SAMPLING_DIST; int dz = ZDIM/Z_SAMPLING_DIST; glm::vec3 scale = glm::vec3(dx,dy,dz); for(int z=0;z
3. W każdym kroku próbkowania wyznacz wartości wolumetryczne we wszystkich ośmiu narożnikach próbkującego sześcianu. GLubyte cubeCornerValues[8]; for( i = 0; i < 8; i++) { cubeCornerValues[i] = SampleVolume( x + (int)(a2fVertexOffset[i][0] *scale.x), y + (int)(a2fVertexOffset[i][1]*scale.y), z + (int)(a2fVertexOffset[i][2]*scale.z)); }
4. Wyznacz wartość wskaźnika krawędziowego, aby zidentyfikować przypadek maszerującego sześcianu dla izopowierzchni o zadanej wartości. int flagIndex = 0; for( i= 0; i<8; i++) { if(cubeCornerValues[i]<= isoValue) flagIndex |= 1<
257
OpenGL. Receptury dla programisty
5. Za pomocą tablicy przeglądowej (a2iEdgeConnection) znajdź właściwe krawędzie dla danego przypadku, a następnie użyj tablicy przesunięć (a2fVertexOffset), aby wyznaczyć wierzchołki krawędzi i normalne. Tablice te są zdefiniowane w pliku nagłówkowym Tables.h umieszczonym w folderze Rozdział7/MaszerująceSześciany/. for(i = 0; i < 12; i++) { if(edgeFlags & (1<
6. Na koniec skorzystaj z tablicy przeglądowej połączeń trójkątów, aby połączyć właściwe wierzchołki i normalne dla danego przypadku. for(i = 0; i< 5; i++) { if(a2iTriangleConnectionTable[flagIndex][3*i] < 0) break; for(int j= 0; j< 3; j++) { int vertex = a2iTriangleConnectionTable[flagIndex][3*i+j]; Vertex v; v.normal = (edgeNormals[vertex]); v.pos = (edgeVertices[vertex])*invDim; vertices.push_back(v); } }
7. Gdy już maszerujący sześcian przebiegnie cały obszar wolumetryczny, przekaż wygenerowane wierzchołki do obiektu tablicy wierzchołków zawierającej obiekt bufora wierzchołków. glGenVertexArrays(1, &volumeMarcherVAO); glGenBuffers(1, &volumeMarcherVBO); glBindVertexArray(volumeMarcherVAO); glBindBuffer (GL_ARRAY_BUFFER, volumeMarcherVBO); glBufferData (GL_ARRAY_BUFFER, marcher-> GetTotalVertices()*sizeof(Vertex), marcher-> GetVertexPointer(), GL_STATIC_DRAW); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,sizeof(Vertex),0);
258
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,sizeof(Vertex), (const GLvoid*)offsetof(Vertex, normal));
8. W celu wyrenderowania powstałej geometrii zwiąż VAO z wygenerowanymi wierzchołkami, uaktywnij shader i wyrenderuj trójkąty. W tej recepturze jako kolor fragmentu wyprowadź normalną wierzchołka. glBindVertexArray(volumeMarcherVAO); shader.Use(); glUniformMatrix4fv(shader("MVP"),1,GL_FALSE, glm::value_ptr(MVP*T)); glDrawArrays(GL_TRIANGLES, 0, marcher->GetTotalVertices()); shader.UnUse();
Jak to działa? Dla wygody umieszczamy całą procedurę maszerującego sześcianu w klasie TetrahedraMarcher. Zgodnie ze swą nazwą procedura ta przesuwa próbkujący sześcian po całym obszarze wolumetrycznym i wyznacza wartości wolumetryczne we wszystkich narożnikach próbki. W zależności od relacji między tymi ośmioma wartościami a wartością określoną dla izopowierzchni generowany jest specjalny indeks znakujący. Za pomocą tego indeksu wybierany jest z tablicy przeglądowej indeks krawędziowy, a ten z kolei pozwala wybrać jedną z predefiniowanych konfiguracji przecięcia sześcianu przez izopowierzchnię. Następnie z tablicy połączeń krawędziowych wybierane są względne położenia narożników sześcianu próbkującego i na ich podstawie wyznaczane są wierzchołki i normalne wielokąta należącego do izoprzestrzeni. Po zgromadzeniu tych wierzchołków przeprowadzana jest triangulacja. Przyjrzyjmy się poszczególnym etapom nieco dokładniej. Indeks znakujący jest określany w wyniku porównania wartości wolumetrycznych we wszystkich ośmiu narożnikach sześcianu próbkującego z zadaną wartością izopowierzchni. Indeks ten umożliwia wybranie znaczników krawędzi z tablicy przeglądowej aiCubeEdgeFlags. flagIndex = 0; for( i= 0; i<8; i++) { if(cubeCornerValues[i] <= isoValue) flagIndex |= 1<
Wierzchołki i normalne wielokąta odpowiadające danemu indeksowi są obliczane na podstawie danych pobranych z tablicy połączeń krawędziowych (a2iEdgeConnection) i umieszczane w tablicach lokalnych. for(i = 0; i < 12; i++) { if(edgeFlags & (1<
259
OpenGL. Receptury dla programisty
edgeVertices[i].x = x + (a2fVertexOffset[a2iEdgeConnection[i][0] ][0] + offset * a2fEdgeDirection[i][0])*scale.x; edgeVertices[i].y = y + (a2fVertexOffset[a2iEdgeConnection[i][0] ][1] + offset * a2fEdgeDirection[i][1])*scale.y; edgeVertices[i].z = z + (a2fVertexOffset[a2iEdgeConnection[i][0] ][2] + offset * a2fEdgeDirection[i][2])*scale.z; edgeNormals[i] = GetNormal( (int)edgeVertices[i].x, (int)edgeVertices[i].y, (int)edgeVertices[i].z );
Na koniec użyta zostaje tablica przeglądowa połączeń trójkątów (a2iTriangleConnectionTable) i z niej pobierane są właściwe uporządkowania wierzchołków i normalnych. Atrybuty te trafiają ostatecznie do odpowiednich wektorów. for(i = 0; i< 5; i++) { if(a2iTriangleConnectionTable[flagIndex][3*i] < 0) break; for(int j= 0; j< 3; j++) { int vertex = a2iTriangleConnectionTable[flagIndex][3*i+j]; Vertex v; v.normal = (edgeNormals[vertex]); v.pos = (edgeVertices[vertex])*invDim; vertices.push_back(v); } }
Po zrealizowaniu procedury maszerujących sześcianów umieszczamy wygenerowane wierzchołki i normalne w obiekcie bufora. W funkcji renderującej wiążemy odpowiedni obiekt tablicy wierzchołków, uaktywniamy shader i rysujemy trójkąty. Zastosowany tu shader fragmentów podaje na wyjście, jako kolor bieżącego fragmentu, współrzędne wektora normalnego. #version 330 core layout(location = 0) out vec4 vFragColor; smooth in vec3 outNormal; void main() { vFragColor = vec4(outNormal,1); }
I jeszcze jedno… Podobnie jak w poprzednich recepturach aplikacja przykładowa renderuje dane wolumetryczne fragmentu silnika, co widać na rysunku na następnej stronie. Kolory są tu ustalane na podstawie normalnych izopowierzchni. Wciśnięcie klawisza W spowoduje włączenie renderingu krawędziowego (wireframe). Można wtedy zobaczyć wielokąty izopowierzchni o wartości 40 (patrz na drugi rysunek na następnej stronie).
260
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
www.FrikShare.pl
261
OpenGL. Receptury dla programisty
Powyższa receptura jest oparta na algorytmie maszerujących sześcianów, ale istnieje też metoda maszerujących czworościanów (marching tetrahedra), która pozwala na dokładniejszą triangulację izopowierzchni.
Dowiedz się więcej Zapoznaj się z następującymi publikacjami: Paul Bourke, Polygonising a scalar field, http://paulbourke.net/geometry/polygonise/. Volume Rendering: Marching Cubes Algorithm, http://cns-alumni.bu.edu/~lavanya/
Graphics/cs580/p5/web-page/p5.html. An implementation of Marching Cubes and Marching Tetrahedra Algorithms,
http://www.siafoo.net/snippet/100.
Wolumetryczne oświetlenie oparte na technice cięcia połówkowokątowego W tej recepturze zaimplementujemy oświetlenie wolumetryczne, a jako technikę renderingu zastosujemy cięcie połówkowokątowe. Zamiast ciąć obszar wolumetryczny na plastry prostopadłe do kierunku patrzenia potniemy go ukośnie, co umożliwi nam symulowanie absorpcji światła przez kolejne plastry.
Przygotowania Pełny kod przykładowej aplikacji znajduje się w folderze Rozdział7/CięciePołówkowokątowe. Jak łatwo się domyślić, dużą część kodu zapożyczymy z receptury stanowiącej przykład implementacji renderingu wolumetrycznego z cięciem tekstury 3D na płaty.
Jak to zrobić? Zacznij od następujących prostych czynności: 1. Ustaw pozaekranowy rendering z użyciem FBO wyposażonego w dwa przyłącza: jedno dla pozaekranowego renderingu bufora światła i jedno dla pozaekranowego renderingu bufora oka. glGenFramebuffers(1, &lightFBOID); glGenTextures (1, &lightBufferID); glGenTextures (1, &eyeBufferID); glActiveTexture(GL_TEXTURE2);
262
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
lightBufferID = CreateTexture(IMAGE_WIDTH, IMAGE_HEIGHT, GL_RGBA16F, GL_RGBA); eyeBufferID = CreateTexture(IMAGE_WIDTH, IMAGE_HEIGHT, GL_RGBA16F, GL_RGBA); glBindFramebuffer(GL_FRAMEBUFFER, lightFBOID); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, lightBufferID, 0); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, eyeBufferID, 0); GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); if(status == GL_FRAMEBUFFER_COMPLETE ) printf("Ustawienie FBO dla swiatla powiodlo sie !!! \n"); else printf("Problem z ustawieniem FBO dla swiatla ");
Dla wygody umieściłem tworzenie tekstury i ustalanie jej parametrów w jednej funkcji o nazwie CreateTexture. Jej definicja wygląda następująco: GLuint CreateTexture(const int w,const int h, GLenum internalFormat, GLenum format) { GLuint texid; glGenTextures(1, &texid); glBindTexture(GL_TEXTURE_2D, texid); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); glTexImage2D(GL_TEXTURE_2D, 0, internalFormat, w, h, 0, format, GL_FLOAT, 0); return texid; }
2. Podobnie jak w recepturze ze zwykłym cięciem tekstury 3D wczytaj dane wolumetryczne. std::ifstream infile(volume_file.c_str(), std::ios_base::binary); if(infile.good()) { GLubyte* pData = new GLubyte[XDIM*YDIM*ZDIM]; infile.read(reinterpret_cast(pData), XDIM*YDIM*ZDIM*sizeof(GLubyte)); infile.close(); glGenTextures(1, &textureID); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_3D, textureID); // ustaw parametry tekstury glTexImage3D(GL_TEXTURE_3D,0,GL_RED,XDIM,YDIM,ZDIM,0,GL_RED,GL_ UNSIGNED_BYTE,pData); GL_CHECK_ERRORS glGenerateMipmap(GL_TEXTURE_3D);
263
OpenGL. Receptury dla programisty
return true; } else { return false; }
3. Podobnie jak w technikach mapowania cieni wyznacz macierz cienia jako iloczyn macierzy modelu i widoku, rzutowania oraz przesunięcia. MV_L=glm::lookAt(lightPosOS,glm::vec3(0,0,0), glm::vec3(0,1,0)); P_L=glm::perspective(45.0f,1.0f,1.0f, 200.0f); B=glm::scale(glm::translate(glm::mat4(1), glm::vec3(0.5,0.5,0.5)), glm::vec3(0.5,0.5,0.5)); BP = B*P_L; S = BP*MV_L;
4. W kodzie renderingu wyznacz wektor połówkowy względem wektorów kierunkowych widoku i światła. viewVec = -glm::vec3(MV[0][2], MV[1][2], MV[2][2]); lightVec = glm::normalize(lightPosOS); bIsViewInverted = glm::dot(viewVec, lightVec)<0; halfVec = glm::normalize( (bIsViewInverted?-viewVec:viewVec) + lightVec);
5. Potnij obszar wolumetryczny tak samo jak w recepturze ze zwykłym cięciem tekstury 3D. Jedyną różnicą niech będzie to, że zamiast ciąć prostopadle do kierunku widoku zastosujesz cięcie w kierunku dwusiecznej kąta między wektorami widoku i światła. float max_dist = glm::dot(halfVec, vertexList[0]); float min_dist = max_dist; int max_index = 0; int count = 0; for(int i=1;i<8;i++) { float dist = glm::dot(halfVec, vertexList[i]); if(dist > max_dist) { max_dist = dist; max_index = i; } if(dist
6. Zwiąż FBO, a następnie wyczyść bufor światła białym kolorem (1,1,1,1) i bufor oka kolorem czarnym (0,0,0,0). glBindFramebuffer(GL_FRAMEBUFFER, lightFBOID); glDrawBuffer(attachIDs[0]); glClearColor(1,1,1,1); glClear(GL_COLOR_BUFFER_BIT );
264
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
glDrawBuffer(attachIDs[1]); glClearColor(0,0,0,0); glClear(GL_COLOR_BUFFER_BIT );
7. Zwiąż obiekt volumeVAO i uruchom pętlę przebiegającą przez wszystkie płaty. W każdym przebiegu najpierw wyrenderuj płat do bufora oka, ale z buforem światła związanym jako teksturą. Następnie wyrenderuj płat do bufora światła. glBindVertexArray(volumeVAO); for(int i =0;i
8. W funkcji renderującej płat z punktu widzenia oka ustaw odpowiedni zwrot funkcji mieszania w zależności od tego, czy kierunek patrzenia jest zgodny z kierunkiem światła, czy też jest do niego przeciwny. void DrawSliceFromEyePointOfView(const int i) { glDrawBuffer(attachIDs[1]); glViewport(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT); if(bIsViewInverted) { glBlendFunc(GL_ONE_MINUS_DST_ALPHA, GL_ONE); } else { glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); } glDrawArrays(GL_TRIANGLES, 12*i, 12); }
9. W przypadku bufora światła zastosuj zwykłe mieszanie „nakładkowe”. void DrawSliceFromLightPointOfView(const int i) { glDrawBuffer(attachIDs[0]); glViewport(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glDrawArrays(GL_TRIANGLES, 12*i, 12); }
10. Na koniec odwiąż FBO i przywróć domyślny bufor rysowania. Ustaw okno widokowe na cały ekran i używając shadera, wyrenderuj bufor oka. glBindVertexArray(0); glBindFramebuffer(GL_FRAMEBUFFER, 0); glDrawBuffer(GL_BACK_LEFT);
265
OpenGL. Receptury dla programisty
glViewport(0,0,WIDTH, HEIGHT); glBindTexture(GL_TEXTURE_2D, eyeBufferID); glBindVertexArray(quadVAOID); quadShader.Use(); glDrawArrays(GL_TRIANGLES, 0, 6); quadShader.UnUse(); glBindVertexArray(0);
Jak to działa? Prezentowana technika polega na akumulowaniu pośrednich rezultatów w dwóch odrębnych buforach i cięciu obszaru wolumetrycznego na plastry w kierunku dwusiecznej kąta między wektorami światła i widoku. Gdy scena jest renderowana z punktu widzenia oka, bufor światła służy za teksturę wskazującą, czy bieżący fragment jest w cieniu, czy nie. Sprawdzian ten odbywa się w shaderze fragmentów z użyciem macierzy cienia, tak jak w algorytmie mapowania cieni. Na tym etapie następuje też zmiana równania mieszającego w zależności od wzajemnej relacji między wektorami kierunkowymi widoku i światła. Gdy wektor widoku jest odwrócony w stosunku do wektora światła, mieszanie zachodzi od tyłu ku przodowi i jest właśnie tak ustawiane za pomocą instrukcji glBlendFunc(GL_ONE_MINUS_DST_ALPHA, GL_ONE). Natomiast gdy oba wektory są zwrócone w tę samą stronę, mieszanie jest ustawiane przez instrukcję glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA). Zauważ, że nie stosujemy tu mieszania nakładkowego, bo już wcześniej kolor został pomnożony przez wartość alfa w shaderze fragmentów (Rozdział7/ CięciePołówkowokątowe/shadery/slicerShadow.frag), co widać w poniższym fragmencie jego kodu: vec3 lightIntensity = textureProj(shadowTex, vLightUVW.xyw).xyz; float density = texture(volume, vUV).r; if(density > 0.1) { float alpha = clamp(density, 0.0, 1.0); alpha *= color.a; vFragColor = vec4(color.xyz*lightIntensity*alpha, alpha); }
Następny etap to renderowanie sceny z punktu widzenia źródła światła. Tym razem stosujemy mieszanie nakładkowe, aby elementy składowe światła kumulowały się tak jak w zwykłych warunkach. Użyty do tego shader fragmentów jest dokładnie taki sam jak ten, którego używaliśmy przy zwykłym cięciu tekstury 3D (patrz Rozdział7/CięciePołówkowokątowe/shadery/ textureSlicer.frag). vFragColor = texture(volume, vUV).rrrr * color ;
I jeszcze jedno… Aplikacja będąca implementacją powyższej receptury renderuje scenę znaną z innych aplikacji opisanych w tym rozdziale. Położenie źródła światła można zmieniać przez przeciąganie myszą z wciśniętym prawym przyciskiem. Widać wtedy, że cienie są dynamiczne i na bieżąco dosto-
266
Rozdział 7. • Techniki renderingu wolumetrycznego bazujące na GPU
sowują się do nowych warunków oświetleniowych. Za pomocą uniformów shadera wprowadzone zostało pokolorowane zanikanie światła wraz z odległością. To z tego powodu widać niebieskawe zabarwienie finalnego obrazu. Zauważ, że tym razem nie widać czarnej otoczki wokół renderowanego obiektu. Zniknęła, bo w shaderze fragmentów dopuściliśmy do obliczeń tylko te wartości wolumetryczne, które są większe od 0,1. W ten sposób pozbyliśmy się niepożądanych artefaktów i uzyskaliśmy dużo lepszy rezultat.
Dowiedz się więcej Zapoznaj się z następującymi publikacjami: „Volume Rendering Techniques”, w GPU Gems 1, rozdział 39., dostępny pod adresem
http://http.developer.nvidia.com/GPUGems/gpugems_ch39.html. Real-Time Volume Graphics, A K Peters/CRC Press, rozdział 6., „Global Volume
Illumination”.
267
OpenGL. Receptury dla programisty
268
8 Animacje szkieletowe i symulacje fizyczne na GPU W tym rozdziale: Implementacja animacji szkieletowej z paletą macierzy skinningowych Implementacja animacji szkieletowej ze skinningiem wykonanym przy użyciu
kwaternionu dualnego Modelowanie tkanin z użyciem transformacyjnego sprzężenia zwrotnego Implementacja wykrywania kolizji z tkaniną i reagowania na nie Implementacja systemu cząsteczkowego z transformacyjnym sprzężeniem zwrotnym
Wstęp Większość aplikacji graficznych działających w czasie rzeczywistym ma elementy interaktywne. Mogą to być np. roboty, którymi sterujemy interaktywnie. Elementy takie często zawierają obiekty, których animacja polega na odtwarzaniu gotowych sekwencji klatek i wtedy mówimy o animacji poklatkowej. Mogą to być także obiekty poruszające się pod wpływem symulowanych sił fizycznych i wówczas mamy do czynienia z animacjami fizycznymi. Specjalną kategorię stanowią animacje ludzi i innych kręgowców — są to tzw. animacje szkieletowe. W tym rozdziale zaprezentuję kilka przepisów na animacje fizyczne i szkieletowe, dające się zaprogramować przy użyciu nowoczesnej biblioteki OpenGL.
OpenGL. Receptury dla programisty
Implementacja animacji szkieletowej z paletą macierzy skinningowych W grach i systemach symulacyjnych często ważnymi elementami pokazywanej scenerii są wirtualne postacie ludzkie lub zwierzęce. Zazwyczaj są one kombinacjami również wirtualnych kości i skóry. Wierzchołki modelu 3D mają przypisane wagi wpływu (zwane wagami wiązania), od których zależy, jak mocno poszczególne kości wpływają na ruchy tych wierzchołków. Sam proces przypisywania takich wag nosi nazwę skinningu. Każda kość przechowuje własne transformacje i animacja polega tu na wykonywaniu tych transformacji w określonych klatkach. Jest to tzw. animacja szkieletowa. Można ją realizować kilkoma sposobami. Jedna z najbardziej popularnych metod polega na zastosowaniu macierzy skinningowych i bywa nazywana skinningiem z mieszaniem liniowym (LBS — linear blend skinning). Właśnie tę metodę teraz zaimplementujemy.
Przygotowania Gotowy kod dla tej receptury znajdziesz w folderze Rozdział8/SkinningMacierzowy. Wykorzystałem w nim kod receptury „Wczytywanie modeli w formacie EZMesh” z rozdziału 5. Format EZMesh opracowany przez Johna Ratcliffa jest łatwy do opanowania i może służyć do zapisywania animacji szkieletowych. Bardziej znane formaty, takie jak COLLADA czy FBX, są niepotrzebnie skomplikowane i zanim się dotrze do właściwych danych, trzeba przebrnąć przez dziesiątki segmentów. Natomiast w formacie EZMesh, należącym do rodziny XML, wszystko jest dużo łatwiejsze. Jest on domyślnym formatem zapisu animacji szkieletowych w opracowanym przez firmę NVIDIA pakiecie narzędzi programistycznych PhysX sdk. Więcej informacji na temat formatu EZMesh i sposobów jego odczytywania znajdziesz w publikacjach wymienionych w punkcie „Dowiedz się więcej”.
Jak to zrobić? Zacznij od wykonania następujących prostych czynności: 1. Wczytaj model EZMesh. Możesz do tego celu wykorzystać kod receptury „Wczytywanie modeli w formacie EZMesh” z rozdziału 5. Jednak tym razem poza siatkami, wierzchołkami, normalnymi, współrzędnymi tekstur i materiałami wczytaj także dane dotyczące szkieletu. EzmLoader ezm; if(!ezm.Load(mesh_filename.c_str(), skeleton, animations, submeshes, vertices, indices, material2ImageMap, min, max)) { cout<<"Nie moge wczytac pliku EZMesh"<
270
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
2. Utwórz obiekt MeshSystem klasy meshImportLibrary. Następnie wczytaj transformacje kości z pliku EZMesh, posługując się tablicą MeshSystem::mSkeletons. Wszystko to wykonasz za pomocą funkcji EzmLoader::Load. Wygeneruj też bezwzględne transformacje kości na podstawie ich transformacji względnych. Jest to potrzebne, aby transformacje kości nadrzędnej wpływały na transformacje kości podrzędnych. Taka zależność powinna istnieć w całej hierarchii szkieletowej. Jeśli model został opracowany w układzie z osią Z skierowaną ku górze, musisz zmienić jego orientację, położenie i skalę, zamieniając osie Z i Y oraz zmieniając zwrot jednej z nich. Jest to konieczne, ponieważ w OpenGL stosowany jest układ ze skierowaną w górę dodatnią częścią osi Y i bez właściwej transformacji z jednego układu do drugiego siatka będzie leżała na płaszczyźnie XZ zamiast XY. Niezbędną do tego macierz otrzymasz przez złożenie skalowania, obrotu i translacji kości. Macierz tę zapisz w polu xform. Będzie to nowa transformacja względna kości. if(ms->mSkeletonCount>0) { NVSHARE::MeshSkeleton* pSkel = ms->mSkeletons[0]; Bone b; for(int i=0;iGetBoneCount();i++) { const NVSHARE::MeshBone pBone = pSkel->mBones[i]; const int s = strlen(pBone.mName); b.name = new char[s+1]; memset(b.name, 0, sizeof(char)*(s+1)); strncpy_s(b.name,sizeof(char)*(s+1), pBone.mName, s); b.orientation = glm::quat( pBone.mOrientation[3],pBone.mOrientation[0], pBone.mOrientation[1],pBone.mOrientation[2]); b.position = glm::vec3( pBone.mPosition[0], pBone.mPosition[1],pBone.mPosition[2]); b.scale = glm::vec3(pBone.mScale[0], pBone.mScale[1], pBone.mScale[2]); if(!bYup) { float tmp = b.position.y; b.position.y = b.position.z; b.position.z = -tmp; tmp = b.orientation.y; b.orientation.y = b.orientation.z; b.orientation.z = -tmp; tmp = b.scale.y; b.scale.y = b.scale.z; b.scale.z = -tmp; } glm::mat4 S = glm::scale(glm::mat4(1), b.scale); glm::mat4 R = glm::toMat4(b.orientation); glm::mat4 T = glm::translate(glm::mat4(1), b.position); b.xform = T*R*S; b.parent = pBone.mParentIndex; skeleton.push_back(b); }
271
OpenGL. Receptury dla programisty
UpdateCombinedMatrices(); bindPose.resize(skeleton.size()); invBindPose.resize(skeleton.size()); animatedXform.resize(skeleton.size());
3. Na podstawie zapisanych transformacji kości wygeneruj macierz pozy wiązania i jej odwrotność. for(size_t i=0;i
4. Zapisz wszystkie wagi i indeksy wiązania dla każdego wierzchołka siatki. mesh.vertices[j].blendWeights.x = pMesh->mVertices[j].mWeight[0]; mesh.vertices[j].blendWeights.y = pMesh->mVertices[j].mWeight[1]; mesh.vertices[j].blendWeights.z = pMesh->mVertices[j].mWeight[2]; mesh.vertices[j].blendWeights.w = pMesh->mVertices[j].mWeight[3]; mesh.vertices[j].blendIndices[0] = pMesh->mVertices[j].mBone[0]; mesh.vertices[j].blendIndices[1] = pMesh->mVertices[j].mBone[1]; mesh.vertices[j].blendIndices[2] = pMesh->mVertices[j].mBone[2]; mesh.vertices[j].blendIndices[3] = pMesh->mVertices[j].mBone[3];
5. W funkcji wywoływanej podczas bezczynności procesora oblicz czas trwania bieżącej klatki. Jeśli jest większy niż czas przeznaczony na jedną klatkę, spowoduj przejście do następnej klatki. Potem wyznacz nowe transformacje kości oraz nowe macierze skinningu i przekaż to wszystko do shadera. QueryPerformanceCounter(¤t); dt = (double)(current.QuadPart - last.QuadPart)/(double)freq.QuadPart; last = current; static double t = 0; t+=dt; NVSHARE::MeshAnimation* pAnim = &animations[0]; float framesPerSecond = pAnim->GetFrameCount()/pAnim->GetDuration(); if( t > 1.0f/ framesPerSecond) { currentFrame++; t=0; } if(bLoop) { currentFrame = currentFrame%pAnim->mFrameCount; } else { currentFrame=max(-1,min(currentFrame,pAnim->mFrameCount-1)); } if(currentFrame == -1) { for(size_t i=0;i
272
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
for(int j=0;jmTrackCount;j++) { NVSHARE::MeshAnimTrack* pTrack = pAnim->mTracks[j]; NVSHARE::MeshAnimPose* pPose = pTrack->GetPose(currentFrame); skeleton[j].position.x = pPose->mPos[0]; skeleton[j].position.y = pPose->mPos[1]; skeleton[j].position.z = pPose->mPos[2]; glm::quat q; q.x = pPose->mQuat[0]; q.y = pPose->mQuat[1]; q.z = pPose->mQuat[2]; q.w = pPose->mQuat[3]; skeleton[j].scale = glm::vec3(pPose->mScale[0], pPose->mScale[1], pPose->mScale[2]); if(!bYup) { skeleton[j].position.y = pPose->mPos[2]; skeleton[j].position.z = -pPose->mPos[1]; q.y = pPose->mQuat[2]; q.z = -pPose->mQuat[1]; skeleton[j].scale.y = pPose->mScale[2]; skeleton[j].scale.z = -pPose->mScale[1]; } skeleton[j].orientation = q; glm::mat4 S =glm::scale(glm::mat4(1),skeleton[j].scale); glm::mat4 R = glm::toMat4(q); glm::mat4 T = glm::translate(glm::mat4(1), skeleton[j].position); skeleton[j].xform = T*R*S; Bone& b = skeleton[j]; if(b.parent==-1) b.comb = b.xform; else b.comb = skeleton[b.parent].comb * b.xform; animatedXform[j] = b.comb * invBindPose[j]; } } shader.Use(); glUniformMatrix4fv(shader("Bones"),animatedXform.size(), GL_FALSE, glm::value_ptr(animatedXform[0])); shader.UnUse(); glutPostRedisplay();
Jak to działa? Recepturę można podzielić na dwie części: generowanie macierzy skinningowych i obliczanie skinningu w shaderze wierzchołków. Aby zrozumieć część pierwszą, trzeba się zapoznać z rozmaitymi transformacjami skinningowymi. Zazwyczaj transformacje są reprezentowane w postaci macierzy o wymiarach 4×4. W animacji szkieletowej mamy do czynienia ze zbiorem kości, 273
OpenGL. Receptury dla programisty
z których każda ma przypisaną transformację lokalną (zwaną też względną) określającą jej położenie i orientację względem kości nadrzędnej. Jeśli na transformację lokalną nakłada się transformacja kości nadrzędnej, otrzymujemy transformację globalną (zwaną też bezwzględną). Przy zapisywaniu animacji do pliku zazwyczaj umieszcza się tam transformacje lokalne poszczególnych kości, a globalne trzeba potem na nowo generować. Strukturę do zapisywania parametrów kości zdefiniujemy następująco: struct Bone { glm::quat orientation; glm::vec3 position; glm::mat4 xform, comb; glm::vec3 scale; char* name; int parent; };
Pierwsze pole o nazwie orientation jest kwaternionem przechowującym orientację kości określoną względem kości nadrzędnej. W polu position przechowywane jest położenie względem kości nadrzędnej. Pola xform i comb służą do przechowywania transformacji, odpowiednio, lokalnej (względnej) i globalnej (bezwzględnej). W polu scale zawarta jest transformacja skalowania. W szerszym ujęciu pole scale zawiera macierz skalującą (S), pole orientation zawiera macierz obrotu (R), a pole position — macierz translacji (T). Macierz wypadkowa T*R*S reprezentuje transformację względną, która jest obliczana podczas wczytywania danych szkieletowych z pliku EZMesh. Pole name zawiera unikatową dla całego szkieletu nazwę kości. Ostatnie pole, parent, przechowuje indeks kości nadrzędnej. Dla kości zajmującej najwyższe miejsce w hierarchii szkieletu pole to przyjmuje wartość –1. Wszystkim innym kościom jest przypisywana wartość z przedziału od 0 do N–1, gdzie N oznacza całkowitą liczbę kości w szkielecie. Po wczytaniu wszystkich kości z ich transformacjami lokalnymi wyznaczamy dla każdej z nich transformację globalną. Odpowiednia pętla jest zakodowana w funkcji UpdateCombinedMatrices zdefiniowanej w pliku Rozdział8/SkinningMacierzowy/main.cpp. for(size_t i=0;i
Gdy już wszystkie transformacje globalne zostaną wyznaczone, zapisujemy macierz pozy wiązania i jej odwrotność. Poza wiązania (bind pose) to nic innego jak transformacje globalne kości wyznaczone jeszcze przed animacją. Jest to też stan, w którym zazwyczaj wykonywane jest łączenie siatki z szkieletem (skinning). Innymi słowy jest to domyślna poza animowanego modelu.
274
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
Pozy wiązania mogą wyglądać rozmaicie, np. dla postaci humanoidalnych stosuje się pozy typu A, T lub podobne. Zazwyczaj wraz macierzą tej pozy wyznaczana jest też macierz do niej odwrotna. A zatem, wracając do naszego przykładowego szkieletu, możemy obie macierze skonstruować w sposób następujący: for(size_t i=0;i < skeleton.size(); i++) { bindPose[i] = skeleton[i].comb; invBindPose[i] = glm::inverse(bindPose[i]); }
Zauważ, że wykonujemy to tylko raz podczas inicjalizacji, a więc nie musimy wyznaczać odwrotności pozy wiązania w każdej klatce animacji. Jeśli zamierzamy poddać szkielet nowej transformacji (np. sekwencji animacyjnej), musimy najpierw cofnąć transformację pozy wiązania. W tym celu trzeba pomnożyć animowaną transformację przez odwrotność transformacji pozy wiązania. Jest to konieczne, ponieważ kolejne względne transformacje kości nałożyłyby się na już istniejące, a to oznaczałoby zupełnie inny od zamierzonego efekt animacyjny. Ostatnia macierz, jaką wyznaczamy w tym procesie, jest nazywana macierzą skinningu (albo finalną macierzą kości). Kontynuując nasz przykład, załóżmy, że zmodyfikowaliśmy transformacje względne kości przez wykonanie sekwencji animacyjnej. W tej sytuacji możemy wygenerować macierz skinningową w sposób następujący: for(size_t i=0;i < skeleton.size(); i++) { Bone& b = skeleton[i]; if(b.parent==-1) b.comb = b.xform; else b.comb = skeleton[b.parent].comb * b.xform; animatedXForm[i] = b.comb*invBindPose[i]; }
Na jedną rzecz trzeba zwrócić tutaj uwagę, a jest nią kolejność poszczególnych macierzy. Jak widać, mnożymy macierz transformacji wypadkowej prawostronnie przez odwrotną macierz pozy wiązania. Jest to istotne, ponieważ w bibliotekach OpenGL i glm macierze działają od lewej do prawej. A zatem będziemy mieli tutaj najpierw mnożenie macierzy odwrotnej przez współrzędne bieżącego wierzchołka, potem przez transformację lokalną (xform) bieżącej kości i w końcu przez transformację globalną (comb) kości nadrzędnej. Po wyznaczeniu macierzy skinningowych przekazujemy je do GPU za pomocą jednej instrukcji. shader.Use(); glUniformMatrix4fv(shader("Bones"), animatedXForm.size(), GL_FALSE, glm::value_ptr(animatedXForm[0])); shader.UnUse();
275
OpenGL. Receptury dla programisty
Aby mieć pewność, że rozmiar tablicy dla kości w shaderze wierzchołków jest prawidłowy, przekażemy mu odpowiedni tekst w sposób dynamiczny, a wykorzystamy do tego przeciążoną funkcję GLSLShader::LoadFromFile. stringstream str( ios_base::app | ios_base::out); str<<"\nconst int NUM_BONES="<
Dzięki temu mamy pewność, że shader wierzchołków otrzymał taką samą liczbę kości, jaka została wczytana z pliku. W naszej przykładowej aplikacji modyfikujemy kod shadera na etapie jego wczytywania, czyli przed kompilacją. Nie należy tego robić w trakcie wykonywania programu shaderowego, ponieważ wymagałoby to ponownej kompilacji i w konsekwencji obniżyłoby wydajność całego procesu.
Struktura Vertex służąca do przechowywania wszystkich atrybutów wierzchołkowych jest zdefiniowana następująco: struct Vertex { glm::vec3 pos, normal; glm::vec2 uv; glm::vec4 blendWeights; glm::ivec4 blendIndices; };
Tablica wierzchołków jest wypełniana przez funkcję EzmLoader::Load. W celu przechowania naprzemiennie zapisanych atrybutów wszystkich wierzchołków generujemy obiekty tablicy i bufora wierzchołków. glGenVertexArrays(1, &vaoID); glGenBuffers(1, &vboVerticesID); glGenBuffers(1, &vboIndicesID); glBindVertexArray(vaoID); glBindBuffer (GL_ARRAY_BUFFER, vboVerticesID); glBufferData (GL_ARRAY_BUFFER, sizeof(Vertex)*vertices.size(), &(vertices[0].pos.x), GL_DYNAMIC_DRAW); glEnableVertexAttribArray(shader["vVertex"]); glVertexAttribPointer(shader["vVertex"], 3, GL_FLOAT, GL_FALSE,sizeof(Vertex),0); glEnableVertexAttribArray(shader["vNormal"]); glVertexAttribPointer(shader["vNormal"], 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)(offsetof(Vertex, normal)) );
276
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
glEnableVertexAttribArray(shader["vUV"]); glVertexAttribPointer(shader["vUV"], 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)(offsetof(Vertex, uv)) ); glEnableVertexAttribArray(shader["vBlendWeights"]); glVertexAttribPointer(shader["vBlendWeights"], 4, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)(offsetof(Vertex, blendWeights)) ); glEnableVertexAttribArray(shader["viBlendIndices"]); glVertexAttribIPointer(shader["viBlendIndices"], 4, GL_INT, sizeof(Vertex), (const GLvoid*)(offsetof(Vertex, blendIndices)) ); Zauważ, że dla indeksów wiązania używamy funkcji glVertexAttribIPointer, ponieważ ten atrybut (viBlendIndices) jest zdefiniowany w shaderze wierzchołków jako ivec4.
Na koniec, w funkcji renderującej, ustawiamy obiekt tablicy wierzchołków i uaktywniamy program shaderowy. Następnie uruchamiamy pętlę przebiegającą wszystkie siatki składowe i dla każdej ustawiamy teksturę materiału i uniformy shadera, aby na koniec wywołać funkcję glDraw Elements. glBindVertexArray(vaoID); { shader.Use(); glUniformMatrix4fv(shader("MV"), 1, GL_FALSE, glm::value_ptr(MV)); glUniformMatrix3fv(shader("N"), 1, GL_FALSE, glm::value_ptr(glm::inverseTranspose(glm::mat3(MV)))); glUniformMatrix4fv(shader("P"), 1, GL_FALSE, glm::value_ptr(P)); glUniform3fv(shader("light_position"),1, &(lightPosOS.x)); for(size_t i=0;i0) { GLuint id = materialMap[ material2ImageMap[submeshes[i].materialName]]; GLint whichID[1]; glGetIntegerv(GL_TEXTURE_BINDING_2D, whichID); if(whichID[0] != id) glBindTexture(GL_TEXTURE_2D, id); glUniform1f(shader("useDefault"), 0.0); } else { glUniform1f(shader("useDefault"), 1.0); } glDrawElements(GL_TRIANGLES, submeshes[i].indices.size(), GL_UNSIGNED_INT, &submeshes[i].indices[0]); }//koniec pętli for shader.UnUse(); }
Skinning macierzowy jest przeprowadzany na GPU przez shader wierzchołków (Rozdział8/ SkinningMacierzowy/shadery/shader.vert). Na podstawie indeksów i wag wiązania jest tam
277
OpenGL. Receptury dla programisty
wyliczane właściwe położenie wierzchołka uwzględniające wpływy powiązanych z nim kości. Wyliczana jest również normalna wierzchołka. Tablica Bones zawiera wygenerowane wcześniej macierze skinningu. Pełny kod shadera wygląda następująco: Zauważ, że uniform Bones nie jest w shaderze zadeklarowany. Wynika to z faktu, że jest to tablica wypełniana w sposób dynamiczny, co było już wcześniej omawiane. #version 330 core layout(location = 0) in vec3 vVertex; layout(location = 1) in vec3 vNormal; layout(location = 2) in vec2 vUV; layout(location = 3) in vec4 vBlendWeights; layout(location = 4) in ivec4 viBlendIndices; smooth out vec2 vUVout; uniform mat4 P; uniform mat4 MV; uniform mat3 N; smooth out vec3 vEyeSpaceNormal; smooth out vec3 vEyeSpacePosition; void main() { vec4 blendVertex=vec4(0); vec3 blendNormal=vec3(0); vec4 vVertex4 = vec4(vVertex,1); int index = viBlendIndices.x; blendVertex = (Bones[index] * vVertex4) * vBlendWeights.x; blendNormal = (Bones[index] * vec4(vNormal, 0.0)).xyz * vBlendWeights.x; index = viBlendIndices.y; blendVertex = ((Bones[index] * vVertex4) * vBlendWeights.y) + blendVertex; blendNormal = (Bones[index] * vec4(vNormal, 0.0)).xyz * vBlendWeights.y + blendNormal; index = viBlendIndices.z; blendVertex = ((Bones[index] * vVertex4) * vBlendWeights.z) + blendVertex; blendNormal = (Bones[index] * vec4(vNormal, 0.0)).xyz * vBlendWeights.z + blendNormal; index = viBlendIndices.w; blendVertex = ((Bones[index] * vVertex4) * vBlendWeights.w) + blendVertex; blendNormal = (Bones[index] * vec4(vNormal, 0.0)).xyz * vBlendWeights.w + blendNormal; vEyeSpacePosition = (MV*blendVertex).xyz; vEyeSpaceNormal = normalize(N*blendNormal); vUVout=vUV; gl_Position = P*vec4(vEyeSpacePosition,1); }
278
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
Shader fragmentów oświetla model światłem zanikającym podobnie jak widzieliśmy to w recepturze „Implementacja zanikającego światła punktowego na poziomie fragmentów” z rozdziału 4.
I jeszcze jedno… Aplikacja przykładowa wyświetla animację modelu dude.ezm wykonaną przy użyciu macierzy skinningowych. Jedna z klatek tej animacji jest pokazana na rysunku poniżej. Źródło światła można obracać przez przeciąganie myszy z wciśniętym prawym przyciskiem. Do zatrzymywania animacji służy klawisz L.
Dowiedz się więcej Zapoznaj się z następującymi materiałami: Matrix Palette Skinning, NVIDIA DirectX SDK 9.0, przykładowa aplikacja,
http://http.download.nvidia.com/developer/SDK/Individual_Samples/DEMOS/ Direct3D9/src/HLSL_PaletteSkin/docs/HLSL_PaletteSkin.pdf.
279
OpenGL. Receptury dla programisty
Materiały Johna Ratcliffa zawierające wiele przydatnych narzędzi i informacji,
włącznie ze specyfikacją formatu EZMesh i czytnikami, http://codesuppository. blogspot.sg/2009/11/test-application-for-meshimport-library.html. NVIDIA sdk, Improved Skinning, przykładowa aplikacja, http://http.download.nvidia.
com/developer/SDK/Individual_Samples/samples.html.
Implementacja animacji szkieletowej ze skinningiem wykonanym przy użyciu kwaternionu dualnego Skinning macierzowy tworzy niezbyt ładnie wyglądające artefakty, zwłaszcza w takich obszarach jak barki i łokcie, gdzie wykonywane są obroty wokół różnych osi. Zastosowanie skinningu opartego na kwaternionach dualnych pozwala znacznie zmniejszyć tego typu zakłócenia. I właśnie to postaramy się teraz zaimplementować. Aby zrozumieć, czym są kwaterniony dualne, przyjrzyjmy się najpierw zwykłym kwaternionom. Kwaternion to wielkość matematyczna zawierająca trzy części urojone (wyznaczające osie obrotu) i jedną rzeczywistą (określającą kąt obrotu). Kwaterniony znalazły zastosowanie w grafice 3D jako reprezentacje obrotów, które nie prowadzą do zjawiska gimbal lock, z którym mamy do czynienia, stosując kąty Eulera. Kwaterniony dualne, w których współczynniki są liczbami dualnymi, a nie rzeczywistymi, umożliwiają jednoczesny zapis zarówno obrotu, jak i przesunięcia. W przeciwieństwie do zwykłych kwaternionów mają nie cztery, ale osiem składników. W skinningu z kwaternionami dualnymi nadal stosowane są liniowe metody mieszania wpływów kości na geometrię. Jednak ze względu na naturę transformacji w teorii kwaternionów dualnych najczęściej stosuje się mieszanie sferyczne. Po wykonaniu mieszania liniowego kwaternion dualny jest na nowo normalizowany, co w rezultacie daje mieszanie sferyczne będące znacznie lepszą aproksymacją krzywizny niż mieszanie liniowe. Bardzo dobrze ilustruje to poniższy rysunek.
Przygotowania Gotowy kod aplikacji przykładowej znajduje się w folderze Rozdział8/SkinningKwaternionowy. Podstawą jest kod z poprzedniej receptury. Macierze skinningowe zostały zastąpione kwaternionami dualnymi.
Jak to zrobić? Aby zamienić liniowe mieszanie w skinningu macierzowym na mieszanie sferyczne w skinningu kwaternionowym, trzeba wykonać następujące czynności:
280
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
1. Wczytaj model EZMesh. Możesz to zrobić tak jak w recepturze „Wczytywanie modeli w formacie EZMesh” z rozdziału 5. if(!ezm.Load(mesh_filename.c_str(), skeleton, animations, submeshes, vertices, indices, material2ImageMap, min, max)) { cout<<"Nie mogę wczytac pliku EZMesh "<
2. Po wczytaniu siatek, materiałów i tekstur wczytaj również transformacje zapisane w pliku EZMesh. Posłuż się przy tym tablicą MeshSystem::mSkeletons tak samo jak w poprzedniej recepturze. Tak jak tam wczytaj nie tylko macierze kości, ale również macierz pozy wiązania i jej odwrotność, lecz zamiast zapisywać te macierze, użyj ich do zainicjalizowania wektora kwaternionów dualnych. Kwaterniony te będą tylko inną reprezentacją macierzy skinningowych. UpdateCombinedMatrices(); bindPose.resize(skeleton.size()); invBindPose.resize(skeleton.size()); animatedXform.resize(skeleton.size()); dualQuaternions.resize(skeleton.size()); for(size_t i=0;i
3. Zaimplementuj funkcję zwrotną bezczynności podobnie jak poprzednio, z tym że do obliczeń macierzy skinningowej dodaj jeszcze wyznaczanie odpowiadającego jej kwaternionu dualnego. Po wykonaniu tych obliczeń prześlij kwaternion do shadera.
281
OpenGL. Receptury dla programisty
glm::mat4 S = glm::scale(glm::mat4(1),skeleton[j].scale); glm::mat4 R = glm::toMat4(q); glm::mat4 T = glm::translate(glm::mat4(1), skeleton[j].position); skeleton[j].xform = T*R*S; Bone& b = skeleton[j]; if(b.parent==-1) b.comb = b.xform; else b.comb = skeleton[b.parent].comb * b.xform; animatedXform[j] = b.comb * invBindPose[j]; glm::vec3 t = glm::vec3( animatedXform[j][3][0], animatedXform[j][3][1], animatedXform[j][3][2]); dualQuaternions[j].QuatTrans2UDQ(glm::toQuat(animatedXform[j]), t); … shader.Use(); glUniform4fv(shader("Bones"), skeleton.size()*2, &(dualQuaternions[0].ordinary.x)); shader.UnUse();
4. W shaderze wierzchołków (Rozdział8/SkinningKwaternionowy/shadery/shader.vert) na podstawie pobranego kwaternionu dualnego wyznacz macierz skinningową i wagi wiązania przetwarzanych wierzchołków. Dalej postępuj z macierzą skinningową tak jak w poprzedniej recepturze. #version 330 core layout(location = 0) in vec3 vVertex; layout(location = 1) in vec3 vNormal; layout(location = 2) in vec2 vUV; layout(location = 3) in vec4 vBlendWeights; layout(location = 4) in ivec4 viBlendIndices; smooth out vec2 vUVout; uniform mat4 P; uniform mat4 MV; uniform mat3 N; smooth out vec3 vEyeSpaceNormal; smooth out vec3 vEyeSpacePosition; void main() { vec4 blendVertex=vec4(0); vec3 blendNormal=vec3(0); vec4 blendDQ[2]; float yc = 1.0, zc = 1.0, wc = if (dot(Bones[viBlendIndices.x < 0.0) yc = -1.0; if (dot(Bones[viBlendIndices.x < 0.0) zc = -1.0; if (dot(Bones[viBlendIndices.x < 0.0)
282
1.0; * 2], Bones[viBlendIndices.y * 2]) * 2], Bones[viBlendIndices.z * 2]) * 2], Bones[viBlendIndices.w * 2])
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
wc = -1.0; blendDQ[0] = Bones[viBlendIndices.x * 2] * vBlendWeights.x; blendDQ[1] = Bones[viBlendIndices.x * 2 + 1] * vBlendWeights.x; blendDQ[0] += yc*Bones[viBlendIndices.y * 2] * vBlendWeights.y; blendDQ[1] += yc*Bones[viBlendIndices.y * 2 + 1] * vBlendWeights.y; blendDQ[0] += zc*Bones[viBlendIndices.z * 2] * vBlendWeights.z; blendDQ[1] += zc*Bones[viBlendIndices.z * 2 + 1] * vBlendWeights.z; blendDQ[0] += wc*Bones[viBlendIndices.w * 2] * vBlendWeights.w; blendDQ[1] += wc*Bones[viBlendIndices.w * 2 + 1] * vBlendWeights.w; mat4 skinTransform = dualQuatToMatrix(blendDQ[0], blendDQ[1]); blendVertex = skinTransform*vec4(vVertex,1); blendNormal = (skinTransform*vec4(vNormal,0)).xyz; vEyeSpacePosition = (MV*blendVertex).xyz; vEyeSpaceNormal = N*blendNormal; vUVout=vUV; gl_Position = P*vec4(vEyeSpacePosition,1); }
Aby przerobić kwaternion dualny na macierz, definiujemy funkcję dualQuatToMatrix. Zwróconą przez nią macierz możemy później pomnożyć przez współrzędne wierzchołka, co da nam wierzchołek przetransformowany.
Jak to działa? Jedyna różnica między tą recepturą a poprzednią sprowadza się do utworzenia kwaternionu na podstawie macierzy skinningowej i potem powrotnej jego konwersji na macierz w shaderze wierzchołków. Po wygenerowaniu macierzy skinningowych przerabiamy je na tablicę kwaternionów dualnych za pomocą funkcji dual_quat::QuatTrans2UDQ, która z kwaternionu obrotów i wektora translacji tworzy kwaternion dualny. Funkcja te jest zdefiniowana w klasie dual_quat (Rozdział8/SkinningKwaternionowy/main.cpp) i wygląda następująco: void QuatTrans2UDQ(const glm::quat& q0, const glm::vec3& t) { ordinary = q0; dual.w = -0.5f * ( t.x * q0.x + t.y * q0.y + t.z * q0.z); dual.x = 0.5f * ( t.x * q0.w + t.y * q0.z - t.z * q0.y); dual.y = 0.5f * (-t.x * q0.z + t.y * q0.w + t.z * q0.x); dual.z = 0.5f * ( t.x * q0.y - t.y * q0.x + t.z * q0.w); }
Tablica kwaternionów dualnych jest potem przekazywana do shadera zamiast macierzy skinningowych. W shaderze najpierw obliczamy iloczyn skalarny kwaternionu zwykłego i dualnego. Jeśli ten iloczyn jest mniejszy od zera, to znaczy, że kwaterniony są zwrócone w przeciwne strony i wtedy odejmujemy zwykły kwaternion od zmieszanego kwaternionu dualnego. W przeciwnym razie sumujemy oba kwaterniony. float yc = 1.0, zc = 1.0, wc = 1.0;
283
OpenGL. Receptury dla programisty
if (dot(Bones[viBlendIndices.x * 2], Bones[viBlendIndices.y * 2]) < 0.0) yc = -1.0; if (dot(Bones[viBlendIndices.x * 2], Bones[viBlendIndices.z * 2]) < 0.0) zc = -1.0; if (dot(Bones[viBlendIndices.x * 2], Bones[viBlendIndices.w * 2]) < 0.0) wc = -1.0; blendDQ[0] = Bones[viBlendIndices.x * 2] * vBlendWeights.x; blendDQ[1] = Bones[viBlendIndices.x * 2 + 1] * vBlendWeights.x; blendDQ[0] += yc*Bones[viBlendIndices.y * 2] * vBlendWeights.y; blendDQ[1] += yc*Bones[viBlendIndices.y * 2 +1] * vBlendWeights.y; blendDQ[0] += zc*Bones[viBlendIndices.z * 2] * vBlendWeights.z; blendDQ[1] += zc*Bones[viBlendIndices.z * 2 +1] * vBlendWeights.z; blendDQ[0] += wc*Bones[viBlendIndices.w * 2] * vBlendWeights.w; blendDQ[1] += wc*Bones[viBlendIndices.w * 2 +1] * vBlendWeights.w;
Następnie zmieszany kwaternion dualny (blendDQ) jest konwertowany na macierz przez funkcję dualQuatToMatrix zdefiniowaną następująco: mat4 dualQuatToMatrix(vec4 Qn, vec4 Qd) { mat4 M; float len2 = dot(Qn, Qn); float w = Qn.w, x = Qn.x, y = Qn.y, z = Qn.z; float t0 = Qd.w, t1 = Qd.x, t2 = Qd.y, t3 = Qd.z;
284
M[0][0] M[0][1] M[0][2] M[0][3]
= = = =
w*w + x*x - y*y - z*z; 2 * x * y + 2 * w * z; 2 * x * z - 2 * w * y; 0;
M[1][0] M[1][1] M[1][2] M[1][3]
= = = =
2 * x * y - 2 * w * z; w * w + y * y - x * x - z * z; 2 * y * z + 2 * w * x; 0;
M[2][0] M[2][1] M[2][2] M[2][3]
= = = =
2 * x * z + 2 * w * y; 2 * y * z - 2 * w * x; w * w + z * z - x * x - y * y; 0;
M[3][0] M[3][1] M[3][2] M[3][3]
= = = =
-2 * t0 * x + 2 * w * t1 - 2 * t2 * z + 2 * y * t3; -2 * t0 * y + 2 * t1 * z - 2 * x * t3 + 2 * w * t2; -2 * t0 * z + 2 * x * t2 + 2 * w * t3 - 2 * t1 * y; len2;
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
M /= len2; return M; }
Zwrócona macierz jest mnożona przez wektory położenia i normalnej wierzchołka, a następnie wyznaczane są jego położenie i normalna w przestrzeni oka oraz współrzędne tekstury. Na koniec obliczane jest położenie w przestrzeni przycięcia. mat4 skinTransform = dualQuatToMatrix(blendDQ[0], blendDQ[1]); blendVertex = skinTransform*vec4(vVertex,1); blendNormal = (skinTransform*vec4(vNormal,0)).xyz; vEyeSpacePosition = (MV*blendVertex).xyz; vEyeSpaceNormal = N*blendNormal; vUVout=vUV; gl_Position = P*vec4(vEyeSpacePosition,1);
Shader fragmentów działa podobnie jak w poprzedniej recepturze, produkując oświetlone i poteksturowane fragmenty.
I jeszcze jedno… Aplikacja przykładowa ilustrująca powyższą recepturę renderuje animację szkieletową modelu dwarf_anim.ezm. Nawet przy ekstremalnych obrotach ramienia staw barkowy wygląda prawidłowo, co widać na rysunku na następnej stronie. Ale jeśli zastosujemy skinning macierzowy, otrzymamy rezultat z wyraźnym efektem papierka cukierkowego.
Dowiedz się więcej Zapoznaj się z następującymi materiałami: Skinning with Dual Quaternions, http://www.seas.upenn.edu/~ladislav/
kavan07skinning/kavan07skinning.pdf. Skinning with Dual Quaternions, NVIDIA DirectX sdk 10.5, aplikacja przykładowa,
http://developer.download.nvidia.com/SDK/10.5/direct3d/samples.html. Dual Quaternion Skinning, materiał z Google Summer of Code 2011,
http://www.ogre3d.org/tikiwiki/tiki-index.php?page=SoC2011%20Dual%20 Quaternion%20Skinning.
285
OpenGL. Receptury dla programisty
286
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
Modelowanie tkanin z użyciem transformacyjnego sprzężenia zwrotnego W tej recepturze zastosujemy dostępny w nowoczesnych procesorach graficznych mechanizm transformacyjnego sprzężenia zwrotnego, a użyjemy go do modelowania tkaniny. Transformacyjne sprzężenie zwrotne (transform feedback) jest specjalnym trybem pracy GPU, w którym shader wierzchołków może wyprowadzać dane wyjściowe wprost do obiektu buforowego. Umożliwia to programistom wykonywanie złożonych obliczeń bez angażowania pozostałych segmentów potoku graficznego. Zobaczymy teraz, jak można to wykorzystać do modelowania tkanin. Z implementacyjnego punktu widzenia transformacyjne sprzężenie zwrotne funkcjonuje jak OpenGL-owy obiekt podobny do tekstury. Praca z takim obiektem jest dwuetapowa: najpierw go generujemy i sprzęgamy z odpowiednimi wyjściami shadera, a potem używamy w obliczeniach symulacyjnych i renderingu. Do generowania służy funkcja glGenTransformFeedbacks, której trzeba podać liczbę generowanych obiektów i zmienną do zapisu zwracanych identyfikatorów. Po wygenerowaniu obiektu wiążemy go z bieżącym kontekstem OpenGL. W tym celu wywołujemy funkcję glBindTransformFeedback, której jedynym parametrem jest identyfikator wiązanego obiektu. Następnie musimy zarejestrować atrybuty wierzchołka, które mają być zapisywane w buforze sprzężenia. Zadanie to wykonujemy przy użyciu funkcji glTransformFeedbackVaryings. Wymagane przez nią parametry podajemy w następującej kolejności: obiekt programu shaderowego, liczba wyjść z shadera, nazwy atrybutów i tryb zapisu. Ten ostatni może przyjąć wartość GL_INTERLEAVED_ATTRIBS (atrybuty będą zapisywane w jednym buforze z przeplotem) lub GL_SEPARATE_ATTRIBS (każdy atrybut będzie zapisywany w oddzielnym buforze). Po zarejestrowaniu atrybutów należy ponownie skonsolidować program shaderowy. Oczywiście musimy też przygotować obiekty buforowe, które będą przyjmować atrybuty przekazywane przez obiekt sprzężenia. Na etapie renderowania najpierw ustawiamy shader i niezbędne uniformy. Następnie wiążemy obiekty tablic wierzchołków, a potem obiekty bufora dla sprzężenia zwrotnego — robimy to za pomocą funkcji glBindBufferBase, której pierwszym parametrem jest indeks, a drugim identyfikator obiektu bufora, w którym będą zapisywane atrybuty wyznaczane przez shader. Możemy związać dowolną liczbę obiektów, ale liczba wywołań tej funkcji nie może być mniejsza niż liczba atrybutów wychodzących z shadera wierzchołków. Po związaniu buforów możemy zainicjować sprzężenie zwrotne przez wywołanie funkcji glBeginTransformFeedback z parametrem określającym typ rejestrowanych prymitywów. Potem już tylko wywołujemy odpowiednie glDraw* i zamykamy sprzężenie wywołaniem glEndTransformFeedback. Aby zaimplementować opisany mechanizm w symulacji zachowania tkaniny, wykonamy kilka czynności. Przygotujemy parę obiektów buforowych do zapisywania bieżących i poprzednich położeń wierzchołków tkaniny. Żeby mieć wygodny dostęp do tych obiektów, umieścimy je
287
OpenGL. Receptury dla programisty
W OpenGL 4.0 i późniejszych wersjach dostępna jest bardzo wygodna funkcja glDrawTransformFeedback. Po prostu podajemy jej typ prymitywów i ona je automatycznie renderuje, uwzględniając wszystkie wyjścia z shadera wierzchołków. Począwszy od OpenGL 4.0 istnieje możliwość zatrzymywania i wznawiania transformacyjnego sprzężenia zwrotnego, a także generowania sprzężenia wielostrumieniowego.
w dwóch obiektach tablic wierzchołków. Następnie, w celu zdeformowania tkaniny, uruchomimy shader i przekażemy mu bieżące i poprzednie położenia wierzchołków. W shaderze najpierw wyznaczymy dla każdej pary wierzchołków siły wewnętrzne i zewnętrzne, a następnie obliczymy przyspieszenia. Stosując całkowanie metodą Verleta, wyznaczymy nowe położenia wierzchołków i wraz położeniami bieżącymi (teraz już jako poprzednie) wyprowadzimy do przyłączonych buforów sprzężenia zwrotnego. Ponieważ mamy parę obiektów tablic wierzchołków, możemy je przełączać. Proces obliczeniowy jest powtarzany i symulacja się rozwija. Całość można zilustrować poniższym rysunkiem.
Więcej szczegółów na temat wewnętrznych mechanizmów tej metody znajdziesz w literaturze podanej w punkcie „Dowiedz się więcej”.
Przygotowania Gotowy kod dla tej receptury znajduje się w folderze Rozdział8/SprzężenieTkanina.
288
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
Jak to zrobić? Rozpocznij od następujących prostych czynności: 1. Wygeneruj geometrię i topologię dla kawałka tkaniny przez podanie zestawu punktów i ich połączeń. Umieść te dane w obiekcie bufora. Wektory X oraz X_last będą zawierały bieżące i poprzednie położenia wierzchołka, a wektor F będzie siłą działającą na ten wierzchołek. vector indices; vector X; vector X_last; vector F; indices.resize( numX*numY*2*3); X.resize(total_points); X_last.resize(total_points); F.resize(total_points); for(int j=0;j<=numY;j++) { for(int i=0;i<=numX;i++) { X[count] = glm::vec4( ((float(i)/(u-1)) *2-1)* hsize, sizeX+1, ((float(j)/(v-1) )* sizeY),1); X_last[count] = X[count]; count++; } } GLushort* id=&indices[0]; for (int i = 0; i < numY; i++) { for (int j = 0; j < numX; j++) { int i0 = i * (numX+1) + j; int i1 = i0 + 1; int i2 = i0 + (numX+1); int i3 = i2 + 1; if ((j+i)%2) { *id++ = i0; *id++ = i2; *id++ = i1; *id++ = i1; *id++ = i2; *id++ = i3; } else { *id++ = i0; *id++ = i2; *id++ = i3; *id++ = i0; *id++ = i3; *id++ = i1; } } } glGenVertexArrays(1, &clothVAOID); glGenBuffers (1, &clothVBOVerticesID); glGenBuffers (1, &clothVBOIndicesID); glBindVertexArray(clothVAOID); glBindBuffer (GL_ARRAY_BUFFER, clothVBOVerticesID); glBufferData (GL_ARRAY_BUFFER, sizeof(float)*4*X.size(), &X[0].x, GL_STATIC_DRAW); glEnableVertexAttribArray(0);
289
OpenGL. Receptury dla programisty
glVertexAttribPointer (0, 4, GL_FLOAT, GL_FALSE,0,0); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, clothVBOIndicesID); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(GLushort)*indices.size(), &indices[0], GL_STATIC_DRAW); glBindVertexArray(0);
2. Utwórz dwie pary obiektów tablic wierzchołków (VAO) — jedną dla renderingu i drugą dla aktualizowania położeń punktów tkaniny. Do aktualizacyjnych VAO przyłącz po dwa obiekty buforowe (zawierające położenia bieżące i poprzednie) i jeden (zawierający położenia bieżące) przyłącz do VAO renderingowego. Dołącz także obiekt tablicy elementów dla indeksów geometrii. Przeznaczenie (usage) obiektu bufora ustaw na GL_DYNAMIC_COPY. Będzie to dla GPU informacja, że bufor będzie często zmieniany i że może służyć jako źródło danych dla operacji wykonywanych przez GPU. glGenVertexArrays(2, vaoUpdateID); glGenVertexArrays(2, vaoRenderID); glGenBuffers( 2, vboID_Pos); glGenBuffers( 2, vboID_PrePos); for(int i=0;i<2;i++) { glBindVertexArray(vaoUpdateID[i]); glBindBuffer( GL_ARRAY_BUFFER, vboID_Pos[i]); glBufferData( GL_ARRAY_BUFFER, X.size()* sizeof(glm::vec4),&(X[0].x), GL_DYNAMIC_COPY); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0); glBindBuffer( GL_ARRAY_BUFFER, vboID_PrePos[i]); glBufferData( GL_ARRAY_BUFFER, X_last.size()*sizeof(glm::vec4), &(X_last[0].x), GL_DYNAMIC_COPY); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 0,0); } //ustaw VAO dla renderingu for(int i=0;i<2;i++) { glBindVertexArray(vaoRenderID[i]); glBindBuffer(GL_ARRAY_BUFFER, vboID_Pos[i]); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vboIndices); if(i==0) glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size()*sizeof(GLushort), &indices[0], GL_STATIC_DRAW); }
3. Aby ułatwić wewnątrz shadera dostęp do obiektów buforowych położeń bieżącego i poprzedniego, zwiąż te obiekty z buforami teksturowymi. Bufory teksturowe są jednowymiarowymi teksturami utworzonymi tak jak zwykłe tekstury OpenGL-owe za pomocą funkcji glGenTextures, ale celem ich wiązania jest GL_TEXTURE_BUFFER.
290
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
Zapewniają one możliwość odczytu całej pamięci obiektu buforowego w shaderze wierzchołków. Odczyt ten realizuje funkcja texelFetchBuffer. for(int i=0;i<2;i++) { glBindTexture( GL_TEXTURE_BUFFER, texPosID[i]); glTexBuffer( GL_TEXTURE_BUFFER, GL_RGBA32F, vboID_Pos[i]); glBindTexture( GL_TEXTURE_BUFFER, texPrePosID[i]); glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA32F, vboID_PrePos[i]); }
4. Wygeneruj obiekt transformacyjnego sprzężenia zwrotnego i przekaż mu nazwy atrybutów wyprowadzanych z naszego deformującego shadera wierzchołków. Nie zapomnij o ponownym skonsolidowaniu programu shaderowego. glGenTransformFeedbacks(1, &tfID); glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, tfID); const char* varying_names[]={"out_position_mass", "out_prev_position"}; glTransformFeedbackVaryings(massSpringShader.GetProgram(), 2, varying_names, GL_SEPARATE_ATTRIBS); glLinkProgram(massSpringShader.GetProgram());
5. W funkcji renderującej uaktywnij shader deformujący tkaninę (Rozdział8/ SprzężenieTkanina/shadery/Spring.vert) i uruchom pętlę. W każdym przebiegu zwiąż bufory teksturowe i aktualizacyjny obiekt tablicy wierzchołków. Równocześnie zwiąż poprzednie obiekty buforowe jako bufory transformacyjnego sprzężenia zwrotnego. To spowoduje, że dane opuszczające shader wierzchołków zostaną zapisane. Wyłącz rasteryzer, włącz tryb sprzężenia zwrotnego i wyrysuj pełny zestaw wierzchołków tkaniny. Do przełączania ścieżek zapisu i odczytu zastosuj metodę pingpongową. massSpringShader.Use(); glUniformMatrix4fv(massSpringShader("MVP"), 1, GL_FALSE, glm::value_ptr(mMVP)); for(int i=0;i
291
OpenGL. Receptury dla programisty
writeID = tmp; } glGetQueryObjectui64v(t_query, GL_QUERY_RESULT, &elapsed_time); delta_time = elapsed_time / 1000000.0f; massSpringShader.UnUse();
6. Po zakończeniu pętli zwiąż renderingowy VAO, z którego zostanie wyrenderowana geometria ze wszystkimi wierzchołkami. glBindVertexArray(vaoRenderID[writeID]); glDisable(GL_DEPTH_TEST); renderShader.Use(); glUniformMatrix4fv(renderShader("MVP"), 1, GL_FALSE, glm::value_ptr(mMVP)); glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_SHORT,0); renderShader.UnUse(); glEnable(GL_DEPTH_TEST); if(bDisplayMasses) { particleShader.Use(); glUniform1i(particleShader("selected_index"), selected_index); glUniformMatrix4fv(particleShader("MV"), 1, GL_FALSE, glm::value_ptr(mMV)); glUniformMatrix4fv(particleShader("MVP"), 1, GL_FALSE, glm::value_ptr(mMVP)); glDrawArrays(GL_POINTS, 0, total_points); particleShader.UnUse(); } glBindVertexArray( 0);
7. W shaderze wierzchołków wyznacz bieżące i poprzednie położenia wierzchołka tkaniny. Jeśli wierzchołek ma być nieruchomy, ustaw jego masę na 0, aby nie uczestniczył w symulacji. W przeciwnym razie wyznacz zewnętrzną siłę (grawitacyjną), która będzie na niego działać. Następnie przejrzyj wszystkie sąsiednie wierzchołki i wyznacz wypadkową sił wewnętrznych oddziaływań między wierzchołkami. float m = position_mass.w; vec3 pos = position_mass.xyz; vec3 pos_old = prev_position.xyz; vec3 vel = (pos - pos_old) / dt; float ks=0, kd=0; int index = gl_VertexID; int ix = index % texsize_x; int iy = index / texsize_x; if(index ==0 || index == (texsize_x-1)) m = 0; vec3 F = gravity*m + (DEFAULT_DAMPING*vel); for(int k=0;k<12;k++) { ivec2 coord = getNextNeighbor(k, ks, kd);
292
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
int j = coord.x; int i = coord.y; if (((iy + i) < 0) || ((iy + i) > (texsize_y-1))) continue; if (((ix + j) < 0) || ((ix + j) > (texsize_x-1))) continue; int index_neigh = (iy + i) * texsize_x + ix + j; vec3 p2 = texelFetchBuffer(tex_position_mass, index_neigh).xyz; vec3 p2_last = texelFetchBuffer(tex_prev_position_mass, index_neigh).xyz; vec2 coord_neigh = vec2(ix + j, iy + i)*step; float rest_length = length(coord*inv_cloth_size); vec3 v2 = (p2- p2_last)/dt; vec3 deltaP = pos - p2; vec3 deltaV = vel - v2; float dist = length(deltaP); float leftTerm = -ks * (dist-rest_length); float rightTerm = kd * (dot(deltaV, deltaP)/dist); vec3 springForce = (leftTerm + rightTerm)* normalize(deltaP); F += springForce; }
8. Na podstawie wypadkowej siły oblicz przyspieszenie i stosując całkowanie metodą Verleta, wyznacz nowe położenie wierzchołka. Podaj odpowiednie atrybuty na wyjście shadera. vec3 acc = vec3(0); if(m!=0) acc = F/m; vec3 tmp = pos; pos = pos * 2.0 - pos_old + acc* dt * dt; pos_old = tmp; pos.y=max(0, pos.y); out_position_mass = vec4(pos, m); out_prev_position = vec4(pos_old,m); gl_Position = MVP*vec4(pos, 1);
Jak to działa? Receptura składa się z dwóch części: generowania geometrii i kierowania wybranych atrybutów do buforów transformacyjnego sprzężenia zwrotnego. Najpierw generujemy geometrię tkaniny, a następnie konfigurujemy niezbędne obiekty buforowe. Żeby mieć łatwiejszy dostęp do bieżących i poprzednich położeń, wiążemy obiekty buforów położenia jako bufory teksturowe. W celu zdeformowania tkaniny uaktywniamy shader deformujący i wiążemy aktualizacyjny VAO. Następnie wskazujemy bufory transformacyjnego sprzężenia zwrotnego, do których mają trafiać dane wyjściowe z shadera wierzchołków. Wyłączamy rasteryzer, aby uniemożliwić wykonanie pozostałych etapów potoku graficznego. Uruchamiamy tryb transformacyjnego sprzężenia
293
OpenGL. Receptury dla programisty
zwrotnego, renderujemy wierzchołki i wyłączamy tryb transformacyjnego sprzężenia zwrotnego. Wszystko to składa się na pierwszy krok całkowania. Aby wykonać następne, stosujemy strategię pingpongową i wiążemy zapisany właśnie bufor renderingowy jako bieżące źródło danych. Właściwa deformacja tkaniny jest wykonywana w shaderze wierzchołków (Rozdział8/ SprzężenieTkanina/shadery/Spring.vert). Tutaj zaczynamy od wyznaczenia bieżącego i poprzedniego położenia wierzchołka. Następnie obliczamy jego prędkość. Na podstawie identyfikatora bieżącego wierzchołka (gl_VertexID) określamy jego liniowy indeks. Jest to unikatowy indeks przypisywany każdemu wierzchołkowi do użytku wewnątrz shadera. Za jego pomocą wskazujemy wierzchołki nieruchome, przypisując im masę równą 0. float m = position_mass.w; vec3 pos = position_mass.xyz; vec3 pos_old = prev_position.xyz; vec3 vel = (pos - pos_old) / dt; float ks=0, kd=0; int index = gl_VertexID; int ix = index % texsize_x; int iy = index / texsize_x; if(index ==0 || index == (texsize_x-1)) m = 0;
Po wykonaniu tych wstępnych czynności obliczamy przyspieszenie będące rezultatem działania wypadkowej sił grawitacji i oporów ruchu. Potem tworzymy pętlę przebiegającą po wszystkich sąsiadach bieżącego wierzchołka i wyznaczającą wypadkową siłę wzajemnych oddziaływań (sprężystości). Siłę tę dodajemy do wypadkowej grawitacji i oporów. vec3 F = gravity*m + (DEFAULT_DAMPING*vel); for(int k=0;k<12;k++) { ivec2 coord = getNextNeighbor(k, ks, kd); int j = coord.x; int i = coord.y; if (((iy + i) < 0) || ((iy + i) > (texsize_y-1))) continue; if (((ix + j) < 0) || ((ix + j) > (texsize_x-1))) continue; int index_neigh = (iy + i) * texsize_x + ix + j; vec3 p2 = texelFetchBuffer(tex_position_mass, index_neigh).xyz; vec3 p2_last = texelFetchBuffer(tex_prev_position_mass, index_neigh).xyz; vec2 coord_neigh = vec2(ix + j, iy + i)*step; float rest_length = length(coord*inv_cloth_size); vec3 v2 = (p2- p2_last)/dt; vec3 deltaP = pos - p2; vec3 deltaV = vel - v2; float dist = length(deltaP); float leftTerm = -ks * (dist-rest_length); float rightTerm = kd * (dot(deltaV, deltaP)/dist);
294
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
vec3 springForce = (leftTerm + rightTerm)* normalize(deltaP); F += springForce; }
Mając wypadkową siłę, obliczamy przyspieszenie, a następnie stosujemy całkowanie Verleta i wyznaczamy nowe położenie wierzchołka. Na koniec sprawdzamy, czy wierzchołek nie zderzył się z podłożem — w tym celu sprawdzamy jego współrzędną Y. Kończymy kod shadera instrukcjami podającymi na wyjście atrybuty (out_position_mass i out_prev_position), które zostaną zapisane w obiektach buforowych transformacyjnego sprzężenia zwrotnego. vec3 acc = vec3(0); if(m!=0) acc = F/m; vec3 tmp = pos; pos = pos * 2.0 - pos_old + acc* dt * dt; pos_old = tmp; pos.y=max(0, pos.y); out_position_mass = vec4(pos, m); out_prev_position = vec4(pos_old,m); gl_Position = MVP*vec4(pos, 1);
Shader ten w połączeniu z transformacyjnym sprzężeniem zwrotnym przemieszcza wierzchołki, prowadząc do zdeformowania tkaniny.
I jeszcze jedno… Aplikacja przykładowa renderuje animację kawałka tkaniny spadającego pod wpływem grawitacji. Rysunek poniżej przedstawia kilka klatek tej animacji. Tkaninę można deformować również ręcznie — przez przeciąganie jej wierzchołków za pomocą myszy z wciśniętym lewym przyciskiem.
295
OpenGL. Receptury dla programisty
W tej recepturze wyprowadzamy z shadera tylko jeden strumień danych, ale może ich być więcej i każdy z nich może być skierowany do innego obiektu buforowego. Można też wprowadzić kilka obiektów transformacyjnego sprzężenia zwrotnego, które na dodatek można włączać i wyłączać w zależności od potrzeb.
Dowiedz się więcej Przeczytaj rozdział 17., „Real-Time Physically Based Deformation Using Transform Feedback”, książki Patricka Cozziego i Christophe’a Riccio pod tytułem OpenGL Insights, wydanej przez A K Peters/CRC Press.
Implementacja wykrywania kolizji z tkaniną i reagowania na nie Ta receptura będzie rozszerzeniem poprzedniej. Dodamy w niej wykrywanie kolizji z tkaniną i reagowanie na nie.
Przygotowania Pełny kod aplikacji przykładowej jest w folderze Rozdział8/SprzężenieTkaninaKolizje. Partie związane z ustawieniem sprzężenia i renderowaniem animacji pozostają takie same jak w poprzedniej aplikacji. Jedyna zmiana będzie polegała na dopisaniu kodu wykrywania kolizji z obiektem o kształcie elipsoidalnym.
Jak to zrobić? Rozpocznij od następujących prostych czynności: 1. Wygeneruj geometrię i topologię dla kawałka tkaniny przez podanie zestawu punktów i ich połączeń. Umieść te dane w obiekcie bufora, tak jak w poprzedniej recepturze. 2. Podobnie jak poprzednio utwórz dwie pary obiektów tablic i bufory wierzchołkowe. Dołącz też bufory teksturowe, aby ułatwić wewnątrz shadera dostęp do pamięci obiektów buforowych. 3. Wygeneruj obiekt transformacyjnego sprzężenia zwrotnego i przekaż mu nazwy atrybutów wyprowadzanych z naszego deformującego shadera wierzchołków. Nie zapomnij o ponownym skonsolidowaniu programu shaderowego.
296
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
glGenTransformFeedbacks(1, &tfID); glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, tfID); const char* varying_names[]={"out_position_mass", "out_prev_position"}; glTransformFeedbackVaryings(massSpringShader.GetProgram(), 2, varying_names, GL_SEPARATE_ATTRIBS); glLinkProgram(massSpringShader.GetProgram());
Wygeneruj elipsoidalny obiekt, stosując prostą macierz 4×4. Jego położenie zapisz w macierzy przesunięcia, orientację w macierzy obrotu i nieproporcjonalne skalowanie w macierzy skalowania. Zapisz też odwrotność macierzy przekształceń tego obiektu. W rzeczywistości macierze przekształceń będą działały w kolejności odwrotnej, tzn. najpierw nieproporcjonalne skalowanie spłaszczy sferę w kierunku osi Z, potem zostanie obrócona o 45° wokół osi X i na koniec przemieści się o 2 jednostki wzdłuż osi Y. ellipsoid = glm::translate(glm::mat4(1),glm::vec3(0,2,0)); ellipsoid = glm::rotate(ellipsoid, 45.0f ,glm::vec3(1,0,0)); ellipsoid = glm::scale(ellipsoid, glm::vec3(fRadius,fRadius,fRadius/2)); inverse_ellipsoid = glm::inverse(ellipsoid);
4. W funkcji renderującej uaktywnij shader deformujący tkaninę (Rozdział8/ SprzężenieTkaninaKolizja/shadery/Spring.vert) i uruchom pętlę. W każdym przebiegu zwiąż bufory teksturowe i aktualizacyjny obiekt tablicy wierzchołków. Równocześnie zwiąż poprzednie obiekty buforowe jako bufory transformacyjnego sprzężenia zwrotnego. Zastosuj metodę pingpongową, jak w poprzedniej procedurze. 5. Po zakończeniu pętli symulacyjnej zwiąż renderingowy VAO i wyrenderuj tkaninę. glBindVertexArray(vaoRenderID[writeID]); glDisable(GL_DEPTH_TEST); renderShader.Use(); glUniformMatrix4fv(renderShader("MVP"), 1, GL_FALSE, glm::value_ptr(mMVP)); glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_SHORT,0); renderShader.UnUse(); glEnable(GL_DEPTH_TEST); if(bDisplayMasses) { particleShader.Use(); glUniform1i(particleShader("selected_index"), selected_index); glUniformMatrix4fv(particleShader("MV"), 1, GL_FALSE, glm::value_ptr(mMV)); glUniformMatrix4fv(particleShader("MVP"), 1, GL_FALSE, glm::value_ptr(mMVP)); glDrawArrays(GL_POINTS, 0, total_points); particleShader.UnUse(); } glBindVertexArray( 0);
6. W shaderze wierzchołków wyznacz bieżące i poprzednie położenia wierzchołka tkaniny. Jeśli wierzchołek ma być nieruchomy, ustaw jego masę na 0, aby nie uczestniczył w symulacji. W przeciwnym razie wyznacz zewnętrzną siłę (grawitacyjną), która będzie na niego działać. Następnie przejrzyj wszystkie sąsiednie wierzchołki i wyznacz wypadkową sił wewnętrznych oddziaływań.
297
OpenGL. Receptury dla programisty
float m = position_mass.w; vec3 pos = position_mass.xyz; vec3 pos_old = prev_position.xyz; vec3 vel = (pos - pos_old) / dt; float ks=0, kd=0; int index = gl_VertexID; int ix = index % texsize_x; int iy = index / texsize_x; if(index ==0 || index == (texsize_x-1)) m = 0; vec3 F = gravity*m + (DEFAULT_DAMPING*vel); for(int k=0;k<12;k++) { ivec2 coord = getNextNeighbor(k, ks, kd); int j = coord.x; int i = coord.y; if (((iy + i) < 0) || ((iy + i) > (texsize_y-1))) continue; if (((ix + j) < 0) || ((ix + j) > (texsize_x-1))) continue; int index_neigh = (iy + i) * texsize_x + ix + j; vec3 p2 = texelFetchBuffer(tex_position_mass, index_neigh).xyz; vec3 p2_last = texelFetchBuffer(tex_prev_position_mass, index_neigh).xyz; vec2 coord_neigh = vec2(ix + j, iy + i)*step; float rest_length = length(coord*inv_cloth_size); vec3 v2 = (p2- p2_last)/dt; vec3 deltaP = pos - p2; vec3 deltaV = vel - v2; float dist = length(deltaP); float leftTerm = -ks * (dist-rest_length); float rightTerm = kd * (dot(deltaV, deltaP)/dist); vec3 springForce = (leftTerm + rightTerm)* normalize(deltaP); F += springForce; }
7. Na podstawie siły wypadkowej oblicz przyspieszenie i stosując całkowanie metodą Verleta, wyznacz nowe położenie wierzchołka. vec3 acc = vec3(0); if(m!=0) acc = F/m; vec3 tmp = pos; pos = pos * 2.0 - pos_old + acc* dt * dt; pos_old = tmp; pos.y=max(0, pos.y);
8. Po sprawdzeniu kolizji z podłożem sprawdź, czy nie doszło do kolizji z obiektem elipsoidalnym. Jeśli tak, zmodyfikuj położenie wierzchołka, aby rozwiązać problem. Na koniec podaj odpowiednie atrybuty na wyjście shadera.
298
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
vec4 x0 = inv_ellipsoid*vec4(pos,1); vec3 delta0 = x0.xyz-ellipsoid.xyz; float dist2 = dot(delta0, delta0); if(dist2<1) { delta0 = (ellipsoid.w - dist2) * delta0 / dist2; vec3 delta; vec3 transformInv = vec3(ellipsoid_xform[0].x, ellipsoid_xform[1].x, ellipsoid_xform[2].x); transformInv /= dot(transformInv, transformInv); delta.x = dot(delta0, transformInv); transformInv = vec3(ellipsoid_xform[0].y, ellipsoid_xform[1].y, ellipsoid_xform[2].y); transformInv /= dot(transformInv, transformInv); delta.y = dot(delta0, transformInv); transformInv = vec3(ellipsoid_xform[0].z, ellipsoid_xform[1].z, ellipsoid_xform[2].z); transformInv /= dot(transformInv, transformInv); delta.z = dot(delta0, transformInv); pos += delta ; pos_old = pos; } out_position_mass = vec4(pos, m); out_prev_position = vec4(pos_old,m); gl_Position = MVP*vec4(pos, 1);
Jak to działa? Shader wierzchołków deformujący tkaninę wzbogacił się o kilka wierszy kodu, w których wykrywane są kolizje i podejmowane odpowiednie reakcje. W przypadku kolizji z płaszczyzną możemy po prostu wstawić współrzędne wierzchołka do równania tej płaszczyzny i jeśli wynik wyjdzie ujemny, to będzie oznaczało, że wierzchołek przeszedł przez płaszczyznę, a zatem należy go cofnąć, czyli przesunąć w kierunku wskazywanym przez normalną płaszczyzny. void planeCollision(inout vec3 x, vec4 plane) { float dist = dot(plane.xyz,x)+ plane.w; if(dist<0) { x += plane.xyz*-dist; } }
Wykrywanie i reagowanie na kolizje z prostymi bryłami geometrycznymi, takimi jak kula czy elipsoida, jest stosukowo łatwe. W przypadku sfery sprawdzamy odległość badanego wierzchołka od jej środka i porównujemy z promieniem. Jeśli odległość ta jest mniejsza od promienia, znaczy to, że doszło do kolizji. Wtedy przesuwamy wierzchołek w kierunku normalnej na odległość równą głębokości penetracji. void sphereCollision(inout vec3 x, vec4 sphere) { vec3 delta = x - sphere.xyz;
299
OpenGL. Receptury dla programisty
float dist = length(delta); if (dist < sphere.w) { x = sphere.xyz + delta*(sphere.w / dist); } } W powyższych obliczeniach można zrezygnować z wyciągania pierwiastków i porównywać kwadraty odległości. Gdy w grę wchodzi duża liczba wierzchołków, może to znacząco poprawić wydajność aplikacji.
Dla dowolnie zorientowanej elipsoidy najpierw przenosimy sprawdzany wierzchołek do przestrzeni obiektu tej bryły. W tym celu mnożymy jego współrzędne przez odwrotną macierz transformacji elipsoidy. W swojej przestrzeni jest ona sferą jednostkową i możemy sprawdzać zaistnienie kolizji tak jak dla zwykłej sfery. Jeśli do kolizji doszło, przenosimy wierzchołek do przestrzeni świata i wyznaczamy głębokość penetracji, aby następnie o taką właśnie odległość przesunąć wierzchołek w kierunku prostopadłym do powierzchni elipsoidy. vec4 x0 = inv_ellipsoid*vec4(pos,1); vec3 delta0 = x0.xyz-ellipsoid.xyz; float dist2 = dot(delta0, delta0); if(dist2<1) { delta0 = (ellipsoid.w - dist2) * delta0 / dist2; vec3 delta; vec3 transformInv = vec3(ellipsoid_xform[0].x, ellipsoid_xform[1].x, ellipsoid_xform[2].x); transformInv /= dot(transformInv, transformInv); delta.x = dot(delta0, transformInv); transformInv = vec3(ellipsoid_xform[0].y, ellipsoid_xform[1].y, ellipsoid_xform[2].y); transformInv /= dot(transformInv, transformInv); delta.y = dot(delta0, transformInv); transformInv = vec3(ellipsoid_xform[0].z, ellipsoid_xform[1].z, ellipsoid_xform[2].z); transformInv /= dot(transformInv, transformInv); delta.z = dot(delta0, transformInv); pos += delta ; pos_old = pos; }
I jeszcze jedno… Przykładowa aplikacja renderuje kawałek tkaniny zamocowany w dwóch punktach, przy czym reszta opada swobodnie pod wpływem grawitacji. W scenie jest jeszcze elipsoida, z którą zderza się opadająca część tkaniny (patrz rysunek poniżej).
300
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
Umiejętność wykrywania kolizji z obiektami podstawowymi, takimi jak płaszczyzna, sfera czy elipsoida, może być przydatna także w odniesieniu do bardziej złożonych modeli, jeśli tylko uda się je zastąpić kombinacją tych najbardziej elementarnych. Możliwe jest także zaimplementowanie wykrywania kolizji z podstawowymi bryłami wielościennymi, ale to zadanie pozostawiam Czytelnikowi jako ćwiczenie do samodzielnego wykonania.
Dowiedz się więcej Zapoznaj się z publikacją Muhammada Mobeena Movanii i Lina Fenga pod tytułem A Novel GPU-Based Deformation Pipeline, zamieszczoną w „ISRN Computer Graphics”, tom 2012, ID artykułu 936315, dostępną pod adresem: http://downloads.hindawi.com/isrn/cg/2012/936315.pdf.
Implementacja systemu cząsteczkowego z transformacyjnym sprzężeniem zwrotnym W tej recepturze pokażę, jak można zaimplementować prosty system cząsteczkowy, używając do tego transformacyjnego sprzężenia zwrotnego. W tym trybie GPU omija rasteryzer i dalsze etapy programowalnego potoku graficznego, aby wykonać sprzężenie zwrotne na etapie obróbki
301
OpenGL. Receptury dla programisty
wierzchołków. Zaletą takiego rozwiązania jest przede wszystkim możliwość zaimplementowania fizycznej symulacji w całości na GPU.
Przygotowania Pełny kod przykładowej aplikacji znajduje się w folderze Rozdział8/SprzężenieZwrotneCząsteczki.
Jak to zrobić? Rozpocznij od następujących prostych czynności: 1. Przygotuj dwie pary tablic wierzchołków: jedną do aktualizacji, drugą do renderowania. Do każdej przyłącz obiekty buforowe, tak jak w poprzednich dwóch recepturach. Tym razem w buforach będą przechowywane właściwości cząsteczek. Włącz także odpowiednie atrybuty wierzchołkowe. glGenVertexArrays(2, vaoUpdateID); glGenVertexArrays(2, vaoRenderID); glGenBuffers( 2, vboID_Pos); glGenBuffers( 2, vboID_PrePos); glGenBuffers( 2, vboID_Direction); for(int i=0;i<2;i++) { glBindVertexArray(vaoUpdateID[i]); glBindBuffer( GL_ARRAY_BUFFER, vboID_Pos[i]); glBufferData( GL_ARRAY_BUFFER, TOTAL_PARTICLES * sizeof(glm::vec4), 0, GL_DYNAMIC_COPY); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0); glBindBuffer( GL_ARRAY_BUFFER, vboID_PrePos[i]); glBufferData( GL_ARRAY_BUFFER, TOTAL_PARTICLES * sizeof(glm::vec4), 0, GL_DYNAMIC_COPY); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 0,0); glBindBuffer( GL_ARRAY_BUFFER, vboID_Direction[i]); glBufferData( GL_ARRAY_BUFFER, TOTAL_PARTICLES * sizeof(glm::vec4), 0, GL_DYNAMIC_COPY); glEnableVertexAttribArray(2); glVertexAttribPointer(2, 4, GL_FLOAT, GL_FALSE, 0,0); } for(int i=0;i<2;i++) { glBindVertexArray(vaoRenderID[i]); glBindBuffer( GL_ARRAY_BUFFER, vboID_Pos[i]); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0); }
302
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
2. Wygeneruj obiekt transformacyjnego sprzężenia zwrotnego i zwiąż go. Następnie określ atrybuty, które po opuszczeniu shadera mają trafić do bufora sprzężenia zwrotnego. Po tych zabiegach ponownie skonsoliduj program shaderowy. glGenTransformFeedbacks(1, &tfID); glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, tfID); const char* varying_names[]={"out_position", "out_prev_position", "out_direction"}; glTransformFeedbackVaryings(particleShader.GetProgram(), 3, varying_names, GL_SEPARATE_ATTRIBS); glLinkProgram(particleShader.GetProgram());
3. W funkcji aktualizacyjnej uaktywnij cząsteczkowy shader wierzchołków, który będzie przekazywał dane do bufora sprzężenia zwrotnego, ustaw odpowiednie uniformy i zwiąż aktualizacyjny obiekt tablicy wierzchołków. Przypominam, że przy sprzężeniu zwrotnym używamy dwóch takich obiektów i do jednego zapisujemy dane, z drugiego odczytujemy. particleShader.Use(); glUniformMatrix4fv(particleShader("MVP"), 1, GL_FALSE, glm::value_ptr(mMVP)); glUniform1f(particleShader("time"), t); for(int i=0;i
4. Zwiąż wierzchołkowe obiekty buforowe, do których będą przekazywane dane sprzężenia zwrotnego zawarte w atrybutach zwracanych przez shader wierzchołków. glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, vboID_Pos[writeID]); glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 1, vboID_PrePos[writeID]); glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 2, vboID_Direction[writeID]);
5. Wyłącz rasteryzer, aby zapobiec wykonywaniu dalszych etapów potoku graficznego, a następnie włącz tryb transformacyjnego sprzężenia zwrotnego. Potem wywołaj funkcję glDrawArrays w celu podania wierzchołków do przetwarzania. Po zakończeniu tego etapu wyłącz tryb sprzężenia i włącz rasteryzer. Za pomocą kwerendy sprzętowej wyznacz czas trwania tej operacji. Na koniec przełącz ścieżki zapisu i odczytu przez zamianę ich identyfikatorów. glEnable(GL_RASTERIZER_DISCARD); glBeginQuery(GL_TIME_ELAPSED,t_query); glBeginTransformFeedback(GL_POINTS); glDrawArrays(GL_POINTS, 0, TOTAL_PARTICLES); glEndTransformFeedback(); glEndQuery(GL_TIME_ELAPSED); glFlush(); glDisable(GL_RASTERIZER_DISCARD); int tmp = readID; readID=writeID; writeID = tmp;
303
OpenGL. Receptury dla programisty
6. Wyrenderuj cząsteczki za pomocą shadera renderingu. Najpierw zwiąż renderingowy obiekt tablicy wierzchołków, a następnie wywołaj funkcję glDrawArrays. glBindVertexArray(vaoRenderID[readID]); renderShader.Use(); glUniformMatrix4fv(renderShader("MVP"), 1, GL_FALSE, glm::value_ptr(mMVP)); glDrawArrays(GL_POINTS, 0, TOTAL_PARTICLES); renderShader.UnUse(); glBindVertexArray(0);
7. W cząsteczkowym shaderze wierzchołków sprawdź, czy życie cząsteczki jest większe od zera. Jeśli tak, przesuń ją i jednocześnie zmniejsz jej życie. Jeśli nie, wygeneruj nową cząsteczkę o losowych parametrach początkowych. Szczegóły tej operacji znajdziesz w pliku Rozdział8/SprzężenieZwrotneCząsteczki/shadery/Particle.vert. Na koniec podaj odpowiednie wartości na wyjście shadera. Pełny kod tego shadera przedstawia się następująco: #version 330 core precision highp float; #extension EXT_gpu_shader4 : require layout( location = 0 ) in vec4 position; layout( location = 1 ) in vec4 prev_position; layout( location = 2 ) in vec4 direction; uniform mat4 MVP; uniform float time; const float PI = 3.14159; const float TWO_PI = 2*PI; const float PI_BY_2 = PI*0.5; const float PI_BY_4 = PI_BY_2*0.5; //wyjścia shadera out vec4 out_position; out vec4 out_prev_position; out vec4 out_direction; const const const const
float DAMPING_COEFFICIENT = 0.9995; vec3 emitterForce = vec3(0.0f,-0.001f, 0.0f); vec4 collidor = vec4(0,1,0,0); vec3 emitterPos = vec3(0);
float float float float float float
emitterYaw = (0.0f); emitterYawVar = TWO_PI; emitterPitch = PI_BY_2; emitterPitchVar = PI_BY_4; emitterSpeed = 0.05f; emitterSpeedVar = 0.01f;
int emitterLife = 60; int emitterLifeVar = 15;
304
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
const float UINT_MAX = 4294967295.0; void main() { vec3 prevPos = prev_position.xyz; int life = int(prev_position.w); vec3 pos = position.xyz; float speed = position.w; vec3 dir = direction.xyz; if(life > 0) { prevPos = pos; pos += dir*speed; if(dot(pos+emitterPos, collidor.xyz)+ collidor.w <0) { dir = reflect(dir, collidor.xyz); speed *= DAMPING_COEFFICIENT; } dir += emitterForce; life--; } else { uint seed = uint(time + gl_VertexID); life = emitterLife + int(randhashf(seed++, emitterLifeVar)); float yaw = emitterYaw + (randhashf(seed++, emitterYawVar )); float pitch = emitterPitch + randhashf(seed++, emitterPitchVar); RotationToDirection(pitch, yaw, dir); float nspeed = emitterSpeed + (randhashf(seed++, emitterSpeedVar )); dir *= nspeed; pos = emitterPos; prevPos = emitterPos; speed = 1; } out_position = vec4(pos, speed); out_prev_position = vec4(prevPos, life); out_direction = vec4(dir, 0); gl_Position = MVP*vec4(pos, 1); }
Trzy pomocnicze funkcje randhash, randhashf i RotationToDirection są zdefiniowane następująco: uint randhash(uint seed) { uint i=(seed^12345391u)*2654435769u; i^=(i<<6u)^(i>>26u); i*=2654435769u; i+=(i<<5u)^(i>>12u); return i; } float randhashf(uint seed, float b) { return float(b * randhash(seed)) / UINT_MAX; }
305
OpenGL. Receptury dla programisty
void RotationToDirection(float pitch, float yaw, out vec3 direction) { direction.x = -sin(yaw) * cos(pitch); direction.y = sin(pitch); direction.z = cos(pitch) * cos(yaw); }
Jak to działa? Mechanizm transformacyjnego sprzężenia zwrotnego polega na przekazywaniu jednego lub kilku atrybutów z wyjścia shadera wierzchołków lub geometrii z powrotem do obiektu bufora. Sprzężenie takie można wykorzystać do zaimplementowania fizycznej symulacji. W tej recepturze sprzężenie obejmuje atrybuty bieżącego i poprzedniego położenia cząsteczki oraz kierunku jej ruchu. Po każdym etapie iteracyjnym następuje zamiana buforów i symulacja jest kontynuowana. W celu zbudowania systemu cząsteczkowego najpierw zestawiamy trzy pary wierzchołkowych obiektów buforowych z atrybutami, które będą wprowadzane do shadera wierzchołków. Atrybuty to położenie cząsteczki, jej położenie poprzednie, życie, kierunek ruchu i prędkość. Dla wygody umieszczamy je w odrębnych obiektach buforowych, ale moglibyśmy je umieścić również w jednym obiekcie buforowym z przeplotem. Ponieważ bufory te będą nie tylko odczytywane, ale też zapisywane, ustawiamy sposób ich użycia jako GL_DYNAMIC_COPY. Tworzymy także odrębny obiekt tablicy wierzchołków do renderowania cząsteczek. for(int i=0;i<2;i++) { glBindVertexArray(vaoUpdateID[i]); glBindBuffer( GL_ARRAY_BUFFER, vboID_Pos[i]); glBufferData( GL_ARRAY_BUFFER, TOTAL_PARTICLES * sizeof(glm::vec4), 0, GL_DYNAMIC_COPY); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0); glBindBuffer( GL_ARRAY_BUFFER, vboID_PrePos[i]); glBufferData( GL_ARRAY_BUFFER, TOTAL_PARTICLES*sizeof(glm::vec4), 0, GL_DYNAMIC_COPY); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 0,0); glBindBuffer( GL_ARRAY_BUFFER, vboID_Direction[i]); glBufferData( GL_ARRAY_BUFFER, TOTAL_PARTICLES*sizeof(glm::vec4), 0, GL_DYNAMIC_COPY); glEnableVertexAttribArray(2); glVertexAttribPointer(2, 4, GL_FLOAT, GL_FALSE, 0,0); } for(int i=0;i<2;i++) { glBindVertexArray(vaoRenderID[i]); glBindBuffer( GL_ARRAY_BUFFER, vboID_Pos[i]); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0); }
306
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
Następnie określamy wyjściowe atrybuty shadera, które mają być podłączone do buforów sprzężenia transformacyjnego. Będą to trzy atrybuty o nazwach out_position, out_prev_position i out_direction i będą one zawierały informacje o bieżącym położeniu cząsteczki, jej poprzednim położeniu, kierunku ruchu, prędkości oraz bieżącej i początkowej wartości życia. Każdy z tych atrybutów łączymy z innym obiektem bufora. glGenTransformFeedbacks(1, &tfID); glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, tfID); const char* varying_names[]={"out_position", "out_prev_position", "out_direction"}; glTransformFeedbackVaryings(particleShader.GetProgram(), 3, varying_names, GL_SEPARATE_ATTRIBS); glLinkProgram(particleShader.GetProgram());
Potem inicjalizujemy tryb sprzężenia zwrotnego. Proces ten zaczynamy od uaktywnienia cząsteczkowego shadera wierzchołków i przekazania mu uniformów zawierających połączoną macierz modelu, widoku i rzutowania (MVP) oraz czas (t). particleShader.Use(); glUniformMatrix4fv(particleShader("MVP"),1,GL_FALSE, glm::value_ptr(mMVP)); glUniform1f(particleShader("time"), t);
Następnie uruchamiamy pętlę symulacyjną z wymaganą liczbą przebiegów. W każdym przebiegu najpierw wiążemy aktualizacyjny obiekt tablicy wierzchołków i podłączamy odpowiednie bufory sprzężenia zwrotnego. for(int i=0;i
Potem wyłączamy rasteryzer i włączamy tryb sprzężenia zwrotnego. Wtedy dopiero wywołujemy funkcję glDrawArrays, aby przekazać wierzchołki do shadera. Gdy shader zakończy pracę, wyłączamy tryb sprzężenia, włączamy rasteryzer i przełączamy ścieżki odczytu i zapisu. Cały proces przetwarzania wierzchołków ujmujemy w klamrę kwerendy sprzętowej mierzącej upływający czas (GL_TIME_ELAPSED), która zwraca wynik pomiaru w nanosekundach. glEnable(GL_RASTERIZER_DISCARD); // wyłączenie rasteryzacji glBeginQuery(GL_TIME_ELAPSED,t_query); glBeginTransformFeedback(GL_POINTS); glDrawArrays(GL_POINTS, 0, TOTAL_PARTICLES); glEndTransformFeedback(); glEndQuery(GL_TIME_ELAPSED); glFlush(); glDisable(GL_RASTERIZER_DISCARD); int tmp = readID; readID=writeID; writeID = tmp;
307
OpenGL. Receptury dla programisty
} // pobierz wynik kwerendy glGetQueryObjectui64v(t_query, GL_QUERY_RESULT, &elapsed_time); delta_time = elapsed_time / 1000000.0f; particleShader.UnUse();
Zasadnicze obliczenia symulacyjne są wykonywane w shaderze wierzchołków (Rozdział8/ SprzężenieZwrotneCząsteczki/shadery/Particle.vert). Po zapisaniu początkowych wartości atrybutów sprawdzamy wartość życia cząstki. Jeśli jest większa od zera, aktualizujemy położenie, uwzględniając prędkość i kierunek ruchu. Następnie sprawdzamy, czy cząsteczka nie zderzyła się z przeszkodą. Jeśli doszło do kolizji, odbijamy cząsteczkę, posługując się funkcją reflect (dostępną w GLSL), której przekazujemy aktualny kierunek ruchu cząsteczki i normalną przeszkody. Zmniejszamy też prędkość odbitej cząstki. Po obsłużeniu ewentualnej kolizji modyfikujemy kierunek ruchu cząstki, aby uwzględnić oddziaływanie emitera, a następnie zmniejszamy wartość jej życia. if(life > 0) { prevPos = pos; pos += dir*speed; if(dot(pos+emitterPos, collidor.xyz)+ collidor.w <0) { dir = reflect(dir, collidor.xyz); speed *= DAMPING_COEFFICIENT; } dir += emitterForce; life--; }
Jeśli życie cząsteczki ma wartość ujemną, ustalamy nowy, losowy kierunek ruchu i nową, losową wartość życia, położenie bieżące i poprzednie zrównujemy z położeniem emitera, a prędkości nadajemy wartość domyślną. Na koniec podajemy wszystkie atrybuty na wyjście shadera. else { uint seed = uint(time + gl_VertexID); life = emitterLife + int(randhashf(seed++, emitterLifeVar)); float yaw = emitterYaw + (randhashf(seed++, emitterYawVar )); float pitch=emitterPitch+randhashf(seed++, emitterPitchVar); RotationToDirection(pitch, yaw, dir); float nspeed = emitterSpeed + (randhashf(seed++, emitterSpeedVar )); dir *= nspeed; pos = emitterPos; prevPos = emitterPos; speed = 1; } out_position = vec4(pos, speed); out_prev_position = vec4(prevPos, life); out_direction = vec4(dir, 0); gl_Position = MVP*vec4(pos, 1); There's more…
308
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU
I jeszcze jedno… Aplikacja przykładowa generuje prosty system cząsteczkowy, używając do tego celu wyłącznie GPU w połączeniu z mechanizmem transformacyjnego sprzężenia zwrotnego. Rezultaty działania shadera wierzchołków są tu wykorzystywane nie tylko do renderowania, ale również do ponownego przetwarzania. Po uruchomieniu aplikacji widzimy animację, której jedną z klatek przedstawia poniższy rysunek.
Zauważ, że w tej aplikacji renderujemy cząsteczki jako punkty o rozmiarze 10 jednostek. Gdybyśmy zastosowali tryb renderowania punktowych sprajtów, moglibyśmy modyfikować ich
309
OpenGL. Receptury dla programisty
rozmiary w shaderze wierzchołków, a to pozwoliłoby na jeszcze większe zróżnicowanie cząsteczek. Moglibyśmy również zastosować szerszą gamę kolorów i trybów mieszania. Bez wpływu na ostateczny rezultat byłoby użycie jednej pary buforów z przeplotem atrybutów lub dwóch odrębnych obiektów transformacyjnego sprzężenia zwrotnego. Po przeanalizowaniu powyższej receptury nie powinieneś mieć problemów z zaimplementowaniem tych wszystkich zmian. Symulację systemu cząsteczkowego na GPU przerabialiśmy też w rozdziale 5., ale zastosowana tam metoda nie uwzględniała zapisywania stanów — wszystkie atrybuty (czyli położenie i prędkość) były wyznaczane na bieżąco na podstawie identyfikatora przetwarzanego wierzchołka, czasu i podstawowego równania kinematyki. Przyjrzyjmy się dokładniej wadom i zaletom obu rozwiązań. Jeśli stan cząsteczki nie jest rejestrowany, informacja o jej wcześniejszym zachowaniu jest niedostępna, a to stwarza problemy przy wykrywaniu kolizji i reagowaniu na nie, bo często w takich sytuacjach potrzebna jest informacja o stanie poprzednim. Kłopotów tego typu możemy jednak uniknąć, stosując zaprezentowaną w tej recepturze metodę z zapisywaniem stanów cząsteczek w obiektach buforowych. Dostęp do stanu poprzedniego nie tylko umożliwił łatwą obsługę kolizji, ale również pozwolił na zastosowanie transformacyjnego sprzężenia zwrotnego.
Dowiedz się więcej Zapoznaj się z następującymi publikacjami: Opis implementacji systemu cząsteczkowego z użyciem transformacyjnego sporzężenia zwrotnego zamieszczony w serwisie OGLDev, pod adresem: http://ogldev.atspace.co.uk/www/tutorial28/tutorial28.html. David Wolff, OpenGL 4.0 Shading Language Cookbook, Packt Publishing, 201,
rozdział 9., „Animation and Particles, Creating a particle system using transform feedbacksection”. Artykuł Noise based Particles, Part II, opublikowany w serwisie The Little
Grasshopper, pod adresem: http://prideout.net/blog/?p=67.
310
Skorowidz A algorytm cięcia połówkowokątowego, 262, 266 tekstury 3D na płaty, 228, 229, 232, 252, 253 maszerującego czworościanu, 228, 262 maszerujących sześcianów, 228, 256, 262 animacja, 174 liczba klatek w ciągu sekundy, 78 poklatkowa, 269 szkieletowa, 171, 174 z paletą macierzy skinningowych, 270 ze skinningiem przy użyciu kwaternionu dualnego, 280, 283 Animation, Patrz: animacja
B biblioteka freeglut, 18, 22, 44 GLEW, 19, 22 inicjalizacja, 24 glm, 44 GLUT, 18 MeshImport, 172, 175, 178 OpenGL, 57 pugixml, 172 SOIL, 19, 57, 60, 152 bi-directional reflectance distribution function, Patrz: BRDF
bind pose, Patrz: poza wiązania Blinna-Phonga model, Patrz: model Blinna-Phonga BRDF, 210 bryła widzenia, 77 bufor głębi, 25, 79, 80 wartość czyszcząca, 25 koloru, 25, 83 wartość czyszcząca, 25 ramki, 101 renderingu, 97 selekcji, 79
C cieniowanie, 18 fragmentów, 115, 116 Gourauda, 116 Phonga, 116 wierzchołków, 115, 116 cień, 115 bryła, 130 mapa, 130, 131 mapowanie, Patrz: mapowanie cieni clip space, Patrz: przestrzeń przycięcia compatibility profile, Patrz: profil zgodnościowy core profile, Patrz: profil rdzenny cube mapping, Patrz: mapowanie sześcienne czas, 44, 181 cząsteczki, 182
OpenGL. Receptury dla programisty
cząsteczka czas, Patrz: czas cząsteczki generowanie położenia, 181 GL_POINT_SPRITE, Patrz: sprajt GL_POINTS, 181 prędkość początkowa, 182 Czebyszewa nierówność, 144
D depth peeling, Patrz: peeling głębi dithering, Patrz: roztrząsanie kolorów drzewo bsp, 74 czwórkowe, 74 kd, 225 ósemkowe, 74 dym, 178, 228 dyrektywa using, 22
E efekt postprodukcyjny, 90 poświaty, Patrz: poświata
F FBO, 89, 97, 102, 130, 191, 204 filtr gaussowski, 145, 245 PCF, 136, 137, 138, 139, 148 wirowy, 90, 92 format 3ds, 151, 156, 157, 161, 164, 165 Collada, 171, 270 EZMesh, 171, 174, 270 FBX, 171, 270 md2, 171 OBJ, 151, 166, 168, 171 RGBE, 210 funkcja C3dsLoader::Load3DS, 157, 161 CreateAndLinkProgram, 29 EmitVertex, 51 EndPrimitive, 51 EzmLoader::Load, 172, 174, 175, 271 glBeginTransformFeedback, 287 glBindBuffer, 36
312
glBindTextures, 61 glBufferData, 36 glCheckFramebufferStatus, 98 glClearDepth, 25 glDrawArrays, 181 glDrawElement, 62 glDrawElements, 53 glDrawElementsInstanced, 53, 56 glDrawTransformFeedback, 288 glGenerateMipMap, 230 glGenTextures, 61 glGenTransformFeedbacks, 287 glGetError, 30 glMatrixMode, 23 glNormal, 23 glPolygonMode, 45 glReadPixels, 83 glRotate, 23 glScale, 23 glTexCoord, 23 glTexImage2D, 61 glTransformFeedbackVaryings, 287 glTranslate, 23 gluInit, 23 glutInitContextVersion, 23 glutMotionFunc, 44 glutPostRedisplay, 53 glutSwapBuffers, 25, 62 glVertex, 23 glVertexAttribIPointer, 277 glVertexAttribPointer, 35 intersectBox, 87 LoadFromString, 29 main, 23 NVSHARE::loadMeshImporters, 175 ObjLoader::Load, 166, 168 OnInit, 23, 24, 33 OnKey, 52 OnMouseDown, 44, 45 OnRender, 23, 56 OnResize, 23, 44 OnShutdown, 23, 24, 36, 62 PointInFrustum, 77 przejścia, 252, 254, 255 rozkładu odbić dwukierunkowa, Patrz: BRDF SOIL_load_image, 60 textureProj, 134, 135 textureProjOffset, 137, 138 uniformRandomDir, 182 zwrotna, 23
Skorowidz
G generator pseudolosowy, 182 gimbal lock, 280 Gourauda cieniowanie, 116 grawitacja, 292, 294, 295, 297
H harmonika sferyczna, Patrz: oświetlenie harmonika sferyczna
I izopowierzchnia, 241, 242, 244, 255, 259 wydzielanie, 255
K kamera sterowanie za pomocą klawiatury, 65 myszy, 65, 67 swobodna, 64, 68, 69, 70 wycelowana, 64, 70, 71, 72 kanał alfa, 25 RGB, 25 kierunek patrzenia, 63 klasa C3dsLoader, 157 CAbstractCamera, 64 CFreeCamera, 68 GLSLShader, 26, 27, 28, 29 VolumeSplatter, 247 klawiatura, 52 kolizji wykrywanie, Patrz: wykrywanie kolizji konwolucja, Patrz: splot kopuła nieba, 93 kość, 270 nadrzędna, 271, 274 nazwa, 274 podrzędna orientacja względem kości nadrzędnej, 274 transformacja, 271 kwaternion, 280 dualny, 280, 281, 283
L LBS, 270 linear blend skinning, Patrz: LBS
M macierz, 19 cienia, 131, 135 kierunku, 69 kości finalna, Patrz: macierz skinningu mnożenie, 44 modelu instancyjna, 54 modelu i widoku, 30, 44 dla światła, 134, 135 normalna, 118 przekształcenia modelu, 29 widoku, 29, 30 przesunięcia, 134 rzutowania, 44, 64 światła, 134 skinningowa, 270, 272, 273, 275, 281 widoku, 29, 30, 63 makro GL_CHECK_ERRORS, 30 mapa cienia, 146 materiału, 175 transformacji, 152 wysokości, 152 mapowanie cieni, 130, 134 wariacyjne, 141, 144, 147, 148, 149 z filtrowaniem PCF, 136, 137, 138, 139, 148 FBO, 130 na podstawie testu głębi, 115 sześcienne, 93 dynamiczne, 90, 93, 101, 103 wariancyjne, 115 marching tetrahedra, Patrz: algorytm maszerującego czworościanu Material, Patrz: materiał materiał, 174, 175 mapa, Patrz: mapa materiału Mesh, Patrz: siatka metoda Monte Carlo, 221
313
OpenGL. Receptury dla programisty
mieszanie alfa, Patrz: kanał alfa liniowe, Patrz: skinning mieszanie liniowe sferyczne, Patrz: skinning mieszanie sferyczne mipmapa, 229, 230 model Blinna-Phonga, 119, 215 siatkowy, 151 modelowanie terenu, 152, 153, 154, 156 fraktalowe, 155 metoda proceduralna, 155 metoda szumowa, 155 tkaniny, 287, 289, 293, 294, 296, 297, 299, 300 modelview matrix, Patrz: macierz modelu i widoku mysz, 67
N nierówność Czebyszewa, 144 normalna, 116, 118, 164
O obiekt bufora ramki, Patrz: FBO GLSLShader tworzenie, 28 o lustrzanej powierzchni, 97 przestrzeń, Patrz: przestrzeń obiektu przezroczysty, Patrz: przezroczystość std::map, 29 tablicy wierzchołków, Patrz: VAO ukrywanie, 64, 74 wskazywanie na ekranie monitora, 79, 80 na podstawie koloru, 83 na podstawie przecięć z promieniem oka, 85, 86 obiekt bufora wierzchołków, Patrz: VBO object space, Patrz: przestrzeń obiektu obraz deformacja, 90 HDR, 189, 211 HDR/RGBE, 210 mieszanie, 111, 112 na powierzchni kuli, 213 przeglądarka, Patrz: przeglądarka obrazów renderowanie, 62
314
rozmycie, 106, 108 gaussowskie, 108, Patrz też: filtr gaussowski splot, Patrz: splot wyostrzenie, 106, 108 wytłoczenie, 106, 108 odbicia, 90 ogień, 178 okluzja obliczanie, 204, 205, 207, 208 otoczenia w przestrzeni ekranu, Patrz: SSAO oświetlenie, 115 absorpcja, 262 globalne, 189, 203, 210 harmonika sferyczna, 189, 210, 211, 213, 215 kod, 213 kierunkowe, 115, 122, 123, 124 obliczenia, 116, 118, 119, 126 obraz HDR, 189 punktowe, 115, 116, 123 zanikające, 124, 126, 127 reflektorowe, 115, 128, 129 kąt odcięcia, 129 tłumienie kątowe, 129 składowa odblaskowa, 119 wolumetryczne, 262, 266
P panoramowanie, 70 pasek tytułowy, 78 PCF, Patrz: filtr PCF peeling głębi, 190, 194 dualny, 190, 196, 197, 200 jednokierunkowy, 190, 194, 195 warstwa, 192 wyłączenie, 196 percent closer filtering, Patrz: filtr PCF Phonga cieniowanie, 116 PhysX sdk, 270 plik .dae, 171 .frag, 34 .geom, 34 .vert, 34 3ds, Patrz: format 3ds AbstractCamera.cpp, 64 AbstractCamera.h, 64 EZMesh, 172, 174
Skorowidz
FreeCamera.cpp, 68 FreeCamera.h, 68 GLSLShader.cpp, 27 GLSLShader.h, 27 iostream, 22 main.cpp, 22 płaszczyzna nieba, 93 podział, 47 poświata, 90, 109, 111 potok, 18 powierzchnia lustrzana, 97, 100, 101 poza wiązania, 274 profil rdzenny, 18, 57 zgodnościowy, 18, 19 promień kroczący, 236, 237 rzucanie, 236 prymityw, 47 przeglądarka obrazów, 57 przekształcenie, Patrz: transformacja przestrzeń ekranu, 30 obiektu, 30 przycięcia, 30 świata, 30 przezroczystość, 190, 197
R Ratcliff John, 270 ray tracing, Patrz: śledzenie promieni rendering do tekstury, Patrz: rendering pozaekranowy instancyjny, 53, 54 izopowierzchni, 228 krawędziowy, 260 objętościowy, Patrz: rendering wolumetryczny odroczony, 90 pozaekranowy, 101, 107, 109 pseudoizopowierzchniowy z jednoprzebiegowym rzucaniem promieni, 241, 244 tekstury głębi, 132 warstwowy, 105 wokseli, 250 wolumetryczny, 227
błędy, 236 z cięciem tekstury 3D na płaty, 228, 229, 232, 252 z jednoprzebiegowym rzucaniem promieni, 236, 237, 239, 240 z użyciem splattingu, 245, 246, 251 renderowanie pozaekranowe, 89 terenu, Patrz: modelowanie terenu rezonans magnetyczny, 229 roztrząsanie kolorów, 83
S sampler tekstur, 29 screen space, Patrz: przestrzeń ekranu screen space ambient occlusion, Patrz: SSAO shader, 18, 26 atrybut, 29 zmieniający się, 33 ewaluacji teselacji, 26 falujący, 38 fragmentów, 26, 32, 33, 61, 90, 112, 117 pathtracer.frag, 223 raytracer.frag, 218 geometrii, 26, 46, 47, 50, 51, 52, 75, 105 maksymalna liczba wierzchołków, 50 kontroli teselacji, 26 konwolucji, 107 uniform, 29 wiązanie, 29 wierzchołków, 26, 32, 33, 38, 46, 61, 91, 104, 117 shader binding, Patrz: shader wiązanie siatka, 38, 42, 174 generowanie topologii, 42 siła grawitacji, 292, 294, 295, 297 Skeleton, Patrz: szkielet skinning, 270, 274 macierzowy, 277, 280 mieszanie liniowe, 270, 280 sferyczne, 280 z kwaternionem dualnym, 280 skóra, 270 sky dome, Patrz: kopuła nieba skybox, Patrz: sześcian nieba skyplane, Patrz: płaszczyzna nieba splatting, 245, 246, 250, 251
315
OpenGL. Receptury dla programisty
splot, 106, 107 cyfrowego, 90 dwuwymiarowy, 106 jądro, 108, 109 jednowymiarowy, 106 separowalny, 106, 112 sprajt, 181 sprzężenie zwrotne transformacyjne, 287, 293, 294, 301, 306, 307, 309 SSAO, 203, 207, 208, 209 system cząsteczkowy, 152, 178, 187, 188 z transformacyjnym sprzężeniem zwrotnym, 301, 306, 307, 309 sześcian nieba, 93, 96 szkielet, 174
Ś śledzenie promieni, 216, 217, 218, 219, 225 ścieżek, 221, 223, 225 Metropolis light transport, 225 świata przestrzeń, Patrz: przestrzeń świata światło, Patrz: oświetlenie
T tablica GLubyte, 229 GLushort, 229 tekstur dwuwymiarowych, 229 tekstura filtrowanie liniowe, 138 pozaekranowa, 103 shadowmap, 134 sześcienna, 102, 103, 104 zawijanie, 108 Terragen, 155 tomografia komputerowa, 229 transform feedback, Patrz: sprzężenie zwrotne transformacyjne
316
transformacja bezwzględna, Patrz: transformacja globalna globalna, 19, 274 kości względna, 271 mapa, Patrz: mapa transformacji rzutowania, 30
U ukrywanie elementów spoza bryły widzenia, 64, 74
V VAO, 34, 247, 290 VBO, 247 vertex array object, Patrz: VAO view frustum culling, Patrz: ukrywanie elementów spoza bryły widzenia
W waga wiązania, 270 Ward Greg, 210 wektor normalny, Patrz: normalna wiązanie poza, Patrz: poza wiązania waga, Patrz: waga wiązania wireframe, Patrz: rendering krawędziowy woksel, 245 renderowanie, Patrz: rendering wokseli World Machine, 155 world space, Patrz: przestrzeń świata wykrywanie kolizji, 296, 297, 299, 300, 301
Z zdarzenie klawiaturowe, 52
Ź źródło światła, Patrz: oświetlenie