1.5 第一个程序:深入分析
现在我们来深入探讨一下之前的第一个程序。
1.5.1 进入main()函数
为了了解示例程序从一开始是如何运行的,首先了解一下main()函数当中都发生了什么。前面的6行使用GLFW设置和打开了一个渲染用的窗口。这方面的详细介绍可以参见附录A,这里只介绍每一行的执行结果。
第一个函数glfwtInit()负责初始化GLFW库。它会处理向程序输入的命令行参数,并且移除其中与控制GLFW如何操作相关的部分(例如设置窗口的大小)。glfwtInit()必须是应用程序调用的第一个GLFW函数,它会负责设置其他GLFW例程所必需的数据结构。
glfwCreateWindow()设置了程序所使用的窗口类型以及期望的窗口尺寸。如果我们不想在这里设置一个固定值的话,也可以先查询显示设备的尺寸,然后根据计算机的屏幕大小动态设置窗口的大小。
glfwCreateWindow()还创建了一个与窗口关联的OpenGL设备环境。在使用环境之前,我们必须设置它为当前环境。在一个程序中,我们可以设置多个设备环境以及多个窗口,而用户指令只会传递到当前设备环境中。
继续讨论这个例子,接下来会调用gl3wInit()函数,它属于我们用到的另一个辅助库GL3W。GL3W可以简化获取函数地址的过程,并且包含了可以跨平台使用的其他一些OpenGL编程方法。如果没有GL3W,我们可能还需要执行相当多的工作才能够运行程序。
到这里,我们已经完成了使用OpenGL之前的全部设置工作。在马上要介绍的init()例程中,我们将初始化OpenGL相关的所有数据,以便完成之后的渲染工作。
main()函数中调用的最后一个指令是一个无限执行的循环,它会负责一直处理窗口和操作系统的用户输入等操作。在循环中我们会判断是否需要关闭窗口(通过调用glfwWindowShouldClose()),重绘它的内容,并且展现给最终用户(通过调用glfwSwapBuffers()),然后检查操作系统返回的任何信息(通过调用glfwPollEvents())。
如果我们认为需要关闭窗口,应用程序需要退出的话,会调用glfwDestroyWindow()来清理窗口,然后调用glfwTerminate()关闭GLFW库。
1.5.2 OpenGL的初始化过程
下面将要讨论例1.1中的init()函数。首先再次列出与之相关的代码。
初始化顶点数组对象
在init()中使用了不少函数和数据。在函数的起始部分,我们调用glCreateVertexArrays()分配了顶点数组对象(vertex-array object)。OpenGL会因此分配一部分顶点数组对象的名称供我们使用,在这里共有NumVAOs个对象,即这个全局变量所指代的数值。glCreateVertexArrays()的第二个参数返回的是对象名的数组,也就是这里的VAOs。
我们对glCreateVertexArrays()函数的完整解释如下:
void glCreateVertexArrays(GLsizei n, GLuint *arrays);
返回n个未使用的对象名到数组arrays中,用作顶点数组对象。返回的名字可以用来分配更多的缓存对象,并且它们已经使用未初始化的顶点数组集合的默认状态进行了数值的初始化。如果n是负数,产生GL_INVALID_VALUE错误。
我们会发现很多OpenGL命令都是glCreate*的形式,它们负责分配不同类型的OpenGL对象的名称。这里的名称类似C语言中的一个指针变量,我们可以分配内存对象并且用名称引用它。当我们得到对象之后,可以将它绑定(bind)到OpenGL环境以便使用。在这个例子中,我们通过glBindVertexArray()函数创建并且绑定了一个顶点数组对象。
void glBindVertexArray(GLuint array);
glBindVertexArray()完成了两项工作。如果输入的变量array非0,并且是glCreate-VertexArrays()所返回的,那么会激活这个顶点数组对象,并且直接影响对象中所保存的顶点数组状态。如果输入的变量array为0,那么OpenGL将不再使用之前绑定的顶点数组。
如果array不是glCreateVertexArrays()所返回的数值,或者它已经被glDelete-VertexArrays()函数释放了,那么这里将产生一个GL_INVALID_OPERATION错误。
这个例子中,在生成一个顶点数组对象之后,就会使用glBindVertexArray()将它绑定起来。在OpenGL中这样的对象绑定操作非常常见,但是我们可能无法立即了解它做了什么。当我们绑定对象时(例如,用指定的对象名作为参数调用glBind()),OpenGL内部会将它作为当前对象,即所有后继的操作都会作用于这个被绑定的对象,例如,这里的顶点数组对象的状态就会被后面执行的代码所改变。在第一次调用glCreate()函数之后,新创建的对象都会初始化为其默认状态,而我们通常需要一些额外的初始化工作来确保这个对象可用。
绑定对象的过程有点类似设置铁路的道岔开关。一旦设置了开关,从这条线路通过的所有列车都会驶向对应的轨道。如果我们将开关设置到另一个状态,那么所有之后经过的列车都会驶向另一条轨道。OpenGL的对象也是如此。总体上来说,在两种情况下我们需要绑定一个对象:创建对象并初始化它所对应的数据时;以及每次我们准备使用这个对象,而它并不是当前绑定的对象时。我们会在display()例程中看到后一种情况,即在程序运行过程中第二次调用glBindVertexArray()函数。
由于示例程序需要尽量短小,因此我们不打算做任何多余的操作。举例来说,在较大的程序里当我们完成对顶点数组对象的操作之后,是可以调用glDeleteVertexArrays()将它释放的。
void glDeleteVertexArrays(GLsizei n, const GLuint *arrays);
删除n个在arrays中定义的顶点数组对象,这样所有的名称可以再次用作顶点数组。如果绑定的顶点数组已经被删除,那么当前绑定的顶点数组对象被重设为0(类似执行了glBindBuffer()函数,并且输入参数为0),并且不再存在一个当前对象。在arrays当中未使用的名称都会被释放,但是当前顶点数组的状态不会发生任何变化。
最后,为了确保程序的完整性,我们可以调用glIsVertexArray()检查某个名称是否已经被保留为一个顶点数组对象了。
GLboolean glIsVertexArray(GLuint array);
如果array是一个已经用glCreateVertexArrays()创建且没有被删除的顶点数组对象的名称,那么返回GL_TRUE。如果array为0或者不是任何顶点数组对象的名称,那么返回GL_FALSE。
对于OpenGL中其他类型的对象,我们都可以看到类似的名为glDelete和glIs的例程。
分配缓存对象
顶点数组对象负责保存一系列顶点的数据。这些数据保存到缓存对象当中,并且由当前绑定的顶点数组对象管理。我们只有一种顶点数组对象类型,但是却有很多种类型的对象,并且其中一部分对象并不负责处理顶点数据。正如前文中所提到的,缓存对象就是OpenGL服务端分配和管理的一块内存区域,并且几乎所有传入OpenGL的数据都是存储在缓存对象当中的。
缓存对象的初始化过程与顶点数组对象的创建过程类似,不过需要有向缓存中添加数据的一个过程。
首先,我们需要创建顶点缓存对象的名称。我们调用的还是glCreate*形式的函数,即glCreateBuffers()。在这个例子中,我们分配NumVBOs个对象(VBO即Vertex Buffer Object,用来标识存储顶点数据的缓存对象)到数组buffers当中。以下是glCreateBuffers()的详细介绍。
void glGenBuffers(GLsizei n, GLuint *buffers);
返回n个当前未使用的缓存对象名称,并保存到buffers数组中。返回到buffers中的名称不一定是连续的整型数据。如果n是负数,那么产生GL_INVALID_VALUE错误。
这里返回的名称表示新创建的缓存对象,带有默认可用状态。
0是一个保留的缓存对象名称,glCreateBuffers()永远都不会返回这个值的缓存对象。
当分配缓存之后,就可以调用glBindBuffer()来绑定它们到OpenGL环境了。由于OpenGL中有很多种不同类型的缓存对象,因此绑定一个缓存时,需要指定它所对应的类型。在这个例子中,由于是将顶点数据保存到缓存当中,因此使用GL_ARRAY_BUFFER类型。而绑定缓存的类型也称作绑定目标(binding target)。缓存对象的类型现在有很多种,它们用于不同的OpenGL功能实现。本书后面的章节会分别讨论各种类型的对应操作。
glBindBuffer()函数的详细介绍如下。
void glBindBuffer(GLenum target, GLuint buffer);
指定当前激活的缓存对象。target必须设置为以下类型中的一个:GL_ARRAY_BUFFER、GL_ATOMIC_COUNTER_BUFFER、GL_ELEMENT_ARRAY_BUFFER、GL_PIXEL_PACK_BUFFER、GL_PIXEL_UNPACK_BUFFER、GL_COPY_READ_BUFFER、GL_COPY_WRITE_BUFFER、GL_SHADER_STORAGE_BUFFER、GL_QUERY_RESULT_BUFFER、GL_DRAW_INDIRECT_BUFFER、GL_TRANSFORM_FEEDBACK_BUFFER和GL_UNIFORM_BUFFER。buffer设置的是要绑定的缓存对象名称。
glBindBuffer()完成了两项工作:1) 如果绑定到一个已经创建的缓存对象,那么它将成为当前target中被激活的缓存对象。2)如果绑定的buffer值为0,那么OpenGL将不再对当前target使用任何缓存对象。
所有的缓存对象都可以使用glDeleteBuffers()直接释放。
void glDeleteBuffers(GLsizei n, const GLuint *buffers);
删除n个保存在buffers数组中的缓存对象。被释放的缓存对象可以重用(例如,使用glCreateBuffers())。
如果删除的缓存对象已经被绑定,那么该对象的所有绑定将会重置为默认的缓存对象,即相当于用0作为参数执行glBindBuffer()的结果。如果试图删除不存在的缓存对象,或者缓存对象为0,那么将忽略该操作(不会产生错误)。
我们也可以用glIsBuffer()来判断一个整数值是否是一个缓存对象的名称。
GLboolean glIsBuffer(GLuint buffer);
如果buffer是一个已经分配并且没有释放的缓存对象的名称,则返回GL_TRUE。如果buffer为0或者不是缓存对象的名称,则返回GL_FALSE。
将数据载入缓存对象
初始化顶点缓存对象之后,我们需要让OpenGL分配缓存对象的空间并把顶点数据从对象传输到缓存对象当中。这一步是通过glNamedBufferStorage()例程完成的,它主要有两个任务:分配顶点数据所需的存储空间,然后将数据从应用程序的数组中拷贝到OpenGL服务端的内存中。glNamedBufferStorage()为一处缓存分配空间,并进行命名(缓存不需要被绑定)。
有可能在很多不同的场景中多次应用glNamedBufferStorage(),因此我们有必要在这里深入了解它的过程,尽管我们在这本书中还会多次遇到这个函数。首先,glNamedBufferStorage()的详细定义介绍如下。
void glNamedBufferStorage(GLuint buffer, GLsizeiptr size, const void *data, GLbitf?ield f?lags);
在OpenGL服务端内存中分配size个存储单元(通常为byte),用于存储数据或者索引。glNamedBufferStorage()作用于名为buffer的缓存区域。它不需要设置target参数。
size表示存储数据的总数量。这个数值等于data中存储的元素的总数乘以单位元素存储空间的结果。
data要么是一个客户端内存的指针,以便初始化缓存对象,要么是NULL。如果传入的指针合法,那么将会有size大小的数据从客户端拷贝到服务端。如果传入NULL,那么将保留size大小的未初始化的数据,以备后用。
f?lags提供了缓存中存储的数据相关的用途信息。它是下面一系列标识量经过逻辑“与”运算的总和:
GL_DYNAMIC_STORAGE_BIT、GL_MAP_READ_BIT、GL_MAP_WRITE_BIT、GL_MAP_PERSISTENT_BIT、GL_MAP_COHERENT_BIT和GL_CLIENT_STORAGE_BIT。我们会在本书的后面部分依次予以介绍。
如果所需的size大小超过了服务端能够分配的额度,那么glNamedBufferData()将产生一个GL_OUT_OF_MEMORY错误。如果f?lags包含的不是可用的模式值,那么将产生GL_INVALID_VALUE错误。
一下子理解这么多的内容可能有点困难,但是这些函数在后面的学习中会多次出现,因此有必要在本书的开始部分就详细地对它们做出讲解。
在上面的例子中,直接调用了glNamedBufferData()。因为顶点数据就保存在一个vertices数组当中。如果需要静态地从程序中加载顶点数据,那么我们可能需要从模型文件中读取这些数值,或者通过某些算法来生成。由于我们的数据是顶点属性数据,因此设置这个缓存的目标为GL_ARRAY_BUFFER,即它的第一个参数。我们还需要指定内存分配的大小(单位为byte),因此直接使用sizeof(vertices)来完成计算。最后,我们需要指定数据在OpenGL中使用的方式。我们可以简单地设置f?lags为0。至于f?lags中可以使用的其他标识量,我们会在本书后面的部分进行介绍。
如果我们仔细观察vertices数组中的数值,就会发现它们在x和y方向都被限定在[–1, 1]的范围内。实际上,OpenGL只能够绘制坐标空间内的几何体图元。而具有该范围限制的坐标系统也称为规格化设备坐标系统(Normalized Device Coordinate,NDC)。这听起来好像是一个巨大的限制,但实际上并不是问题。第5章会介绍将三维空间中的复杂物体映射到规格化设备坐标系中的数学方法。在这个例子中直接使用NDC坐标,不过实际上我们通常会使用一些更为复杂的坐标空间。
现在,我们已经成功地创建了一个顶点数组对象,并且将它传递到缓存对象中。下一步,我们要设置程序中用到的着色器了。
初始化顶点与片元着色器
每一个OpenGL程序进行绘制的时候,都需要指定至少两个着色器:顶点着色器和片元着色器。在这个例子中,我们通过一个辅助函数LoadShaders()来实现这个要求,它需要输入一个ShaderInfo结构体数组(这个结构体的实现过程可以参见示例源代码的头文件LoadShaders.h)。
对于OpenGL程序员而言,着色器就是使用OpenGL着色语言(OpenGL Shading Language,GLSL)编写的一个小型程序。GLSL是构成所有OpenGL着色器的语言,它与C++语言非常类似,尽管GLSL中的所有特性并不能用于OpenGL的每个着色阶段。我们可以以字符串的形式传输GLSL着色器到OpenGL。不过为了简化这个例子,并且让读者更容易地使用着色器去进行开发,我们选择将着色器字符串的内容保存到文件中,并且使用LoadShaders()读取文件和创建OpenGL着色器程序。使用OpenGL着色器进行编程的具体过程可以参见第2章的内容。
为了帮助读者尽快开始了解着色器的内容,我们并没有将所有相关的细节内容都立即呈现出来。事实上,本书后面的内容都会与GLSL的具体实现相关,而现在,我们只需要在例1.2中对顶点着色器的代码做一个深入了解。
例1.2 triangles.cpp对应的顶点着色器:triangles.vert
没错,它的内容只有这么多。事实上这就是我们之前所说的传递着色器(pass-through shader)的例子。它只负责将输入数据拷贝到输出数据中。不过即便如此,我们也还是要展开深入讨论。
第一行“#version 450 core”指定了我们所用的OpenGL着色语言的版本。这里的“450”表示我们准备使用OpenGL 4.5对应的GLSL语言。这里的命名规范是基于OpenGL 3.3版本的。在那之前的OpenGL版本中,版本号所用的数字是完全不一样的(详细介绍参见第2章)。这里的“core”表示我们将使用OpenGL核心模式(core prof?ile),它也是新的应用程序应当采用的模式。每个着色器的第一行都应该设置“#version”,否则系统会假设使用“110”版本,但是这与OpenGL核心模式并不兼容。我们在本书中只针对330版本及以上的着色器以及它的特性进行讲解;如果这个版本号不是最新的版本,那么程序的可移植性应该会更好,但是你将无法使用最新的系统特性。
下一步,我们分配了一个着色器变量。着色器变量是着色器与外部世界的联系所在。换句话说,着色器并不知道自己的数据从哪里来,它只是在每次运行时直接获取数据对应的输入变量。而我们必须自己完成着色管线的装配(在后面内容中你将了解它所表示的意思),然后才可以将应用程序中的数据与不同的OpenGL着色阶段互相关联。
在这个简单的例子中,只有一个名为vPosition的输入变量,它被声明为“in”。事实上,就算是这一行也包含了很多的内容。
我们最好从右往左来解读这一行的信息。
显而易见vPosition就是变量的名称。我们使用一个字符“v”作为这个顶点属性名称的前缀。这个变量所保存的是顶点的位置信息。
下一个字段是vec4,也就是vPosition类型。在这里它是一个GLSL的四维浮点数向量。GLSL中有非常多的数据类型,这会在第2章里详细介绍。
你也许已经注意到,我们在例1.1的程序中对每个顶点只设置了两个坐标值,但是在顶点着色器中却使用vec4来表达它。那么另外两个坐标值来自哪里?事实上OpenGL会用默认数值自动填充这些缺失的坐标值。而vec4的默认值为(0.0, 0.0, 0.0, 1.0),因此当仅指定了x和y坐标的时候,其他两个坐标值(z和w)将被自动指定为0和1。
在类型之前就是我们刚才提到的in字段,它指定了数据进入着色器的流向。正如你所见,这里还可以声明变量为out。不过我们在这里暂时还不会用到它。
最后的字段是layout(location = 0),它也叫做布局限定符(layout qualif?ier),目的是为变量提供元数据(meta data)。我们可以使用布局限定符来设置很多不同的属性,其中有些是与不同的着色阶段相关的。
在这里,设置vPosition的位置属性location为0。这个设置与init()函数的最后两行会共同起作用。
最后,在着色器的main()函数中实现它的主体部分。OpenGL的所有着色器,无论是处于哪个着色阶段,都会有一个main()函数。对于这个着色器而言,它所实现的就是将输入的顶点位置复制到顶点着色器的指定输出位置gl_Position中。后文中我们将会了解到OpenGL所提供的一些着色器变量,它们全部都是以gl_作为前缀的。
与之类似,我们也需要一个片元着色器来配合顶点着色器的工作。例1.3所示就是片元着色器的内容。
例1.3 triangles.cpp对应的片元着色器:triangles.frag
令人高兴的是,这里大部分的代码看起来很类似,虽然它们分别属于两个完全不同的着色器类型。我们还是需要声明版本号、变量以及main()函数。这里存在着一些差异,但是你依然可以看出,几乎所有着色器的基本结构都是这样的。
片元着色器的重点内容如下:
声明的变量名为fColor。没错,它使用了out限定符!在这里,着色器将会把fColor对应的数值输出,而这也就是片元所对应的颜色值(因此这里用到了前缀字符“f”)。
与我们在顶点着色器中的输入类似,在输出变量fColor的声明之前也需要加上限定符layout (location = 0)。片元着色器可以设置多个输出值,而某个变量所对应的输出结果就是通过location来设置的。虽然在这个着色器中我们只用到了一个输出值,但是我们还是有必要养成一个好习惯,给所有的输入和输出变量设置location。
设定片元的颜色。在这里,每个片元都会设置一个四维的向量。OpenGL中的颜色是通过RGB颜色空间来表示的,其中每个颜色分量(R表示红色,G表示绿色,B表示蓝色)的范围都是[0, 1]。留心的读者在这里可能会问,“但是这是一个四维的向量”。没错,OpenGL实际上使用了RGBA颜色空间,其中第四个值并不是颜色值。它叫做alpha值,专用于度量透明度。第4章将深入讨论这个话题,但是在现在,我们将它直接设置为1.0,这表示片元的颜色是完全不透明的。
片元着色器具有非常强大的功能,我们可以用它来实现非常多的算法和技巧。
我们已经基本完成了初始化的过程。init()中最后的两个函数指定了顶点着色器的变量与我们存储在缓存对象中数据的关系。这也就是我们所说的着色管线装配的过程,即将应用程序与着色器之间,以及不同着色阶段之间的数据通道连接起来。
为了输入顶点着色器的数据,也就是OpenGL将要处理的所有顶点数据,需要在着色器中声明一个in变量,然后使用glVertexAttribPointer()将它关联到一个顶点属性数组。
void glVertexAttribPointer(GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, const GLvoid *pointer);
设置index(着色器中的属性位置)位置对应的数据值。pointer表示缓存对象中,从起始位置开始计算的数组数据的偏移值(假设起始地址为0),使用基本的系统单位(byte)。size表示每个顶点需要更新的分量数目,可以是1、2、3、4或者GL_BGRA。type指定了数组中每个元素的数据类型(GL_BYTE、GL_UNSIGNED_BYTE、GL_SHORT、GL_UNSIGNED_SHORT、GL_INT、GL_UNSIGNED_INT、GL_FIXED、GL_HALF_FLOAT、GL_FLOAT或GL_DOUBLE)。normalized设置顶点数据在存储前是否需要进行归一化(或者使用glVertexAttribFourN*()函数)。stride是数组中每两个元素之间的大小偏移值(byte)。如果stride为0,那么数据应该紧密地封装在一起。
看起来我们有一大堆事情需要考虑,因为glVertexAttribPointer()其实是一个非常灵活的命令。只要在内存中数据是规范组织的(保存在一个连续的数组中,不使用其他基于节点的容器,比如链表),我们就可以使用glVertexAttribPointer()告诉OpenGL直接从内存中获取数据。在例子中,vertices里已经包含了我们所需的全部信息。表1-2所示为在这个例子里glVertexAttribPointer()中各个参数的设置及其意义。
希望上面的参数解释能够帮助你判断自己的数据结构所对应的数值。在后文中我们还会多次用到glVertexAttribPointer()来实现示例程序。
这里我们还用到了一个技巧,就是用glVertexAttribPointer()中的BUFFER_OFFSET宏来指定偏移量。这个宏的定义没有什么特别的,如下所示:
在以往版本的OpenGL当中并不需要用到这个宏,不过现在我们希望使用它来设置数据在缓存对象中的偏移量,而不是像glVertexAttribPointer()的原型那样直接设置一个指向内存块的指针。
在init()中,我们还有一项任务没有完成,那就是启用顶点属性数组。我们通过调用glEnableVertexAttribArray()来完成这项工作,同时将glVertexAttribPointer()初始化的属性数组指针索引传入这个函数。有关glEnableVertexAttribArray()的详细解释如下所示。
void glEnableVertexAttribArray(GLuint index);
void glDisableVertexAttribArray(GLuint index);
设置是否启用与index索引相关联的顶点数组。index必须是一个介于0到GL_MAX_VERTEX_ATTRIBS-1之间的值。
需要注意的是,我们刚刚使用glVertexAttribPointer()和glEnableVertexAttribArray()设置的状态是保存到在函数伊始就绑定好的顶点数组对象中的。而状态的改变是在绑定对象时私下完成的。如果希望设置一个顶点数组对象,但是不要把它绑定到设备环境中,那么可以调用glEnableVertexArrayAttrib()、glVertexArrayAttribFormat()和glVertexArrayVertexBuffers(),也就是通过直接状态访问(direct state access)的模式来完成相同的操作。
现在,我们只需要完成绘制的工作即可。
1.5.3 第一次使用OpenGL进行渲染
在设置和初始化所有数据之后,渲染的工作(在这个例子中)就非常简单了。display()函数只有4行代码,不过它所包含的内容在所有OpenGL程序中都会用到。下面我们先阅读其中的代码。
首先,我们要清除帧缓存的数据再进行渲染。清除的工作由glClearBufferfv()完成。
void glClearBufferfv(GLenum buffer, GLint drawbuffer, const GLf?loat *value);
清除当前绘制帧缓存中的指定缓存类型,清除结果为value。参数buffer设置了要清除的缓存类型,它可以是GL_COLOR、GL_DEPTH,或者GL_STENCIL。参数drawbuffer设置了要清除的缓存索引。如果当前绑定的是默认帧缓存,或者buffer设置为GL_DEPTH或GL_STENCIL,那么drawbuffer必须是0。否则它表示需要被清除的颜色缓存的索引。
参数value是一个数组的指针,其中包含了一个或者四个浮点数,用来设置清除缓存之后的颜色。如果buffer设置为GL_COLOR,那么value必须是一个最少四个数值的数组,以表示颜色值。如果buffer是GL_DEPTH或者GL_STENCIL,那么value可以是一个单独的浮点数,分别用来设置深度缓存或者模板缓存清除后的结果。
我们会在第4章中学习深度缓存(depth buffer)与模板缓存(stencil buffer)的内容,当然还有对颜色缓存(color buffer)的深入探讨。
在这个例子中,我们将颜色缓存清除为黑色。如果你想把视口中的画面清除为白色,可以调用glClearBufferfv()并设置value为一个数组的指针,且这个数组的四个浮点数都是1.0。
试一试 在triangles.cpp中修改black变量中的数值,观察颜色清除后的不同效果。
使用OpenGL进行绘制
例子中后面两行的工作是选择我们准备绘制的顶点数据,然后请求进行绘制。首先调用glBindVertexArray()来选择作为顶点数据使用的顶点数组。正如前文中提到的,我们可以用这个函数来切换程序中保存的多个顶点数据对象集合。
其次调用glDrawArrays()来实现顶点数据向OpenGL管线的传输。
void glDrawArrays(GLenum mode, GLint f?irst, GLsizei count);
使用当前绑定的顶点数组元素来建立一系列的几何图元,起始位置为f?irst,而结束位置为f?irst + count-1。mode设置了构建图元的类型,它可以是GL_POINTS、GL_LINES、GL_LINE_STRIP、GL_LINE_LOOP、GL_TRIANGLES、GL_TRIANGLE_STRIP、GL_TRIANGLE_FAN和GL_PATCHES中的任意一种。
glDrawArrays()函数可以被认为是更复杂的glDrawArraysInstancedBaseInstance()函数的一个简化版本,后者包含了更多的参数。我们会在3.4.2节予以介绍。
在这个例子中,我们使用glVertexAttribPointer()设置渲染模式为GL_TRIANGLES,起始位置位于缓存的0偏移位置,共渲染NumVertices个元素(这个例子中为6个),这样就可以渲染出独立的三角形图元了。我们会在第3章详细介绍所有的图元形状。
试一试 修改triangles.cpp让它渲染一个不同类型的几何图元,例如GL_POINTS或者GL_LINES。你可以使用上文中列出的任何一种图元,但是有些的结果可能会比较奇怪,此外GL_PATCHES类型是不会输出任何结果的,因为它是用于细分着色器的,参见第9章的内容。
就是这样!现在我们已经绘制了一些内容。而这些框架性质的代码已经可以很好地维护显示的结果了。
启用和禁用OpenGL的操作
在第一个例子当中有一个重要的特性并没有用到,但是在后文中我们会反复用到它,那就是对于OpenGL操作模式的启用和禁用。绝大多数的操作模式都可以通过glEnable()和glDisable()命令开启或者关闭。
void glEnable(GLenum capability);
void glDisable(GLenum capability);
glEnable()会开启一个模式,glDisable()会关闭它。有很多枚举量可以作为模式参数传入glEnable()和glDisable()。例如GL_DEPTH_TEST可以用来开启或者关闭深度测试;GL_BLEND可以用来控制融合的操作,而GL_RASTERIZER_DISCARD用于transform feedback过程中的高级渲染控制。
很多时候,尤其是我们用OpenGL编写的库需要提供给其他程序员使用的时候,可以根据自己的需要来判断是否开启某个特性,这时候可以使用glIsEnabled()来返回是否启用指定模式的信息。
GLboolean glIsEnabled(GLenum capability);
根据是否启用当前指定的模式,返回GL_TRUE或者GL_FALSE。