着色器
正如在“1.2.你好 三角形”章节中所提到的,着色器是运行在图形处理器(GPU)上的小程序。这些程序会针对图形渲染管线的每个特定环节运行。从基本意义上讲,着色器无非就是将输入转换为输出的程序。着色器也是相互隔离的程序,因为它们不允许相互通信;它们之间唯一的通信方式是通过各自的输入和输出。
在上一章中,我们简要涉及了着色器以及如何正确使用它们的相关内容。现在,我们将以更通用的方式来讲解着色器,特别是OpenGL着色语言。
GLSL
着色器是用类似C语言的GLSL(OpenGL着色语言)编写的。GLSL是为图形处理量身定制的,包含了专门用于向量和矩阵操作的实用特性。
着色器总是以版本声明开头,接着是输入变量、输出变量、统一变量(uniforms)的列表以及其主函数。每个着色器的入口点就在它的主函数中,在那里我们处理任何输入变量,并将结果输出到输出变量中。如果你不知道统一变量是什么,别担心,我们很快就会讲到。
一个着色器通常具有以下结构:
#version version_number
in type in_variable_name;
in type in_variable_name;
out type out_variable_name;
uniform type uniform_name;
void main()
{
// 对输入数据进行处理,并执行特定的图形处理操作以实现特定图形效果。
...
// 将处理后的结果输出至输出变量。
out_variable_name = weird_stuff_we_processed;
}
当我们具体谈及顶点着色器时,每个输入变量也被称为顶点属性。我们能够声明的顶点属性数量有一个最大值,该值受硬件限制。OpenGL保证至少总是有16个4分量的顶点属性可用,但一些硬件可能允许更多,你可以通过查询GL_MAX_VERTEX_ATTRIBS来获取相关信息:
int nrAttributes = 0;
GL.GetInteger(GetPName.MaxVertexAttribs, out nrAttributes);
Console.WriteLine("顶点属性所支持的最大数量: " + nrAttributes);
这通常返回的最小值为16,对于大多数场景而言,这个数值应该是绰绰有余的。
类型
与其他任何编程语言一样,GLSL(OpenGL着色语言)拥有数据类型,用于指定我们想要使用的变量类型。GLSL具备我们在C语言等中熟知的大多数默认基本类型:整型(int)、浮点型(float)、双精度浮点型(double)、无符号整型(uint)和布尔型(bool)。GLSL还具有两种我们将频繁使用的容器类型,即向量和矩阵。我们将在后续章节讨论矩阵。
Vectors(向量)
在GLSL中,向量是一种可容纳上述提及的任意基本类型的、具有1、2、3或4个分量的容器。它们可以呈现以下形式(其中n代表分量的数量):
- vecn:由n个浮点数组成的默认向量类型。
- bvecn:由n个布尔值构成的向量类型。
- ivecn:由n个整数组成的向量类型。
- uvecn:由n个无符号整数组成的向量类型。
- dvecn:由n个双精度浮点数分量构成的向量类型。
大多数时候,我们会使用基本的vecn类型,因为对于我们的大多数需求而言,浮点数就足够了。
可以通过vec.x的方式访问向量的分量,其中x是向量的第一个分量。你可以使用.x、.y、.z和.w分别访问它们的第一、第二、第三和第四个分量。GLSL还允许你使用rgba来表示颜色或使用stpq来表示纹理坐标,以此来访问相同的分量。
向量数据类型允许进行一些有趣且灵活的分量选择操作,称为“混合访问(swizzling)”。混合访问允许我们使用如下这样的语法:
vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;
你可以使用最多4个字母的任意组合来创建一个(相同类型的)新向量,前提是原始向量具有这些分量;例如,不允许访问一个vec2向量的.z分量。我们还可以将向量作为参数传递给不同的向量构造函数调用,从而减少所需的参数数量:
vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);
因此,向量是一种灵活的数据类型,我们可将其用于各类输入和输出操作。在本书中,你会看到大量关于我们如何创造性地运用向量的示例。
输入和输出
着色器本身是很不错的小程序,但它们是一个整体的组成部分,因此我们希望各个着色器都有输入和输出,这样我们就能传递数据。GLSL专门为此定义了“in”和“out”关键字。每个着色器都可以使用这些关键字来指定输入和输出,并且只要一个着色器阶段的输出变量与下一个着色器阶段的输入变量匹配,数据就会被传递下去。不过,顶点着色器和片元着色器稍有不同。
顶点着色器应该接收某种形式的输入,否则它的效果会很差。顶点着色器在输入方面有所不同,它直接从顶点数据中接收输入。为了定义顶点数据的组织方式,我们使用位置元数据来指定输入变量,这样就能在CPU上配置顶点属性。我们在上一章中已经见过类似的情况,比如“layout(location = 0)”。因此,顶点着色器的输入需要额外的布局规范,以便我们能将其与顶点数据相链接。
也可以省略“layout(location = 0)”指定符,并通过“glGetAttribLocation”在OpenGL代码中查询属性位置,但我更倾向于在顶点着色器中设置它们。这样更容易理解,也能为你(和OpenGL)省去一些工作。
另一个例外情况是,片元着色器需要一个“vec4”类型的颜色输出变量,因为片元着色器需要生成最终的输出颜色。如果你在片元着色器中没有指定输出颜色,那么这些片元的颜色缓冲区输出将是未定义的(这通常意味着OpenGL会将它们渲染成黑色或白色)。
所以,如果我们想将数据从一个着色器发送到另一个着色器,我们就必须在发送着色器中声明一个输出,在接收着色器中声明一个类似的输入。当两边的类型和名称都相等时,OpenGL会将这些变量链接在一起,然后就可以在着色器之间发送数据了(这是在链接程序对象时完成的)。为了向你展示这在实际中是如何工作的,我们将对上一章的着色器进行修改,让顶点着色器来决定片元着色器的颜色。
顶点着色器
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置为0 。
out vec4 vertexColor; // 指定一个颜色输出到片元着色器
void main()
{
gl_Position = vec4(aPos, 1.0); // 看看我们是如何直接将一个vec3传递给vec4的构造函数的。
vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // 将输出变量设置为暗红色。
}
片段着色器
#version 330 core
out vec4 FragColor;
in vec4 vertexColor; // 来自顶点着色器的输入变量(名称相同且类型相同)。
void main()
{
FragColor = vertexColor;
}
你可以看到,我们声明了一个名为vertexColor的变量作为vec4类型的输出,该变量是在顶点着色器中设置的;同时,我们在片元着色器中也声明了一个类似的vertexColor输入。由于它们的类型和名称都相同,所以片元着色器中的vertexColor就与顶点着色器中的vertexColor链接起来了。因为我们在顶点着色器中将颜色设置为了暗红色,所以生成的片元也应该是暗红色的。以下图片展示了输出效果:
好啦!我们刚刚成功地将一个值从顶点着色器发送到了片元着色器。让我们再加点料,看看是否能将一种颜色从我们的应用程序发送到片元着色器吧!
Uniforms(统一变量)
统一变量(Uniforms)是将数据从CPU上的应用程序传递到GPU上的着色器的另一种方式。然而,统一变量与顶点属性相比略有不同。首先,统一变量是全局的。所谓全局,是指一个统一变量对于每个着色器程序对象来说是唯一的,并且可以在着色器程序的任何阶段被任何着色器访问。其次,无论你将统一变量的值设置成什么,它们都会保持该值,直到被重置或更新。
要在GLSL中声明一个统一变量,我们只需在着色器中添加带有类型和名称的“uniform”关键字即可。从那时起,我们就可以在着色器中使用新声明的统一变量了。让我们看看这次是否能通过一个统一变量来设置三角形的颜色:
#version 330 core
out vec4 FragColor;
uniform vec4 ourColor; // 我们在OpenGL代码中设置了这个变量。
void main()
{
FragColor = ourColor;
}
我们在片元着色器中声明了一个统一变量“uniform vec4 ourColor”,并将片元的输出颜色设置为该统一变量的值。由于统一变量是全局变量,我们可以在任何我们想要的着色器阶段定义它们,所以无需再通过顶点着色器来将数据传递到片元着色器。我们在顶点着色器中没有使用这个统一变量,所以没必要在那里定义它。
如果你声明了一个在GLSL代码中任何地方都未被使用的统一变量,编译器会默默地从编译版本中移除该变量,这就是导致一些令人懊恼的错误的原因;要记住这一点!
目前这个统一变量是空的,我们还没有给它添加任何数据,所以让我们来试试吧。我们首先需要找到着色器中统一变量属性的索引/位置。一旦我们得到了统一变量的索引/位置,我们就可以更新它的值。与其向片元着色器传递单一的颜色,不如让事情变得更有趣些,通过随时间逐渐改变颜色来实现:
double timeValue = _timer.Elapsed.TotalSeconds;
float greenValue = (float)Math.Sin(timeValue) / 2.0f + 0.5f;
int vertexColorLocation = GL.GetUniformLocation(_shader.Handle, "ourColor");
GL.Uniform4(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
首先,我们通过名为 _timer 的秒表对象获取运行时间(以秒为单位)。然后,我们利用正弦函数(sin function)在0.0至1.0的范围内改变颜色,并将结果存储在greenValue中。
接着,我们使用glGetUniformLocation函数查询ourColor统一变量的位置。我们将着色器程序以及想要获取其位置的统一变量名称提供给查询函数。如果glGetUniformLocation返回 -1,则表示它未能找到该位置。最后,我们可以使用glUniform4f函数来设置统一变量的值。需要注意的是,查找统一变量的位置并不要求你首先使用着色器程序,但更新一个统一变量则确实需要你首先使用该程序(通过调用glUseProgram),因为它是在当前处于活动状态的着色器程序上设置统一变量的。
由于OpenGL从本质上讲是一个C语言库,它本身并不支持函数重载,所以无论在何处,如果一个函数可以用不同类型进行调用,OpenGL就会为所需的每种类型定义新的函数;glUniform就是一个很好的例子。该函数针对你想要设置的统一变量类型需要一个特定的后缀。以下是一些可能的后缀:
- f:该函数期望接收一个浮点数作为其值。
- i:该函数期望接收一个整数作为其值。
- ui:该函数期望接收一个无符号整数作为其值。
- 3f:该函数期望接收3个浮点数作为其值。
- fv:该函数期望接收一个浮点数向量/数组作为其值。
每当你想要配置OpenGL的某个选项时,只需选择与你的类型相对应的重载函数即可。在我们的例子中,我们想要分别设置统一变量的4个浮点数,所以我们通过glUniform4f来传递我们的数据(注意,我们原本也可以使用fv版本)。
现在我们已经知道如何设置统一变量的值了,我们就可以将它们用于渲染。如果我们希望颜色逐渐变化,那么我们就需要在每一帧都更新这个统一变量,否则如果我们只设置一次,三角形就会一直保持单一的纯色。所以我们计算greenValue并在每次渲染迭代时更新统一变量:
protected override void OnRenderFrame(FrameEventArgs e)
{
base.OnRenderFrame(e);
// 渲染
// 清除颜色缓冲区。
GL.Clear(ClearBufferMask.ColorBufferBit);
// 务必激活着色器。
_shader.Use();
// 更新统一颜色。
double timeValue = _timer.Elapsed.TotalSeconds;
float greenValue = (float)Math.Sin(timeValue) / 2.0f + 0.5f;
int vertexColorLocation = GL.GetUniformLocation(_shader.Handle, "ourColor");
GL.Uniform4(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
// 现在渲染这个三角形。
GL.BindVertexArray(_vertexArrayObject);
GL.DrawArrays(PrimitiveType.Triangles, 0, 3);
// 交换缓冲区。
SwapBuffers();
}
这段代码是对之前代码相对直接的改编。这一次,在绘制三角形之前,我们会在每一帧都更新一个统一变量的值。如果你正确地更新了这个统一变量,那么你应该能看到三角形的颜色逐渐从绿色变为黑色,然后再变回绿色。
如你所见,统一变量(uniforms)是一种很有用的工具,可用于设置那些可能每帧都会变化的属性,或者在应用程序和着色器之间交换数据。但是,如果我们想要为每个顶点设置一种颜色,那该怎么办呢?在这种情况下,我们就得声明和顶点数量一样多的统一变量。一个更好的解决方案是在顶点属性中包含更多的数据,这也正是我们现在要做的事情。
更多属性
在上一章中我们已经了解到如何填充顶点缓冲对象(VBO)、配置顶点属性指针并将所有这些都存储在顶点数组对象(VAO)中。这次,我们还想在顶点数据中添加颜色数据。我们打算以3个浮点数的形式将颜色数据添加到顶点数组(vertices array)中。我们分别为三角形的每个顶点分配一种红、绿、蓝颜色:
private readonly float[] _vertices =
{
// positions // colors
0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 上方
};
由于现在我们有更多的数据要发送到顶点着色器,所以有必要对顶点着色器进行调整,使其也能将我们的颜色值作为顶点属性输入来接收。需要注意的是,我们使用布局指定符将aColor属性的位置设置为1:
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置为0。
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置为1。
out vec3 ourColor; // 向片元着色器输出一种颜色。
void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor; // 将ourColor设置为从顶点数据获取的输入颜色。
}
由于我们不再使用统一变量来设置片元的颜色,而是现在使用ourColor输出变量,所以我们也必须对片元着色器进行修改:
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
void main()
{
FragColor = vec4(ourColor, 1.0);
}
因为我们添加了另一个顶点属性并且更新了顶点缓冲对象(VBO)的内存,所以我们必须重新配置顶点属性指针。现在,VBO内存中的更新数据看起来大致如下:
了解了当前的布局之后,我们就可以使用glVertexAttribPointer来更新顶点格式了:
GL.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), 0);
GL.EnableVertexAttribArray(0);
GL.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), 3 * sizeof(float));
GL.EnableVertexAttribArray(1);
glVertexAttribPointer的前几个参数相对来说比较简单明了。这次我们要配置的是位于属性位置1的顶点属性。颜色值的大小是3个浮点数,并且我们不对这些值进行归一化处理。
由于现在我们有了两个顶点属性,所以必须重新计算步长值。为了在数据数组中获取下一个属性值(例如,位置向量的下一个x分量),我们必须向右移动6个浮点数的位置,其中3个是位置值,另外3个是颜色值。这就使得我们的步长值为6乘以一个浮点数的字节大小(即24字节)。而且,这次我们还必须指定一个偏移量。对于每个顶点来说,位置顶点属性是排在首位的,所以我们声明偏移量为0。颜色属性在位置数据之后开始,所以其偏移量为3乘以sizeof(float)(即12字节)。
运行该应用程序应该会得到如下所示的图像:
生成的图像可能和你预期的不太一样,因为我们只提供了3种颜色,而不是我们现在看到的这么丰富的调色板。这都是片元着色器中所谓的片元插值(fragment interpolation)导致的结果。
在渲染三角形时,光栅化阶段产生的片元数量通常比最初指定的顶点数量要多得多。然后,光栅器会根据片元在三角形形状内所处的位置来确定每个片元的位置。基于这些位置,它会对片元着色器的所有输入变量进行插值。
比如说,我们有一条线,上面的端点是绿色,下面的端点是蓝色。如果在位于这条线70%位置处的片元上运行片元着色器,那么其最终的颜色输入属性就会是绿色和蓝色的线性组合;更确切地说:是30%的蓝色和70%的绿色。
这正是在三角形上发生的情况。我们有3个顶点,也就有3种颜色,从三角形的像素来看,它可能包含大约50,000个片元,片元着色器就在这些像素之间对颜色进行了插值。如果你仔细观察这些颜色,就会发现这一切都是有道理的:从红色到蓝色,首先会变成紫色,然后再变成蓝色。片元插值会应用于片元着色器的所有输入属性。