Table of contents
🌐 Translate this post:

上一次實作了法線與視差貼圖,也玩了一遍TBN矩陣,這次接著實作HDR與Bloom

Advanced Lighting - HDR


float point frame buffer

  • 一般的螢幕,每個像素的顏色範圍只能介於0到1之間,這個範圍又稱為LDR(low dynamic range)。
  • 在做光照時的各種計算時,實際上並不期待輸出一定會介於0到1之間,我們需要更大的範圍的值才能表達出不同類型的光照強度(尤其是到了PBR時代)。這時候就會需要讓畫面能在HDR(high dynamic range)的範圍內做計算,
  • 首先要先讓Pixel Shader輸出的值能超過0~1,所以要從frame buffer下手,預設的GL_RGBA,每個channel有8bit,範圍為0-1,將internal format改成GL_RGBA16F或32F就可以將這個frame buffer視為float point framebuffer,這樣用pixel shader渲染到該frame buffer時,就可以儲存HDR範圍的值了,
**glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, windowWidth, windowHeight, 0, GL_RGBA, GL_FLOAT, NULL);**

改完之後立刻來實驗看看 :

設定8個光源,各自輸出一個隨機顏色,每個顏色的channel值介於0~3之間

Untitled.png

除了前排(左2)之外,幾乎都是白色的,這是因為雖然frame buffer已經改為可以HDR範圍,但是螢幕本身還是LDR的,

  • 單單只是改Frame buffer並不夠,改成float point frame buffer讓pixel shader能在輸出HDR範圍的顏色後,要在顯示到畫面前透過Tone Mapping的方式將整個HDR的畫面重新映射到LDR的範圍內,再顯示到螢幕上。

Tone Mapping

Tone Mapping的工作就是把HDR的數值重新映射回0到1的範圍內,映射的公式有很多種。目前這個小專案就是照抄LearnOpenGL的Code,

vec3 hdrColor = texture(hdrBuffer, uv).rgb;
// exposure tone mapping
vec3 mappedColor = vec3(1.0) - exp(-hdrColor * _Exposure);

//gamma correction
//...

最後拿上面八個光源的場景再測試一次,可以看出現在能夠看出不同光源間各自不同的顏色了。

Untitled.png

所以簡單來說渲染一個HDR畫面的流程就是

pixel shader輸出到一個支援HDR的frame buffer

回到default frambuffer後HDR frame buffer當成貼圖,貼到一個全螢幕的quad上做Tone Mapping(映射回0~1範圍)

Gamma Correction

螢幕顯示。

嘗試不同的Tone Mapping

UE預設有Filmic ToneMapping,Unity預設則是有兩種(Neutral跟ACES)可以選擇(這兩種我都不太不喜歡…),有公式的話其實就是一個post effect的效果,要自己加進專案是很容易的。之前在Unity做卡通渲染有試過跑車浪滿旅使用的曲線(已經有人實作進Unity → https://github.com/yaoling1997/GT-ToneMapping),效果還不錯。

雖然筆記短短的,不過Tone Mapping是現在渲染遊戲時非常非常重要的一環,適合的Tone Mapping可以讓畫面提升不只一個檔次,一個醜醜的Unity專案配上適合的LUT跟Tone Mapping其實會瞬間讓畫面品質提升非常多,對馬戰鬼的開發者在SIGGRAPH簡報中也有介紹他們針對場景環境做出不同的Tone Mapping與LUT組合

Advanced Lighting - Bloom(泛光)


LearnOpenGL實作完HDR,接著實做過去被濫用到爆開的Bloom後處理,Bloom透過將高亮度物體的顏色向外暈開做出光暈,視覺上就會產生這個物體會更加明亮的感覺。

bloom_example_%281%29.png

UE的bloom

Bloom這個效果並不跟HDR綁定,也就是說你可以完全不用float point frame buffer的同時來實做bloom,但是Bloom很適合跟HDR一起用,HDR畫面→開Bloom→Tone Mapping,就可以防止過曝以及過曝區域過大的發生。

實做

Bloom的原理超簡單,其實就是把需要有光暈的部分輸出成另一張貼圖,然後對這貼圖做模糊,接著將模糊後的貼圖疊加到畫面上。流程如下圖

Untitled.png

實做bloom有兩個地方的code要特別處理,繪製高亮區mask以及實作模糊。

這章節在繪製mask的部分使用MRT(Multiple Render Targets),直接讓frame buffer object綁定兩個輸出的渲染對象,這樣就不需要再寫額外的pass去畫mask了,在bind這個frame buffer後,pixel shader內可以定義多個out變數來將畫出的結果輸出到不同的渲染對象中。

MRT

OpenGL的code

    unsigned int hdrFBO;
    glGenFramebuffers(1, &hdrFBO);
    glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
    unsigned int colorBuffers[2];
    glGenTextures(2, colorBuffers);
    for(int i = 0; i < 2;i++)
    {
        glBindTexture(GL_TEXTURE_2D, colorBuffers[i]);
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, windowWidth, windowHeight, 0, GL_RGBA, 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 + i, GL_TEXTURE_2D, colorBuffers[i], 0);
    }
//rbo
//...
//指定shader內不同的out輸出的對象
unsigned int attachments[2] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 };
glDrawBuffers(2, attachments);

Pixel Shader中可以定義多個out

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

最後把畫面上的RGB顏色轉換成Luminance來當成是否輸入到Mask的依據

loat luminance = 0.2126 * finalColor.r + 0.7152 * finalColor.g + 0.0722 * finalColor.b;
vec3 brightColor = vec3(0,0,0);
if(luminance > _bloomThreshold)
{
    brightColor = vec3(finalColor.rgb);
}
BrightColor = vec4(brightColor.rgb,1);

這裡的公式可以在wiki上找到說明,手工一點的話也可以用自定義的,比如將mask值存在vertex color或者貼圖中來指定bloom的範圍。

在Unity URP中,有個ConfigureTarget可以塞多個render target給它,來達成MRT

Blur Pass

模糊這種效果基本上會用到影像處理中的convolution kernal,

在繪製每個pixel時,對四周的pixel(根據kernal大小)各做一次採樣,並且將每個採樣的結果乘上kernal上對應的權重在全部相加,結果仍然會等於一,下面這張是一個隨便找的kernal範例XD

Untitled.png

高斯模糊使用的就是…高斯模糊的kernal,照抄網路上數值就行,另外高斯模糊的kernal是可以拆成一維的,比如說今天有一個5x5的kernal,如果在一個shader draw內就要完成,每個pixel要計算5*5次,而如果將模糊這個步驟拆成兩個pass,一個pass只負責kernal上橫向的採樣,另一個pass負責縱向的,這樣對一次完整的模糊來說,每個pixel合計只要計算5+5次,可以節省非常多效能。

對GPU來說這種2 pass的做法,是很常見的技巧,因為比起一個肥厚的for loop,有時候將計算拆成多個pass處理更符合GPU的特性

        int amount = 10;

        blurShader.use();
        //總共交替10次,橫向5次,縱向5次
        for (unsigned int i = 0; i < amount; i++)
        {
            //在pingpongFBO[0]與pingpongFBO[1]間交互的畫
            glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[horizontal]); 
            blurShader.SetInt1("horizontal", horizontal);
            glBindVertexArray(quadVAO);
            glActiveTexture(GL_TEXTURE0);

            //把上一個pass畫完的結果帶到目前的pass中
            glBindTexture(
                GL_TEXTURE_2D, first_iteration ? colorBuffers[1] : pingpongColorbuffers[!horizontal]
            ); 
            glDrawArrays(GL_TRIANGLES, 0, 6);
            
            horizontal = !horizontal;
            if (first_iteration)
                first_iteration = false;
        }

把那張bloom mask跑完上面這個for loop之後就可以取得模糊後的結果了。

如果是HDR的,只要再配合Tone Mapping整個畫面就算大功告成了。

Untitled.png

結論

可以看出影響bloom效能與結果的關鍵就是模糊的方式了,模糊的方法很多,可以參考毛星雲大大整理的這篇。另外近年UE4有推出基於FFT的bloom,甚至可以控制光暈的形狀,滿厲害der。

最後,緬懷一下bloom被濫用的年代的遊戲節圖 (Need For Speed : Most Wanted 2005)

Untitled.png

參考資料

https://learnopengl.com/Advanced-Lighting/Bloom

A Brief History of Graphics

Real-Time Samurai Cinema

高品质后处理:十种图像模糊算法的总结与实现