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

Domyślnie wartości jasności i koloru są obcinane do wartości pomiędzy 0.0 i 1.0, gdy są zapisywane do bufora ramki. To na pozór niewinne stwierdzenie sprawia, że zawsze określamy wartości światła i koloru gdzieś w tym zakresie, próbując dopasować je do sceny. Działa to dobrze i daje przyzwoite wyniki, ale co się stanie, jeśli będziemy chodzić w szczególnie jasnym obszarze z wieloma jasnymi źródłami światła, które w sumie przekraczają wartość 1.0? Odpowiedź brzmi, że wszystkie fragmenty, których jasność lub suma kolorów przekracza 1.0, zostają obcięte do 1.0, co nie jest zbyt miłe do oglądania:

Wartości kolorów obcięte w jasnych obszarach

Ze względu na dużą liczbę wartości kolorów fragmentów obciętych do wartości 1.0 każdy z jasnych fragmentów ma dokładnie taki sam biały kolor na dużym obszarze, tracąc znaczną ilość szczegółów i nadając im fałszywy wygląd.

Rozwiązaniem tego problemu byłoby zmniejszenie mocy źródeł światła i upewnienie się, że żaden obszar fragmentów w twojej scenie nie będzie jaśniejszy niż 1.0; nie jest to dobre rozwiązanie, ponieważ zmusza do użycia nierealistycznych parametrów oświetlenia. Lepszym rozwiązaniem jest, aby wartości kolorów tymczasowo przekroczyły wartość 1.0 i przekształcić je z powrotem do oryginalnego zakresu 0.0 i 1.0 w ostatnim kroku, ale bez utraty szczegółów.

Monitory są ograniczone do wyświetlania kolorów w zakresie 0.0 i 1.0, ale nie ma takiego ograniczenia w równaniach oświetlenia. Dzięki temu, że kolory fragmentów mogą przekroczyć 1.0, mamy znacznie większy zakres wartości kolorów znany jako high dynamic range (HDR). Przy HDR jasne rzeczy mogą być naprawdę jasne, ciemne mogą być naprawdę ciemne, a szczegóły można zobaczyć w obu przypadkach.

HDR był pierwotnie używany tylko w fotografii, gdzie fotograf robił wiele zdjęć tej samej sceny z różnymi poziomami ekspozycji, rejestrując duży zakres wartości kolorów. Te połączone obrazy tworzą obraz HDR, w którym duży zakres szczegółów jest widoczny na podstawie połączonych poziomów ekspozycji lub określonej ekspozycji, z jaką jest oglądany. Na przykład obraz poniżej pokazuje wiele szczegółów w jasno oświetlonych regionach z niską ekspozycją (patrz okno), ale te szczegóły zniknęły przy dużej ekspozycji. Jednak wysoka ekspozycja ujawnia teraz dużą ilość szczegółów w ciemniejszych regionach, które wcześniej nie były widoczne.

Obraz HDR z wieloma poziomami ekspozycji i ich odpowiednimi szczegółami

Jest to również bardzo podobne do działania ludzkiego oka i podstawą renderowania HDR. Kiedy jest mało światła, ludzkie oko dostosowuje się, więc ciemniejsze części są znacznie lepiej widoczne i podobnie jest dla jasnych obszarów, tak jakby ludzkie oko miało automatyczny suwak ekspozycji w oparciu o jasność sceny.

Renderowanie z wykorzystaniem HDR działa trochę tak: pozwalamy na uzyskanie znacznie większego zakresu wartości kolorów w celu zebrania szerokiego zakresu ciemnych i jasnych szczegółów sceny, a na końcu przekształcamy wszystkie wartości HDR z powrotem na low dynamic range (LDR) [0.0, 1.0]. Ten proces przekształcania wartości HDR w wartości LDR nazywa się mapowaniem tonalnym (ang. tone mapping) i istnieje duża kolekcja algorytmów mapowania tonalnego, które mają na celu zachowanie większości szczegółów HDR podczas procesu konwersji. Te algorytmy mapowania tonalnego często zawierają parametr ekspozycji, który selektywnie faworyzuje ciemne lub jasne obszary.

Jeśli chodzi o rendering w czasie rzeczywistym, HDR pozwala nam nie tylko przekroczyć zakres LDR [0.0, 1.0] i zachować więcej szczegółów, ale także daje nam możliwość określenia intensywności źródła światła przez realnej intensywności. Na przykład słońce ma znacznie większą intensywność niż latarka, więc dlaczego nie skonfigurować słońca takiego jakie jest (np. z jasnością diffuse 10.0). To pozwala nam na lepsze skonfigurowanie oświetlenia sceny z bardziej realistycznymi parametrami oświetlenia, co nie byłoby możliwe przy renderowaniu LDR, ponieważ wtedy zostaną one bezpośrednio obcięte do 1.0.

Ponieważ monitory wyświetlają tylko kolory w zakresie od 0.0 do 1.0, musimy przekształcić wartości kolorów HDR z powrotem do zakresu monitora LDR. Po prostu ponowne przekształcenie kolorów za pomocą prostego uśredniania wartości nie przyniesie nam zbyt wiele dobrego, ponieważ jaśniejsze obszary stają się bardziej dominujące. Możemy jednak użyć różnych równań i/lub krzywych, aby przekształcić wartości HDR z powrotem w LDR, co daje nam pełną kontrolę nad jasnością sceny. Jest to proces wcześniej określony jako mapowanie tonalne i jest to ostatni krok renderowania HDR.

Zmiennoprzecinkowe framebuffery

Aby zaimplementować renderowanie HDR, potrzebujemy jakiegoś sposobu, aby zapobiec obcinaniu wartości kolorów po uruchomieniu każdego Fragment Shadera. Gdy framebuffery używają znormalizowanego formatu koloru (np. GL_RGB) jako wewnętrznego formatu bufora kolorów OpenGL automatycznie obcina wartości do zakresu pomiędzy 0.0 i 1.0 przed zapisaniem ich w buforze ramki. Ta operacja dotyczy większości typów formatów bufora ramki, z wyjątkiem formatów zmiennoprzecinkowych, które są używane dla ich rozszerzonego zakresu wartości.

Gdy wewnętrzny format bufora kolorów bufora ramki jest określony jako GL_RGB16F, GL_RGBA16F, GL_RGB32F lub GL_RGBA32F znany jest jako zmiennoprzecinkowy framebuffer, który może przechowywać wartości zmiennoprzecinkowe poza domyślnym zakresem wartości 0.0 i 1.0. Jest to idealne do renderowania w HDR!

Aby utworzyć zmiennoprzecinkowy bufor ramki, jedyną rzeczą, którą musimy zmienić, jest parametr formatu wewnętrznego bufora kolorów:

    glBindTexture(GL_TEXTURE_2D, colorBuffer);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL);  

Domyślny framebuffer OpenGL (domyślnie) zajmuje tylko 8 bitów na składnik koloru. Dzięki zmiennoprzecinkowemu framebufferowi z 32 bitami na składnik koloru (przy użyciu GL_RGB32F lub GL_RGBA32F) używamy 4 razy więcej pamięci do przechowywania wartości kolorów. Ponieważ 32 bity nie są tak naprawdę potrzebne, chyba że potrzebujesz wysokiego poziomu precyzji, wystarczy użyć GL_RGBA16F.

Dzięki zmiennoprzecinkowemu buforowi kolorów dołączonemu do bufora ramki możemy teraz renderować scenę do tego bufora ramki, wiedząc, że wartości kolorów nie zostaną obcięte do zakresu między 0.0 i 1.0. W przykładzie demonstracyjnym tego samouczka najpierw renderujemy oświetloną scenę w zmiennoprzecinkowym buforze ramki, a następnie wyświetlamy bufor kolorów bufora ramki na wypełnionym kwadracie pełnoekranowym; będzie wyglądać to tak:

    glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);  
        // [...] render (lighted) scene 
    glBindFramebuffer(GL_FRAMEBUFFER, 0);

    // now render hdr colorbuffer to 2D screen-filling quad with different shader
    hdrShader.use();
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, hdrColorBufferTexture);
    RenderQuad();

Tutaj wartości kolorów sceny są wprowadzane do bufora kolorów zmiennoprzecinkowych, który może zawierać dowolną wartość koloru, prawdopodobnie przekraczającą 1.0. W tym samouczku powstała prosta scena demo z dużym rozciągniętym sześcianem działającym jak tunel z czterema punktowymi światłami, z których jedno jest bardzo jasne i znajduje się na końcu tunelu:

    std::vector<glm::vec3> lightColors;
    lightColors.push_back(glm::vec3(200.0f, 200.0f, 200.0f));
    lightColors.push_back(glm::vec3(0.1f, 0.0f, 0.0f));
    lightColors.push_back(glm::vec3(0.0f, 0.0f, 0.2f));
    lightColors.push_back(glm::vec3(0.0f, 0.1f, 0.0f));  

Rendering do zmiennoprzecinkowego framebuffera jest dokładnie taki sam jak taki sam jak rendering do normalnego framebuffera. Nowością jest Fragment Shader hdrShader, który renderuje ostateczny kwadrat 2D z dołączoną teksturą bufora zmiennoprzecinkowego. Najpierw określmy prosty Fragment Shader:

    #version 330 core
    out vec4 FragColor;

    in vec2 TexCoords;

    uniform sampler2D hdrBuffer;

    void main()
    {             
        vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
        FragColor = vec4(hdrColor, 1.0);
    }  

Tutaj bezpośrednio próbkujemy zmiennoprzecinkowy bufor kolorów i używamy jego wartości koloru jako wyjścia Fragment Shadera. Jednakże, ponieważ dane wyjściowe kwadratu 2D są bezpośrednio renderowane do domyślnego bufora ramki, wszystkie wartości wyjściowe Fragment Shadera będą obcięte do zakresu między 0.0 i 1.0, mimo że mamy kilka wartości w teksturach zmiennoprzecinkowych przekraczających 1.0.

Bezpośrednie renderowanie wartości kolorów zmiennoprzecinkowych do domyślnego bufora ramki bez mapowania tonalnego

Staje się jasne, że intensywne wartości światła na końcu tunelu są obcięte do 1.0, ponieważ duża ich część jest całkowicie biała, skutecznie tracąc wszystkie szczegóły oświetlenia które przekraczają 1.0. Ponieważ bezpośrednio przekształcamy wartości HDR w wartości LDR, to tak, jakbyśmy nie mieli włączonego HDR. To, co musimy zrobić, aby to naprawić, to przekształcenie wszystkich wartości zmiennoprzecinkowych z powrotem w zakres 0.0 - 1.0 bez utraty jakichkolwiek szczegółów. Musimy zastosować proces o nazwie mapowania tonalnego (ang. tone mapping).

Mapowanie tonalne

Mapowanie tonalne to proces przekształcania wartości kolorów zmiennoprzecinkowych do oczekiwanego zakresu [0,0, 1,0] znanego jako LDR bez utraty zbyt dużej ilości szczegółów, któremu często towarzyszy określony stylistyczny balans kolorów.

Najprostszy algorytm mapowania tonalnego znany jest jako mapowanie tonalne Reinharda i polega na podziale wartości kolorów HDR na wartości kolorów LDR równomiernie równoważąc je wszystkie. Algorytm mapowania tonalnego Reinharda równomiernie rozkłada wszystkie wartości jasności na LDR. Dodamy mapowanie tonalne Reinharda do poprzedniego Fragment Shadera, a także dodamy korekcję gamma (w tym wykorzystanie tekstur sRGB):

    void main()
    {             
        const float gamma = 2.2;
        vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;

        // reinhard tone mapping
        vec3 mapped = hdrColor / (hdrColor + vec3(1.0));
        // gamma correction 
        mapped = pow(mapped, vec3(1.0 / gamma));

        FragColor = vec4(mapped, 1.0);
    }    

Dzięki zastosowaniu mapowania tonalnego Reinharda nie tracimy już szczegółów w jasnych obszarach naszej sceny. Algorytm ma tendencję do faworyzowania jaśniejszych obszarów, sprawiając, że ciemniejsze regiony wydają się mniej szczegółowe i wyraźne:

Algorytm mapowania tonalnego Reinharda zastosowany do renderowaniem HDR w OpenGL

Ponownie można zobaczyć szczegóły na końcu tunelu, ponieważ wzór tekstury drewna staje się ponownie widoczny. Dzięki temu względnie prostemu algorytmowi mapowania tonalnego możemy właściwie zobaczyć cały zakres wartości HDR przechowywanych w zmiennoprzecinkowym buforze ramki, co daje nam precyzyjną kontrolę nad oświetleniem sceny bez utraty szczegółów.

Zauważ, że mogliśmy również bezpośrednio zastosować mapowanie tonalne na końcu naszego Fragment Shadera, nie potrzebując żadnego zmiennoprzecinkowego bufora ramki! Ponieważ sceny stają się coraz bardziej złożone, często będziesz musiał przechowywać wyniki pośrednie HDR w buforach zmiennoprzecinkowych, więc jest to dobre ćwiczenie.

Innym interesującym zastosowaniem mapowania tonalnego jest umożliwienie użycia parametru ekspozycji. Prawdopodobnie pamiętasz ze wstępu, że obrazy HDR zawierają wiele szczegółów widocznych na różnych poziomach ekspozycji. Jeśli mamy scenę z cyklem dziennym i nocnym, sensowne jest używanie mniejszej ekspozycji w świetle dziennym i wyższej ekspozycji w porze nocnej, podobnie jak dostosowuje się ludzkie oko. Przy takim parametrze ekspozycji pozwala nam skonfigurować parametry oświetlenia, które działają zarówno w dzień, jak i w nocy w różnych warunkach oświetleniowych, ponieważ musimy jedynie zmienić parametr ekspozycji.

Stosunkowo prosty algorytm mapowania tonalnego wykorzystujący ekspozycję wygląda następująco:

    uniform float exposure;

    void main()
    {             
        const float gamma = 2.2;
        vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;

        // Exposure tone mapping
        vec3 mapped = vec3(1.0) - exp(-hdrColor * exposure);
        // Gamma correction 
        mapped = pow(mapped, vec3(1.0 / gamma));

        FragColor = vec4(mapped, 1.0);
    }  

Tutaj zdefiniowaliśmy uniform exposure, który domyślnie przyjmuje wartość 1.0 i pozwala nam precyzyjniej określić, czy chcielibyśmy bardziej skupić się na ciemnych czy jasnych regionach wartości kolorów HDR. Na przykład, przy wysokich wartościach ekspozycji ciemniejsze obszary tunelu wykazują znacznie więcej szczegółów. W przeciwieństwie do tego, niska ekspozycja w dużym stopniu usuwa szczegóły ciemnego regionu, ale pozwala nam zobaczyć więcej szczegółów w jasnych obszarach sceny. Rzuć okiem na poniższy obraz, aby zobaczyć tunel o różnych poziomach ekspozycji:

Wiele poziomów ekspozycji mapowania tonów HDR w OpenGL

Ten obraz wyraźnie pokazuje korzyści płynące z renderingu HDR. Zmieniając poziom ekspozycji, widzimy wiele szczegółów naszej sceny, które w innym przypadku zostałyby utracone przy LDR. Na przykład na końcu tunelu, przy normalnej ekspozycji struktura drewna jest ledwo widoczna, ale przy niewielkiej ekspozycji drewniane wzory są wyraźnie widoczne. To samo dotyczy drewnianych wzorów w pobliżu, które są znacznie lepiej widoczne przy wysokiej ekspozycji.

Możesz znaleźć kod źródłowy wersji demonstracyjnej tutaj.

Więcej HDR

Pokazane algorytmy mapowania tonalnego to tylko garstka z dużej kolekcji (bardziej zaawansowanych) algorytmów mapowania tonalnego, z których każdy ma swoje mocne i słabe strony. Niektóre algorytmy mapowania tonalnego faworyzują pewne kolory/intensywności niż inne, a niektóre algorytmy wyświetlają równocześnie kolory o niskiej i wysokiej ekspozycji, aby uzyskać bardziej kolorowe i szczegółowe obrazy. Istnieje również zbiór technik znanych jako automatyczna regulacja ekspozycji (ang. automatic exposure adjustment) lub adaptacja oka (ang. eye adaptation), które określają jasność sceny w poprzedniej klatce i (powoli) dostosowują parametr ekspozycji, np. scena staje się jaśniejsza w ciemnych obszarach lub ciemniejsza w jasnych obszarach naśladujących ludzkie oko.

Prawdziwa korzyść z renderowania HDR naprawdę pokazuje się w dużych i złożonych scenach z ciężkimi algorytmami oświetlenia. Ponieważ trudno jest stworzyć tak złożoną scenę demo do celów dydaktycznych, przy zachowaniu jej dostępności scena demo tutoriala jest niewielka i brakuje jej szczegółów. Chociaż jest stosunkowo prosta, pokazuje niektóre zalety renderowania HDR: żadne szczegóły nie są tracone w obszarach jasnych i ciemnych, ponieważ można je odzyskać za pomocą mapowania tonalnego, a dodanie wielu świateł nie powoduje utraty szczegółów i wartości światła można określić za pomocą ich oryginalnych wartości jasności nie ograniczonych przez wartości LDR. Co więcej, renderowanie HDR sprawia, że ​​kilka interesujących efektów jest bardziej wykonalnych i realistycznych. Jednym z tych efektów jest bloom, o którym porozmawiamy w następnym samouczku.

Dodatkowe materiały