OpenGL Draw函数族以及渲染优化
前言
影响OpenGL绘制的效率直接和OpenGL API相关的一部分来自于其在CPU上执行的开销,一部分来自于渲染本身在GPU上执行的开销。CPU上执行的开销主要是由于调用API导致的OpenGL驱动的开销,这类开销一般可以分成三大类:
(1)由于驱动提交渲染命令的开销,即调用OpenGL draw函数造成;
(2)第二类是由于驱动提交状态命令导致的状态命令切换的开销,这种切换命令包括了固定管线中的各种光照函数的切换,片段测试与操作的切换,不同Shader, Texture之间的切换,甚至包括VBO, UBO等GPU缓冲对象之间的开销,这两类情况在D3D API使用时也是一样,甚至有表明在D3D中更显著;
(3)第三类就是其他由于OpenGL API被调用导致加载或是同步数据的驱动开销。
通过批次合并(即将合理的方式将渲染状态相同多个可渲染物的draw绘制数据合并到一批绘制),以及实例渲染(即将诸多几何数据近似的可渲染物通过一次drawInstance函数绘制,而将这些可渲染物的区别通过数组传入渲染命令中),可以显著降低第一类开销。通过对可渲染物进行有效的排序,将状态相同的部分的可渲染物尽可能依次渲染,从而减少状态的切换,可以较明显减少第二类开销。因此在执行渲染之前,可以通过上述两种方式对数据进行预处理,从而达到目的,这两种策略已经成为3D渲染引擎最常用的提高效率的方法。对此本文不再进行讲解,本文主要聚焦通过新式的OpenGL API来尽可能减少这三类开销。
1.最基本的Draw函数
1.1 glDrawArrays
- glDrawArrays(GLenum mode, GLint first, GLsizei count)
为最常用的Draw函数,按mode指定的图元类型,画出vertex buffer中第first个起的count个顶点。
1.2 glDrawElements
- glDrawElements(GLenum mode, GLsizei count, GLenum type, GLvoid* indices)
使用glDrawElements前,除了类似glDrawArrays一样准备好VAO和VBO外,还需要产生并绑定EBO(Element Buffer Object),注入的数据为一个类型为type的数组,其元素表示vertex buffer中顶点索引。glDrawElement以按照指定的图元类型mode,以其前count个元素作为VBO中顶点数据的索引。
2.基本Draw函数的变种
2.1 *Instanced
- glDrawArraysInstanced(GLenum mode, GLsizei first, GLsizei count, GLsizei primcount)
- glDrawElementsInstanced(mode, count, type, indicesPtr, instanceCount)
当vertex buffer中的顶点会被多个图元频繁使用时,DrawElements比DrawArrays更为高效;
当需要将一个模型多次渲染,并且每次具有不同的位置或某种属性时,DrawArraysInstanced为更为有效简洁。
在应用中有时需要多次画同一个模型,但每次该模型的部分属性有所不同。例如画楼群,森林时,可以使用同一个模型,每次为该模型赋予不同的坐标变换,颜色等等。这样需要调用多次Draw函数,每次使用新的属性数据等。
DrawArraysInstanced便是为这种需要准备的,它接收5个参数,前4个参数与DrawArrays一致。我们将其中指定的count个顶点构成的模型称为一个Instance,第5个参数primcount表示要重复画多少个instance。使用glVertexAttribDivisor(GLuint index, GLuint divisor)
将序数为index的属性指定为instance属性,每隔divisor个instance
,vertex shader中注进buffer一个新的属性值。
2.2 *BaseVertex
- glDrawElementsBaseVertex(mode, count, type, indicesPtr, baseVertex)
用于扩展DrawElements,当根据indices作为索引从vertex buffer中读取数据时,有时希望允许一定数目顶点的偏移——例如当vertex buffer中存着动画的多帧,需要按照一定的偏移取每一帧的数据进行渲染。该偏移通过一个额外的参数GLint basevertex
指定。
2.3 *BaseInstance
用于扩展Draw*Instanced,通过一个额外的参数GLint baseInstance
指定按照baseInstace作为偏移从buffer中取出instance属性的数据。
2.4 *BaseInstanceBaseVertex
同时具有前面两种作用。
2.5 *Indirect
间接命令绘制的缓存对象需要被存储于GL_DRAW_INDIRECT_BUFFER
中。
- glDrawArraysIndirect(GLenum mode, const void*indirect)
typedef struct DrawArraysIndirectCommand_t{
GLuint count;
GLuint primCount;
GLuint first;
GLuint baseInstance;
} DrawArraysIndirectCommand;
- glDrawElementsIndirect(GLenum mode, GLenum type, const GLvoid * indirect)
typedef struct DrawElementsIndirectCommand_t{
GLuint count;
GLuint primCount;
GLuint firstIndex;
GLuint baseVertex;
GLuint baseInstance;
} DrawElementsIndirectCommand;
DrawArraysIndiret具有DrawArraysInstanced一样的参数及功能,只不过其参数间接从类型为GL_DRAW_INDIRECT_BUFFER
的buffer中读出。
DrawElementsIndiret
具有DrawElementsInstanceBaseVertex
一样的参数及功能,只不过其参数间接从buffer中读出。
2.6 小结
两个常用函数
- glDrawElementsInstancedBaseVertex(mode, count, type, indicesPtr, instanceCount, baseVertex)
当baseVertex等于0时,此API即退化为glDrawElementsInstanced;当instanceCount等于1时,此API退化为glDrawElementsBaseVertex。
- glDrawElementsInstancedBaseVertexBaseInstance(mode, vertexCount, type, instanceCount, baseVertex, baseInstance)
当baseInstance等于0时,此API即退化为glDrawElementsInstancedBaseVertex。
这样就将一次渲染据大多数命令都直接通过放置在GPU上,减少了绘制API在CPU上的开销。但是上述版本的非直接绘制API用法并不是本文要推荐的API,因为如上述用法,当绘制多个可渲染物时,其实还是需要多次绑定indirect buffer并多次调用glDrawElementsIndirect,大量绑定indirect buffer就会导致新的开销,也就是前面说的第二类开销。因此非直接绘制技术只有在大量绘制能合并的情况下才有效,即用一个indirect buffer将多次渲染的命令数据整合。因此,现在来介绍第二个版本的非直接绘制glMultiDrawArraysIndirect(mode, indirectPtr, drawwCount, stride)
和glMultiDrawElementsIndirect(mode, type, indirectPtr, drawwCount, stride)
,这里的indirectPtr即非Multi版本API中indirectPtr的数组,drawCount指该数组的个数, stride表示步长,若等于0说明绘制数据来源于紧密的数据流。由于Multi版本的API可将多次绘制命令一起存在GPU,从而大大减少了多次调用绘制API的次数,因此这两个API往往是非直接绘制技术用才真正使用的。
3.多重Draw函数
- glMultiDrawArrays(GLenum mode, const GLint * first, const GLint * count, GLsizei primcount)
- glMultiDrawElementsIndirect(GLenum mode,GLenum type,const void*indirect,GLsizei primcount,GLsizei stride)
在GPU中调用,其实现类似:
void glMultiDrawArrays(GLenum mode,const GLint * first,onst GLint * count, GLsizei primcount)
{
GLsizei i;
for (i = 0; i < primcount; i++)
{
glDrawArrays(mode, first[i], count[i]);
}
}
glDrawElementsInstancedBaseVertexBaseInstance
,这里简化下用其退化的版本说明,即多次调用glDrawElementsBaseVertex
,这是用单个绘制API所不能代替的,这时非独立绘制的好处就体现出来了,但是前提是这些绘制的数据单个顶点属性都来自一个数据流,然后渲染状态也一致(实际三维渲染中大量情况都如此)。假设不用非直接绘制,每个draw的渲染命令数据是16或20字节,若绘制100万批次,那单渲染命令就是20G!这么多数据每次让OpenGL驱动在CPU上执行的话,那是巨大的开销,现在通过非直接绘制将这种开销理论上降为0!
4.无绑定技术(bindless)
在渲染中不同纹理,VBO,UBO等之间的切换开销是各种状态开销中比较明显的,尤其是这些对象数量众多时较为明显。比如对于纹理,传统的解决办法主要有纹理图册(Texture Atlases),就是将多个纹理合并到一张图中,然后重新给出不同的纹理坐标;另外还有纹理数组(Texture Array),即将这些纹理组装成数组传到OpenGL中。这些方法都存在可能会将不需要的纹理数据负载到管线中,以及纹理的某些参数必须一致的情况:纹理图层必须各种环绕,过滤等都一致,纹理数组也得必须原来的纹理格式和形状一致。通过无绑定技术即可在改动原有代码很少的基础上解决上述问题。
glBindTexture
, glBindBuffer
等API的调用实质上就是驱动对相应的buffer和texture进行查找与解引用,之前文章《Nvidia OpenGL无绑定VBO与UBO技术》(http://www.opengpu.org/home.php?mod=space&uid=36152&do=blog&id=595),讲过的无绑定VBO与UBO原理。这里在说下无绑定纹理,与无绑定VBO与UBO相比,这里无绑定纹理是一个ARB通用标准,而不是仅N卡支持的标准,在OpenGL 4.0或以上版本的机器和驱动上支持。同样无绑定纹理也只有在每帧需要做较多的纹理切换时才效果明显,使用方法与传统的纹理使用区别如下实例代码:
//初始化时
GLuint texture;
GLuint64 textureHandle;
glGenTexture(1, texture);
//通过glTexImage或glTexStorage初始化纹理
textureHandle = glGetTextureHandleARB(texture);
glMakeTextureHandleResidentARB(textureHandle);
//渲染时:
glUniformHandleui64ARB(location, textureHandle);
//渲染, draw call
5.稀疏纹理技术(sparse)
当大规模纹理被OpenGL载入时,当纹理数据的大小超过当前显卡能分配的显存时,这时候纹理会被分页的加载到显存中,这种分页不是稳定的,这就在一定程度上降低了使用大纹理程序的性能,甚至在某些情况下会导致程序崩溃。这种开销也是在驱动上进行的,同样才CPU端。在OpenGL 4.3及以上版本中,可以利用稀疏纹理技术解决这个问题。所谓稀疏纹理技术,就是OpenGL对输入的纹理按照用户指定的参数固定的对大纹理进行了分页与金字塔处理,形成一个虚拟的被分割后纹理,因此称“稀疏”纹理。这使得纹理在实际加载中实现了稳定的动态分页调度,从而改善了大纹理加载的效率。分页纹理的原理
使用方式如下:
glGenTexture(1, &id);
glBindTexture(GL_TEXTURE_2D, id);
//首先要调用纹理参数接口,让OpenGL知道使用稀疏纹理特性
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SPARSE_ARB, GL_TRUE);
// 分配空间,创建了一个金字塔为10层,长宽各为1024像素的纹理
glTexStorage2D(GL_TEXTURE_2D, 10, GL_RGBA8, 1024, 1024);
// 得到可用的分页数
glGetInternalformativ(GL_TEXTURE_2D, GL_NUM_VIRTUAL_PAGE_SIZES_ARB, GL_RGBA8, sizeof(GLint), &num_sizes);
// 得到实际的页大小
glGetInternalformativ(GL_TEXTURE_2D, GL_VIRTUAL_PAGE_SIZE_X_ARB,
GL_RGBA8, sizeof(page_sizes_x), &page_sizes_x[0]);
glGetInternalformativ(GL_TEXTURE_2D, GL_VIRTUAL_PAGE_SIZE_Y_ARB, GL_RGBA8, sizeof(page_sizes_y), &page_sizes_y[0]);
//设置分页的大小
glTexParameteri(GL_TEXTURE_2D, GL_VIRTUAL_PAGE_SIZE_INDEX_ARB, n);
//然后将分配好的这些虚拟空间提交到OpenGL中,并进行真正存储空间的分配
glTexPageCommitmentARB(GL_TEXTURE_2D, level, xoffset, yoffset, zoffset, width, height, depth, commit);
//之后再将纹理原始数据和常规纹理一样载入
glTexImage2D(GL_TEXTURE_2D, level, internalFormat, width, height, border, format, type, dataPtr);
6.使用新的缓冲区分配接口解决由于GPU缓冲区对象数据同步带来的开销
在OpenGL中有一组叫glMapBuffer/glMapBufferRange
的API,这个API将GPU缓存中的数据映射到内存的地址上,使得内存可以直接通过这个地址将数据读回或将数据写入到该GPU缓冲中。这是这个函数已经被各方面证明了出奇得慢,慢到似乎不管填什么参数,在渲染时如果稍微多次调用该函数,那么时间的开销就无法接受,必须换其他方法(D3D中类似的API也慢,但是要比OpenGL要好些,还没有到必须换其他方法的程度)。可以通过使用glGetBufferSubData/glBufferSubData
来分别替换map的读写操作。之所以这个API慢,是因为调用该API进行了对GPU的同步操作。
对于常常需要修改其内容的GPU缓存对象,解决此问题的方法还有使用glBufferStorage(target, size, dataPtr, flags)
这个API来代替进行数据的分配,使得在后续使用glMapBuffer/glMapBufferRange
时可以不进行同步操作,也就是说缓冲区在被映射的状态下,GPU也能使用这个对象而不发生任何错误,并且可以在被映射的状态下不管是CPU还是GPU哪端更改了数据,被更新后的都立刻在另一端可见。GPU缓冲区对象的这个特性在OpenGL 4.4或更高版本中得以支持,使用方式如下:
glGenBuffer(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
GLbitfield flags = GL_MAP_WRITE_BIT
| GL_MAP_PERSISTENT_BIT //在被映射状态下不同步
| GL_MAP_COHERENT_BIT //数据对GPU立即可见
//为Buffer分配数据,取代之前的glBufferData
glBufferStroage(GL_ARRAY_BUFFER, size, data, flags);
//映射一次即可,保存该指针后用于渲染时使用
GLvoid* dataPtr = glMapBuffer(GL_ARRAY_BUFFER, flags);
//渲染时用dataPtr修改数据
.....
//接着可以直接调用draw call,不需要调用glUnmapBuffer,如果需要同步的话,需要在draw之前手动调用glFenceSync与glClientWaitSync。
glDrawArray(mode, first, count);
已经有证明非同步下的GPU缓存对象进行map的效率比要求同步下明显要高,这时候大量调用glMapBuffer/glMapBufferRange
不再是效率瓶颈。
参考文献
OpenGL中的绘制命令,https://blog.csdn.net/yyww322/article/details/47807637
利用现代OpenGL API大幅度减少由于执行驱动导致CPU的开销,https://blog.csdn.net/kasteluo/article/details/52810551