本节书摘来自华章出版社《OpenGL编程指南》一书中的第3章,第3.2节,作者 Bill Licea-Kane ,更多章节内容可以访问云栖社区“华章计算机”公众号查看
3.2 OpenGL缓存数据
几乎所有使用OpenGL完成的事情都用到了缓存buffers中的数据中。OpenGL的缓存表示为缓存对象(buffer object)。在第1章里我们已经简要地介绍了缓存对象的意义。不过,这一节将稍微深入到缓存对象的方方面面当中,包括它的种类、创建方式、管理和销毁,以及与缓存对象有关的一些最优解决方案。
3.2.1 创建与分配缓存
与OpenGL中的很多其他实现类似,缓存对象也是使用GLuint的值来进行命名的。这个值可以使用glGenBuffers()命令来创建。我们已经在第1章介绍过这个函数了,但是在这里会再次给出它的原型,以方便读者参考。
void glGenBuffers(GLsizei n, GLuint* buffers);
返回n个当前未使用的缓存对象名称,并保存到buffers数组中。
调用glGenBuffers()完成之后,我们将在buffers中得到一个缓存对象名称的数组,但是此时这些名称只是徒有其表。它们还不是真正的缓存对象。只有某个名称首次绑定到系统环境中的一个结合点之后,它所对应的缓存对象才会真正创建出来。这一点非常重要,因为OpenGL会采取一种最优内存管理策略,根据缓存对象完成绑定的情况来分配它对应的内存。可用的缓存结合点(称作目标,target)如表3-2中所示。
缓存对象的建立,实际上就是通过调用glGenBuffers()函数生成一系列名称,然后通过glBindBuffer()将一个名称绑定到表3-2中的一个目标来完成的。第1章当中已经介绍过glBindBuffer()和glBindBuffer()函数,不过这里将再次给出函数的原型,以保证文字的完整性。
void glBindBuffer(GLenum target, GLuint buffer);
将名称为buffer的缓存对象绑定到target所指定的缓存结合点。target必须是OpenGL支持的缓存绑定目标之一,buffer必须是通过glGenBuffers()分配的名称。如果buffer是第一次被绑定,那么它所对应的缓存对象也将同时被创建。
好了,现在我们已经将缓存对象绑定到表3-2中的某一个目标上了,然后呢?新创建的缓存对象的默认状态,相当于是不存在任何数据的一处缓存区域。如果想要将它实际使用起来,就必须向其中输入一些数据才行。
3.2.2 向缓存输入和输出数据
将数据输入和输出OpenGL缓存的方法有很多种。比如直接显式地传递数据,又比如用新的数据替换缓存对象中已有的部分数据,或者由OpenGL负责生成数据然后将它记录到缓存对象中。向缓存对象中传递数据最简单的方法就是在分配内存的时候读入数据。这一步可以通过glBufferData()函数来完成。下面再次给出glBufferData()的原型。
void glBufferData(GLenum target, GLsizeiptr size, const GLvoid* data, GLenum usage);
为绑定到target的缓存对象分配size大小(单位为字节)的存储空间。如果参数data不是NULL,那么将使用data所在的内存区域的内容来初始化整个空间。usage允许应用程序向OpenGL端发出一个提示,指示缓存中的数据可能具备一些特定的用途。
要特别注意的是,glBufferData()是真正为缓存对象分配(或者重新分配)存储空间的。也就是说,如果新的数据大小比缓存对象当前所分配的存储空间要大,那么缓存对象的大小将被重设以获取更多空间。与之类似,如果新的数据大小比当前所分配的缓存要小,那么缓存对象将会收缩以适应新的大小。因此,虽然我们可以直接在初始化的时候指定缓存对象中的数据,但是这只是一种方便的用法而已,并不一定就是最好的方法(有的时候也不一定是最方便的用法)。
OpenGL对于缓存对象存储数据中的最优分配方案的管理,并不仅仅依赖于初始化绑定时的target参数。另一个重要的参数就是glBufferData()中的usage。usage必须是内置标准标识符中的一个,例如GL_STATIC_DRAW或者GL_DYNAMIC_COPY。注意这里的标识符名称要分解为两个部分去理解:第一部分可以是STATIC、DYNAMIC或者STREAM中的一个,而第二部分可以是DRAW、READ或者COPY中的一个。
这些“分解”的标识符的具体含义如表3-3所示。
如何为usage参数提供一个准确的定义,这关系到能否达到最优的性能。这个参数向OpenGL提供了重要的缓存使用策略信息。首先考虑相关标识符的第一部分。如果标识符使用_STATIC_开头,那么就是说数据的变动是非常有限的,或者根本就没有—因为它在本质上是静态数据。这类标识符显然需要用于那些只修改过一次就不再变动的数据类型。如果usage包含了_STATIC_,那么OpenGL会在内部对数据重新进行处理,以保证它在内存中的布置更为合理,或者使用更为优化的数据格式。这一步操作的代价可能较大,但是由于数据已经是静态的,因此这一操作只需要执行一次,整体上还是非常理想的。
如果在usage中包含了_DYNAMIC_,那么说明数据的变动是频繁的,而变动过程中对数据的使用也是频繁的。例如,如果有一个建模程序,它所使用的数据可能被用户所编辑,此时有必要用到这个标识符。这种时候,一个可能的情况是数据在多帧内被持续使用,然后被修改,然后再次被更多帧使用,如此反复。这种情况的相反面就是GL_STREAM_标识符。它的含义是,缓存数据的修改是有规律的,并且每次修改数据后只会少量地加以使用(可能只使用一次)。这种时候,OpenGL甚至可能不会将数据拷贝到快速的图像内存中,而是直接在原地进行访问。这种情形通常发生在CPU端执行应用程序诸如物理仿真的操作时,此时每帧都会给出一些新的数据集,供程序调取。
现在我们要了解usage标识符的第二部分。这一部分指示更新和使用数据的责任者。如果这个标识符包含_DRAW,那么就是说这处缓存将作为标准OpenGL绘制操作的数据源使用。它会被频繁地读取;而与之相反的就是在标识符中包含_READ,这类标识符会被频繁地写入。如果应用程序需要从缓存中回读数据(参见“访问缓存内容”一节),那么应当使用_READ标识符,这样OpenGL会认为这处数据是需要多次写入的。如果缓存中保存的是顶点数据,那么usage参数中必须包含_DRAW;而像素缓存对象(pixel buffer object)和其他从OpenGL端获取数据的缓存则必须使用含有_READ的标识符。最后,如果usage中包含_COPY,那么说明应用程序会通过OpenGL端来生成数据并且保存到缓存中,然后将它作为后继的绘制操作的输入源。使用_COPY标识符的一个相应例子就是transform feedback缓存,这个缓存需要由OpenGL写入数据,然后在之后的绘制命令中再作为顶点缓存使用。
缓存的部分初始化
假设有一个包含部分顶点数据的数组,另一个数组则包含一部分颜色信息,还有一个数组包含纹理坐标或者别的什么数据。你需要将这些数据进行紧凑的打包,并且存入一个足够大的缓存对象让OpenGL使用。在内存中数组之间可能是连续的,也可能不连续,因此无法使用glBufferData()一次性地更新所有的数据。此外,如果使用glBufferData()进行更新的话,那么首先是顶点数据,然后缓存的大小与顶点数据的大小完全一致,并且也就不再有空间去存储颜色或者纹理坐标信息了。因此我们需要引入新的glBufferSubData()函数。
void glBufferSubData(GLenum target, GLintptr offset, GLsizeiptr size, const GLvoid* data);
使用新的数据替换缓存对象中的部分数据。绑定到target的缓存对象要从offset字节处开始需要使用地址为data、大小为size的数据块来进行更新。如果offset和size的总和超出了缓存对象绑定数据的范围,那么将产生一个错误。
如果将glBufferData()和glBufferSubData()结合起来使用,那么我们就可以对一个缓存对象进行分配和初始化,然后将数据更新到它的不同区块当中。一个相应的示例可以参见例3.1。
例3.1 使用glBufferSubData()来初始化缓存对象
如果只是希望将缓存对象的数据清除为一个已知的值,那么也可以使用glClearBufferData()或者glClearBufferSubData()函数。它们的原型如下所示:
void glClearBufferData(GLenum target, GLenum internalformat, GLenum format, GLenum type, const void* data);
void glClearBufferSubData(GLenum target, GLenum internalformat, GLintptr offset, GLintptr size, GLenum format, GLenum type, const void* data);
清除缓存对象中所有或者部分数据。绑定到target的缓存存储空间将使用data中存储的数据进行填充。format和type分别指定了data对应数据的格式和类型。
首先将数据被转换到internalformat所指定的格式,然后填充缓存数据的指定区域范围。
对于glClearBufferData()来说,整个区域都会被指定的数据所填充。而对于glClearBufferSubData()来说,填充区域是通过offset和size来指定的,它们分别给出了以字节为单位的起始偏移地址和大小。
glClearBufferData()和glClearBufferSubData()函数允许我们初始化缓存对象中存储的数据,并且不需要保留或者清除任何一处系统内存。
缓存对象中的数据也可以使用glCopyBufferSubData()函数互相进行拷贝。与glBufferSubData()函数对较大缓存中的数据依次进行组装的做法不同,此时我们可以使用glBufferData()将数据更新到独立的缓存当中,然后将这些缓存直接用glCopyBufferSubData()拷贝到一个较大的缓存中。根据OpenGL的具体实现,这些拷贝之间还可以存在互相重叠的部分,因为每次调用glBufferData()时,缓存对象都会将当前区域的内容标记为需要更新的状态。因此,有的时候我们可以让OpenGL直接分配一整块数据缓存区域,并且不用关心之前的数据拷贝操作是否已经完成了。旧的数据会在之后的某个时刻直接释放。
glCopyBufferSubData()的原型如下所示:
void glCopyBufferSubData(GLenum readtarget, GLenum writetarget, GLintptr readoffset, GLintprr writeoffset, GLsizeiptr size);
将绑定到readtarget的缓存对象的一部分存储数据拷贝到与writetarget相绑定的缓存对象的数据区域上。readtarget对应的数据从readoffset位置开始复制size个字节,然后拷贝到writetarget对应数据的writeoffset位置。如果readoffset或者writeoffset与size的和超出了绑定的缓存对象的范围,那么OpenGL会产生一个GL_INVALID_VALUE错误。
glCopyBufferSubData()可以在两个目标对应的缓存之间拷贝数据,而GL_COPY_READ_BUFFER和GL_COPY_WRITE_BUFFER这两个目标正是为了这个目的而生。它们不能用于其他OpenGL的操作当中,并且如果将缓存与它们进行绑定,并且只用于数据的拷贝和存储目的,不影响OpenGL的状态也不需要记录拷贝之前的目标区域信息的话,那么整个操作过程都是可以保证安全的。
读取缓存的内容
我们可以通过多种方式从缓存对象中回读数据。第一种方式就是使用glGetBufferSubData()函数。这个函数可以从绑定到某个目标的缓存中回读数据,然后将它放置到应用程序保有的一处内存当中。glGetBufferSubData()的原型如下所示:
void glGetBufferSubData(GLenum target, GLintptr offset, GLsizeiptr size, GLvoid* data);
返回当前绑定到target的缓存对象中的部分或者全部数据。起始数据的偏移字节位置为offset,回读的数据大小为size个字节,它们将从缓存的数据区域拷贝到data所指向的内存区域中。如果缓存对象当前已经被映射,或者offset和size的和超出了缓存对象数据区域的范围,那么将提示一个错误。
如果我们使用OpenGL生成了一些数据,然后希望重新获取到它们的内容,那么此时应该使用glGetBufferSubData()。这样的例子包括在GPU级别使用transform feedback处理顶点数据,以及将帧缓存或者纹理数据读取到像素缓存对象(Pixel Buffer Object)中。后文将依次给出这些内容的具体介绍。当然,我们也可以使用glGetBufferSubData()简单地将之前存入到缓存对象中的数据读回到内存中。
3.2.3 访问缓存的内容
目前为止,在这一节当中给出的所有函数(glBufferData()、glBufferSubData()、glCopyBufferSubData()和glGetBufferSubData())都存在同一个问题,就是它们都会导致OpenGL进行一次数据的拷贝操作。glBufferData()和glBufferSubData()会将应用程序内存中的数据拷贝到OpenGL管理的内存当中。显而易见glCopyBufferSubData()会将源缓存中的内容进行一次拷贝。glGetBufferSubData()则是将OpenGL管理的内存中的数据拷贝到应用程序内存中。根据硬件的配置,其实也可以通过获取一个指针的形式,直接在应用程序中对OpenGL管理的内存进行访问。当然,获取这个指针的对应函数就是glMapBuffer()。
void* glMapBuffer(GLenum target, GLenum access);
将当前绑定到target的缓存对象的整个数据区域映射到客户端的地址空间中。之后可以根据给定的access策略,通过返回的指针对数据进行直接读或者写的操作。如果OpenGL无法将缓存对象的数据映射出来,那么glMapBuffer()将产生一个错误并且返回NULL。发生这种情况的原因可能是与系统相关的,比如可用的虚拟内存过低等。
当我们调用glMapBuffer()时,这个函数会返回一个指针,它指向绑定到target的缓存对象的数据区域所对应的内存。注意这块内存只是对应于这个缓存对象本身—它不一定就是图形处理器用到的内存区域。access参数指定了应用程序对于映射后的内存区域的使用方式。它必须是表3-4中列出的标识符之一。
如果glMapBuffer()无法映射缓存对象的数据,那么它将返回NULL。access参数相当于用户程序与OpenGL对内存访问的一个约定。如果用户违反了这个约定,那么将产生很不好的结果,例如写缓存的操作将被忽略,数据将被破坏,甚至用户程序会直接崩溃。
当你要求映射到应用程序层面的数据正处于无法访问的内存当中,OpenGL可能会被迫将数据进行移动,以保证能够获取到数据的指针,也就是你期望的结果。与之类似,当你完成了对数据的操作,以及对它进行了修改,那么OpenGL将再次把数据移回到图形处理器所需的位置上。这样的操作对于性能上的损耗是比较高的,因此必须特别加以对待。
如果缓存已经通过GL_READ_ONLY或者GL_READ_WRITE访问模式进行了映射,那么缓存对象中的数据对于应用程序就是可见的。我们可以回读它的内容,将它写入磁盘文件,甚至直接对它进行修改(如果使用了GL_READ_WRITE作为访问模式的话)。如果访问模式为GL_READ_WRITE或者GL_WRITE_ONLY,那么可以通过OpenGL返回的指针向映射内存中写入数据。当结束数据的读取或者写入到缓存对象的操作之后,必须使用glUnmapBuffer()执行解除映射操作,它的原型如下所示:
GLboolean glUnmapBuffer(GLenum target);
解除glMapBuffer()创建的映射。如果对象数据的内容在映射过程中没有发生损坏,那么glUnmapBuffer()将返回GL_TRUE。发生损坏的原因通常与系统相关,例如屏幕模式发生了改变,这会影响图形内存的可用性。这种情况下,函数的返回值为GL_FALSE,并且对应的数据内容是不可预测的。应用程序必须考虑到这种几率较低的情形,并且及时对数据进行重新初始化。
如果解除了缓存的映射,那么之前写入到OpenGL映射内存中的数据将会重新对缓存对象可见。这句话的意义是,我们可以先使用glBufferData()分配数据空间,并且在data参数中直接传递NULL,之后进行映射并且直接将数据写入,最后解除映射,从而完成了数据向缓存对象传递的操作。例3.2所示就是一个将文件内容读取并写入到缓存对象的例子。
例3.2 使用glMapBuffer()初始化缓存对象
在例3.2中,文件的所有内容都在单一操作中被读入到缓存对象当中。缓存对象创建时的大小与文件是相同的。当缓存映射之后,我们就可以直接将文件内容读入到缓存对象的数据区域当中。应用程序端并没有拷贝的操作,并且如果数据对于应用程序和图形处理器都是可见的,那么OpenGL端也没有进行任何拷贝的操作。
使用这种方式来初始化缓存对象可能会带来显著的性能优势。其理由如下:如果调用glBufferData()或者glBufferSubData(),当返回这些函数后,我们可以对返回的内存区域中的数据进行任何操作—释放它,使用它做别的事情—都是可以的。这也就是说,这些函数在完成后不能与内存区域再有任何瓜葛,因此必须采取数据拷贝的方式。但是,如果调用glMapBuffer(),它所返回的指针是OpenGL端管理的。当调用glUnmapBuffer()时,OpenGL依然负责管理这处内存,而用户程序与这处内存已经不再有瓜葛了。这样的话即使数据需要移动或者拷贝,OpenGL都可以在调用glUnmapBuffer()之后才开始这些操作并且立即返回,而内容操作是在系统的空闲时间之内完成,不再受到应用程序的影响。因此,OpenGL的数据拷贝操作与应用程序之后的操作(例如建立更多的缓存,读取别的文件,等等)实际上是同步进行的。如果不需要进行拷贝的话,那么结果就再好不过了!此时在本质上解除映射的操作相当于是对空间的释放。
异步和显式的映射
为了避免glMapBuffer()可能造成的缓存映射问题(例如应用程序错误地指定了access参数,或者总是使用GL_READ_WRITE),glMapBufferRange()函数使用额外的标识符来更精确地设置访问模式,glMapBufferRange()函数的原型如下所示:
void* glMapBufferRange(GLenum target, GLintptr offset, GLsizeiptr length, GLbitfield access);
将缓存对象数据的全部或者一部分映射到应用程序的地址空间中。target设置了缓存对象当前绑定的目标。offset和length一起设置了准备映射的数据范围(单位为字节)。access是一个位域标识符,用于描述映射的模式。
对于glMapBufferRange()来说,access位域中必须包含GL_MAP_READ_BIT和GL_MAP_WRITE_BIT中的一个或者两个,以确认应用程序是否要对映射数据进行读操作、写操作,或者两者皆有。此外,access中还可以包含一个或多个其他的标识符,如表3-5所示。
正如你在表3-5中看到的这些标识符所提示的,对于OpenGL数据的使用以及数据访问时的同步操作,这个命令可以实现一个更精确的控制过程。
如果打算通过GL_MAP_INVALIDATE_RANGE_BIT或者GL_MAP_INVALIDATE_BUFFER_BIT标识符来实现缓存数据的无效化,那么也就意味着OpenGL可以对缓存对象中任何已有的数据进行清理。除非你确信自己要同时使用GL_MAP_WRITE_BIT标识符对缓存进行写入操作,否则不要设置这两个标识符中的任意一个。如果你设置了GL_MAP_INVALIDATE_RANGE_BIT的话,你的目的应该是对某个区域的整体进行更新(或者至少是其中对你的程序有意义的部分)。如果设置了GL_MAP_INVALIDATE_BUFFER_BIT,那么就意味着你不打算再关心那些没有被映射的缓存区域的内容了。无论是哪种方法,你都必须通过标识符的设置来声明你准备在后继的映射当中对缓存中剩下的部分进行更新。由于此时OpenGL是可以抛弃缓存数据中剩余的部分,因此即使你将修改过的数据重新合并到原始缓存中也没有什么意义了。因此,如果打算对映射缓存的第一个部分使用GL_MAP_INVALIDATE_BUFFER_BIT,然后对缓存其他的部分使用GL_MAP_INVALIDATE_RANGE_BIT,那么应该是一个不错的想法。
GL_MAP_UNSYNCHRONIZED_BIT标识符用于禁止OpenGL数据传输和使用时的自动同步机制。没有这个标志符的话,OpenGL会在使用缓存对象之前完成任何正在执行的命令。这一步与OpenGL的管线有关,因此可能会造成性能上的损失。如果可以确保之后的操作可以在真正修改缓存内容之前完成(不过在调用glMapBufferRange()之前这并不是必须的),例如调用glFinish()或者使用一个同步对象(参见11.3节),那么OpenGL也就不需要专门为此维护一个同步功能了。
最后,GL_MAP_FLUSH_EXPLICIT_BIT标识符表明了应用程序将通知OpenGL它修改了缓存的哪些部分,然后再调用glUnmapBuffer()。通知的操作可以通过glFlushMappedBufferRange()函数的调用来完成,其原型如下:
void glFlushMappedBufferRange(GLenum target, GLintptr offset, GLsizeiptr length);
通知OpenGL,绑定到target的映射缓存中由offset和length所划分的区域已经发生了修改,需要立即更新到缓存对象的数据区域中。
我们可以对缓存对象中独立的或者互相重叠的映射范围多次调用glFlushMappedBufferRange()。缓存对象的范围是通过offset和length划分的,这两个值必须位于缓存对象的映射范围之内,并且映射范围必须通过glMapBufferRange()以及GL_MAP_FLUSH_EXPLICIT_BIT标识符来映射。当执行这个操作之后,会假设OpenGL对于映射缓存对象中指定区域的修改已经完成,并且开始执行一些相关的操作,例如重新激活数据的可用性,将它拷贝到图形处理器的显示内存中,或者进行刷新,数据缓存的重新更新等。就算缓存的一部分或者全部还处于映射状态下,这些操作也可以顺利完成。这一操作对于OpenGL与其他应用程序操作的并行化处理是非常有意义的。举例来说,如果需要从文件加载一个非常庞大的数据块并将他们送入缓存,那么需要在缓存中分配足够囊括整个文件大小的区域,然后读取文件的各个子块,并且对每个子块都调用一次glFlushMappedBufferRange()。然后OpenGL就可以与应用程序并行地执行一些工作,从文件读取更多的数据并且存入下一个子块当中。
通过这些标识符的不同混合方式,我们可以对应用程序和OpenGL之间的数据传输过程进行优化,或者实现一些高级的技巧,例如多线程或者异步的文件操作。
3.2.4 丢弃缓存数据
高级技巧
如果已经完成了对缓存数据的处理,那么可以直接通知OpenGL我们不再需要使用这些数据。例如,如果我们正在向transform feedback的缓存中写入数据,然后使用这些数据进行绘制。如果最后访问数据的是绘制命令,那么我们就可以及时通知OpenGL,让它适时地抛弃数据并且将内存用作其他用途。这样OpenGL的实现就可以完成一些优化工作,诸如紧密的内存分配策略,或者避免系统与多个GPU之间产生代价高昂的拷贝操作。
如果要抛弃缓存对象中的部分或者全部数据,那么我们可以调用glInvalidateBufferData()或者glInvalidateBufferSubData()函数。这两个函数的原型如下所示:
void glInvalidateBufferData(GLuint buffer);
void glInvalidateBufferSubData(GLuint buffer, GLintptr offset, GLsizeiptr length);
通知OpenGL,应用程序已经完成对缓存对象中给定范围内容的操作,因此可以随时根据实际情况抛弃数据。glInvalidateBufferSubData()会抛弃名称为buffer的缓存对象中,从offset字节处开始共length字节的数据。glInvalidateBufferData()会直接抛弃整个缓存的数据内容。
注意,从理论上来说,如果调用glBufferData()并且传入一个NULL指针的话,那么所实现的功能与直接调用glInvalidateBufferData()是非常相似的。这两个方法都会通知OpenGL实现可以安全地抛弃缓存中的数据。但是,从逻辑上glBufferData()会重新分配内存区域,而glInvalidateBufferData()不会。根据OpenGL的具体实现,通常调用glInvalidateBufferData()的方法会更为优化一些。此外,glInvalidateBufferSubData()也是唯一一个可以抛弃缓存对象中的区域数据的方法。