使用 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 着色语言,文档