【GDC 2016】Optimizing the Graphics Pipeline with Compute

这篇文章是寒霜引擎在GDC2016上的分享,主要介绍了他们的GPU Driven的思路,与上一篇育碧的内容有部分相似之处,接下来详细介绍下。


在DX12的新API上可以支持海量的DrawCall,极大的提高了CPU的性能,带来CPU的low overhead,但是GPU端仍然会卡在tiny draw call上,主要是因为场景中远处的细小细节的物体会被Hi-Z Cull,从GPU性能分析图中可以看到后半段,大部分只有VS计算,而没有PS计算,这样就会导致VS和PS的分配不均,造成性能的浪费。

大部分的引擎在CPU端最粗粒度的culling,然后在GPU上做refine,由于CPU和GPU之间存在延迟,因此很多这类的优化并不合适,因为需要二者严格的同步执行。在主机上CPU的资源本来就有限,因此让一个核心来做这个事情的性价比并不高。在PC端由于需要通过PCIE总线进行数据传输,这种方式的代价会更高。因此,希望剔除操作能够适配GPU的执行节奏,所以采用的方案是基于GPGPU的提交模式,包括Depth-aware culling, 和cluster/triangle culling。

之所以能做这些优化是因为计算着色器的网格处理能够更加高效的支持各类优化,理想的情况是,在多轮渲染的过程中复用CS处理之后的结果(如GPUSkin Cache)同时可以减少CPU端的绘制准备工作,从而优化性能。

优化的核心思路是将所有的绘制操作都当作常规数据来处理。这些数据可以进行构建,缓存,复用,设置可以在GPU端实时生成,带来了更高的灵活性,同时避免了各类固定功能模型的瓶颈。

Culling OverView


上图表示了GPU Culling的Overview, 整个游戏场景进行层级表达,Culling和DrawCall的发起都是根据这个层级来进行。

首先场景是由一系列特定视角下的meshes组成。

然后场景中所有的meshes会按照Batch进行划分,这里划分batch的依据是使用相同的shader和使用相同的vertex/index stride。因为一个batch与DX12中的一个PSO相对应,所以需要材质和顶点数据类型相同。

每个Batch又由若干个Mesh Section组成,这里的Mesh Section可以理解为拥有独立VB,IB信息的模型,对应于传统管线中的Mesh。

每个Mesh Section又继续划分为若干个Work Item,与上一篇中所讲的Mesh Cluster是差不多的,表示的是若干个Triangle的组合。Triangle的数量取决于使用的硬件的处理能力,更具体的是硬件的一个wavefront的thread数量(比如AMD GCN中每个wavefront有64个thread)。一般是一个thread处理一个triangle。


再来回顾下这个整体的流程图,首先对cluster级别进行culling,可见的cluster还需要进行更细粒度的triangle的culling,最后通过一个compaction pass,将一个section所有可见的triangle compact到一起,确保没有Zero size drawcall(如果一个mesh section没有可见的三角形,那么将不会发起drawcall)。填充好所有的Mesh Section的DrawArgs之后,通过一个MultidrawIndirect发起DrawCall。在XBOX平台ExcuteIndirect的extension甚至可以通过indirect args来切换PSO,从而使只需要一个drawcall就可以绘制整个场景,不需要进行渲染状态的切换。

这里演示了如何将MeshSectionId塞入到MultiDrawArgs里,在Shader中可以将MeshSectionID映射到MultidrawID,会被加载到GPU的SGPR寄存器中被使用。

同时Vertex Buffer使用非交叉的数据,从SOA变成AOS。有几个好处,首先是提高了cache的命中率,相比较交叉的数据,非交叉数据的cache miss更少,而且不同的attribute的属性可以自由组合,处理起来也会更加的简单,因为他们使用相同的stride,除非是32bit和16bit数据的切换。不同的绘制pass可以自由的选择需要的顶点数据,比如shadowpass只需要postion.

Cluster Culling

Cluster Culling使用Normal Cone culling,在离线计算出cluster的normal cone,编码为RGBA8 SNORM,在GPU端计算cone的朝向与相机之间的夹角来判断cluser是否可见。

Cluster的Frustum Culling则使用的是BoundSphere,Occlusion Cull使用的是Screen Space Bounding Box,因为在透视投影中sphere会变成ellipsoid。Cluster的剔除细节跟刺客信条大革命中的相似。

Draw Compaction

从上述这张图中可以看到,灰色部分都是empty drawcall, 到151us处有一个10us的idle time,由于流水线被重新填满需要的时间超过10us,而且即使只有0个primitive,获取indirect drawcall args也不是完全免费的,所以Compact Index十分有必要。

在DX12的ExcuteIndirect参数中列表中,pCountBuffer可以用于记录实际需要绘制的command数量,如果为nullptr,则绘制MaxCommandCount次,实际的绘制次数会是MaxCommandCount和pCountBuffer中取min。

具体的并行Compact的代码逻辑如上图所示,将所有indexCount不为0的args compact到一起,并将compact的结果保存到batchData里,也就是之前说的pCountBuffer。

传统的并行归约算法需要进行线程的同步才能进行,会影响执行的效率,为了减少全局的同步,可以使用Parallel prefix sum算法。所谓的prefix sum,就是每个位置保存的是当前位置之前所有为true的位置总数,如上图的底部显示。

这里使用Ballot指令来记录每个thread的mask,__XB_Ballot64(condition)会产生一个64bit的mask,每个bit位表示对应的thread的condition条件是否成立,图中奇数bit位为true,偶数bit位为false。

为了得到每个thread index之前的前缀和,每个thread只需要一个Thread Mask,如图中thread 5的mask就是此thread之前的bit都为1,此位及之后的bit都为0,然后只需要将 Ballot mask和thread mask做与操作,最后统计下结果中有多少个有效的bit位即可得到当前thread index的前缀和,这个很好理解。


实践中使用两个uint来表示64bit mask,最后可以通过__XB_MBCNT64来获取到每个valid thread的compact index,即完成了compact的流程。

具体的代码细节可以参考上图。

Triangle Culling

相比较于大革命中将每个triangle的可见性离线烘焙到bitmask上,寒霜对于triangle的culling做的更加精细。cluster中每个triangle对应一个thread, 执行完成之后会得到一个triangle cull mask,标记cluster中每个triangle的可见性,然后可以采用前面的index compact算法来将可见的triangle compact到一起。他们测试的结果显示,为了达到比较好的顶点复用率,一个cluster的triangle数量在256比较好。

这张图是Per-Triangle Culling Overview,包含了一个triangle的culling流程。

首先也是最重要的culling method是Orientation和zero area的culling,理论上而言会有接近50%的背面三角形会被cull掉。这里使用了论文《Triangle Scan Conversion using 2D Homogeneous Coordinates》中,通过齐次坐标的计算,来避免透视除法的开销。




Small Primitive和 Frustum Culling, Depth Culling都是在NDC空间进行,其中上述几个图片表示了small triangle culling的几种情形,总结就是通过判断triangle的bound是否包含pixel center,如果不包含任何一个pixel center,那么就会被cull。


Frustum Cull则是保守的culling,只判断四个triangle相对于四个plane的位置。

因为随着场景和视角的变化,场景中可见的三角形数量会存在比较大的波动,这里采用的是固定缓冲区大小的策略。每个缓冲区能够保存128k个三角形,对应284k个索引,占用768kb内存。至少需要使用4个这样的缓冲区。如果按照dispatch - wait - draw这样的loop,那么效率会比较差,所以这里希望dispatch和draw能够同步进行,即当前帧的draw是上一帧dispatch内容。(所以这里需要两个相同的缓冲区?)同时4个缓冲区的大小(3MB)刚好跟GPU的L2 cache差不多,因此可以有效的利用缓存,降级内存访问延迟。

在上图中,我们有4个缓冲区,总共可以保存512k个triangle,culling之后的triangle数量为434k,在缓冲区大小范围之内。然后执行一次render,绘制所有的三角形,下轮的处理再复用这些缓冲区。

但是当场景中最后可见的三角形数量大于缓冲区范围的时候,就需要在调度过程中触发一次渲染刷新,这样才能释放出缓冲区,用来处理剩余的数据。


在测试场景中,原来的44w的triangle,culling掉78%,只有9w的可见triangle,剔除效率还是非常高的,其中不同阶段剔除比例也在表格中可以看到,其中最重要的还是Orientation的culling。

不同平台的GPU的性能提升在**15%到30%**左右。

参考文献

【GDC 2016】Optimizing the Graphics Pipeline with Compute