Geometry Instancing(几何体实例化),是一种用于大批量重复物件渲染的GPU技术,以降低客户端和显卡端数据传输量,所谓的“一次提交,多次渲染”。在OpenGL 3.x下的Instancing技术已经是作为核心,本文也大致地记录一下自己最近使用时的一些思维片段罢。——ZwqXin.com
本文来源于 ZwqXin (http://www.zwqxin.com/), 转载请注明
原文地址:http://www.zwqxin.com/archives/opengl/talk-about-geometry-instancing.html
不由得想起当年的CityDreamSnow,在那个Demo中,“涉世未深”的我是这样绘制封闭街道两旁的建筑群的:四种手工建筑,按一定顺序和错落关系列于两侧,整个场景中,每种建筑都大概有4、5个吧——而且几乎都是一样的(可以回想的不同之处大概除了位置、旋转和缩放度外,还有配色和一些动画的随机控时之类)。对于每种建筑,大概是这样绘制的:
- for(int i = 0; i < NUM; ++i)
- {
- topColor = nTopCol[rand() % COLCOUNT];
- ....
- glPushMatrix();
- glTranslate(...);
- glRotate(..., 0, 1, 0);
- glScale(..);
- DrawArchitecture(topColor, stripColor, startTick,...);
- glPopMatrix();
- }
这里是通过一个循环调用了NUM个DrawCall(DrawCall在DrawArchitecture里,当然,那时候都是用glBegin/glEnd的,但是这里看做一个glDrawXX好了),在调用前可以设置这次渲染的各种状态(不仅GL状态,还包括上述的各种矩阵变换、配色等等的状态)。
把一次DrawCall作为一个Batch,这样做相当于我们在本地客户端(我们的程序所在)向显卡(OpenGL的“服务端”)连续传输同一份顶点数据共NUM次,这NUM个Batch不同之处仅在于一些顶点属性(attribute)之类的。对于更大的建筑群,或者说广阔的草簇群、NPC群,这样的NUM可能就是成千上万之巨了。显卡不会对这种重复数据多次传输做优化,所以内存和GPU的数据传输负载随着DrawCall的调用次数增多而增大,当程序的效率更多地损失在数据传输上之时,就造成了渲染瓶颈,FPS惨不忍睹。
Geometry Instancing技术就是为了这样的场合而产生的。这时候,我们可以只调用一次DrawCall,把该份顶点数据(VBO所维护的)传输到显卡,并告诉显卡需要绘制多少次(或者说,执行多少次Vertex Shader)。这就是Insatncing所谓的“一次提交,多次渲染”。对于OpenGL来说,这样的操作只需要简单地调用Draw函数的Intanced版本就可以了:
- void glDrawArraysInstanced(GLenum mode, GLint first, GLsizei count GLsizei primcount);
- void glDrawElementsInstanced(GLenum mode, GLsizei count, GLenum type, const void *indicies, GLsizei primcount);
对于VBO([学一学,VBO] [索引顶点的VBO与多重纹理下的VBO] )有了解的话,对上述DrawCall函数的原生版本也不会陌生:glDrawArrays和glDrawElements。这里的Instanced版本也就在最后加了个primcount的参数指明需要绘制的次数而已。当然还有其他的变式函数(OpenGL的DrawCall函数的某些变式的名字那可是很让人惊叹的东西),就不一一列举。
调用该函数后,对于传入流水线的每个顶点,其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)有它的一份模型矩阵。
- 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);
这里都是司空见惯的代码了(见[AB是一家?VAO与VBO] ),我们直接使用一个位置向量作为attribute(当然你也可以使用矩阵,但就要多使用几个attribute location来划分了。事实上我只需要“不同的位置”,那直接使用位置向量,在Shader里再结合进一个单位模型矩阵岂不更好)。但不同之处在于FLOOR_ATTRIB这个shader attribute location的设置方法,有两点:第一点是数据本身。
- std::vector<ZWVector3> floorLocations;
- for (int i = 0; i < m_nInstanceCount; ++i)
- {
- floorLocations.push_back(..floorLocation[i]);
- }
上述交代了数据大致是怎么定义的。注意到了吗,总数是m_nInstanceCount,也就是说它不是按顶点个数来组织的,而是以Instance个数来组织的——它不是顶点的Attribute而是Instance的Attribute。如果单纯从数据量来改变,这是没有效果的(默认还是把这数据当做顶点的数据,一般如果数据个数小于顶点数,那渲染结果就是后半的顶点要悲剧了 - -),真正让它成为Instance专属数据的是glVertexAttribDivisor这个函数——这是第二点。
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]这个数据:
- glBindVertexArray(m_nFloorVAO);
- glDrawElementsInstanced(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, NULL, m_nInstanceCount);
这就是我们需要的。接下来就是在Vertex Shader里根据该attribute去构建模型矩阵,把顶点移到floorLocations[i]指定的位置了。无论是变换矩阵、配色还是其他任何特定于各个Instance的特性,都可以通过这种方法去实现。回到开头的那段代码,应用Geometry Instancing的话:
- //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);
这里只有一个DrawCall,而且所有实例Attribute都用VAO存储好。渲染的时候就简单很多了,“一次提交,多次渲染”。
再提一下,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,六个面都是矩形,模型矩阵和纹理不一样,就可以这样做。
- #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;
- }
再谈到Geometry Shader的缺点,其中一个就是对于CPU端的视锥体剔除(在渲染前设立条件,视锥体外的物体都不渲染)。因为只有一个DrawCall,你将无法根据预先判断把不在视锥体内的Insatnce剔除渲染阵列——所有流水线操作都将执行,这样对于大场景的大批量渲染的场景管理策略失效,会造成效率的负向影响,甚至Geometry Instancing这应用也得不偿失了。
在往后的文章,我将会谈及另一种针对Instanced Objects的剔除方法,也就是在[乱弹纪录I:Geometry Shader]中提及的利用Geometry Shader进行几何元剔除的方式,通过额外的一个简单Pass判定可见性,剔除并FeedBack到第二个Pass渲染视锥体可见的物件。这种方式可以一定程度减小Instancing的上述负向影响。
本文到此结束。随着GPU图形技术的发展,以及大批量物件渲染的需要,过去使用范围很受限的Geometry Instancing如今也越来越重要了,OpenGL对这类技术的支持也越来越丰富,也将越来越更丰富。
本文来源于 ZwqXin ( http://www.zwqxin.com/), 转载请注明.原文地址: http://www.zwqxin.com/archives/opengl/talk-about-geometry-instancing.html