This is the Polish translation of Advanced-Lighting/SSAO article of learnopengl.com tutorial series.

Wspomnieliśmy o tym w samouczku dotyczącym podstaw oświetlenia: oświetlenie otoczenia (ambient). Oświetlenie otoczenia to stała światła, którą dodajemy do ogólnego oświetlenia sceny, aby symulować rozpraszanie światła. W rzeczywistości światło rozprasza się we wszystkich kierunkach z różną intensywnością, więc pośrednio oświetlone części sceny również powinny mieć różną intensywność, zamiast stałego elementu otoczenia. Jeden z rodzajów aproksymacji oświetlenia pośredniego nazywa się ambient occlusion, który próbuje przybliżać oświetlenie pośrednie poprzez ściemnianie zagięć, dziur i powierzchni, które są blisko siebie. Obszary te są w dużym stopniu zasłonięte przez otaczającą geometrię, a zatem promienie świetlne mają mniej miejsca do “ucieczki”, stąd obszary wydają się ciemniejsze. Rzuć okiem na rogi i zagięcia swojego pokoju, aby zobaczyć, że światło wydaje się w nich odrobinę ciemniejsze.

Poniżej znajduje się przykładowy obraz sceny z i bez SSAO. Zwróć uwagę, że szczególnie pomiędzy zagięciami światło (otoczenia) jest bardziej przesłonięte:

Przykładowy obraz SSAO i bez SSAO

Chociaż nie jest to oczywisty efekt, obraz z włączoną funkcją SSAO jest o wiele bardziej realistyczny ze względu na te małe szczegóły, dzięki którym cała scena zyskuje większą głębię.

Techniki okluzji otoczenia (ang. ambient occlusion techniques) są drogie, ponieważ muszą uwzględniać otaczającą geometrię. Można byłoby rzucić dużą liczbę promieni dla każdego punktu w przestrzeni, aby określić jego wielkość okluzji, ale szybko staje się to niewykonalne obliczeniowo dla rozwiązań w czasie rzeczywistym. W 2007 roku Crytek opublikował technikę zwaną screen-space ambient occlusion (SSAO) do gry Crysis. Technika wykorzystuje głębokość sceny w przestrzeni ekranu w celu określenia ilości okluzji, zamiast rzeczywistych danych geometrycznych. Takie podejście jest niewiarygodnie szybkie w porównaniu do rzeczywistej okluzji otoczenia i daje wiarygodne wyniki, czyniąc z niego standard aproksymacji okluzji otoczenia w czasie rzeczywistym.

Podstawy okluzji otoczenia w przestrzeni ekranu są proste: dla każdego fragmentu na ekranie wypełnionym kwadratem obliczamy współczynnik okluzji na podstawie otaczających wartości głębokości danego fragmentu. Współczynnik okluzji jest następnie używany do zmniejszenia lub zerowania komponentu oświetlenia otoczenia fragmentu. Współczynnik okluzji uzyskuje się przez pobranie wielu próbek głębokości w kuli jądra próbki otaczającej pozycję fragmentu i porównanie każdej z próbek z wartością głębokości bieżącego fragmentu. Liczba próbek, które mają wyższą wartość głębokości niż głębokość fragmentu, reprezentuje współczynnik okluzji.

Obraz opartej na kołach techniki SSAO wykonanej przez Crysis

Każda z szarych próbek głębokości, które znajdują się wewnątrz geometrii, przyczynia się do całkowitego współczynnika okluzji; im więcej próbek znajdujemy wewnątrz geometrii, tym mniej oświetlenia otoczenia powinien ostatecznie otrzymać fragment.

Oczywiste jest, że jakość i precyzja efektu bezpośrednio odnoszą się do liczby otaczających próbek. Jeśli liczba próbek jest zbyt niska, precyzja drastycznie się zmniejsza i dostajemy artefakt zwany bandingiem (?paskowaniem?); jeśli jest zbyt wysoka, tracimy wydajność. Możemy zmniejszyć ilość próbek, które musimy przetestować, wprowadzając losowość do jądra próbki. Poprzez losowe obracanie jądra próbki każdego fragmentu możemy uzyskać wyniki wysokiej jakości przy znacznie mniejszej ilości próbek. To ma swoją cenę, ponieważ przypadkowość wprowadza zauważalny wzorzec szumu, który musimy naprawić, rozmywając wyniki. Poniżej znajduje się zdjęcie (dzięki uprzejmości John’a Chapman’a) prezentujące efekt bandingu i efekt losowości:

Jakość obrazu SSAO z wieloma próbkami i dodanym rozmyciem

Jak widać, mimo, że dostrzegamy banding na wynikach SSAO z powodu niskiej liczby próbek, przez wprowadzenie losowości efekty bandingu całkowicie zniknął.

Metoda SSAO opracowana przez Crytek miała określony styl wizualny. Ponieważ użytym jądrem próbki była sfera, spowodowało to, że płaskie ściany wyglądały na szare, ponieważ połowa próbek jądra znalazła się w otaczającej geometrii. Poniżej znajduje się obraz SSAO Crysis’a, który wyraźnie przedstawia to wrażenie szarości:

Screen space ambient occlusion w grze Crysis firmy Crytek, ukazujące wrażenie szarości z powodu użycia jądra sferycznego zamiast normalnego jądra z próbką na poziomie półkuli w OpenGL

Z tego powodu nie będziemy używać kulistego jądra próbki, ale raczej półkulistego jądra próbki zorientowanego wzdłuż wektora normalnego powierzchni.

Obraz półkulistego jądra próbki ułożonego wzdłuż wektora normalnego dla SSAO w OpenGL

Poprzez pobieranie próbek wokół tej półkuli zorientowanej wokół wektora normalnego nie bierzemy pod uwagę geometrii bazowej fragmentu jako wkładu we współczynnik okluzji. Eliminuje to szare wrażenie okluzji otoczenia i generuje bardziej realistyczne wyniki. Ten samouczek SSAO oparty jest na półkuli zorientowanej wokół wektora normalnego i lekko zmodyfikowanej wersji genialnego samouczka SSAO Johna Chapmana.

Bufory próbkowania

SSAO wymaga informacji geometrycznych, ponieważ potrzebujemy jakiegoś sposobu określenia współczynnika okluzji fragmentu. Dla każdego fragmentu będziemy potrzebować następujących danych:

  • Wektor position per-fragment.
  • Wektor normal per-fragment.
  • Kolor albedo per-fragment.
  • Sample kernel (jądro próbkowania).
  • Wektor losowej rotacji per-fragment, używany do obracania jądra próbkowania.

Używając pozycji per-fragment w przestrzeni widoku, możemy ustawić półkulę jądra prókowania wokół wektora normalnego powierzchni w przestrzeni widoku i użyć tego jądra do spróbkowania tekstury pozycji dla różnych przesunięć. Dla każdej próbki per-fragment porównujemy jej głębokość z jej głębokością w buforze pozycji, aby określić ilość okluzji. Uzyskany współczynnik okluzji jest następnie wykorzystywany do ograniczenia końcowego elementu oświetlenia otoczenia. Poprzez uwzględnienie wektora rotacji per-fragment możemy znacznie zmniejszyć liczbę próbek, których będziemy potrzebować.

Przegląd techniki SSAO

Ponieważ SSAO jest techniką w przestrzeni ekranu, obliczamy jej wpływ na każdy fragment na pełnoekranowym kwadracie 2D, ale oznacza to, że nie mamy informacji geometrycznych o scenie. Moglibyśmy wyrenderować geometryczne dane na fragment do tekstur w przestrzeni ekranu, które następnie wysłalibyśmy do Fragment Shadera SSAO, abyśmy mieli dostęp do danych geometrycznych na fragment. Jeśli śledziłeś poprzedni samouczek, uświadomisz sobie, że wygląda to podobnie do odroczonego renderowania i z tego powodu SSAO doskonale wpasowuje się w odroczony rendering, ponieważ mamy już pozycje i wektory normalne w G-buffer.

W tym samouczku zamierzamy zaimplementować SSAO w nieco uproszczonej wersji odroczonego renderera z tutoriala deferred shading, więc jeśli nie jesteś pewien, czym jest odroczone cieniowanie to najpierw przejrzyj samouczek o odroczonym renderingu.

Ponieważ już powinniśmy mieć dane pozycji i wektorów normalnych per-fragment dostępnych w G-buffer, Fragment Shader etapu geometrii jest dość prosty:

    #version 330 core
    layout (location = 0) out vec4 gPosition;
    layout (location = 1) out vec3 gNormal;
    layout (location = 2) out vec4 gAlbedoSpec;

    in vec2 TexCoords;
    in vec3 FragPos;
    in vec3 Normal;

    void main()
    {    
        // store the fragment position vector in the first gbuffer texture
        gPosition = FragPos;
        // also store the per-fragment normals into the gbuffer
        gNormal = normalize(Normal);
        // and the diffuse per-fragment color
        gAlbedoSpec.rgb = vec3(0.95);
    }  

Ponieważ SSAO jest techniką w przestrzeni ekranu, w której okluzja jest obliczana na podstawie wygenerowanego obrazu, sensowne jest zaimplementowanie algorytmu w przestrzeni widoku. Dlatego FragPos, dostarczony przez Vertex Shader etapu geometrii, jest transformowany do przestrzeni widoku. Wszystkie dalsze obliczenia są również wykonywane w przestrzeni widoku, więc upewnij się, że dane pozycji i wartości wektorów normalnych G-buffera są w przestrzeni widoku (przemnożone przez macierz widoku).

Możliwe jest zrekonstruowanie rzeczywistych wektorów pozycji z samych tylko wartości głębi za pomocą sprytnych sztuczek, jak opisał Matt Pettineo na swoim blogu. Wymaga to dodatkowych obliczeń w Fragment Shaderach, ale oszczędza nam konieczności przechowywania danych pozycji w G-buffer, który zajmuje dużo pamięci. W celu zachowania jak najbardziej prostego przykładu, pominiemy te optymalizacje w tym samouczku.

Tekstura bufora kolorów gPosition jest skonfigurowana w następujący sposób:

    glGenTextures(1, &gPosition);
    glBindTexture(GL_TEXTURE_2D, gPosition);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
    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_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);  

Daje nam to teksturę pozycji, której możemy użyć do uzyskania wartości głębokości dla każdej próbki jądra. Pamiętaj, że przechowujemy pozycje w formacie danych zmiennoprzecinkowych; w ten sposób wartości pozycji nie są obcięte do zakresu [0,0 1.0]. Zwróć też uwagę na metodę zawijania tekstur GL_CLAMP_TO_EDGE. Gwarantuje to, że przypadkowo nie przekroczymy wartości pozycji/głębokości w obszarze ekranu poza domyślnym obszarem współrzędnych tekstury.

Następnie potrzebujemy rzeczywistej półkuli jądra próbkowania i pewnej metody do jej losowego obrócenia.

Półkula zorientowana według wektora normalnego

Musimy wygenerować pewną liczbę próbek zorientowanych wzdłuż wektora normalnego powierzchni. Jak wspomnieliśmy na początku tego samouczka, chcemy wygenerować próbki, które tworzą półkulę. Ponieważ generowanie jądra próbkowania dla każdego kierunku wektora normalnego powierzchni jest trudne i mało wiarygodne, będziemy generować jądro próbkowania w przestrzeni stycznych, z wektorem normalnym wskazującym w kierunku dodatnim osi z.

Obraz jądra próbkowania o orientacji półkuli według normalnej do użycia w SSAO w OpenGL

Zakładając, że mamy jednostkową półkulę, możemy uzyskać jądro próbkowania o maksymalnej wartości 64 próbek w następujący sposób:

    std::uniform_real_distribution<float> randomFloats(0.0, 1.0); // random floats between 0.0 - 1.0
    std::default_random_engine generator;
    std::vector<glm::vec3> ssaoKernel;
    for (unsigned int i = 0; i < 64; ++i)
    {
        glm::vec3 sample(
            randomFloats(generator) * 2.0 - 1.0, 
            randomFloats(generator) * 2.0 - 1.0, 
            randomFloats(generator)
        );
        sample  = glm::normalize(sample);
        sample *= randomFloats(generator);
        float scale = (float)i / 64.0; 
        ssaoKernel.push_back(sample);  
    }

Zmieniamy kierunek osi x i y w przestrzeni stycznych pomiędzy -1.0 i 1.0 i zmieniamy kierunek próbek z pomiędzy 0.0 i 1.0 (jeśli zmienilibyśmy kierunek z pomiędzy -1.0 i 1.0 uzyskalibyśmy sferyczne jądro próbkowania). Ponieważ jądro próbkowania będzie zorientowane wzdłuż wektora normalnego powierzchni, powstałe wektory próbek znajdą się w półkuli.

Obecnie wszystkie próbki są losowo rozmieszczane w jądrze próbkowania, ale wolimy dać większą wagę dla okluzji bardziej zbliżonych do danego fragmentu, aby rozprowadzić próbki jądra bliżej punktu początkowego. Możemy to zrobić za pomocą funkcji przyspieszania interpolacji:

       scale   = lerp(0.1f, 1.0f, scale * scale);
       sample *= scale;
       ssaoKernel.push_back(sample);  
    }

Gdzie lerp jest zdefiniowana jako:

    float lerp(float a, float b, float f)
    {
        return a + f * (b - a);
    }  

To daje nam dystrybucję jądra, która umieszcza większość próbek bliżej środka.

Jądra próbkowania SSAOO (półkula zorientowana wokół normalnej) z próbkami bardziej zbliżonymi do środka fragmentu w OpenGL

Każda z próbek jądra zostanie użyta do przesunięcia położenia fragmentu w przestrzeni widoku w celu pobrania próbki geometrii otoczenia. Potrzebujemy dość wielu próbek w przestrzeni widoku, aby uzyskać realistyczne wyniki, które mogą być zbyt dużym obciążeniem dla wydajności. Jednakże, jeśli wprowadzimy częściowo losowy obrót/szum per-fragment, możemy znacznie zmniejszyć wymaganą liczbę próbek.

Losowe rotacje jądra

Wprowadzając nieco losowości do jądra próbkowania, w znacznym stopniu zmniejszamy liczbę próbek potrzebnych do uzyskania dobrych wyników. Możemy stworzyć wektor losowej rotacji dla każdego fragmentu sceny, ale to szybko pożera pamięć. Bardziej sensowne jest stworzenie małej tekstury wektorów o losowej rotacji, którą nakładamy na ekran.

Tworzymy tablicę 4x4 losowych wektorów rotacji zorientowanych wokół wektora normalnego powierzchni w przestrzeni stycznych:

    std::vector<glm::vec3> ssaoNoise;
    for (unsigned int i = 0; i < 16; i++)
    {
        glm::vec3 noise(
            randomFloats(generator) * 2.0 - 1.0, 
            randomFloats(generator) * 2.0 - 1.0, 
            0.0f); 
        ssaoNoise.push_back(noise);
    }  

Ponieważ jądro próbkowania jest zorientowane wzdłuż dodatniego kierunku z w przestrzeni stycznych, pozostawiamy komponent z na poziomie 0.0, więc obracamy się wokół osi z.

Następnie tworzymy teksturę 4x4, która zawiera losowe wektory rotacji; upewnij się, że ustawiłeś metodę zawijania na GL_REPEAT, aby poprawnie powielała się na ekranie.

    unsigned int noiseTexture; 
    glGenTextures(1, &noiseTexture);
    glBindTexture(GL_TEXTURE_2D, noiseTexture);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, 4, 4, 0, GL_RGB, GL_FLOAT, &ssaoNoise[0]);
    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_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);  

Mamy teraz wszystkie istotne dane wejściowe potrzebne do wdrożenia SSAO.

Shader SSAO

Shader SSAO działa na pełnoekranowym kwadracie, który oblicza wartość okluzji dla każdego z wygenerowanych fragmentów (do użycia w końcowym shaderze oświetlenia). Ponieważ musimy przechowywać wynik etapu SSAO, tworzymy kolejny obiekt bufora ramki:

    unsigned int ssaoFBO;
    glGenFramebuffers(1, &ssaoFBO);  
    glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);

    unsigned int ssaoColorBuffer;
    glGenTextures(1, &ssaoColorBuffer);
    glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);

    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ssaoColorBuffer, 0);  

Ponieważ wynik okluzji otoczenia jest pojedynczą wartością w skali szarości, potrzebujemy tylko czerwonego komponentu tekstury, dlatego ustawiliśmy wewnętrzny format bufora kolorów na GL_RED.

Kompletny proces renderowania SSAO wygląda tak:

    // geometry pass: render stuff into G-buffer
    glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
        [...]
    glBindFramebuffer(GL_FRAMEBUFFER, 0);  

    // use G-buffer to render SSAO texture
    glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
        glClear(GL_COLOR_BUFFER_BIT);    
        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, gPosition);
        glActiveTexture(GL_TEXTURE1);
        glBindTexture(GL_TEXTURE_2D, gNormal);
        glActiveTexture(GL_TEXTURE2);
        glBindTexture(GL_TEXTURE_2D, noiseTexture);
        shaderSSAO.use();
        SendKernelSamplesToShader();
        shaderSSAO.setMat4("projection", projection);
        RenderQuad();
    glBindFramebuffer(GL_FRAMEBUFFER, 0);

    // lighting pass: render scene lighting
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    shaderLightingPass.use();
    [...]
    glActiveTexture(GL_TEXTURE3);
    glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer);
    [...]
    RenderQuad();  

Shader shaderSSAO przyjmuje jako dane wejściowe odpowiednie tekstury G-buffera, teksturę szumu i półkule jądra próbkowania zorientowane wokół wektora normalnego:

    #version 330 core
    out float FragColor;

    in vec2 TexCoords;

    uniform sampler2D gPosition;
    uniform sampler2D gNormal;
    uniform sampler2D texNoise;

    uniform vec3 samples[64];
    uniform mat4 projection;

    // tile noise texture over screen based on screen dimensions divided by noise size
    const vec2 noiseScale = vec2(1280.0/4.0, 720.0/4.0); // screen = 1280x720

    void main()
    {
        [...]
    }

Warto zauważyć tutaj zmienną noiseScale. Chcemy rozłożyć teksturę szumu na całym ekranie, ale ponieważ TexCoords są w zakresie 0.0 a 1.0, tekstura texNoise nie będzie w ogóle powielana. Obliczymy, o ile będziemy musieli przeskalować współrzędne TexCoords, dzieląc wymiary ekranu przez rozmiar tekstury szumu:

    vec3 fragPos   = texture(gPosition, TexCoords).xyz;
    vec3 normal    = texture(gNormal, TexCoords).rgb;
    vec3 randomVec = texture(texNoise, TexCoords * noiseScale).xyz;  

Gdy ustawimy parametry kafelkowania texNoise na GL_REPEAT, losowe wartości będą powielane na całym ekranie. Wraz z wektorami fragPos i normal mamy wtedy wystarczająco dużo danych, aby utworzyć macierz TBN do transformacji dowolnego wektora z przestrzeni stycznych do przestrzeni widoku:

    vec3 tangent   = normalize(randomVec - normal * dot(randomVec, normal));
    vec3 bitangent = cross(normal, tangent);
    mat3 TBN       = mat3(tangent, bitangent, normal);  

Używając procesu Gramm-Schmidta tworzymy podstawę ortogonalną, za każdym razem lekko przechyloną w oparciu o wartość randomVec. Zauważ, że ponieważ używamy losowego wektora do konstrukcji wektora stycznego, nie ma potrzeby, aby macierz TBN była dokładnie wyrównana do geometrii powierzchni, a zatem nie ma potrzeby stosowania wektorów tangent per-wierzchołek (i wektorów bitangent).

Następnie wykonujemy iteracje po każdej z próbek jądra, przekształcamy próbki z przestrzeni stycznych do przestrzeni widoku, dodajemy je do aktualnej pozycji fragmentu i porównujemy głębokość położenia fragmentu z głębokością próbki przechowywaną w buforze pozycji w przestrzeni widoku. Omówmy to krok po kroku:

    float occlusion = 0.0;
    for(int i = 0; i < kernelSize; ++i)
    {
        // get sample position
        vec3 sample = TBN * samples[i]; // From tangent to view-space
        sample = fragPos + sample * radius; 

        [...]
    }  

Tutaj kernelSize i radius są zmiennymi, których możemy użyć do ulepszenia efektu; w tym przypadku ustawione odpowiednio na wartości 64 i 0.5. Dla każdej iteracji najpierw transformujemy odpowiednią próbkę do przestrzeni widoku. Następnie dodajemy przesunięcie do próbki jądra do położenia fragmentu w przestrzeni widoku. Następnie mnożymy przesuniętą próbkę przez radius, aby zwiększyć (lub zmniejszyć) efektywny promień próbki SSAO.

Następnie chcemy przekształcić sample do przestrzeni ekranu, abyśmy mogli spróbkować wartość położenia/głębokości sample tak, jakbyśmy renderowali jej położenie bezpośrednio na ekran. Ponieważ wektor jest obecnie w przestrzeni widoku, przekształcimy go do przestrzeni NDC przy użyciu macierzy projection:

    vec4 offset = vec4(sample, 1.0);
    offset      = projection * offset;    // from view to clip-space
    offset.xyz /= offset.w;               // perspective divide
    offset.xyz  = offset.xyz * 0.5 + 0.5; // transform to range 0.0 - 1.0  

Po przekształceniu zmiennej do przestrzeni NDC wykonujemy podział perspektywy poprzez podzielenie jego komponentów xyz za pomocą składnika w. Powstałe znormalizowane współrzędne urządzenia są następnie transformowane do zakresu [0.0, 1.0], abyśmy mogli użyć ich do spróbkowania tekstury pozycji:

    float sampleDepth = texture(gPosition, offset.xy).z; 

Używamy komponentów x i y wektora offset, aby spróbkować teksturę pozycji, aby uzyskać głębokość lub wartość z próbkowanej pozycji widzianej z perspektywy widza (pierwszy niezasłonięty, widoczny fragment). Następnie sprawdzamy, czy aktualna wartość głębokości próbki jest większa od zapisanej wartości głębokości, a jeśli tak, dodajemy do ostatecznego współczynnika kontrybucji:

    occlusion += (sampleDepth >= sample.z + bias ? 1.0 : 0.0);  

Zwróć uwagę, że dodajemy tutaj mały bias do wartości głębokości oryginalnego fragmentu (w przykładzie ustawionym na 0.025). Odchylenie nie zawsze jest konieczne, ale pomaga wizualnie poprawić efekt SSAO i rozwiązuje acne effect, który może wystąpić w zależności od złożoności sceny.

Jeszcze całkowicie nie skończyliśmy, ponieważ wciąż musimy wziąć pod uwagę niewielki problem. Ilekroć fragment jest badany pod kątem okluzji otoczenia, który jest ustawiony blisko krawędzi powierzchni, uwzględni on również wartości głębokości powierzchni daleko za powierzchnią testową; wartości te (nieprawidłowo) przyczynią się do współczynnika okluzji. Możemy rozwiązać ten problem, wprowadzając kontrolę zasięgu, jak ilustruje to poniższy obraz (dzięki uprzejmości Johna Chapmana):

Obraz z i bez sprawdzenia zasięgu powierzchni SSAO w OpenGL

Wprowadzamy kontrolę zasięgu, która zapewnia nas, że fragment przyczynia się do czynnika okluzji, jeśli jego wartości głębokości mieszczą się w promieniu próbki. Zmieniamy ostatnią linię na:

    float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
    occlusion       += (sampleDepth >= sample.z + bias ? 1.0 : 0.0) * rangeCheck;         

Tutaj użyliśmy funkcji GLSL smoothstep, która gładko interpoluje swój trzeci parametr między zakresem pierwszego i drugiego parametru, zwracając 0.0, jeśli jest mniejszy lub równy pierwszemu parametrowi i 1.0, jeśli jest równy lub większy od jego drugiego parametru. Jeśli różnica głębokości jest pomiędzy radius, jego wartość zostaje płynnie interpolowana pomiędzy 0.0 i 1.0 przez następującą krzywą:

Obraz funkcji smoothstep w OpenGL używanej do sprawdzania zasięgu w SSAO w OpenG

Jeśli mielibyśmy użyć testu z twardym zakresem granicznym, który gwałtownie usunąłby kontrybucję okluzji, gdyby głębokość znajdowała się poza radius, zobaczylibyśmy oczywiste (nieatrakcyjne) granice w miejscu, w którym zastosowano kontrolę zasięgu.

W końcowym etapie normalizujemy wkład okluzji przez rozmiar jądra i zwracamy wyniki. Zwróć uwagę, że odejmujemy współczynnik okluzji od 1.0, abyśmy mogli bezpośrednio użyć współczynnika okluzji do skalowania komponentu oświetlenia otoczenia.

    }
    occlusion = 1.0 - (occlusion / kernelSize);
    FragColor = occlusion;  

Jeśli wyobrazimy sobie scenę, w której nasz ulubiony model nanokombinezonu trochę drzemie, shader SSAO generuje następującą teksturę:

Shader SSAO daje w wyniku taki obraz

Jak widać, okluzja otoczenia daje świetne poczucie głębi. Przy samej teksturze okluzji otoczenia możemy już wyraźnie zobaczyć, że model rzeczywiście leży na podłodze, zamiast unosić się nieco nad nią.

Nadal nie wygląda to idealnie, ponieważ powtarzający się wzór tekstury szumu jest wyraźnie widoczny. Aby uzyskać efekt gładkiej okluzji otoczenia, musimy rozmazać teksturę okluzji otoczenia.

Rozmycie okluzji otoczenia

Między przejściem SSAO a przejściem oświetlenia najpierw chcemy rozmazać teksturę SSAO, więc stwórzmy kolejny obiekt bufora ramki do przechowywania wyniku rozmycia:

    unsigned int ssaoBlurFBO, ssaoColorBufferBlur;
    glGenFramebuffers(1, &ssaoBlurFBO);
    glBindFramebuffer(GL_FRAMEBUFFER, ssaoBlurFBO);
    glGenTextures(1, &ssaoColorBufferBlur);
    glBindTexture(GL_TEXTURE_2D, ssaoColorBufferBlur);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ssaoColorBufferBlur, 0);

Ponieważ losowa kafelkowa tekstura szumu daje nam stałą losowość, możemy wykorzystać tę właściwość dla naszej przewagi, aby stworzyć bardzo prosty shader rozmycia:

    #version 330 core
    out float FragColor;

    in vec2 TexCoords;

    uniform sampler2D ssaoInput;

    void main() {
        vec2 texelSize = 1.0 / vec2(textureSize(ssaoInput, 0));
        float result = 0.0;
        for (int x = -2; x < 2; ++x) 
        {
            for (int y = -2; y < 2; ++y) 
            {
                vec2 offset = vec2(float(x), float(y)) * texelSize;
                result += texture(ssaoInput, TexCoords + offset).r;
            }
        }
        FragColor = result / (4.0 * 4.0);
    }  

Tutaj przechodzimy przez otaczające teksele SSAO między -2,0 i 2.0 próbkując teksturę SSAO w ilości identycznej do wymiarów tekstury szumu. Przesuwamy każdą współrzędną tekstury przez dokładny rozmiar pojedynczego texela za pomocą textureSize, która zwraca vec2 o wymiarach danej tekstury. Uzyskane uśrednione wyniki pozwalają uzyskać proste, ale skuteczne rozmycie:

Obraz tekstury SSAO z rozmyciem w OpenGL

I oto mamy teksturę z danymi o okluzji otoczenia per-fragment, gotową do użycia w przejściu oświetlenia.

Zastosowanie okluzji otoczenia

Zastosowanie współczynników okluzji do równania oświetlenia jest niewiarygodnie proste: wystarczy pomnożyć współczynnik okluzji otoczenia per-fragment z komponentem oświetlenia ambient. Jeśli weźmiemy odroczony shader Blinna-Phonga z poprzedniego tutoriala i nieco go dostosujemy, otrzymamy następujący Fragment Shader:

    #version 330 core
    out vec4 FragColor;

    in vec2 TexCoords;

    uniform sampler2D gPosition;
    uniform sampler2D gNormal;
    uniform sampler2D gAlbedo;
    uniform sampler2D ssao;

    struct Light {
        vec3 Position;
        vec3 Color;

        float Linear;
        float Quadratic;
        float Radius;
    };
    uniform Light light;

    void main()
    {             
        // retrieve data from gbuffer
        vec3 FragPos = texture(gPosition, TexCoords).rgb;
        vec3 Normal = texture(gNormal, TexCoords).rgb;
        vec3 Diffuse = texture(gAlbedo, TexCoords).rgb;
        float AmbientOcclusion = texture(ssao, TexCoords).r;

        // blinn-phong (in view-space)
        vec3 ambient = vec3(0.3 * Diffuse * AmbientOcclusion); // here we add occlusion factor
        vec3 lighting  = ambient; 
        vec3 viewDir  = normalize(-FragPos); // viewpos is (0.0.0) in view-space
        // diffuse
        vec3 lightDir = normalize(light.Position - FragPos);
        vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Diffuse * light.Color;
        // specular
        vec3 halfwayDir = normalize(lightDir + viewDir);  
        float spec = pow(max(dot(Normal, halfwayDir), 0.0), 8.0);
        vec3 specular = light.Color * spec;
        // attenuation
        float dist = length(light.Position - FragPos);
        float attenuation = 1.0 / (1.0 + light.Linear * dist + light.Quadratic * dist * dist);
        diffuse  *= attenuation;
        specular *= attenuation;
        lighting += diffuse + specular;

        FragColor = vec4(lighting, 1.0);
    }

Jedyną rzeczą (oprócz zmiany do przestrzeni widoku), którą naprawdę zmieniliśmy w porównaniu do poprzednich implementacji oświetlenia, jest mnożenie komponentu ambient sceny przez wartość AmbientOcclusion. Przy jednym punktowym niebieskim źródle światła w scenie uzyskamy następujący wynik:

Obraz SSAO zastosowany w OpenGL

Możesz znaleźć pełny kod źródłowy sceny demo tutaj.

SSAO jest wysoce konfigurowalnym efektem, który polega w dużym stopniu na dostosowywaniu jego parametrów w zależności od rodzaju sceny. Nie ma idealnej kombinacji parametrów dla każdego rodzaju sceny; niektóre sceny działają tylko z małym promieniem, a niektóre sceny wymagają większego promienia i większej liczby próbek, aby wyglądał realistycznie. Obecne demo używa 64 próbek, co jest trochę za dużo. Pobaw się mniejszym rozmiarem jądra i postaraj się uzyskać dobre wyniki.

Niektóre parametry, które możesz modyfikować (np. za pomocą uniformów) to: rozmiar jądra, promień, odchylenie i/lub rozmiar jądra szumu. Możesz także zwiększyć końcową wartość okluzji za pomocą mocy zdefiniowanej przez użytkownika, aby zwiększyć jej siłę:

    occlusion = 1.0 - (occlusion / kernelSize);       
    FragColor = pow(occlusion, power);

Pobaw się z różnymi scenami i różnymi parametrami, aby docenić możliwość dostosowania SSAO.

Mimo, że SSAO jest subtelnym efektem, który nie jest zbyt wyraźnie zauważalny, dodaje dużo realizmu do prawidłowo oświetlonych scen i zdecydowanie jest techniką, którą chciałbyś mieć w swoim zestawie narzędzi.

Dodatkowe materiały

  • SSAO Tutorial: doskonały samouczek SSAO Johna Chapmana; duża część kodu i technik tego samouczka opiera się na jego artykule.
  • Know your SSAO artifacts: świetny artykuł o ulepszaniu artefaktów specyficznych dla SSAO.
  • SSAO With Depth Reconstruction: samouczek uzupełniający na bazie SSAO od OGLDeva o rekonstrukcji wektorów pozycji z samej głębokości, oszczędzając nam przechowywania drogich wektorów pozycji w G-buffer.