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,贴图,其他渲染状态的限制)。
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分割。
现在我们就可以把LOD/剔除利用ComputeShader这种gpu通用计算能力来解决。
GPU-Driven Rendering Pipeline
现在我们有了前置知识,发现很多事情都可以在GPU做,现在我们来提出GPU-Driven管线并梳理下。刺客信条大革命提出的GPU Driven Render Pipeline如下
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。
CLUSTER CHUNK EXPANSION
每个Chunk包含不同Cluster
Cluster culling
这一步剔除的单位是:Mesh Cluster
做了视锥剔除/遮挡剔除/背面剔除(即NormalCone剔除,如下图,对法线表示为一个范围)
三角面片剔除
每个Warp处理一个Mesh Cluster,每个Warp是64线程,所以在MeshCluster时我们也设置为64个顶点MeshCluster。
每个线程对一个三角形进行Culling:
- 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调用
Multi-draw 间接绘制
一切剔除完成,index buffer包含了我们想要回值的数据,接下来我们来提交DrawCall开始绘制吧!
我们肯定不希望通过GPU将DrawCall参数回传给CPU,DX12提供了间接调用函数ExecuteIndirect,这个函数可以从command buffer中读取参数调用drawcall,这样我们只需要将drawcall的参数存储到command buffer中,然后在C++端调用ExecuteIndirect即可。
考虑去除无用drawcall,我们不希望这些被剔除的instance也被调用,因此我们需要对drawcall进行压缩,删除那些不需要调用的drawcall。这个需要配合ExecuteIndirect函数的pCountBuffer参数来实现,pCountBuffer参数指定了ExecuteIndirect会调用多少次Drawcall。
当然我们可以对每个instance做一次drawcall,也可以使用给indexbuffer索引标记下对应的instancing id,如果相同可以用一个drawcall,不过意义不大。
Ref
GPU Driven Render Pipeline - 安柏霖的文章 - 知乎 https://zhuanlan.zhihu.com/p/37084925
《天涯明月刀》手游中用GPU Driven优化渲染效果 - 安柏霖的文章 - 知乎https://zhuanlan.zhihu.com/p/335325149
现代渲染引擎开发-GPU Driven Render Pipeline - mike的文章 - 知乎https://zhuanlan.zhihu.com/p/409244895
剔除:从软件到硬件 - 洛城的文章 - 知乎https://zhuanlan.zhihu.com/p/66407205
- 理解Nanite(一):遮挡剔除 - GuardHei的文章 - 知乎 https://zhuanlan.zhihu.com/p/583245401
- Sig2015GPU-Driven Rendering Pipelines—https://advances.realtimerendering.com/s2015/aaltonenhaar_siggraph2015_combined_final_footer_220dpi.pdf
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!