乱弹纪录III:Geometry Instancing
发表于2016-08-10
Geometry Instancing(几何体实例化),是一种用于大批量重复物件渲染的GPU技术,以降低客户端和显卡端数据传输量,所谓的“一次提交,多次渲染”。在OpenGL 3.x下的Instancing技术已经是作为核心,本文也大致地记录一下自己最近使用时的一些思维片段罢。——ZwqXin.com
本文来源于 ZwqXin (http://www.zwqxin.com/), 转载请注明
不由得想起当年的CityDreamSnow,在那个Demo中,“涉世未深”的我是这样绘制封闭街道两旁的建筑群的:四种手工建筑,按一定顺序和错落关系列于两侧,整个场景中,每种建筑都大概有4、5个吧——而且几乎都是一样的(可以回想的不同之处大概除了位置、旋转和缩放度外,还有配色和一些动画的随机控时之类)。对于每种建筑,大概是这样绘制的:
C++代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | for ( int i = 0; i < NUM; ++i) { topColor = nTopCol[ rand () % COLCOUNT]; .... glPushMatrix(); glTranslate(...); glRotate(..., 0, 1, 0); glScale(..); DrawArchitecture(topColor, stripColor, startTick,...); glPopMatrix(); } |
把一次DrawCall作为一个Batch,这样做相当于我们在本地客户端(我们的程序所在)向显卡(OpenGL的“服务端”)连续传输同一份顶点数据共NUM次,这NUM个Batch不同之处仅在于一些顶点属性(attribute)之类的。对于更大的建筑群,或者说广阔的草簇群、NPC群,这样的NUM可能就是成千上万之巨了。显卡不会对这种重复数据多次传输做优化,所以内存和GPU的数据传输负载随着DrawCall的调用次数增多而增大,当程序的效率更多地损失在数据传输上之时,就造成了渲染瓶颈,FPS惨不忍睹。
Geometry Instancing技术就是为了这样的场合而产生的。这时候,我们可以只调用一次DrawCall,把该份顶点数据(VBO所维护的)传输到显卡,并告诉显卡需要绘制多少次(或者说,执行多少次Vertex Shader)。这就是Insatncing所谓的“一次提交,多次渲染”。对于OpenGL来说,这样的操作只需要简单地调用Draw函数的Intanced版本就可以了:
1 2 3 | void glDrawArraysInstanced(GLenum mode, GLint first, GLsizei count GLsizei primcount); void glDrawElementsInstanced(GLenum mode, GLsizei count, GLenum type, const void *indicies, GLsizei primcount); |
调用该函数后,对于传入流水线的每个顶点,其Vertex Shader会执行primcount次(当然包括后面的对应的流水线阶段了,都是执行primcount次),每一次就作为一次实例化,亦即一个Instance。在Vertex Shader或者Geometry Shader([乱弹纪录I:Geometry Shader] )里,可以使用gl_InstanceID这个attribute变量来获悉当前的Shader是该DrawCall的第几次执行(当前处理的是第几个Instance)。慢着!这样说的话,Instanced版本的Draw函数下,所有顶点的所有Instance都用同一个Vertex Shader,同一套流水线操作,那岂不最终的结果就是一模一样的?!这primcount个物件岂不完全重叠在一起?
恩。当然咯。
那么我们以前是怎样做的呢?多个DrawCall下,我们可以在DrawCall之前设置好该DrawCall的所有属性。考虑一个简单的情况:让各个物件的位置各不相同,那就在调用DrawCall前传入不同的模型矩阵作为Vertex Shader的uniform。那在Geometry Instancing下,我们只有一个DrawCall,怎样做到上述的效果呢?
我们还有另一种方法向Vertex Shader输入数据:Attribute变量。我们可以把模型矩阵作为顶点的attribute变量,那么每个顶点就有它的一份模型矩阵了。等等,你说这有啥用?是的,这本身没啥改变:因为我们需要的是该顶点的每个Instance有不同的模型矩阵,反而是同一个Instance的所有顶点的模型矩阵都应该是相同的。这里要说的是,我们可以对每个Instance做同样的事情——我们可以把模型矩阵作为顶点的attribute变量,让每个实例(Instance)有它的一份模型矩阵。
C++代码 (OpenGL Instanced VAO Attribute Setup)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | glGenVertexArrays(1, &m_nFloorVAO); glBindVertexArray(m_nFloorVAO); //...... glGenBuffers(1, &nFloorLVBO); glBindBuffer(GL_ARRAY_BUFFER, nFloorLVBO); glBufferData(GL_ARRAY_BUFFER, floorLocations.size() * sizeof (ZWVector3), floorLocations.data(), GL_STATIC_DRAW); glEnableVertexAttribArray(FLOOR_ATTRIB); glVertexAttribPointer(FLOOR_ATTRIB, 3, GL_FLOAT, GL_FALSE, 0, NULL); glVertexAttribDivisor(FLOOR_ATTRIB, 1); |
C++代码
1 2 3 4 5 6 | std::vector for ( int i = 0; i < m_nInstanceCount; ++i) { floorLocations.push_back(..floorLocation[i]); } |
glVertexAttribDivisor第一个参数也还是attribute location,第二个参数指明当前的数据(floorLocations)是每多少个Instance变更一次。这里1的意思是每一个Instance(实例)变更一次,所以渲染时第一个Instance的vertex shader中的FLOOR_ATTRIB对应的attribute都将全是floorLocations[0]这个数据,第二个Insatnce则是对应floorLocations[1]这个数据……第m_nInstanceCount个Instance则是对应floorLocations[m_nInstanceCount - 1]这个数据:
C++代码 (OpenGL Instanced VAO Attribute Render)
1 2 3 | glBindVertexArray(m_nFloorVAO); glDrawElementsInstanced(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, NULL, m_nInstanceCount); |
这就是我们需要的。接下来就是在Vertex Shader里根据该attribute去构建模型矩阵,把顶点移到floorLocations[i]指定的位置了。无论是变换矩阵、配色还是其他任何特定于各个Instance的特性,都可以通过这种方法去实现。回到开头的那段代码,应用Geometry Instancing的话:
这里只有一个DrawCall,而且所有实例Attribute都用VAO存储好。渲染的时候就简单很多了,“一次提交,多次渲染”。
再谈到Geometry Shader的缺点,其中一个就是对于CPU端的视锥体剔除(在渲染前设立条件,视锥体外的物体都不渲染)。因为只有一个DrawCall,你将无法根据预先判断把不在视锥体内的Insatnce剔除渲染阵列——所有流水线操作都将执行,这样对于大场景的大批量渲染的场景管理策略失效,会造成效率的负向影响,甚至Geometry Instancing这应用也得不偿失了。
C++代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | //Setup VAO glGenVAO(..., m_nVAO); glBindVAO(..., m_nVAO); glGenVBO(...); glBindVBO(...); glBufferData(...InstanceData...); glEnableVertexAttrib(...); glVertexAttribPointer(..); glVertexAttribDivisor(.., 1); //.....and so on for every instance property //and Vertex Data VBO glBindVAO(..., NULL); //// //Render glBindVAO(..., m_nVAO); DrawArchitecture(..., NUM); |
再提一下,glVertexAttribDivisor的第二个参数,如果是2的话,那就是每两个Instance变更一次instance attribute……如此类推。那如果是0呢?那就是跟以前一样,数据“退化”变成顶点Attribute了,呵呵。
还有没有其他方法呢?
再回头看一看Uniform这种类型的输入参数。Uniform一般是针对每个DrawCall的,目前是无法“降格”到针对每个Insatnce(与此相对,attribute一般是针对每个顶点的,可以“升格”到针对每个Instance,如上所述)。但是我们也可以把所有的Instance数据打包成一个Array,作为uniform传入vertex shader——上面不是提及gl_InstanceID这个东西的作用了么?用它来检索这个Array不就OK了么!当然了这个方法需要在DrawCall前传入一个或许很“重”的unifom变量(使用UBO或许可以减小GLSL对uniform变量占宽的限制),Vettex Shader里也得多个检索。至于什么方法更好,就看应用场合+见仁见智了。像如果每个实例需要不同的纹理,那最好的方案是传入一个texture Array([学一学, Texture Array纹理数组] ),然后使用gl_InstanceID来检索(注意它是个int值,传入fragment shader里的时候要指定flat来避免栅格化插值)。像一个天空盒SkyBox,六个面都是矩形,模型矩阵和纹理不一样,就可以这样做。
glsl代码 (fragment shader, texture for each instance)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | #version 330 #extension GL_EXT_gpu_shader4 : enable uniform sampler2DArray basetexArray; in vec2 varying_texcoord; flat in int varying_InstanceID; layout(location = 0) out vec4 fragColor; void main( void ) { vec4 texCol = texture2DArray(basetexArray, vec3(varying_texcoord, varying_InstanceID)); fragColor = texCol; } |
在往后的文章,我将会谈及另一种针对Instanced Objects的剔除方法,也就是在[乱弹纪录I:Geometry Shader]中提及的利用Geometry Shader进行几何元剔除的方式,通过额外的一个简单Pass判定可见性,剔除并FeedBack到第二个Pass渲染视锥体可见的物件。这种方式可以一定程度减小Instancing的上述负向影响。
本文到此结束。随着GPU图形技术的发展,以及大批量物件渲染的需要,过去使用范围很受限的Geometry Instancing如今也越来越重要了,OpenGL对这类技术的支持也越来越丰富,也将越来越更丰富。
本文来源于 ZwqXin (http://www.zwqxin.com/), 转载请注明