使用 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
)。 顶点将带有位置、法线和纹理坐标。 还为您加载了两个纹理:
最后, 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 着色语言,文档