歡迎來到WebGL系列教程的第7課,這節課基于NeHe OpenGL教程的[第7課](http://nehe.gamedev.net/data/lessons/lesson.asp?lesson=07)部分編寫,由于WebGL光照處理有些麻煩,所以沒有放在第6課講解,本課我們會學習在WebGL頁面中加入簡單光照;這比在OpenGL中復雜一些,但是希望能講明白。
如下是這節課的demo,在支持WebGL的瀏覽器中運行時的樣子:
【看云目前不支持youtube視頻,又不想放國內的廣告視頻,只能先放個鏈接了】
https://youtu.be/wq68Q6WJgyo
[點擊這里](http://learningwebgl.com/lessons/lesson07/index.html),如果你的瀏覽器支持WebGL,你可以看到一個緩慢旋轉的立方體, 并且看上去在被一個點光源照射,這個點光源的位置在前方【即處于你和立方體之間】,稍微偏右上方。實際上在頁面中可看出,光照方向為$$ \vec a $$= (-0.25, -0.25, -1.0), 即從(0, 0, 0)點指向(-0.25, -0.25, -1.0)點的方向。
你可以使用畫布下面的復選框來切換光照開關,以觀察效果的不同。也可以改變平行光和環境光的顏色(稍后會有更精確的介紹),以及平行光的方向。多把玩下這個樣例;當平行光的RGB值大于1時會出現很有趣的效果(但如果大于5時,則會丟失很多紋理細節)。同時,就像上節課那樣,你可以使用方向鍵來加速或減慢立方體旋轉的速度,使用PageUp和PageDown來縮放立方體。這次我們只使用最好的紋理過濾器【mipmap】,所以F鍵已經沒用了。
下面講解這個Demo的工作原理...
本課的源碼可以查看:https://github.com/gracefung/webgl-codes/tree/master/lesson07 【這是繁體譯文對應的github源碼,感覺還不錯】
在我們了解WebGL的光照工作原理之前,我要宣布一個壞消息。WebGL本身并不支持光照。而OpenGL可以讓你至少指定8個光源,并且能夠替你處理它們。WebGL則是把所有的事情都交給你自己做。**但是** -- 重點來了 -- 一旦被解釋清楚,光照其實非常簡單。如果這幾節課你對shader感覺還不錯,那么理解光照肯定沒問題 -- 并且,作為新手, 編寫一個簡單的光照代碼,有助于你日后更容易理解那些進階的代碼。畢竟,OpenGL的光照系統只是模擬了真實場景的最基本方面 -- 它并不能處理陰影, 例如, 對于曲面的模擬,它的表現效果就很粗糙 -- 所以除了一些簡單的場景,其它的復雜光照還是需要自己動手寫代碼。
好啦,讓我們先想想從光照中想得到什么。目標是能夠在場景中模擬一些光源。這些光源不需要是可見的,但他們需要逼真的照亮3D物體, 即物體面朝光源的面是亮的,遠離光源的面是暗的。換句話說, 我們想能夠指定一組光源,對于3D場景中的每一部分,我們想看看所有光線是如何作用它的。現在我相信你已經足夠了解WebGL,并感覺到這些是放在shader中處理。講的更確切些,這節課要做的就是編寫**vertex shader**處理光照。對于每一個頂點, 我們會計算出光線是如何作用它的,然后調整它的顏色。目前我們只處理一條光線的情況;多光線的情況只是為每條光線重復相同的處理過程,然后將結果相加。
還有一點要說明;因為我們是基于逐頂點來計算光照,所以介于2頂點之間的像素光照效果,是通過常見的線性插值計算得出。也就是說頂點之間的空隙會假設成都是平面而被照亮;恰巧,我們繪制的是一個立方體,這正是我們想要的例子!對于曲面,就需要每一個像素獨立的計算光照效果,技術上被稱做**“逐片段(或逐像素)光照”**,這種效果會非常好。我們將在后續課程中看到逐片段光照,而我們目前做的,可以稱之為“逐頂點光照”。
好,進行下一步:如果我們的任務是編寫一個vertex shader,來計算出一個光源是如何作用頂點的顏色,該怎么做?好吧,一個好的突破口是**Phong反射模型**。通過以下要點,這個模型是最容易理解的:
* 雖然在現實世界中光照只有一種類型,但是為了方便計算,圖形學中對光照做了2種分類:
>[success]1. 來自于特定方向的光線且只能照亮面朝它的物體。我們稱之為**平行光**。
>2. 來自于任何地方的光線且均勻的照亮所有的物體,無論它朝向何方。這種稱之為**環境光**(當然,在真實世界中,這種光線只是平行光照射到其它物體上反射而散發出來的,比如空氣,塵埃等。但是為了我們的模擬,需要對它單獨建模)。
* 當光線抵達物體表面, 會發生2種情況:
>[success]1. 漫反射:即,無論入射角度是多少, 都會朝所有方向均勻的反射。無論你從哪一個角度去觀察,反射光的亮度都完全依賴于入射光的入射角度 -- 入射角越大,反射光越暗。當我們思考一個物體被照亮時,一般考慮的就是漫反射。
>2. 鏡面反射:即,就像鏡子一樣。一部分光線通過這種方式,按照入射角度被反射出來。在這種情況下,反射光的亮度取決于你的眼睛和反射光是否在同一直線上 -- 即, 它不僅依賴于入射角度,還依賴于你的視線和物體表面的夾角【視角】。這種鏡面反射導致了物體的“閃爍”或“高光”,并且鏡面反射的能量,根據材質不同變化明顯;粗糙的木料只有極少量的鏡面反射,而高度拋光的金屬則有大量的鏡面反射。
Phong模型通過聲明所有光線都有2個屬性,對上面這個四步系統進一步簡化:
>[info]1. 它們產生的漫反射光的RGB值
>2. 它們產生的鏡面反射光的RGB值
同時所有的材質都有4種屬性:
>[info]1. 它們反射的環境光RGB值
>2. 它們反射的漫反射光RGB值
>3. 它們反射的鏡面反射光RGB值
>4. 物體的反光度, 這個決定了鏡面反射的細節。
對于場景中的每一個點,它的顏色都是由照射光的顏色,材質本身的顏色,以及光照效果混合而成。所以,為了根據Phong模型來完整指定一個場景中的光照,我們需要每個光線的2種屬性,和物體表面的每個點的4種屬性。環境光由于它的特點,不依賴于任何特定光線,但是我們依然需要找到一種方式,來存儲整個場景的所有環境光強度;有時為了簡單起見,我們只需為每一個光源指定一個環境等級,然后把它們全加起來放在一個變量中。
無論怎樣,一旦我們擁有了所有信息, 我們可以根據環境光,平行光,鏡面反射光計算出每個點的顏色,然后把它們相加來算出全部顏色值。計算原理如下圖所示:

>[danger] **shader所要做的工作就是計算出每個頂點上的環境光,漫反射光,以及鏡面反射光的紅色分量,綠色分量,和藍色分量,構成顏色RGB值,再相加在一起,最終輸出結果。**
現在,為了課程說明,我們將做些簡化。只考慮漫反射光和環境光,而忽略鏡面反射。我們依然使用上節課的紋理立方體,并假設紋理的顏色就是漫反射光和環境反射光的顏色值。最后,我們只考慮一種簡單的漫反射光 -- 平行光。可以用下面的圖來講解。

從單方向照射一個表面的光分2種 -- 平行光,即按照相同的方向穿過整個場景;點光源,來源于場景內一個點發出的光線,即不同的地方,光線角度也不同。
對于平行光,光打到給定面上的任何一點角度總是相同的 -- 例如上圖中的AB點。想象一下太陽光;所有的射線都是平行的。
再來看從點光源發出的光,每一個頂點上的入射角度都不一樣,例如下圖中的A點,入射角度大概是$$ 45^o $$,而B點的入射角已基本是$$90^o$$。

這就意味著對于點光源,我們需要為每一個頂點計算出光的入射方向,而對于平行光,我們只需要知道平行光源的方向。這就使得點光源的處理稍微有些困難,所以這節課只討論平行光,后續課程再講解點光源,不過你自己想要研究出來,應該也不會太難。
所以,目前我們已經把問題精煉了不少。我們知道場景中的所有光都會來自于一個特定方向,并且這個方向不會隨頂點而變化。這意味著我們可以把它放到一個uniform變量中,供shader使用。我們也知道光線對于每個頂點的作用效果,取決于在該點的入射角度,所以我們需要用一種方式來描述表面的朝向。在3D幾何中最好的辦法就是指定表面在該點的**法線向量**;這樣我們就可以通過一組3個數字來描述表面的朝向。(在2D幾何中,我們可以等價使用切線 -- 即,表面在該點的方向 -- 但是在3D幾何中切線可以有2個方向,所以我們就需要2個向量來描述它,但是法線只用一個向量就夠了)。
一旦我們有了法線,就剩下最后一樣東西我們就可以寫shader了;給定一個表面在該點的法線向量,和入射光的入射光方向向量,我們需要知道有多少光會被表面漫反射。可以證明這個值與兩向量夾角的余弦成正比。如果法線為$$0^o$$(即,來自于所有方向的入射光,全都以$$90^o$$打到表面上),那么我們可以說它反射了所有的光。如果入射光和法線夾角90度,就沒有任何光被反射。而在這2種情況之間的,都遵循余弦曲線。(如果夾角超過90度,理論上會計算出一個負值,這顯然是不合理的,所以我們實際使用的要么是余弦值,要么是0,哪個大就用哪個)
所幸的是,計算2個向量夾角的余弦值很簡單,如果它們還都是單位向量;那么對它們求點積就是夾角余弦值。更加方便的是,點乘運算已內置在shader中,調用名為*dot* 的函數即可。
哇哦!開端講了這么多理論 -- 但我們已經知道處理簡單平行光所需要做的工作:
>[warning]* 存放一組法線向量,每個頂點一個。
>* 描述光線的一個方向向量。
>* 在vertex shader中,計算頂點法線和入射光向量的點積,適當的加權計算出顏色值,同時不要忘了環境光的影響。
讓我們看看代碼是如何工作的。將會從底層開始向上講解。很明顯這節課的html頁面和上節課有區別,因為我們有了額外的輸入框,但這會先不講這些細節... 讓我們先看JavaScript代碼,首先看*initBuffers* 函數。你會發現,在創建頂點位置數組之后,紋理坐標數組之前,我們創建了法線數組, 現在看這些代碼應該會很熟悉:
~~~
cubeVertexNormalBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexNormalBuffer);
var vertexNormals = [
// Front face
0.0, 0.0, 1.0,
0.0, 0.0, 1.0,
0.0, 0.0, 1.0,
0.0, 0.0, 1.0,
// Back face
0.0, 0.0, -1.0,
0.0, 0.0, -1.0,
0.0, 0.0, -1.0,
0.0, 0.0, -1.0,
// Top face
0.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, 1.0, 0.0,
// Bottom face
0.0, -1.0, 0.0,
0.0, -1.0, 0.0,
0.0, -1.0, 0.0,
0.0, -1.0, 0.0,
// Right face
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
// Left face
-1.0, 0.0, 0.0,
-1.0, 0.0, 0.0,
-1.0, 0.0, 0.0,
-1.0, 0.0, 0.0
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexNormals), gl.STATIC_DRAW);
cubeVertexNormalBuffer.itemSize = 3;
cubeVertexNormalBuffer.numItems = 24;
~~~
下面我們看*drawScene* 函數,如下代碼用來將法線數組綁定到對應的shader屬性中:
~~~
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexNormalBuffer);
gl.vertexAttribPointer(shaderProgram.vertexNormalAttribute, cubeVertexNormalBuffer.itemSize, gl.FLOAT, false, 0, 0);
~~~
然后, 由于我們本課只是用一種紋理過濾器,所以移除了上節課中紋理可配置的代碼:
~~~
gl.bindTexture(gl.TEXTURE_2D, crateTexture);
~~~
接下來的部分稍微有些復雜。首先,我們需要查看“lighting”復選框是否被勾選,所以我們在shader中設置了一個uniform變量來指示這個值:
~~~
var lighting = document.getElementById("lighting").checked;
gl.uniform1i(shaderProgram.useLightingUniform, lighting);
~~~
然后,如果啟用了光照,我們讀取輸入框中環境光的紅,綠,藍分量值,并將它們推送給shader:
~~~
if (lighting) {
gl.uniform3f(
shaderProgram.ambientColorUniform,
parseFloat(document.getElementById("ambientR").value),
parseFloat(document.getElementById("ambientG").value),
parseFloat(document.getElementById("ambientB").value)
);
~~~
接著, 我們還需要推送平行光的方向:
~~~
var lightingDirection = [
parseFloat(document.getElementById("lightDirectionX").value),
parseFloat(document.getElementById("lightDirectionY").value),
parseFloat(document.getElementById("lightDirectionZ").value)
];
var adjustedLD = vec3.create();
vec3.normalize(lightingDirection, adjustedLD);
vec3.scale(adjustedLD, -1);
gl.uniform3fv(shaderProgram.lightingDirectionUniform, adjustedLD);
~~~
從上面的代碼,你可以發現我們在傳給shader之前,調整了平行光的方向向量,使用了glMatrix的vec3模塊 -- 這個模塊類似于我們之前描述模型視圖,和投影矩陣時用到的mat4模塊,都是glMatrix的一部分。第1步變換,`vec3.normalize` 是為了放大或縮小向量至長度為1;你可能還記得夾角余弦值等于2向量點積的前提是,這2個向量的長度都是1。上面我們定義的法線向量都是1,但是平行光的方向是由用戶填寫的(讓他們自己填出一個單位向量,可能是一件很頭疼的事情),所以就由我們來變換。第2步變換是讓向量乘以一個標量-1 -- 即,反轉它的方向。這是因為填寫平行光方向時,我們一般都是想表達光傳輸的方向【即光照向哪里】,而我們之前討論的計算方法中,用到的都是光來自哪里【**這個結合法線點積很好理解:法線的方向是朝外的,即背離物體表面,如果計算點積時,用的是入射方向,那么2個向量的夾角其實是個鈍角,算出來的余弦值是負的,所以當我們計算2向量點積時,由于法線朝外,所以入射光方向,取用的也是朝外方向,即入射方向的反方向**】。當我們做完以上轉換后,我們使用`gl.uniform3fv`將其傳遞給shader,這個函數將一個三元素的Float32Array數組放入uniform變量中。
下面的代碼就簡單多了;就是將平行光的顏色部分傳遞給shader中對應的uniform變量:
~~~
gl.uniform3f(
shaderProgram.directionalColorUniform,
parseFloat(document.getElementById("directionalR").value),
parseFloat(document.getElementById("directionalG").value),
parseFloat(document.getElementById("directionalB").value)
);
~~~
以上就是drawScene函數的所有改動。接下來看鍵盤交互的代碼,它只是移除了F鍵的控制,我們可以忽略掉這個簡單改動,下一個有趣的變化是*setMatrixUniforms* 函數, 你應該還記得將模型視圖矩陣,和投影矩陣傳遞給shader的uniforms。我們增加了4行代碼,拷貝了一個新的,基于模型視圖的矩陣:
~~~
var normalMatrix = mat3.create();
mat4.toInverseMat3(mvMatrix, normalMatrix);//這里涉及到 **法線變換** 知識,建議google
mat3.transpose(normalMatrix);
gl.uniformMatrix3fv(shaderProgram.nMatrixUniform, false, normalMatrix);
~~~
正如你所想,normalMatrix用來變換法線,我們不能像變換頂點位置那樣,使用同樣的模型視圖矩陣來變換法線向量,因為法線會因為平移,旋轉操作產生變化 -- 舉個例子,如果我們忽略旋轉,并假設做了$$\vec a = (0, 0, -5)$$的平移操作,那么法線$$\vec n = (0, 0, 1)$$就會變為$$\vec n = (0, 0, -4)$$,結果就是不僅向量長度變的太長,而且指向了一個錯誤的方向。我們可以繞過這個錯誤;你也許已經注意到在vertex shader中,當我們將一個3元的頂點位置與$$4 \times 4$$的模型視圖矩陣相乘時,為了使它們相容,我們通過在尾部添加1,將頂點位置向量擴展為4個元素。這個1不僅是為了填充長度,也是為了使平移,旋轉,以及其他的變換操作得以生效,所以如果我們恰巧用0來替代1,我們就可以做乘法運算時,忽略掉平移操作【這里涉及到**模型視圖矩陣的分解**,[這里有篇文章](http://blog.csdn.net/dcrmg/article/details/53088617)講解的很明白】。這個方法目前來看滿足我們的要求,但如果我們的模型視圖矩陣包含了不同的變換,尤其是縮放和裁剪時,這個方法就無效了。例如,假設我們的模型視圖矩陣是將物體放大2倍,那么即使末尾添加了0, 法線長度依然會變為原來的2倍 -- 這就會導致光照計算各種問題。所以,為了避免養成壞習慣,我們還是要用正確的方法解決問題。
使法線指向正確方向的方法是:使用模型視圖矩陣左上角的$$3\times3$$部分的逆的轉置。【詳見[法線變換](http://blog.csdn.net/bugrunner/article/details/7285356)】
好吧,當我們計算完這個矩陣,就可以將它像其它矩陣那樣傳遞給shader的uniform中。
繼續看代碼,會發現有一些不重要的改動,是關于紋理加載的,現在只加載一張mipmap貼圖,而不再是上節課那樣加載3個貼圖,還有*initShaders* 方法中新添一些代碼,用來初始化*vertexNormalAttribute*,以便*drawScene* 方法可以用它把發現傳遞給shader,其它新添的uniform也都是類似的方式處理。這些都沒什么細節可講,直接看shader。
首先是fragment shader,很簡潔:
~~~
precision mediump float;
varying vec2 vTextureCoord;
varying vec3 vLightWeighting;
uniform sampler2D uSampler;
void main(void) {
vec4 textureColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
gl_FragColor = vec4(textureColor.rgb * vLightWeighting, textureColor.a);//光作用在材質上就是相乘運算。
}
~~~
你會發現,我們先是從紋理中提取顏色,但是在返回前,我們通過一個varying變量*vLightWeighting* 調整了它的RGB值,vLightWeighting是一個3元向量,就像你猜想的那樣,它存放了R, G, B的調整因子,這些調整因子是在vertex shader中計算光照得出的。
讓我們來看看vertex shader是如何計算的:
~~~
attribute vec3 aVertexPosition;
attribute vec3 aVertexNormal;
attribute vec2 aTextureCoord;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
uniform mat3 uNMatrix;
uniform vec3 uAmbientColor;
uniform vec3 uLightingDirection;
uniform vec3 uDirectionalColor;
uniform bool uUseLighting;
varying vec2 vTextureCoord;
varying vec3 vLightWeighting;
void main(void) {
gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
vTextureCoord = aTextureCoord;
if (!uUseLighting) {
vLightWeighting = vec3(1.0, 1.0, 1.0);
} else {
vec3 transformedNormal = uNMatrix * aVertexNormal;
float directionalLightWeighting = max(dot(transformedNormal, uLightingDirection), 0.0);
vLightWeighting = uAmbientColor + uDirectionalColor * directionalLightWeighting;//多光源的作用效果,用加法,就像這里的環境光效果 + 平行光效果,RGB這里可以理解為強度,顏色強度之類
}
}
~~~
新屬性*aVertexNormal* 當然存放的就是我們在*initBuffers()* 中指定的頂點法線。*uNMatrix* 就是法線變換矩陣,*uUseLighting* 是一個uniform變量,指示是否啟用光照,*uAmbientColor, uDirectionalColor, uLightingDirection* 表示的是用戶在頁面輸入框中的可輸入參數。
按照我們上面講述的那一大堆數學知識, 實際實現的代碼應該很容易理解。vertex shader的主要輸出就是varying變量*vLightWeighting*,即我們剛才在fragment shader中看到的用來調整紋理顏色的因子,如果禁用了光照,就是用默認值$$\vec w = (1, 1, 1)$$,即不調整顏色。如果啟用光照,我們首先應用法線變換矩陣*uNMatrix* 計算出法線朝向,然后求法線方向和光線方向的點積,以得出有多少光會被反射(最小是0,就像我之前提到的)。這個值乘以平行光的顏色,然后再加上環境光顏色,計算出的結果就是最終的光照權重,即fragment shader中用到的*vLightWeighting*。
以上就是本課所有內容:對于圖形學中的光照原理,你已經有了扎實的基礎,并且知道了如何實現2種簡單光照:平行光和環境光。這一切都是自己編寫的。