WebGL學(xué)習(xí)之HDR與Bloom

原文地址:WebGL學(xué)習(xí)之HDR與Bloom

什么是HDR

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


hdr


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

本節(jié)實現(xiàn)的效果請看hdr & bloom

hdr & bloom

浮點幀緩沖

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

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

創(chuàng)建浮點幀緩沖,我們只需要改變顏色緩沖的內(nèi)部格式參數(shù)就行了(注意 gl.FLOAT參數(shù)):

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),我們需要四倍的內(nèi)存來存儲這些顏色。所以除非你需要一個非常高的精確度,32位不是必須的,使用 gl.RGB16F就足夠了。

色調(diào)映射

色調(diào)映射(Tone Mapping)是一個損失很小的轉(zhuǎn)換浮點顏色值至我們所需的LDR[0.0, 1.0]范圍內(nèi)的過程,通常會伴有特定的風(fēng)格的色平衡(Stylistic Color Balance)。

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

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

有了Reinhard色調(diào)映射的應(yīng)用,我們不再會在場景明亮的地方損失細節(jié)。當然,這個算法是傾向明亮的區(qū)域的,暗的區(qū)域會不那么精細也不那么有區(qū)分度。

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

一個簡單的曝光色調(diào)映射算法會像這樣:

uniform float exposure;

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

什么是Bloom

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


bloom

泛光的品質(zhì)很大程度上取決于所用的模糊過濾器的質(zhì)量和類型。下面這幾步就是泛光后處理特效的過程,它總結(jié)了實現(xiàn)泛光所需的步驟。


泛光處理過程

提取亮色

首先我們要從渲染出來的場景中提取兩張圖片??梢凿秩緢鼍皟纱危看问褂靡粋€不同的不同的著色器渲染到不同的幀緩沖中,但可以使用一個叫做MRT(Multiple Render Targets多渲染目標)的小技巧,有了它我們能夠在一個單獨渲染處理中提取兩個圖片。在片元著色器的輸出前,我們指定一個布局location標識符,這樣我們便可控制一個片元著色器寫入到哪個顏色緩沖:

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

使用多個片元著色器輸出的必要條件是,有多個顏色緩沖附加到了當前綁定的幀緩沖對象上。直到現(xiàn)在,我們一直使用著 gl.COLOR_ATTACHMENT0,但通過使用 gl.COLOR_ATTACHMENT1,可以得到一個附加了兩個顏色緩沖的幀緩沖對象。

但首先我們還是將創(chuàng)建幀緩沖的功能進行封裝:

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;//顏色緩沖數(shù)量
    const depth = !!opt.depth;

    // SECTION 創(chuàng)建紋理
    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 創(chuàng)建用于保存深度的渲染緩沖區(qū)
    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);   
    }
    // 檢查幀緩沖區(qū)對象
    const e = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
    if (gl.FRAMEBUFFER_COMPLETE !== e) {
        throw new Error('Frame buffer object is incomplete: ' + e.toString());
    }
    // 解綁幀緩沖區(qū)對象
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.bindTexture(gl.TEXTURE_2D, null);
    if(depth) gl.bindRenderbuffer(gl.RENDERBUFFER, null);
    return framebufferInfo;
}

接著調(diào)用上面的函數(shù)創(chuàng)建包含兩個顏色附件和一個深度附件的幀緩沖區(qū)。

//場景幀緩存(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修飾符,然后把不同顏色值渲染到相應(yīng)的顏色緩沖。這樣就省去了為提取高光區(qū)域的額外渲染步驟。

#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;
  
    // 檢查結(jié)果值是否高于某個門檻,如果高于就渲染到高光顏色緩存中
    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的東西來決定它的亮度是否超過了一定閾限。我們通過恰當?shù)貙⑵滢D(zhuǎn)為灰度的方式計算一個fragment的亮度,如果它超過了一定閾限,我們就把顏色輸出到第二個顏色緩沖,那里保存著所有亮部。

這也說明了為什么泛光在HDR基礎(chǔ)上能夠運行得很好。因為HDR中,我們可以將顏色值指定超過1.0這個默認的范圍,我們能夠得到對一個圖像中的亮度的更好的控制權(quán)。沒有HDR我們必須將閾限設(shè)置為小于1.0的數(shù),雖然可行,但是亮部很容易變得很多,這就導(dǎo)致光暈效果過重。

有了一個提取出的亮區(qū)圖像,我們現(xiàn)在就要把這個圖像進行模糊處理。

高斯模糊

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

幸運的是,高斯方程有個非常巧妙的特性,它允許我們把二維方程分解為兩個更小的方程:一個描述水平權(quán)重,另一個描述垂直權(quán)重。我們首先用水平權(quán)重在整個紋理上進行水平模糊,然后在經(jīng)改變的紋理上進行垂直模糊。利用這個特性,結(jié)果是一樣的,但是可以節(jié)省難以置信的性能,因為我們現(xiàn)在只需做32+32次采樣,不再是1024了!這叫做兩步高斯模糊。


高斯模糊

這意味著我們?nèi)绻麑σ粋€圖像進行模糊處理,至少需要兩步,最好使用幀緩沖對象做這件事。具體來說,我們將實現(xiàn)像乒乓球一樣的幀緩沖來實現(xiàn)高斯模糊。意思是使用一對幀緩沖,我們把另一個幀緩沖的顏色緩沖放進當前的幀緩沖的顏色緩沖中,使用不同的著色效果渲染指定的次數(shù)。基本上就是不斷地切換幀緩沖和紋理去繪制。這樣我們先在場景紋理的第一個緩沖中進行模糊,然后在把第一個幀緩沖的顏色緩沖放進第二個幀緩沖進行模糊,接著將第二個幀緩沖的顏色緩沖放進第一個,循環(huán)往復(fù)。

在我們研究幀緩沖之前,先來實現(xiàn)高斯模糊的片元著色器:

#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);
}

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

接著為圖像的模糊處理創(chuàng)建兩個基本的幀緩沖,每個只有一個顏色緩沖紋理,調(diào)用上面封裝好的createFramebuffer函數(shù)即可。

//2乒乓?guī)彺?都只包含1顏色附件)
const hFbo = createFramebuffer(gl,{informat:gl.RGBA16F, type:gl.FLOAT});
const vFbo = createFramebuffer(gl,{informat:gl.RGBA16F, type:gl.FLOAT});

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

/**
 * 乒乓?guī)彺? */
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次兩個乒乓?guī)彺娑紴榭?,因此第一次要將燈光紋理傳入
    });
    drawBufferInfo(gl, pVao);
}

每次循環(huán)根據(jù)渲染的是水平還是垂直來綁定兩個緩沖其中之一,而將另一個綁定為紋理進行模糊。第一次迭代,因為兩個顏色緩沖都是空的所以我們隨意綁定一個去進行模糊處理。重復(fù)這個步驟6次,亮區(qū)圖像就進行一個重復(fù)3次的高斯模糊了。這樣我們可以對任意圖像進行任意次模糊處理;高斯模糊循環(huán)次數(shù)越多,模糊的強度越大。

把兩個紋理混合

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

#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;     //添加融合
    
    //色調(diào)映射
    // vec3 result = hdrColor / (hdrColor + vec3(1.0));
    vec3 result = vec3 (1.0) - exp(-hdrColor * exposure);
    //進行g(shù)amma校正
    result = pow(result, vec3 (1.0 / gamma));
    FragColor = vec4(result, 1.0);
}

注意要在應(yīng)用色調(diào)映射之前添加泛光效果。這樣添加的亮區(qū)的泛光,也會柔和轉(zhuǎn)換為LDR,光照效果相對會更好。把兩個紋理結(jié)合以后,場景亮區(qū)便有了合適的光暈特效:

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

后記

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

參考資料:
HDR
泛光

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容