利用现代OpenGL API大幅度减少由于执行驱动导致CPU的开销

来源:互联网 发布:logger4j输出sql 编辑:程序博客网 时间:2024/05/13 07:21

    影响OpenGL绘制的效率直接和OpenGL API相关的一部分来自于其在CPU上执行的开销,一部分来自于渲染本身在GPU上执行的开销。CPU上执行的开销主要是由于调用API导致的OpenGL驱动的开销,这类开销一般可以分成三大类:第一类是由于驱动提交渲染命令的开销,即调用OpenGL draw函数造成;第二类是由于驱动提交状态命令导致的状态命令切换的开销,这种切换命令包括了固定管线中的各种光照函数的切换,片段测试与操作的切换,不同Shader, Texture之间的切换,甚至包括VBO, UBOGPU缓冲对象之间的开销,这两类情况在D3D API使用时也是一样,甚至有表明在D3D中更显著;第三类就是其他由于OpenGL API被调用导致加载或是同步数据的驱动开销。

通过批次合并(即将合理的方式将渲染状态相同多个可渲染物的draw绘制数据合并到一批绘制),以及实例渲染(即将诸多几何数据近似的可渲染物通过一次drawInstance函数绘制,而将这些可渲染物的区别通过数组传入渲染命令中),可以显著降低第一类开销。通过对可渲染物进行有效的排序,将状态相同的部分的可渲染物尽可能依次渲染,从而减少状态的切换,可以较明显减少第二类开销。因此在执行渲染之前,可以通过上述两种方式对数据进行预处理,从而达到目的,这两种策略已经成为3D渲染引擎最常用的提高效率的方法。对此本文不再进行讲解,本文主要聚焦通过新式的OpenGL API来尽可能减少这三类开销

(一)使用非直接Indirect)绘制来减少绘制命令提交导致的开销

在非直接绘制技术之前,常规的OpenGL绘制典型的如glDrawArrays(mode, first, count)glDrawElements(mode, count, type, indicesPtr),需要至少将mode,first,  count等这些信息传入GPU中,这其实就是通过驱动将这些绘制命令传输到GPU中,这部分开销是在CPU端的,若每帧大量调用这些draw方法,会使得驱动频繁得去发送这些命令,从而导致帧率降低,这就是渲染中最常见的影响性能的地方。

现在,通过非直接绘制技术,可以直接将这些绘制命令存在GPU上,从而将这种开销在理论上降为0。第一版本的非直接绘制的API分别为glDrawArraysIndirect(mode, indirectPtr)glDrawElementsIndirect(mode, type, indirectPtr),这里的modetype和直接绘制的API中的意义一样,接口区别是这里有一个要用户传入的结构体指针indirectPtr,这里指针即上文所述的渲染命令相关参数,例如glDrawElementsIndirect中的indirectPtr代表的结构体定义如下:

typedef  struct {

        uint  vertexCount;

        uint  instanceCount;

        uint  firstIndex;

        uint  baseVertex;

        uint  baseInstance;

    } DrawElementsIndirectCommand

如此多的参数,是因为这两个非直接绘制的API对应直接绘制的API分别是glDrawArraysInstancedBaseInstance()glDrawElementsInstancedBaseVertexBaseInstance(mode,vertexCount, type, instanceCount, baseVertex, baseInstance),非直接绘制只有对于直接绘制函数参数个数最做的两个API,没有对应直接绘制函数glDrawArraysglDrawElements等的非直接绘制函数的版本。

glDrawElementsIndirect为例,除了modetype不在indirectPtr代表的结构体中传入,其他那些参数代表意思和直接绘制版本的API的参数意义一致。再来从简单到复杂解释下glDrawElementsInstancedBaseVertexBaseInstance这个API各个参数的意思。

首先glDrawElement升级后第一个版本——glDrawElementsInstanced(mode, count, type, indicesPtr, instanceCount),这是实例渲染的典型绘制APIinstanceCount表示要绘制的实例数目,前面几个参数都是这些实例对象的源对象数据。关于实例渲染,可以参见文章《Geometry Instancing》(http://www.zwqxin.com/archives/opengl/talk-about-geometry-instancing.html),这里不再累述,若instanceCount等于1,此API即退化为glDrawElement

继续来看在原基础上升级后的第二个版本——glDrawElementsBaseVertex(mode, count, type, indicesPtr, baseVertex),这里多了一个baseVertex参数,意思是当多个可渲染物的绘制数据被放到一个数据流中,每次绘制这些可渲染物中的某一个可渲染物的时候,baseVertex即数据流的第几个顶点开始,baseVertex即数据流的第几个顶点开始,当baseVertex等于0时,此API即退化为glDrawElements

再来看升级后的第三个版本——glDrawElementsInstancedBaseVertex(mode, count, type, indicesPtr, instanceCount, baseVertex),这个API是将第一个版本和第二个版本做了合并,参数意思与上述一致。当baseVertex等于0时,此API即退化为glDrawElementsInstanced;当instanceCount等于1时,此API退化为glDrawElementsBaseVertex

最后来看看升级到的第四个版本——glDrawElementsInstancedBaseVertexBaseInstance(mode,vertexCount, type,instanceCount, baseVertex, baseInstance),比第二个版本的API最后多了一个baseIntance参数,这个参数表示在第三个版本的情况基础上,多个诸实例数据信息数据可以也放在一个数据流中,baseInstance即表示从第几个实例开始渲染,当baseInstance等于0时,此API即退化为glDrawElementsInstancedBaseVertex

通常使用第四个版本和第三个版本的情况较少,往往都是使用第一个版本和第二个版本比较多,对应到glDrawArraysIndirect上往往就是将baseInstance置为0等,即使用其退化后的性质。

使用非直接绘制时,一般是将这些绘制命令放在一个专用的gpu缓冲区中,而将传入的indirectPtr参数置为nullptr,类似于glVertexAttribPointer(..., pointer),当使用VBO,属性数据若是存储在VBO中时,这时绑定到该VBO上,然后pointer参数传nullptr,这里的gpu缓冲区绑定区别和VBO也类似,区别就是绑定点不同。使用方法如下:

//赋值

GLuint buffer;

glGenBuffers(1,& buffer);

glBindBuffer(GL_DRAW_INDIRECT_BUFFER, buffer);

glBufferData(GL_DRAW_INDIRECT_BUFFER,sizeof(drawCommandData), drawsCommandData, GL_STATIC_DRAW);

//渲染循环中

glBindBuffer(GL_DRAW_INDIRECT_BUFFER, buffer);

glDrawElementsIndirect (GL_TRIANGLES, GL_UNSIGNED_INT, nullptr);

这样就将一次渲染据大多数命令都直接通过放置在GPU上,减少了绘制APICPU上的开销。但是上述版本的非直接绘制API用法并不是本文要推荐的API,因为如上述用法,当绘制多个可渲染物时,其实还是需要多次绑定indirect buffer并多次调用glDrawElementsIndirect,大量绑定indirect buffer就会导致新的开销,也就是前面说的第二类开销。因此非直接绘制技术只有在大量绘制能合并的情况下才有效,即用一个indirect buffer将多次渲染的命令数据整合。因此,现在来介绍第二个版本的非直接绘制glMultiDrawArraysIndirect(mode, indirectPtr, drawwCount, stride)glMultiDrawElementsIndirect(mode, type, indirectPtr, drawwCount, stride),这里的indirectPtr即非Multi版本APIindirectPtr的数组,drawCount指该数组的个数,stride表示步长,若等于0说明绘制数据来源于紧密的数据流。由于Multi版本的API可将多次绘制命令一起存在GPU,从而大大减少了多次调用绘制API的次数,因此这两个API往往是非直接绘制技术用才真正使用的。

glMultiDrawElementsIndirect为例,该API相当于多次调用glDrawElementsInstancedBaseVertexBaseInstance,这里简化下用其退化的版本说明,即多次调用glDrawElementsBaseVertex,这是用单个绘制API所不能代替的,这时非独立绘制的好处就体现出来了,但是前提是这些绘制的数据单个顶点属性都来自一个数据流,然后渲染状态也一致(实际三维渲染中大量情况都如此)。假设不用非直接绘制,每个draw的渲染命令数据是1620字节,若绘制100万批次,那单渲染命令就是20G!这么多数据每次让OpenGL驱动在CPU上执行的话,那是巨大的开销,现在通过非直接绘制将这种开销理论上降为0

拿一个实例进行说明,这里用4万个直接绘制API和一个Multi版本的非直接绘制APICPU驱动开销上进行比较,例子绘制的是风车,这4万个风车的数据都存在一个VAO中,并且渲染状态一致。如图,使用多个直接绘制API每帧在CPU上开销平均为62ms左右,而使用非直接绘制API每帧在开销则为1ms左右,提高了渲染的效率。实验室中渲染帧率和之前的相比几乎没有提升,这是因为CPUGPU执行本身是异步的,在这个实验中GPU上的时间一直大于CPU上的时间,但这样也为其他CPU执行腾出了空间;但是若CPU上的时间开销明显大于GPU上的时间时,这时这种优化就能明显提高帧率。

直接绘制

非直接绘制

与原来的用法相比,代码更新方法如下所示:

原来渲染代码

glBindVertexArray(vao);

foreach(everyRenderable)

   glDrawElementBaseVertex(GL_TRIANGLES, indexCount, GL_UNSIGED_INT,  indexDataPtr, offsetVertex);

glBindVertexArray(0);


现在渲染代码

glBindBuffer(GL_DRAW_INDIRECT_BUFFER, indirectDrawBuffer);(每个renderable的渲染命令数据都已经预先存好这个buffer中)

glBindVertexArray(vao);

glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGED_INT, nullptr, renderableCount, 0);

glBindVertexArray(0);

glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0);


(二)使用无绑定(bindless)技术来提高状态切换的效率

在渲染中不同纹理,VBOUBO等之间的切换开销是各种状态开销中比较明显的,尤其是这些对象数量众多时较为明显。比如对于纹理,传统的解决办法主要有纹理图册(Texture Atlases),就是将多个纹理合并到一张图中,然后重新给出不同的纹理坐标;另外还有纹理数组(Texture Array),即将这些纹理组装成数组传到OpenGL中。这些方法都存在可能会将不需要的纹理数据负载到管线中,以及纹理的某些参数必须一致的情况:纹理图层必须各种环绕,过滤等都一致,纹理数组也得必须原来的纹理格式和形状一致。通过无绑定技术即可在改动原有代码很少的基础上解决上述问题。

下图是Nvidia给出的状态切换开销程度,越上方颜色越红的表示开销越大,可以作为参考。

glBindTexture, glBindBufferAPI的调用实质上就是驱动对相应的buffertexture进行查找与解引用,之前文章《Nvidia OpenGL无绑定VBOUBO技术》(http://www.opengpu.org/home.php?mod=space&uid=36152&do=blog&id=595),讲过的无绑定VBOUBO原理。这里在说下无绑定纹理,与无绑定VBOUBO相比,这里无绑定纹理是一个ARB通用标准,而不是仅N卡支持的标准,在OpenGL 4.0或以上版本的机器和驱动上支持。同样无绑定纹理也只有在每帧需要做较多的纹理切换时才效果明显,使用方法与传统的纹理使用区别如下实例代码:

//初始化时

GLuint texture;

GLuint64 textureHandle;

glGenTexture(1, texture);

//通过glTexImageglTexStorage初始化纹理

textureHandle = glGetTextureHandleARB(texture);

glMakeTextureHandleResidentARB(textureHandle);

//渲染时:

glUniformHandleui64ARB(location, textureHandle);

//渲染, draw call

(三)使用稀疏(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);

(四)使用新的缓冲区分配接口解决由于GPU缓冲区对象数据同步带来的开销

OpenGL中有一组叫glMapBuffer/glMapBufferRangeAPI,这个APIGPU缓存中的数据映射到内存的地址上,使得内存可以直接通过这个地址将数据读回或将数据写入到该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之前手动调用glFenceSyncglClientWaitSync

glDrawArray(mode, first, count);

已经有证明非同步下的GPU缓存对象进行map的效率比要求同步下明显要高,这时候大量调用glMapBuffer/glMapBufferRange不再是效率瓶颈。

0 0
原创粉丝点击