1 简介
到目前为止,已经介绍完了OpenGL的主要知识点,我们已经能够完成一些复杂的OpenGL程序。但是可能有时我们会发现某个OpenGL程序不能正常工作,或者它的渲染结果和我们预想的并不相同,或者它的渲染性能过低。本章我们将学习如何调试OpenGL程序,以及如何提升程序的性能,使得我们能够写出高质量的程序,并且能够在尽可能多的平台上运行。
本章所包含的知识点如下:
- 当OpenGL程序并未按照我们预期的效果工作时,如何定位问题
- 如何尽可能高的提高OpenGL程序的性能
- 如何确定我们充分利用了OpenGL的高性能
2 调试程序
在写程序的时候常出现一种场景,你设计了一个巧妙的算法来渲染场景,但是当你设置好所有的纹理对象,顶点数据,帧缓存对象以及其他必要数据,你调用了渲染质量,结果你却并未观察到任何效果,或者你看到了异常的效果。本章中我们会介绍两个强大的资产,它们可以帮助你调试你的程序。第一个是调试上下文(Debug Context),它是OpenGL提供的一种模式,使得你可以检查调用的API并给相应的反馈。第二个是一个免费的调试工具。使用这些工具运行你的程序可以让你更清楚其内部的行为,甚至一些工具还能够提供一些建议使得程序运行更快。
1.1 调试上下文
当你创建OpenGL上下文的时候你可以额外指定上下文的模式,其中一种就是调试上下文。当你创建这种类型的上下文时,OpenGL会在程序、程序到驱动器的路径、以及GPU硬件之间添加额外的层。这些额外的层会执行严格的错误检查,分析函数参数,记录错误等相对于默认类型的OpenGL上下文而言额外操作。这些操作通常都很消耗硬件性能,因此并不建议在生产环境使用这种方式。创建调试类型的OpenGL的方法在不同的平台上会有所差异,并且调试程序相关的API需要OpenGL4.3及其以上的版本支持,而MacOS只支持到OpenGL4.1,这里暂不做详细介绍。
2 性能优化
当你成功编写了一个程序后,你可能需要从如下两方面来优化程序性能,提升用户体验。
- 降低能够运行该程序的最低硬件配置,从而获得更多的用户
- 在给定的每帧时间内应用更高级的特效,渲染更多的模型,应用更复杂的着色器
在本小节中,我们将会结束一些性能分析工具来分析程序中各指令耗时的详细情况,并给出一些能够建议使你能够更合理的使用计算资源。最后我们会讲到一些提升程序性能的示例。
2.1 性能分析工具
接下来我们会讲到一些免费的性能分析工具,这些工具我们能够方便下载并使用。如GPUView是微软提供的窗口性能工具(Windows Performance Toolkit)的一部分,以及AMD开发的GPU PerfStudio2,这两个工具都能从对应的供应商网站上获取。此外在MacOS平台上,XCode也提供了类似的工具用于性能分析,这个可以在苹果的开发者网站获得更多信息。
2.2 提升渲染效率
为了使程序能有更高的效率,我们需要压缩OpenGL驱动器消耗的时间,从而使GPU能够获得更多的直接执行渲染指令。
2.2.1 从OpenGL获取状态和数据
通常星空下从OpenGL获取状态或者读取数据并不是一个好主意,这些操作会直接影响图像渲染管道效率。这些操作包含使用函数glReadPixels()
从帧缓存中读取数据,读取遮蔽查询(Occlusion Query)、转换反馈查询以及其他依赖于渲染操作的查询对象的结果,或者等待未完成的围栏(Fence)信号。最重要一点是,在任何情况下尽量避免调用函数glFinish()
。
另外还有一些操作尽管对程序的性能影响没有上面列举的情况严重,但是我们仍应该尽量避免。诸如函数glGetError()
、glGetIntegerv()
、glGetUniformLocation()
等可能并不会影响OpenGL图形渲染管道等性能,但是它们可能在多线程的环境下对程序的性能有负面效果。因此尽量少调用签名中带有Get
或者Is
关键字的函数。另外频繁的创建和销毁OpenGL对象的操作也应该尽量避免,即尽可能少的调用签名中带有Gen
的函数。
对于需要从GPU显存读取数据到内存中的场景,下面会介绍多个策略来应对这种需求,它们并不会影响图像渲染管道的性能。它们之间的大部分方法都采用通过延迟CPU的读数据逻辑时机,例如读取上一帧的数据这种手段,使得OpenGL有足够的时间去准备好所需要的数据,并且不会阻塞当前的任务。
第一个场景是使用函数glReadPixels()
从帧缓存中读取数据。如果我们读取数据的目的是为了在OpenGL中使用,则只需要将缓存对象绑定到靶点GL_PIXEL_PACK_BUFFER
,再向该靶点写入数据,最后将其绑定到你需要的任意靶点以完成渲染任务。当你确实需要将这些数据读取到内存中时,你可以通过如下方式实现。
最简单的方式是直接调用函数glReadPixels()
并传人一个内存地址的指针,OpenGL会将图像数据填充到这段内存中。在几乎所有场景中,这种操作都会在OpenGL的图像管道中形成下图所示的“气泡“。
上图左侧部分表示的是程序正常运行时GPU和CPU的工作状态,可以看到它们的效率都正常。当调用函数glReadPixels()
后,CPU和GPU开始同步,可以明显看出此时GPU的性能开始下降,在指令执行对了中出现了大量间隙。
当然我们可以将一个缓存对象绑定到靶点GL_PIXEL_PACK_BUFFER
,然后再调用函数glReadPixels()
将数据读取到这个缓存对象中,最后再调用函数glMapBufferRange()
将数据读取到内存中,这种操作策略对应的GPU和CPU工作状态如上图右侧。可以看到在GPU的执行队列中有一些明显的改善,但是在GPU的执行队列中仍然存在了大量的间隙,这并不是我们想看到的现象。
对于使用函数glReadPixels()
将数据读到了一个被绑定到靶点GL_PIXEL_PACK_BUFFER
的缓存对象这种方式,尽管它使得GPU能够连续处理渲染任务,和将数据拷贝到缓存的任务,但是当调用函数glMapBufferRange()
后,程序就会被阻塞,直至数据被完整拷贝到缓存对象中。这是一个非常糟糕的情况,不仅这种方式使GPU无法发挥最大性能,并且实际上还会额外处理一些任务。对程序进行一些改善后得到了如下的性能监测截图。
在上图左侧,我们仍然立即获取当前帧的图像数据,如图所示这样会使得GPU和CPU都以较低的效率工作。紧接着我们改变了数据读取策略,我们仍然使用函数glReadPixels()
读取当前帧的数据到缓存对象中,但是我们使用函数glMapBufferRange()
映射上一帧获得的缓存对象。通过创建多个缓存对象,并且延时执行GPU和CPU同步的操作,使得GPU有更多的时间能够执行渲染指令,从而提高程序渲染性能。尽管在上图中的执行队列中仍然存在一些不平整现象,但是至少我们让GPU持续工作,这样对程序性能不会造成太严重的影响。
2.2.2 高效缓存映射
当你使用函数glBufferData()
为某个缓存对象分配了显存空间后,你可以使用函数glMapBuffer()
将正片显存地址映射到内存中。在使用这个函数时,你必须意识到如下几点。第一你是否只是想重写其中的部分数据,这意味着OpenGL必须保证剩余部分的数据完整。第二是这个缓存对象可能非常大,OpenGL不能够在内存中找到一个足够大的连续内存。第三,如果你想向这个缓存数据中写入数据,此时OpenGL会等待其他向该缓存写入数据的渲染指令执行完毕,再将找到的内存地址返回给你,或者OpenGL也可能维护多个数据拷贝,并将其中的1个拷贝的捏成地址返回。
为了处理这些问题,我们可以使用函数glMapBufferRange()
,该函数允许我们只映射缓存对象的部分数据到内存中,并且提供了额外的参数和一些标记使我们能够控制数据是如何映射的,以及在进行内存映射时OpenGL内部的同步逻辑,该函数原型如下。
void *glMapBufferRange(GLenum target,
GLintptr offset,
GLsizeiptr length,
GLbitfield access);
参数target
表示需要映射到哪一个缓存绑定点,改参数应该和想要获取的缓存对象的绑定靶点相同。参数offset
和length
表示需要映射的显存地址偏移量和长度,它们的单位都是字节。参数access
对显存映射行为实现更精细的控制,其可能的取值如下。
取值(GL_MAP_*) | 含义 |
---|---|
READ_BIT | 返回的内存指针用于读取显存中的数据 |
WRITE_BIT | 返回的内存指针用于向显存中写数据 |
INVALIDATE_RANGE_BIT | OpenGL可以丢弃在被映射范围内的显存数据,在这个范围内的显存数据都是未定义的,除非通过基于CPU的程序更新 |
INVALIDATE_BUFFER_BIT | OpenGL可以丢弃整个缓存的显存数据,整个缓存数据都是未定义的,除非通过基于CPU的程序更新 |
FLUSH_EXPLICIT_BIT | 必须通过函数glFlushMappedBufferRange() 显示更新显存中的数据,如果该值未指定,将在函数glUnmapBuffer() 被调用时更新显存数据 |
UNSYNCHRONIZED_BIT | 通知OpenGL不需等待被访问的缓存对象等待之前的渲染指令执行完毕,而是直接返回一个内存指针 |
使用逻辑与符号连(|)接上面的这些值可以实现多选,需要注意的是使用改函数的时候需要小心设置参数,否则会造成程序崩溃。如果在执行内存映射操作后,不再需要显存中的数据,请最好设置GL_MAP_INVALIDATE_RANGE_BIT
和GL_MAP_INVALIDATE_BUFFER_BIT
中的一个。
在调用该函数时,如果你只想更新缓存中的部分数据,但是你在当前时机并不知道更新的范围,你可以设置标记GL_MAP_FLUSH_EXPLICIT_BIT
。设置该函数后你必须显示调用函数glFlushMappedBufferRange()
才能够更新缓存数据,其原型如下。
GLvoid glFlushMappedBufferRange(GLenum target,
GLintptr offset,
GLsizeiptr length);
另外当你想更新一个缓存对象中多个独立片段的数据,并且你不想多次调用函数glMapBufferRange()
,你也可以使用函数glFlushMappedBufferRange()
替代。另外当你想要映射一个非常大的缓存对象时,使用该函数也是一个比较好的选择。这样做可能产生一些额外的性能开销,如多次从文件中读取数据。在设置该标记时,你必须小心,错误的函数glFlushMappedBufferRange()
调用可能会造成数据更新失败。
参数GL_MAP_UNSYNCHRONIZED_BIT
可以使得函数glMapBufferRange()
的调用立即返回,在未设置该标记时,如果当前映射的缓存对象在之前的调用中还有渲染指令向其中写入数据,则该函数会阻塞线程,直至数据写入完毕。当该标记被开启时,你需要自己来管理OpenGL对象的同步逻辑。OpenGL提供了很多机制来实现同步操作,如调用函数glFinish()
,和使用围栏(Fences)信号等。需要注意的是尽管函数glFinish()
能够处理同步逻辑,但是除非没有其他选择,尽量不要使用该函数。
2.2.3 使用OpenGL提供的特性
OpenGL是一个庞大的API接口集合,其中包含大量的特性,其中有些特性可以完成类似的工作,但是在这些特性之间也有性能差异。这中组成的有个优势就是你坑过很容易的快速搭建一个简单的OpenGL程序,这样做的劣势就是如果你想要使你的程序能够利用所有的高级特性,你需要大量的知识储备。
例如OpenGL提供了多种容器对象,这些对象表示了一系列的状态集合。例如顶点数组对象,帧缓存对象,转换反馈对象。简单的讲,就是尽量使用容器对象,而不是逐个修改大量OpenGL对象的状态。例如,一个顶点数组对象包含了关联到管道前端的所有顶点数组状态。其中包括真正存储数据的缓存对象绑定状态,以及这些缓存对象内顶点数据的格式,内存步长和位移值,以及顶点属性的开启禁用状态。你可以通过调用函数glBindVertexArray
切换顶点数组对象。
同样的,帧缓存对象包装了所有和帧缓存相关的状态,如当前帧缓存的颜色、深度和模版附件。使用多个帧缓存对象并做不同的配置,在渲染任务开始之前切换帧缓存对象的方式比只使用一个帧缓存对象,在渲染任务开始之前重新去配置其中的各种附件效率要高很多。
转换反馈对象包装了所有和转换反馈阶段相关的状态,它不仅是在调用诸如函数glDrawTransformFeedback()
的必要对象,另外使用函数glBindTransformFeedback()
来切换当前使用的帧缓存对象也比重新配置所有相关的状态效率更高。
2.2.4 使用合理的数据格式
尽管在顶点着色器中的输入,或者纹理查询函数的返回值都是浮点型的,这并不意味着我们需要在内存或者显存中以浮点格式存储它们。在很多场景中,选用更小的数据格式足够表现出我们想要的效果。使用不合理的数据格式可能会造成以下影响。
程序会比消耗更多的不必要的显存空间,这意味着OpenGL可能不能给把数据存储在最优的位置,或者更糟糕的情况下无法为OpenGL对象分配内存空间。
OpenGL需要读取到缓存中的数据越多,缓存、内存控制器等资源的压力越大,这也可能影响性能。另外,GPU和内存的频繁连接将会形成更多的显存访问,这会加大设备的电力消耗。
例如顶点数据(通常以模型坐标系为参考坐标系)没有必要使用浮点格式精度,在预处理阶段,尝试对数据进行标准化处理,使其被映射到区间[-1, 1]之间。这样就可以在显存中存储有符号的标准化数据,例如在调用函数glVertexAttribPointer()
时将数据格式设置为GL_SHORT
,并将参数normalized
设置为GL_TRUE
。接着你可以在构建模型矩阵时再应用对应的缩放因子还原顶点坐标,这样每个顶点的每个分量之需要使用16位的存储空间,而不是32位的存储空间。并且这种方式比使用16位的浮点格式有更高的精度。
此外,对于基于模型坐标系的顶点坐标,其w分量通常总为1。因此我们只需要存储xyz分量,同样的策略对于法向量和切向量的顶点属性也适用。另外法向量和切向量所需要的精度并不高,通常10位的存储空间即可,因此我们可以使用一些包装数据格式。如在调用函数glVertexAttribPointer()
时将数据格式参数type
设置为GL_INT_2_10_10_10_REV
,同样此时我们需要使用标准化数据格式,需要将参数GL_TRUE
,则原始数据将被截取到区间[-1, 1]。需要注意到这种封装格式带关键字REV,及内存最高两位存储w分量,接下来的3个10位分别存储z、y和x分量。
在使用法线纹理贴图时,我们还可以进一步优化。因为存储到法向量都位于切线空间,它们的指向都背离曲面向外,它们的z轴分量总是正值。通常情况下法向量纹理中存储的都是单位向量,即它们满足如下关系。
因此我们可以在法向量纹理中仅存储x和y分量,并在片段着色器中计算出z分量,并重建完整的法向量。这种方式通过牺牲算法和逻辑单元(Arithmetic and Logic Uinit, ALU)性能换取纹理性能。通常情况下,图像处理器执行简单的数学运算比占用更多的存储带宽更经济。法向量贴图的一个高效的数据格式包含两个8位有符号的标准化数据分量。
使用压缩格式的法向量纹理通常不会有太大的收益,一些纹理格式被设计用于处理普通的数据,但是法向量数据通常看上去不连续,因此不会有太好的效果。然而,对于如漫射和反色系数纹理压缩纹理格式能够发挥不错的效果。因此在使用这些贴图时,尽量挑选合适的纹理压缩格式。
上文聚焦在OpenGL可能读取到的数据格式,同样的对于OpenGL写入的数据格式,我们也需要注意。例如当你想使用离屏渲染的方式绘制一个HDR类型的图片时,你可能想要使用GL_RGBA32F
格式的颜色附件。然而,这样会消耗很大的显存空间,并且会占据大量的存储带宽。如果你不需要透明度信息,则使用颜色格式GL_RGB32F
,同样的,如果你不需要这样高的精度,考虑使用颜色格式GL_RGBA16F
。
同样的从着色器中,或者使用转换反馈向图片中写入数据时,也应该注意数据格式问题。尽管你并不需要像关注通过帧缓存写入数据和其他管道前端读取数据那样严格,但是在大多数情况下,尽量使用GLSL内的数据包装函数将数据打包成整型数据存储到图像或者缓存中。
2.2.5 着色器编译性能
OpenGL不仅处理图像渲染任务,它还包含一个完整的编译环境。显然,到目前为止我们所有的OpenGL程序都使用了这个编译器,现在你需要意识到GLSL非常复杂,所以GLSL编译器必须处理大量的工作,确保着色器被正确的编译,并且其中的代码能够高效的运行在图形硬件上。可能你不并不认为着色器的编译性能会影响到程序运行时的表现,但实际上它确实会影响用户体验。
首先,最明显的是准备好能够运行的着色器时间会影响程序的启动时间。有些OpenGL实现可能会使用额外的CPU线程去编译你的着色器,甚至还会并行编译多个着色器。你可以像看待图形渲染管道不能够被阻塞一样去考虑OpenGL的驱动器和OpenGL实现在正式开启GPU的工作之前也尽量不要被阻塞。因此如果你调用函数glCompileShader()
编译某个着色器,然后立即调用函数glGetShaderiv()
获取编译状态或者日志信息,你将会阻塞你的OpenGL编译实现,因为后者会阻塞当前线程直至着色器编译完成。更好的方式是编译所有的着色器,然后在查询编译结果,具体步骤如下:
- 准备好需要编译的着色器源码,创建着色器对象,调用函数
glCompileShader()
依次编译,但是不要查询编译结果 - 在调试和编译环境,可以在所有着色器编译后查询它们的编译结果和日志信息,或者依靠系统日志输出。设置一个开关可以在这两种方式中切换,当你的程序发布时可以认为在调试阶段已经出来玩所有的错误信息,因此可以关闭查询编译信息的逻辑。
- 同样的对于所有的OpenGL程序对象,首先准备好这些对象,然后附着好着色器,再调用函数
glLinkProgram()
链接,这个时候不要查询链接结果,在所有的程序链接调用完成后再查询链接结果,并且将此逻辑设置为调试环境独有的逻辑。
在一个大型的应用中,毫无疑问会有大量的着色器、以及着色器组合需要被链接到程序对象中。需要避免为了将单个着色器链接到不同的程序对象,而重复编译着色。正确的做法是使用多个程序对象,对每个需要用到的着色器对象只编译一次,并且在合适的时机切换程序对象,执行渲染任务。
一个庞大的程序对象可能会为GPU的性能带来提升,但是如果着色器写得足够好,这种提升很有限。因此如果你有大量的着色器,最好好用使用多个OpenGL程序来组合它们。你可以为管道前端的每一种着色器组合配置一个程序对象,再配置一个仅仅包含片段着色器的程序对象。这种将图像渲染管道前后端分离的策略可以使得OpenGL的实现去优化曲面细分控制和计算着色器组合,或者顶点和几何着色器组合。
尽管为每种着色器组合创建一个程序对象会使其数量增长,但是这种方式仍然相对于每次重新配置程序对象更高效。可以维护100个程序对象的缓存列表,如果要使用的程序对象在缓存中存在,则直接使用缓存中的对象,如果不存在则新建一个程序对象并添加到缓存中,或者从缓存中取出一个程序对象重新配置。
在处理好着色器的组合管理问题后,我们需要聚焦着色器内部的复杂代码。着色器编译包含了很多工作。首先预处理器开始工作,它展开宏定义,移除注释代码等。接下来着色器代码经过分词处理,语法检查,最终被编译成内部格式,这一阶段伴随着代码优化和生成。通常代码优化器会对一段代码多次处理,尽可能的执行本地优化任务,然后将结果保存。紧接着继续下一次迭代,直到不能再继续优化,或者达到最大迭代数。经过优化器处理后,会发生如下两件事情。
- 如果优化器是因为代码已经没有优化空间而停止工作,那得到的可执行代码确实有着很高的效率,但是优化器可能执行了太多的迭代运算来到达这种效果,这样会增加代码优化时间。
- 如果优化器是因为执行迭代的数量达到最大值,这很可能得到的可执行代码并不是最优的。另外优化器也耗尽了所有分配的时间。
为了避免这种情况,作为开发者我们应该采取一些技巧使编译器更高效的工作。首先,尽量确保所写的着色器代码是高效的。其次,我们也可以通过一些技巧使OpenGL的着色器能够被更高效的编译。首先你可以在发布程序之前,通过一个离线的预处理器预先处理你的代码,并将处理后的结果应用在程序中。这样着色器中的宏定义以及其他预处理指令就不会在运行时再被处理,从而减轻着色器编译的负担。
如果你想做得更好,你还可以预优化你的着色器代码。使用一个离线的着色器编译器预处理、解析并将着色器代码编译成一个中间格式,然后执行一些优化操作,如移除无效代码,常量折叠和传播以及移除注释等等。最好将优化好的着色器代码重新部署到程序内,这样程序运行时编译器发现代码已经足够优化,能够加快编译速度。
最后回顾之前讲到的二进制程序文件,也就是将着色器编译连接到程序对象中,然后将得到的结果存储成文件。当要使用该程序对象时,直接加载该二进制文件并将其交付给OpenGL使用,这种方式能够从二进制文件中获取缓存信息,从而使得在程序运行阶段能够省去几乎所有的编译工作,进一步提升程序运行效率。
2.2.6 充分利用多GPU资源
一些用户会在单台机器上安装多个图形处理卡实现多GPU系统。AMD将这种系统称为交火(CrossFire),英伟达将这种系统称为双显卡技术(SLI)。尽管不同的硬件制造商为这种工作方式采取了不同的命名,但是通常它们都有相同的工作原理,即交替帧渲染模式(Alternate Frame Rendering, AFR)。在这种工作模式下,某个GPU渲染当前帧的画面,另一个GPU渲染下一帧画面,并以此方式直至利用完所有可用的GPU。大多数这种系统只有两个GPU,但是其中一部分有着三个GPU甚至更多。尽管AFR不是唯一利用多个GPU的工作方式,但是它是最常见的一种。
在使用AFR系统时,需要避免将一个GPU生成的数据在另外一个GPU中使用。这里有两个原因,首先这种操作意味着两个GPU之间必须同步,也就是其中一个依赖于另外一个显卡的输出数据,这样这些GPU就不能并行计算,从而丧失多个GPU的并行计算优势。其次,将数据从一个显卡传输到另外一个显卡的成本非常高,这种数据传输必须通过总线(如PCI-express),它的数据传输速度远低于图形卡内部的显存的数据传输速度。
这个建议本身看上去很好理解,但是哪些类型的操作会引发数据在多个GPU之间的流动却不是非常明确的,下面是可能引发数据在多个GPU之间拷贝的行为清单。
将数据渲染到某个纹理对象中,并在下一帧的渲染任务中使用这些数据。例如如果你写了一个程序生成了一个动态的环境贴图,并且想在渲染下一帧时使用并更新这个环境贴图,你会发现这个程序在单GPU的系统上运行的效率更高。因为在多GPU系统中,环境贴图的数据必须通过总线才能拷贝到另外的GPU中,才能渲染新的一帧画面,这就会迫使GPU进行同步操作。因此在这种环境中,更好的方式是在每帧画面渲染时都重新生成一张新的环境贴图,避免数据在多个GPU中拷贝。如果你必须要重用这些资源,那么尽量在双GPU系统中隔帧重用数据,从而避免数据拷贝。
向纹理中写入数据的时候不清空其内部的数据。在90年代晚期当程序员确定他们最后将会重写整个纹理内容时,这是一个很常见的减少内存带宽的方式。然而对于现代图形硬件,这是一个非常糟糕的主意。首先现代显卡在设计时都实现了某种帧缓存的压缩格式,使得清除帧缓存的操作非常快。在单GPU的系统中,不清楚帧缓存内容都可能会导致压缩特性被关闭,从而使得程序运行更慢。在多GPU系统中,问题会变得更加严重。如果不情况帧缓存,则OpenGL无法知道你什么时候会重写整个帧缓存的内容,这意味着其在执行相关的第一个绘制命令之前它必须等待上一帧(正在另外的GPU上渲染)完成所有的渲染指令,接着将数据传输到当前的GPU上,从而使得帧缓存在被重写之前的所有数据都是有效的。
在当前帧中向某个缓存对象中写入数据,并在下一帧中读取这些数据会导致同步和数据拷贝。例如前文中讲到的物理仿真算法在多GPU系统中表现并不是很好,因为算法的每一步都依赖于前一帧渲染得到的数据。如果你确定算法的运行环境是在两个GPU中,你可能会想到并行运行两个物理仿真的拷贝从而提高程序效率,第二个仿真稍领先于第一个,并偶尔对他们做同步操作。
使用某个GPU遮蔽查询的结果来作为渲染条件,从而控制另外一个GPU上的渲染指令将会使OpenGL执行同步操作。尽管遮蔽查询结果传递时需要拷贝的数据量并不大,但是GPU之间的同步会对整个程序的性能造成严重的影响。如果可能,你可以尽早的执行遮蔽查询的操作,并且尽量在帧渲染的末尾使用查询的结果,这样它不会阻塞已经发布的渲染指令。或者你可以使用两套遮蔽查询对象,从而在每帧渲染时使用前两帧的查询结果,从而避免数据在多显卡之间拷贝。需要留意的是查询结果使用延迟的帧数需要和你的GPU数量相匹配。
不幸的是这里并没有一个标准的方法可以用来判断你当前程序运行的环境是否是多显卡系统,以及显卡的数量是多少。尽管存在一些扩展能够实现这些目的,但是也有其他的扩展允许你创建一个上下文,使得即使你的程序运行在多个GPU系统中时,仅仅使用其中的一个GPU执行图像渲染任务。在这些扩展可用的情况下,你可能会尝试通过将场景的不同部分分发到不同的GPU中渲染,最后再合成整个帧的方式来优化程序性能。
2.2.7 使用多线程
OpenGL很好的支持了多线程的渲染工作,并且有着很好定义的线程模型。每个线程同时只能有一个激活的上下文,调用函数wglMakeCurrent()
、glXMakeCurrent()
或者当前平台特定的函数可以切换当前激活的OpenGL的上下文。在设置好当前线程的激活OpenGL上下文后,就可以在这个线程上创建OpenGL对象,编译着色器,加载纹理,甚至同时向窗口中写入数据。这是除大多数OpenGL驱动器的高效多线程任务实现之外我们还能使用到的策略。实际上,如果在调试模式下运行程序,或者使用了其他调试工具,你可以看到多个线程在工作,它们在当前的显卡驱动程序内有着自己的启动程序。
尽管OpenGL能够很好的支持多线程系统,并且有着能够共享模型的定义明确的对象使得多个上下文在不同的线程中使用同一套OpenGL对象,但是这可能并不会得到我们期望的提升性能的效果。例如,创建两个上下文对象,将它们分别设置为两个线程的激活上下文,其中一个用于加载纹理数据以及编译着色器,另外一个用于渲染图像,这种处理策略看上去很具有吸引力。但是,如果你真的这样做了,你可能得到意料之外的结果,即性能并没有提升,甚至还会下降。从较简单的角度考虑,我们有一个GPU,一个命令缓存,OpenGL会保证所有的渲染指令以特定的顺序执行。这意味着大多数访问OpenGL的指令都会被序列化,因此对于OpenGL访问的协调和同步操作造成的不利影响可能会超过CPU的多线程指令带来的性能收益。
为了避免这些序列化问题,同样的还是创建两个或者更多的OpenGL上下文,并用当前平台提供的激活上下文函数为每个线程设置激活上下文。需要注意的是,尽管这种方式使不同的上下文能够隔离状态改变的影响,但是切换上下文操作可能是一个非常耗费性能的操作。特别是大多数窗口系统中都会在切换上下文时隐式的glFlush()
操作。
在OpenGL中实现利用多线程提升程序性能的方式很多,这里仅介绍其中一种策略。首先大多数复杂的程序将会将非图像渲染任务分发到其他线程上执行,这些任务包括人工智能,音频特效,对象管理,输入和网络交互以及物理仿真等。接着使用其中的一个OpenGL上下文作为主渲染线程的激活上下文,这个线程是唯一和OpenGL进行交互的线程。
接下来,假定现在需要从文件中加载一些纹理到纹理对象中,我们可以在主渲染线程中创建缓存对象,将其绑定到靶点GL_PIXEL_UNPACK_BUFFER
之上,并映射该缓存对象的地址到内存中。然后通知一个工作线程从文件中加载数据到指定的内存地址,数据加载完毕后再通知回主渲染线程。此时主渲染线程可以调用函数glTexSubImage2D()
从缓存中将数据通过绑定靶点拷贝到纹理对象中。
同样的技术也能够被用于任何能够存储在缓存对象中的数据,包括顶点和索引数据,存储在统一闭包中的着色器常量,纹理的图像数据,甚至是通过靶点GL_DRAW_INDIRECT_BUFFER
存储的和渲染指令相关的参数。你可以根据实际的情况采取对应的操作,从而提升渲染引擎效率。在主渲染线程上,可以创建两套缓存对象,并动态的更新其内部的数据。在渲染某帧画面之前,映射下一帧的缓存对象内存地址,并绑定当前帧所需要使用到的缓存对象。接下来在一个或者更多的工作线程中,准备下一帧渲染所需要的数据,例如执行CPU剔除,实现动态顶点生成,更新长了以及设置绘制参数等。如果存在多批次绘制,则每批次的绘制需要使用到的数据在其对应的统一缓存中都应该有一个新的内存偏移值。你可以在CPU环境下使用原子操作以线程安全的方式在这些缓存对象中分配内存空间。
当工作线程正在准备下一帧渲染所需要的数据时,主渲染线程使用工作现在准备好的数据,绑定需要使用的OpenGL对象,如纹理对象和缓存对象等,然后发布绘制命令,完成当前帧的渲染。如果你能够将纹理对象整合到一个纹理数组中,并且通过统一闭包的方式存储在这个数字中数据的便宜量,你甚至可以将数据准备的工作飞分派到其他工作线程中去。这种方式可以使主渲染线程的工作变得更简单,它只需要执行映射和解除映射,基本的状态改变和绘制任务。通过这种策略,和大多数OpenGL驱动器的高效多线程任务实现,在大多数场景下我们应该都能够为多核CPU环境实现最大程度的性能优化。
2.2.8 扔掉不想要的部分
图像程序可能会耗费大量的存储空间。纹理、帧缓存附件以及用于保存顶点数据或者其他数据的缓存对象都呢能消耗大量的资源。在前面的子章节中讲到过在向帧缓存中写入数据时需要先清除其中的内容,这样做不仅会触发帧缓存压缩特性等高性能的优化功能,另外这也隐式的通知OpenGL这部分显存将不再被使用,可以被回收。当向帧缓存对象再次需要显存空间接收数据写入时,OpenGL为可以很轻易的为其分配显存空间。
显然,通过这种隐式的释放显存并不是最好的选择。实际上OpenGL提供了一系列函数使其能够明确的知道哪些资源是应该被释放,并且能够回收重用。对于纹理对象有如下两个函数。
void glInvalidateTexImage(GLuint texture, GLint level);
void glInvalidateTexSubImage(GLuint texture, GLint level,
GLint xoffset, GLint yoffset, GLint zoffset,
GLsizei width, GLsizei height, GLsizei depth);
第一个函数通知OpenGL纹理中的某个层的数据我们不再需要,OpenGL能够扔掉这部分显存数据,并用于其他目的。当然这个纹理对象仍然存在,只是对应层的数据被标记为未定义。在多GPU的环境中,OpenGL就不会将这部分数据从一个GPU拷贝到另一个GPU中。第二个函数通过更多的参数对显存回收的范围有更精细的控制。从参数xoffset
开始,通过三个参数定义了回收图像数据区域的原点,后三个参数定义了回收区域的尺寸,这些参数与函数glTexSubImage3D()
中对应的参数有相同的含义。
对于缓存对象,OpenGL提供了如下两个函数用于回收显存数据。
void glInvalidateBufferData(GLuint buffer);
void glInvalidateBufferSubData(GLuint buffer, GLintptr offset, GLsizeiptr length);
和函数glInvalidateTexImage()
相同,函数glInvalidateBufferData()
回收了整个缓存对象的显存空间。该函数调用后,该缓存对象的所有内容都变为未定义的,但是该缓存对象仍然存在,并且被OpenGL所持有。如果你使用转换反馈缓存作为中间缓存存储数据,在调用函数glDrawTransformFeedback()
使用缓存中的数据执行渲染操作后,你应该调用显存释放函数。可以调用函数glInvalidateBufferData()
回收整个缓存分配的显存,使得这部分显存能够被OpenGL用于其他目的。第二个函数是一个更精细的版本,通过参数offset
和length
来确定需要回收的显存范围。
最后,OpenGL同样提供了如下两个函数用于处理和帧缓存相关的类似逻辑。
void glInvalidateFramebuffer(GLenum target,
GLsizei numAttachments,
const GLenum * attachments);
void glInvalidateSubFramebuffer(GLenum target,
GLsizei numAttachments,
const GLenum * attachments,
GLint x, GLint y,
GLint width, GLint height);
在上面的两个函数中,参数target
可选的值有GL_FRAMEBUFFER
、GL_DRAW_FRAMEBUFFER
和GL_READ_FRAMEBUFFER
,其中GL_FRAMEBFUFER
和GL_DRAW_FRAMEBUFFER
是等效的。参数numAttachments是需要标记为无效内容的附件数量。参数attachments
是一个数组指针,这个数组包含需要标记为无效内容的附件标识,这些标识的取值应该是如GL_COLOR_ATTACHMENT0
、GL_DEPTH_ATTACHMENT
和GL_STENCIL_ATTACHMENT
等OpenGL定义好的常量。
函数glInvalidateFramebuffer()
将指定的附件依赖的缓存对象的全部显存都标记为无效。同样的函数glInvalidateSubFramebuffer()
提供了更多的参数允许你将缓存的部分数据标记为无效。这个区域的原点通过参数x
和y
定义,尺寸通过参数width
和height
定义。
将资源标记为无效使得OpenGL能够执行如下一些操作从而避免一些负面影响。
- 无效的数据对应的显存空间可能会被OpenGL再次分配用于其他目的。
- 能够避免无效的数据拷贝,这在多GPU系统中尤其明显。
- 能够在不保证帧缓存附件的内容有效性情况下,将帧缓存附件重置为压缩状态。
总的来说,当你某个资源不再被使用时,但是你又不会直接删除某个对象时,你应该调用相应的无效性声明函数。最坏的情况下,OpenGL不会做任何处理,但是最好的情况下,我们能够避免可能发生的昂贵数据拷贝、清除、分页操作或者内存耗尽情况。
3 总结
本文讲到了一些可以帮助我们开发程序的调试技术,包括使用调试上下文,以及一些调试工具。另外我们也讲到了一些分析程序性能,使程序高效运行以及充分利用显卡资源的方法。通过确保你的程序在调试上下文中不会产生任何错误,不会生成任何警告,并且尽可能优化性能,降低程序运行的硬件门槛,从而获得更多的用户群体。