GPU-Driven Rendering Pipeline

CPU在管线中一般做些什么?

​ GPU算力提升后远超CPU,我们希望把CPU端的计算放在GPU端,这也是行业一直在努力的方向。

​ 考虑一个传统管线,往往是一个个object来渲染,而在CPU端意味着你需要做这么几件事情:

  • 选择LOD
  • 根据object的AABB/Sphere Bounding等进行视锥剔除/远距离剔除/遮挡剔除
  • 设置PSO
  • 最后做一次DrawCall

传统下怎么减少Draw call

虽然这个过程往往会有优化,也就是常说的怎么减少DrawCall:

  • 比如Mesh合批,虽然预计算不影响运行时,但是Mesh大了后剔除粒度出现了问题,部分Mesh即使不在相机视线内也不会被剔除。

  • GPU Instance一次绘制多个相同物体,把相同Mesh的物体batch起来等。我们需要一个单独的Instance buffer记录每个instance特有的数据比如Transfrom,MatId等,在VS中我们根据instance_Id来获取这些数据。缺点就是不支持不同Mesh之间的Instance合并。

Merge-Instancing

GPU Instance的问题在于不能不同Mesh间的Instance合并,Merge-Instancing技术就是为了解决这个问题提出的。即把Mesh合并在一起,一次DrawInstance但是会每次对缓冲区数据指针进行移动,以不断渲染下一个Mesh。如何知道当前渲染到哪儿个mesh了?如果我们可以让每个Mesh都有一样的顶点数$PerObjectVertexNum$,那么就可以直接 $当前定点数/PerObjectVertexNum$获取渲染到哪儿个Object了。

​ 然而我们每个物体显然不可能有有着一样的顶点数,因此我们需要Mesh Cluster,把一个物体拆成一堆有着相同定点数的Meshlet,下节我们会介绍。

​ 理论上讲使用Merge-Instancing可以使用一次DrawCall渲染整个场景(不考虑shader,贴图,其他渲染状态的限制)。

image-20240718173809671

MeshCluster

​ 上文提到了Merge-instancing的问题主要在于每个Object定点数不一样,这节我们介绍MeshCluster,他把每个Object切分成了多个定点数相同的MeshCluster。有了MeshCluster我们获得了以下好处:

  • 可以使用Merge-Instancing,实现减少大量DrawCall,理论上可以1次DrawCall渲染完成整个场景。
  • 有了MeshCluster剔除粒度更细腻

​ 由于物体的粒度不同,对于一个超大的物体你往往很难剔除,并且物体大部分表面还很可能不再视锥空间中,粒度的不够导致了大量的浪费,我们希望粒度缩小一些从而剔除可以更加细,这个操作在CPU完全不可接受,因此GPU-Driven pipeline出现了。

​ 我们把mesh分为meshlet或者叫做MeshCluster,可以得到更精细的剔除效果,同时由于MeshCluster的顶点数相同,这意味着可以利用GPU Instancing(Merge-Instancing)一次Draw call解决。甚至你可以利用MultiRenderTarget分成几个不同的顶点数的Cluster分割。

image-20240718171743886

​ 现在我们就可以把LOD/剔除利用ComputeShader这种gpu通用计算能力来解决。

GPU-Driven Rendering Pipeline

现在我们有了前置知识,发现很多事情都可以在GPU做,现在我们来提出GPU-Driven管线并梳理下。刺客信条大革命提出的GPU Driven Render Pipeline如下

image-20240718194042855

COARSE FRUSTUM CULLING视锥粗剔除

这一步剔除的单位是:实例物体Instance。育碧在这一步使用coarse quad tree culling放在了CPU上做,当然也可以放在GPU上做,主要还是根据项目实际情况来trade-off。

​ 通常这一步具体会做的剔除是:视锥体剔除遮挡剔除

  • 视锥体剔除,我们的场景都会被组织成四叉树,八叉树或者BVH树,我们可以利用这些加速结构快速的剔除不在视锥体内的instance。以前这一步都会在CPU端做,现在也可以使用ComputeShader在GPU中处理。
  • 遮挡剔除
    • 高端渲染引擎都会在CPU端实现一个软光栅化器,这样就可以在CPU端做遮挡剔除。
    • 现代渲染引擎通常在GPU端利用Hierarchical Z-Buffering做遮挡剔除。

INSTANCE CULLING

这一步剔除的单位是:实例物体Instance。前面的视锥/遮挡剔除在GPU做时就需要在这里做。

​ 同时这一步通过的instance生成Cluster chunk,因为instance中包含cluster的数量相差比较大,直接处理会造成wavefront/warp的浪费。GPU的warp/wavefront需要同步执行,而不同的工作负载导致线程间计算时间不均匀,进而降低了整体计算效率。因此大革命在上面又加了一层Chunk,先拆分成Chunk再拆分成Cluster。

image-20240718204050467

CLUSTER CHUNK EXPANSION

每个Chunk包含不同Cluster

image-20240718204147822

Cluster culling

这一步剔除的单位是:Mesh Cluster

做了视锥剔除/遮挡剔除/背面剔除(即NormalCone剔除,如下图,对法线表示为一个范围)

image-20240718212834938

image-20240718205142437

三角面片剔除

每个Warp处理一个Mesh Cluster,每个Warp是64线程,所以在MeshCluster时我们也设置为64个顶点MeshCluster。

每个线程对一个三角形进行Culling:

image-20240718211851567

  • Orientation and Zero Area Culling:先是做背面剔除零区域剔除(退化三角形),然后做透视除法,转换到NDC空间。
  • Depth Culling-Hi-z:这一步是做Hierarchical Z test ,快速对三角形面元做深度剔除。
  • Small Primitive Culling:硬件在Fine Raster他会以quad进行测试,小三角形在以quad进行raster时,造成3倍ps性能的浪费,这里我们提前剔除掉小三角形。
  • Frustum Culling:三角形面元级别的视锥剔除

Index buffer compaction

删除不需要的Instance drawcall调用

image-20240718215445281

image-20240718215459469

Multi-draw 间接绘制

​ 一切剔除完成,index buffer包含了我们想要回值的数据,接下来我们来提交DrawCall开始绘制吧!

​ 我们肯定不希望通过GPU将DrawCall参数回传给CPU,DX12提供了间接调用函数ExecuteIndirect,这个函数可以从command buffer中读取参数调用drawcall,这样我们只需要将drawcall的参数存储到command buffer中,然后在C++端调用ExecuteIndirect即可。

image-20240718215811862

image-20240718215705931

​ 考虑去除无用drawcall,我们不希望这些被剔除的instance也被调用,因此我们需要对drawcall进行压缩,删除那些不需要调用的drawcall。这个需要配合ExecuteIndirect函数的pCountBuffer参数来实现,pCountBuffer参数指定了ExecuteIndirect会调用多少次Drawcall。

​ 当然我们可以对每个instance做一次drawcall,也可以使用给indexbuffer索引标记下对应的instancing id,如果相同可以用一个drawcall,不过意义不大。

Ref