« shader复习与深入:Depth of Field(景深)乱弹纪录IV:Transform Feedback »

乱弹纪录III:Geometry Instancing

Geometry Instancing(几何体实例化),是一种用于大批量重复物件渲染的GPU技术,以降低客户端和显卡端数据传输量,所谓的“一次提交,多次渲染”。在OpenGL 3.x下的Instancing技术已经是作为核心,本文也大致地记录一下自己最近使用时的一些思维片段罢。——ZwqXin.com

[乱弹纪录I:Geometry Shader]

[乱弹纪录II:Alpha To Coverage]

 本文来源于 ZwqXin (http://www.zwqxin.com/), 转载请注明
      原文地址:http://www.zwqxin.com/archives/opengl/talk-about-geometry-instancing.html

不由得想起当年的CityDreamSnow,在那个Demo中,“涉世未深”的我是这样绘制封闭街道两旁的建筑群的:四种手工建筑,按一定顺序和错落关系列于两侧,整个场景中,每种建筑都大概有4、5个吧——而且几乎都是一样的(可以回想的不同之处大概除了位置、旋转和缩放度外,还有配色和一些动画的随机控时之类)。对于每种建筑,大概是这样绘制的:

C++代码
  1. for(int i = 0; i < NUM; ++i)  
  2. {  
  3.   topColor = nTopCol[rand() % COLCOUNT];  
  4.   
  5.   ....  
  6.   
  7.   glPushMatrix();  
  8.   
  9.   glTranslate(...);  
  10.   
  11.   glRotate(..., 0, 1, 0);  
  12.   
  13.   glScale(..);  
  14.   
  15.   DrawArchitecture(topColor, stripColor, startTick,...);  
  16.   
  17.   glPopMatrix();  
  18. }  

这里是通过一个循环调用了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版本就可以了:

C++代码
  1. void glDrawArraysInstanced(GLenum mode, GLint first, GLsizei count  GLsizei primcount);   
  2.   
  3. 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)有它的一份模型矩阵。

C++代码  (OpenGL Instanced VAO Attribute Setup)
  1. glGenVertexArrays(1, &m_nFloorVAO);
  2.  
  3. glBindVertexArray(m_nFloorVAO);
  4.  
  5. //......
  6.  
  7. glGenBuffers(1, &nFloorLVBO);  
  8.   
  9. glBindBuffer(GL_ARRAY_BUFFER, nFloorLVBO);  
  10.   
  11. glBufferData(GL_ARRAY_BUFFER, floorLocations.size() * sizeof(ZWVector3), floorLocations.data(), GL_STATIC_DRAW);  
  12.   
  13. glEnableVertexAttribArray(FLOOR_ATTRIB);  
  14.   
  15. glVertexAttribPointer(FLOOR_ATTRIB, 3, GL_FLOAT, GL_FALSE, 0, NULL);  
  16.   
  17. glVertexAttribDivisor(FLOOR_ATTRIB, 1);  

这里都是司空见惯的代码了(见[AB是一家?VAO与VBO] ),我们直接使用一个位置向量作为attribute(当然你也可以使用矩阵,但就要多使用几个attribute location来划分了。事实上我只需要“不同的位置”,那直接使用位置向量,在Shader里再结合进一个单位模型矩阵岂不更好)。但不同之处在于FLOOR_ATTRIB这个shader attribute location的设置方法,有两点:第一点是数据本身。

C++代码
  1. std::vector<ZWVector3> floorLocations;  
  2.   
  3. for (int i = 0; i < m_nInstanceCount; ++i)  
  4. {  
  5.     floorLocations.push_back(..floorLocation[i]);  
  6. }  

上述交代了数据大致是怎么定义的。注意到了吗,总数是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]这个数据:

C++代码 (OpenGL Instanced VAO Attribute Render)
  1. glBindVertexArray(m_nFloorVAO);  
  2.   
  3. glDrawElementsInstanced(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, NULL, m_nInstanceCount);  

乱弹纪录III:Geometry Instancing - www.zwqxin.com

这就是我们需要的。接下来就是在Vertex Shader里根据该attribute去构建模型矩阵,把顶点移到floorLocations[i]指定的位置了。无论是变换矩阵、配色还是其他任何特定于各个Instance的特性,都可以通过这种方法去实现。回到开头的那段代码,应用Geometry Instancing的话:

C++代码
  1. //Setup VAO  
  2.   
  3. glGenVAO(..., m_nVAO);  
  4.   
  5. glBindVAO(..., m_nVAO);  
  6.   
  7. glGenVBO(...);  
  8.   
  9. glBindVBO(...);  
  10.   
  11. glBufferData(...InstanceData...);  
  12.   
  13. glEnableVertexAttrib(...);  
  14.   
  15. glVertexAttribPointer(..);  
  16.   
  17. glVertexAttribDivisor(.., 1);  
  18.   
  19. //.....and so on for every instance property   
  20.   
  21. //and Vertex Data VBO  
  22.   
  23. glBindVAO(..., NULL);  
  24.   
  25. ////  
  26.   
  27. //Render  
  28.   
  29. glBindVAO(..., m_nVAO);  
  30.   
  31. 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,六个面都是矩形,模型矩阵和纹理不一样,就可以这样做。

glsl代码 (fragment shader, texture for each instance)
  1. #version 330  
  2. #extension GL_EXT_gpu_shader4 : enable  
  3.   
  4. uniform sampler2DArray   basetexArray;  
  5.   
  6. in vec2 varying_texcoord;  
  7.   
  8. flat in int varying_InstanceID;  
  9.   
  10. layout(location = 0) out vec4 fragColor;  
  11.   
  12. void main(void)  
  13. {  
  14.    vec4 texCol = texture2DArray(basetexArray, vec3(varying_texcoord, varying_InstanceID));  
  15.   
  16.    fragColor = texCol;   
  17. }  

再谈到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

  • quote 1.William
  • 大神,VAO,VBO做OpenGL instance我知道怎么做,用display list怎么做?能否提供点思路,感激不尽!
  • 2015-7-26 21:57:14 回复该留言

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

IE下本页面显示有问题?

→点击地址栏右侧【兼容视图】←

日历

Search

网站分类

最新评论及回复

最近发表

Powered By Z-Blog 1.8 Walle Build 100427

Copyright 2008-2013 ZwqXin. All Rights Reserved. Theme edited from ipati.