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

Jasne źródła światła i jasno oświetlone obszary są często trudne do przekazania widzowi, ponieważ zakres intensywności monitora jest ograniczony. Jednym ze sposobów odróżnienia jasnych źródeł światła od monitora jest sprawienie, by świeciły, a ich światło - zbierało się wokół źródła światła. To skutecznie daje widzowi iluzję, że te źródła światła lub jasne obszary są bardzo jasne.

Efekt poświaty źródeł światła (ang. light bleeding/glow) uzyskuje się dzięki efektowi przetwarzania końcowego zwanego bloom. Bloom nadaje wszystkim jasno oświetlonym obszarom sceny efekt łuny/blasku. Poniżej przedstawiono przykład sceny z i bez poświaty (zdjęcie dzięki uprzejmości Unreal):

Bloom daje wizualne wskazówki o jasności obiektów, ponieważ bloom zwykle daje iluzję, że obiekty są naprawdę jasne. Wykonanie w subtelny sposób (które niektóre gry nadużywają tego efektu) efektu bloom znacznie rozjaśnia oświetlenie Twojej sceny i pozwala uzyskać szeroki zakres ciekawych efektów.

Bloom działa najlepiej w połączeniu z renderingiem HDR. Częstym błędnym przekonaniem jest to, że HDR jest tym samym, co bloom, ponieważ wiele osób używa tych terminów zamiennie. Są to jednak całkowicie różne techniki wykorzystywane do różnych celów. Możliwe jest wykonanie bloom z domyślnymi 8-bitowymi framebufferami, tak jak można używać HDR bez efektu bloom. Po prostu HDR sprawia, że bloom jest lepiej widoczny (jak zobaczymy później).

Aby zaimplementować Blooma, renderujemy jak zwykle oświetloną scenę i wyodrębniamy zarówno bufor koloru HDR sceny, jak i obraz sceny z widocznymi tylko jasnymi regionami. Wyodrębniony obraz jasności jest następnie rozmazywany, a wynik dodawany na wierzch oryginalnego obrazu sceny HDR.

Zilustrujmy ten proces krok po kroku. Renderujemy scenę wypełnioną 4 jasnymi źródłami światła zwizualizowanymi jako kolorowe sześciany. Kolorowe kostki światła mają wartości jasności między 1.5 i 15.0. Jeśli zrobimy to z wykorzystaniem bufora kolorów HDR, scena będzie wyglądać następująco:

Obraz sceny HDR, w której musimy dodać efekt bloom lub blask w OpenGL

Bierzemy teksturę HDR bufora kolorów i wyodrębniamy wszystkie fragmenty, które przekraczają pewną jasność. Daje nam to obraz, który pokazuje jasno zabarwione regiony, ponieważ intensywność ich fragmentów przekroczyła pewien próg:

Jasne regiony wyodrębnione ze sceny dla efektu bloom lub poświaty w OpenGL

Następnie wykonujemy tę progową jasność i zamazujemy wynik. Siła efektu bloom jest w dużej mierze zdeterminowana przez zasięg i siłę zastosowanego filtra rozmycia.

Jasne regiony wyodrębnione w celu uzyskania efektu bloom są zamazywane w OpenGL

Uzyskana zamazana tekstura jest tym, czego używamy, aby uzyskać efekt poświaty lub blasku światła. Ta rozmazana tekstura jest dodawana do oryginalnej tekstury sceny HDR. Ponieważ jasne obszary są rozszerzone zarówno w szerokości jak i wysokości ze względu na filtr rozmycia, jasne obszary sceny wydają się świecić.

Przykład efektu post-processingu Bloom lub Glow w OpenGL z HDR

Bloom sam w sobie nie jest skomplikowaną techniką, ale jest trudny, aby zrobić go dokładnie. Większość jego jakości wizualnej zależy od jakości i rodzaju filtru rozmycia używanego do rozmycia wyodrębnionych regionów jasności. Po prostu ulepszenie filtra rozmycia może drastycznie zmienić jakość efektu bloom.

Wykonując te czynności uzyskamy efekt post-processingu bloom. Poniższy obrazek pokrótce podsumowuje wymagane etapy realizacji tego efektu.

Kroki wymagane do wdrożenia efektu post-processingu bloom w OpenG

Pierwszy krok wymaga od nas wyodrębnienia wszystkich jasnych kolorów sceny na podstawie pewnego progu (ang. threshold).

Wyodrębnianie jasnych kolorów

Pierwszy krok wymaga od nas wyodrębnienia dwóch obrazów z wyrenderowanej sceny. Moglibyśmy renderować scenę dwukrotnie, renderując do innych framebufferów z różnymi shaderami, ale możemy również użyć zgrabnej sztuczki o nazwie Multiple Render Targets (MRT), która pozwala nam określić więcej niż jedno wyjście dla Fragment Shadera; daje nam to opcję wyodrębnienia pierwszych dwóch obrazów w jednym przejściu renderowania. Określając specyfikator layout położenia przed zmienną wyjściową Fragment Shadera, możemy kontrolować, do którego bufora kolorów zapisuje Fragment Shader:

    layout (location = 0) out vec4 FragColor;
    layout (location = 1) out vec4 BrightColor;  

Działa to jednak tylko wtedy, gdy mamy gdzie zapisywać te dane. Jako wymóg użycia wielu zmiennych wyjściowych Fragment Shadera potrzebujemy wielu buforów kolorów dołączonych do aktualnie powiązanego obiektu bufora ramki. Możesz pamiętać z samouczka framebuffers, że możemy określić załącznik podczas łączenia tekstury z buforem kolorów framebuffera. Do tej pory zawsze używaliśmy GL_COLOR_ATTACHMENT0, ale używając również GL_COLOR_ATTACHMENT1 możemy mieć dwa bufory kolorów dołączone do obiektu framebuffer:

    // set up floating point framebuffer to render scene to
    unsigned int hdrFBO;
    glGenFramebuffers(1, &hdrFBO);
    glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
    unsigned int colorBuffers[2];
    glGenTextures(2, colorBuffers);
    for (unsigned int i = 0; i < 2; i++)
    {
        glBindTexture(GL_TEXTURE_2D, colorBuffers[i]);
        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_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
        // attach texture to framebuffer
        glFramebufferTexture2D(
            GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D, colorBuffers[i], 0
        );
    }  

Musimy wyraźnie powiedzieć OpenGL, że renderujemy do wielu buforów kolorów za pośrednictwem glDrawBuffers, ponieważ w przeciwnym razie OpenGL wyrenderuje tylko do pierwszego załącznika koloru bufora ramki, ignorując wszystkie inne. Możemy to zrobić, przekazując szereg wyrażeń załączników kolorów, do których chcielibyśmy wyrenderować w kolejnych operacjach:

    unsigned int attachments[2] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 };
    glDrawBuffers(2, attachments);  

Podczas renderowania do tego bufora ramki, za każdym razem kiedy Fragment Shader używa specyfikatora położenia layoutu, odpowiedni bufor koloru jest używany do renderowania fragmentów. Jest to świetne, ponieważ pozwala nam to zaoszczędzić jeden etap renderowania w celu wydobycia jasnych regionów, ponieważ możemy teraz bezpośrednio wyodrębnić je z fragmentu, który ma być renderowany:

    #version 330 core
    layout (location = 0) out vec4 FragColor;
    layout (location = 1) out vec4 BrightColor;

    [...]

    void main()
    {            
        [...] // first do normal lighting calculations and output results
        FragColor = vec4(lighting, 1.0);
        // check whether fragment output is higher than threshold, if so output as brightness color
        float brightness = dot(FragColor.rgb, vec3(0.2126, 0.7152, 0.0722));
        if(brightness > 1.0)
            BrightColor = vec4(FragColor.rgb, 1.0);
        else
            BrightColor = vec4(0.0, 0.0, 0.0, 1.0);
    }

Tutaj najpierw obliczamy oświetlenie i przekazujemy je do pierwszej zmiennej wyjściowej Fragment Shadera FragColor. Następnie używamy tego, co jest aktualnie przechowywane w FragColor, aby określić, czy jego jasność przekracza określoną wartość progową. Obliczamy jasność fragmentu, odpowiednio przekształcając go do skali szarości (pobierając iloczyn skalarny obu wektorów, efektywnie mnożymy każdy składnik obu wektorów i sumujemy wyniki) i jeśli przekracza on określony próg, wyprowadzamy kolor do drugiego bufora kolorów, który przechowuje wszystkie jasne obszary; podobnie do renderowania kostek światła.

To pokazuje również, dlaczego bloom działa niesamowicie dobrze z renderowaniem HDR. Ponieważ renderujemy w HDR, wartości kolorów mogą przekraczać 1.0, co pozwala nam określić próg jasności poza domyślnym zakresem, dając nam znacznie większą kontrolę nad tym, jaki obraz jest uważany za jasny. Bez HDR musielibyśmy ustawić próg niższy niż 1.0, który jest nadal możliwy, ale regiony są o wiele szybciej uważane za jasne, co czasami prowadzi do zbyt dużego dominującego efektu blasku (na przykład białego świecącego śniegu).

Wewnątrz dwóch buforów kolorów mamy obraz normalny sceny i obraz wyodrębnionych jasnych regionów; wszystkie uzyskane w pojedynczym etapie renderowania.

Obraz dwóch buforów kolorów uzyskanych z pojedynczego przejścia renderowania z wieloma kolorowymi załącznikami dla efektu bloom w OpenGL

Musimy teraz rozmazać obraz wyodrębnionych jasnych kolorów. Możemy to zrobić za pomocą prostego filtru, tak jak zrobiliśmy to w sekcji post-processingu tutoriala framebuffers, ale użyjemy bardziej zaawansowanego i lepiej wyglądającego filtru rozmycia o nazwie rozmycie Gaussa (ang. Gaussian blur).

Rozmycie Gaussa

W rozmyciu z sekcji post-processing tutoriala framebuffers, po prostu wzięliśmy średnią wszystkich otaczających pikseli obrazu. Podczas gdy daje nam to łatwe rozmycie, nie daje najlepszych rezultatów. Rozmycie Gaussa opiera się na krzywej Gaussa, która jest zwykle opisywana jako krzywa w kształcie dzwona, dająca duże wartości zbliżone do jej centrum, które stopniowo zanikają wraz z odległością. Krzywa Gaussa może być matematycznie reprezentowana w różnych postaciach, ale generalnie ma następujący kształt:

Obraz krzywej Gaussa wykorzystywany do rozmycia obrazu bloom w OpenGL

Ponieważ krzywa Gaussa ma większy obszar w pobliżu jej środka, użycie wartości jako wagi do rozmycia obrazu daje świetne wyniki, ponieważ próbki w pobliżu mają wyższy priorytet. Jeśli np. spróbkujemy okienko 32x32 wokół fragmentu, używamy stopniowo mniejszych wag, im większa jest odległość od fragmentu; generalnie daje to lepsze i bardziej realistyczne rozmycie, znane jako rozmycie Gaussa.

Aby zaimplementować filtr rozmycia Gaussa, potrzebujemy dwuwymiarowego zestawu wag, które możemy uzyskać z dwuwymiarowego równania krzywej Gaussa. Problem z tym podejściem polega jednak na tym, że szybko staje się on niezwykle ciężki pod względem wydajności. Weźmy na przykład rozmycie jądra o rozmiarze 32x32, to wymagałoby od nas pobrania próbki tekstury 1024 razy dla każdego fragmentu!

Na szczęście dla nas równanie Gaussa ma bardzo schludną właściwość, która pozwala nam podzielić równanie dwuwymiarowe na dwa mniejsze równania: jedno opisujące wagi poziome, a drugie opisujące wagi pionowe. Najpierw wykonujemy poziome rozmycie z poziomymi wagami na całej teksturze, a następnie na wynikowej teksturze wykonamy pionowe rozmycie. Z powodu tej właściwości wyniki są dokładnie takie same, ale oszczędzają nam wydajności, ponieważ teraz musimy zrobić tylko 32 + 32 próbki w porównaniu do 1024! Jest to znane jako dwustopniowe rozmycie Gaussa (ang. two-pass Gaussian blur).

Obraz dwustopniowego rozmycia Gaussa z takimi samymi efektami jak normalne rozmycie gaussowskie, ale teraz pozwala zaoszczędzić dużo wydajności w OpenGL

Oznacza to, że musimy rozmazać obraz co najmniej dwa razy, a to działa najlepiej przy ponownym użyciu obiektów bufora ramki. W szczególności do implementacji rozmycia Gaussa zamierzamy wdrożyć framebuffery ping-pong. Jest to para framebufferów, do których renderujemy określoną liczbę razy za kazdym razem zmieniając przeznaczenie buforów - najpierw do jednego zapisujemy, a z drugiego odczytujemy, a potem odwrotnie. Pozwala nam to najpierw rozmazać teksturę sceny w pierwszym buforze ramek, następnie rozmazać teksturę w drugim buforze ramki, potem rozmazać teksturę w pierwszym buforze ramki i tak dalej.

Zanim zagłębimy się w framebuffery, najpierw omówmy Fragment Shader rozmycia Gaussa:

    #version 330 core
    out vec4 FragColor;

    in vec2 TexCoords;

    uniform sampler2D image;

    uniform bool horizontal;
    uniform float weight[5] = float[] (0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216);

    void main()
    {             
        vec2 tex_offset = 1.0 / textureSize(image, 0); // gets size of single texel
        vec3 result = texture(image, TexCoords).rgb * weight[0]; // current fragment's contribution
        if(horizontal)
        {
            for(int i = 1; i < 5; ++i)
            {
                result += texture(image, TexCoords + vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
                result += texture(image, TexCoords - vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
            }
        }
        else
        {
            for(int i = 1; i < 5; ++i)
            {
                result += texture(image, TexCoords + vec2(0.0, tex_offset.y * i)).rgb * weight[i];
                result += texture(image, TexCoords - vec2(0.0, tex_offset.y * i)).rgb * weight[i];
            }
        }
        FragColor = vec4(result, 1.0);
    }

Tutaj bierzemy stosunkowo niewielką próbkę wag Gaussa, której używamy do przypisania określonej wagi poziomym lub pionowym próbkom wokół bieżącego fragmentu. Widać, że zasadniczo podzieliliśmy filtr rozmycia na sekcję poziomą i pionową w oparciu o dowolną wartość, jaką ustawimy w uniformie horizontal. Oparliśmy odległość przesunięcia na dokładnym rozmiarze teksela uzyskanego przez podział 1.0 przez rozmiar tekstury (uzyskany jako vec2 z textureSize).

W celu rozmazania obrazu tworzymy dwa podstawowe framebuffery, z których każdy ma tylko teksturę koloru:

    unsigned int pingpongFBO[2];
    unsigned int pingpongBuffer[2];
    glGenFramebuffers(2, pingpongFBO);
    glGenTextures(2, pingpongBuffer);
    for (unsigned int i = 0; i < 2; i++)
    {
        glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[i]);
        glBindTexture(GL_TEXTURE_2D, pingpongBuffer[i]);
        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_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
        glFramebufferTexture2D(
            GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, pingpongBuffer[i], 0
        );
    }

Następnie, po uzyskaniu tekstury HDR i wyodrębnieniu jasności, najpierw wypełniamy jeden z buforów ramki ping-pong teksturą jasności, a następnie rozmazujemy obraz 10 razy (5 razy w poziomie i 5 razy w pionie):

    bool horizontal = true, first_iteration = true;
    int amount = 10;
    shaderBlur.use();
    for (unsigned int i = 0; i < amount; i++)
    {
        glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[horizontal]); 
        shaderBlur.setInt("horizontal", horizontal);
        glBindTexture(
            GL_TEXTURE_2D, first_iteration ? colorBuffers[1] : pingpongBuffers[!horizontal]
        ); 
        RenderQuad();
        horizontal = !horizontal;
        if (first_iteration)
            first_iteration = false;
    }
    glBindFramebuffer(GL_FRAMEBUFFER, 0); 

W każdej iteracji wiążemy jeden z dwóch buforów ramki w zależności od tego, czy chcemy zastosować rozmycie w poziomie czy w pionie i wiążemy bufor kolorów drugiego bufora ramki z rozmytą teksturą. W pierwszej iteracji wiążemy konkretnie teksturę, którą chcielibyśmy rozmazać (brightnessTexture), ponieważ oba bufory kolorów mogłyby zostać puste. Powtarzając ten proces dziesięciokrotnie obraz jasności kończy jako pełne rozmyciem Gaussa, które zostało powtórzone 5 razy. Ta konstrukcja pozwala nam rozmazywać dowolny obraz tak często, jak chcemy; im więcej iteracji rozmycia Gaussa, tym silniejsze rozmycie.

Rozmywając wyodrębnioną teksturę jasności 5 razy uzyskujemy odpowiednio zamazany obraz wszystkich jasnych obszarów sceny.

Rozmazany obraz za pomocą efektu gaussowskiego z wyodrębnionych obszarów jasności dla efektu bloom w OpenGL

Ostatnim krokiem do zakończenia efektu bloom jest połączenie tej rozmazanej tekstury jasności z oryginalną teksturą HDR sceny.

Łączenie obu tekstur

Musimy teraz połączyć teksturę HDR sceny i rozmazaną teksturę jasności sceny, aby uzyskać efekt bloom. W ostatecznym Fragment Shader (w dużym stopniu podobnym do tego, którego użyliśmy w samouczku HDR, łączymy obie tekstury poprzez ich dodanie:

    #version 330 core
    out vec4 FragColor;

    in vec2 TexCoords;

    uniform sampler2D scene;
    uniform sampler2D bloomBlur;
    uniform float exposure;

    void main()
    {             
        const float gamma = 2.2;
        vec3 hdrColor = texture(scene, TexCoords).rgb;      
        vec3 bloomColor = texture(bloomBlur, TexCoords).rgb;
        hdrColor += bloomColor; // additive blending
        // tone mapping
        vec3 result = vec3(1.0) - exp(-hdrColor * exposure);
        // also gamma correct while we're at it       
        result = pow(result, vec3(1.0 / gamma));
        FragColor = vec4(result, 1.0);
    }  

Interesujące jest to, że dodajemy efekt bloom przed zastosowaniem mapowania tonalnego. W ten sposób dodana jasność bloom jest również łagodnie przekształcana w zakres LDR z lepszym oświetleniem względnym.

Po dodaniu obu tekstur wszystkie jasne obszary naszej sceny zyskują teraz odpowiedni efekt poświaty:

Przykład efektu post-processingu Bloom lub Glow w OpenGL z HDR

Kolorowe kostki wydają się teraz o wiele jaśniejsze i dają lepszą iluzję jakoby były obiektami emitującymi światło. Jest to stosunkowo prosta scena, więc efekt bloom nie jest tutaj zbyt imponujący, ale w dobrze oświetlonych scenach może to zrobić znaczącą różnicę, gdy jest on poprawnie skonfigurowany. Możesz znaleźć kod źródłowy tego prostego demo tutaj.

W tym samouczku wykorzystaliśmy stosunkowo prosty filtr rozmycia Gaussa, w którym pobieramy tylko 5 próbek w każdym kierunku. Pobranie większej ilości próbek wzdłuż większego promienia lub powtórzenie filtru rozmycia może dodatkowo poprawić efekt rozmycia. Ponieważ jakość rozmycia bezpośrednio koreluje z jakością efektu bloom, polepszenie stopnia rozmycia może spowodować znaczną poprawę efektu bloom. Niektóre z tych ulepszeń łączą filtry rozmycia z jądrami o różnych rozmiarach lub wykorzystują wiele krzywych Gaussa do selektywnego łączenia wag. Dodatkowe zasoby od Kalogirou i EpicGames omawiają, w jaki sposób znacząco poprawić efekt bloom, poprawiając rozmycie Gaussa.

Dodatkowe materiały