2.1 OpenGL管线
现代3D图形编程会使用管线的概念,在管线中,将3D场景转换成2D图形的过程被分割成许多步骤。OpenGL和DirectX使用了相似的管线概念。
图2.2展示了OpenGL图形管线简化后的概览(并未展示所有阶段,仅包含我们要学习的主要阶段)。C++/OpenGL应用程序发送图形数据到顶点着色器,随着管线处理,最终生成在显示器上显示的像素点。
图2.2 OpenGL管线概览
用灰色阴影表示的阶段(顶点着色器、曲面细分着色器、几何着色器、片段着色器)可以用GLSL编写。将GLSL程序载入这些着色器阶段也是C++/OpenGL应用程序的责任之一,其过程如下。
(1)使用C++获取GLSL着色器代码,既可以从文件中读取,也可以硬编码在字符串中。
(2)创建OpenGL着色器对象,并将GLSL着色器代码加载到着色器对象中。
(3)用OpenGL命令编译并连接着色器对象,将它们装载到GPU。
在实践中,一般至少要提供顶点着色器和片段着色器阶段的GLSL代码,而曲面细分着色器和几何着色器阶段是可省略的。接下来我们将简单地跟随整个过程,看看每步发生了什么。
2.1.1 C++/OpenGL应用程序
我们的图形应用程序大部分是使用C++进行编写的。根据程序目的的不同,它可能需要用标准C++库与最终用户交互,用OpenGL调用实现与3D渲染相关的任务。正如前面章节所述,我们将会使用一些扩展库:GLEW、GLM、SOIL2,以及GLFW。
GLFW库包含GLFWwindow类,我们可以在其上进行3D场景绘制。如前所述,OpenGL也向我们提供了用于将GLSL程序载入可编程着色器阶段并对其进行编译的命令。最后,OpenGL使用缓冲区将3D模型和其他相关图形数据发送到管线中。
在我们尝试编写着色器之前,先编写一个简单的C++/OpenGL应用程序,创建一个GLFWwindow实例并为其设置背景色。这个过程根本用不到着色器!其代码如程序2.1所示。程序2.1中的main()函数与本书中所有将会用到的main()函数一样。其中重要的操作有:(a)初始化GLFW库;(b)实例化GLFWwindow;(c)初始化GLEW库;(d)调用一次init()函数;(e)重复调用display()函数。
我们将每个应用程序的初始化任务都放在init()函数中,将用于绘制GLFWwindow的代码都放在display()函数中。
在本例中,glClearColor()命令指定了清除背景时用的颜色值(1.0, 0.0, 0.0, 1.0),代表红色(末尾的1.0表示不透明度)。接下来使用OpenGL调用glClear(GL_COLOR_BUFFER_BIT),使用红色填充颜色缓冲区。
程序2.1 第一个C++/OpenGL应用程序
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <iostream>
using namespace std;
void init(GLFWwindow* window) { }
void display(GLFWwindow* window, double currentTime) {
glClearColor(1.0, 0.0, 0.0, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
}
int main(void) {
if (!glfwInit()) { exit(EXIT_FAILURE); }
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
GLFWwindow* window = glfwCreateWindow(600, 600, "Chapter2 - program1", NULL, NULL);
glfwMakeContextCurrent(window);
if (glewInit() != GLEW_OK) { exit(EXIT_FAILURE); }
glfwSwapInterval(1);
init(window);
while (!glfwWindowShouldClose(window)) {
display(window, glfwGetTime());
glfwSwapBuffers(window);
glfwPollEvents();
}
glfwDestroyWindow(window);
glfwTerminate();
exit(EXIT_SUCCESS);
}
图2.3展示了程序2.1的输出。
图2.3 程序2.1的输出
相关函数部署的机制为:GLFW和GLEW库先分别使用glfwInit()和glewInit()初始化。glfwCreateWindow()命令负责创建GLFW窗口,同时其相关的OpenGL上下文[1]由glfwCreateWindow()命令创建,其可选项由前面的WindowHint设置。WindowHint指定了计算机必须与OpenGL版本4.3 兼容(主版本号为4,次版本号为3)。glfwCreateWindow命令的参数指定了窗口的宽、高(以像素为单位)以及窗口顶部的标题(将这里没有用到的另外两个参数设为NULL,这两个参数分别用来允许全屏显示和资源共享)。glfwSwapInterval()命令和glfwSwapBuffers()命令用来开启垂直同步,因为GLFW窗口默认是双缓冲[2]的。这里需要注意,创建GLFW窗口并不会自动将它与当前OpenGL上下文关联起来,因此我们需要调用glfwMakeContextCurrent()。
main()函数包括一个简单的渲染循环,用来反复调用display()。它同时也调用了glfwSwapBuffers()以绘制屏幕,以及glfwPollEvents()以处理窗口相关事件(如按键事件)。当GLFW探测到应该关闭窗口的事件(如用户单击了右上角的“×”)时,循环就会终止。这里需要注意,我们将一个GLFW窗口对象的引用传入了init()和display()调用。这些函数在特定环境下需要访问GLFW窗口对象。同时我们也将当前时间传入了 display()调用,若要保证动画在不同计算机上以相同速度播放,这样做会很有用。在这里,我们用了glfwGetTime(),它会返回GLFW初始化之后经过的时间。
现在是时候详细看看程序2.1中的OpenGL调用了。首先关注一下这个调用:
glClear(GL_COLOR_BUFFER_BIT);
在这里,调用的OpenGL参考文档中的描述是:
void glClear(GLbitfield mask);
参数中引用了类型为GLbitfield的GL_COLOR_BUFFER_BIT。OpenGL有很多预定义的常量(其中很多是枚举量)。GL_COLOR_BUFFER_BIT引用了包含渲染后像素的颜色缓冲区。OpenGL有多个颜色缓冲区,这个命令会将它们全部清除——用一种被称为“清除色”(clear color)的预定义颜色填充所有缓冲区。注意,这里的“清”表示的不是“颜色清晰”,而是重置缓冲区时填充的颜色。
调用glClear()前是glClearColor()的调用。glClearColor()让我们能够指定颜色缓冲区清除后填充的值。这里我们指定了(1.0, 0.0, 0.0, 1.0),代表红色。
最后,当用户尝试关闭GLFW窗口时,程序将退出渲染循环。这时,main()会通过分别调用glfwDestroyWindow()和glfwTerminate()通知GLFW销毁窗口并终止运行。
2.1.2 顶点着色器和片段着色器
在第一个OpenGL程序中,我们实际上并没有绘制任何东西——仅仅用一种颜色填充了颜色缓冲区。要真的绘制点儿什么,我们需要加入顶点着色器和片段着色器。
你可能会惊讶于OpenGL只能绘制几类非常简单的东西,如点、线、三角形。这些简单的东西叫作图元,多数3D模型通常由许多三角形图元构成。图元由顶点组成,例如三角形有3个顶点。顶点可以有很多来源,如从文件读取并由C++/OpenGL应用载入缓冲区,直接在C++文件中硬编码,或者直接在GLSL代码中生成。
在加载顶点之前,C++/OpenGL应用程序必须编译并链接合适的GLSL顶点着色器和片段着色器程序,之后将它们载入管线。我们稍后将会看到这些命令。
C++/OpenGL应用程序同时也负责通知OpenGL构建三角形,通过使用如下OpenGL函数实现:
glDrawArrays(GLenum mode, Glint first, GLsizei count);
mode参数表示图元的类型。对于三角形,我们使用GL_TRIANGLES。first参数表示从哪个顶点开始绘制(通常是顶点0,即第一个顶点),count表示总共要绘制的顶点数。
当调用glDrawArrays()时,管线中的GLSL代码开始执行。现在可以向管线添加一些GLSL代码了。
不管它们从何处读入,所有的顶点都会被传入顶点着色器。顶点们会被逐个处理,即着色器会对每个顶点执行一次。对拥有很多顶点的大型复杂模型而言,顶点着色器会执行成百上千甚至上百万次,这些执行过程通常是并行的。
现在,我们来编写一个简单的程序,它仅包含硬编码于顶点着色器中的一个顶点。这虽然不足以让我们画出三角形,但是足以画出一个点。为了显示这个点,我们还需要提供片段着色器。简单起见,我们将这两个着色器程序声明为字符串数组。
程序2.2 着色器,画一个点
//#include列表与之前相同
#define numVAOs 1 ⇽---新的定义
GLuint renderingProgram;
GLuint vao[numVAOs];
GLuint createShaderProgram() {
const char *vshaderSource =
"#version 430 \n"⇽---①
"void main(void) \n"
"{ gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }";
const char *fshaderSource =
"#version 430 \n"
"out vec4 color; \n"
"void main(void) \n"
"{ color = vec4(0.0, 0.0, 1.0, 1.0); }";
GLuint vShader = glCreateShader(GL_VERTEX_SHADER);
GLuint fShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(vShader, 1, &vshaderSource, NULL);
glShaderSource(fShader, 1, &fshaderSource, NULL);
glCompileShader(vShader);
glCompileShader(fShader);
GLuint vfProgram = glCreateProgram();
glAttachShader(vfProgram, vShader);
glAttachShader(vfProgram, fShader);
glLinkProgram(vfProgram);
return vfProgram;
}
void init(GLFWwindow* window) {
renderingProgram = createShaderProgram();
glGenVertexArrays(numVAOs, vao);⇽---②
glBindVertexArray(vao[0]);
}
void display(GLFWwindow* window, double currentTime) {
glUseProgram(renderingProgram);
glDrawArrays(GL_POINTS, 0, 1);
}
//main()函数与之前相同
程序的输出效果看起来只显示了一个空的窗口(见图2.4),但仔细观察一下,会发现窗口中央有一个蓝色的点(见彩插)。OpenGL中点的默认大小为1像素。
图2.4 程序2.2的输出效果
程序2.2中有很多值得讨论的重要细节,方便起见已用阴影和带圈数字标出。第一,注意其中多次用到的“GLuint”——这是由OpenGL提供的“unsigned int”的平台无关简写(许多OpenGL结构体都是整数类型引用)。第二,init()不再是空函数了——现在它会调用另一个叫作createShaderProgram的函数(由我们编写)。createShaderProgram()函数先定义了两个字符串vshaderSource和fshaderSource,之后调用了两次glCreateShader()函数,创建了类型为GLVERTEX SHADER和GL_FRAGMENT_SHADER的着色器。OpenGL创建每个着色器对象(初始值为空)的时候,会返回一个整数ID作为后面引用它的序号——我们的代码将这两个 ID 分别存入 vShader和 fShader 变量。第三,createShaderProgram()调用了glShaderSource(),这个函数用于将GLSL代码从字符串载入空着色器对象中,并由glCompileShader()编译各着色器。glShaderSource()有4个参数:用来存放着色器的着色器对象、着色器源代码中的字符串数量、包含源代码的字符串指针,以及一个此处没有用到的参数(我们会在补充说明中解释这个参数)。注意,这两次调用glCompileShader()时都指明了着色器的源代码字符串数量为“1”——这个参数也会在补充说明中解释。
程序创建了一个叫作vfProgram的程序对象,并储存指向它的整数ID。OpenGL“程序”对象包含一系列编译过的着色器,这里可以看到glCreateProgram()创建程序对象,glAttachShader()将着色器加入程序对象,接着glLinkProgram()请求GLSL编译器,以确保它们的兼容性。
如前所见,在 init()结束后,程序调用了 display()。display()函数所做的事情中包含调用glUseProgram(),用于将含有两个已编译着色器的程序载入OpenGL管线阶段(在GPU上!)。注意,glUseProgram()并没有运行着色器,它只是将着色器加载进硬件。
我们在第4章会看到,一般情况下,这里C++/OpenGL会准备要发送给管线绘制的模型的顶点集。但是由于本例是本书第一个着色器程序,我们仅仅在顶点着色器中硬编码了一个顶点。因此,本例中的display()函数接着调用了glDrawArrays()用来启动管线处理过程。原始类型是GL_POINTS,仅用来显示一个点。
现在我们来看一下着色器,在程序2.2中标记为数字①(下文分段重复了一遍该着色器)。正如我们所看到的,在C++/OpenGL应用程序中,它们被声明为字符串数组。这是一种“笨拙”的编程方式,不过在这个超简单的例子中足够了。这个顶点着色器是:
#version 430
void main(void)
{ gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
第一行代码指明了OpenGL版本,这里是4.3版。接下来是一个main()函数(我们后面将会看到,GLSL的语法与C++的类似)。所有顶点着色器的主要目标都是将顶点发送给管线(正如之前所说的,它会对每个顶点进行处理)。内置变量gl_Position用来设置顶点在3D空间中的坐标位置,并将其发送至下一个管线阶段。GLSL数据类型vec4用来存储四元组,适合用来存储坐标,四元组的前3个值分别表示x坐标、y坐标、z坐标,第4个值在这里设为1.0(在第3章中将会学习第4个值的用途)。本例中,顶点坐标被硬编码为原点。
顶点接下来将沿着管线移动到光栅着色器,它们会在这里被转换成像素位置(更精确地说是片段,后文会解释)。最终这些像素(片段)到达片段着色器:
#version 430
out vec4 color;
void main(void)
{ color = vec4(0.0, 0.0, 1.0, 1.0); }
所有片段着色器的目的都是给为要展示的像素赋予颜色。在本例中所指定的输出颜色值为(0.0, 0.0, 1.0, 1.0),代表蓝色(第4个值1.0是不透明度)。注意这里的out标签表明color变量是输出变量。(在顶点着色器,给gl_Position指定out标签不是必需的,因为gl_Position是预定义的输出变量。)
代码中还有一处我们没有讨论的细节,位于init()函数的最后两行,在程序2.2中标记为数字②。它们看起来可能有些神秘。我们在第4章中会看到,当准备将数据集发送给管线时,数据集是以缓冲区形式发送的。这些缓冲区最后都会被存入顶点数组对象(Vertex Array Object,VAO)中。在本例中,我们向顶点着色器中硬编码了一个点,因此不需要任何缓冲区。但是,即使应用程序完全没有用到任何缓冲区,OpenGL仍然需要在使用着色器的时候拥有至少一个创建好的VAO,所以这两行代码用来创建OpenGL要求的VAO。
最后的问题就是从顶点着色器出来的顶点是如何变成片段着色器中的像素的。回忆一下,在顶点处理和像素处理中间存在着栅格化阶段。正是在这个阶段,图元(点或三角形)转换成了像素的集合。OpenGL中默认点的大小为1像素,因此我们的点最终被渲染成了单个像素。
我们将下面的命令加入display()函数中,就放在调用glDrawArrays()之前:
glPointSize(30.0f);
现在,栅格化阶段从顶点着色器收到顶点时,会设置像素的颜色,组成一个尺寸为30像素的点。输出的结果展示在图2.5中(见彩插)。
图2.5 改变glPointSize
下面我们继续观察其余OpenGL管线。
2.1.3 曲面细分着色器
我们在第12章中介绍曲面细分。可编程曲面细分阶段是最近加入OpenGL(在4.0版中)的功能。它提供了一个曲面细分着色器以生成大量三角形,通常以网格形式排列。同时也提供了一些可以以各种方式操作这些三角形的工具。例如,程序员可能需要以图 2.6 展示的方式操作一个经曲面细分的三角形网格。
图2.6 曲面细分着色器生成的网格
当在简单形状上需要很多顶点时(如在方形区域或曲面上),曲面细分着色器就能发挥作用了。稍后我们会看到,它在生成复杂地形时也很有用。对于这种情况,有时用GPU中的曲面细分着色器在硬件里生成三角形网格比在C++中生成要高效得多。
2.1.4 几何着色器
我们将在第13章中介绍几何着色器阶段。顶点着色器可赋予程序员一次操作一个顶点(“按顶点”处理)的能力,片段着色器(稍后会看到)可赋予程序员一次操作一个像素(“按片段”处理)的能力,几何着色器可赋予程序员一次操作一个图元(“按图元”处理)的能力。
前文中的三角形是很通用的图元。当我们到达几何着色器阶段时,管线肯定已经完成了将顶点组合为三角形的过程(这个过程叫作图元组装)。接下来几何着色器会让程序员可以同时访问每个三角形的所有顶点。
按图元处理有很多用途,可以让图元变形(比如拉伸或者缩小),还可以删除一些图元从而在渲染的物体上产生“洞”——这是一种将简单模型转化为复杂模型的方法。
几何着色器也提供了生成额外图元的方法,这些方法也打开了通过转换简单模型得到复杂模型的“大门”。几何着色器有一种有趣的用法,就是在物体上增加表面纹理,如凸起、“鳞”甚至“毛发”。考虑图2.7所示的简单环面(本书后面会介绍如何生成它),该环面的表面由上百个三角形构成。如果我们用几何着色器在其外侧表面增加额外的三角形,就会得到图2.8所示的“鳞环面”。如果这个“鳞环面”通过C++/OpenGL应用程序从零开始建模生成,代价就大了。
图2.7 环面模型
图2.8 几何着色器修改后的环面
在曲面细分阶段已经赋予程序员同时访问模型中所有顶点的能力后,按图元运算的着色器阶段看起来可能有点儿多余。它们的区别是,曲面细分只在非常少的情况下提供了这个能力,它尤其针对模型是由曲面细分器生成的三角形网格的情况,并没有提供同时访问所有顶点(即任何从C++用缓冲区传来的顶点)的能力。
2.1.5 栅格化
最终,我们3D世界中的点、三角形、颜色等全都需要展现在一个2D显示器上。这个2D屏幕由栅格(即矩形像素阵列)组成。
当3D物体栅格化后,OpenGL会将物体中的图元(通常是三角形)转化为片段。片段拥有关于像素的信息。栅格化过程确定了为了显示由3个顶点确定的三角形需要绘制的所有像素的位置。
栅格化过程开始时,先对三角形的每对顶点进行插值。插值过程可以通过选项调节,就目前而言,使用图2.9所示的简单的线性插值就够了。原本的3个顶点被标记为红色(见彩插)。
图2.9 栅格化(步骤1)
如果栅格化过程到此为止,那么呈现出的图像将会是线框模型。呈现线框模型也是OpenGL中的一个选项,设置方法是在display()函数中glDrawArrays()的调用之前添加如下代码:
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
如果2.1.4小节中的环面使用了这行额外代码,结果看起来会如图2.10所示。
图2.10 使用线框模型渲染的环面
如果我们不加入之前的那一行代码(或者配置时使用GL_FILL而非GL_LINE),插值过程将会继续沿着栅格线填充三角形的内部,如图2.11所示。将其应用于环面时会产生一个完全栅格化的“实心”环面,如图 2.12(左)所示。请注意,在这种情况下,环面的整体形状和曲率不明显——这是因为我们没有运用任何纹理或照明技术,因此它看起来是“平”的。图2.12(右)为同样的“平”环面叠加了线框模型。图2.7所示的环面包括照明效果,因此更清晰地显示了环面的形状。我们将在第7章学习照明。
图2.11 完全栅格化的三角形
图2.12 环面的完全栅格化图元渲染(左)和使用线框叠加(右)
在本章后面我们将看到,栅格化不仅可以对像素插值,任何顶点着色器输出的变量和片段着色器的输入变量都可以基于对应的像素进行插值。我们将会使用该功能生成平滑的颜色渐变,实现真实光照及许多其他效果。
2.1.6 片段着色器
如前所述,片段着色器用于为栅格化的像素指定颜色。我们已经在程序2.2中看到了片段着色器示例。在程序2.2中,片段着色器仅将输出硬编码为特定值,从而为每个输出的像素赋予相同的颜色。不过GLSL为我们提供了其他计算颜色的方式,可以发挥我们无穷的创造力。
一个简单的例子就是基于像素位置决定输出颜色。在顶点着色器中,顶点的输出坐标曾使用预定义变量gl_Position。在片段着色器中,同样有一个变量让程序员可以访问输入片段的坐标,叫作gl_FragCoord。我们可以通过修改程序2.2中的片段着色器,让它使用gl_FragCoord(在本例中通过GLSL属性选择语法引用它的x坐标)基于位置设置每个像素的颜色,如:
#version 430
out vec4 color;
void main(void)
{ if (gl_FragCoord.x < 295) color = vec4(1.0, 0.0, 0.0, 1.0);
else color = vec4(0.0, 0.0, 1.0, 1.0);
}
如果我们像在2.1.2小节末尾那样增大glPointSize,那么渲染的点的像素颜色将会随着坐标变化——x坐标小于200时是红色,否则就是蓝色,如图2.13所示(见彩插)。
图2.13 片段着色器颜色变化
2.1.7 像素操作
当我们在display()中使用glDrawArrays()命令绘制场景中的物体时,我们通常期望前面的物体挡住后面的物体。这也可以推广到物体自身,我们通常期望看到物体的正对我们,而不是背对我们。
为了实现这个效果,我们需要执行隐藏面消除(Hidden Surface Removal,HSR)操作。基于场景需要,OpenGL可以进行一系列不同的HSR操作。虽然这个阶段不可编程,但是理解它的工作原理也是非常重要的。我们不仅需要正确地配置它,之后还需要在给场景添加阴影时对它进行进一步操作。
OpenGL可以精巧地协调两个缓冲区,即颜色缓冲区(我们之前讨论过)和深度缓冲区(也叫作Z缓冲区、Z-buffer),从而完成隐藏面消除。这两个缓冲区都和栅格的大小相同——对于屏幕上每个像素,在两个缓冲区都各有对应条目。
当绘制场景中的各种对象时,片段着色器会生成像素颜色。像素颜色会存放在颜色缓冲区中,而最终颜色缓冲区会被写入屏幕。当多个对象占据颜色缓冲区中的相同像素时,必须根据最接近观察者的对象来确定要保留的像素颜色。
隐藏面消除按照如下步骤完成。
(1)在每个场景渲染前,将深度缓冲区全部初始化为表示最大深度的值。
(2)当片段着色器输出像素颜色时,计算它到观察者的距离。
(3)如果(对于当前像素)距离小于深度缓冲区存储的值,那么用当前像素颜色替换颜色缓冲区中的颜色,同时用当前距离替换深度缓冲区中的值;否则,抛弃当前像素。
这个过程即Z-buffer算法,其伪代码如图2.14所示。
图2.14 Z-buffer算法