WebGL學習之HDR與Bloom

Jeff.Zhong發表於2019-05-21

什麼是HDR

HDR (High Dynamic Range,高動態範圍),在攝影領域,指的是可以提供更多的動態範圍和影像細節的一種技術手段。簡單講就是將不同曝光拍攝出的最佳細節的LDR (低動態範圍) 影像合成後,就叫HDR,它能同時反映出場景最暗和最亮部分的細節。為什麼需要多張圖片?因為目前的單反相機的寬容度還是有限的,一張照片不能反映出高動態場景的所有細節。一張圖片拍攝就必須要在暗光和高光之間做出取捨,只能亮部暗部兩者取其一。但是通過HDR合成多張圖片,卻能達到我們想要的效果。
hdr

那麼在WebGL中,HDR具體指的是什麼。它指的是讓我們能用超過1.0的資料表示顏色值。到目前為止,我們用的都是LDR(低動態範圍),所有的顏色值都被限制在了 [0,1] 範圍。在現實當中,太陽,燈光這類光源它們的顏色值肯定是遠遠超出1.0的範圍的。

本節實現的效果請看hdr & bloom
hdr & bloom

浮點幀緩衝

當幀緩衝使用標準化的定點格式(像gl.RGB)為其顏色緩衝的內部格式,WebGL會在將這些值存入幀緩衝前自動將其約束到0.0到1.0之間。這一操作對大部分幀緩衝格式都是成立的,除了專門用來存放被擴充範圍值的浮點格式。

WebGL擴大顏色值範圍的方法就是:把顏色的格式設定成16位浮點數或者32位浮點數,即把幀緩衝的顏色緩衝的內部格式設定成 gl.RGB16F, gl.RGBA16F, gl.RGB32F 或者 gl.RGBA32F,這些幀緩衝被叫做浮點幀緩衝(Floating Point Framebuffer),浮點幀緩衝可以儲存超過0.0到1.0範圍的浮點值,所以非常適合HDR渲染。

建立浮點幀緩衝,我們只需要改變顏色緩衝的內部格式引數就行了(注意 gl.FLOAT引數):

gl.bindTexture(gl.TEXTURE_2D, colorBuffer);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, gl.RGB, gl.FLOAT, NULL);  

幀緩衝預設一個顏色分量只佔用8位(bits)。當使用一個使用32位每顏色分量時(使用gl.RGB32F 或者 gl.RGBA32F),我們需要四倍的記憶體來儲存這些顏色。所以除非你需要一個非常高的精確度,32位不是必須的,使用 gl.RGB16F就足夠了。

色調對映

色調對映(Tone Mapping)是一個損失很小的轉換浮點顏色值至我們所需的LDR[0.0, 1.0]範圍內的過程,通常會伴有特定的風格的色平衡(Stylistic Color Balance)。

最簡單的色調對映演算法是Reinhard色調對映,它涉及到分散整個HDR顏色值到LDR顏色值上,所有的值都有對應。Reinhard色調對映演算法平均地將所有亮度值分散到LDR上。將Reinhard色調對映應用到之前的片段著色器上,並且加上一個Gamma校正過濾:

void main()
{             
    const float gamma = 2.2;
    vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
    // Reinhard色調對映
    vec3 mapped = hdrColor / (hdrColor + vec3(1.0));
    // Gamma校正
    mapped = pow(mapped, vec3(1.0 / gamma));
    color = vec4(mapped, 1.0);
}   

有了Reinhard色調對映的應用,我們不再會在場景明亮的地方損失細節。當然,這個演算法是傾向明亮的區域的,暗的區域會不那麼精細也不那麼有區分度。

另一個色調對映應用是曝光(Exposure)引數的使用。HDR圖片包含在不同曝光等級的細節。如果我們有一個場景要展現日夜交替,我們當然會在白天使用低曝光,在夜間使用高曝光,就像人眼調節方式一樣。有了這個曝光引數,我們可以去設定可以同時在白天和夜晚不同光照條件工作的光照引數,我們只需要調整曝光引數就行了。

一個簡單的曝光色調對映演算法會像這樣:

uniform float exposure;

void main()
{             
    const float gamma = 2.2;
    vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
    // 曝光色調對映
    vec3 mapped = vec3(1.0) - exp(-hdrColor * exposure);
    // Gamma校正 
    mapped = pow(mapped, vec3(1.0 / gamma));
    color = vec4(mapped, 1.0);
}  

什麼是Bloom

Bloom 泛光 (或者眩光),是用來模擬光源那種發光或發熱的技術。區分明亮光源的方式是使它們發出光芒,光源的光芒向四周發散,這樣觀察者就會產生光源或亮區的確是強光區。Bloom使我們感覺到一個明亮的物體真的有種明亮的感覺。而Bloom和HDR的結合使用能非常完美地展示光源效果。
bloom

泛光的品質很大程度上取決於所用的模糊過濾器的質量和型別。下面這幾步就是泛光後處理特效的過程,它總結了實現泛光所需的步驟。
泛光處理過程

提取亮色

首先我們要從渲染出來的場景中提取兩張圖片。可以渲染場景兩次,每次使用一個不同的不同的著色器渲染到不同的幀緩衝中,但可以使用一個叫做MRT(Multiple Render Targets多渲染目標)的小技巧,有了它我們能夠在一個單獨渲染處理中提取兩個圖片。在片元著色器的輸出前,我們指定一個佈局location識別符號,這樣我們便可控制一個片元著色器寫入到哪個顏色緩衝:

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

使用多個片元著色器輸出的必要條件是,有多個顏色緩衝附加到了當前繫結的幀緩衝物件上。直到現在,我們一直使用著 gl.COLOR_ATTACHMENT0,但通過使用 gl.COLOR_ATTACHMENT1,可以得到一個附加了兩個顏色緩衝的幀緩衝物件。

但首先我們還是將建立幀緩衝的功能進行封裝:

function createFramebuffer(gl,opt,width,height){  
    const fb = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
    const framebufferInfo = {
        framebuffer: fb,
        textures: []
    };
    const texs = opt.texs || 1;//顏色緩衝數量
    const depth = !!opt.depth;

    // SECTION 建立紋理
    for(let i=0;i< texs;i++){
        const tex = initTexture(gl,opt, width, height);
        framebufferInfo.textures.push(tex);
        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, tex, 0);
    }

    // SECTION 建立用於儲存深度的渲染緩衝區
    if(depth) {
        const depthBuffer = gl.createRenderbuffer();
        gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer);
        gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height);
        gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer);   
    }
    // 檢查幀緩衝區物件
    const e = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
    if (gl.FRAMEBUFFER_COMPLETE !== e) {
        throw new Error('Frame buffer object is incomplete: ' + e.toString());
    }
    // 解綁幀緩衝區物件
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.bindTexture(gl.TEXTURE_2D, null);
    if(depth) gl.bindRenderbuffer(gl.RENDERBUFFER, null);
    return framebufferInfo;
}

接著呼叫上面的函式建立包含兩個顏色附件和一個深度附件的幀緩衝區。

//場景幀快取(2顏色附件 包含正常顏色 和 hdr高光顏色,1深度附件)
const fbo = createFramebuffer(gl,{informat:gl.RGBA16F, type:gl.FLOAT, texs:2, depth:true});

在渲染的時候還需要顯式告知WebGL我們正在通過gl.drawBuffers渲染到多個顏色緩衝,否則WebGL只會渲染到幀緩衝的第一個顏色附件,而忽略所有其他的。

//取樣到2個顏色附件
gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]);

當渲染到這個幀緩衝的時候,一個著色器使用一個佈局location修飾符,然後把不同顏色值渲染到相應的顏色緩衝。這樣就省去了為提取高光區域的額外渲染步驟。

#version 300 es
precision highp float; 
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;
//...

void main() { 
    vec3 normal = normalize(vNormal);
    vec3 viewDirection = normalize(u_viewPosition - vposition);
        //...
        vec3 result = ambient + lighting;
  
    // 檢查結果值是否高於某個門檻,如果高於就渲染到高光顏色快取中
    float brightness = dot(result, vec3(0.2126, 0.7152, 0.0722));
    if(brightness > 1.0){
        BrightColor = vec4(result, 1.0);
    } else {
        BrightColor = vec4(0.0, 0.0, 0.0, 1.0);
    }
    FragColor = vec4(result, 1.0);
}

這裡先正常計算光照,將其傳遞給第一個片元著色器的輸出變數FragColor。然後我們使用當前儲存在FragColor的東西來決定它的亮度是否超過了一定閾限。我們通過恰當地將其轉為灰度的方式計算一個fragment的亮度,如果它超過了一定閾限,我們就把顏色輸出到第二個顏色緩衝,那裡儲存著所有亮部。

這也說明了為什麼泛光在HDR基礎上能夠執行得很好。因為HDR中,我們可以將顏色值指定超過1.0這個預設的範圍,我們能夠得到對一個影像中的亮度的更好的控制權。沒有HDR我們必須將閾限設定為小於1.0的數,雖然可行,但是亮部很容易變得很多,這就導致光暈效果過重。

有了一個提取出的亮區影像,我們現在就要把這個影像進行模糊處理。

高斯模糊

要實現高斯模糊過濾需要一個二維四方形作為權重,從這個二維高斯曲線方程中去獲取它。然而這個過程有個問題,就是很快會消耗極大的效能。以一個32×32的模糊kernel為例,我們必須對每個fragment從一個紋理中取樣1024次!

幸運的是,高斯方程有個非常巧妙的特性,它允許我們把二維方程分解為兩個更小的方程:一個描述水平權重,另一個描述垂直權重。我們首先用水平權重在整個紋理上進行水平模糊,然後在經改變的紋理上進行垂直模糊。利用這個特性,結果是一樣的,但是可以節省難以置信的效能,因為我們現在只需做32+32次取樣,不再是1024了!這叫做兩步高斯模糊。
高斯模糊
這意味著我們如果對一個影像進行模糊處理,至少需要兩步,最好使用幀緩衝物件做這件事。具體來說,我們將實現像乒乓球一樣的幀緩衝來實現高斯模糊。意思是使用一對幀緩衝,我們把另一個幀緩衝的顏色緩衝放進當前的幀緩衝的顏色緩衝中,使用不同的著色效果渲染指定的次數。基本上就是不斷地切換幀緩衝和紋理去繪製。這樣我們先在場景紋理的第一個緩衝中進行模糊,然後在把第一個幀緩衝的顏色緩衝放進第二個幀緩衝進行模糊,接著將第二個幀緩衝的顏色緩衝放進第一個,迴圈往復。

在我們研究幀緩衝之前,先來實現高斯模糊的片元著色器:

#version 300 es
precision highp float;
uniform sampler2D image;
uniform bool horizontal;
in vec2 texcoord;
out vec4 FragColor;
const float weight[5] = float[](0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162);

void main() {
    vec2 tex_offset = vec2(1.0 / float(textureSize(image, 0)));//每個畫素的尺寸
    vec3 result = texture(image, texcoord).rgb * weight[0];
    if (horizontal) {
        for (int i = 0; i < 5; ++i) {
            result += texture(image, texcoord + vec2(tex_offset.x * float(i), 0.0)).rgb * weight[i];
            result += texture(image, texcoord - vec2(tex_offset.x * float(i), 0.0)).rgb * weight[i];
        }
    } else {
        for (int i = 0; i < 5; ++i) {
            result += texture(image, texcoord + vec2(0.0, tex_offset.y * float(i))).rgb * weight[i];
            result += texture(image, texcoord - vec2(0.0, tex_offset.y * float(i))).rgb * weight[i];
        }
    }
    FragColor = vec4 (result, 1.0);
}

這裡使用一個比較小的高斯權重做例子,每次我們用它來指定當前fragment的水平或垂直樣本的特定權重。你會發現我們基本上是將模糊過濾器根據我們在uniform變數horizontal設定的值分割為一個水平和一個垂直部分。通過用1.0除以紋理的大小(從textureSize得到一個vec2)得到一個紋理畫素的實際大小,以此作為偏移距離的根據。

接著為影像的模糊處理建立兩個基本的幀緩衝,每個只有一個顏色緩衝紋理,呼叫上面封裝好的createFramebuffer函式即可。

//2乒乓幀快取(都只包含1顏色附件)
const hFbo = createFramebuffer(gl,{informat:gl.RGBA16F, type:gl.FLOAT});
const vFbo = createFramebuffer(gl,{informat:gl.RGBA16F, type:gl.FLOAT});

得到一個HDR紋理後,我們用提取出來的亮區紋理填充一個幀緩衝,然後對其模糊處理6次(3次垂直3次水平):

/**
 * 乒乓幀快取
 */
gl.useProgram(pProgram.program);
for(let i=0; i < 6; i++){
    bindFramebufferInfo(gl, i%2 ? hFbo:vFbo);
    setBuffersAndAttributes(gl, pProgram, pVao);
    setUniforms(pProgram,{
        horizontal: i%2? true:false,
        image: i == 0 ? fbo.textures[1]: i%2 ? vFbo.textures[0]: hFbo.textures[0], //第1次兩個乒乓幀快取都為空,因此第一次要將燈光紋理傳入
    });
    drawBufferInfo(gl, pVao);
}

每次迴圈根據渲染的是水平還是垂直來繫結兩個緩衝其中之一,而將另一個繫結為紋理進行模糊。第一次迭代,因為兩個顏色緩衝都是空的所以我們隨意繫結一個去進行模糊處理。重複這個步驟6次,亮區影像就進行一個重複3次的高斯模糊了。這樣我們可以對任意影像進行任意次模糊處理;高斯模糊迴圈次數越多,模糊的強度越大。

把兩個紋理混合

有了場景的HDR紋理和模糊處理的亮區紋理,只需把它們結合起來就能實現泛光或稱光暈效果了。最終的片元著色器要把兩個紋理混合:

#version 300 es
precision highp float;
in vec2 texcoord;
uniform sampler2D image;
uniform sampler2D imageBlur;
uniform bool bloom;
out vec4 FragColor;
const float exposure = 1.0;
const float gamma = 2.2;

void main() {   
    vec3 hdrColor = texture(image, texcoord).rgb;
    vec3 bloomColor = texture(imageBlur, texcoord).rgb;
    if (bloom)
        hdrColor += bloomColor;     //新增融合
    
    //色調對映
    // vec3 result = hdrColor / (hdrColor + vec3(1.0));
    vec3 result = vec3 (1.0) - exp(-hdrColor * exposure);
    //進行gamma校正
    result = pow(result, vec3 (1.0 / gamma));
    FragColor = vec4(result, 1.0);
}

注意要在應用色調對映之前新增泛光效果。這樣新增的亮區的泛光,也會柔和轉換為LDR,光照效果相對會更好。把兩個紋理結合以後,場景亮區便有了合適的光暈特效:

這裡只用了一個相對簡單的高斯模糊過濾器,它在每個方向上只有5個樣本。通過沿著更大的半徑或重複更多次數的模糊,進行取樣我們就可以提升模糊的效果。因為模糊的質量與泛光效果的質量正相關,提升模糊效果就能夠提升泛光效果。

後記

這個HDR + Bloom的是目前為止渲染流程最複雜的一個特效了,使用了3個著色器program和3個幀緩衝區,繪製的時候要不斷切換program 和 幀緩衝區。目前有個問題是,從幀緩衝渲染到正常緩衝後場景的鋸齒感挺嚴重的,後續還得深入學習下抗鋸齒(anti-aliasing)。

參考資料:
HDR
泛光

相關文章