歡迎來到WebGL系列教程的第8課,基于NeHe OpenGL教程的[第8課](http://nehe.gamedev.net/data/lessons/lesson.asp?lesson=08)編寫。本課,我們將會探討顏色混合【blending】,順帶簡單穿插一下深度緩存的工作原理。
以下是demo演示視頻(備注:視頻并不能完全表現出透明度的效果)
https://youtu.be/sQNv-88BW6Q
[點擊這里你能看到樣例展示](http://learningwebgl.com/lessons/lesson08/index.html),當然了,如果你的瀏覽器支持WebGL,你能看到一個緩慢旋轉的半透明立方體,看上去是用雕花的玻璃制成。而場景中的光照和上節課是一樣的。
你可以使用畫布下方的復選框切換顏色混合開關,來改變透明效果。你也可以調整alpha級別因子(我們稍后會解釋這個)看看效果。同樣的,你也可以改表各種光照參數。
下面講解它是如何工作的...
>[info] 慣例聲明:本課程面向具有一定編程基礎,但在3D圖形學方面無實際經驗人群,目標是使學習者運行并了解WebGL代碼,以便快速構造自己的3D頁面。如果你還沒有閱讀之前的課程,建議先讀完它們 -- 這里只會講述和之前不同,以及新增的部分
>在教程中難免會有bug和錯誤,如果你發現了它,請告訴我,我會盡快改正它
本課的源碼可以查看:https://github.com/gracefung/webgl-codes/tree/master/lesson08 【這是繁體譯文對應的github源碼,感覺還不錯】
在開始講解代碼之前,還需要介紹一些理論。作為開始,我覺得應該闡述一下顏色混合到底是什么東西,為了解釋清楚,恐怕要先講解深度緩存的一些知識。
**深度緩存【The Depth Buffer】**
當告知WebGL繪制一些東西的時候,回憶下第2課,它會經歷一個渲染管線階段。從頂層開始,將有以下步驟:
1. 運行vertex shader,對每一個頂點進行計算,得到所有頂點的位置。
2. 頂點間使用線性插值,以此來告訴WebGL哪些片段需要渲染,片段在這里你可以理解為像素。
3. 對于每一個片段,運行fragment shader來算出它的顏色。
4. 將片段【fragments】寫入到片段緩存【frame buffer】中。**這里可以看出fragment真實含義,其實是關于一個像素的所有信息集合**
可以發現,最終片段緩存存儲了要繪制的內容。但如果你要繪制2個東西呢?比如,你要繪制2個同樣大小的正方形,第一個中心位置為$$ p_1 = (0, 0, -5) $$,第二個中心位置為$$ p_2 = (0, 0, -10) $$,這種情況下會發生什么呢?你肯定不希望第2個方形畫在第1個上面,因為第2個距離攝像機更遠,它應該被隱藏才對。
WebGL處理這種情況時,用的就是深度緩存。當片段及RGBA顏色值被fragment shader處理后,寫入到片段緩存中時,它同時存儲了該片段的一個深度值,這個深度值和該片段Z坐標值相關【related to】,但又不完全一樣。因此,**深度緩存又常常被稱為Z緩存**【Z buffer】
為什么我說是“相關”呢?因為WebGL通常會將Z值映射到(0,1)的區間中,0表示最近,1表示最遠。這個操作在drawScene()函數開始,我們調用透視來創建投影矩陣的時候就已經發生了,所以對我們來說是透明的。現在你需要知道的就是一個物體Z-buffer值越大,那么它就離攝像機越遠;這和我們常見的坐標系是相反的【WebGL的Z軸坐標系正方向是朝向攝像機】。
OK,這就是深度緩存。現在,你也許回憶起第一課中,我們初始化WebGL上下文的代碼,有這么一行:
~~~
gl.enable(gl.DEPTH_TEST);
~~~
這句話就是告訴WebGL系統,當有一個新的片段寫入片段緩存時應該怎么做。基本上意思就是“考慮深度緩存”。它通常和另一個WebGL設置結合使用:深度函數。這個函數本身是有一個合適的默認值,但如果我們顯式設置它為默認值時,看起來是這樣:
~~~
gl.depthFunc(gl.LESS)
~~~
這意思是“如果我們的片段Z值比當前同位置的Z值小,就使用新的片段,取代舊的”。這個測試是系統內置的,結合上面的代碼來啟用它后,就能夠提供給我們合理的表現;近處的物體遮擋住遠處的物體(你也可以用其它不同的值來設置深度函數,但我覺得它們出場率極低)
**顏色混合【Blending】**
顏色混合是這一過程的另一選擇。通過深度測試,我們使用深度函數來決定是否用新片段來替換舊片段。而當我們做顏色混合,我們使用一個混合函數,將已存在的片段顏色,和新片段的顏色結合起來,生成一個全新的片段,然后將其寫入到片段緩存中。
現在我們來看看代碼。大部分都是和第7課一樣,并且重要的代碼基本上都在*drawScene()* 函數中,且代碼量很小。首先, 我們檢查“blending”復選框是否被勾選上:
~~~
var blending = document.getElementById("blending").checked;
~~~
如果被勾選,我們將混合函數設置為結合2個片段顏色:
~~~
if (blending) {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
~~~
混合函數中的參數指定了混合的方式。這是一個費時的操作,但并不困難。首先,讓我們定義2個術語:我們當前正在繪制的片段稱為**源片段**,已經存在于片段緩存中的稱為**目標片段**。*gl.blendFunc()* 函數的第1個參數決定了**源因子【source factor】**,第2個參數決定了**目標因子【destination factor】**,它們都是數字參數。在這個例子中,我們將源因子定義為源片段的alpha值,目標因子則是常量1。當然也有其它的選擇。比如,你可以使用SRC_COLOR來表示源片段的顏色,那么最終你將分別得到紅,綠,藍,透明度【alpha】的值作為源因子【可以看出因子可以是多個數】,它們分別等于源片段的RGBA分量值。
現在,讓我們想象一下,有了目標片段,其顏色值為$$ c1 = (R_d, G_d, B_d, A_d)$$,也有了源片段,其顏色值為$$ c2 = (R_s, G_s, B_s, A_s)$$。WebGL將要計算出新的片段顏色。
此外,我們假設源因子是$$ f1 = (S_r, S_g, S_b, S_a) $$,目標因子是$$ f2 = (D_r, D_g, D_b, D_a)$$。
對于每一個顏色分量,WebGL會做如下計算:
$$R_{result} = R_s \times S_r + R_d \times D_r$$
$$G_{result} = G_s \times S_g + G_d \times D_g$$
$$B_{result} = B_s \times S_b + B_d \times D_b$$
$$A_{result} = A_s \times S_a + A_d \times D_a$$
所以,在我們的例子中,我們有(為了簡單,這里僅給出紅色分量的計算):
$$R_{result} = R_s \times A_s + R_d$$
一般情況下,這并不是產生透明的理想方式,但是在這個例子中啟用光照的情況下,它恰好表現的很好。有一點是值得強調的:顏色混合并不等價于透明,它只是可以得到透明效果的其中一種技術。在我自己學習Nehe教程時,我花了很久才參悟出這一點,所以請原諒我現在過于強調這一點。
好的,我們繼續:
~~~
gl.enable(gl.BLEND);
~~~
一行很簡單的代碼 -- 就像WebGL很多其它特性一樣,顏色混合默認是關閉的,所以我們需要打開它。
~~~
gl.disable(gl.DEPTH_TEST);
~~~
這里有點有趣;我們需要關閉深度測試。如果我們不這樣做,顏色混合會在有些地方有效,而有些地方失效。比如,如果我們先繪制立方體的背面,然后繪制正面。那么當背面繪制時,它會被寫入片段緩存中,然后正面會在它上面進行混合,這是我們想要的。然而,若我們調換順序,先畫正面再畫背面,那么背面就會被深度測試忽略掉,從而到不了混合函數環節,所以它就不會對最終圖像產生影響。這不是我們想要的。
敏銳的讀者可以從這里,以及上面提到的混合函數注意到,顏色混合強依賴于繪制的順序,這在前面的課程中并沒有遇到。稍后會對此作出更多的講解。現在先看完下面這行代碼:
~~~
gl.uniform1f( shaderProgram.alphaUniform, parseFloat(document.getElementById("alpha").value) );
~~~
這里我們讀取頁面輸入框中的alpha值,傳入到shader中。這是因為我們用作紋理的圖片自身并沒有alpha通道(它只有RGB,所以每個像素的alpha值都是默認的1)。因此自由調節alpha值,能夠方便看到它是如果影響圖像的。
drawScene()中剩余的代碼,是為了禁用顏色混合后,使用正常的方式進行圖形處理。
~~~
else {
gl.disable(gl.BLEND);
gl.enable(gl.DEPTH_TEST);
}
~~~
在fragment shader中也有一些小的改動,在處理紋理時用上alpha值:
~~~
precision mediump float;
varying vec2 vTextureCoord;
varying vec3 vLightWeighting;
uniform float uAlpha;
uniform sampler2D uSampler;
void main(void) {
vec4 textureColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
gl_FragColor = vec4(textureColor.rgb * vLightWeighting, textureColor.a * uAlpha);
}
~~~
這就是fragment shader中所有的改變【就加了個uAlpha uniform】
現在讓我們回到繪制順序這個問題上來。我們在這個例子中得到的透明效果非常好 -- 它看上去真的像雕花玻璃制成。但是改變一下平行光方向,使其來自Z軸反方向 -- 只需要將Z坐標的負號去掉。它看上依然不錯,但失去了逼真的“雕花玻璃”效果。
產生這種現象的原因是,在原來的光照處理中,立方體的背面總是光線暗淡的。這就意味著它的RGB分量值都很小,所以當進行如下計算后:
$$R_{result} = R_s \times R_a + R_d$$
它們只是隱約可見。換句話說,當啟用了光照,那么物體背面就基本不可見。如果我們調整光線方向,使物體正面基本不可見,那么我們的透明效果就不怎么好了。
那么如何才能得到“合適的”透明度呢?OpenGL FAQ的建議是,你需要使用一個源因子SRC_ALPHA【源片段的alpha值】,和一個目標因子ONE_MINUS_SRC_ALPHA【 1 - SRC_ALPHA 】。但是我們依然會遇到問題,因為源片段和目標片段是區別對待的**【即你一旦提了這2個概念定義,那就是在區別對待這2個事物,那么它們就不能等價替換,所以也就不能調換順序】**,所以依舊依賴于物體繪制的順序。這最終牽扯出OpenGL/WebGL中關于透明處理,我認為是不光彩的潛規則的一點。引用一下OpenGL的FAQ:
>[warning] 當在程序中使用深度緩存時,你需要關注渲染圖元的繪制順序。按照由遠及近的順序,完全不透明的圖元需要最先被渲染,接著是部分透明的圖元。如果你不按照這樣的順序渲染圖元,那些本來通過部分透明圖元可見的物體,可能會完全無法通過深度測試。
所以,你懂的。使用顏色混合得到的透明,是復雜而繁瑣的,但如果你對于場景其它方面控制到位,就像我們本節課中控制光照,那么就不需要多復雜就能得到正確的效果。正確的繪制出物體很容易,但是想讓它好看,則需要仔細的按照一個特定的順序來繪制。
幸運的是,顏色混合對其它效果也有用,就像你將在下節課看到的。但目前,你已經學完了本課的只是:你清楚的了解了深度緩存,知道如何使用顏色混合來實現簡單的透明。
對于這節課,我第一次學習時,感覺這是NeHe課程中最難的部分,我希望至少能和源課程講的一樣明白。