Physically based rendering jest zbiorem technik renderowania, które bazują na teorii, która ma za zadanie odwzorowywać świat rzeczywisty.
Podstawową dla obliczenia jest rendering equation następującej postaci.
\[L_o(p,\omega_o)=\int_\Omega f_r(p,\omega_i,\omega_o)L_i(p,\omega_i)n\cdot \omega_i\ d\omega_i\] Całka przechodzi po wszystkich kierunkach na półkuli \(\Omega\) oblicza wyjściowe oświetlenie \(L_O\) bazując na wejściowym oświetleniu \(L_i\) i funkcji \(f_r\) znanej jako BRDF, czyli bidirectional reflective distribution function. W najbardziej ogólnym przypadku należałoby traktować \(L_i\) jako dystrybucję i jest nadmiernym formalizmem w naszym przypadku, gdy będziemy wykorzystywać wyłącznie światła punktowe i kierunkowe. Dlatego o powyższej całce możemy myśleć jak o sumie po źródłach światła.
By uzyskać realistyczny efekt, potrzebujemy realistycznej funkcji \(f_r\). Wybierzemy Cook-Torrance BRDF, który jest najczęściej wykorzystywaną funkcją przy liczeniu PBR w czasie rzeczywistym.
Cook-Torrance BRDF rozdzielamy na światło rozproszone (diffuse) i odbite kierunkowo (specular)
\[f_r=k_d f_{lambert}+k_s f_{cook−torrance},\] gdzie \(k_d\) i \(k_s\) to są współczynniki, rozdzielające ile światła zostało rozproszone a ile odbite, ich suma musi być równa 1. komponent \(f_{lambert}\) jest znany jako Lambertian diffuse i wynosi \[f_{lambert}=\frac{c}{\pi}\] przy czym \(c\) to jest jest kolorem powierzchni a \(\pi\) odpowiada za normalizację.
Część odbicia kierunkowego jest bardziej skomplikowana i wygląda następująco:
\[f_{cook−torrance}=\frac{DFG}{4(w_o\cdot n)(w_i\cdot n)}\] gdzie \(D\), \(F\) i \(G\) są funkcjami, które zależą od normalnej, wektora wejściowego i wyjściowego oraz parametrów chropowatości \(\alpha\) i odbicia \(F_0\). Te funkcje to odpowiednio: - \(D\) - Normal distribution function przybliża ilość powierzchni, która jest ustawiona prostopadle do wektora połówkowego korzystamy z funkcji Trowbridge-Reitz GGX \[D(n,h,\alpha) = \frac{\alpha^2}{π((n⋅h)^2(α^2−1)+1)^2},\] gdzie \(h\) to: \[h = \frac{\omega_o+\omega_i}{||\omega_o+\omega_i||}\] - G - Geometry function opisuje stopień samo-zacieninienia. Wykorzystujemy Schlick-GGX \[G(n,\omega_o,k)=\frac{n⋅\omega_o}{(n⋅\omega_o)(1−k)+k}\] przy czym \(k\) wynosi \[k=\frac{(\alpha+1)^2}{8}\] - F - Fresnel equation opisuje stopień odbicia w zależności od kąta padania. Wykorzystujemy aproksymację Fresnela-Schlicka \[F(h,v,F_0)=F_0+(1−F_0)(1−(h⋅v))^5\] Parametr \(F_0\) jest własnością materiału dla uproszczenia uznaje się, że niemetale mają wartość (0.04,0.4,0.4) a dla metali jest ona równa kolorowi obiektu. Wprowadza się parametr metaliczności, który jest współczynnikiem, jakim miksujemy między (0.04,0.4,0.4) a kolorem, żeby uzyskać \(F_0\).
W projekcie znajduje się jedna kula, punktowe oświetlenie i cieniowanie Phonga. Zaimplementuj PBR i rozmieść kule w macierzy 10 x 10 zmieniając stopniowo w nich parametr chropowatości \(\alpha\) i metaliczności, jak na poniższym obrazku.
Do uzyskania realistycznego efektu wykorzystuje się szereg tekstur:
Tekstury Albedo i Normalnych odpowiadają za kolor i normalne jak przy cieniowaniu Phonga. Metalic i Roughness przechowuje wartość metaliczności i chropowatości, które pojawiły się w BRDF. Natomiast AO (ambient occlision) odpowiada za stopień samo-zacienienia, który modyfikuje oświetlenie ambientowe w danym punkcie, uwzględnienie jej podkreśla detale obiektów.
Karty graficzne posiadają ograniczenia, jeżeli chodzi o ilość tekstur, które można przesłać za jednym razem (współcześnie są to 32 tekstury). Może być to znaczące ograniczenie, dlatego AO Roughness i Metalic mogą być przesłane w jednej teksturze kolejno jako kanał r, g i b. Taką teksturę określa się jako arm od pierwszych liter nazw.
W ex_8_2.hpp
znajduje się podstawowa scena. Zaimplementuj w niej PBR z użyciem tekstur. Stwórz nową parę shaderów, które będą obsługiwać PBRW oparciu o tekstury. Wyświetl statek zestawem tekstur w folderze textures/spaceshipPBR
. Możesz też wziąć swój układ słoneczny z poprzednich zajęć. Ładowanie tekstur jest męczące, napisz klasę/strukturę PBRTexturesHandler
, która przechowuje zestaw tekstur albedo, normal i arm. Posiada konstruktor, który ładuje tekstury po ścieżkach oraz funkcję activateTextures(int[] indices)
, która aktywuje tekstury z indeksami podanymi w tablicy. int[] indices
.
Podobnie jak w przypadku zwykłych tekstur możemy mieszać tekstury przesyłane przez PBR, należy wtedy mieszać wszystkie tekstury z takim samym współczynnikiem. Jednak stopniowe przejście z jednej tekstury do drugiej często prowadzi to do nienaturalnych rezultatów, gdy próbujemy mieszać tekstury o różnej częstotliwości detali, jak na przykład poniżej: W prawdziwym świecie, przy stopniowej zmianie z kamiennego podłoża na piaszczyste, wiedzielibyśmy, jak piasek stopniowo zaczyna wypełniać szpary między kamieniami i przykrywać je coraz bardziej. Istnieje wiele metod mieszania tekstur np ta. My wykorzystamy technikę opartą na mapach wysokości, opisaną w https://www.gamedeveloper.com/programming/advanced-terrain-texture-splatting.
Mapa wysokości to dodatkowa tekstura, która wskazuje wysokość obiektów znajdujących się na teksturze względem płaskiej powierzchni. Może być również wykorzystana przy paralax mappingu czy tessalacji. Jest ona nazywana po angielsku heightmap, displacement map czy bump map w zależności od zastosowania. Przykładowe znajdują się w teksutrach.
Możemy te mapy wykorzystać do mieszania dwóch map tak, że wyświetlana będzie ta, która jest wyżej. Zaprezentować to możemy w poniższym jednowymiarowym schemacie. Niebieska linia reprezentuje mapę piasku a czerwona kamieni. Jako rezultat otrymujemy
Kod funkcji mieszającej:
float height1, float3 texture2, float height2, float blend_ratio)
float3 blend(float3 texture1,
{if (height1>height2){
return texture1;
}else{
return texture2;
} }
To pozwala nam zmieszać dwie tekstury. Jednak samo w sobie nie powoduję przejścia z jednej do drugiej, to możemy osiągnąć modyfikując wysokość parametrem blend_ratio
, który będzie z zakresu od 0 do 1. Na jednowymiarowym schemacie wartość wysokości wygląda to następująco. Daje nam to to poniższy efekt Kod nowej funkcji mieszającej:
float height1, float3 texture2, float height2, float blend_ratio)
float3 blend(float3 texture1,
{float h1=height1+1.0-blend_ratio;
float h2=height2+blend_ratio;
if (h1>h2){
return texture1;
}else{
return texture2;
} }
To podejście dało nam bardziej naturalne przejście z piasku do kamieni. Widać jak kamienie stopniowo wyłaniają się spod piasku. Jednak pojawiły się artefakty w miejscach, gdzie wartości mapy wysokości są bardzo zbliżone. Wynika to z ograniczeń kwantyzacji. By zminimalizować te błędy, będziemy mieszać wartości tekstur, ale tylko, gdy wartości będą blisko siebie. Zdefiniujemy dodatkowy parametr mix_threshold
, który będzie określał, jak blisko mają być wartości, żeby je mieszać. Jeżeli różnica między jedną wysokością a drugą będzie mniejsza od mix_threshold
to będziemy je mieszać proporcjonalnie do ich różnicy podzielonej przez mix_threshold
, w przeciwnym wypadku wybierzemy tą, która jest wyżej. W celu optymalizacji zamiast optymalizacji korzystamy z funkcji clamp
float height1, float3 texture2, float height2, float blend_ratio)
float3 blend(float3 texture1,
{float mix_threshold=0.1;
float h1=height1+1.0-blend_ratio;
float h2=height2+blend_ratio;
float havg=(h1+h2)/2.;
0.5*mix_threshold)/(mix_threshold),0.,1.);
h1 = clamp((h1-hav+0.5*mix_threshold)/(mix_threshold),0.,1.);
h2 = clamp((h2-hav+
return (texture1*h1+texture2*h2)
}
Stosunek w jakim je zmieszamy:
Finalna tekstura
W teksturach znajduje plik heightmap.png
wykorzystaj go jako mapę wysokości przy rysowaniu jednej z planet. Mapa ta zawiera wartości, od 0 do 1. Dla różnych wysokości chcemy wykorzystywać różne tekstury, ustal na przykład, że poniżej 0,3 znajduje się woda, między 0,3 a 0,6 trawa, natomiast powyżej skały.
Zmodyfikuj swój układ słoneczny z poprzednich zajęć, zmień w nim model oświetlenia z Phonga na PBR. Tekstury możesz pobrać na przykład z https://polyhaven.com/textures lub www.texturecan.com.