使用 Babylon.js 構建著色器
已發表: 2022-03-10如果您想釋放 GPU 的原始功能,著色器是一個關鍵概念。 感謝 Babylon.js,我將幫助您了解它們的工作原理,甚至可以輕鬆地試驗它們的內在力量。
它是如何工作的?
在進行實驗之前,我們必須了解事物內部是如何工作的。
在處理硬件加速 3D 時,您將不得不處理兩個 CPU:主 CPU 和 GPU。 GPU是一種極其專業的CPU。
關於 SmashingMag 的進一步閱讀:
- 使用 Babylon.js 構建跨平台 WebGL 遊戲
- 在 Web 遊戲中使用 Gamepad API
- 多邊形建模和 Three.js 簡介
- 如何創建響應式 8 位鼓機
GPU 是您使用 CPU 設置的狀態機。 例如,CPU 會將 GPU 配置為渲染線條而不是三角形; 它將定義是否開啟透明度; 等等。
一旦設置了所有狀態,CPU 就可以定義要渲染的內容:幾何。
幾何由以下部分組成:
- 稱為頂點並存儲在稱為頂點緩衝區的數組中的點列表,
- 定義存儲在名為索引緩衝區的數組中的面(或三角形)的索引列表。
CPU 的最後一步是定義如何渲染幾何圖形; 對於這個任務,CPU 將在 GPU 中定義著色器。 著色器是 GPU 將為它必須渲染的每個頂點和像素執行的代碼片段。 (一個頂點——或多個頂點——在 3D 中是一個“點”)。
有兩種著色器:頂點著色器和像素(或片段)著色器。
圖形管道
在深入研究著色器之前,讓我們退後一步。 為了渲染像素,GPU 將採用 CPU 定義的幾何圖形並執行以下操作:
- 使用索引緩衝區,收集三個頂點來定義一個三角形。
- 索引緩衝區包含一個頂點索引列表。 這意味著索引緩衝區中的每個條目都是頂點緩衝區中頂點的編號。
- 這對於避免重複頂點非常有用。
例如,以下索引緩衝區是兩個面的列表:[1 2 3 1 3 4]。 第一個麵包含頂點 1、頂點 2 和頂點 3。第二個麵包含頂點 1、頂點 3 和頂點 4。因此,此幾何中有四個頂點:

頂點著色器應用於三角形的每個頂點。 頂點著色器的主要目標是為每個頂點生成一個像素(3D 頂點在 2D 屏幕上的投影):

使用這三個像素(在屏幕上定義一個 2D 三角形),GPU 將插入所有附加到像素的值(至少是它們的位置),並且像素著色器將應用於 2D 三角形中包含的每個像素,以便為每個像素生成一種顏色:

這個過程是針對索引緩衝區定義的每個面完成的。
顯然,由於它的並行特性,GPU 能夠同時處理很多人臉的這一步驟,並獲得非常好的性能。
GLSL
我們剛剛看到,要渲染三角形,GPU 需要兩個著色器:頂點著色器和像素著色器。 這些著色器是用一種名為圖形庫著色器語言 (GLSL) 的語言編寫的。 看起來像C。
下面是一個常見的頂點著色器示例:
precision highp float; // Attributes attribute vec3 position; attribute vec2 uv; // Uniforms uniform mat4 worldViewProjection; // Varying varying vec2 vUV; void main(void) { gl_Position = worldViewProjection * vec4(position, 1.0); vUV = uv; }
頂點著色器結構
頂點著色器包含以下內容:
- 屬性。 屬性定義了頂點的一部分。 默認情況下,一個頂點至少應該包含一個位置(一個
vector3:x, y, z
)。 但是,作為開發人員,您可以決定添加更多信息。 例如,在之前的著色器中,有一個名為uv
的vector2
(即紋理坐標,允許您將 2D 紋理應用到 3D 對象)。 - 制服。 統一是著色器使用並由 CPU 定義的變量。 我們這裡唯一的統一是一個矩陣,用於將頂點 (x, y, z) 的位置投影到屏幕 (x, y) 上。
- 變化。 可變變量是由頂點著色器創建並傳輸到像素著色器的值。 在這裡,頂點著色器會將一個
vUV
(一個簡單的uv
副本)值傳輸到像素著色器。 這意味著一個像素在這裡定義了一個位置和紋理坐標。 這些值將由 GPU 插值並由像素著色器使用。 - 主要。 名為
main
的函數是 GPU 為每個頂點執行的代碼,並且必須至少為gl_position
(屏幕上當前頂點的位置)生成一個值。
我們可以在示例中看到頂點著色器非常簡單。 它生成一個名為gl_position
的系統變量(以gl_
開頭)來定義相關像素的位置,並設置一個名為vUV
的變量。
矩陣背後的巫術
關於我們的著色器,我們有一個名為worldViewProjection
的矩陣,我們使用這個矩陣將頂點位置投影到gl_position
變量。 這很酷,但是我們如何獲得這個矩陣的值呢? 它是統一的,所以我們必須在 CPU 端定義它(使用 JavaScript)。
這是做 3D 的複雜部分之一。 您必須了解複雜的數學(否則您將不得不使用像 Babylon.js 這樣的 3D 引擎,我們稍後會看到)。
worldViewProjection
矩陣是三個不同矩陣的組合:

使用生成的矩陣使我們能夠將 3D 頂點轉換為 2D 像素,同時考慮視點以及與當前對象的位置、縮放和旋轉相關的所有內容。
這是您作為 3D 開發人員的責任:創建並保持此矩陣是最新的。
回到著色器
一旦在每個頂點上執行了頂點著色器(然後是 3 次),我們將擁有具有正確gl_position
和vUV
值的三個像素。 GPU 將在由這些像素生成的三角形中包含的每個像素上插入這些值。
然後,對於每個像素,它將執行像素著色器:
precision highp float; varying vec2 vUV; uniform sampler2D textureSampler; void main(void) { gl_FragColor = texture2D(textureSampler, vUV); }
像素(或片段)著色器結構
像素著色器的結構類似於頂點著色器:
- 變化。 可變變量是由頂點著色器創建並傳輸到像素著色器的值。 在這裡,像素著色器將從頂點著色器接收一個
vUV
值。 - 制服。 統一是著色器使用並由 CPU 定義的變量。 我們這裡唯一的製服是一個採樣器,它是一個用於讀取紋理顏色的工具。
- 主要。 名為
main
的函數是 GPU 為每個像素執行的代碼,它必須至少為gl_FragColor
生成一個值(即當前像素的顏色)。
這個像素著色器相當簡單:它使用來自頂點著色器的紋理坐標(反過來,從頂點獲取它)從紋理中讀取顏色。
問題是在開發著色器時,您只完成了一半,因為您必須處理大量的 WebGL 代碼。 確實,WebGL 非常強大,但也非常低級,您必須自己做所有事情,從創建緩衝區到定義頂點結構。 您還必須完成所有數學運算、設置所有狀態、處理紋理加載等。
太難? BABYLON.ShaderMaterial 救援
我知道你在想什麼:“著色器真的很酷,但我不想打擾 WebGL 的內部管道甚至數學。”
你是對的! 這是一個完全合理的問題,這正是我創建 Babylon.js 的原因!
要使用 Babylon.js,首先需要一個簡單的網頁:
<!DOCTYPE html> <html> <head> <title>Babylon.js</title> <script src="Babylon.js"></script> <script type="application/vertexShader"> precision highp float; // Attributes attribute vec3 position; attribute vec2 uv; // Uniforms uniform mat4 worldViewProjection; // Normal varying vec2 vUV; void main(void) { gl_Position = worldViewProjection * vec4(position, 1.0); vUV = uv; } </script> <script type="application/fragmentShader"> precision highp float; varying vec2 vUV; uniform sampler2D textureSampler; void main(void) { gl_FragColor = texture2D(textureSampler, vUV); } </script> <script src="index.js"></script> <style> html, body { width: 100%; height: 100%; padding: 0; margin: 0; overflow: hidden; margin: 0px; overflow: hidden; } #renderCanvas { width: 100%; height: 100%; touch-action: none; -ms-touch-action: none; } </style> </head> <body> <canvas></canvas> </body> </html>
您會注意到著色器是由<script>
標籤定義的。 使用 Babylon.js,您還可以在單獨的文件( .fx
文件)中定義它們。
- Babylon.js 源碼
- GitHub存儲庫
最後,主要的 JavaScript 代碼是這樣的:
"use strict"; document.addEventListener("DOMContentLoaded", startGame, false); function startGame() { if (BABYLON.Engine.isSupported()) { var canvas = document.getElementById("renderCanvas"); var engine = new BABYLON.Engine(canvas, false); var scene = new BABYLON.Scene(engine); var camera = new BABYLON.ArcRotateCamera("Camera", 0, Math.PI / 2, 10, BABYLON.Vector3.Zero(), scene); camera.attachControl(canvas); // Creating sphere var sphere = BABYLON.Mesh.CreateSphere("Sphere", 16, 5, scene); var amigaMaterial = new BABYLON.ShaderMaterial("amiga", scene, { vertexElement: "vertexShaderCode", fragmentElement: "fragmentShaderCode", }, { attributes: ["position", "uv"], uniforms: ["worldViewProjection"] }); amigaMaterial.setTexture("textureSampler", new BABYLON.Texture("amiga.jpg", scene)); sphere.material = amigaMaterial; engine.runRenderLoop(function () { sphere.rotation.y += 0.05; scene.render(); }); } };
您可以看到我使用BABYLON.ShaderMaterial
來擺脫編譯、鏈接和處理著色器的負擔。
創建BABYLON.ShaderMaterial
時,您必須指定用於存儲著色器的 DOM 元素或著色器所在文件的基本名稱。 如果選擇使用文件,則必須為每個著色器創建一個文件並使用以下模式: basename.vertex.fx
和basename.fragment.fx
。 然後,您必須像這樣創建材料:
var cloudMaterial = new BABYLON.ShaderMaterial("cloud", scene, "./myShader", { attributes: ["position", "uv"], uniforms: ["worldViewProjection"] });
您還必須指定您使用的屬性和製服的名稱。
然後,您可以使用setTexture
、 setFloat
、 setFloats
、 setColor3
、 setColor4
、 setVector2
、 setVector3
、 setVector4
、 setMatrix
函數直接設置制服和採樣器的值。
很簡單,對吧?
你還記得之前使用 Babylon.js 和BABYLON.ShaderMaterial
的worldViewProjection
矩陣嗎? 你只是不必擔心它! BABYLON.ShaderMaterial
將自動為您計算它,因為您將在製服列表中聲明它。
BABYLON.ShaderMaterial
還可以為您處理以下矩陣:
-
world
, -
view
, -
projection
, -
worldView
, -
worldViewProjection
。
不再需要數學了。 例如,每次執行sphere.rotation.y += 0.05
時,都會為您生成球體的world
矩陣並傳輸到 GPU。
親自查看實時結果。
創建自己的著色器 (CYOS)
現在,讓我們擴大並創建一個頁面,您可以在其中動態創建自己的著色器並立即查看結果。 此頁面將使用我們之前討論過的相同代碼,並將使用BABYLON.ShaderMaterial
對象來編譯和執行您將創建的著色器。
我使用 ACE 代碼編輯器創建您自己的著色器 (CYOS)。 這是一個令人難以置信的代碼編輯器,帶有語法高亮。 隨意看看它。
使用第一個組合框,您將能夠選擇預定義的著色器。 我們將在之後看到他們中的每一個。
您還可以使用第二個組合框更改用於預覽著色器的網格(即 3D 對象)。
編譯按鈕用於從您的著色器創建一個新的BABYLON.ShaderMaterial
。 該按鈕使用的代碼如下:
// Compile shaderMaterial = new BABYLON.ShaderMaterial("shader", scene, { vertexElement: "vertexShaderCode", fragmentElement: "fragmentShaderCode", }, { attributes: ["position", "normal", "uv"], uniforms: ["world", "worldView", "worldViewProjection"] }); var refTexture = new BABYLON.Texture("ref.jpg", scene); refTexture.wrapU = BABYLON.Texture.CLAMP_ADDRESSMODE; refTexture.wrapV = BABYLON.Texture.CLAMP_ADDRESSMODE; var amigaTexture = new BABYLON.Texture("amiga.jpg", scene); shaderMaterial.setTexture("textureSampler", amigaTexture); shaderMaterial.setTexture("refSampler", refTexture); shaderMaterial.setFloat("time", 0); shaderMaterial.setVector3("cameraPosition", BABYLON.Vector3.Zero()); shaderMaterial.backFaceCulling = false; mesh.material = shaderMaterial;
非常簡單,對吧? 該材料已準備好向您發送三個預先計算的矩陣( world
、 worldView
和worldViewProjection
)。 頂點將帶有位置、法線和紋理坐標。 還為您加載了兩個紋理:


amiga.jpg
(查看大圖) 
ref.jpg
(查看大圖) 最後, renderLoop
是我更新兩個方便的製服的地方:
- 一個叫做
time
並得到一些有趣的動畫。 - 另一個稱為
cameraPosition
,它將相機的位置獲取到您的著色器中(對於照明方程很有用)。
engine.runRenderLoop(function () { mesh.rotation.y += 0.001; if (shaderMaterial) { shaderMaterial.setFloat("time", time); time += 0.02; shaderMaterial.setVector3("cameraPosition", camera.position); } scene.render(); });
基本著色器
讓我們從 CYOS 中定義的第一個著色器開始:基本著色器。
我們已經知道這個著色器。 它計算gl_position
並使用紋理坐標來獲取每個像素的顏色。
要計算像素位置,我們只需要worldViewProjection
矩陣和頂點的位置:
precision highp float; // Attributes attribute vec3 position; attribute vec2 uv; // Uniforms uniform mat4 worldViewProjection; // Varying varying vec2 vUV; void main(void) { gl_Position = worldViewProjection * vec4(position, 1.0); vUV = uv; }
紋理坐標 ( uv
) 未經修改地傳輸到像素著色器。
請注意,我們需要在第一行為頂點和像素著色器添加precision mediump float
,因為 Chrome 需要它。 它指定,為了獲得更好的性能,我們不使用全精度浮點值。
像素著色器更簡單,因為我們只需要使用紋理坐標並獲取紋理顏色:
precision highp float; varying vec2 vUV; uniform sampler2D textureSampler; void main(void) { gl_FragColor = texture2D(textureSampler, vUV); }
我們之前看到, textureSampler
制服是用amiga
紋理填充的。 所以,結果如下:

黑白著色器
讓我們繼續使用新的著色器:黑白著色器。 這個著色器的目標是使用前一個著色器,但只有黑白渲染模式。
為此,我們可以保持相同的頂點著色器。 像素著色器將稍作修改。
我們的第一個選擇是只取一個組件,例如綠色的:
precision highp float; varying vec2 vUV; uniform sampler2D textureSampler; void main(void) { gl_FragColor = vec4(texture2D(textureSampler, vUV).ggg, 1.0); }
如您所見,我們沒有使用.rgb
(此操作稱為 swizzle),而是使用.ggg
。
但是如果我們想要一個真正準確的黑白效果,那麼計算亮度(考慮到所有分量)會更好:
precision highp float; varying vec2 vUV; uniform sampler2D textureSampler; void main(void) { float luminance = dot(texture2D(textureSampler, vUV).rgb, vec3(0.3, 0.59, 0.11)); gl_FragColor = vec4(luminance, luminance, luminance, 1.0); }
dot
運算(或dot
積)計算如下: result = v0.x * v1.x + v0.y * v1.y + v0.z * v1.z
。
所以,在我們的例子中, luminance = r * 0.3 + g * 0.59 + b * 0.11
。 (這些值是基於人眼對綠色更敏感的事實。)
聽起來很酷,不是嗎?

單元格著色器
讓我們轉向更複雜的著色器:單元著色器。
這需要我們將頂點的法線和頂點的位置放入像素著色器中。 因此,頂點著色器將如下所示:
precision highp float; // Attributes attribute vec3 position; attribute vec3 normal; attribute vec2 uv; // Uniforms uniform mat4 world; uniform mat4 worldViewProjection; // Varying varying vec3 vPositionW; varying vec3 vNormalW; varying vec2 vUV; void main(void) { vec4 outPosition = worldViewProjection * vec4(position, 1.0); gl_Position = outPosition; vPositionW = vec3(world * vec4(position, 1.0)); vNormalW = normalize(vec3(world * vec4(normal, 0.0))); vUV = uv; }
請注意,我們也使用了世界矩陣,因為位置和法線是在沒有任何變換的情況下存儲的,我們必須應用世界矩陣來考慮對象的旋轉。
像素著色器如下:
precision highp float; // Lights varying vec3 vPositionW; varying vec3 vNormalW; varying vec2 vUV; // Refs uniform sampler2D textureSampler; void main(void) { float ToonThresholds[4]; ToonThresholds[0] = 0.95; ToonThresholds[1] = 0.5; ToonThresholds[2] = 0.2; ToonThresholds[3] = 0.03; float ToonBrightnessLevels[5]; ToonBrightnessLevels[0] = 1.0; ToonBrightnessLevels[1] = 0.8; ToonBrightnessLevels[2] = 0.6; ToonBrightnessLevels[3] = 0.35; ToonBrightnessLevels[4] = 0.2; vec3 vLightPosition = vec3(0, 20, 10); // Light vec3 lightVectorW = normalize(vLightPosition - vPositionW); // diffuse float ndl = max(0., dot(vNormalW, lightVectorW)); vec3 color = texture2D(textureSampler, vUV).rgb; if (ndl > ToonThresholds[0]) { color *= ToonBrightnessLevels[0]; } else if (ndl > ToonThresholds[1]) { color *= ToonBrightnessLevels[1]; } else if (ndl > ToonThresholds[2]) { color *= ToonBrightnessLevels[2]; } else if (ndl > ToonThresholds[3]) { color *= ToonBrightnessLevels[3]; } else { color *= ToonBrightnessLevels[4]; } gl_FragColor = vec4(color, 1.); }
這個著色器的目標是模擬光,而不是計算平滑著色,我們將根據特定的亮度閾值應用光。 例如,如果光強度介於 1(最大值)和 0.95 之間,則將直接應用對象的顏色(從紋理中獲取)。 如果強度在 0.95 和 0.5 之間,顏色將衰減 0.8 倍。 等等。
這個著色器主要有四個步驟。
首先,我們聲明閾值和水平常數。
然後,我們使用 Phong 方程計算光照(我們將認為光沒有移動):
vec3 vLightPosition = vec3(0, 20, 10); // Light vec3 lightVectorW = normalize(vLightPosition - vPositionW); // diffuse float ndl = max(0., dot(vNormalW, lightVectorW));
每個像素的光強度取決於法線和光方向之間的角度。
然後,我們得到像素的紋理顏色。
最後,我們檢查閾值並將級別應用於顏色。
結果看起來像一個卡通對象:

Phong 著色器
我們在之前的著色器中使用了 Phong 方程的一部分。 現在讓我們完全使用它。
頂點著色器在這裡顯然很簡單,因為一切都將在像素著色器中完成:
precision highp float; // Attributes attribute vec3 position; attribute vec3 normal; attribute vec2 uv; // Uniforms uniform mat4 worldViewProjection; // Varying varying vec3 vPosition; varying vec3 vNormal; varying vec2 vUV; void main(void) { vec4 outPosition = worldViewProjection * vec4(position, 1.0); gl_Position = outPosition; vUV = uv; vPosition = position; vNormal = normal; }
根據等式,我們必須使用光的方向和頂點的法線計算“漫反射”和“鏡面反射”部分:
precision highp float; // Varying varying vec3 vPosition; varying vec3 vNormal; varying vec2 vUV; // Uniforms uniform mat4 world; // Refs uniform vec3 cameraPosition; uniform sampler2D textureSampler; void main(void) { vec3 vLightPosition = vec3(0, 20, 10); // World values vec3 vPositionW = vec3(world * vec4(vPosition, 1.0)); vec3 vNormalW = normalize(vec3(world * vec4(vNormal, 0.0))); vec3 viewDirectionW = normalize(cameraPosition - vPositionW); // Light vec3 lightVectorW = normalize(vLightPosition - vPositionW); vec3 color = texture2D(textureSampler, vUV).rgb; // diffuse float ndl = max(0., dot(vNormalW, lightVectorW)); // Specular vec3 angleW = normalize(viewDirectionW + lightVectorW); float specComp = max(0., dot(vNormalW, angleW)); specComp = pow(specComp, max(1., 64.)) * 2.; gl_FragColor = vec4(color * ndl + vec3(specComp), 1.); }
我們已經在之前的著色器中使用了漫反射部分,所以這裡我們只需要添加高光部分。 您可以在 Wikipedia 上找到有關 Phong 著色的更多信息。
我們的球體的結果:

丟棄著色器
對於丟棄著色器,我想介紹一個新概念: discard
關鍵字。
此著色器丟棄每個非紅色像素並創建挖掘對象的錯覺。
頂點著色器與基本著色器使用的相同:
precision highp float; // Attributes attribute vec3 position; attribute vec3 normal; attribute vec2 uv; // Uniforms uniform mat4 worldViewProjection; // Varying varying vec2 vUV; void main(void) { gl_Position = worldViewProjection * vec4(position, 1.0); vUV = uv; }
例如,當綠色分量太高時,其一側的像素著色器將不得不測試顏色並使用丟棄:
precision highp float; varying vec2 vUV; // Refs uniform sampler2D textureSampler; void main(void) { vec3 color = texture2D(textureSampler, vUV).rgb; if (color.g > 0.5) { discard; } gl_FragColor = vec4(color, 1.); }
結果有點搞笑:

波浪著色器
我們用像素著色器玩了很多,但我也想讓你知道,我們可以用頂點著色器做很多事情。
對於波形著色器,我們將重用 Phong 像素著色器。
頂點著色器將使用統一命名time
來獲取一些動畫值。 使用這個統一,著色器將生成一個帶有頂點位置的波:
precision highp float; // Attributes attribute vec3 position; attribute vec3 normal; attribute vec2 uv; // Uniforms uniform mat4 worldViewProjection; uniform float time; // Varying varying vec3 vPosition; varying vec3 vNormal; varying vec2 vUV; void main(void) { vec3 v = position; vx += sin(2.0 * position.y + (time)) * 0.5; gl_Position = worldViewProjection * vec4(v, 1.0); vPosition = position; vNormal = normal; vUV = uv; }
對position.y
應用一個 sinus,結果如下:

球形環境映射
這篇文章很大程度上受到了文章“創建球面反射/環境映射著色器”的啟發。 我會讓你閱讀那篇優秀的文章並使用相關的著色器。

菲涅耳著色器
我想用我最喜歡的菲涅耳著色器來結束這篇文章。
此著色器用於根據視圖方向和頂點法線之間的角度應用不同的強度。
頂點著色器與單元著色器使用的相同,我們可以在像素著色器中輕鬆計算菲涅耳項(因為我們有法線和相機位置,可用於評估視圖方向):
precision highp float; // Lights varying vec3 vPositionW; varying vec3 vNormalW; // Refs uniform vec3 cameraPosition; uniform sampler2D textureSampler; void main(void) { vec3 color = vec3(1., 1., 1.); vec3 viewDirectionW = normalize(cameraPosition - vPositionW); // Fresnel float fresnelTerm = dot(viewDirectionW, vNormalW); fresnelTerm = clamp(1.0 - fresnelTerm, 0., 1.); gl_FragColor = vec4(color * fresnelTerm, 1.); }

你的著色器?
您現在更準備創建自己的著色器。 隨意張貼到 Babylon.js 論壇分享您的實驗!
如果您想更進一步,這裡有一些有用的鏈接:
- Babylon.js,官方網站
- Babylon.js,GitHub 存儲庫
- Babylon.js 論壇,HTML5 遊戲開發者
- 創建您自己的著色器 (CYOS),Babylon.js
- OpenGL 著色語言,”維基百科
- OpenGL 著色語言,文檔