You are on page 1of 316

Tytuł oryginału: OpenGL Development Cookbook

Tłumaczenie: Zbigniew Waśko

ISBN: 978-83-283-0021-7

Copyright © Packt Publishing 2013.

First published in the English language under the title ‘OpenGL Development Cookbook’

© 2015 Helion S.A.


All rights reserved.

All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means,
electronic or mechanical, including photocopying, recording or by any information storage retrieval
system, without permission from the Publisher.

Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu niniejszej


publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą kserograficzną,
fotograficzną, a także kopiowanie książki na nośniku filmowym, magnetycznym lub innym powoduje
naruszenie praw autorskich niniejszej publikacji.

Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi ich
właścicieli.

Autor oraz Wydawnictwo HELION dołożyli wszelkich starań, by zawarte w tej książce informacje
były kompletne i rzetelne. Nie bierze jednak żadnej odpowiedzialności ani za ich wykorzystanie,
ani za związane z tym ewentualne naruszenie praw patentowych lub autorskich. Wydawnictwo
HELION nie ponosi również żadnej odpowiedzialności za ewentualne szkody wynikłe
z wykorzystania informacji zawartych w książce.

Wydawnictwo HELION
ul. Kościuszki 1c, 44-100 GLIWICE
tel. 32 231 22 19, 32 230 98 63
e-mail: helion@helion.pl
WWW: http://helion.pl (księgarnia internetowa, katalog książek)

Pliki z przykładami omawianymi w książce można znaleźć pod adresem:


ftp://ftp.helion.pl/przyklady/openrp.zip

Drogi Czytelniku!
Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres
http://helion.pl/user/opinie/openrp_ebook
Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję.

 Poleć książkę na Facebook.com  Księgarnia internetowa


 Kup w wersji papierowej  Lubię to! » Nasza społeczność
 Oceń książkę
Spis treści

O autorze 7

O recenzentach 9

Wstęp 11

Rozdział 1. Wprowadzenie do nowoczesnego OpenGL 17


Wstęp 17
Instalacja rdzennego profilu OpenGL 3.3 w Visual Studio 2013
przy użyciu bibliotek GLEW i freeglut 18
Projektowanie klasy shadera w GLSL 26
Renderowanie kolorowego trójkąta za pomocą shaderów 29
Wykonanie deformatora siatki przy użyciu shadera wierzchołków 38
Dynamiczne zagęszczanie podziału płaszczyzny przy użyciu shadera geometrii 46
Dynamiczne zagęszczanie podziału płaszczyzny przy użyciu shadera geometrii
i renderingu instancyjnego 53
Rysowanie obrazu 2D przy użyciu shadera fragmentów i biblioteki SOIL 57

Rozdział 2. Wyświetlanie i wskazywanie obiektów 3D 63


Wstęp 63
Implementacja wektorowego modelu kamery z obsługą ruchów w stylu gier FPS 64
Implementacja kamery swobodnej 68
Implementacja kamery wycelowanej 70
Ukrywanie elementów spoza bryły widzenia 74
Wskazywanie obiektów z użyciem bufora głębi 79
Wskazywanie obiektów na podstawie koloru 83
Wskazywanie obiektów na podstawie ich przecięć z promieniem oka 85
OpenGL. Receptury dla programisty

Rozdział 3. Rendering pozaekranowy i mapowanie środowiska 89


Wstęp 89
Implementacja filtra wirowego przy użyciu shadera fragmentów 90
Renderowanie sześcianu nieba metodą statycznego mapowania sześciennego 93
Implementacja lustra z renderowaniem pozaekranowym przy użyciu FBO 97
Renderowanie obiektów lustrzanych z użyciem dynamicznego mapowania sześciennego 101
Implementacja filtrowania obrazu (wyostrzania, rozmywania, wytłaczania) metodą splotu 106
Implementacja efektu poświaty 109

Rozdział 4. Światła i cienie 115


Wstęp 115
Implementacja oświetlenia punktowego na poziomie wierzchołków i fragmentów 116
Implementacja światła kierunkowego na poziomie fragmentów 122
Implementacja zanikającego światła punktowego na poziomie fragmentów 124
Implementacja oświetlenia reflektorowego na poziomie fragmentów 128
Mapowanie cieni przy użyciu FBO 130
Przygotowania 131
Mapowanie cieni z filtrowaniem PCF 136
Wariancyjne mapowanie cieni 141

Rozdział 5. Formaty modeli siatkowych i systemy cząsteczkowe 151


Wstęp 151
Modelowanie terenu przy użyciu mapy wysokości 152
Wczytywanie modeli 3ds przy użyciu odrębnych buforów 156
Wczytywanie modeli OBJ przy użyciu buforów z przeplotem 166
Wczytywanie modeli w formacie EZMesh 171
Implementacja prostego systemu cząsteczkowego 178

Rozdział 6. Mieszanie alfa i oświetlenie globalne na GPU 189


Wstęp 189
Implementacja przezroczystości techniką peelingu jednokierunkowego 190
Implementacja przezroczystości techniką peelingu dualnego 197
Implementacja okluzji otoczenia w przestrzeni ekranu (SSAO) 203
Implementacja metody harmonik sferycznych w oświetleniu globalnym 210
Śledzenie promieni realizowane przez GPU 216
Śledzenie ścieżek realizowane przez GPU 221

Rozdział 7. Techniki renderingu wolumetrycznego bazujące na GPU 227


Wstęp 227
Implementacja renderingu wolumetrycznego z cięciem tekstury 3D na płaty 228
Implementacja renderingu wolumetrycznego z jednoprzebiegowym rzucaniem promieni 236

4
Spis treści

Pseudoizopowierzchniowy rendering w jednoprzebiegowym rzucaniu promieni 241


Rendering wolumetryczny z użyciem splattingu 245
Implementacja funkcji przejścia dla klasyfikacji objętościowej 252
Implementacja wydzielania wielokątnej izopowierzchni metodą maszerujących sześcianów 255
Wolumetryczne oświetlenie oparte na technice cięcia połówkowokątowego 262

Rozdział 8. Animacje szkieletowe i symulacje fizyczne na GPU 269


Wstęp 269
Implementacja animacji szkieletowej z paletą macierzy skinningowych 270
Implementacja animacji szkieletowej ze skinningiem wykonanym przy użyciu kwaternionu
dualnego 280
Modelowanie tkanin z użyciem transformacyjnego sprzężenia zwrotnego 287
Implementacja wykrywania kolizji z tkaniną i reagowania na nie 296
Implementacja systemu cząsteczkowego z transformacyjnym sprzężeniem zwrotnym 301

Skorowidz 311

5
OpenGL. Receptury dla programisty

Zespół oryginalnego wydania

Autor Koordynator projektu


Muhammad Mobeen Movania Rahul Dixit

Recenzenci Korektorzy
Bastien Berthe Stephen Silk
Dimitrios Christopoulos Lauren Tobon
Oscar Ripolles Mateu
Indekser
Redaktor inicjujący Tejal R. Soni
Erol Staveley
Grafik
Redaktor zamawiający Abhinash Sahu
Shreerang Deshpande
Kierownik produkcji
Główny redaktor techniczny Aparna Bhagat
Madhuja Chaudhari
Autor okładki
Redaktorzy techniczni Aparna Bhagat
Jeeten Handu
Sharvari H. Baet
Ankita R. Meshram
Priyanka Kalekar

6
O autorze

Muhammad Mobeen Movania zrobił doktorat z zaawansowanej grafiki komputerowej i wizuali-


zacji na Uniwersytecie Technologicznym Nanyang (NTU) w Singapurze. Licencjat z informa-
tyki 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 hie-
rarchizację 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 implemen-
tacji 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 Patra-


skim 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 obej-
mują rzeczywistość wirtualną, interakcję między człowiekiem a komputerem, grafikę kompu-
terową 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 Program-
ming. 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 wir-
tualnych 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 modelowa-
niem, 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 przy-
gotowanie podstawowej aplikacji shaderowej. Po omówieniu spraw związanych z konfiguracją
środowiska programistycznego następuje prezentacja wszystkich etapów cieniowania, a odpo-
wiednio dobrane przykłady praktyczne mają ułatwić czytelnikowi zrozumienie zasad funkcjo-
nowania poszczególnych faz toku pracy nowoczesnego procesora graficznego. Po takim roz-
dziale 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 gra-
ficznymi prezentowane są implementacje cyfrowych filtrów konwolucyjnych służących do
przetwarzania obrazów i wykorzystujących shadery fragmentów. Opisane jest również prze-
kształ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ą wyspecjalizo-


wanych 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 prze-
plotem. 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 mate-
riałami. Modele szkieletowe wprowadzimy w nowym formacie animacyjnym EZMesh. Zoba-
czymy, 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, recep-
tury 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. Przyj-
rzymy 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świe-
tlenia, 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: har-
monik 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 prze-
znaczeniem dla nowoczesnego procesora graficznego.

Grafika komputerowa ma duży wpływ na rozwój szeregu innych dziedzin, począwszy od fil-
mowych 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 wyzna-
czanie ruchów obiektów przez aproksymowanie modeli fizycznych. Rozpracujemy nowy mecha-
nizm transformacyjnego sprzężenia zwrotnego (transform feedback) i użyjemy go do prze-
prowadzenia 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 zaimplemen-
tować 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 obra-
zó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 kie-
runkowego ze stopniowym zanikaniem. Ponadto szczegółowo omawiane są metody renderowa-
nia 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 wczy-
tywać dane zapisane w klasycznych formatach modelowych 3DS i OBJ, używając buforów odręb-
nych 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 pre-
zentowane 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, splat-
tingiem 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 implemen-
tacji animacji szkieletowej ze skinningiem opartym na palecie macierzy lub dualnym kwater-
nionie. Pokazuje też, jak należy używać transformacyjnego sprzężenia zwrotnego przy imple-
mentowaniu 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 podsta-
wową 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ójwymia-
rowej, którzy mają już pewne doświadczenie w stosowaniu jakiegokolwiek interfejsu do progra-
mowania 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ż dodat-
kowe 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 dodat-
kowych 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 zamiesz-
czone 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. Wydaw-
nictwo 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 inter-
netowej, abyśmy mogli dochodzić swoich praw.

Informacje na temat łamania praw autorskich można wysyłać na adres helion@helion.pl.

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 redakcja@helion.pl, 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 prze-
znaczano do wykonania sprzętowego poprzez stosowne rozszerzenia. Do wersji 2.0 (która uka-
zał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 programi-
stom 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 wer-
sji 4.0 wprowadzono dwie dodatkowe fazy realizowane za pomocą shaderów kontroli (ang. tessel-
lation 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 śro-
dowiska 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 wer-
sjami 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 plat-
formach. Pomaga on również w tworzeniu kontekstu dla obu profili interfejsu OpenGL. Naj-
nowszą 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 pobra-
niu 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 środowi-
ska 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 korzy-
stał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 kompatybil-
noś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 mecha-
nizmó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 biblio-
tekami. Najnowszą wersję można pobrać ze strony http://glm.g-truc.net. W przykładach zamiesz-
czonych 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 pre-


zentowane 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 potrze-
buje 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 <GL/glew.h>
#include <GL/freeglut.h>
#include <iostream>
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ąza-
nego z OpenGL. Przekazanie znacznika GLUT_DEBUG do funkcji glutInitContextFlags tworzy kon-
tekst w trybie debugowania wymaganym do zwrotnego wywoływania funkcji przez komunikaty genero-
wane 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: "<<glewGetErrorString(err)<<endl;
} else {
if (GLEW_VERSION_3_3)
{
cout<<"Sterownik obsługuje OpenGL 3.3\nSczegóły:"<<endl;
}
}
cout<<"\tBiblioteka GLEW "<<glewGetString(GLEW_VERSION)<<endl;
cout<<"\tProducent: "<<glGetString (GL_VENDOR)<<endl;
cout<<"\tRenderer: "<<glGetString (GL_RENDERER)<<endl;
cout<<"\tWersja OpenGL: "<<glGetString (GL_VERSION)<<endl;
cout<<"\tGLSL: "<<glGetString (GL_SHADING_LANGUAGE_VERSION)<<endl;
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ę"<<endl;
}
void OnShutdown() {
cout<<"Zamknięcie udało się"<<endl;
}
void OnResize(int nw, int nh) {
}
void OnRender() {
glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);
glutSwapBuffers();
}

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 mie-
szaniem 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 apli-
kacji 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 uru-
chamiane 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 wierz-
chołków (które odpowiadają za wyznaczanie położenia wierzchołków w bryle widzenia), sha-
dery kontroli teselacji (odpowiedzialne za określanie poziomu teselacji przetwarzanej łaty),
shadery ewaluacji teselacji (wyznaczające interpolowane wartości położenia i innych atrybu-
tó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ą poszcze-
gó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ą kom-
pilację 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<string,GLuint> _attributeList;
map<string,GLuint> _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 deklara-
cjami 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 atry-
buty 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ły-
wana 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 uzy-
ska lokalizację atrybutu (uniformu). Lokalizacja jednoznacznie identyfikuje atrybut (uniform).
W klasie GLSLShader ze względów praktycznych lokalizacje atrybutów i uniformów są składo-
wane 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 naj-
pierw 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 znaj-
duje 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 wier-
szu 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łkowitolicz-
bowego indeksu atrybutowego z konkretnym atrybutem wierzchołka. Kolejność lokalizacji atry-
butów można ustawiać dowolnie, ale my będziemy konsekwentnie (we wszystkich prezento-
wanych 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 wszyst-
kich wywołań tej funkcji.

Shader wierzchołków przekazuje na wyjście kolor wierzchołka (vSmoothColor). Atrybuty inter-


polowane 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 prze-
kazanie 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 kwalifi-
katora 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 sha-
derami wierzchołków będą miały rozszerzenie .vert, z shaderami geometrii — .geom, a z shade-
rami 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 przeprowa-
dzamy 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 prze-


strzeni 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 two-
rzymy za pomocą funkcji glGenVertexArrays. Obiekty buforów generujemy przy użyciu funkcji
glGenBuffers. Pierwszym parametrem obu tych funkcji jest liczba potrzebnych obiektów, a dru-
gim — 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żyt-
kowaniu 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 graficz-
nemu 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ć, sto-
sujemy konstrukcję GLSLShader::operator[], przekazując jej nazwę atrybutu, który nas inte-
resuje. 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 prze-
sunię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 usu-
nąć program shaderowy i w tym celu wywołujemy funkcję GLSLShader::DeleteShaderProgram.
Następnie usuwamy oba obiekty buforów (vboVerticesID i vboIndicesID) i na koniec pozby-
wamy 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 pro-
gramu 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ą sygnali-
zują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 informu-
jemy 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 prze-
strzeni 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 znaj-
duje 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 obser-
wować 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 matematycz-


nym wzorze fali). Warto w tym miejscu zauważyć, że stałe te można zastąpić uniformami i mody-
fikować 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ść wsta-
wiamy 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 rzu-


tuje 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 kom-
binacje — 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++ = i1;
*id++ = i1; *id++ = i2; *id++ = i3;
} else {
*id++ = i0; *id++ = i2; *id++ = i3;
*id++ = i0; *id++ = i3; *id++ = 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 gene-
rowane 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żyt-
kownika.

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ązy-
wany jest obiekt klasy GLSLShader i zamieniane są bufory obrazu.

W głównej funkcji programu podpinamy dwie nowe funkcje zwrotne: glutMouseFunc obsłu-
giwaną 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 ozna-
cza 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 ren-
deringu 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 rozcho-


dzą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 poszcze-
gó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. Podsta-
wowa 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ą infor-
macje 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 przeci-


wień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 sha-
dera 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<sub_divisions*sub_divisions;j++) {
gl_Position = MVP * vec4(x,0,z,1);
EmitVertex();
gl_Position = MVP * vec4(x,0,z+dz,1);
EmitVertex();
gl_Position = MVP * vec4(x+dx,0,z,1);
EmitVertex();
gl_Position = MVP * vec4(x+dx,0,z+dz,1);
EmitVertex();
EndPrimitive();
x+=dx;
if((j+1) %sub_divisions == 0) {
x=v0.x;
z+=dz;
}
}
}
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"<<endl;
}

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 infor-
mują 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 opraco-
wywaniu 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ój-
ką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ęd-
nieniem 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<sub_divisions*sub_divisions;j++) {
gl_Position = MVP * vec4(x, 0, z,1); EmitVertex();
gl_Position = MVP * vec4(x, 0,z+dz,1); EmitVertex();
gl_Position = MVP * vec4(x+dx,0, z,1); EmitVertex();
gl_Position = MVP * vec4(x+dx,0,z+dz,1); EmitVertex();
EndPrimitive();
x+=dx;
if((j+1) % sub_divisions == 0) {
x=v0.x;
z+=dz;
}
}
}

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 wyko-
nywana 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 stru-
mieniu 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 przy-
wracana 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 recep-
turze 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 przesu-
nię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 definiu-
jemy 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 zmniej-
szenie 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 ser-
wisie Geeks3D http://www.geeks3d.com/20111111/simple-introduction-to-geometry-shaders-glsl-
opengl-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 sha-
dera 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<sub_divisions*sub_divisions;j++) {
gl_Position = PV * vec4(x,0,z,1); EmitVertex();
gl_Position = PV * vec4(x,0,z+dz,1); EmitVertex();
gl_Position = PV * vec4(x+dx,0,z,1); EmitVertex();
gl_Position = PV * vec4(x+dx,0,z+dz,1); EmitVertex();
EndPrimitive();
x+=dx;
if((j+1) %sub_divisions == 0) {
x=v0.x;

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ęp-
nie 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] = 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));

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. Ren-
dering 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 korzy-
stanie 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: "<<filename.c_str()<<endl;
exit(EXIT_FAILURE);
}
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;

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: "<<filename.c_str()<<endl;
exit(EXIT_FAILURE);
}

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 wygene-
rowania 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 dru-
gim przechowywany jest identyfikator (ID) wygenerowanego obiektu tekstury. Po wygenero-
waniu 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 pierw-
szym 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 przyjmo-
wać 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 poprzed-
nich 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 przy-
padku 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ć, sto-
sują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ój-
wymiarowej 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 tworze-
nia 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 programistycz-
nym 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 konkret-
nych kamer. W typowej aplikacji OpenGL operacje widokowe mają na celu pokazanie wirtu-
alnego 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ł bezczyn-
noś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łyn-
niejszy ruch sterowanego obiektu. My będziemy stosować proste filtrowanie polegające na ważo-
nym 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 fol-
derze 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 pano-
ramowanie, 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ą sza-


chownicę, 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 sorto-
wanie 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 ele-
mentó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łaszczy-
zna 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<gl_in.length(); i++) {
vec4 vInPos = gl_in[i].gl_Position;
vec2 tmp = (vInPos.xz*2-1.0)*5;
vec3 V = vec3(tmp.x, vInPos.y, tmp.y);
gl_Position = MVP*vec4(V,1);
if(PointInFrustum(V)) {
EmitVertex();
}
}
EndPrimitive();
}
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<dot(pos,pos)) discard;
vFragColor = vec4(0,0,1,1);
}
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ło-
wym 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 renderu-
ją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 pozo-
stał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<minDist) {
selected_box = i;
minDist = 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"<<endl;
selected_box = 0;
}
if(pixel[0]==0 && pixel[1]==255 && pixel[2]==0) {
cout<<"picked box 2"<<endl;
selected_box = 1;
}
if(pixel[0]==0 && pixel[1]==0 && pixel[2]==255) {
cout<<"picked box 3"<<endl;
selected_box = 2;
}

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ęp-
nie przez porównywanie wartości r, g i b piksela z odpowiadającymi im wartościami poszcze-
gó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. Wykorzy-
stamy tę samą scenę co poprzednio, z trzema kostkami (czerwoną, zieloną i niebieską) umieszczo-
nymi 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<float>::max();


selected_box = -1;
for(int i=0;i<3;i++) {
glm::vec2 tMinMax = intersectBox(eyeRay, boxes[i]);
if(tMinMax.x<tMinMax.y && tMinMax.x<tMin) {
selected_box=i;
tMin = tMinMax.x;
}
}
if(selected_box==-1)
cout<<"Nie wskazano żadnej kostki"<<endl;
else
cout<<"Wskazano kostkę nr: "<<selected_box<<endl;

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 kie-
runku 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 pro-
mień 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łasz-
czyzn 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 prosto-
padł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 zaimplemen-
towane 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 pozaekra-
nowy 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 wykorzysta-
niu 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 frag-
mentów, a zatem wszystko będzie wykonywane przez GPU.

Przygotowania
Tym razem wykorzystamy kod z receptury poświęconej rysowaniu obrazu zamieszczonej w roz-
dziale 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 biegu-
nowych 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ślo-
nego obszaru, powiedzmy koła o promieniu 150 pikseli i środku w centrum obrazu. Podpo-
wiedź: 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ą tech-
niką 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: ..."<<endl;
for(int i=0;i<6;i++) {
cout<<"\tWczytywanie: "<<texture_names[i]<<" ... ";
pData[i] = SOIL_load_image(texture_names[i], &texture_widths[i],
&texture_heights[i], &channels[i],
SOIL_LOAD_AUTO);
cout<<"Gotowe."<<endl;
}
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 wkompono-
wana 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ć tek-
sturę 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ę pokry-
wać 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 ryso-
wania 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 wyrendero-
wać 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."<<endl;
exit(EXIT_FAILURE);
} else {
cerr<<"Ustawienie FBO powiodlo sie."<<endl;
}

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 rendero-
wanie 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 sto-
sować 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ę) dwuwy-
miarowego 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ą, przynajm-
niej z implementacyjnego punktu widzenia, jest konwolucja separowalna, w której splot dwu-
wymiarowy 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 poszcze-
gó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 przetwa-
rzaniu 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 czwo-
rokąt i poddajemy go działaniu shadera konwolucji. Tym samym operacja splotu jest przeprowa-
dzana 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 aktu-
alnej 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 Macierz jądra


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
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 znaj-
duje 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."<<endl;
exit(EXIT_FAILURE);
} else {
cerr<<"FBO ustawiony pomyslnie."<<endl;
}
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);

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 oddziel-
nego 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 usta-
wiamy 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 trze-
ciego, gdzie są zapisane rezultaty wygładzania pionowego. Wtedy uruchamiamy shader z roz-
mywaniem 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” opi-
sanej 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 czer-
wone, 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 kla-
sycznego 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 wska-


zó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 wer-
sji 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.xyz-
vEyeSpacePosition.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ść transpono-
wanej macierzy modelu i widoku gwarantuje, że będą tylko obracane, a ich jednostkowa długość pozo-
stanie bez zmiany.

Następnie wyznaczamy wektor łączący źródło światła z wierzchołkiem w przestrzeni oka i obli-
czamy jego iloczyn skalarny z wektorem normalnym rozpatrywanego wierzchołka. W ten spo-
só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 vEyeSpaceLightPosition = (MV * vec4(light_position,1)).xyz;
vec3 N = normalize(vEyeSpaceNormal);
vec3 L = normalize(vEyeSpaceLightPosition-vEyeSpacePosition);
vec3 V = normalize(vEyeSpaceCameraPosition.xyzv-EyeSpacePosition.xyz);
vec3 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 kieru-
nek, 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ą kieru-
nek padania promieni.

Przygotowania
Przy opracowywaniu kodu dla tej receptury będziemy bazować w dużej mierze na tym, co stwo-
rzyliśmy w ramach przykładowej implementacji oświetlenia punktowego na poziomie fragmen-
tó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 imple-
mentacji światła punktowego na poziomie fragmentów, tak jak to robiliśmy w recepturze „Imple-
mentacja 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 pozio-
mie fragmentów”, z tym że dodatkowo przeprowadzane są tu obliczenia związane z zanika-
niem ś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(spotPositionOS-
lightPosOS,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. Podob-
nie 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 kie-
runek rozchodzenia się światła (V=-L). Następnie sprawdzamy, czy kąt między kierunkiem reflek-
tora 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 sce-
nie. 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 wsta-
wimy 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."<<endl;
} else {
cout<<"Jest problem z ustawieniem FBO."<<endl;
}
glBindFramebuffer(GL_FRAMEBUFFER,0);
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ć zapi-
sywanie do bufora koloru (glDrawBuffer(GL_NONE)), a następnie włączyć je dla zwykłego ren-
deringu. 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 mat4 MVP; //macierz rzutowania, modelu i widoku


uniform mat4 MV; //macierz modelu i widoku
uniform mat4 M; //macierz modelu
uniform mat3 N; //macierz normalna
uniform mat4 S; //macierz cienia

132
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 zawi-
jania 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 widocz-
nych 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 tek-
stury 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 przeszukiwa-
niem mapy cienia sprawdzamy wartość współrzędnej w dla tekstury rzutowanej i przeprowa-
dzamy 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 aliasingo-
we wynikające z ograniczonej rozdzielczości mapy cienia. Poza tym cienie utworzone tą tech-
niką 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 frag-
ment 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, ivec2(-2,-2));
sum += textureProjOffset(shadowMap, vShadowCoords, ivec2(-2, 0));
sum += textureProjOffset(shadowMap, vShadowCoords, ivec2(-2, 2));
sum += textureProjOffset(shadowMap, vShadowCoords, ivec2( 0,-2));
sum += textureProjOffset(shadowMap, vShadowCoords, ivec2( 0, 0));
sum += textureProjOffset(shadowMap, vShadowCoords, ivec2( 0, 2));
sum += textureProjOffset(shadowMap, vShadowCoords, ivec2( 2,-2));
sum += textureProjOffset(shadowMap, vShadowCoords, ivec2( 2, 0));
sum += textureProjOffset(shadowMap, vShadowCoords, ivec2( 2, 2));
float shadow = sum/9.0;
diffuse = mix(diffuse, diffuse*shadow, 0.5);
}

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 wskazu-
jemy kolejne próbki, aż całe otoczenie w obszarze 3×3 zostanie sprawdzone. Następnie uśred-
niamy 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ób-
kowania warstwowego w obszarze 3×3), STRATIFIED_5x5 (do próbkowania warstwowego w obsza-
rze 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 filtrowa-


nia 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 frag-
mentu 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 obli-
czamy prawdopodobieństwo zacienienia rozpatrywanej próbki i porównujemy je z prawdopodo-
bień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."<<endl;
} else {
cout<<"Problem z ustawieniem FBO."<<endl;
}
glBindFramebuffer(GL_FRAMEBUFFER,0);

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]);
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);
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);
glFramebufferTexture2D(GL_FRAMEBUFFER,GL_COLOR_ATTACHMENT0+i,
GL_TEXTURE_2D,blurTexID[i],0);
}

142
Rozdział 4. • Światła i cienie

status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if(status == GL_FRAMEBUFFER_COMPLETE) {
cout<<"Ustawienie filtrujacego FBO powiodlo sie."<<endl;
} else {
cout<<"Problem ustawieniem filtrujacego FBO."<<endl;
}
glBindFramebuffer(GL_FRAMEBUFFER,0);
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, korzy-
stając z nierówności Czebyszewa, wyznaczamy prawdopodobieństwo, że fragment o określo-
nej 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 fragmen-
tó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 VarianceSha-
dowMap.vert i VarianceShadowMap.frag w folderze Rozdział4/WariancyjneMapowanieCieni/

145
OpenGL. Receptury dla programisty

shadery). Shader wierzchołków mapowania wariancyjnego (VarianceShadowMap.vert) wypro-


wadza 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ą jedno-
rodną 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 prze-


chowują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 gene-
rowanie 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 filtro-
waniem 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 specy-
ficznych 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 pierw-
szych 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 forma-
tach. 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 zaim-
plementować 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 recep-
turze „Wykonanie deformatora siatki przy użyciu shadera wierzchołków”. Pełny kod modelo-
wania 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<TERRAIN_DEPTH;j++) {
for( i=0;i<TERRAIN_WIDTH;i++) {
vertices[count]=glm::vec3((float(i)/(TERRAIN_WIDTH-1)), 0,
(float(j)/(TERRAIN_DEPTH-1)));
count++;
}
}
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 wierz-
chołków o wartość pobraną z mapy wysokości. Do tworzenia takich map służą rozmaite pro-
gramy. 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 wir-
tualnych 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"<<endl;
exit(EXIT_FAILURE);
}
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;k<materials.size();k++) {
for(size_t m=0;m< materials[k]->textureMaps.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()<<endl;
exit(EXIT_FAILURE);
}
//odwrócenie obrazu w osi Y
int i,j;

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<GLushort*>(glMapBuffer(GL_ELEMENT_ARRAY_BUFFER,
GL_WRITE_ONLY));
for(size_t i=0;i<faces.size();i++) {
*(pIndices++)=faces[i].a;
*(pIndices++)=faces[i].b;
*(pIndices++)=faces[i].c;
}
glUnmapBuffer(GL_ELEMENT_ARRAY_BUFFER);
}
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;i<materials.size();i++) {
GLint whichID[1];
glGetIntegerv(GL_TEXTURE_BINDING_2D, whichID);
if(materials[i]->textureMaps.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<char*>(&chunk_id), 2);
infile.read(reinterpret_cast<char*>(&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<char*>(&total_vertices), 2);
pMesh->vertices.resize(total_vertices);
infile.read(reinterpret_cast<char*>(&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<char*>(&total_tris), 2);
pMesh->faces.resize(total_tris);
infile.read(reinterpret_cast<char*>(&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;i<total;i++) {
if(materials[i]->face_ids.size()==0)
materials.erase(materials.begin()+i);
}
for(size_t i=0;i<meshes.size();i++) {
for(size_t j=0;j<meshes[i]->vertices.size();j++)

163
OpenGL. Receptury dla programisty

vertices.push_back(meshes[i]->vertices[j]);

for(size_t j=0;j<meshes[i]->uvs.size();j++)
uvs.push_back(meshes[i]->uvs[j]);

for(size_t j=0;j<meshes[i]->faces.size();j++) {
faces.push_back(meshes[i]->faces[j]);
}
}

Zauważ, że w formacie 3ds nie ma wprost zapisanych wektorów normalnych dla poszczegól-
nych 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<faces.size();j++) {
Face f = faces[j];
glm::vec3 v0 = vertices[f.a];
glm::vec3 v1 = vertices[f.b];
glm::vec3 v2 = vertices[f.c];
glm::vec3 e1 = v1 - v0;
glm::vec3 e2 = v2 - v0;
glm::vec3 N = glm::cross(e1,e2);
normals[f.a] += N;
normals[f.b] += N;
normals[f.c] += N;
}
for(size_t i=0;i<normals.size();i++) {
normals[i]=glm::normalize(normals[i]);
}

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 mate-
riały i poszerzamy ich identyfikatory ściankowe o trzy identyfikatory wierzchołków dla każdej
ścianki.
for(size_t i=0;i<materials.size();i++) {
Material* pMat = materials[i];
for(int j=0;j<pMat->face_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 wykorzy-
stuje kolor światła rozpraszanego.

Zaprezentowana przeglądarka plików 3ds nie uwzględnia wcale grup wygładzania. Zaintere-
sowanym 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
W tej recepturze zaimplementujemy wczytywanie modeli zapisanych w formacie OBJ opra-
cowanym 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.

Przygotowania
Gotowy kod dla tej receptury znajduje się w folderze Rozdział5/PrzeglądarkaOBJ.

Jak to zrobić?
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"<<endl;
exit(EXIT_FAILURE);
}
2. Dla każdego materiału, jeśli zawiera mapę tekstury, wygeneruj obiekt teksturowy
OpenGL za pomocą biblioteki SOIL.
for(size_t k=0;k<materials.size();k++) {
if(materials[k]->map_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: "<<full_filename.c_str()<<endl;
exit(EXIT_FAILURE);
}
//… kod przerzucania obrazów
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);
textures.push_back(id);
}
}
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;i<materials.size();i++) {
Material* pMat = materials[i];
if(pMat->map_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 deskrypto-
rami 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 zmien-
noprzecinkowe 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, cztere-


ma (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, rozpra-
szanego 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 tra-
fiają 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 znaj-
dziesz 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ła-
dować 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 przeciw-
nym 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 prostopa-
dł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 przypad-
kach 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ętrz-
nych. 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 otwar-
ciu 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. Sko-
piować 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"<<endl;
exit(EXIT_FAILURE);
}
2. Wykorzystując informacje materiałowe, wygeneruj tekstury OpenGL dla geometrii
EZMesh.
for(size_t k=0;k<materialNames.size();k++) {
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);

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;i<submeshes.size();i++) {
if(strlen(submeshes[i].materialName)>0) {
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 pobra-
nych 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:
<MeshSystem>
<Skeletons count="N">
<Animations count="N">
<Materials count="N">
<Meshes count="N">
</MeshSystem>

174
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe

Na razie interesować nas będą tylko dwa ostatnie elementy, czyli Materials i Meshes. Pozo-
stał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;i<totalMaterials;i++) {
std::string name = material.attribute("name").value();
std::string metadata = material.attribute("meta_data").value();
//czyszczenie metadanych
int len = metadata.length();
if(len>0) {
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. Rezul-
tatem 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 repre-
zentacją 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;i<ms->mMeshCount;i++) {
NVSHARE::Mesh* pMesh = ms->mMeshes[i];
vertices.resize(pMesh->mVertexCount);
for(size_t j=0;j<pMesh->mVertexCount;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;j<pMesh->mSubMeshCount;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 umiesz-
czamy 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<materialNames.size();k++) {
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;

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: "<<full_filename.c_str()<<endl;
exit(EXIT_FAILURE);
}
//… Odwracanie obrazu w pionie i określanie jego formatu
glTexImage2D(GL_TEXTURE_2D, 0, format, texture_width,
texture_height, 0, format, GL_UNSIGNED_BYTE, pData);
SOIL_free_image_data(pData);
materialMap[filename] = id;
}

Po materiałach wczytujemy shadery, tak jak w poprzednich recepturach. Następnie przeno-


simy do GPU dane wierzchołkowe, używając do tego celu obiektów tablicy i bufora wierz-
choł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;i<submeshes.size();i++) {
if(strlen(submeshes[i].materialName)>0) {
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 prze-
suwanie 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 przechowu-
jemy 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ą jedno-
znacznie 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 kinematycz-
nego 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że-
nia w przestrzeni przycięcia.
vSmoothColor = vec4(mix(RED,YELLOW,alpha),alpha);
gl_Position = MVP*vec4(pos,1);
}

182
Rozdział 5. • Formaty modeli siatkowych i systemy cząsteczkowe

Shader fragmentów po prostu przydziela bieżącemu fragmentowi kolor vSmoothColor otrzy-


many 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: "<<texture_filename.c_str()<<endl;
exit(EXIT_FAILURE);
}
//Odwracanie obrazu w pionie
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;
}
}

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 teksturo-
wanych. 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 modyfi-
kację 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 za-
danym 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, zaj-
rzyj 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 reali-
stycznie. 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 interak-
tywnej, 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 wyko-
rzystuje 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ład-


nego 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ść obli-
czeniowa całego procesu. Żeby te problemy ominąć, zastosujemy techniki renderowania prze-
zroczystoś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 imple-
mentacje 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 nieprze-
zroczyste, 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 po-
przedniego 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 para-
metr, 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 testo-
wanie 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ółprzezro-
czystych 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 zie-
lona 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 roz-
wią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 Transpa-
rency 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 pod-
stawie 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 pozaekrano-


wej tekstury, stosując FBO. Używamy dwóch FBO ― jednego do przechowywania głębi i nor-
malnych 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 gaussow-
skiego 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ą zapi-
sywane 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 uru-
chamiany 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 rezul-
tat łą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;i<materials.size();i++) {
Material* pMat = materials[i];
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]));
}
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) przeka-


zuje 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ła-
skim 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 wci-
skanie 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 obli-
czania dwukierunkowej funkcji rozkładu odbić (bi-directional reflectance distribution func-
tion — 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 pod-
stawie obrazów HDR/RGBE.

Format RGBE wynalazł Greg Ward. Obrazy tego typu przeznaczają trzy bajty na przechowy-
wanie 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 przy-
kł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 float C1 = 0.429043;


const float C2 = 0.511664;
const float C3 = 0.743125;
const float C4 = 0.886227;
const float C5 = 0.247708;
const float PI = 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 zawie-
rającego informacje oświetleniowe. Zastosowana aproksymacja umożliwia dość wierne odtwo-
rzenie 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 roz-


proszenia przy użyciu współczynników harmonik sferycznych. Do tego można dodać konwen-
cjonalny model oświetlenia Blinna-Phonga — trzeba tylko wyznaczyć rozkład światła na pod-
stawie 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 zaimple-
mentujemy 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 rende-
rowane 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<unsigned short> indices2;
vector<glm::vec3> vertices2;
if(!obj.Load(mesh_filename.c_str(), meshes, vertices, indices, materials,
aabb, vertices2, indices2)) {
cout<<"Nie moge wczytac siatki 3D"<<endl;
exit(EXIT_FAILURE);
}
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;k<materials.size();k++) {
if(materials[k]->map_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: "<<full_filename.c_str()<<endl;
exit(EXIT_FAILURE);
}
//odwróć obraz i ustal jego format
if(k==0) {
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, format, texture_width,
texture_height, total, 0, format, GL_UNSIGNED_BYTE, NULL);
}
glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0,0,0,k, texture_width,
texture_height, 1, format, GL_UNSIGNED_BYTE, pData);
SOIL_free_image_data(pData);
}
}

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<vertices2.size();i++) {
pData[count++] = vertices2[i].x;
pData[count++] = vertices2[i].y;
pData[count++] = vertices2[i].z;
pData[count++] = 0;
}
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, vertices2.size(),1, 0,
GL_RGBA, GL_FLOAT, pData);
delete [] pData;
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<indices2.size();i+=4) {
pData2[count++] = (indices2[i]);
pData2[count++] = (indices2[i+1]);
pData2[count++] = (indices2[i+2]);
pData2[count++] = (indices2[i+3]);
}
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16I, indices2.size()/4, 1, 0,
GL_RGBA_INTEGER, GL_UNSIGNED_SHORT, pData2);
delete [] pData2;
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 przy-
kł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 najmniej-
sza. 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 war-
tość parametru t.
vec4 val=vec4(t,0,0,0);
vec3 N;
for(int i=0;i<int(TRIANGLE_TEXTURE_SIZE);i++)
{
vec3 normal;
vec4 res = intersectTriangle(eyeRay.origin, eyeRay.dir, i, normal);
if(res.x>0 && 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 wypusz-
czamy 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 rasteryza-
cją 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<vertices2.size();i++) {
pData[count++] = vertices2[i].x;
pData[count++] = vertices2[i].y;
pData[count++] = vertices2[i].z;
pData[count++] = 0;
}
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, vertices2.size(), 1, 0, GL_RGBA,
GL_FLOAT, pData);
delete [] pData;
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<indices2.size();i+=4) {
pData2[count++] = (indices2[i]);
pData2[count++] = (indices2[i+1]);
pData2[count++] = (indices2[i+2]);
pData2[count++] = (indices2[i+3]);
}
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16I, indices2.size()/4, 1, 0,
GL_RGBA_INTEGER, GL_UNSIGNED_SHORT, pData2);
delete [] pData2;

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<tNearFar.y ) {
t = tNearFar.y+1;
vec3 light = light_position + uniformlyRandomVector(time) *
0.1;
vFragColor = vec4(pathtrace(eyeRay.origin, eyeRay.dir, light,
t),1);
}

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.y<t)
t = tNearFar.y+1;
vec3 N;
vec4 val=vec4(t,0,0,0);
for(int i=0;i<int(TRIANGLE_TEXTURE_SIZE);i++)
{
vec3 normal;
vec4 res = intersectTriangle(origin, ray, i, normal);
if(res.x>0.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 frag-
mentu. Potem przenosimy punkt początkowy promienia do miejsca trafienia i zmieniamy kie-
runek 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ły-
wó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 wizu-
alizacji 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 roz-
powszechnione metody polegające na cięciu trójwymiarowej tekstury, jednoprzebiegowym rzu-
caniu 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łów-
kowoką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 przy-
kł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 wspoma-
gane sprzętowo mieszanie. Jako że wszystko to może być realizowane przez sprzęt rasteryzu-
jący, szybkość działania tej metody jest bardzo duża.

Pseudokod cięcia trójwymiarowej tekstury na płaty prostopadłe do kierunku patrzenia przed-


stawia 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<char*>(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 wyzna-
czane 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<min_dist)
min_dist = dist;
}
int max_dim = FindAbsMax(viewDir);
min_dist -= EPSILON;
max_dist += EPSILON;

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 prze-
strzeni obiektu (vPosition) przez połączoną macierz modelu, widoku i rzutowania (MVP). Obli-
cza też współrzędne tekstury 3D (vUV) dla danych wolumetrycznych. Stosujemy sześcian jed-
nostkowy, 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 wolume-
tryczne 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. Jed-
nak 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ć wieloprze-
biegowe 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óre-
go 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 zaku-
mulowanego 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łnoekra-
nowego 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 jednost-
kowego. 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ęd-


nych 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 frag-
mentó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 jednoprzebie-
gowym 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 wyzna-
czamy 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ą naj-
bliż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 wolu-
metryczne 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. Gaus-
sowskie jądro wygładzające usuwa wyższe częstotliwości i wygładza krawędzie, przez co wyren-
derowany 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<char*>(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<ZDIM;z+=dz) {
for(int y=0;y<YDIM;y+=dy) {
for(int x=0;x<XDIM;x+=dx) {
SampleVoxel(x,y,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."<<endl;
} else {
cout<<"Problem z ustawieniem filtrujacego FBO."<<endl;
}
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."<<endl;
} else {
cout<<" Problem z ustawieniem pozaekranowego FBO."<<endl;
}
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 odpo-
wiednie 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 wierz-
choł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ść wyzna-
czają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."<<std::endl;

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 filtrowa-
niu 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 zeskanowa-
nego 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 ósem-
kowego, 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. Klasy-
fikacja 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 kla-
syfikację 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<indices[j+1];i++)
{
pData[i][0] = (pData[i-1][0] + dDataIncR);
pData[i][1] = (pData[i-1][1] + dDataIncG);
pData[i][2] = (pData[i-1][2] + dDataIncB);

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 pobie-
ranie z niej kolorów w shaderze fragmentów. Obie są dość łatwe do zrozumienia. Pierwszą roz-
poczynamy 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łą inter-
polację. Po prostu najpierw wyznaczamy różnicę między wartościami sąsiednich kolorów pod-
stawowych 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 zbudo-
wana 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 przecze-


sywaniu całego zbioru danych wolumetrycznych i wstawianiu określonych wielokątów w miej-
scach 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<char*>(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<ZDIM;z+=dz) {
for(int y=0;y<YDIM;y+=dy) {
for(int x=0;x<XDIM;x+=dx) {
SampleVoxel(x,y,z, scale);
}
}
}
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<<i;
}
edgeFlags = aiCubeEdgeFlags[flagIndex];

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<<i))
{
float offset = GetOffset(cubeCornerValues[a2iEdgeConnection[i][0] ],
cubeCornerValues[ a2iEdgeConnection[i][1] ]);
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 );
}
}
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 wolume-
trycznym 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 genero-
wany jest specjalny indeks znakujący. Za pomocą tego indeksu wybierany jest z tablicy prze-
glądowej indeks krawędziowy, a ten z kolei pozwala wybrać jedną z predefiniowanych konfigu-
racji 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 wyzna-
czane 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<<i;
}
edgeFlags = aiCubeEdgeFlags[flagIndex];

Wierzchołki i normalne wielokąta odpowiadające danemu indeksowi są obliczane na podsta-


wie danych pobranych z tablicy połączeń krawędziowych (a2iEdgeConnection) i umieszczane
w tablicach lokalnych.
for(i = 0; i < 12; i++) {
if(edgeFlags & (1<<i)) {
float offset = GetOffset(cubeCornerValues[a2iEdgeConnection[i][0] ],
cubeCornerValues[a2iEdgeConnection[i][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 wierz-


choł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 pod-
stawie 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ą trian-
gulację 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 prosto-
padł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 imple-
mentacji 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<char*>(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<min_dist)
min_dist = dist;
}
//reszta funkcji SliceVolume jak w recepturze ze zwykłym cięciem tekstury 3D
//zmienia się tylko viewVec na halfVec
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<num_slices;i++) {
shaderShadow.Use();
glUniformMatrix4fv(shaderShadow("MVP"), 1, GL_FALSE,
glm::value_ptr(MVP));
glUniformMatrix4fv(shaderShadow("S"), 1, GL_FALSE, glm::value_ptr(S));
glBindTexture(GL_TEXTURE_2D, lightBuffer);
DrawSliceFromEyePointOfView(i);

shader.Use();
glUniformMatrix4fv(shader("MVP"), 1, GL_FALSE,
glm::value_ptr(P_L*MV_L));
DrawSliceFromLightPointOfView(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 sto-
sunku 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żywa-
liś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ół rende-
rowanego 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 symulowa-
nych 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ą wirtu-
alne 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 trans-
formacje 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 skin-
ningiem 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 zapisywa-
nia animacji szkieletowych. Bardziej znane formaty, takie jak COLLADA czy FBX, są niepo-
trzebnie 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"<<endl;
exit(EXIT_FAILURE);
}

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;i<pSkel->GetBoneCount();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<skeleton.size();i++) {
bindPose[i] = (skeleton[i].comb);
invBindPose[i] = glm::inverse(bindPose[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(&current);
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<skeleton.size();i++) {
skeleton[i].comb = bindPose[i];
animatedXform[i] = skeleton[i].comb*invBindPose[i];
}
}
else {

272
Rozdział 8. • Animacje szkieletowe i symulacje fizyczne na GPU

for(int j=0;j<pAnim->mTrackCount;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 rozma-
itymi 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 poszcze-
gó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 ska-
lowania. 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 repre-
zentuje transformację względną, która jest obliczana podczas wczytywania danych szkieleto-
wych z pliku EZMesh.

Pole name zawiera unikatową dla całego szkieletu nazwę kości. Ostatnie pole, parent, przecho-
wuje 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 prze-
dział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<skeleton.size();i++) {
Bone& b = skeleton[i];
if(b.parent==-1)
b.comb = b.xform;
else
b.comb = skeleton[b.parent].comb * b.xform;
}

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ć odwrot-
ności pozy wiązania w każdej klatce animacji.

Jeśli zamierzamy poddać szkielet nowej transformacji (np. sekwencji animacyjnej), musimy naj-
pierw cofnąć transformację pozy wiązania. W tym celu trzeba pomnożyć animowaną trans-
formację 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 transfor-
macje względne kości przez wykonanie sekwencji animacyjnej. W tej sytuacji możemy wyge-
nerować 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="<<skeleton.size()<<";"<<endl;
str<<"uniform mat4 Bones[NUM_BONES];"<<endl;
shader.LoadFromFile(GL_VERTEX_SHADER, "shadery/shader.vert", str.str());
shader.LoadFromFile(GL_FRAGMENT_SHADER, "shadery/shader.frag");

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 zdefi-


niowana 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 pro-


gram 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;i<submeshes.size();i++) {
if(strlen(submeshes[i].materialName)>0) {
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 recep-
turze „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 zatrzymy-
wania 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 przesu-
nię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 kwater-
nionami 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 "<<endl;
exit(EXIT_FAILURE); }
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<skeleton.size();i++) {
bindPose[i] = (skeleton[i].comb);
invBindPose[i] = glm::inverse(bindPose[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 = 1.0;
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)

282
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 wierz-
choł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ę kwater-
nionó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 skinnin-
gowych. 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;

M[0][0] = w*w + x*x - y*y - z*z;


M[0][1] = 2 * x * y + 2 * w * z;
M[0][2] = 2 * x * z - 2 * w * y;
M[0][3] = 0;

M[1][0] = 2 * x * y - 2 * w * z;
M[1][1] = w * w + y * y - x * x - z * z;
M[1][2] = 2 * y * z + 2 * w * x;
M[1][3] = 0;

M[2][0] = 2 * x * z + 2 * w * y;
M[2][1] = 2 * y * z - 2 * w * x;
M[2][2] = w * w + z * z - x * x - y * y;
M[2][3] = 0;

M[3][0] = -2 * t0 * x + 2 * w * t1 - 2 * t2 * z + 2 * y * t3;
M[3][1] = -2 * t0 * y + 2 * t1 * z - 2 * x * t3 + 2 * w * t2;
M[3][2] = -2 * t0 * z + 2 * x * t2 + 2 * w * t3 - 2 * t1 * y;
M[3][3] = len2;

284
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ło-
wo, 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. Transfor-
macyjne 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 oblicze-
niach symulacyjnych i renderingu. Do generowania służy funkcja glGenTransformFeedbacks,
której trzeba podać liczbę generowanych obiektów i zmienną do zapisu zwracanych identyfi-
katorów. Po wygenerowaniu obiektu wiążemy go z bieżącym kontekstem OpenGL. W tym
celu wywołujemy funkcję glBindTransformFeedback, której jedynym parametrem jest identy-
fikator 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. Wymaga-
ne 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 zarejestro-
waniu atrybutów należy ponownie skonsolidować program shaderowy.

Oczywiście musimy też przygotować obiekty buforowe, które będą przyjmować atrybuty prze-
kazywane przez obiekt sprzężenia. Na etapie renderowania najpierw ustawiamy shader i nie-
zbę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 wierz-
choł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<GLushort> indices;
vector<glm::vec4> X;
vector<glm::vec4> X_last;
vector<glm::vec3> 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<NUM_ITER;i++) {
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_BUFFER, texPosID[writeID]);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_BUFFER, texPrePosID[writeID]);
glBindVertexArray(vaoUpdateID[writeID]);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, vboID_Pos[readID]);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 1,
vboID_PrePos[readID]);
glEnable(GL_RASTERIZER_DISCARD); // wyłącz rasteryzację
glBeginQuery(GL_TIME_ELAPSED,t_query);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, total_points);
glEndTransformFeedback();
glEndQuery(GL_TIME_ELAPSED);
glFlush();
glDisable(GL_RASTERIZER_DISCARD);
int tmp = readID;
readID=writeID;

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ą tra-
fiać dane wyjściowe z shadera wierzchołków. Wyłączamy rasteryzer, aby uniemożliwić wyko-
nanie 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 poprzed-
niego 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ą wskazu-
jemy 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 wszyst-
kich 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 zde-
rzył 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 wierz-


chołki, prowadząc do zdeformowania tkaniny.

I jeszcze jedno…
Aplikacja przykładowa renderuje animację kawałka tkaniny spadającego pod wpływem gra-
witacji. 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 poprzed-
niej 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 wykry-
wane 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 prze-


strzeni 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 zaimple-
mentowanie wykrywania kolizji z podstawowymi bryłami wielościennymi, ale to zadanie pozo-
stawiam 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<NUM_ITER;i++) {
glBindVertexArray( vaoUpdateID[readID]);
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 float DAMPING_COEFFICIENT = 0.9995;


const vec3 emitterForce = vec3(0.0f,-0.001f, 0.0f);
const vec4 collidor = vec4(0,1,0,0);
const vec3 emitterPos = vec3(0);

float emitterYaw = (0.0f);


float emitterYawVar = TWO_PI;
float emitterPitch = PI_BY_2;
float emitterPitchVar = PI_BY_4;
float emitterSpeed = 0.05f;
float 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. Atry-
buty 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ów-
nież w jednym obiekcie buforowym z przeplotem. Ponieważ bufory te będą nie tylko odczy-
tywane, 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 poprzed-
nim 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 prze-


biegu najpierw wiążemy aktualizacyjny obiekt tablicy wierzchołków i podłączamy odpowiednie
bufory sprzężenia zwrotnego.
for(int i=0;i<NUM_ITER;i++) {
glBindVertexArray( vaoUpdateID[readID]);
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]);

Potem wyłączamy rasteryzer i włączamy tryb sprzężenia zwrotnego. Wtedy dopiero wywołu-
jemy funkcję glDrawArrays, aby przekazać wierzchołki do shadera. Gdy shader zakończy pra-
cę, 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 atry-
butó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ą prze-
szkody. Zmniejszamy też prędkość odbitej cząstki. Po obsłużeniu ewentualnej kolizji modyfiku-
jemy 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ęd-
kość) 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 nie-
dostę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 bind pose, Patrz: poza wiązania


Blinna-Phonga model, Patrz: model
algorytm Blinna-Phonga
cięcia BRDF, 210
połówkowokątowego, 262, 266 bryła widzenia, 77
tekstury 3D na płaty, 228, 229, 232, 252, bufor
253 głębi, 25, 79, 80
maszerującego czworościanu, 228, 262 wartość czyszcząca, 25
maszerujących sześcianów, 228, 256, 262 koloru, 25, 83
animacja, 174 wartość czyszcząca, 25
liczba klatek w ciągu sekundy, 78 ramki, 101
poklatkowa, 269 renderingu, 97
szkieletowa, 171, 174 selekcji, 79
z paletą macierzy skinningowych, 270
ze skinningiem przy użyciu kwaternionu
dualnego, 280, 283
C
Animation, Patrz: animacja cieniowanie, 18
fragmentów, 115, 116
B Gourauda, 116
Phonga, 116
biblioteka wierzchołków, 115, 116
freeglut, 18, 22, 44 cień, 115
GLEW, 19, 22 bryła, 130
inicjalizacja, 24 mapa, 130, 131
glm, 44 mapowanie, Patrz: mapowanie cieni
GLUT, 18 clip space, Patrz: przestrzeń przycięcia
MeshImport, 172, 175, 178 compatibility profile, Patrz: profil zgodnościowy
OpenGL, 57 core profile, Patrz: profil rdzenny
pugixml, 172 cube mapping, Patrz: mapowanie sześcienne
SOIL, 19, 57, 60, 152 czas, 44, 181
bi-directional reflectance distribution function, cząsteczki, 182
Patrz: BRDF
OpenGL. Receptury dla programisty

cząsteczka glBindTextures, 61
czas, Patrz: czas cząsteczki glBufferData, 36
generowanie położenia, 181 glCheckFramebufferStatus, 98
GL_POINT_SPRITE, Patrz: sprajt glClearDepth, 25
GL_POINTS, 181 glDrawArrays, 181
prędkość początkowa, 182 glDrawElement, 62
Czebyszewa nierówność, 144 glDrawElements, 53
glDrawElementsInstanced, 53, 56
glDrawTransformFeedback, 288
D glGenerateMipMap, 230
depth peeling, Patrz: peeling głębi glGenTextures, 61
dithering, Patrz: roztrząsanie kolorów glGenTransformFeedbacks, 287
drzewo glGetError, 30
bsp, 74 glMatrixMode, 23
czwórkowe, 74 glNormal, 23
kd, 225 glPolygonMode, 45
ósemkowe, 74 glReadPixels, 83
dym, 178, 228 glRotate, 23
dyrektywa using, 22 glScale, 23
glTexCoord, 23
glTexImage2D, 61
E glTransformFeedbackVaryings, 287
glTranslate, 23
efekt
gluInit, 23
postprodukcyjny, 90 glutInitContextVersion, 23
poświaty, Patrz: poświata glutMotionFunc, 44
glutPostRedisplay, 53
F glutSwapBuffers, 25, 62
glVertex, 23
FBO, 89, 97, 102, 130, 191, 204 glVertexAttribIPointer, 277
filtr glVertexAttribPointer, 35
gaussowski, 145, 245 intersectBox, 87
PCF, 136, 137, 138, 139, 148 LoadFromString, 29
wirowy, 90, 92 main, 23
format NVSHARE::loadMeshImporters, 175
3ds, 151, 156, 157, 161, 164, 165 ObjLoader::Load, 166, 168
Collada, 171, 270 OnInit, 23, 24, 33
EZMesh, 171, 174, 270 OnKey, 52
FBX, 171, 270 OnMouseDown, 44, 45
md2, 171 OnRender, 23, 56
OBJ, 151, 166, 168, 171 OnResize, 23, 44
RGBE, 210 OnShutdown, 23, 24, 36, 62
funkcja PointInFrustum, 77
C3dsLoader::Load3DS, 157, 161 przejścia, 252, 254, 255
CreateAndLinkProgram, 29 rozkładu odbić dwukierunkowa, Patrz: BRDF
EmitVertex, 51 SOIL_load_image, 60
EndPrimitive, 51 textureProj, 134, 135
EzmLoader::Load, 172, 174, 175, 271 textureProjOffset, 137, 138
glBeginTransformFeedback, 287 uniformRandomDir, 182
glBindBuffer, 36 zwrotna, 23

312
Skorowidz

G L
generator pseudolosowy, 182 LBS, 270
gimbal lock, 280 linear blend skinning, Patrz: LBS
Gourauda cieniowanie, 116
grawitacja, 292, 294, 295, 297
M
H macierz, 19
cienia, 131, 135
harmonika sferyczna, Patrz: oświetlenie kierunku, 69
harmonika sferyczna kości finalna, Patrz: macierz skinningu
mnożenie, 44
modelu instancyjna, 54
I modelu i widoku, 30, 44
izopowierzchnia, 241, 242, 244, 255, 259 dla światła, 134, 135
wydzielanie, 255 normalna, 118
przekształcenia
modelu, 29
K widoku, 29, 30
kamera przesunięcia, 134
sterowanie za pomocą rzutowania, 44, 64
klawiatury, 65 światła, 134
myszy, 65, 67 skinningowa, 270, 272, 273, 275, 281
swobodna, 64, 68, 69, 70 widoku, 29, 30, 63
wycelowana, 64, 70, 71, 72 makro GL_CHECK_ERRORS, 30
kanał mapa
alfa, 25 cienia, 146
RGB, 25 materiału, 175
kierunek patrzenia, 63 transformacji, 152
klasa wysokości, 152
C3dsLoader, 157 mapowanie
CAbstractCamera, 64 cieni, 130, 134
CFreeCamera, 68 wariacyjne, 141, 144, 147, 148, 149
GLSLShader, 26, 27, 28, 29 z filtrowaniem PCF, 136, 137, 138, 139, 148
VolumeSplatter, 247 FBO, 130
klawiatura, 52 na podstawie testu głębi, 115
kolizji wykrywanie, Patrz: wykrywanie kolizji sześcienne, 93
konwolucja, Patrz: splot dynamiczne, 90, 93, 101, 103
kopuła nieba, 93 wariancyjne, 115
kość, 270 marching tetrahedra, Patrz: algorytm
nadrzędna, 271, 274 maszerującego czworościanu
nazwa, 274 Material, Patrz: materiał
podrzędna materiał, 174, 175
orientacja względem kości nadrzędnej, 274 mapa, Patrz: mapa materiału
transformacja, 271 Mesh, Patrz: siatka
kwaternion, 280 metoda Monte Carlo, 221
dualny, 280, 281, 283

313
OpenGL. Receptury dla programisty

mieszanie rozmycie, 106, 108


alfa, Patrz: kanał alfa gaussowskie, 108, Patrz też: filtr gaussowski
liniowe, Patrz: skinning mieszanie liniowe splot, Patrz: splot
sferyczne, Patrz: skinning mieszanie wyostrzenie, 106, 108
sferyczne wytłoczenie, 106, 108
mipmapa, 229, 230 odbicia, 90
model ogień, 178
Blinna-Phonga, 119, 215 okluzja
siatkowy, 151 obliczanie, 204, 205, 207, 208
modelowanie otoczenia w przestrzeni ekranu, Patrz: SSAO
terenu, 152, 153, 154, 156 oświetlenie, 115
fraktalowe, 155 absorpcja, 262
metoda proceduralna, 155 globalne, 189, 203, 210
metoda szumowa, 155 harmonika sferyczna, 189, 210, 211, 213, 215
tkaniny, 287, 289, 293, 294, 296, 297, 299, 300 kod, 213
modelview matrix, Patrz: macierz modelu i widoku kierunkowe, 115, 122, 123, 124
mysz, 67 obliczenia, 116, 118, 119, 126
obraz HDR, 189
punktowe, 115, 116, 123
N zanikające, 124, 126, 127
nierówność Czebyszewa, 144 reflektorowe, 115, 128, 129
normalna, 116, 118, 164 kąt odcięcia, 129
tłumienie kątowe, 129
składowa odblaskowa, 119
O wolumetryczne, 262, 266
obiekt
bufora ramki, Patrz: FBO P
GLSLShader tworzenie, 28
o lustrzanej powierzchni, 97 panoramowanie, 70
przestrzeń, Patrz: przestrzeń obiektu pasek tytułowy, 78
przezroczysty, Patrz: przezroczystość PCF, Patrz: filtr PCF
std::map, 29 peeling
tablicy wierzchołków, Patrz: VAO głębi, 190, 194
ukrywanie, 64, 74 dualny, 190, 196, 197, 200
wskazywanie jednokierunkowy, 190, 194, 195
na ekranie monitora, 79, 80 warstwa, 192
na podstawie koloru, 83 wyłączenie, 196
na podstawie przecięć z promieniem oka, percent closer filtering, Patrz: filtr PCF
85, 86 Phonga cieniowanie, 116
obiekt bufora wierzchołków, Patrz: VBO PhysX sdk, 270
object space, Patrz: przestrzeń obiektu plik
obraz .dae, 171
deformacja, 90 .frag, 34
HDR, 189, 211 .geom, 34
HDR/RGBE, 210 .vert, 34
mieszanie, 111, 112 3ds, Patrz: format 3ds
na powierzchni kuli, 213 AbstractCamera.cpp, 64
przeglądarka, Patrz: przeglądarka obrazów AbstractCamera.h, 64
renderowanie, 62 EZMesh, 172, 174

314
Skorowidz

FreeCamera.cpp, 68 błędy, 236


FreeCamera.h, 68 z cięciem tekstury 3D na płaty, 228, 229,
GLSLShader.cpp, 27 232, 252
GLSLShader.h, 27 z jednoprzebiegowym rzucaniem
iostream, 22 promieni, 236, 237, 239, 240
main.cpp, 22 z użyciem splattingu, 245, 246, 251
płaszczyzna renderowanie
nieba, 93 pozaekranowe, 89
podział, 47 terenu, Patrz: modelowanie terenu
poświata, 90, 109, 111 rezonans magnetyczny, 229
potok, 18 roztrząsanie kolorów, 83
powierzchnia lustrzana, 97, 100, 101
poza wiązania, 274
profil
S
rdzenny, 18, 57 sampler tekstur, 29
zgodnościowy, 18, 19 screen space, Patrz: przestrzeń ekranu
promień screen space ambient occlusion, Patrz: SSAO
kroczący, 236, 237 shader, 18, 26
rzucanie, 236 atrybut, 29
prymityw, 47 zmieniający się, 33
przeglądarka obrazów, 57 ewaluacji teselacji, 26
przekształcenie, Patrz: transformacja falujący, 38
przestrzeń fragmentów, 26, 32, 33, 61, 90, 112, 117
ekranu, 30 pathtracer.frag, 223
obiektu, 30 raytracer.frag, 218
przycięcia, 30 geometrii, 26, 46, 47, 50, 51, 52, 75, 105
świata, 30 maksymalna liczba wierzchołków, 50
przezroczystość, 190, 197 kontroli teselacji, 26
konwolucji, 107
R uniform, 29
wiązanie, 29
Ratcliff John, 270 wierzchołków, 26, 32, 33, 38, 46, 61, 91,
ray tracing, Patrz: śledzenie promieni 104, 117
rendering shader binding, Patrz: shader wiązanie
do tekstury, Patrz: rendering pozaekranowy siatka, 38, 42, 174
instancyjny, 53, 54 generowanie topologii, 42
izopowierzchni, 228 siła grawitacji, 292, 294, 295, 297
krawędziowy, 260 Skeleton, Patrz: szkielet
objętościowy, Patrz: rendering skinning, 270, 274
wolumetryczny macierzowy, 277, 280
odroczony, 90 mieszanie liniowe, 270, 280
pozaekranowy, 101, 107, 109 sferyczne, 280
pseudoizopowierzchniowy z kwaternionem dualnym, 280
z jednoprzebiegowym rzucaniem promieni, skóra, 270
241, 244 sky dome, Patrz: kopuła nieba
tekstury głębi, 132 skybox, Patrz: sześcian nieba
warstwowy, 105 skyplane, Patrz: płaszczyzna nieba
wokseli, 250 splatting, 245, 246, 250, 251
wolumetryczny, 227

315
OpenGL. Receptury dla programisty

splot, 106, 107 transformacja


cyfrowego, 90 bezwzględna, Patrz: transformacja globalna
dwuwymiarowy, 106 globalna, 19, 274
jądro, 108, 109 kości względna, 271
jednowymiarowy, 106 mapa, Patrz: mapa transformacji
separowalny, 106, 112 rzutowania, 30
sprajt, 181
sprzężenie zwrotne transformacyjne, 287, 293, U
294, 301, 306, 307, 309
SSAO, 203, 207, 208, 209 ukrywanie elementów spoza bryły widzenia, 64,
system cząsteczkowy, 152, 178, 187, 188 74
z transformacyjnym sprzężeniem zwrotnym,
301, 306, 307, 309
sześcian nieba, 93, 96
V
szkielet, 174 VAO, 34, 247, 290
VBO, 247
vertex array object, Patrz: VAO
Ś view frustum culling, Patrz: ukrywanie
śledzenie elementów spoza bryły widzenia
promieni, 216, 217, 218, 219, 225
ścieżek, 221, 223, 225 W
Metropolis light transport, 225
świata przestrzeń, Patrz: przestrzeń świata waga wiązania, 270
światło, Patrz: oświetlenie Ward Greg, 210
wektor normalny, Patrz: normalna
wiązanie
T poza, Patrz: poza wiązania
tablica waga, Patrz: waga wiązania
GLubyte, 229 wireframe, Patrz: rendering krawędziowy
GLushort, 229 woksel, 245
tekstur dwuwymiarowych, 229 renderowanie, Patrz: rendering wokseli
tekstura World Machine, 155
filtrowanie liniowe, 138 world space, Patrz: przestrzeń świata
pozaekranowa, 103 wykrywanie kolizji, 296, 297, 299, 300, 301
shadowmap, 134
sześcienna, 102, 103, 104 Z
zawijanie, 108
Terragen, 155 zdarzenie klawiaturowe, 52
tomografia komputerowa, 229
transform feedback, Patrz: sprzężenie zwrotne Ź
transformacyjne
źródło światła, Patrz: oświetlenie

316

You might also like