使用 Babylon.js 構建著色器

已發表: 2022-03-10
快速總結 ↬如果您想釋放 GPU 的原始功能,著色器是一個關鍵概念。 感謝Babylon.js ,我將幫助您了解它們的工作原理,甚至可以輕鬆地試驗它們的內在力量。 在進行實驗之前,我們必須了解事物內部是如何工作的。 在處理硬件加速 3D 時,您將不得不處理兩個 CPU:主 CPU 和 GPU。 GPU是一種極其專業的CPU。

如果您想釋放 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 )。 但是,作為開發人員,您可以決定添加更多信息。 例如,在之前的著色器中,有一個名為uvvector2 (即紋理坐標,允許您將 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_positionvUV值的三個像素。 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.fxbasename.fragment.fx 。 然後,您必須像這樣創建材料:

 var cloudMaterial = new BABYLON.ShaderMaterial("cloud", scene, "./myShader", { attributes: ["position", "uv"], uniforms: ["worldViewProjection"] });

您還必須指定您使用的屬性和製服的名稱。

然後,您可以使用setTexturesetFloatsetFloatssetColor3setColor4setVector2setVector3setVector4setMatrix函數直接設置制服和採樣器的值。

很簡單,對吧?

你還記得之前使用 Babylon.js 和BABYLON.ShaderMaterialworldViewProjection矩陣嗎? 你只是不必擔心它! 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;

非常簡單,對吧? 該材料已準備好向您發送三個預先計算的矩陣( worldworldViewworldViewProjection )。 頂點將帶有位置、法線和紋理坐標。 還為您加載了兩個紋理:

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 著色語言,文檔