乱弹纪录III:Geometry Instancing

发表于2016-08-10
评论0 4.8k浏览
  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(); 
}
  这里是通过一个循环调用了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
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);
  对于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
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);
  这里都是司空见惯的代码了(见[AB是一家?VAO与VBO] ),我们直接使用一个位置向量作为attribute(当然你也可以使用矩阵,但就要多使用几个attribute location来划分了。事实上我只需要“不同的位置”,那直接使用位置向量,在Shader里再结合进一个单位模型矩阵岂不更好)。但不同之处在于FLOOR_ATTRIB这个shader attribute location的设置方法,有两点:第一点是数据本身。

  C++代码  
1
2
3
4
5
6
std::vector 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]这个数据:

  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的话:

  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);
  这里只有一个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
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;  
}
  再谈到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/), 转载请注明















如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引