原文地址: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合成多張圖片,卻能達到我們想要的效果。

那么在WebGL中,HDR具體指的是什么。它指的是讓我們能用超過1.0的數(shù)據(jù)表示顏色值。到目前為止,我們用的都是LDR(低動態(tài)范圍),所有的顏色值都被限制在了 [0,1] 范圍。在現(xiàn)實當中,太陽,燈光這類光源它們的顏色值肯定是遠遠超出1.0的范圍的。
本節(jié)實現(xiàn)的效果請看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é)合使用能非常完美地展示光源效果。

泛光的品質(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)。