乱弹纪录IV:Transform Feedback
发表于2016-08-10
Transform Feedback是SM4.0显卡新流水线下带来的又一项新特性,它使得当前在GPU端流水线上的顶点数据能够有机会回传到我们本地端的应用程序内存中。这个特性带来的渲染流程上的变化,也使得基于2-Pass的场景决策渲染技术进入我们的视野。——ZwqXin.com
当然,只是一对一替换成VBOs的话也很简单的,初始化跟渲染替换一下:
COUNT有多大,就生成多少个VBO,这样是很不环保的。但是当你要把所有数据都放进一个VBO里的时候,初始化不需要for循环了,渲染也不需要多个DrawCall了,你才会意识到这时候的更新变得多么麻烦:
更新的时候,相当于将一批新的顶点数据传给VBO了,这又将是巨大的性能问题。这时你想到了Shader,何不把整个更新过程移交Shader来完成?可是别忘了,VBO中的数据是固定的,每次传入Shader的顶点变量(vertex attribute)都是粒子在初始状态的数值,所以Shader里应该是一个累积型更新的过程:
累积型更新只有在你从一开始就确定好粒子的所有运动轨迹的时候才有效。譬如你某个瞬间临时更改速度的方向、或是其他特性,都会导致粒子的位置突变。即使你设立了很多边界条件,确保粒子轨迹的正常,你也很难去调整粒子的个数——粒子生命期的结束、新粒子的诞生等等;即使你在VBO中设定足够多的粒子数量,通过隐藏和重现某些粒子来模拟粒子的消失和诞生,你也很难去索引各个粒子的行为……说了这么久就是说明了粒子系统在新世代的GPU渲染中有必要有新的渲染手法——抱歉,终于把话题重新拐回来了:Transform Feedback。 ![](http://gameweb-img.qq.com/gad/20160810/phpO4UcKC.1470840453.jpg)
这里是GL3.x的Transform Feedback(其实相比GL4.x虽然功能效率都差些,但也有可读性好点的优势嘛。GL4.x那一套我看过,如果是用于粒子系统这种输入输出交换的模式,那是很容易就头晕了)。使用GL_RASTERIZER_DISCARD这个状态可以开启或关闭当前的DrawCall是否在栅格化前就结束(启用的话,数据在Geometry Shader之后就不再进入栅格化阶段了);整个Tranform Feedback过程:在绑定了一个作为tranform feedback buffer的VBO后,在glBeginTransformFeedback/glEndTransformFeedback之间的DrawCall将会把结果按GL_PONTS的组织形式输出到该Buffer中(这里glBeginTransformFeedback的这个图元组织参数要与Geometry Shader一致,或者没有Geometry Shader的话就得跟glDrawArrays的那个一致咯)。云簇更新的操作在第一个Pass完成了,第二个Pass就是以该结果数据的VBO为输入的一般的Billboard渲染了。
Transfom Feedback还有一个重要的方面,就是输出数据的形式。上面提及的图元组织形式只是一方面(而且它必须跟Geometry Shader输出图元或DrawCall函数的图元参数一致),还有一方面就是这些数据究竟是”哪些数据“。事实上,Transform Feedback输出的并不是gl_Position(甚至如果你不需要后续流水线操作的话,你在Vertex Shader或Geometry Shader里也不需要给这个输出值赋值了),而是我们预先告诉它的输出顶点属性(out varying)。在上面的Geometry Shader中,我们的输出其实是varing_gf_position、 varing_gf_cloudcount、varing_gf_dimension这三个varing变量。它们在哪里指定呢——在建立Shader的时候,正确地说是该Shader program编译(compile)完各Shader但尚未链接(link)的时候:
首先,我真不知道该怎么翻译表达Transform Feedback,变换-反馈?Feedback很好理解,因为它点明了这项技术的特性——让OpenGL反馈给我们关于流水线中的数据的信息(在Direct3D 中,这技术被称为Stream-output,所以我们也会把Feedback这种行为称为“stream out”。)。那么很自然地要问,是什么数据?在OpenGL的辞典中,Transform更常用于矩阵变换这个意义(在传统渲染管线中,顶点进入流水线的第一步就是T&L——顶点矩阵变换+顶点光照计算),因此我们很容易联想到,这里的数据应该就是指顶点数据了。Transform Feedback,就是将Geometry Shader输出的顶点数据(如果没有使用Geometry Shader,则是Vertex Shader输出的数据)回传到我们客户端的一个缓存——Transform Feedback Buffer中。
话说,如果要体验Transform Feedback的强大,最好还是拥有一张SM5.0的显卡,也就是说,在OpenGL 4.x下使用。毕竟,Transform Feedback这技术也在不断强化中,但要说最大的原因,就是使用风格上的不同——在OpenGL 4.x上的Transform Feedback(GL_transform_feedback2)跟很多其他GL的概念一样,是作为一种State Object(transfrom feedback object)的形式存在的,可以在初始化时绑定一个或多个VBO([学一学,VBO] )作为自己的Transform Feedback Buffer(这一点和VAO就很像了[AB是一家?VAO与VBO] ),这个状态对象可以直接用于后续的渲染步骤(第二个Pass的绘制),也可以通过它对对应的transform feedback过程进行暂停和重启等等;而SM4.0下的OpenGL 3.x,只有通过GL_NV_transform_feedback来支持Transform Feedback,先不说这本质是N卡的拓展,其使用方式也很tricky:绑定某个VBO来作为输出这个步骤是渲染时才设定的,之后使用这个VBO的数据若需要知道其大小还得用个query object去进行异步查询(容易产生time block)……但是呢,如果你是像我一样穷到没钱为显卡更新换代的话,那就只好将就着用了(本文基于OpenGL 3.x)。顺便得提及一下,GL 3.x下要用glew来检查这个功能是否能用,最好使用GL_NV_transform_feedback这个宏而不是GL_transform_feedback。
举一个常见的应用场合:粒子系统。以前(还可以随心所欲使用glVertex3f的时代),这东西很好理解:初始化、更新、渲染、销毁,for循环,CPU计算。但是一旦步入GL3.0这只有VBO的时代,就傻眼了:这是怎么更新的?
一、OpenGL代码 (glVertex3f的时代)
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | struct Particle { vec3 pos; vec3 rot; //... }; Particle particle[COUNT]; //初始化 init() { for ( int i = 0; i < COUNT; ++i) { particle[i].pos = vec3(...); particle[i].rot = ...; //... } } //渲染 render() { for ( int i = 0; i < COUNT; ++i) { //.... glBegin(...); glVertex3fv(particle[i].pos); glEnd(); //... } } //更新 update() { for ( int i = 0; i < COUNT; ++i) { particle[i].pos += vec3(...); particle[i].rot += ...; //... } } //销毁 destroy(){} |
二、OpenGL代码 (COUNT VBOs)
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 32 33 | GLuint particlePosVBO[COUNT]; GLuint particleRotVBO[COUNT]; //初始化 init() { glGenBuffers(COUNT, particlePosVBO); glGenBuffers(COUNT, particleRotVBO); PosData = vec3(0); for ( int i = 0; i < COUNT; ++i) { glBindBuffer(particlePosVBO[i]...); glBufferData(.....Quad PosData); //... } } //渲染 render() { for ( int i = 0; i < COUNT; ++i) { glTranslate( particle[i].pos); //.... glBindBuffer(particlePosVBO[i]...); glDawArray(...); //... } } |
三、OpenGL代码 (One VBO)
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 32 33 34 35 36 37 38 39 40 | GLuint particlePosVBO; GLuint particleRotVBO; //初始化 init() { glGenBuffers(1, &particlePosVBO); glGenBuffers(1, &particleRotVBO); particlePosData[] = {vec3(...), ...}; //All Data glBindBuffer(particlePosVBO...); glBufferData(.....PosData); //... } //渲染 render() { //.... glBindBuffer(particlePosVBO...); glDawArray(...); //... } //更新 update() { for ( int i = 0; i < COUNT; ++i) { particlePosData[i] += vec3();... //... } glBindBuffer(particlePosVBO...); glBufferSubData(.....particlePosData); } |
四、glsl代码 (accumulated update)
1 2 3 4 5 6 7 8 9 10 11 12 | void main() { vec3 newPos = mat(attribute_Rot) * gl_Vertex; newPos = gl_Vertex + attribute_velocity * uniform_Time; newPos = otherUpdate(newPos); //... gl_Position = newPos; } |
避免累积型更新的方法,就是避免规划性。这时候需要回归之前的那种思考模式——某个粒子在当前帧仅仅知道前一帧结束时自己的状态,并以此为状态更新的依据。但我们还是不想使用上述glBufferSubData这种疯狂的更新方式,我们还是希望更新由Shader来完成——Shader的顶点输入数据,可能是上一帧结束时的顶点数据吗?来看看Transform Feedback是怎么完成这个需求的:
![](http://gameweb-img.qq.com/gad/20160810/phpO4UcKC.1470840453.jpg)
这类似于给予某个物体一个初始动力(Impulse)之后,让它一直在理想光滑圆形轨道上循环运动,同时可以在某个站台装货在某个站台卸货。在这里,我们将粒子的初始状态作为一次性的输入(之后就没它的事情了),粒子流在经过顶点相关的处理(更新)后,一方面继续流水线直到输出到屏幕,另一方面通过Transfom Feedback返回,作为下一轮循环的输入。因为每次作为输入的数据都是当前“最新”的,所以它可以很简单地针对每个粒子当前状态作出下一个状态的判断和更新(在[乱弹纪录I:Geometry Shader]中我提及过,使用GL_POINT去表达粒子,这样使得粒子的更新可以直接在Vertex Shader(或Geometry Shader下的顶点处理)这个层面进行)。粒子系统的更新问题,于是就可以通过这样的途径去解决。
要注意的一点是,我们上面的流水线中,粒子数据是使用一个Buffer作为输入、另一个Buffer作为(Trasform Feedback的)输出的,因为一个Buffer不可能同时作为同一批次流水线数据的输入和输出(考虑流水线顶点数据的并行处理)。于是我们的Input Buffer和Output Buffer是两个不同的Buffer,要完成上面那个循环,就得不断交换这两个Buffer作为“输入“和”输出“的角色——当前帧,VBO1作为Input Buffer数据在Shader内更新后通过Transform Feedback输出到Output Buffer(VBO2),下一帧则由VBO2作为Input Buffer,VBO1作为Output Buffer……
另外,既然我们采用的是GL_POINT去表达粒子,以”点“为单位更新,那么我们在Output Buffer中输出也应该是GL_POINT,这样它才能在下一帧作为等价的输入。这里便产生了矛盾——对Transform Feedback而言我们的Geometry Shader应该输出points;但对于我们的流水线而言要使接下来的Rasterization(栅格化)有意义、让最终在屏幕上能看见一个个Quad的粒子而不是一个个的点,Geometry Shader的输出就应该是triangle_strip。这是无法调和的,因为Transform Feedback必须会在确定性地产生图元后才好执行,而Geometry Shader阶段是目前流水线中最后影响图元输出的阶段([乱弹纪录I:Geometry Shader])!
折衷的方式就是采用2-Pass的渲染流程——第一个Pass(Update-Pass)直接按上所述进行输入-输出的循环,但不进入后续的流水线操作;第二个Pass(Render-Pass)把刚给到Output Buffer(transform feedback buffer)里的数据再绘制出来,这一次不需要更新了(因为已经是本帧在Pass1进行更新后的结果了),但需要在Geometry Shader里把points膨胀成quads,输出进行裁剪和栅格化、像素处理、输出到屏幕。
![](http://gameweb-img.qq.com/gad/20160810/phpYj6je8.1470840580.jpg)
半空的云簇乍一看的话,作为一个粒子系统貌似离题千尺。但是其实这些东西——包括草簇啊之类各种——都可以是粒子系统:成批细小对象的初始化、更新、渲染、销毁。当然了,那些细小的粒子系统是不会考虑深度排序的、也不会特意作为billboard,但这里的云簇就会具有这两种特性。(关于Geometry Shader产生billboard,我在[乱弹纪录I:Geometry Shader]这篇文章提及过了;关于解决大量物件的深度排序的一种方式——A2C,我在[乱弹纪录II:Alpha To Coverage]这篇文章也描述过。)这里把它看做粒子系统,使用Transform Feedback执行2-Pass的更新和渲染,使各个云簇具有自己的速度并自主地飘动。
五、C++代码 (OpenGL Tranform Feedback)
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | //Pass1 Update { m_CloudFeedShader.Enable(); //... glEnable(GL_RASTERIZER_DISCARD); static bool bShouldDoOriginalInput = false ; if (!bShouldDoOriginalInput) { bShouldDoOriginalInput = true ; glBindVertexArray(m_nCloudVAO); //Input } else { //Use Input VBO (在本VAO内) glBindVertexArray(m_nTFCloudVAO[m_nTFCloudVAOIndex]); //Input //m_nTFCloudVAOIndex现在指向下一个VBO(作为Output) m_nTFCloudVAOIndex = (m_nTFCloudVAOIndex + 1) % 2; } //GL3.0 Bind Output VBO as Transform Feedback Buffer // GL_INTERLEAVED_ATTRIBS // glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, m_nTFCloudVBO); //GL_SEPARATE_ATTRIBS glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, m_nTFCloudVBO0[m_nTFCloudVAOIndex]); //Output //glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 1, m_nTFCloudVBO1[m_nTFCloudVAOIndex]);//Output glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 2, m_nTFCloudVBO2[m_nTFCloudVAOIndex]); //Output glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 3, m_nTFCloudVBO3[m_nTFCloudVAOIndex]); //Output glBeginTransformFeedback(GL_POINTS); glDrawArrays(GL_POINTS, 0, m_CloudClusterVec.size()); glEndTransformFeedback(); glDisable(GL_RASTERIZER_DISCARD); } //Pass2 Render { m_CloudShader.Enable(); //... glBindTexture(GL_TEXTURE_2D, m_nCloudTex); //Bind Output VBO(in the VAO) as data for Pass2 glBindVertexArray(m_nTFCloudVAO[m_nTFCloudVAOIndex]); glDrawArrays(GL_POINTS, 0, m_CloudClusterVec.size()); m_CloudShader.Disable(); |
六、glsl代码 (Pass1 Geometry Shader for Updating)
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 32 33 34 35 36 37 38 39 40 41 42 | #version 330 #extension GL_EXT_gpu_shader4 : enable layout(points) in; layout(points, max_vertices = 5) out; uniform float lastElapsedTime; uniform float windStrength; uniform float windSpeed; uniform vec3 windDirection; in vec3 varying_vg_randvalue[]; in vec3 varying_vg_dimension[]; in int varying_vg_cloudcount[]; out vec3 varing_gf_position; out int varing_gf_cloudcount; out vec3 varing_gf_dimension; void main( void ) { for ( int i = 0; i < gl_in.length(); ++i) { vec3 vLastPos = gl_in[i].gl_Position.xyz; int nCloudCount = varying_vg_cloudcount[i]; vec3 vNewPos = vLastPos + windDirection * windSpeed * lastElapsedTime / nCloudCount; varing_gf_position = vNewPos; varing_gf_cloudcount = nCloudCount; varing_gf_dimension = varying_vg_dimension[i]; EmitVertex(); EndPrimitive(); } EndPrimitive(); } |
七、C++代码 (OpenGL CloudCluster-Update-Shader Setup)
1 2 3 4 5 6 7 8 9 | m_CloudFeedShader.SetShaders( "CloudFeed.vert" , "CloudFeed.geom" , NULL); m_CloudFeedShader.Load( false ); const GLchar *varyingOutCloudFeed[] = { "varing_gf_position" , "varing_gf_cloudcount" , "varing_gf_dimension" }; glTransformFeedbackVaryings(m_CloudFeedShader.GetProgramHandler(), 3, varyingOutCloudFeed, GL_SEPARATE_ATTRIBS); m_CloudFeedShader.Link(); |
glTransformFeedbackVaryings这个函数指定了这个Shader中将可用于Transform FeedBack的输出变量。末参数有两种值:GL_SEPARATE_ATTRIBS或GL_INTERLEAVED_ATTRIBS。前者指定这些Varying变量将输出到不同的VBO中(这时候Transform Feedback绑定相应数目的VBO,glBindBufferBase第二个参数指定要绑定的VBO的slot。十二分注意的一点,就是数据的一致性,用于输入和输出的VBOs,还有用于初始输入的VBOs[如果另外设置的话],顺序、大小都得一致。最好的方法就是都遵循粒子结构体[struct CloudCluster]的顺序。像上述代码,我们的输入是包括一个固定随机值randvalue的,但它没必要更新,所以transform Feedback在绑定VBO的时候[glBindBufferBase]没必要绑定第二个VBO,但slot的值依然需要遵循输入的VBO的顺序——CloudCluster的四个成员按顺序对应4个输入和输出VBO,哪怕其中一个没实际作用。这个我也觉得很tricky,也花费了我不少精力去搞清楚,但它就是这样了);后者则是指定这些Varying变量到一个Interleaved VBO([索引顶点的VBO与多重纹理下的VBO])上。前者有个slot的数量限制,后者则没有,具体使用哪种方式主要还是看原VBO的设定形式和对实际数据集的方便性和习惯了。
![](http://gameweb-img.qq.com/gad/20160810/phpRDWD4b.1470840811.jpg)
最后要提及的就是,基于Transform Feedback的应用衍生了一些基于2-Pass的场景决策渲染技术,它们与上述粒子系统的渲染的主要区别在于它们在第一个Pass会根据某些规则选择剔除一些对于第二个Pass的最终渲染来说不需要的points,这样对于第二个Pass的渲染的提交数据量就会有效减少(要查询新的数据量[GL_PRIMITIVES_GENERATED]需要执行异步query,在GL3.x下这种GPU的反馈方式会造成客户端的阻塞式延迟,但GL4.x的Transform Feedback Object提供了数据量状态的记录用,使第二个Pass可以不查询数据量而直接用glDrawTransformFeedback执行第二个Pass)。这些技术中,基于场景管理的有广度方向的视锥体裁剪、深度的方向的z遮挡测试、模型个体方向的细节程度选择策略,等等。接下来的文章我也会谈及一些,谢谢关注。
本文到此结束。Transform Feedback这项GPU技术带来的渲染流程变革,除了以上提及的粒子系统、场景决策外,应该还有很多,也将会有越来越多。随着这项技术的演变和强化,可编程渲染管线下的图形程序编程方式也将会越来越多样化吧。
本文来源于 ZwqXin (http://www.zwqxin.com/), 转载请注明