WebGL基礎教程:第三部分

前端達人發表於2019-01-14

歡迎回到第三部分,也是我們的迷你WebGL教程系列的最後一部分。在此課程中,我們會會介紹光照和新增2D物件到場景中。新的內容很多,我們還是直接開始吧。

光照

光照可能是3D應用中最技術化和最難理解的部分了。牢固地掌握光照知識絕對是非常基本的。

光照是如何工作的?

在我們介紹不同型別的光照,和編碼技術之前,一件重要的事情是,理解真實世界中的光照是如何形成的。 每個光源 (比如:一個燈泡,太陽,等等) 產生了稱為光子的粒子。這些光子在物件周圍彈跳,直到它們最終進入我們的眼睛。 我們的眼睛將光子轉化為一個可視的"影象"。這就是我們能夠看到東西的原理。光是可加的,意思是一個顏色更多的物件要比沒有顏色 (黑色) 的物件顯得更亮一些。 黑色是絕對的沒有顏色,而白色包含了所有的顏色。這是在處理非常亮或"過飽和"光照時需要注意的一個重要區別。

亮度只不是是具有多個狀態的一個原則。比如,反射可以有多個不同的層次。像鏡子一樣的一個物件可以是完全反射的,而其它物件的表面則少一些光澤。 透明度決定了物件如何彎曲和折射光線;一個物件可以是完全透明的,也可以是完全不透明,或中間的任意狀態。

這個知識清單還可以繼續列下去,但我想你已經意識到光照不是那麼簡單了。

如果你想在一個小場景中對真實光照進行模擬,很有可能一個小時只能渲染4幀,這還是高效能電腦的情況。 為了克服這個問題,程式設計師們使用了一些技巧和技術來模擬半真實的光照,以實現更合理的幀率。 你必須在真實感和速度之間進行妥協。讓我們看一看部分這樣的技術。

在我開始詳細介紹不同的技術時,我要先小小地宣告一下。 對於不同的光照技術,它們精確名稱是有爭議的,比如"光線跟蹤"或"光照對映"技術,不同的人會給出不同的解釋來。 所以,在我們捲入這種招人恨的爭議中之前,我要說的是,我只是用了我所學過的名稱;有些人可能並不會同意我用的名詞。 無論如何,重要的是知道不同的技術具體是什麼。不再囉嗦,我們開始吧。

你必須在真實感和速度之間進行權衡。

光線跟蹤

光線跟蹤是更具真實感的一種光照技術,但它也是更耗時的一種。光線跟蹤模仿了真實光;它從光源處發射"光子"或"光線",並讓它們四處彈跳。 在大多數光線跟蹤實現中,光線來自於"攝像機",並延相反方向彈向場景。這個技術通常用於電影,或可以提前渲染的場合。 這並不是說,你不能在實時應用中使用光線跟蹤,但這樣做會迫使你調整場景中的其它東西。 比如,你可能必須要減少光線必須"彈跳"的次數,或你可以確保沒有物件有反射或折射表面。 如果你的應用中光源和物件較少,光線跟蹤也是一個可行選項。

如果你有一個實時應用,你可能會提前編譯場景內的部分內容。

如果應用中的光源不會到處移動,或一次只在小區域內移動,則你可以有一種非常高階的光線跟蹤演算法來預編譯光照,並在移動光源附近重新計算一個小區域。 比如,如果你在做一個遊戲應用,其中的光源是不動的,你可以預編譯整個遊戲世界,並實現所需的光照和效果。 然後,當你的角色移動時,你可以只在它附近新增一個陰影。這會得到非常高質量的效果,而只需要最小的處理量。

光線投射

光線投射與光線跟蹤非常相似,只不過"光子"不再彈跳或與不同材料進行互動。 在一個典型的應用中,你基本上是一個黑暗的場景開始的,然後你會從光源發射一些光線。光線所到之處會被點亮,而其它區域仍然保持黑暗。 這個技術比光線跟蹤快很多,但仍然給你一個真實的陰影效果。但光線投射的問題在於它的嚴格限制;當需要新增光線反射效果時,你並沒有太多辦法可想。 通常,你不得不在光線投射和光線追蹤之間進行妥協,在速度和視覺效果之間進行平衡。

這兩種技術的主要問題在於WebGL並不會讓你訪問到除當前頂點外的其它頂點。

這意味著你要麼在CPU (相對於圖形卡) 上處理一切,要麼用第二個著色器來計算所有光照,然後將資訊存於一個假紋理上。 然後,你需要將紋理解壓縮為光照資訊,並對映到頂點上。 所以,基本上,WebGL當前的版本不是很適合於這個任務。但我並不是說無法做到,我只是說WebGL幫不了你。

Shadow Mapping

如果你的應用中光照和物件很少,光線追蹤是一個可行選項。

在WebGL中,光線投射的一個更好的替代品是陰影對映。它可以得到和光線投射一樣的效果,但用到的是一種不同的技術。 陰影對映不會解決你的所有問題,但WebGL對它是半優化了的。你可以將其理解為一種詭計,但陰影對映確實被用於真實的PC和終端應用中了。

你會問,那麼它到底是什麼呢?

你必須理解WebGL是如何渲染場景的,然後才能回答這個問題。WebGL將所有的頂點傳入頂點著色器,在應用了變換之後,它會計算出每個頂點的最終座標。 然後,為了節約時間,WebGL丟掉了被擋在其它物件之後的那些頂點,且只畫最重要的物件。就像光線投射一樣,它只不過是將光線投射到可見物件上。 所以,我們將場景的"攝像機"設定為光源的座標,並讓它的朝向光線前進的方向。 然後,WebGL自動刪除不在光線照耀下的那些頂點。於是,我們可以將這個資料存起來,使得我們在渲染場景時知道哪些頂點上是有光照的。

這個技術在紙面上聽起來不錯,但是它有一些缺點:

  • WebGL不允許你訪問深度快取;你需要在片元著色器中採用創造性的方法來儲存這個資料。
  • 即使你儲存了所有的資料,在渲染場景時,你仍然需要在它們進入頂點陣列之前將它們對映到頂點上。這需要額外的CPU時間。

所有這些技術需要大量的WebGL技巧。但我這裡展示的是一種非常基本的技術,它可以產生一種散射的光照,使得你的物件更有個性。 我不會稱其為真實感光照,但它確實讓你的物件更有意思。這個技術用到了物件的法向量矩陣,以計算相對於物件表面的光線夾角。 這是非常快而高效的,不需要什麼WebGL技巧。讓我們開始吧。

新增光照

讓我們先修改著色器,增加光照功能。我們需要增加一個boolean變數,用來確定一個物件是否應該有光照。 然後,我們需要實際的法向量,並將變換到與模型對齊。最後,我們要用一個變數,將最後的結果傳遞給片元著色器。下面是我們的新的頂點著色器:

<script id="VertexShader" type="x-shader/x-vertex">  
attribute highp vec3 VertexPosition;
attribute highp vec2 TextureCoord;
attribute highp vec3 NormalVertex;
uniform highp mat4 TransformationMatrix;
uniform highp mat4 PerspectiveMatrix;
uniform highp mxat4 NormalTransformation;
uniform bool UseLights;
varying highp vec2 vTextureCoord;
varying highp vec3 vLightLevel;
void main(void) {
 gl_Position = PerspectiveMatrix * TransformationMatrix * vec4(VertexPosition, 1.0);
 vTextureCoord = TextureCoord;
 if (UseLights) {
 highp vec3 LightColor = vec3(0.15, 0.15, 0.15);
 highp vec3 LightDirection = vec3(0.5, 0.5, 4);
 highp vec4 Normal = NormalTransformation * vec4(VertexNormal, 1.0);
 highp float FinalDirection = max(dot(Normal.xyz, LightDirection), 0.0);
 vLightLevel = (FinalDirection * LightColor);
 } else {    
 vLightLevel = vec3(1.0, 1.0, 1.0);
 }
}
</script>複製程式碼

如果我不用這些光照,則我們只不過將一個空白頂點傳遞到片元著色器,從而顏色將保持不變。當光照開啟時,我們用點乘函式來計算光線方向與物件表面法向之間的夾角,並且讓結果乘以光線的顏色,作為一種覆蓋在物件上的掩膜。

WebGL基礎教程:第三部分

Oleg Alexandrov畫的曲面法向量。

這是可行的,因為法向量已經與物件表面垂直,而點乘函式得到一個由光線與法向量的夾角相關的數。如果法向量和光線幾乎是平行的,則點乘函式返回一個正數,表示光線是正對著表面的。 當法向量和光線垂直時,曲面與光線平行,點乘函式返回零。光線與法向量之間的角度大於90度時會得到負數,但我們會用"max zero"函式將這些情況過濾掉。

現在,讓我給出如下的片元著色器:

<script id="FragmentShader" type="x-shader/x-fragment">  
varying highp vec2 vTextureCoord;
varying highp vec3 vLightLevel;
uniform sampler2D uSampler;
void main(void) {
 highp vec4 texelColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
 gl_FragColor = vec4(texelColor.rgb * vLightLevel, texelColor.a);
}     
</script>複製程式碼

這個著色器與上篇文章非常相同。唯一的差別在於我們將紋理的顏色乘上了光線層次。這個亮度或暗度將物件的不同部分割槽分開,從而表現出深度資訊。

著色器就是這些了,現在我們回到WebGL.js檔案,並修改其中的兩個類。

更新我們的框架

我們先從GLObject類開始。我們需要加一個變數來表示法向量陣列。這裡是GLObject類的定義的最開始的一部分程式碼:

function GLObject(VertexArr, TriangleArr, TextureArr, ImageSrc, NormalsArr) {
    this.Pos = { X : 0, Y : 0, Z : 0};
    this.Scale = { X : 1.0, Y : 1.0, Z : 1.0};
    this.Rotation = { X : 0, Y : 0, Z : 0};
    this.Vertices = VertexArr;
 
    //Array to hold the normals data
    this.Normals = NormalsArr;
 
    //The Rest of GLObject continues here複製程式碼

這個程式碼的意思很明顯。現在,我們回到HTML檔案,併為我們的物件新增法向量陣列。

Ready()函式中,我們已經載入了3D模型,我們還需要增加表示法向量陣列的引數。 一個空陣列表示模型並不包含任何法向量資料,於是我們不得不在沒有光照的情況下繪製物件。當此陣列包含資料時,我們要將其傳遞給GLObject物件。

我們還需要更新WebGL類。我們需要在載入完著色器後立刻將變數連結到著色器。讓我們新增法向量頂點;你的程式碼現在應該像下面這樣了:

//Link Vertex Position Attribute from Shader
this.VertexPosition = this.GL.getAttribLocation(this.ShaderProgram, "VertexPosition");
this.GL.enableVertexAttribArray(this.VertexPosition);
//Link Texture Coordinate Attribute from Shader
this.VertexTexture = this.GL.getAttribLocation(this.ShaderProgram, "TextureCoord");
this.GL.enableVertexAttribArray(this.VertexTexture);
//This is the new Normals array attribute
this.VertexNormal = this.GL.getAttribLocation(this.ShaderProgram, "VertexNormal");
this.GL.enableVertexAttribArray(this.VertexNormal);複製程式碼

接下來,讓我們更新PrepareModel()函式,並增加程式碼來在合適的時候快取法向量資料。新的程式碼置於底部Model.Ready語句之前:

if (false !== Model.Normals) {
 Buffer = this.GL.createBuffer();
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Buffer); 
 this.GL.bufferData(this.GL.ARRAY_BUFFER, new Float32Array(Model.Normals), this.GL.STATIC_DRAW);
 Model.Normals = Buffer;
}
Model.Ready = true;複製程式碼

最後,同樣重要的一件事是,更新實際的Draw函式,來併入所有這些修改。因為有不少的修改,我打算對整個函式逐一的瀏覽一遍。

this.Draw = function(Model) {
 if(Model.Image.ReadyState == true && Model.Ready == false) {
 this.PrepareModel(Model);
 }
 if (Model.Ready) {
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.Vertices); 
 this.GL.vertexAttribPointer(this.VertexPosition, 3, this.GL.FLOAT, false, 0, 0); 
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.TextureMap);
 this.GL.vertexAttribPointer(this.VertexTexture, 2, this.GL.FLOAT, false, 0, 0);複製程式碼

到這裡為止,還和以前一樣。然後是法向量部分:

//Check For Normals
if (false !== Model.Normals) {
 //Connect The normals buffer to the Shader
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.Normals);
 this.GL.vertexAttribPointer(this.VertexNormal, 3, this.GL.FLOAT, false, 0, 0);
 //Tell The shader to use lighting
 var UseLights = this.GL.getUniformLocation(this.ShaderProgram, "UseLights");  
 this.GL.uniform1i(UseLights, true);
} else {
 //Even if our object has no normals data we still have to pass something
 //So I pass in the Vertices instead
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.Vertices);
 this.GL.vertexAttribPointer(this.VertexNormal, 3, this.GL.FLOAT, false, 0, 0);
 //Tell The shader to use lighting
 var UseLights = this.GL.getUniformLocation(this.ShaderProgram, "UseLights");  
 this.GL.uniform1i(UseLights, false);
}複製程式碼

我們檢查模型是否有法向量資料。如果有,則將其連結到快取,並設定boolean變數。如果沒有,則著色器仍然需要某種資料,否則會報錯。 所以,我傳遞了頂點快取,並將UseLight變數設定為false。你可以用多個著色器來處理這種情況,但我認為我的方案在當前場合下會更簡單一些。

this.GL.bindBuffer(this.GL.ELEMENT_ARRAY_BUFFER, Model.Triangles);
//Generate The Perspective Matrix
var PerspectiveMatrix = MakePerspective(45, this.AspectRatio, 1, 1000.0);  
var TransformMatrix = Model.GetTransforms();複製程式碼

本函式的這一部分仍然保持不變。

varNormalsMatrix =  MatrixTranspose(InverseMatrix(TransformMatrix));複製程式碼

接下來是計演算法向變換矩陣。我會馬上討論MatrixTranspose()InverseMatrix()函式。 為了計演算法向量陣列的變換矩陣,我們需要計算物件的常規變換矩陣的逆矩陣的轉置。這個主題後面會介紹。

 //Set slot 0 as the active Texture
 this.GL.activeTexture(this.GL.TEXTURE0);
 //Load in the Texture To Memory
 this.GL.bindTexture(this.GL.TEXTURE_2D, Model.Image);
 //Update The Texture Sampler in the fragment shader to use slot 0
 this.GL.uniform1i(this.GL.getUniformLocation(this.ShaderProgram, "uSampler"), 0);
 //Set The Perspective and Transformation Matrices
 var pmatrix = this.GL.getUniformLocation(this.ShaderProgram, "PerspectiveMatrix");  
 this.GL.uniformMatrix4fv(pmatrix, false, new Float32Array(PerspectiveMatrix));
 var tmatrix = this.GL.getUniformLocation(this.ShaderProgram, "TransformationMatrix");  
 this.GL.uniformMatrix4fv(tmatrix, false, new Float32Array(TransformMatrix));  
 var nmatrix = this.GL.getUniformLocation(this.ShaderProgram, "NormalTransformation");  
 this.GL.uniformMatrix4fv(nmatrix, false, new Float32Array(NormalsMatrix));  
 //Draw The Triangles
 this.GL.drawElements(this.GL.TRIANGLES, Model.TriangleCount, this.GL.UNSIGNED_SHORT, 0);
 }
};複製程式碼

你可容易地看到任何WebGL應用來學到更多。

下面是Draw()函式的剩下部分。它幾乎和之前一樣,只不過新增了連結法向量矩陣到著色器的程式碼。現在,讓我們回到用於計演算法向變換矩陣的那兩個函式。

InverseMatrix()函式接受一個矩陣作為引數,並返回其逆矩陣。一個矩陣的逆矩陣指的是,乘以原矩陣得到一個單位矩陣。我們用一點基礎的代數示例來解釋這一點。 數字4的逆是1/4,因為1/4 x 4=1。矩陣裡和"1"相當的是單位矩陣。因而,InverseMatrix()函式的返回值與引數的乘積是單位矩陣。下面是此函式:

function InverseMatrix(A) {
 var s0 = A[0] * A[5] - A[4] * A[1];
 var s1 = A[0] * A[6] - A[4] * A[2];
 var s2 = A[0] * A[7] - A[4] * A[3];
 var s3 = A[1] * A[6] - A[5] * A[2];
 var s4 = A[1] * A[7] - A[5] * A[3];
 var s5 = A[2] * A[7] - A[6] * A[3];
 var c5 = A[10] * A[15] - A[14] * A[11];
 var c4 = A[9] * A[15] - A[13] * A[11];
 var c3 = A[9] * A[14] - A[13] * A[10];
 var c2 = A[8] * A[15] - A[12] * A[11];
 var c1 = A[8] * A[14] - A[12] * A[10];
 var c0 = A[8] * A[13] - A[12] * A[9];
 var invdet = 1.0 / (s0 * c5 - s1 * c4 + s2 * c3 + s3 * c2 - s4 * c1 + s5 * c0);
 var B = [];
 B[0] = ( A[5] * c5 - A[6] * c4 + A[7] * c3) * invdet;
 B[1] = (-A[1] * c5 + A[2] * c4 - A[3] * c3) * invdet;
 B[2] = ( A[13] * s5 - A[14] * s4 + A[15] * s3) * invdet;
 B[3] = (-A[9] * s5 + A[10] * s4 - A[11] * s3) * invdet;
 B[4] = (-A[4] * c5 + A[6] * c2 - A[7] * c1) * invdet;
 B[5] = ( A[0] * c5 - A[2] * c2 + A[3] * c1) * invdet;
 B[6] = (-A[12] * s5 + A[14] * s2 - A[15] * s1) * invdet;
 B[7] = ( A[8] * s5 - A[10] * s2 + A[11] * s1) * invdet;
 B[8] = ( A[4] * c4 - A[5] * c2 + A[7] * c0) * invdet;
 B[9] = (-A[0] * c4 + A[1] * c2 - A[3] * c0) * invdet;
 B[10] = ( A[12] * s4 - A[13] * s2 + A[15] * s0) * invdet;
 B[11] = (-A[8] * s4 + A[9] * s2 - A[11] * s0) * invdet;
 B[12] = (-A[4] * c3 + A[5] * c1 - A[6] * c0) * invdet;
 B[13] = ( A[0] * c3 - A[1] * c1 + A[2] * c0) * invdet;
 B[14] = (-A[12] * s3 + A[13] * s1 - A[14] * s0) * invdet;
 B[15] = ( A[8] * s3 - A[9] * s1 + A[10] * s0) * invdet;
 return B;
}複製程式碼

這個函式相當複雜,不妨偷偷告訴你,我並不完全理解這背後的數學。但我已經為你解釋了它的精髓。這個函式並不是我寫的;它是Robin Hilliard用ActionScript寫的。

下一個函式MatrixTranspose()則簡單多了,它只不過返回一個輸入矩陣的"轉置"的版本。簡而言之,它將矩陣沿對角線轉了一下。下面是程式碼:

function MatrixTranspose(A) {
 return [
 A[0], A[4], A[8], A[12],
 A[1], A[5], A[9], A[13],
 A[2], A[6], A[10], A[14],
 A[3], A[7], A[11], A[15]
 ];
}複製程式碼

你可看到,經過轉置,原來的水平行 (A[0],A[1],A[2]...) 變成了豎直列,原來的豎直列 (A[0],A[4],A[8]...) 變成了水平行。

你可以將這兩個函式新增到WebGL.js檔案中去,然後,任何包含法向量資料的模型都會有光照效果。你可以修改頂點著色器中的光照方向和顏色來得到不同的效果。

我最後希望介紹的主題是在場景中新增2D內容。在3D場景中新增2D元素有很多好處。 比如,它可用於展示座標資訊,一個縮圖,應用的指令,以及其它資訊。這個過程並不是你想象那麼直接,所以,我們還是討論一下吧。

2D?還是2.5D?

HTML不會讓你在同一個畫布 (canvas) 上使用WebGL API和2D API。

你可能會想,"為何不用HTML5的畫布 (canvas) 的內建2D API"?原因在於HTML不讓你在同一個畫布上同時使用WebGL API和2D API。 一量你將畫布 (canvas) 的上下文賦給WebGL之後,你不能再在它上面使用2D API。當你嘗試訪問2D上下文時,你得到的null。所以,我們怎麼解決這個問題呢?我可以給你兩個選項:

2.5D

2.5D指的是將2D物件 (沒有深度的物件) 新增到3D場景中。在場景中新增文字是2.5D的一個例子。 你可以將文字寫到一幅圖中,然後將圖片用作紋理貼到3D平面上,或者,你可以構造一個文字的3D模型,然後在螢幕上渲染。

這種方法的好處在於,你不需要兩個畫布 (canvas),而且如果你只用簡單的形狀,它的繪製效率也會很高。

但是,為了處理文字,要麼你為每個句話都準備圖片,要麼你為每個字建一個3D模型 (我覺得有點誇張了)。

2D

另一種方法是生成第二個畫布 (canvas),將它覆蓋在3D畫布上。我傾向於這種方法,因為它看上去更適於繪製2D內容。 我不會開始造一個新的2D框架,但是我們可以用一個簡單例子來顯示模型在當前旋轉情況下的座標資訊。 讓我們在HTML檔案中新增第二個畫布,就放在WebGL畫布的後面。下面是當前畫布和新畫布的程式碼:

<canvas id="GLCanvas" width="600" height="400" style="position:absolute; top:0px; left:0px;">  
 Your Browser Doesn't Support HTML5's Canvas.  
</canvas>  
<canvas id="2DCanvas" width="600" height="400" style="position:absolute; top:0px; left:0px;">
 Your Browser Doesn't Support HTML5's Canvas.
</canvas>複製程式碼

我還新增了一些行內的CSS程式碼,以讓第二個畫布覆蓋在第一個上。下一步是用一個變數來獲取這個2D畫布的上下文。 我將在Ready()函式中實現這一點。你的修改後的程式碼應該像下面這樣:

var GL; 
var Building;
var Canvas2D;
function Ready(){
 //Gl Declaration and Load model function Here
 Canvas2D = document.getElementById("2DCanvas").getContext("2d");
 Canvas2D.fillStyle="#000";
}複製程式碼

在頂部,你可看到我新增了2D畫布的全域性的變數。然後,我在Ready()函式的底部新增了兩行。第一行取得2D上下文,第二行設定顏色為黑色。

最後一步是在Update()函式內繪製文字。

function Update(){
 Building.Rotation.Y += 0.3
 //Clear the Canvas from the previous draw
 Canvas2D.clearRect(0, 0, 600, 400);
 //Title Text
 Canvas2D.font="25px sans-serif";
 Canvas2D.fillText("Building" , 20, 30);
 //Object's Properties
 Canvas2D.font="16px sans-serif";
 Canvas2D.fillText("X : " + Building.Pos.X , 20, 55);
 Canvas2D.fillText("Y : " + Building.Pos.Y , 20, 75);
 Canvas2D.fillText("Z : " + Building.Pos.Z , 20, 95);
 Canvas2D.fillText("Rotation : " + Math.floor(Building.Rotation.Y) , 20, 115);
 GL.GL.clear(16384 | 256);
 GL.Draw(Building);
}複製程式碼

我們首先讓模型繞Y軸旋轉,然後我們清除2D畫布之前的內容。接下來,我們設定字型大小,併為每個座標軸繪製文字。 fillText()方法接受引數:待繪製文字,x座標,y座標。

WebGL基礎教程:第三部分

此方法的簡潔性顯而易見。為了畫一些文字而這樣做似乎有些小題大做;你儘可以在指定了位置的<div/><p/>元素中寫一些文字。 但是,如果你要畫一些形狀,螺線,或一個健康顯示條,等等,此方法很可能是你最好的選擇。

最後的思考

在這三個教程中,我們建立了一個非常漂亮,但又比較基礎的3D引擎。雖然還比較原始,但它為我們進一步前行打下了堅實的基礎。 若繼續前行,我建議瞭解一下其它的框架,比如three.js或gige,從它們那兒可以瞭解有哪些可行性。此外,WebGL在瀏覽器中執行,你總是可以通過檢視其原始碼來學到更多。

更多精彩內容,請微信關注”前端達人”公眾號!

WebGL基礎教程:第三部分

原文連結:https://code.tutsplus.com/tutorials/webgl-essentials-part-iii--net-27694

原文作者:Gabriel Manricks


相關文章