《OpenGL编程指南(原书第9版)》——3.2 OpenGL缓存数据

时间:2024-04-04 13:39:24

3.2 OpenGL缓存数据

几乎所有使用OpenGL完成的事情都用到了缓存buffers中的数据中。OpenGL的缓存表示为缓存对象(buffer object)。第1章已经简要地介绍了缓存对象的意义。不过,这一节将稍微深入到缓存对象的方方面面当中,包括它的种类、创建方式、管理和销毁,以及与缓存对象有关的一些最优解决方案。
3.2.1 创建与分配缓存
与OpenGL中的很多其他实现类似,缓存对象也是使用GLuint的值来进行命名的。这个值可以使用glCreateBuffers()命令来创建。我们已经在第1章介绍过这个函数了,但是在这里会再次给出它的原型,以方便读者参考。
void glCreateBuffers(GLsizei n, GLuint* buffers);
返回n个当前未使用的缓存对象名称(每个都表示一个新创建的缓存对象),并保存到buffers数组中。
调用glCreateBuffers()完成之后,我们将在buffers中得到一个缓存对象名称的数组。这些缓存对象已经被创建了,但是还没有连接到任何存储空间。用户需要使用glNamedBufferStorage()为每个缓存对象分配存储空间。拥有存储空间之后,我们就可以绑定对象到缓存目标了。可用的缓存目标(target)如表3-2中所示。
《OpenGL编程指南(原书第9版)》——3.2 OpenGL缓存数据

缓存对象的建立,实际上就是通过调用glCreateBuffers()函数生成一系列名称,然后通过glBindBuffer()将一个名称绑定到表3-2中的一个目标来完成的。第1章当中已经介绍过glCreateBuffers()和glBindBuffer()函数,不过这里将再次给出函数的原型,以保证文字的完整性。
void glBindBuffer(GLenum target, GLuint buffer);
将名称为buffer的缓存对象绑定到target所指定的缓存结合点。target必须是OpenGL支持的缓存绑定目标之一,buffer必须是通过glCreateBuffers()分配的名称。如果buffer是第一次被绑定,那么它所对应的缓存对象也将同时被创建。
好了,现在我们已经将缓存对象绑定到表3-2中的某一个目标上了,然后呢?新创建的缓存对象的默认状态,相当于是不存在任何数据的一处缓存区域。如果想要将它实际使用起来,就必须向其中输入一些数据才行。
3.2.2 向缓存输入和输出数据
将数据输入和输出OpenGL缓存的方法有很多种。比如直接显式地传递数据,又比如用新的数据替换缓存对象中已有的部分数据,或者由OpenGL负责生成数据然后将它记录到缓存对象中。向缓存对象中传递数据最简单的方法就是在分配内存的时候读入数据。这一步可以通过glNamedBufferStorage()函数来完成。下面再次给出glNamedBufferStorage()的原型。
void glNamedBufferStorage(GLuint buffer, GLsizeiptr size, const void *data, GLbitf?ield f?lags);
为缓存对象buffer分配size大小(单位为字节)的存储空间。如果参数data不是NULL,那么将使用data所在的内存区域的内容来初始化整个空间。f?lags用来设置缓存的预期用途信息。这些f?lags标识量在用户程序和OpenGL之间构建了协议,允许OpenGL尽可能极致地优化缓存的存储空间。
对于glNamedBufferStorage()来说,最重要的参数可能就是f?lags参数了。f?lags是一系列标识量的按位合并的结果,这些标识量如表3-3所示。
《OpenGL编程指南(原书第9版)》——3.2 OpenGL缓存数据
《OpenGL编程指南(原书第9版)》——3.2 OpenGL缓存数据

准确判断f?lags参数,对于性能的优化以及正确执行都非常重要。这个参数向OpenGL传达了有关用户如何使用缓存的关键数据。
缓存的部分初始化
假设有一个包含部分顶点数据的数组,另一个数组则包含一部分颜色信息,还有一个数组包含纹理坐标或者别的什么数据。你需要将这些数据进行紧凑的打包,并且存入一个足够大的缓存对象让OpenGL使用。在内存中数组之间可能是连续的,也可能不连续,因此无法简单地使用glNamedBufferStorage()来存储数据,以及一次性地更新所有的数据。此外,如果使用glNamedBufferStorage()进行更新的话,那么首先是顶点数据,然后缓存的大小与顶点数据的大小一致,并且也就不再有空间去存储颜色或者纹理坐标信息了。因此我们需要引入新的glNamedBufferSubData()函数。
void glNamedBufferSubData(GLuint buffer, GLintptr offset, GLsizeiptr size, const void *data);
使用新的数据替换缓存对象buffer中的部分数据。缓存中从offset字节处开始需要使用地址为data、大小为size的数据块来进行更新。如果offset和size的总和超出了缓存对象绑定数据的范围,那么将产生一个错误。
缓存buffer中存储的数据必须经过glNamedBufferStorage()初始化,并且标识量应当设置为GL_DYNAMIC_STORAGE_BIT。
如果将glNamedBufferStorage()和glNamedBufferSubData()结合起来使用,那么我们就可以对一个缓存对象进行分配和初始化,然后将数据更新到它的不同区块当中。一个相应的示例可以参见例3.1。
例3.1 使用glNamedBufferStorage()来初始化缓存对象
《OpenGL编程指南(原书第9版)》——3.2 OpenGL缓存数据
《OpenGL编程指南(原书第9版)》——3.2 OpenGL缓存数据
《OpenGL编程指南(原书第9版)》——3.2 OpenGL缓存数据

如果只是希望将缓存对象的数据清除为一个已知的值,那么也可以使用glClear-NamedBufferData()或者glClearNamedBufferSubData()函数。它们的原型如下所示:
void glClearNamedBufferData(GLuint buffer, GLenum internalformat, GLenum format, GLenum type, const void* data);
void glClearNamedBufferSubData(GLuint buffer, GLenum internalformat, GLintptr offset, GLsizeiptr size, GLenum format, GLenum type, const void* data);
清除缓存对象中所有或者部分数据。名为buffer的缓存存储空间将使用data中存储的数据进行填充。format和type分别指定了data对应数据的格式和类型。
首先将数据被转换到internalformat所指定的格式,然后填充缓存数据的指定区域范围。
对于glClearNamedBufferData()来说,整个区域都会被指定的数据所填充。而对于glClearNamedBufferSubData()来说,填充区域是通过offset和size来指定的,它们分别给出了以字节为单位的起始偏移地址和大小。
glClearNamedBufferData()和glClearNamedBufferSubData()函数允许我们初始化缓存对象中存储的数据,并且不需要保留或者清除任何一处系统内存。
缓存对象中的数据也可以使用glCopyNamedBufferSubData()函数互相进行拷贝。与glNamedBufferSubData()函数对较大缓存中的数据依次进行组装的做法不同,此时我们可以使用glNamedBufferStorage()将数据更新到独立的缓存当中,然后将这些缓存直接用glCopyNamedBufferSubData()拷贝到一个较大的缓存中。你也可以分配一系列缓存对象,然后循环对它们进行两两操作,确保正在写入的数据不会同时被使用,从而实现拷贝数据的叠加。
glCopyNamedBufferSubData()的原型如下所示:
void glCopyNamedBufferSubData(GLuint readBuffer, GLuint writeBuffer, GLintptr readoffset, GLintptr writeoffset, GLsizeiptr size);
将名为readBuffer的缓存对象的一部分存储数据拷贝到名为writeBuffer的缓存对象的数据区域上。readBuffer对应的数据从readoffset位置开始复制size个字节,然后拷贝到writeBuffer对应数据的writeoffset位置。如果readoffset或者writeoffset与size的和超出了绑定的缓存对象的范围,那么OpenGL会产生一个GL_INVALID_VALUE错误。
glCopyNamedBufferSubData()可以在两个目标对应的缓存之间拷贝数据,而GL_COPY_READ_BUFFER和GL_COPY_WRITE_BUFFER这两个目标正是为了这个目的而生。它们不能用于其他OpenGL的操作当中,并且如果将缓存与它们进行绑定,并且只用于数据的拷贝和存储目的,不影响OpenGL的状态也不需要记录拷贝之前的目标区域信息的话,那么整个操作过程都是可以保证安全的。
读取缓存的内容
我们可以通过多种方式从缓存对象中回读数据。第一种方式就是使用glGet-NamedBufferSubData()函数。这个函数可以从绑定到某个目标的缓存中回读数据,然后将它放置到应用程序保有的一处内存当中。glGetNamedBufferSubData()的原型如下所示:
void glGetNamedBufferSubData(GLenum target, GLintptr offset, GLsizeiptr size, void* data);
返回当前名为buffer的缓存对象中的部分或者全部数据。起始数据的偏移字节位置为offset,回读的数据大小为size个字节,它们将从缓存的数据区域拷贝到data所指向的内存区域中。如果缓存对象当前已经被映射,或者offset和size的和超出了缓存对象数据区域的范围,那么将提示一个错误。
如果我们使用OpenGL生成了一些数据,然后希望重新获取到它们的内容,那么此时应该使用glGetNamedBufferSubData()。这样的例子包括在GPU级别使用transform feedback处理顶点数据,以及将帧缓存或者纹理数据读取到像素缓存对象(Pixel Buffer Object)中。后文将依次给出这些内容的具体介绍。当然,我们也可以使用glGetBufferSubData()简单地将之前存入到缓存对象中的数据读回到内存中。
3.2.3 访问缓存的内容
目前为止,本节给出的所有函数(glNamedBufferData()、glCopyNamedBufferSubData()和glGetNamedBufferSubData())都存在同一个问题,就是它们都会导致OpenGL进行一次数据的拷贝操作。glNamedBufferSubData()会将应用程序内存中的数据拷贝到OpenGL管理的内存当中。显而易见glCopyNamedBufferSubData()会将源缓存中的内容进行一次拷贝(到另一个缓存或同一个缓存的不同位置)。glGetNamedBufferSubData()则是将缓存对象的数据拷贝到应用程序内存中。根据硬件的配置,其实也可以通过获取一个指针的形式,直接在应用程序中对OpenGL管理的内存进行访问。当然,获取这个指针的对应函数就是glMapBuffer()。
void* glMapBuffer(GLenum target, GLenum access);
将当前绑定到target的缓存对象的整个数据区域映射到客户端的地址空间中。之后可以根据给定的access策略,通过返回的指针对数据进行直接读或者写的操作。如果OpenGL无法将缓存对象的数据映射出来,那么glMapBuffer()将产生一个错误并且返回NULL。发生这种情况的原因可能是与系统相关的,比如可用的虚拟内存过低等。
当我们调用glMapBuffer()时,这个函数会返回一个指针,它指向绑定到target的缓存对象的数据区域所对应的内存。注意这块内存只是对应于这个缓存对象本身—它不一定就是图形处理器用到的内存区域。access参数指定了应用程序对于映射后的内存区域的使用方式。它必须是表3-4中列出的标识符之一。
《OpenGL编程指南(原书第9版)》——3.2 OpenGL缓存数据

如果glMapBuffer()无法映射缓存对象的数据,那么它将返回NULL。access参数相当于用户程序与OpenGL对内存访问的一个约定。如果用户违反了这个约定,那么将产生很不好的结果,例如写缓存的操作将被忽略,数据将被破坏,甚至用户程序会直接崩溃。
当你要求映射到应用程序层面的数据正处于无法访问的内存当中,OpenGL可能会*将数据进行移动,以保证能够获取到数据的指针,也就是你期望的结果。与之类似,当你完成了对数据的操作,以及对它进行了修改,那么OpenGL将再次把数据移回到图形处理器所需的位置上。这样的操作对于性能上的损耗是比较高的,因此必须特别加以对待。
如果缓存已经通过GL_READ_ONLY或者GL_READ_WRITE访问模式进行了映射,那么缓存对象中的数据对于应用程序就是可见的。我们可以回读它的内容,将它写入磁盘文件,甚至直接对它进行修改(如果使用了GL_READ_WRITE作为访问模式的话)。如果访问模式为GL_READ_WRITE或者GL_WRITE_ONLY,那么可以通过OpenGL返回的指针向映射内存中写入数据。当结束数据的读取或者写入到缓存对象的操作之后,必须使用glUnmapNamedBuffer()执行解除映射操作,它的原型如下所示:
GLboolean glUnmapNamedBuffer(Gluint buffer);
解除glMapNamedBufferRange()针对缓存对象buffer创建的映射。如果对象数据的内容在映射过程中没有发生损坏,那么glUnmapBuffer()将返回GL_TRUE。发生损坏的原因通常与系统相关,例如屏幕模式发生了改变,这会影响图形内存的可用性。这种情况下,函数的返回值为GL_FALSE,并且对应的数据内容是不可预测的。应用程序必须考虑到这种几率较低的情形,并且及时对数据进行重新初始化。
如果解除了缓存的映射,那么之前写入到OpenGL映射内存中的数据将会重新对缓存对象可见。这句话的意义是,我们可以先使用glNamedBufferStorage()分配数据空间,并且在data参数中直接传递NULL,之后进行映射并且直接将数据写入,最后解除映射,从而完成了数据向缓存对象传递的操作。例3.2所示就是一个将文件内容读取并写入到缓存对象的例子。
例3.2 使用glMapBuffer()初始化缓存对象
《OpenGL编程指南(原书第9版)》——3.2 OpenGL缓存数据

在例3.2中,文件的所有内容都在单一操作中被读入到缓存对象当中。缓存对象创建时的大小与文件是相同的。当缓存映射之后,我们就可以直接将文件内容读入到缓存对象的数据区域当中。应用程序端并没有拷贝的操作,并且如果数据对于应用程序和图形处理器都是可见的,那么OpenGL端也没有进行任何拷贝的操作。
使用这种方式来初始化缓存对象可能会带来显著的性能优势。其理由如下:如果调用glNamedBufferStorage()或者glNamedBufferSubData(),当返回这些函数后,我们可以对返回的内存区域中的数据进行任何操作—释放它,使用它做别的事情—都是可以的。这也就是说,这些函数在完成后不能与内存区域再有任何瓜葛,因此必须采取数据拷贝的方式。但是,如果调用glMapNamedBufferRange(),它所返回的指针是OpenGL端管理的。当调用glUnmapNamedBuffer()时,OpenGL依然负责管理这处内存,而用户程序与这处内存已经不再有瓜葛了。这样的话即使数据需要移动或者拷贝,OpenGL都可以在调用glUnmapNamedBuffer()之后才开始这些操作并且立即返回,而内容操作是在系统的空闲时间之内完成,不再受到应用程序的影响。因此,OpenGL的数据拷贝操作与应用程序之后的操作(例如建立更多的缓存,读取别的文件,等等)实际上是同步进行的。如果不需要进行拷贝的话,那么结果就再好不过了!此时在本质上解除映射的操作相当于是对空间的释放。
异步和显式的映射
为了避免glMapBuffer()可能造成的缓存映射问题(例如应用程序错误地指定了access参数,或者总是使用GL_READ_WRITE),glMapNamedBufferRange()函数使用额外的标识符来更精确地设置访问模式,glMapNamedBufferRange()函数的原型如下所示:
void * glMapNamedBufferRange(GLuint buffer, GLintptr offset, GLsizeiptr length, GLbitf?ield access);
将缓存对象数据的全部或者一部分映射到应用程序的地址空间中。buffer设置了缓存对象的名字。offset和length一起设置了准备映射的数据范围(单位为字节)。access是一个位域标识符,用于描述映射的模式。
对于glMapNamedBufferRange()来说,access位域中必须包含GL_MAP_READ_BIT和GL_MAP_WRITE_BIT中的一个或者两个,以确认应用程序是否要对映射数据进行读操作、写操作,或者两者皆有。此外,access中还可以包含一个或多个其他的标识符,如表3-5所示。
《OpenGL编程指南(原书第9版)》——3.2 OpenGL缓存数据

正如你在表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的管线有关,因此可能会造成性能上的损失。如果可以确保之后的操作可以在真正修改缓存内容之前完成(不过在调用glMapNamedBufferRange()之前这并不是必须的),例如调用glFinish()或者使用一个同步对象(参见11.3节),那么OpenGL也就不需要专门为此维护一个同步功能了。
最后,GL_MAP_FLUSH_EXPLICIT_BIT标识符表明了应用程序将通知OpenGL它修改了缓存的哪些部分,然后再调用glUnmapNamedBuffer()。通知的操作可以通过glFlush-MappedBufferRange()函数的调用来完成,其原型如下:
void glFlushMappedNamedBufferRange(GLuint buffer, GLintptr offset, GLsizeiptr length);
通知OpenGL,映射缓存buffer中由offset和length所划分的区域已经发生了修改,需要立即更新到缓存对象的数据区域中。
我们可以对缓存对象中独立的或者互相重叠的映射范围多次调用glFlushMapped-NamedBufferRange()。缓存对象的范围是通过offset和length划分的,这两个值必须位于缓存对象的映射范围之内,并且映射范围必须通过glMapNamedBufferRange()以及GL_MAP_FLUSH_EXPLICIT_BIT标识符来映射。当执行这个操作之后,会假设OpenGL对于映射缓存对象中指定区域的修改已经完成,并且开始执行一些相关的操作,例如重新**数据的可用性,将它拷贝到图形处理器的显示内存中,或者进行刷新,数据缓存的重新更新等。就算缓存的一部分或者全部还处于映射状态下,这些操作也可以顺利完成。这一操作对于OpenGL与其他应用程序操作的并行化处理是非常有意义的。举例来说,如果需要从文件加载一个非常庞大的数据块并将他们送入缓存,那么需要在缓存中分配足够囊括整个文件大小的区域,然后读取文件的各个子块,并且对每个子块都调用一次glFlushMappedNamedBufferRange()。然后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()也是唯一一个可以抛弃缓存对象中的区域数据的方法。