着色器简介

Paul Lewis

简介

我之前向您介绍了 Three.js。如果您尚未阅读该文章,不妨先阅读一下,因为本文将以此为基础进行讲解。

我要讨论的是着色器。WebGL 非常出色,正如我之前所说,Three.js(以及其他库)在为您消除了难题方面表现出色。但有时,您可能希望实现特定效果,或者想要深入了解这些令人惊叹的内容是如何出现在屏幕上的,而着色器几乎肯定会是其中的一部分。此外,如果您和我一样,可能希望从上一教程中的基本内容过渡到更复杂的内容。我将假定您使用的是 Three.js,因为它在启用着色器方面为我们完成了很多繁重工作。我还想先说明一下,在本教程的开头部分,我将介绍着色器的上下文,在后面部分,我们将进入稍微高级一些的内容。之所以这样,是因为着色器乍一看很不寻常,需要一些解释。

1. 我们的两个着色器

WebGL 不提供固定流水线的使用,这是一种简写方式,表示它不提供任何开箱即用的渲染内容的方法。不过,它提供的可编程流水线,该流水线功能更强大,但也更难理解和使用。简而言之,可编程管道意味着,作为程序员,您负责将顶点等渲染到屏幕上。着色器是此流水线的一部分,分为两种类型:

  1. 顶点着色器
  2. fragment 着色器

我相信您一定会同意,这两者本身毫无意义。您需要了解的是,这两种模式完全在显卡的 GPU 上运行。这意味着,我们希望尽可能将所有工作都分流到 GPU,让 CPU 去执行其他工作。现代 GPU 针对着色器所需的功能进行了深度优化,因此能够使用它非常棒。

2. 顶点着色器

取一个标准基元形状,例如球体。它由顶点组成,对吗? 顶点着色器会依次获得这些顶点中的每一个,并可以对它们进行处理。顶点着色器可以对每个顶点执行实际操作,但它有一种责任:必须在某个时间设置一个名为 gl_Position 的 4D 浮点矢量,该矢量是顶点在屏幕上的最终位置。这本身就是一个非常有趣的过程,因为我们实际上是在讨论如何将 3D 位置(具有 x、y、z 的顶点)投影到 2D 屏幕上。幸运的是,如果我们使用的是 Three.js 之类的库,则可以使用简写方式设置 gl_Position,而不会使系统负担过重。

3. fragment 着色器

现在,我们有了带有顶点的对象,并将其投影到 2D 屏幕上,但我们使用的颜色如何?纹理和光照怎么样?fragment 着色器正是为此而存在的。与顶点着色程序非常相似,Fragment 着色程序也只有一项必须执行的工作:它必须设置或舍弃 gl_FragColor 变量(另一个 4D 浮点矢量),该变量是 fragment 的最终颜色。但什么是 fragment?假设有三个顶点构成三角形。需要绘制该三角形中的每个像素。fragment 是这三个顶点提供的数据,用于绘制该三角形中的每个像素。因此,碎片会从其组成顶点接收插值值。如果一个顶点的颜色为红色,而其相邻顶点的颜色为蓝色,我们会看到颜色值从红色经紫色过渡到蓝色。

4. 着色器变量

谈论变量时,您可以进行三种声明:UniformsAttributesVaryings。当我第一次听到这些术语时,感到非常困惑,因为它们与我之前接触过的任何其他术语都不符。不过,您可以这样理解它们:

  1. Uniform 会发送到顶点着色器和片段着色器,并且包含在整个渲染帧中保持不变的值。一个很好的示例是灯的位置。

  2. 属性是应用于单个顶点的值。属性适用于顶点着色器。例如,每个顶点都有不同的颜色。属性与顶点之间存在一对一关系。

  3. “Varying”是在顶点着色器中声明且希望与 fragment 着色器共享的变量。为此,我们确保在顶点着色器和 fragment 着色器中声明同一类型和名称的不同变量。典型的用法是顶点的法线,因为它可用于照明计算。

稍后,我们将使用这三种类型,以便您了解它们的实际应用方式。

我们已经讨论了顶点着色器和 fragment 着色器,以及它们可以处理的变量类型,现在有必要了解一下我们可以创建的最简单的着色器。

5. Bonjourno World

下面是顶点着色器的 Hello World:

/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}   

下面是片段着色器的相同代码:

/**
* Set the colour to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}

不过,这并不复杂,对吧?

在顶点着色器中,Three.js 会向我们发送几个 uniform。这两个 uniform 是 4D 矩阵,分别称为 Model-View 矩阵和投影矩阵。您无需急切地了解这些内容的确切工作原理,但如果可以,最好还是了解一下这些内容的运作方式。简而言之,它们是顶点的 3D 位置实际投影到屏幕上的最终 2D 位置的方式。

我实际上没有在上面的代码段中添加这些代码,因为 Three.js 会将它们添加到着色器代码本身的顶部,因此您无需担心执行此操作。事实上,它实际上还添加了更多内容,例如光照数据、顶点颜色和顶点法线。如果您不使用 Three.js 执行此操作,则必须自行创建并设置所有这些 uniform 和属性。真实故事。

6. 使用 MeshShaderMaterial

好的,我们已经设置了着色器,但如何将其与 Three.js 搭配使用呢?事实证明,这非常简单。大致如下所示:

/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader:   $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});

然后,Three.js 将编译和运行连接到该材料的网格的着色器。其实,没有比这更简单的了。可能需要,但我们讨论的是浏览器中运行的 3D 内容,因此我认为您预计会遇到一定程度的复杂性。

实际上,我们还可以向 MeshShaderMaterial 添加两个属性:uniform 和 attribute。它们都可以接受矢量、整数或浮点数,但正如我之前所提到的,uniform 在整个帧(即所有顶点)中都是相同的,因此它们通常是单个值。不过,属性是每个顶点的变量,因此它们应为数组。属性数组中的值数量与网格中的顶点数量之间应存在一对一的关系。

7. 后续步骤

现在,我们将花些时间添加动画循环、顶点属性和 uniform。我们还将添加一个可变变量,以便顶点着色器可以向片段着色器发送一些数据。最终结果是,粉红色的球体看起来会从上方和侧面发光,并且会闪烁。这有点令人费解,但希望能帮助您深入了解这三种变量类型,以及它们彼此之间以及与底层几何图形的关系。

8. 假灯

我们来更新一下颜色,使其不再是单色对象。我们可以看看 Three.js 如何处理光照,但我相信您一定能理解,这比我们目前需要的要复杂得多,因此我们将对其进行模拟。您应该仔细研究 Three.js 中的出色的着色器,以及 Chris Milk 和 Google 近期推出的出色 WebGL 项目 Rome 中的着色器。我们继续来看看着色器。我们将更新顶点着色器,以便为 fragment 着色器提供每个法线顶点。我们通过以下方式实现这一点:

// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;

void main() {

// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}

在片段着色器中,我们将设置相同的变量名称,然后使用顶点法线与表示从球体上方和右侧照射的光的矢量的点积。这最终产生的效果类似于 3D 文件包中的定向光。

// same name and type as VS
varying vec3 vNormal;

void main() {

// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
    
// ensure it's normalized
light = normalize(light);

// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));

// feed into our frag colour
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

因此,点积之所以有效,是因为给定两个向量后,它会得出一个数字,告诉您这两个向量的“相似度”。对于归一化向量,如果它们指向完全相同的方向,则得到的值为 1。如果它们指向相反方向,则结果为 -1。我们要做的是获取这个数字,并将其应用到光照上。因此,右上角的顶点的值接近或等于 1(即完全点亮),而侧面的顶点的值接近 0,而背面的顶点的值为 -1。我们会将任何负值的值限制为 0,但当您输入数字后,最终会得到我们看到的基本照明效果。

接下来该做什么?最好尝试调整一些顶点位置。

9. 属性

现在,我希望我们通过某个属性向每个顶点附加一个随机数。我们将使用此数值沿着法向量将顶点推向外部。最终结果是某种奇怪的峰值,每次您刷新页面时,它都会发生变化。它暂时不会动画化(下一步会进行动画化),但刷新几次页面后,您会发现它是随机的。

首先,我们将属性添加到顶点着色器中:

attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

它看起来如何?

其实没太大变化!这是因为该属性尚未在 MeshShaderMaterial 中设置,因此着色器实际上会改用零值。目前,它有点像占位符。我们将在下一秒将该属性添加到 JavaScript 中的 MeshShaderMaterial,然后 Three.js 会自动将这两个属性关联起来。

另请注意,我必须将更新后的坐标分配给一个 vec3 变量,因为原始属性(与所有属性一样)是只读的。

10. 更新 MeshShaderMaterial

我们直接更新 MeshShaderMaterial,为其添加实现位移所需的属性。提醒一下:属性是每个顶点的值,因此我们需要在球体中为每个顶点提供一个值。示例如下:

var attributes = {
displacement: {
    type: 'f', // a float
    value: [] // an empty array
}
};

// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}

现在,我们看到了一个经过处理的球体,但有趣的是,所有位移都在 GPU 上进行。

11. 为吸尘器添加动画

我们完全可以为其添加动画效果。我们是如何做到的?我们需要完成以下两项工作:

  1. 用于为每个帧应用的位移量进行动画处理的 uniform。我们可以使用正弦或余弦函数,因为它们的取值范围为 -1 到 1
  2. JS 中的动画循环

我们将向 MeshShaderMaterial 和 Vertex Shader 添加 uniform。首先是顶点着色器:

uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement *
                        amplitude);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

接下来,我们更新 MeshShaderMaterial:

// add a uniform for the amplitude
var uniforms = {
amplitude: {
    type: 'f', // a float
    value: 0
}
};

// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms:       uniforms,
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

我们的着色器到此就完成了。但右侧好像后退了一步。 这主要是因为我们的振幅值为 0,而我们将其与位移值相乘,因此没有看到任何变化。我们还没有设置动画循环,因此永远不会看到 0 更改为任何其他值。

现在,我们需要在 JavaScript 中将渲染调用封装到一个函数中,然后使用 requestAnimationFrame 调用该函数。在其中,我们还需要更新 uniform 的值。

var frame = 0;
function update() {

// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;

renderer.render(scene, camera);

// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);

12. 总结

这样就大功告成了!现在,您可以看到它以奇怪(且略显迷幻)的脉动方式显示动画效果。

我们还可以就着色器这个主题介绍更多内容,但希望本简介对您有所帮助。现在,您应该能够在看到着色器时理解它们,并且有信心自行创建一些出色的着色器!