天天看点

《OpenGL编程指南》一1.5 第一个程序:深入分析

本节书摘来自华章出版社《opengl编程指南》一书中的第1章,第1.5节,作者 bill licea-kane ,更多章节内容可以访问云栖社区“华章计算机”公众号查看

为了了解示例程序从一开始是如何运行的,首先了解一下main()函数当中都发生了什么。前面的6行使用opengl utility toolkit初始化和打开了一个渲染用的窗口。这方面的详细介绍可以参见附录a,这里只介绍每一行的执行结果。

《OpenGL编程指南》一1.5 第一个程序:深入分析

第一个函数glutinit()负责初始化glut库。它会处理向程序输入的命令行参数,并且移除其中与控制glut如何操作相关的部分(例如设置窗口的大小)。glutinit()必须是应用程序调用的第一个glut函数,它会负责设置其他glut例程所必需的数据结构。

glutinitdisplaymode()设置了程序所使用的窗口的类型。在这个例子中只需要设置窗口使用rgba颜色空间(这会在第4章深入进行讨论)。除此之外,还可以给窗口设置更多的opengl特性,例如使用深度缓存或者动画效果。

glutinitwindowsize()设置所需的窗口大小。如果不想在这里设置一个固定值,也可以先查询显示设备的尺寸,然后根据计算机的屏幕动态设置窗口的大小。

后面的两个调用glutinitcontextversion()和glutinitcontextprofile()设置了我们所需的opengl环境(context)的类型—这是opengl内部用于记录状态设置和操作的数据结构。这个例子中使用opengl 4.3版本的核心模式(core profile)来创建环境。这个模式可以确保使用的只是opengl的最新特性,否则也可以选择另外一种兼容模式,这样自opengl 1.0版本以来的所有特性都可以在程序中使用。

随后的一个调用是glutcreatewindow(),它的功能和它的名字一致。如果当前的系统环境可以满足glutinitdisplaymode()的显示模式要求,这里就会创建一个窗口(此时会调用计算机窗口系统的接口)。只有glut创建了一个窗口之后(其中也包含创建opengl环境的过程),我们才可以使用opengl相关的函数。

继续这个例子的内容,接下来会调用glewinit()函数,它属于我们用到的另一个辅助库glew(opengl extension wrangler)。glew可以简化获取函数地址的过程,并且包含了可以跨平台使用的其他一些opengl编程方法。如果没有glew,我们可能还需要执行相当多的工作才能够运行程序。

到这里,我们已经完成了使用opengl之前的全部设置工作。在马上要介绍的init()例程中,我们将初始化opengl相关的所有数据,以便完成之后的渲染工作。

下一个例程是glutdisplayfunc(),它设置了显示回调(display callback),即glut在每次更新窗口内容的时候会自动调用的例程。这里给glut库传入display()这个函数的地址,后文会讨论其中的内容。glut可以使用一系列回调函数来处理诸如用户输入、重设窗口尺寸等不同的操作。附录a会详细地介绍glut库的内容。

main()函数中调用的最后一个函数是glutmainloop(),这是一个无限执行的循环,它会负责一直处理窗口和操作系统的用户输入等操作。举例来说,glutmainloop()会判断窗口是否需要进行重绘,然后它就会自动调用glutdisplayfunc()中注册的函数。特别要注意的是,glutmainloop()是一个无限循环,因此不会执行在它之后的所有命令。

下面将要讨论例1.1中的init()函数。首先再次列出与之相关的代码。

《OpenGL编程指南》一1.5 第一个程序:深入分析

初始化顶点数组对象

在init()中使用了不少函数和数据。在函数的起始部分,我们调用glgenvertexarrays()分配了顶点数组对象(vertex-array object)。opengl会因此分配一部分顶点数组对象的名称供我们使用,在这里共有numvaos个对象,即这个全局变量所指代的数值。glgenvertexarrays()的第二个参数返回的是对象名的数组,也就是这里的vaos。

我们对glgenvertexarrays()函数的完整解释如下:

void glgenvertexarrays(glsizei n, gluint *arrays);

返回n个未使用的对象名到数组arrays中,用作顶点数组对象。返回的名字可以用来分配更多的缓存对象,并且它们已经使用未初始化的顶点数组集合的默认状态进 行了数值的初始化。

我们会发现很多opengl命令都是glgen的形式,它们负责分配不同类型的opengl对象的名称。这里的名称类似c语言中的一个指针变量,我们必须分配内存并且用名称引用它之后,名称才有意义。在opengl中,这个分配的机制叫做绑定对象(bind an object),它是通过一系列glbind形式的opengl函数集合去实现的。在这个例子中,我们通过glbindvertexarray()函数创建并且绑定了一个顶点数组对象。

void glbindvertexarray(gluint array);

glbindvertexarray()完成了三项工作。如果输入的变量array非0,并且是glgenvertexarrays()所返回的,那么它将创建一个新的顶点数组对象并且与其名称关联起来。如果绑定到一个已经创建的顶点数组对象中,那么会激活这个顶点数组对象,并且直接影响对象中所保存的顶点数组状态。如果输入的变量array为0,那么opengl将不再使用程序所分配的任何顶点数组对象,并且将渲染状态重设为顶点数组的默认状态。

如果array不是glgenvertexarrays()所返回的数值,或者它已经被gldelete-vertexarrays()函数释放了,那么这里将产生一个gl_invalid_operation错误。

这个例子中,在生成一个顶点数组对象的名字之后,就会使用glbindvertexarray()将它绑定起来。在opengl中这样的对象绑定操作非常常见,但是我们可能无法立即了解它做了什么。当我们第一次绑定对象时(例如,第一次用指定的对象名作为参数调用glbind()),opengl内部会分配这个对象所需的内存并且将它作为当前对象,即所有后继的操作都会作用于这个被绑定的对象,例如,这里的顶点数组对象的状态就会被后面执行的代码所改变。在第一次调用glbind()函数之后,新创建的对象都会初始化为其默认状态,而我们通常需要一些额外的初始化工作来确保这个对象可用。

绑定对象的过程有点类似设置铁路的道岔开关。一旦设置了开关,从这条线路通过的所有列车都会驶向对应的轨道。如果我们将开关设置到另一个状态,那么所有之后经过的列车都会驶向另一条轨道。opengl的对象也是如此。总体上来说,在两种情况下我们需要绑定一个对象:创建对象并初始化它所对应的数据时;以及每次我们准备使用这个对象,而它并不是当前绑定的对象时。我们会在display()例程中看到后一种情况,即在程序运行过程中第二次调用glbindvertexarray()函数。

由于示例程序需要尽量短小,因此我们不打算做任何多余的操作。举例来说,在较大的程序里当我们完成对顶点数组对象的操作之后,是可以调用gldeletevertexarrays()将它释放的。

void gldeletevertexarrays(glsizei n, gluint *arrays);

删除n个在arrays中定义的顶点数组对象,这样所有的名称可以再次用作顶点数组。如果绑定的顶点数组已经被删除,那么当前绑定的顶点数组对象被重设为0(类似执行了glbindbuffer()函数,并且输入参数为0),而默认的顶点数组会变成当前对象。在arrays当中未使用的名称都会被释放,但是当前顶点数组的状态不会发生任何变化。

最后,为了确保程序的完整性,我们可以调用glisvertexarray()检查某个名称是否已经被保留为一个顶点数组对象了。

glboolean glisvertexarray(gluint array);

如果array是一个已经用glgenvertexarrays()创建且没有被删除的顶点数组对象的名称,那么返回gl_true。如果array为0或者不是任何顶点数组对象的名称,那么返回gl_false。

对于opengl中其他类型的对象,我们都可以看到类似的名为gldelete和glis的例程。

分配顶点缓存对象

顶点数组对象负责保存一系列顶点的数据。这些数据保存到缓存对象当中,并且由当前绑定的顶点数组对象管理。我们只有一种顶点数组对象类型,但是却有很多种类型的对象,并且其中一部分对象并不负责处理顶点数据。正如前文中所提到的,缓存对象就是opengl服务端分配和管理的一块内存区域,并且几乎所有传入opengl的数据都是存储在缓存对象当中的。

顶点缓存对象的初始化过程与顶点数组对象的创建过程类似,不过需要有向缓存中添加数据的一个过程。

首先,我们需要创建顶点缓存对象的名称。我们调用的还是glgen*形式的函数,即glgenbuffers()。在这个例子中,我们分配numvbos个对象(vbo即vertex buffer objects)到数组buffers当中。以下是glgenbuffers()的详细介绍。

void glgenbuffers(glsizei n, gluint *buffers);

返回n个当前未使用的缓存对象名称,并保存到buffers数组中。返回到buffers中的名称不一定是连续的整型数据。

这里返回的名称只用于分配其他缓存对象,它们在绑定之后只会记录一个可用的状态。

0是一个保留的缓存对象名称,glgenbuffers()永远都不会返回这个值的缓存对象。

当分配缓存的名称之后,就可以调用glbindbuffer()来绑定它们了。由于opengl中有很多种不同类型的缓存对象,因此绑定一个缓存时,需要指定它所对应的类型。在这个例子中,由于是将顶点数据保存到缓存当中,因此使用gl_array_buffer类型。缓存对象的类型现在共有8种,分别用于不同的opengl功能实现。本书后面的章节会分别讨论各种类型的对应操作。

glbindbuffer()函数的详细介绍如下。

void glbindbuffer(glenum target, gluint buffer);

指定当前激活的缓存对象。target必须设置为以下类型中的一个:gl_array_buffer、gl_element_array_buffer、gl_pixel_pack_buffer、gl_pixel_unpack_buffer、gl_copy_read_buffer、gl_copy_write_buffer、gl_transform_feedback_buffer和gl_uniform_buffer。buffer设置的是要绑定的缓存对象名称。

glbindbuffer()完成了三项工作:1)如果是第一次绑定buffer,且它是一个非零的无符号整型,那么将创建一个与该名称相对应的新缓存对象。2) 如果绑定到一个已经创建的缓存对象,那么它将成为当前被激活的缓存对象。3)如果绑定的buffer值为0,那么opengl将不再对当前target应用任何缓存对象。

所有的缓存对象都可以使用gldeletebuffers()直接释放。

void gldeletebuffers(glsizei n, const gluint *buffers);

删除n个保存在buffers数组中的缓存对象。被释放的缓存对象可以重用(例如,使用glgenbuffers())。

如果删除的缓存对象已经被绑定,那么该对象的所有绑定将会重置为默认的缓存对象,即相当于用0作为参数执行glbindbuffer()的结果。如果试图删除不存在的缓存对象,或者缓存对象为0,那么将忽略该操作(不会产生错误)。

我们也可以用glisbuffer()来判断一个整数值是否是一个缓存对象的名称。

glboolean glisbuffer(gluint buffer);

如果buffer是一个已经分配并且没有释放的缓存对象的名称,则返回gl_true。如果buffer为0或者不是缓存对象的名称,则返回gl_false。

将数据载入缓存对象

初始化顶点缓存对象之后,我们需要把顶点数据从对象传输到缓存对象当中。这一步是通过glbufferdata()例程完成的,它主要有两个任务:分配顶点数据所需的存储空间,然后将数据从应用程序的数组中拷贝到opengl服务端的内存中。

有可能在很多不同的场景中多次应用glbufferdata(),因此我们有必要在这里深入了解它的过程,尽管我们在这本书中还会多次遇到这个函数。首先,glbufferdata()的详细定义介绍如下。

void glbufferdata(glenum target, glsizeiptr size, const glvoid *data, glenum usage);

在opengl服务端内存中分配size个存储单元(通常为byte),用于存储数据或者索引。如果当前绑定的对象已经存在了关联的数据,那么会首先删除这些数据。

对于顶点属性数据,target设置为gl_array_buffer;索引数据为gl_element_array_buffer;opengl的像素数据为gl_pixel_unpack_buffer;对于从opengl中获取的像素数据为gl_pixel_pack_buffer;对于缓存之间的复制数据为gl_copy_read_buffer和gl_copy_write_buffer;对于纹理缓存中存储的纹理数据为gl_texture_buffer;对于通过transform feedback着色器获得的结果设置为gl_transform_feedback_buffer;而一致变量要设置为gl_uniform_buffer。

size表示存储数据的总数量。这个数值等于data中存储的元素的总数乘以单位元素存储空间的结果。

data要么是一个客户端内存的指针,以便初始化缓存对象,要么是null。如果传入的指针合法,那么将会有size大小的数据从客户端拷贝到服务端。如果传入null,那么将保留size大小的未初始化的数据,以备后用。

usage用于设置分配数据之后的读取和写入方式。可用的方式包括gl_stream_draw、gl_stream_read、gl_stream_copy、gl_static_draw、gl_static_read、gl_static_copy、gl_dynamic_draw、gl_dynamic_read和gl_dynamic_copy。

如果所需的size大小超过了服务端能够分配的额度,那么glbufferdata()将产生一个gl_out_of_memory错误。如果usage设置的不是可用的模式值,那么将产生gl_invalid_value错误。

一下子理解这么多的内容可能有点困难,但是这个函数在后面的学习中会多次出现,因此有必要在本书的开始部分就详细地对它做出讲解。

在上面的例子中,直接调用了glbufferdata()。因为顶点数据就保存在一个vertices数组当中。如果需要静态地从程序中加载顶点数据,那么我们可能需要从模型文件中读取这些数值,或者通过某些算法来生成。由于我们的数据是顶点属性数据,因此设置这个缓存为gl_array_buffer,即指定它的第一个参数。我们还需要指定内存分配的大小(单位为byte),因此直接使用sizeof(vertices)来完成计算。最后,我们需要指定数据在opengl中使用的方式。因为我们只是用它来绘制几何体,不会在运行时对它做出修改,所以设置glbufferdata()的usage参数为gl_static_draw。

除此之外,usage还有一系列的选项可用,第3章会详细地介绍它们。

如果我们仔细观察vertices数组中的数值,就会发现它们在x和y方向都被限定在[–1, 1]的范围内。实际上,opengl只能够绘制坐标空间内的几何体图元。而具有该范围限制的坐标系统也称为规格化设备坐标系统(normalized device coordinate,ndc)。这听起来好像是一个巨大的限制,但实际上并不是问题。第5章会介绍将3维空间中的复杂物体映射到规格化设备坐标系中的数学方法。在这个例子中直接使用ndc坐标,不过实际上我们通常会使用一些更为复杂的坐标空间。

现在,我们已经成功地创建了一个顶点数组对象,并且将它传递到缓存对象中。下一步,我们要设置程序中用到的着色器了。

初始化顶点与片元着色器

对于每一个opengl程序,当它所使用的opengl版本高于或等于3.1时,都需要指定至少两个着色器:顶点着色器和片元着色器。在这个例子中,我们通过一个辅助函数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

《OpenGL编程指南》一1.5 第一个程序:深入分析

没错,它的内容只有这么多。事实上这就是我们之前所说的传递着色器(pass-through shader)的例子。它只负责将输入数据拷贝到输出数据中。不过即便如此,我们也还是要展开深入讨论。

第一行“#version 430 core”指定了我们所用的opengl着色语言的版本。这里的“430”表示我们准备使用opengl 4.3对应的glsl语言。这里的命名规范是基于opengl 3.3版本的。在那之前的opengl版本中,版本号所用的数字是完全不一样的(详细介绍参见第2章)。这里的“core”表示我们将使用opengl核心模式(core profile),这与之前glut的函数glutinitcontextprofile()设置的内容应当一致。每个着色器的第一行都应该设置“#version”,否则系统会假设使用“110”版本,但是这与opengl核心模式并不兼容。我们在本书中只针对330版本及以上的着色器以及它的特性进行讲解;如果这个版本号不是最新的版本,那么程序的可移植性应该会更好,但是你将无法使用最新的系统特性。

下一步,我们分配了一个着色器变量。着色器变量是着色器与外部世界的联系所在。换句话说,着色器并不知道自己的数据从哪里来,它只是在每次运行时直接获取数据对应的输入变量。而我们必须自己完成着色管线的装配(在后面内容中你将了解它所表示的意思),然后才可以将应用程序中的数据与不同的opengl着色阶段互相关联。

在这个简单的例子中,只有一个名为vposition的输入变量,它被声明为“in”。事实上,就算是这一行也包含了很多的内容。

我们最好从右往左来解读这一行的信息。

显而易见vposition就是变量的名称。我们使用一个字符“v”作为这个顶点属性名称的前缀。这个变量所保存的是顶点的位置信息。

下一个字段是vec4,也就是vposition类型。在这里它是一个glsl的四维浮点数向量。glsl中有非常多的数据类型,这会在第2章里详细介绍。

你也许已经注意到,我们在例1.1的程序中对每个顶点只设置了两个坐标值,但是在顶点着色器中却使用vec4来表达它。那么另外两个坐标值来自哪里?事实上opengl会用默认数值自动填充这些缺失的坐标值。而vec4的默认值为(0, 0, 0, 1),因此当仅指定了x和y坐标的时候,其他两个坐标值(z和w)将被自动指定为0和1。

在类型之前就是我们刚才提到的in字段,它指定了数据进入着色器的流向。正如你所见,这里还可以声明变量为out。不过我们在这里暂时还不会用到它。

最后的字段是layout(location = 0),它也叫做布局限定符(layout qualifier),目的是为变量提供元数据(meta data)。我们可以使用布局限定符来设置很多不同的属性,其中有些是与不同的着色阶段相关的。

在这里,设置vposition的位置属性location为0。这个设置与init()函数的最后两行会共同起作用。

最后,在着色器的main()函数中实现它的主体部分。opengl的所有着色器,无论是处于哪个着色阶段,都会有一个main()函数。对于这个着色器而言,它所实现的就是将输入的顶点位置复制到顶点着色器的指定输出位置gl_position中。后文中我们将会了解到opengl所提供的一些着色器变量,它们全部都是以gl_作为前缀的。

与之类似,我们也需要一个片元着色器来配合顶点着色器的工作。例1.3所示就是片元着色器的内容。

例1.3 triangles.cpp对应的片元着色器:triangles.frag

《OpenGL编程指南》一1.5 第一个程序:深入分析

令人高兴的是,这里大部分的代码看起来很类似,虽然它们分别属于两个完全不同的着色器类型。我们还是需要声明版本号、变量以及main()函数。这里存在着一些差异,但是你依然可以看出,几乎所有着色器的基本结构都是这样的。

片元着色器的重点内容如下:

声明的变量名为fcolor。没错,它使用了out限定符!在这里,着色器将会把fcolor对应的数值输出,而这也就是片元所对应的颜色值(因此这里用到了前缀字符“f”)。

设定片元的颜色。在这里,每个片元都会设置一个四维的向量。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()中各个参数的设置及其意义。

表1-2 判断glvertexattribpointer()中参数的例子

《OpenGL编程指南》一1.5 第一个程序:深入分析

希望上面的参数解释能够帮助你判断自己的数据结构所对应的数值。在后文中我们还会多次用到glvertexattribpointer()来实现示例程序。

这里我们还用到了一个技巧,就是用glvertexattribpointer()中的buffer_offset宏来指定偏移量。这个宏的定义没有什么特别的,如下所示。

《OpenGL编程指南》一1.5 第一个程序:深入分析

在以往版本的opengl当中并不需要用到这个宏,不过现在我们希望使用它来设置数据在缓存对象中的偏移量,而不是像glvertexattribpointer()的原型那样直接设置一个指向内存块的指针。

在init()中,我们还有一项任务没有完成,那就是启用顶点属性数组。我们通过调用glenablevertexattribarray()来完成这项工作,同时将glvertexattribpointer()初始化的属性数组指针索引传入这个函数。有关glenablevertexattribarray()的详细解释如下所示。

void glenablevertexattribarray(gluint index);

void gldisablevertexattribarray(gluint index);

设置是否启用与index索引相关联的顶点数组。index必须是一个介于0到gl_max_vertex_attribs-1之间的值。

现在,我们只需要完成绘制的工作即可。

在设置和初始化所有数据之后,渲染的工作(在这个例子中)就非常简单了。display()函数只有4行代码,不过它所包含的内容在所有opengl程序中都会用到。下面我们先阅读其中的代码。

《OpenGL编程指南》一1.5 第一个程序:深入分析

首先,我们要清除帧缓存的数据再进行渲染。清除的工作由glclear()完成。

void glclear(glbitfield mask);

清除指定的缓存数据并重设为当前的清除值。mask是一个可以通过逻辑“或”操作来指定多个数值的参数,可用的数值如表1-3所示。

我们会在第4章中学习深度缓存(depth buffer)与模板缓存(stencil buffer)的内容,当然还有对颜色缓存(color buffer)的深入探讨。

现在你可能想知道,glclear()会使用一个什么样的清除数值。在这个例子中,我们直接使用opengl默认的清除颜色,即黑色。如果要改变清除颜色的数值,可以使用glclearcolor()。

《OpenGL编程指南》一1.5 第一个程序:深入分析

设置当前使用的清除颜色值,用于rgba模式下对颜色缓存的清除工作。(参见第4章有关rgba模式的内容)这里的red、green、blue、alpha都会被截断到[0, 1]的范围内。默认的清除颜色是(0, 0, 0, 0),在rgba模式下它表示黑色。

清除颜色本身也是opengl状态机制的一个例子,它的数值会一直保留在当前opengl环境当中。opengl有一个庞大的状态量列表(详细的介绍参见附录d),当创建一个新的opengl环境时,所有的状态量都会被初始化为默认数值。因为opengl会保留所有更改的状态值,所以我们可以减少设置状态数值的次数。

举例说明清除颜色的用法,比如我们希望将当前视口的背景颜色设置为白色,那么需要调用glclearcolor(1, 1, 1, 1)。但是我们应该在什么时候调用这个函数呢?当然,我们可以直接在display()函数中调用glclear()之前调用它。但是这样的话,除了第一次进入循环之时,其他所有对glclearcolor()的调用都是多余的—因为opengl在每次渲染时都会重复设置清除颜色的状态值为白色。另一个效率更高的方法是在init()函数中设置清除颜色。事实上,这样我们就可以避免冗余的状态切换;所有在程序运行时不会发生变化的数值都应该在init()中设置。当然,冗余的函数调用本身并没有危害,但是它会造成程序运行速度稍微变慢。

试一试 在triangles.cpp中添加对于glclearcolor()的调用。

使用opengl进行绘制

例子中后面两行的工作是选择我们准备绘制的顶点数据,然后请求进行绘制。首先调用glbindvertexarray()来选择作为顶点数据使用的顶点数组。正如前文中提到的,我们可以用这个函数来切换程序中保存的多个顶点数据对象集合。

其次调用gldrawarrays()来实现顶点数据向opengl管线的传输。

使用当前绑定的顶点数组元素来建立一系列的几何图元,起始位置为first,而结束位置为first + count-1。mode设置了构建图元的类型,它可以是gl_points、gl_lines、gl_line_strip、gl_line_loop、gl_triangles、gl_triangle_strip、gl_triangle_fan和gl_patches中的任意一种。

在这个例子中,我们使用glvertexattribpointer()设置渲染模式为gl_triangles,起始位置位于缓存的0偏移位置,共渲染numvertices个元素(这个例子中为6个),这样就可以渲染出独立的三角形图元了。我们会在第3章详细介绍所有的图元形状。

试一试 修改triangles.cpp让它渲染一个不同类型的几何图元,例如gl_points或者gl_lines。你可以使用上文中列出的任何一种图元,但是有些的结果可能会比较奇怪,此外gl_patches类型是不会输出任何结果的,因为它是用于细分着色器的,参见第9章的内容。

最后在display()函数中调用glflush(),即强制所有进行中的opengl命令立即完成并传输到opengl服务端处理。在后文中我们很快就会把glflush()替换为另一个更为平滑的命令,但是这样的话还需要对当前这个例子进行更多的设置。

强制之前的opengl命令立即执行,这样就可以保证它们在一定时间内全部完成。

深入理解

在你的opengl编程生涯的某个时刻,你可能会被问及(或者自问)“这需要多少时间”?“这”可能是渲染一个物体、绘制一整个场景,或者opengl能够实现的其他操作。为了能够精确地度量和执行自己的任务,我们有必要了解opengl是在什么时候完成这些操作的。

上文所述的glflush()命令看起来像是一个正确的答案,但是它不是。事实上glflush()只是强制所有运行中的命令送入opengl服务端而已,并且它会立即返回—它并不会等待所有的命令完成,而等待却是我们所需要的。为此,我们需要使用glfinish()命令,它会一直等待所有当前的opengl操作完成后,再返回。

强制所有当前的opengl命令立即执行,并且等待它们全部完成。

 你最好只是在开发阶段使用glfinish()—如果你已经完成了开发的工作,那么最好去掉对这个命令的调用。虽然它对于判断opengl命令运行效率很有帮助,但是对于程序的整体性能却有着相当的拖累。

启用和禁用opengl的操作

在第一个例子当中有一个重要的特性并没有用到,但是在后文中我们会反复用到它,那就是对于opengl操作模式的启用和禁用。绝大多数的操作模式都可以通过glenable()和gldisable()命令开启或者关闭。

glenable()会开启一个模式,gldisable()会关闭它。有很多枚举量可以作为模式参数传入glenable()和gldisable()。例如gl_depth_test可以用来开启或者关闭深度测试;gl_blend可以用来控制融合的操作,而gl_rasterizer_discard用于transform feedback过程中的高级渲染控制。

很多时候,尤其是我们用opengl编写的库需要提供给其他程序员使用的时候,可以根据自己的需要来判断是否开启某个特性,这时候可以使用glisenabled()来返回是否启用指定模式的信息。

glboolean glisenabled(glenum capability);

根据是否启用当前指定的模式,返回gl_true或者gl_false。

继续阅读