原文地址:WebGL學習之HDR與Bloom
什么是HDR
HDR (High Dynamic Range,高動態范圍),在攝影領域,指的是可以提供更多的動態范圍和圖像細節的一種技術手段。簡單講就是將不同曝光拍攝出的最佳細節的LDR (低動態范圍) 圖像合成后,就叫HDR,它能同時反映出場景最暗和最亮部分的細節。為什么需要多張圖片?因為目前的單反相機的寬容度還是有限的,一張照片不能反映出高動態場景的所有細節。一張圖片拍攝就必須要在暗光和高光之間做出取舍,只能亮部暗部兩者取其一。但是通過HDR合成多張圖片,卻能達到我們想要的效果。
那么在WebGL中,HDR具體指的是什么。它指的是讓我們能用超過1.0的數據表示顏色值。到目前為止,我們用的都是LDR(低動態范圍),所有的顏色值都被限制在了 [0,1] 范圍。在現實當中,太陽,燈光這類光源它們的顏色值肯定是遠遠超出1.0的范圍的。
本節實現的效果請看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的結合使用能非常完美地展示光源效果。
泛光的品質很大程度上取決於所用的模糊過濾器的質量和類型。下面這幾步就是泛光后處理特效的過程,它總結了實現泛光所需的步驟。
提取亮色
首先我們要從渲染出來的場景中提取兩張圖片。可以渲染場景兩次,每次使用一個不同的不同的着色器渲染到不同的幀緩沖中,但可以使用一個叫做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)。