3D 圖形:WebGL 教程
已發表: 2022-03-11進入 3D 圖形的世界可能非常令人生畏。 無論您只是想創建一個交互式 3D 徽標,還是設計一個成熟的遊戲,如果您不了解 3D 渲染的原理,您就會被一個抽像出很多東西的庫所困。
使用庫可能是正確的工具,JavaScript 有一個令人驚嘆的開源工具,以 three.js 的形式。 但是,使用預製解決方案有一些缺點:
- 它們可能具有許多您不打算使用的功能。 縮小的基礎 three.js 功能的大小約為 500kB,任何額外的功能(加載實際模型文件就是其中之一)使有效負載更大。 傳輸這麼多數據只是為了在您的網站上顯示一個旋轉的徽標將是一種浪費。
- 額外的抽象層可以使原本簡單的修改變得困難。 您在屏幕上為對象著色的創造性方法可以直接實現,也可以需要數十小時的工作才能融入庫的抽像中。
- 雖然該庫在大多數情況下都得到了很好的優化,但可以為您的用例剪掉很多花里胡哨的東西。 渲染器可以使某些程序在顯卡上運行數百萬次。 從此類過程中刪除的每條指令都意味著較弱的圖形卡可以毫無問題地處理您的內容。
即使您決定使用高級圖形庫,對底層事物的基本知識也可以讓您更有效地使用它。 庫還可以具有高級功能,例如ShaderMaterial
中的three.js
。 了解圖形渲染的原理可以讓您使用這些功能。
我們的目標是簡要介紹渲染 3D 圖形和使用 WebGL 實現它們背後的所有關鍵概念。 您將看到最常見的操作,即在空白空間中顯示和移動 3D 對象。
最終代碼可供您分叉和使用。
表示 3D 模型
您需要了解的第一件事是如何表示 3D 模型。 模型由三角形網格構成。 對於三角形的每個角,每個三角形由三個頂點表示。 有三個最常見的屬性附加到頂點。
頂點位置
位置是頂點最直觀的屬性。 它是 3D 空間中的位置,由 3D 坐標向量表示。 如果您知道空間中三個點的確切坐標,您將擁有在它們之間繪製一個簡單三角形所需的所有信息。 為了使模型在渲染時看起來真的很好,還需要向渲染器提供一些東西。
頂點法線
考慮上面的兩個模型。 它們由相同的頂點位置組成,但在渲染時看起來完全不同。 這怎麼可能?
除了告訴渲染器我們想要一個頂點的位置,我們還可以給它一個關於表面在那個確切位置是如何傾斜的提示。 提示採用模型上特定點的表面法線的形式,用 3D 矢量表示。 下圖應該讓您更清楚地了解它是如何處理的。
左右表面分別對應上圖中的左右球。 紅色箭頭表示為頂點指定的法線,而藍色箭頭表示渲染器對法線應如何查找頂點之間的所有點的計算。 該圖像顯示了 2D 空間的演示,但同樣的原理也適用於 3D。
法線是燈光如何照亮表面的提示。 光線的方向越接近法線,點越亮。 法線方向的漸變會導致光梯度,而中間沒有變化的突然變化會導致表面具有恆定的照明,以及它們之間的照明突然變化。
紋理坐標
最後一個重要的屬性是紋理坐標,通常稱為 UV 映射。 您有一個模型和一個要應用到它的紋理。 紋理上有不同的區域,代表我們想要應用於模型不同部分的圖像。 必須有一種方法來標記哪個三角形應該用紋理的哪個部分來表示。 這就是紋理映射的用武之地。
對於每個頂點,我們標記兩個坐標,U 和 V。這些坐標代表紋理上的一個位置,其中 U 代表水平軸,V 代表垂直軸。 這些值不是以像素為單位,而是圖像中的百分比位置。 圖像的左下角用兩個 0 表示,而右上角用兩個 1 表示。
通過獲取三角形中每個頂點的 UV 坐標,並在紋理上應用在這些坐標之間捕獲的圖像來繪製三角形。
您可以在上圖中看到 UV 映射的演示。 採用球形模型,並將其切成小到可以展平到 2D 表面上的部分。 進行切割的接縫用粗線標記。 其中一個補丁已突出顯示,因此您可以很好地看到事物的匹配方式。 您還可以看到穿過微笑中間的接縫如何將嘴巴的各個部分分成兩個不同的補丁。
線框不是紋理的一部分,只是覆蓋在圖像上,這樣您就可以看到事物是如何映射在一起的。
加載 OBJ 模型
信不信由你,這就是創建自己的簡單模型加載器所需的全部知識。 OBJ 文件格式非常簡單,只需幾行代碼即可實現解析器。
該文件以v <float> <float> <float>
格式列出頂點位置,並帶有可選的第四個浮點數,我們將忽略它,以保持簡單。 頂點法線用vn <float> <float> <float>
類似地表示。 最後,紋理坐標用vt <float> <float>
表示,還有一個可選的第三個浮點數,我們將忽略它。 在所有三種情況下,浮點數代表各自的坐標。 這三個屬性累積在三個數組中。
面用一組頂點表示。 每個頂點用每個屬性的索引表示,索引從 1 開始。有多種表示方式,但我們將堅持f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3
格式,要求提供所有三個屬性,並將每個面的頂點數限制為三個。 所有這些限制都是為了使加載器盡可能簡單,因為所有其他選項都需要一些額外的瑣碎處理才能採用 WebGL 喜歡的格式。
我們對文件加載器提出了很多要求。 這可能聽起來有限制,但 3D 建模應用程序往往使您能夠在將模型導出為 OBJ 文件時設置這些限制。
以下代碼解析表示 OBJ 文件的字符串,並以面數組的形式創建模型。
function Geometry (faces) { this.faces = faces || [] } // Parses an OBJ file, passed as a string Geometry.parseOBJ = function (src) { var POSITION = /^v\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var NORMAL = /^vn\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var UV = /^vt\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var FACE = /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/ lines = src.split('\n') var positions = [] var uvs = [] var normals = [] var faces = [] lines.forEach(function (line) { // Match each line of the file against various RegEx-es var result if ((result = POSITION.exec(line)) != null) { // Add new vertex position positions.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = NORMAL.exec(line)) != null) { // Add new vertex normal normals.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = UV.exec(line)) != null) { // Add new texture mapping point uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2]))) } else if ((result = FACE.exec(line)) != null) { // Add new face var vertices = [] // Create three vertices from the passed one-indexed indices for (var i = 1; i < 10; i += 3) { var part = result.slice(i, i + 3) var position = positions[parseInt(part[0]) - 1] var uv = uvs[parseInt(part[1]) - 1] var normal = normals[parseInt(part[2]) - 1] vertices.push(new Vertex(position, normal, uv)) } faces.push(new Face(vertices)) } }) return new Geometry(faces) } // Loads an OBJ file from the given URL, and returns it as a promise Geometry.loadOBJ = function (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(Geometry.parseOBJ(xhr.responseText)) } } xhr.open('GET', url, true) xhr.send(null) }) } function Face (vertices) { this.vertices = vertices || [] } function Vertex (position, normal, uv) { this.position = position || new Vector3() this.normal = normal || new Vector3() this.uv = uv || new Vector2() } function Vector3 (x, y, z) { this.x = Number(x) || 0 this.y = Number(y) || 0 this.z = Number(z) || 0 } function Vector2 (x, y) { this.x = Number(x) || 0 this.y = Number(y) || 0 }
Geometry
結構包含將模型發送到圖形卡進行處理所需的確切數據。 不過,在您這樣做之前,您可能希望能夠在屏幕上移動模型。
執行空間變換
我們加載的模型中的所有點都相對於它的坐標系。 如果我們想要平移、旋轉和縮放模型,我們需要做的就是在其坐標系上執行該操作。 相對於坐標系 B 的坐標系 A 由其中心的位置定義為向量p_ab
,其每個軸的向量x_ab
、 y_ab
和z_ab
表示該軸的方向。 因此,如果一個點在坐標系 A 的x
軸上移動 10,那麼——在坐標系 B 中——它將向x_ab
的方向移動,乘以 10。
所有這些信息都存儲在以下矩陣形式中:
x_ab.x y_ab.x z_ab.x p_ab.x x_ab.y y_ab.y z_ab.y p_ab.y x_ab.z y_ab.z z_ab.z p_ab.z 0 0 0 1
如果我們想變換 3D 向量q
,我們只需要將變換矩陣與向量相乘:
qx qy qz 1
這導致該點沿新的x
軸移動qx
,沿新的y
軸移動qy
,並沿新的z
軸移動qz
。 最後,它使點額外移動了p
向量,這就是我們使用 1 作為乘法的最後一個元素的原因。
使用這些矩陣的最大優勢在於,如果我們要對頂點執行多個變換,我們可以在變換頂點本身之前通過將它們的矩陣相乘來將它們合併為一個變換。
可以執行各種轉換,我們將看看關鍵的轉換。
沒有轉型
如果沒有發生變換,則p
向量是零向量, x
向量是[1, 0, 0]
, y
是[0, 1, 0]
, z
是[0, 0, 1]
。 從現在開始,我們將這些值稱為這些向量的默認值。 應用這些值會給我們一個單位矩陣:
1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1
這是鏈接轉換的一個很好的起點。
翻譯
當我們執行平移時,除了p
向量之外的所有向量都有它們的默認值。 這導致以下矩陣:
1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1
縮放
縮放模型意味著減少每個坐標對點位置的貢獻量。 由於縮放沒有統一的偏移量,所以p
向量保持其默認值。 默認軸向量應乘以它們各自的縮放因子,得到以下矩陣:
s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1
這裡s_x
、 s_y
和s_z
表示應用於每個軸的縮放。
迴轉
上圖顯示了當我們圍繞 Z 軸旋轉坐標係時會發生什麼。
旋轉導致沒有統一的偏移量,因此p
向量保持其默認值。 現在事情變得有點棘手。 旋轉會導致沿原始坐標系中某個軸的移動向不同方向移動。 因此,如果我們將坐標系繞 Z 軸旋轉 45 度,沿原始坐標系的x
軸移動會導致新坐標系中x
軸和y
軸之間的對角線方向移動。
為簡單起見,我們將向您展示變換矩陣如何尋找圍繞主軸的旋轉。
Around X: 1 0 0 0 0 cos(phi) sin(phi) 0 0 -sin(phi) cos(phi) 0 0 0 0 1 Around Y: cos(phi) 0 sin(phi) 0 0 1 0 0 -sin(phi) 0 cos(phi) 0 0 0 0 1 Around Z: cos(phi) -sin(phi) 0 0 sin(phi) cos(phi) 0 0 0 0 1 0 0 0 0 1
執行
所有這些都可以實現為一個存儲 16 個數字的類,以列優先順序存儲矩陣。
function Transformation () { // Create an identity transformation this.fields = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } // Multiply matrices, to chain transformations Transformation.prototype.mult = function (t) { var output = new Transformation() for (var row = 0; row < 4; ++row) { for (var col = 0; col < 4; ++col) { var sum = 0 for (var k = 0; k < 4; ++k) { sum += this.fields[k * 4 + row] * t.fields[col * 4 + k] } output.fields[col * 4 + row] = sum } } return output } // Multiply by translation matrix Transformation.prototype.translate = function (x, y, z) { var mat = new Transformation() mat.fields[12] = Number(x) || 0 mat.fields[13] = Number(y) || 0 mat.fields[14] = Number(z) || 0 return this.mult(mat) } // Multiply by scaling matrix Transformation.prototype.scale = function (x, y, z) { var mat = new Transformation() mat.fields[0] = Number(x) || 0 mat.fields[5] = Number(y) || 0 mat.fields[10] = Number(z) || 0 return this.mult(mat) } // Multiply by rotation matrix around X axis Transformation.prototype.rotateX = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[5] = c mat.fields[10] = c mat.fields[9] = -s mat.fields[6] = s return this.mult(mat) } // Multiply by rotation matrix around Y axis Transformation.prototype.rotateY = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[10] = c mat.fields[2] = -s mat.fields[8] = s return this.mult(mat) } // Multiply by rotation matrix around Z axis Transformation.prototype.rotateZ = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[5] = c mat.fields[4] = -s mat.fields[1] = s return this.mult(mat) }
透過相機看
這是在屏幕上呈現對象的關鍵部分:相機。 相機有兩個關鍵組件; 即它的位置,以及它如何將觀察到的物體投射到屏幕上。
相機位置通過一個簡單的技巧來處理。 將相機向前移動一米和將整個世界向後移動一米之間沒有視覺上的區別。 所以很自然,我們通過將矩陣的逆作為變換來做後者。
第二個關鍵組成部分是將觀察到的物體投影到鏡頭上的方式。 在 WebGL 中,屏幕上可見的所有內容都位於一個框內。 該框在每個軸上的範圍在 -1 和 1 之間。 所有可見的東西都在那個盒子裡。 我們可以使用相同的變換矩陣方法來創建投影矩陣。
正交投影
最簡單的投影是正交投影。 你在空間中取一個盒子,表示寬度、高度和深度,假設它的中心在零位置。 然後,投影會調整框的大小以使其適合之前描述的 WebGL 觀察對象的框。 由於我們想將每個維度調整為 2,我們將每個軸縮放2/size
,其中size
是相應軸的維度。 一個小警告是我們將 Z 軸乘以負數。 這樣做是因為我們想要翻轉該維度的方向。 最終矩陣具有以下形式:
2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1
透視投影
我們不會詳細介紹這個投影是如何設計的,而只是使用最終的公式,這已經是非常標準的了。 我們可以通過將投影放置在 x 和 y 軸上的零位置來簡化它,使右/左和上/下限分別等於width/2
和height/2
。 參數n
和f
表示near
和far
裁剪平面,它們是相機可以捕獲的點的最小和最大距離。 它們由上圖中的平截頭體的平行邊表示。
透視投影通常用視野(我們將使用垂直的)、縱橫比以及近平面和遠平面距離來表示。 該信息可用於計算width
和height
,然後可以從以下模板創建矩陣:
2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0
要計算寬度和高度,可以使用以下公式:
height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height
FOV(視野)表示相機用鏡頭捕捉到的垂直角度。 縱橫比表示圖像寬度和高度之間的比率,並且基於我們要渲染到的屏幕的尺寸。
執行
現在我們可以將相機表示為一個存儲相機位置和投影矩陣的類。 我們還需要知道如何計算逆變換。 解決一般矩陣求逆可能會出現問題,但對於我們的特殊情況,有一種簡化的方法。
function Camera () { this.position = new Transformation() this.projection = new Transformation() } Camera.prototype.setOrthographic = function (width, height, depth) { this.projection = new Transformation() this.projection.fields[0] = 2 / width this.projection.fields[5] = 2 / height this.projection.fields[10] = -2 / depth } Camera.prototype.setPerspective = function (verticalFov, aspectRatio, near, far) { var height_div_2n = Math.tan(verticalFov * Math.PI / 360) var width_div_2n = aspectRatio * height_div_2n this.projection = new Transformation() this.projection.fields[0] = 1 / height_div_2n this.projection.fields[5] = 1 / width_div_2n this.projection.fields[10] = (far + near) / (near - far) this.projection.fields[10] = -1 this.projection.fields[14] = 2 * far * near / (near - far) this.projection.fields[15] = 0 } Camera.prototype.getInversePosition = function () { var orig = this.position.fields var dest = new Transformation() var x = orig[12] var y = orig[13] var z = orig[14] // Transpose the rotation matrix for (var i = 0; i < 3; ++i) { for (var j = 0; j < 3; ++j) { dest.fields[i * 4 + j] = orig[i + j * 4] } } // Translation by -p will apply R^T, which is equal to R^-1 return dest.translate(-x, -y, -z) }
這是我們開始在屏幕上繪製東西之前需要的最後一塊。
使用 WebGL 圖形管道繪製對象
您可以繪製的最簡單的表面是三角形。 事實上,您在 3D 空間中繪製的大部分內容都由大量三角形組成。
您需要了解的第一件事是屏幕在 WebGL 中是如何表示的。 它是一個 3D 空間,在x 、 y和z軸上跨越 -1 和 1。 默認情況下不使用此z軸,但您對 3D 圖形感興趣,因此您需要立即啟用它。
考慮到這一點,接下來是在此表面上繪製三角形所需的三個步驟。
您可以定義三個頂點,它們代表您要繪製的三角形。 您序列化該數據並將其發送到 GPU(圖形處理單元)。 使用可用的整個模型,您可以對模型中的所有三角形執行此操作。 您給出的頂點位置在您加載的模型的局部坐標空間中。 簡而言之,您提供的位置是文件中的確切位置,而不是執行矩陣變換後得到的位置。
現在您已經將頂點提供給 GPU,您可以告訴 GPU 在將頂點放置到屏幕上時使用什麼邏輯。 這一步將用於應用我們的矩陣變換。 GPU 非常擅長將大量 4x4 矩陣相乘,因此我們將充分利用這種能力。
在最後一步,GPU 將光柵化該三角形。 光柵化是獲取矢量圖形並確定需要為要顯示的矢量圖形對象繪製屏幕的哪些像素的過程。 在我們的例子中,GPU 試圖確定哪些像素位於每個三角形內。 對於每個像素,GPU 會詢問您希望將其繪製成什麼顏色。
這些是繪製您想要的任何東西所需的四個元素,它們是圖形管道的最簡單示例。 接下來是對它們中的每一個的了解,以及一個簡單的實現。
默認幀緩衝區
WebGL 應用程序最重要的元素是 WebGL 上下文。 您可以使用gl = canvas.getContext('webgl')
訪問它,或者使用'experimental-webgl'
作為後備,以防當前使用的瀏覽器尚不支持所有 WebGL 功能。 我們提到的canvas
是我們要在其上繪製的畫布的 DOM 元素。 上下文包含很多東西,其中包括默認的幀緩衝區。
您可以將幀緩衝區鬆散地描述為可以繪製的任何緩衝區(對象)。 默認情況下,默認幀緩衝區存儲 WebGL 上下文綁定到的畫布的每個像素的顏色。 如上一節所述,當我們在幀緩衝區上繪製時,每個像素位於x和y軸上的 -1 和 1 之間。 我們還提到的事實是,默認情況下,WebGL 不使用z軸。 該功能可以通過運行gl.enable(gl.DEPTH_TEST)
來啟用。 很好,但什麼是深度測試?
啟用深度測試允許像素存儲顏色和深度。 深度是該像素的z坐標。 繪製到某個深度z的像素後,要更新該像素的顏色,您需要在更靠近相機的z位置進行繪製。 否則,抽籤嘗試將被忽略。 這允許 3D 的錯覺,因為在其他對像後面繪製對象將導致這些對像被它們前面的對象遮擋。
您執行的任何抽獎都會保留在屏幕上,直到您告訴他們清除為止。 為此,您必須調用gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
。 這將清除顏色和深度緩衝區。 要選擇清除像素設置的顏色,請使用gl.clearColor(red, green, blue, alpha)
。
讓我們創建一個使用畫布並根據請求清除它的渲染器:
function Renderer (canvas) { var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') gl.enable(gl.DEPTH_TEST) this.gl = gl } Renderer.prototype.setClearColor = function (red, green, blue) { gl.clearColor(red / 255, green / 255, blue / 255, 1) } Renderer.prototype.getContext = function () { return this.gl } Renderer.prototype.render = function () { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) loop() function loop () { renderer.render() requestAnimationFrame(loop) }
將此腳本附加到以下 HTML 將在屏幕上顯示一個亮藍色矩形
<!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>
requestAnimationFrame
調用會導致在前一幀完成渲染並且所有事件處理完成後再次調用循環。
頂點緩衝區對象
您需要做的第一件事是定義要繪製的頂點。 您可以通過 3D 空間中的向量來描述它們來做到這一點。 之後,您希望通過創建一個新的頂點緩衝區對象(VBO) 將該數據移動到 GPU RAM。
緩衝區對象通常是在 GPU 上存儲內存塊數組的對象。 它是一個 VBO 只是表示 GPU 可以將內存用於什麼。 大多數時候,您創建的緩衝區對象將是 VBO。
您可以通過獲取我們擁有的所有N
個頂點並創建一個浮點數組來填充 VBO,其中3N
個元素用於頂點位置和頂點法線 VBO, 2N
個元素用於紋理坐標 VBO。 每組三個浮點數或兩個用於 UV 坐標的浮點數表示頂點的各個坐標。 然後我們將這些數組傳遞給 GPU,我們的頂點已準備好用於管道的其餘部分。
由於數據現在位於 GPU RAM 上,因此您可以將其從通用 RAM 中刪除。 也就是說,除非您想稍後修改它,然後再次上傳。 每次修改後都需要上傳,因為我們的 JS 數組中的修改不適用於實際 GPU RAM 中的 VBO。
下面是一個代碼示例,它提供了所有描述的功能。 需要注意的重要一點是,存儲在 GPU 上的變量不會被垃圾收集。 這意味著一旦我們不想再使用它們,就必須手動刪除它們。 我們將在這里為您提供一個示例,說明如何做到這一點,並且不會進一步關注該概念。 僅當您計劃在整個程序中停止使用某些幾何圖形時,才需要從 GPU 中刪除變量。
我們還向Geometry
類和其中的元素添加了序列化。
Geometry.prototype.vertexCount = function () { return this.faces.length * 3 } Geometry.prototype.positions = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.position answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.normals = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.normal answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.uvs = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.uv answer.push(vx, vy) }) }) return answer } //////////////////////////////// function VBO (gl, data, count) { // Creates buffer object in GPU RAM where we can store anything var bufferObject = gl.createBuffer() // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject) // Write the data, and set the flag to optimize // for rare changes to the data we're writing gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW) this.gl = gl this.size = data.length / count this.count = count this.data = bufferObject } VBO.prototype.destroy = function () { // Free memory that is occupied by our buffer object this.gl.deleteBuffer(this.data) }
VBO
數據類型基於作為第二個參數傳遞的數組在傳遞的 WebGL 上下文中生成 VBO。
您可以看到對gl
上下文的三個調用。 createBuffer()
調用創建緩衝區。 bindBuffer()
調用告訴 WebGL 狀態機將此特定內存用作所有未來操作的當前 VBO ( ARRAY_BUFFER
),除非另有說明。 之後,我們使用bufferData()
將當前 VBO 的值設置為提供的數據。
我們還提供了一個 destroy 方法,通過使用deleteBuffer()
從 GPU RAM 中刪除我們的緩衝區對象。
您可以使用三個 VBO 和一個轉換來描述網格的所有屬性及其位置。
function Mesh (gl, geometry) { var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() }
例如,下面是我們如何加載模型,將其屬性存儲在網格中,然後銷毀它:
Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })
著色器
接下來是前面描述的將點移動到所需位置並繪製所有單個像素的兩步過程。 為此,我們編寫了一個在顯卡上多次運行的程序。 該程序通常由至少兩個部分組成。 第一部分是Vertex Shader ,它為每個頂點運行,並輸出我們應該在屏幕上放置頂點的位置,等等。 第二部分是Fragment Shader ,它針對三角形在屏幕上覆蓋的每個像素運行,並輸出該像素應繪製的顏色。
頂點著色器
假設您想要一個在屏幕上左右移動的模型。 在一種簡單的方法中,您可以更新每個頂點的位置並將其重新發送到 GPU。 這個過程既昂貴又緩慢。 或者,您可以為 GPU 提供一個針對每個頂點運行的程序,並與專為完成該工作而構建的處理器並行執行所有這些操作。 這就是頂點著色器的作用。
頂點著色器是處理單個頂點的渲染管道的一部分。 對頂點著色器的調用接收單個頂點並在應用對頂點的所有可能變換後輸出單個頂點。
著色器是用 GLSL 編寫的。 這種語言有很多獨特的元素,但大部分語法都非常類似於 C,因此大多數人應該可以理解。
有三種類型的變量進出頂點著色器,它們都有特定的用途:
-
attribute
——這些是保存頂點特定屬性的輸入。 之前,我們以三元素向量的形式將頂點的位置描述為一個屬性。 您可以將屬性視為描述一個頂點的值。 -
uniform
— 這些輸入對於同一渲染調用中的每個頂點都是相同的。 假設我們希望能夠通過定義一個變換矩陣來移動我們的模型。 您可以使用uniform
變量來描述它。 您也可以指向 GPU 上的資源,例如紋理。 您可以將製服視為描述模型或模型一部分的值。 -
varying
——這些是我們傳遞給片段著色器的輸出。 由於頂點三角形可能有數千個像素,因此每個像素將根據位置接收此變量的插值。 因此,如果一個頂點發送 500 作為輸出,另一個發送 100,則位於它們中間的像素將接收 300 作為該變量的輸入。 您可以將變量視為描述頂點之間的曲面的值。
So, let's say you want to create a vertex shader that receives a position, normal, and uv coordinates for each vertex, and a position, view (inverse camera position), and projection matrix for each rendered object. Let's say you also want to paint individual pixels based on their uv coordinates and their normals. “How would that code look?” you might ask.
attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 model; uniform mat4 view; uniform mat4 projection; varying vec3 vNormal; varying vec2 vUv; void main() { vUv = uv; vNormal = (model * vec4(normal, 0.)).xyz; gl_Position = projection * view * model * vec4(position, 1.); }
Most of the elements here should be self-explanatory. The key thing to notice is the fact that there are no return values in the main
function. All values that we would want to return are assigned, either to varying
variables, or to special variables. Here we assign to gl_Position
, which is a four-dimensional vector, whereby the last dimension should always be set to one. Another strange thing you might notice is the way we construct a vec4
out of the position vector. You can construct a vec4
by using four float
s, two vec2
s, or any other combination that results in four elements. There are a lot of seemingly strange type castings which make perfect sense once you're familiar with transformation matrices.

You can also see that here we can perform matrix transformations extremely easily. GLSL is specifically made for this kind of work. The output position is calculated by multiplying the projection, view, and model matrix and applying it onto the position. The output normal is just transformed to the world space. We'll explain later why we've stopped there with the normal transformations.
For now, we will keep it simple, and move on to painting individual pixels.
Fragment Shaders
A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.
The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:
- There are no more
varying
outputs, andattribute
inputs have been replaced withvarying
inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader. - Our only output now is
gl_FragColor
, which is avec4
. The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you're doing transparency. Transparency is a fairly advanced concept though, so we'll stick to opaque objects. - At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.
With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec2 clampedUv = clamp(vUv, 0., 1.); gl_FragColor = vec4(clampedUv, 1., 1.); }
The function clamp
just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.
With all of this in mind, all that is left is to implement this in WebGL.
Combining Shaders into a Program
The next step is to combine the shaders into a program:
function ShaderProgram (gl, vertSrc, fragSrc) { var vert = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vert, vertSrc) gl.compileShader(vert) if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(vert)) throw new Error('Failed to compile shader') } var frag = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(frag, fragSrc) gl.compileShader(frag) if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(frag)) throw new Error('Failed to compile shader') } var program = gl.createProgram() gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) throw new Error('Failed to link program') } this.gl = gl this.position = gl.getAttribLocation(program, 'position') this.normal = gl.getAttribLocation(program, 'normal') this.uv = gl.getAttribLocation(program, 'uv') this.model = gl.getUniformLocation(program, 'model') this.view = gl.getUniformLocation(program, 'view') this.projection = gl.getUniformLocation(program, 'projection') this.vert = vert this.frag = frag this.program = program } // Loads shader files from the given URLs, and returns a program as a promise ShaderProgram.load = function (gl, vertUrl, fragUrl) { return Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(function (files) { return new ShaderProgram(gl, files[0], files[1]) }) function loadFile (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(xhr.responseText) } } xhr.open('GET', url, true) xhr.send(null) }) } }
There isn't much to say about what's happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.
Actually Drawing the Model
Last, but not least, you draw the model.
First you pick the shader program you want to use.
ShaderProgram.prototype.use = function () { this.gl.useProgram(this.program) }
Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.
Transformation.prototype.sendToGpu = function (gl, uniform, transpose) { gl.uniformMatrix4fv(uniform, transpose || false, new Float32Array(this.fields)) } Camera.prototype.use = function (shaderProgram) { this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection) this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view) }
Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.
VBO.prototype.bindToAttribute = function (attribute) { var gl = this.gl // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, this.data) // Enable this attribute in the shader gl.enableVertexAttribArray(attribute) // Define format of the attribute array. Must match parameters in shader gl.vertexAttribPointer(attribute, this.size, gl.FLOAT, false, 0, 0) }
Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call drawArrays()
from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using TRIANGLES
takes three by three vertices and draws a triangle for each triplet. Using POINTS
would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:
Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) }
The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.
Renderer.prototype.setShader = function (shader) { this.shader = shader } Renderer.prototype.render = function (camera, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }
We can combine all the elements that we have to finally draw something on the screen:
var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Geometry.loadOBJ('/assets/sphere.obj').then(function (data) { objects.push(new Mesh(gl, data)) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) loop() function loop () { renderer.render(camera, objects) requestAnimationFrame(loop) }
This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); gl_FragColor = vec4(brown, 1.); }
It doesn't look very convincing. It looks like the scene needs some shading effects.
Adding Light
Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.
Sunlight sounds like it's the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.
You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.
If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); gl_FragColor = vec4(brown * lightness, 1.); }
我們設置了太陽在前左下方向照耀。 您可以看到陰影的平滑程度,即使模型非常參差不齊。 您還可以注意到左下角有多暗。 我們可以添加一個級別的環境光,這將使陰影中的區域更亮。
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); float ambientLight = 0.3; lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }
您可以通過引入一個光照類來實現同樣的效果,該類存儲光照方向和環境光強度。 然後您可以更改片段著色器以適應該添加。
現在著色器變為:
#ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }
然後你可以定義光:
function Light () { this.lightDirection = new Vector3(-1, -1, -1) this.ambientLight = 0.3 } Light.prototype.use = function (shaderProgram) { var dir = this.lightDirection var gl = shaderProgram.gl gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z) gl.uniform1f(shaderProgram.ambientLight, this.ambientLight) }
在著色器程序類中,添加所需的製服:
this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')
在程序中,在渲染器中添加對新燈光的調用:
Renderer.prototype.render = function (camera, light, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() light.use(shader) camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }
然後循環將略有變化:
var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
如果你做的一切都正確,那麼渲染的圖像應該與上一張圖像中的相同。
最後一個要考慮的步驟是為我們的模型添加一個實際的紋理。 現在讓我們這樣做。
添加紋理
HTML5 對加載圖片有很好的支持,所以不需要做瘋狂的圖片解析。 通過告訴著色器要對哪些綁定紋理進行採樣,圖像作為sampler2D
傳遞給 GLSL。 可以綁定的紋理數量有限,並且該限制取決於所使用的硬件。 可以在特定位置查詢sampler2D
的顏色。 這就是 UV 坐標的用武之地。這是一個示例,我們用採樣顏色替換了棕色。
#ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; uniform sampler2D diffuse; varying vec3 vNormal; varying vec2 vUv; void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }
必須將新制服添加到著色器程序的列表中:
this.diffuse = gl.getUniformLocation(program, 'diffuse')
最後,我們將實現紋理加載。 如前所述,HTML5 提供了加載圖像的功能。 我們需要做的就是將圖像發送到 GPU:
function Texture (gl, image) { var texture = gl.createTexture() // Set the newly created texture context as active texture gl.bindTexture(gl.TEXTURE_2D, texture) // Set texture parameters, and pass the image that the texture is based on gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) // Set filtering methods // Very often shaders will query the texture value between pixels, // and this is instructing how that value shall be calculated gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) this.data = texture this.gl = gl } Texture.prototype.use = function (uniform, binding) { binding = Number(binding) || 0 var gl = this.gl // We can bind multiple textures, and here we pick which of the bindings // we're setting right now gl.activeTexture(gl['TEXTURE' + binding]) // After picking the binding, we set the texture gl.bindTexture(gl.TEXTURE_2D, this.data) // Finally, we pass to the uniform the binding ID we've used gl.uniform1i(uniform, binding) // The previous 3 lines are equivalent to: // texture[i] = this.data // uniform = i } Texture.load = function (gl, url) { return new Promise(function (resolve) { var image = new Image() image.onload = function () { resolve(new Texture(gl, image)) } image.src = url }) }
該過程與用於加載和綁定 VBO 的過程沒有太大區別。 主要區別在於我們不再綁定到屬性,而是將紋理的索引綁定到整數統一。 sampler2D
類型只不過是指向紋理的指針偏移量。
現在需要做的就是擴展Mesh
類來處理紋理:
function Mesh (gl, geometry, texture) { // added texture var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.texture = texture // new this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() } Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.texture.use(shaderProgram.diffuse, 0) // new this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) } Mesh.load = function (gl, modelUrl, textureUrl) { // new var geometry = Geometry.loadOBJ(modelUrl) var texture = Texture.load(gl, textureUrl) return Promise.all([geometry, texture]).then(function (params) { return new Mesh(gl, params[0], params[1]) }) }
最終的主腳本如下所示:
var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Mesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png') .then(function (mesh) { objects.push(mesh) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
在這一點上,即使是動畫也很容易。 如果您希望相機圍繞我們的對象旋轉,您只需添加一行代碼即可:
function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) }
隨意使用著色器。 添加一行代碼會將這種逼真的照明變成卡通化的東西。
void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = lightness > 0.1 ? 1. : 0.; // new lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }
這就像告訴照明是否超過設定的閾值一樣簡單。
下一步去哪裡
有許多信息來源可用於學習 WebGL 的所有技巧和復雜性。 最好的部分是,如果您找不到與 WebGL 相關的答案,您可以在 OpenGL 中查找它,因為 WebGL 幾乎基於 OpenGL 的一個子集,其中一些名稱已更改。
沒有特別的順序,這裡有一些關於 WebGL 和 OpenGL 的詳細信息的重要來源。
- WebGL 基礎
- 學習 WebGL
- 一個非常詳細的 OpenGL 教程,以非常緩慢和詳細的方式指導您了解此處描述的所有基本原理。
- 還有許多其他網站專門教您計算機圖形學的原理。
- WebGL 的 MDN 文檔
- Khronos WebGL 1.0 規範,如果您有興趣了解 WebGL API 如何在所有邊緣情況下工作的更多技術細節。