Od pewnego czasu, zepchnąłem tworzenie frameworka na drugi albo i nawet trzeci plan na mojej liście priorytetów. Do tego stopnia, że prawie w ogóle nie notuję w kalendarzu żadnych
zapisków dotyczących dalszych pomysłów na rozwiązywanie problemów, a jak już, to nie określam dokładnie daty. Oczywiście, jako że nie jestem osobą o specjalnie silnej woli, dlatego programowanie frameworka zwykle kontynuuję każdego dnia. Sprawia mi to jednak tyle radości, że aż chce się łamać harmonogram. Mam też jednak nadzieję, że powoli, bo powoli dojdę do wszystkiego, co tak naprawdę przepisuję z tutoriali i kursów - bądź co bądź, ten powoli rozszerzający się projekt zwany Yestą nie jest obrazem moich umiejętności programistyczno-projektowych, choć się staram. Ech, ale przestaję marudzić - są inne tematy ku temu.
A w tej notce chciałbym się zająć pewnym efektem, który ostatnio spędzał mi sen z powiek i nadal spędza, choć już nieco mi się rozjaśniło. Mam jednak nadzieję, że opisując kolejne etapy powstawania Parallax Mappingu (bo o nim mowa) także mnie uda się zrozumieć proces. Wiem, że to herezja - pisanie sposobu działania czegoś, czego się do końca samego nie rozumie. Myślę jednak, że każdy coś z tego wyniesie - Czytelnicy pewną wiedzę dzięki kawałkom kodu źródłowego, a ja inną wiedzę dzięki staraniu się zamglenia mojej niewiedzy. A przy okazji wypróbuję mechanizm pisania ładnych kodów źródłowych z użyciem Windows Live Writera (dzięki gamer.cpp, pozdrawiam). Przepraszam zatem już na wstępie, jeśli układ strony będzie lekko niestrawny – dopiero się uczę obsługi tegoż narzędzia (a prezentuje się bardzo porządnie, jak na razie).
Może na sam początek przywołam definicję samego efektu z Wikipedii.
Parallax mapping (also called offset mapping or virtual displacement mapping) is an enhancement of the bump mapping or normal mapping techniques applied to textures in 3D rendering applications such as video games. To the end user, this means that textures such as stone walls will have more apparent depth and thus greater realism with less of an influence on the performance of the simulation. Parallax mapping was introduced by Tomomichi Kaneko et al in 2001.
Parallax mapping is implemented by displacing the texture coordinates at a point on the rendered polygon by a function of the view angle in tangent space (the angle relative to the surface normal) and the value of the height map at that point. At steeper view angles the texture coordinates are displaced more, and so give the illusion of depth due to parallax effects as the view changes.
Parallax mapping described by Kaneko is a single step process that does not account for occlusion. Subsequent enhancements have been made to the algorithm incorporating iterative approaches to allow for occlusion and accurate silhouette rendering.
Jak można wywnioskować, całość sprowadza się do takiego operowania współrzędnymi tekstury, aby całość dawała efekt głębi. Dlaczego zatem nazwa wzięła się od paralaksy? Odpowiednia strona na Wikipedii to poniekąd wyjaśnia - całość opiera się na wykorzystaniu wrażenia różnych odległości przedmiotu od oka na innych planach. Brzmi skomplikowanie, a moje wyjaśnienie pewnie jeszcze dodatkowo to komplikuje. Mam jednak nadzieję, że każdy chociaż intuicyjnie wie, o co chodzi. Natomiast jeśli ktoś ma tutaj jakieś uwagi - koniecznie proszę zawrzeć to w komentarzu, to przy okazji ja również zweryfikuję swoje refleksje.
W artykule będę opierał się na wersji przedstawionej na tej stronie . Z pewnych względów, przykład na UGP okazał się niewystarczający, jednak zamieszczam również ten odnośnik.
Ogólnie, według mnie, plan działania przedstawia się następująco na szalonym obrazku narysowanym w Paintcie:
Mając pozycję oka kamery, obliczamy kierunek wektora i jego pozycję padania na płaszczyznę. Wtedy następuje rzutowanie na podstawie mapy wysokości i obliczenie końcowej współrzędnej tekstury, która będzie się przesunięta o pewien offset w stosunku do "właściwej" pozycji. Brzmi nieskomplikowanie w sumie matematycznie - z podobnymi rzeczami mają do czynienia uczniowie liceum na lekcji fizyki w trzeciej klasie przy odbiciu promieni w cieczy i takich tam. Przyznam jednak, że nie lubiłem tych tematów - zawsze byłem bardziej matematykiem aniżeli fizykiem, ale jako że lubię się też wkopywać w różne dziwne rzeczy tylko po to, aby zdobyć potrzebną wiedzę i doświadczenie, to zająłem się grafiką.
Od razu też powiem, że istnieje nowsza, lepsza i bardziej realistyczna wersja tej techniki zwana Parallax Occlusion Mappingiem lub Steep Parallaxem. Mimo zwiększonej pamięciożerności wydaje się dużo lepszym wyborem dla nowoczesnych gier. Różni się wysyłaniem promienia i opieraniu się na parokrotnych obliczeniach w celu większego "displacingu" (przefałdowania, przemieszczenia). Jednocześnie może być wykorzystane do stworzenia efektu futra. Nieco później także się tym zajmę w oparciu o przykład ze strony przytoczonej wcześniej. Natomiast, jeśli chodzi o porównanie efektów mapowania wypukłosci, jest ono dokonane tutaj.
Zajmijmy się najpierw implementacją shaderów odpowiedzialnych za efekt. Proponuję na początek program do obróbki wierzchołków i jego szkielet na warsztat.
1: struct VertexIn
2: { 3: float4 Position : POSITION;
4: float2 TexCoord : TEXCOORD0;
5: float3 Normal : NORMAL;
6: float3 Tangent : COLOR;
7: };
8:
9: struct VertexOut
10: { 11: float4 Position : POSITION;
12: float2 TexCoord : TEXCOORD0;
13: float3 TanEyeVec : TEXCOORD1;
14: float3 TanLightVec : TEXCOORD2;
15: }
16:
17: VertexOut main (VertexIn In, uniform float4x4 ModelViewProj, uniform float4x4 WorldMatrix, uniform float3 EyePosition, uniform float3 LightPosition)
18: { 19: VertexOut Out;
20: //...
21: }
Widać dokładnie, jakie wartości rejestrów będziemy potrzebować i jakie umieścimy w nich po zakończeniu działania vertex shadera. Wartości, które musimy podać “ręcznie” to kolejno: macierz projekcji i modelowania (ModelViewProj), odrócona macierz modelowania (WorldMatrix), pozycja oka kamery (EyePosition) oraz pozycję źródła światła (LightPosition). Od razu przestrzegę też przed jedną rzeczą, która zdarzyła mi się przy formatowaniu fragment shadera – jeśli podamy czteroelementową tablicę dla argumentu, który jest zdefiniowany jako float3, to w większości przypadków nie dostaniemy dobrych wyników. Niby oczywiste, ale tym sposobem męczyłem się z współczynnikiem światła odbijającego (specular). Zatem szkielet pierwszego programu mamy. Dodajmy zatem funkcjonalność w funkcji main (czyli w miejscu komentarza jednolinijkowego z metaforycznym przesłaniem “…”. Ale po kolei – najpierw podstawowe obliczenia, które nawet ja rozumiem. Czyli zaliczyłem pierwszy sukces.
1: Out.Position = mul(ModelViewProj, In.Position);
2: Out.TexCoord = In.TexCoord;
3:
4: float4 eyePosition = mul(WorldMatrix, float4(EyePosition.x, EyePosition.y, EyePosition.z, 1));
5: eyePosition /= eyePosition.w;
6:
7: float4 objectLightPosition = mul(WorldMatrix, float4(LightPosition.x, LightPosition.y, LightPosition.z, 1.0f));
8: objectLightPosition *= objectLightPosition.w;
Obliczenie pozycji wierzchołka oraz współrzędnej tekstury – w miarę proste. To drugie przyjmujemy bez zmian, natomiast pozycję wierzchołka obliczamy mnożąc wejściową wartość przez macierz modelowania i projekcji. Następnie w dosyć mętnie wyglądający sposób obliczamy wartości wektorów oka kamery i pozycji światła. Ktoś zapyta “po co obliczamy to jeszcze raz, skoro podajemy to na wejściu?”. Pewnie, że moglibyśmy dalej ciągnąć program na argumentach, ale są przynajmniej trzy powody, dlaczego tak się nie dzieje. Po pierwsze – przejrzystość kodu. Jakoś generalnie nie spotykam się z shaderami, które operują dalej na argumentach podanych “ręcznie” (uniformach), więc musi być w tym jakaś logika. Drugi powód wynika po części z trzeciego i zakłada fakt, że operujemy na macierzach 4x4 oraz wektorach o czterech współrzędnych. Klasę CLighting oparłem jednakże na argumentach trójelementowych, zatem należy utworzyć nowe wektory, identyczne z podanymi wraz z czwartym elementem 1, przez który normalizacja wygląda dosyć dziwnie, ale formalnie rzecz biorąc – jest konieczna. Trzeci powód to taki, że musimy przemnożyć te wektory przez macierz świata (nie wiem, czy nie popełnię teraz herezji, ale to się chyba fachowo określa “przejść na płaszczyznę tangensową (tangent space)” – tutaj proszę o skorygowanie mnie, jeśli się mylę). Zatem, posiadając już przetransformowane wektory, możemy przejść dalej, do ciekawszych obliczeń.
1: float3 tangent = In.Tangent;
2: float3 binormal = cross(In.Normal, tangent);
3:
4: float3 eyeVec = normalize(eyePosition - In.Position);
5: Out.TanEyeVec = 0.5 + eyeVec / 2;
To może jeszcze koniecznie nie są te bardzo ciekawe działania, ale równie potrzebne i fundamentalne jak powyższe. Co to jest wektor normalny i jak się go oblicza (przypominam – iloczyn wektorowy dwóch wektorów prostopadłych do siebie opartych na bokach wielokąta, do która wektor normalny jest liczony), wspominałem bodajże przy okazji artykułu o kolizjach. Wypada teraz dokładniej określić wektory tangent i binormal (błędnie określany czasami przeze mnie jako bitangent – przepraszam zainteresowanych). Tangent to iloczyn wektorowy obliczonego przed chwilą (a podawanego w shaderze przez rejestr, podobnie zresztą jak tangent) wektora normalnego oraz jednego z wektorów na boku wielokąta. Binormal to kolejny “cross product”, tym razem wektorów normalnego i tangenta. Tak uzbrojeni, możemy przejść dalej, do obliczenia wektora widzenia, czyli znormalizowanej różnicy “tych śmiesznych kresek ze strzałkami” obliczonej powyżej pozycji oka kamery i wejściowej pozycji wierzchołka. Następnie uzyskujemy trzecią zwracaną wartość, w rejestrze TEXCOORD1, czyli TanEyeVec, gdyż jest to eyeVec ograniczony do przedziału [0, 1]. Czas na ostatni etap pisania vertex shadera.
1: float3 lightVec = objectLightPosition - In.Position;
2: float3 tanLightVec;
3: tanLightVec.x = dot(tangent, lightVec);
4: tanLightVec.y = dot(binormal, lightVec);
5: tanLightVec.z = dot(In.Normal, lightVec);
6: Out.TanLightVec = 0.5 + normalize(tanLightVec) / 2;
7:
8: return Out;
W podobny sposób jak wyżej obliczamy lightVec, czyli wektor światła – tym razem nie ma potrzeby jego normalizowania już w tym miejscu, stanie się to dopiero potem. Następnie obliczamy kolejne współrzędne pomocniczego wektora tanLightVec jako iloczyn skalarny kolejnych wektorów oraz lightVec. Mimo iż sama idea nie jest do końca jasna, dlaczego akurat takie wektory, dlaczego iloczyn skalarny i dlaczego nie wziąłem się za prostsze rzeczy, ale myślę, że kod jest rozpisany w miarę przystępnie. Na końcu obliczamy wartość czwartego parametru wyjściowego (w TEXCOORD2), czyli TanLightVec (zwracam uwagę na wielkość początkowej litery, jest to dodatkowy znacznik oprócz oczywiście identyfikator obiektu Out). W tym miejscu normalizujemy tan LightVec i ograniczamy jego zakres w identyczny sposób jak poprzednio TanEyeVec. Operacje kończą się oczywiście zwróceniem obiektu.
Przeanalizujmy szybko naszą obecną sytuację – po zakończeniu pracy vertex shadera, uzyskaliśmy cztery rejestry wypełnione danymi, które potrzebujemy dalej – pozycję wierzchołka w POSITION, współrzędne tekstury w TEXCOORD0, tangens wektora widoku w TEXCOORD1 oraz tangens wektora światła w TEXCOORD2. Posiadając te informacje możemy przystąpić do tworzenia szkieletu fragment shadera.
1: struct FragmentIn
2: { 3: float4 Position : POSITION;
4: float2 TexCoord : TEXCOORD0;
5: float3 TanEyeVec : TEXCOORD1;
6: float3 TanLightVec : TEXCOORD2;
7: };
8:
9: struct FragmentOut
10: { 11: float4 Color : COLOR;
12: };
13:
14: FragmentOut main (FragmentIn In, uniform sampler2D Tex, uniform sampler2D Tex1, uniform sampler2D Tex2, uniform float Scale, uniform float Bias, uniform float3 Ka, uniform float3 Kd, uniform float3 Ks, uniform float Shininess, uniform float AmbientCoeff, uniform float DiffuseCoeff, uniform float SpecularCoeff)
15: { 16: FragmentOut Out;
17: //...
18: }
Strukturę FragmentIn można było przewidzieć na podstawie VertexOut z poprzedniego programu. Zwykle wynikiem działania fragment (pixel) shadera jest wartość koloru w danym punkcie zapisana w rejestrze COLOR – tak jest też tym razem. Gwałtowną zmianę możemy jednak zauważyć przy ilości podawanych uniformów. Tex to identyfikator tekstury “zwykłej”, którą będziemy widzieć na ekranie (oczywiście, ulepszoną przez shader). Tex1 to mapa wysokości tekstury zwykłej i określa głębokość tekstury – jest to jedna z rzeczy najmocniej różniących zwykły bump mapping i parallax. Tex2 to mapa normalnych. Scale to skala wypukłości, jaką chcemy uzyskać, natomiast dodatnia wartość Biasa pozwala uzyskać te przesunięcia, na których nam zależy. Kolejne parametry Ka, Kd, Ks oraz Shininess to wartości znane z implementacji Per Pixel Lighting – odpowiednio, współczynniki materiałowe dla światła otaczającego (ambient), światła rozproszonego (diffuse), światła odbijającego (specular) oraz współczynnik mocy odblasku (shininess). Kolejne parametry (AmbientCoeff, DiffuseCoeff, SpecularCoeff) to dodatkowe współczynniki liniowe dla odpowiednich rodzajów światła. Uch, trochę nudne to było – przejdźmy zatem do implementacji.
1: float2 texUV;
2:
3: float height = tex2D(Tex1, In.TexCoord).r;
4: height = height * 2 * Scale - Scale;
5: float3 tanEyeVec = normalize((In.TanEyeVec - 0.5) * 2);
6:
7: if (Bias > 0)
8: { 9: texUV = In.TexCoord + (tanEyeVec.yx * height);
10: }
11: else
12: { 13: texUV = In.TexCoord;
14: }
Na początku deklarujemy sobie dwueelementowany wektor dla współrzędnych tekstury. Następnie dochodzi do wykorzystania mocy mapy wysokości – obliczamy wysokość, jaka następuje poprzez “stekselowanie” Tex1 oraz współrzędnej tekstury jaką mamy na wejściu. Dokładniej, zależy nam na tylko jednej współrzędnej struktury jaką uzyskamy po wykonaniu działania – r, czyli trzeci parametr stanowiący głębokość. Następnie dokonujemy dalszego powiększania wysokości danego punktu na podstawie skali, jaką podaliśmy. Obliczamy też znormalizowany i odpowiednio zoperowany wektor tangensowy widoku. Jeśli jako parametr Bias podaliśmy liczbę dodatnią, uzyskujemy parallax – współrzędne wyjściowe tekstury będą zależy nie tylko od wejściowych, ale także od iloczynu obliczonego przed chwilą wektora i wysokości. To przesunięcie jest reprezentowane przez offset na rysunku poglądowym na wstępie tej notki.
1: float3 normal = (tex2D(Tex2, texUV).rgb - 0.5) * 2;
2: normal = normalize(normal);
3:
4: float3 tanLightVec = normalize((In.TanLightVec - 0.5) * 2);
5:
6: tanLightVec.x = - tanLightVec.x;
Musimy obliczyć też wektor normalny, co następuje już z użyciem wartości wektora texUV obliczonego wcześniej. Weryfikowany jest także tangens wektora światła. Dlaczego negujemy pierwszą współrzędną? Ze względu na to, iż zależy nam na kierunku do światła, a nie od. Kolejne fragmenty kodu dotyczą już obliczania wpływu światła.
1: float diffuse = max(dot(tanLightVec, normal), 0.0) * DiffuseCoeff;
2:
3: float3 tanHalf = normalize(tanLightVec + tanEyeVec);
4: float specular = max(dot(tanHalf, normal), 0.0);
5: specular = pow(specular, Shininess) * SpecularCoeff;
6:
7: float3 ambient = Ka * AmbientCoeff;
Po kolei obliczymy teraz wszystkie podstawowe składowe światła łączące się na końcowy kolor piksela. Nie różni się to wiele od mechanizmu w per pixel – dla diffuse bierzemy większą wartość od iloczynu skalarnego tanLightVec i wektora normalnego oraz zera i mnożymy przez współczynnik liniowy. Podobnie postępujemy przy specular z tą różnicą, iż najpierw obliczamy pomocniczy wektor będący złożeniem tanLightVec i tanEyeVec. Dodatkowo, specular podnosimy do potęgi Shininess, czyli stopnia odblasku. Przy obliczaniu światła otaczającego nie potrzeba już takich wygibasów – jest to iloczyn współczynników tablicowego i liniowego dla tego rodzaju oświetlenia.
1: float3 texColor = tex2D(Tex, texUV).rgb;
2:
3: Out.Color.rgb = texColor * (ambient + Kd * DiffuseCoeff * diffuse) + Ks * SpecularCoeff * specular;
4: Out.Color.a = 1.0f;
5:
6: return Out;
Pozostało nam już jedynie obliczenie wartości teksela na podstawie podstawowej tekstury i “nowych” współrzędnych texUV oraz złożenie ostatnio otrzymanych składników w jeden wynik, będącym wartością danego piksela. Czynimy to poprzez przemnożenie texColor i światła ambient oraz diffuse, a następnie dodania speculara.
Jeśli napisaliśmy dobrze shader i kod, który komunikuje się z językiem CG z poziomu aplikacji, powinniśmy uzyskać żądany efekt Parallax Mapping.
Cóż, dobrnęliśmy do końca. Mam nadzieję, że mimo swoich predyspozycji do tłumaczenia tego typu rzeczy, ktoś wyniósł cokolwiek z tego artykułu. Jeśli ktoś widzi jakieś błędy (a na pewno jakieś się znajdą – to już wynika bezpośrednio z praw Murphy’ego, dokładnie z pierwszego, drugiego i szyberdzieści dziabniętego), proszę koniecznie o napisanie tego w komentarzu. Zachęcam też do spojrzenia na lepszą wersję tego algorytmu oświetlenia, czyli “steepowaną” – za co ja się też (mam nadzieję) kiedyś zabiorę, choć artykułu o tym raczej nie napiszę.
Pozdrawiam i dziękuję – SceNtriC.
Ps. Przepraszam za lekką nieestetykę - zauważyłem już od dawna, że metoda Kopiego-Pasta z Notepad++ przy zawijaniu wierszy nie należy do wygodnych.