WebGL基礎教程:第二部分

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

本文基於這個系列第一部分中介紹的框架,另外還增加了一個模型匯入器,和針對3D物件定製的類。 你會從中瞭解到動畫和控制,內容很多,我們趕緊開始吧。

因為嚴重依賴於上一篇文章,所以,如果你還沒讀過,建議先
讀一下

WebGL在3D世界中操縱物體的方式是使用稱為

變換
的數學公式。所以,在我們開始構建3D類之前,我將向你展示不同型別的一些變換,以前它們是如何實現的。

變換

有三種基本變換可作用於3D物件。

  • 移動
  • 縮放
  • 旋轉

這些函式中的每一個都可作用於X軸、Y軸或Z軸,因而組合得到9種基本的變換。它們通過不同的方式來影響3D物件的4x4變換矩陣。 為了在同一個物件中執行多個變換,而不產生重疊的問題,我們要將將每個變換乘到物件的矩陣中去,而不是逐一地直接應用到物件的矩陣上。 移動變換是最簡單的,我們先從移動開始。

移動又稱為平移 (Translation)。

移動一個3D物件是最簡單的一種變換,因為在4x4矩陣中為它保留了特殊的位置。 我們可以不用涉及任何數學;只需要把X,Y和Z座標放到矩陣中指定位置上,就可以了。如果你觀察這個4x4矩陣,你會發現它們被放在最後一行上。 此外,你需要知道的是,正Z軸指向攝像機後面。因而,Z值為-100時,會導致物件深入螢幕100個單元。在我們的程式碼中會對此進行補償。

為了執行多個變換,你不能簡單地修改物件的真實矩陣;你必須將變換應用於一個新的空白矩陣,稱為

單位矩陣
,然後將其與主矩陣相乘。

WebGL基礎教程:第二部分

矩陣乘法理解起來會有些困難,但基本思想是第一個矩陣的豎直的列乘以第二個矩陣的水平行。 比如,新矩陣第一個數為第一個矩陣第一行乘以另一矩陣的第一列。新矩陣第二個數是第一個矩陣的第一行乘以第二個矩陣的第二列,依此類推。

下面的程式碼片斷是JavaScript中實現的矩陣乘法。將其加到你的.js檔案中,參見本系列教程第一部分。

function MH(A, B) {
 var Sum = 0;
 for (var i = 0; i < A.length; i++) {
 Sum += A[i] * B[i];
 }
 return Sum;
}
function MultiplyMatrix(A, B) {
 var A1 = [A[0], A[1], A[2], A[3]];
 var A2 = [A[4], A[5], A[6], A[7]];
 var A3 = [A[8], A[9], A[10], A[11]];
 var A4 = [A[12], A[13], A[14], A[15]];
 var B1 = [B[0], B[4], B[8], B[12]];
 var B2 = [B[1], B[5], B[9], B[13]];
 var B3 = [B[2], B[6], B[10], B[14]];
 var B4 = [B[3], B[7], B[11], B[15]];
 return [
 MH(A1, B1), MH(A1, B2), MH(A1, B3), MH(A1, B4),
 MH(A2, B1), MH(A2, B2), MH(A2, B3), MH(A2, B4),
 MH(A3, B1), MH(A3, B2), MH(A3, B3), MH(A3, B4),
 MH(A4, B1), MH(A4, B2), MH(A4, B3), MH(A4, B4)];
}複製程式碼

我認為我們無需糾纏於如何理解這個過程,因為它們只不過是數學上矩陣乘法的必要步驟。我們接著介紹縮放吧。

縮放

縮放一個模型同樣簡單-因為它也是乘法。你需要將第三個對角元素乘以縮放係數。 再一次,記得順序是X,Y和Z。所以,如果你想讓你的物件在所有三個座標軸上都變成兩倍大,則你需要讓第一個,第六個和第十一個元素都乘以2。

WebGL基礎教程:第二部分

旋轉

旋轉是最難懂的變換,因為旋轉軸在三個座標軸上時,旋轉矩陣都不一樣。下圖給出了每個座標軸上的旋轉方程。

WebGL基礎教程:第二部分

如果你完全看不懂也沒關係;我們馬上會在JavaScript的具體實現中複習一下的。

重要的一點是,執行變換的順序是很關鍵的;不同的順序會產生不同的結果。

重要的一點是,執行變換的順序是很關鍵的;不同的順序會產生不同的結果。 如果你先移動物件然後再旋轉,WebGL會像揮舞球拍一樣舞動你的物件,而不只是讓物件在原地旋轉。 如果你先旋轉再移動,則你會將物件移動到指定的位置上,只不過它會朝向你指定的方向上。 這是因為在3D世界中,變換是繞原點-0,0,0-來執行的。不存在對的或錯的順序。最終都是取決於你想要實現的效果。

要實現一些高階的動畫,需要的每一種變換可能都會多個。比如,如果你想讓一扇門繞絞鏈轉動,你會先移動門,讓它的絞鏈位於Y軸上,即在X軸和Z軸上都為零。 然後,繞Y軸旋轉,這樣門就可以繞絞鏈轉動了。最後,你還需要將其再次移動,使得它可以放到場景中的指定位置上。

這些型別的動畫在不同的場合下需要進行不同的定製,所以就沒有必要專門寫一個函式了。 不過,我會寫一個函式執行最基本的順序的變換:縮放,旋轉,移動。這確保了所有物體都在指定位置,並有正確的朝向。

現在你已經對所有幕後的數學有了基本的理解,並瞭解了動畫的工作原理,讓我們建立一個JavaScript資料型別,來儲存我們的3D物件。

GL物件

回憶本系列教程的第一部分,你需要三個陣列來繪製一個基本的3D物件:頂點陣列,三角陣列和紋理陣列。它們將是我們的資料型別的基礎。 我們還需要用一些變數來表示在每一個軸上的三種變換。最後,我們需要用一個變數來表示紋理影象,並用來指示模型是否已經載入完畢。

下面是一個3D物件在JavaScript中的實現。

function GLObject(VertexArr, TriangleArr, TextureArr, ImageSrc) {
 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;
 this.Triangles = TriangleArr;
 this.TriangleCount = TriangleArr.length;
 this.TextureMap = TextureArr;
 this.Image = new Image();
 this.Image.onload = function () {
 this.ReadyState = true;
 };
 this.Image.src = ImageSrc;
 this.Ready = false;
 //Add Transformation function Here
}複製程式碼

我增加了兩個獨立的“ready”變數:一個用來表示影象是否準備好了,一個用於模型。當影象準備完畢,我們將通過將影象變換為WebGL紋理,以及將三個陣列快取於WebGL的快取中,從而準備我們的模型。 這會加速我們的程式,因為不需要在每個繪製迴圈中都快取一次資料。因為我們將陣列存到快取中去了,我們需要將三角形的數目存於一個獨立的變數中。

現在,讓我們加一個函式,來計算物件的變換矩陣。這個函式將取出所有的區域性變數,並讓它們以之前提到的順序 (縮放,旋轉,然後平移) 相乘。 你可以在這個變換順序下得到一些不同的效果。將註釋//Add Transformation function Here換成如下程式碼:

this.GetTransforms = function () {
 //Create a Blank Identity Matrix
 var TMatrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
 //Scaling
 var Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
 Temp[0] *= this.Scale.X;
 Temp[5] *= this.Scale.Y;
 Temp[10] *= this.Scale.Z;
 TMatrix = MultiplyMatrix(TMatrix, Temp);
 //Rotating X
 Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
 var X = this.Rotation.X * (Math.PI / 180.0);
 Temp[5] = Math.cos(X);
 Temp[6] = Math.sin(X);
 Temp[9] = -1 * Math.sin(X);
 Temp[10] = Math.cos(X);
 TMatrix = MultiplyMatrix(TMatrix, Temp);
 //Rotating Y
 Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
 var Y = this.Rotation.Y * (Math.PI / 180.0);
 Temp[0] = Math.cos(Y);
 Temp[2] = -1 * Math.sin(Y);
 Temp[8] = Math.sin(Y);
 Temp[10] = Math.cos(Y);
 TMatrix = MultiplyMatrix(TMatrix, Temp);
 //Rotating Z
 Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
 var Z = this.Rotation.Z * (Math.PI / 180.0);
 Temp[0] = Math.cos(Z);
 Temp[1] = Math.sin(Z);
 Temp[4] = -1 * Math.sin(Z);
 Temp[5] = Math.cos(Z);
 TMatrix = MultiplyMatrix(TMatrix, Temp);
 //Moving
 Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
 Temp[12] = this.Pos.X;
 Temp[13] = this.Pos.Y;
 Temp[14] = this.Pos.Z * -1;
 return MultiplyMatrix(TMatrix, Temp);
}
因為旋轉公式相互重疊,它們必須一次執行一個。這個函式替換了上一個教程中的MakeTransform函式,所以你可以將它從指令碼中刪除。複製程式碼

OBJ匯入器

現在,我們有了一個3D類,我們還需要一種方式來匯入資料。我們將編寫一個簡單的模型匯入器,它會將.obj檔案變換為必要的資料,然後得到一個我們新建立的GLObject的物件。 使用.obj模型格式的原因在於,它用一種原始的形式來儲存所有的資料,並且它有很好的文件介紹它的資訊儲存方式。 如果你的3D建模程式不支援匯出.obj檔案,則你總是可以編寫一個基它資料格式的匯入器。 .obj是一種標準的3D檔案型別;所以,應該不會有什麼問題。或者,你也可以安裝Blender,這是一個跨平臺的3D建模程式,它是支援匯出.obj的。

.obj檔案中,每一行的頭兩個字母告訴我們該行中包含了什麼型別的資料。 "v"表示一個"頂點座標"行,"vt"表示一個"紋理座標"行,而"f"是一個對映行。基於這些資訊,我編寫了下面的函式:

function LoadModel(ModelName, CB) {
 var Ajax = new XMLHttpRequest();
 Ajax.onreadystatechange = function () {
 if (Ajax.readyState == 4 && Ajax.status == 200) {
 //Parse Model Data
 var Script = Ajax.responseText.split("\n");
 var Vertices = [];
 var VerticeMap = [];
 var Triangles = [];
 var Textures = [];
 var TextureMap = [];
 var Normals = [];
 var NormalMap = [];
 var Counter = 0;複製程式碼

此函式接受兩個引數:模型名稱和回撥函式。回撥函式接受四個陣列作為引數:頂點,三角形,紋理和法向量陣列。 我之前還沒介紹過法向量,所以你可以現在暫時忽略。我會在接下來的文章中討論光照時進行介紹。

這個匯入器首先建立一個XMLHttpRequest物件,並定義它的onreadystatechange事件處理器。在此處理器內部,我們將檔案分割成行,然後定義了一些變數。 .obj檔案首先定義了所有的唯一座標,並定義它們的順序。這也是為什麼為頂點、紋理和法向量定義了兩個變數的原因。 計數器counter變數用於填充三角形陣列,因為.obj檔案是按照順序定義這些三角形的。

接下來,我們必須遍歷檔案的每一行,並檢查它們各自是哪一種型別:

for (var I in Script) {
 var Line = Script[I];
 //If Vertice Line
 if (Line.substring(0, 2) == "v ") {
 var Row = Line.substring(2).split(" ");
 Vertices.push({
 X: parseFloat(Row[0]),
 Y: parseFloat(Row[1]),
 Z: parseFloat(Row[2])
 });
 }
 //Texture Line
 else if (Line.substring(0, 2) == "vt") {
 var Row = Line.substring(3).split(" ");
 Textures.push({
 X: parseFloat(Row[0]),
 Y: parseFloat(Row[1])
 });
 }
 //Normals Line
 else if (Line.substring(0, 2) == "vn") {
 var Row = Line.substring(3).split(" ");
 Normals.push({
 X: parseFloat(Row[0]),
 Y: parseFloat(Row[1]),
 Z: parseFloat(Row[2])
 });
 複製程式碼

前三行非常簡單;它們包含了唯一性座標的一個列表,用於頂點、紋理和法向量。 我們需要做的是將這些座標存入相應的陣列中。最後一種行的型別稍微複雜一些,因為它包含了多個東西。 它可以包含頂點,或頂點和紋理,或頂點、紋理和法向量。這樣,我們不得不檢查是這三種情況中的哪一種。下面的程式碼實現了這個功能:

//Mapping Line
 else if (Line.substring(0, 2) == "f ") {
 var Row = Line.substring(2).split(" ");
 for (var T in Row) {
 //Remove Blank Entries
 if (Row[T] != "") {
 //If this is a multi-value entry
 if (Row[T].indexOf("/") != -1) {
 //Split the different values
 var TC = Row[T].split("/");
 //Increment The Triangles Array
 Triangles.push(Counter);
 Counter++;
 //Insert the Vertices 
 var index = parseInt(TC[0]) - 1;
 VerticeMap.push(Vertices[index].X);
 VerticeMap.push(Vertices[index].Y);
 VerticeMap.push(Vertices[index].Z);
 //Insert the Textures
 index = parseInt(TC[1]) - 1;
 TextureMap.push(Textures[index].X);
 TextureMap.push(Textures[index].Y);
 //If This Entry Has Normals Data
 if (TC.length > 2) {
 //Insert Normals
 index = parseInt(TC[2]) - 1;
 NormalMap.push(Normals[index].X);
 NormalMap.push(Normals[index].Y);
 NormalMap.push(Normals[index].Z);
 }
 }
 //For rows with just vertices
 else {
 Triangles.push(Counter); //Increment The Triangles Array
 Counter++;
 var index = parseInt(Row[T]) - 1;
 VerticeMap.push(Vertices[index].X);
 VerticeMap.push(Vertices[index].Y);
 VerticeMap.push(Vertices[index].Z);
 }
 }
 }
 }
複製程式碼

這個程式碼雖然長,但並不算複雜。雖然我討論了.obj檔案中只包含有頂點資料的情況,但我們的框架還需要頂點座標和紋理座標。 如果一個.obj檔案只包含頂點資料,你將必須手動地新增紋理座標資料。

現在,讓我們將這些資料傳遞給回撥函式,並完成我們的LoadModel函式。

 }
 //Return The Arrays
 CB(VerticeMap, Triangles, TextureMap, NormalMap);
 }
 }
 Ajax.open("GET", ModelName + ".obj", true);
 Ajax.send();
}複製程式碼

你需要小心的是,我們的WebGL框架是非常基本的,只能畫用三角形構造出來的模型。所以,你需要相應地編輯你的3D模型。 幸運的是,大部分3D應用都支援或有外掛支援模型的三角化。我通過基本的建模技術構造了一個簡單的房子的模型,包含在原始碼中,供你使用。

WebGL基礎教程:第二部分

現在,讓我們修改上篇文章中的Draw函式,使之能夠處理我們新的3D模型的資料型別。

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);
 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();
 //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));
 //Draw The Triangles
 this.GL.drawElements(this.GL.TRIANGLES, Model.TriangleCount, this.GL.UNSIGNED_SHORT, 0);
 }
};複製程式碼

新的繪製函式首先檢查模型是否已經為WebGL準備好。如果紋理已經載入,它會開始準備繪製模型。我們呆會兒會介紹這個PrepareModel函式。 如果模型準備好了,它會連線到著色器中的快取,並和之前一樣,載入透視矩陣和變換矩陣。唯一實在的差別在於,它的所有資料都來自於模型物件。

PrepareModel函式只不過是將紋理和資料陣列轉變為與WebGL相容的變數。下面就是這個函式;將它加到繪製函式之前。

this.PrepareModel = function (Model) {
 Model.Image = this.LoadTexture(Model.Image);
 //Convert Arrays to buffers
 var Buffer = this.GL.createBuffer();
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Buffer);
 this.GL.bufferData(this.GL.ARRAY_BUFFER, new Float32Array(Model.Vertices), this.GL.STATIC_DRAW);
 Model.Vertices = Buffer;
 Buffer = this.GL.createBuffer();
 this.GL.bindBuffer(this.GL.ELEMENT_ARRAY_BUFFER, Buffer);
 this.GL.bufferData(this.GL.ELEMENT_ARRAY_BUFFER, new Uint16Array(Model.Triangles), this.GL.STATIC_DRAW);
 Model.Triangles = Buffer;
 Buffer = this.GL.createBuffer();
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Buffer);
 this.GL.bufferData(this.GL.ARRAY_BUFFER, new Float32Array(Model.TextureMap), this.GL.STATIC_DRAW);
 Model.TextureMap = Buffer;
 Model.Ready = true;
};複製程式碼

現在,我們的框架已經完成,我們可以開始修改HTML頁面。

HTML頁面

你可以清除script標籤中的所有程式碼,由於新的GLObject的功勞,我們可以把程式碼寫得更緊湊一些。

下面是完整的JavaScript程式碼:

var GL;
var Building;
 
function Ready() {
 GL = new WebGL("GLCanvas", "FragmentShader", "VertexShader");
 LoadModel("House", function (VerticeMap, Triangles, TextureMap) {
 Building = new GLObject(VerticeMap, Triangles, TextureMap, "House.png");
 
 Building.Pos.Z = 650;
 
 //My Model Was a bit too big
 Building.Scale.X = 0.5;
 Building.Scale.Y = 0.5;
 Building.Scale.Z = 0.5;
 
 //And Backwards
 Building.Rotation.Y = 180;
 
 setInterval(Update, 33);
 });
}
 
function Update() {
 Building.Rotation.Y += 0.2
 GL.Draw(Building);
}複製程式碼

我們載入一個模型,告訴頁面每秒鐘更新30次。Update函式讓模型繞Y軸旋轉,這是通過更新這個物件的Y軸Rotation實現的。 我的模型對於WebGL來說還是大了一些,這不太好,所以我需要在程式碼中稍作調整。

除非你想要那種影院般的WebGL展示,你很可能希望新增一些控制功能。讓我們看看如何在應用中新增滑鼠控制功能。

鍵盤控制

這只不過是原生的JavaScript功能,並非WebGL的技術,但它對於控制和放置3D模型是很有幫助的。你需要做的全部事情只是為鍵盤的keydownkeyup事件新增一個事件監聽器,並檢查到底是哪個鍵被按下了。 每個鍵都一個特殊的程式碼,找出這種對應關係的一種較好的辦法是在事件觸發時在終端中記錄下按鍵的程式碼。所以,在載入模型的程式碼處,在setInterval行之後新增如下的程式碼:

document.onkeydown = handleKeyDown;複製程式碼

這會設定函式handleKeyDown,來處理keydown事件。下面是handleKeyDown函式的程式碼:

function handleKeyDown(event) {
 //You can uncomment the next line to find out each key's code
 //alert(event.keyCode);
 if (event.keyCode == 37) {
 //Left Arrow Key
 Building.Pos.X -= 4;
 } else if (event.keyCode == 38) {
 //Up Arrow Key
 Building.Pos.Y += 4;
 } else if (event.keyCode == 39) {
 //Right Arrow Key
 Building.Pos.X += 4;
 } else if (event.keyCode == 40) {
 //Down Arrow Key
 Building.Pos.Y -= 4;
 }
}複製程式碼

這個函式的功能是更新物件的屬性;而我們WebGL框架會處理剩下的所有事情。

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

WebGL基礎教程:第二部分


相關文章