DX12之手撸GPU Driven Pipeline

使用DX12手撸了一个GPU Driven Pipeline,前前后后大概花了一个月的时间,效率有点低,先总结一下为什么效率不高。

1.图形API不熟悉,很多东西虽然概念上理解了,但在实际编写的时候你会很困惑,为了实现这个功能,该怎么使用这些API。因此我需要花费精力去熟悉DX12,主要的学习途径就是MSDN以及各种示例代码,龙书可以做为入门书籍,对dx12有一个大概的了解。

2.调式困难,引擎开发最常遇到的两个问题崩溃和效果不对,崩溃往往都是和内存相关的问题。一旦崩溃很难定位,使用DX12开发功能是一个非常繁琐的过程,比如你要在shader中使用一个buffer,你首先要创建一个buffer,然后可能还需要一个上传的buffer,然后还要定义根参数,根参数要和shader的寄存器匹配,还要正确的设置根实参。其中一个步骤出错,哪怕是一个参数设置错误,都可能会导致崩溃。因此在开发的时候我采用小步快跑的原则,一口气写太多功能,一旦出错很难定位。当你开开心心的写了一堆代码,然后一运行,我靠什么都没画出来,到底问题出现在哪里?这时候如果没有Frame debug你会束手无策。有的时候我们需要判断shader中的参数是否正确传入或者计算的结果是否正确,我很希望能够debug shader,但是很遗憾我没有找到可以debug dx12 shader的工具,dx11下倒是可以调试。为了解决shader中的疑问,有的时候我会直接读取buffer中的内容做判断,甚至向buffer中写一些结果做为调试的手段。如果能够找到一个可以在dx12下调试shader的工具,我工作的效率会提高很多。

3.粗心和坑,在开发的过程中有几个问题印象深刻,我在这里列举出来避免以后再犯。

  • 为了偷懒,很多基础的代码我是从其他demo中拷贝过来的,在初始化的时候我忘记了调用OnResize()方法,导致没有创建RT和DS资源,结果程序崩溃。
  • 在设置根实参的时候用错了API接口,SetGraphicsRootShaderResourceView和SetComputeRootSignature分别对应渲染管线和计算管线。
  • cs中传递贴图不能使用根描述符需要使用根描述表,我尝试了很多次都是这样的结果,不知道是我使用的问题,还是潜规则。

  • C++代码中的结构体会做内存对齐,shader中的结构体虽然和C++中声明的结构一致,但是实际分配的内存大小可能并不一致,这个一定要很小心。

  • 并行开发经验不足,在写cs的时候遇到一些问题,比如不要在同一次cs调用中,多次修改一个buffer中的值,然后后面的代码又要读取这个值,串行开发是没有问题的,读取的肯定是最后一次设置的值,但是并行开发,我们无法保证读取的是最后一次设置的值,同理我们也无法在同一次cs中又读又写同一个buffer。

ok,言归正传,让我们开启GPU Driven Pipeline之旅,第一站为什么要使用GPU Driven Pipeline?

cpu和gpu发展的路线是不一致的,cpu的计算核心比较少,控制核心比较强大,它更适合做复杂的逻辑运算,虽然也有多核多线程,但是和gpu的并行计算相比还是弱了很多。gpu中的每一个处理器都是按照SIMD32执行指令的,也就是32个线程同时执行同一个指令,CPU端的SIMD通常是4个向量同时执行同一指令。当今游戏cpu端逐渐成为性能瓶颈,我们希望GPU能够缓解CPU端的压力,而且如果逻辑符合并行计算的要求,放到GPU端做也是非常适合的。

在渲染端,CPU主要的性能消耗在场景管理及图形API的调用。

如果场景中物件非常多,由其是使用基础模块拼接的方式搭建场景,那么相机剔除将会消耗大量的CPU性能。另外如果为了执行遮挡剔除在CPU端执行软光栅化,也会消耗大量的CPU性能。而场景管理算法(四/八叉树)本身其实消耗的性能并不多。

游戏最常见的优化手段就是合并批次,合并批次的目的是为了减少Drawcall的调用,Drawcall为什么会消耗CPU性能呢,因为每一次Drawcall的背后是一系列的操作,比如设置顶点和索引缓冲区,设置贴图和常量缓存区,设置shader,设置Blend等等,说白了就是图形API的调用,也可以说是渲染状态的切换。每一个API调用,CPU端都会做很多工作,比如顶点缓存区的设置,cpu端需要监测顶点格式和输入流是否匹配,创建一个临时的buffer为VS做准备,这些细节与图形API和显卡驱动的设计息息相关,对于上层开发的我们要牢记每一个API调用都会消耗cpu时间。理想情况下如果能够一个Drawcall渲染整个场景那么这部分的CPU消耗将会降到最低。

基于以上的分析,我们重新划分cpu和gpu端的工作。

  1. cpu端使用四/八叉树对场景进行粗剔除,虽然在技术上完全可以把这部分工作放到gpu上做,但是我并不推荐这么做,原因有二,第一场景管理并不是只有渲染使用,物理碰撞检测也会使用,因此渲染模块和物理模块有可能复用这部分的逻辑。第二场景管理一般都是递归的方式去遍历树,gpu并行计算不支持递归,因此我们需要把算法改成非递归版本,写一个非递归版本的八叉树也不难,但是保证并行计算的效率,这个我就没有经验了,但是想想限制应该还是挺多的。
  2. cpu端将场景物件按照PSO进行排序
  3. cpu端为每一个PSO中的所有物体合并顶点和索引缓存区,合并Instance缓存区。
  4. gpu端做相机剔除和遮挡剔除。
  5. gpu端设置Drawcall参数并调用Drawcall。

 GDC中提到将物体划分成更细粒度的Cluster ,我在做GPU Driven Pipeline的时候一直在思考为什么要划分成Cluster,目前来说还没有找到一个必须要使用Cluster 的理由。使用Cluster 可能更多的是为了更细粒度的做裁剪和遮挡剔除,用cs的消耗换渲染管线的性能,cs和渲染管线配合多线程从而提高性能。当然因为目前我只是做了一个简单的技术测试,可能当真正做一个复杂场景的时候就会发现新的理由去使用Cluster。目前为止我没有使用Cluster。

让我们先把焦点放到如何减少Drawcall,通常有两种技术合并批次,但无论使用哪种技术都无法绕过切换shader,切换Blend等一些渲染状态的切换。当然你可以在shader中使用动态控制写一个超级大的shader支持所有效果,但是真的会这么做么?目前我们只能尽量减少 shader的数量,另外PBR渲染会大大减少shader的数量,因此很适合与GPU Driven Pipeline配合使用。

第一种是将许多mesh合并成一个大的mesh,这样做的缺点是浪费内存并且降低被相机裁剪的概率。

第二种是使用instance技术。

DX12的Drawcall API只有两个,一个是DrawInstanced,另一个是DrawIndexedInstanced,都是支持instance技术,一个不使用索引缓存,一个使用索引缓存。

使用索引的好处是减少VB的数量,因为共面的点可以用索引进行表示。

我们先创建一个UAV存储Instace Data,也就是每一个物体特有的属性比如坐标变换矩阵,AABB包围盒等。为什么是UAV不是SRV,因为我们需要在CS中对其进行剔除,然后在VS中读取这个Buffer,所以需要创建UAV。

如果我们使用相同的顶点格式,我们可以使用一个SRV存储所有物体的顶点信息,然后再创建一个SRV存储所有物体的索引信息。这样我们就可以不用再调用SetVB和SetIB函数。试想一下如果有n个不同种类的模型我们就需要调用n次SetVB和SetIB。GDC文章中推荐这么做,但是这里有一个疑问,只要合并了VB和IB,相同的PSO其实只需要设置一次即可,而且如果自己管理VB和IB还需要设置根实参,SetVB和SetIB比SetView浪费很多性能么?还是有其他的理由需要我们自己去管理VB和IB?

如果我们自己管理VB和IB,那么我们就只能使用DrawInstanced这个方法进行渲染了,而且我们需要告诉VS当前的顶点在索引buffer和顶点buffer中的位置,我们利用SV_VertexID和SV_InstanceID进行查找,而且我们还需要自己管理VertexStart,IndexStart,InstanceIdOffset。代码如下:

VertexOut VS(VertexIn vin) 
{
    VertexOut vout;

    uint indexdOfIndex = IndexStart + vin.VertexId;
    uint indexOfVertex = indexDataBuffer[indexdOfIndex].VertexIndex;
    VertexData verData = vertexDataBuffer[indexOfVertex + VertexStart];
    
    float4 posW = mul(float4(verData.LocalPositon, 1.0f), instanceDataBuffer[vin.InstanceId + InstanceIdOffset].world);

    vout.PosH = mul(posW, gViewProj);
    vout.Color = verData.VertexColor;
    
    return vout;
}

ok,我们现在抛弃了VB和IB并且使用instance技术减少Drawcall。假设同一个PSO下有10种不同的模型,那么我们就需要10次Drawcall,这10次Drawcall都不需要切换渲染状态,因此性能还是很高的。

我们的思路是使用CS进行剔除,然后将剔除的结果传递给渲染管线执行渲染,我们绝对不可以将CS的剔除结果回传给CPU然后再调用渲染API,这个思路是错误的。因此我们需要使用间接绘制技术,在DX12中就是ExecuteIndirect接口。

我们需要把设置根实参和Darwcall的函数参数写入到一个command buffer中,然后调用ExecuteIndirect执行,这样整个过程都是在GPU端进行,没有回传到cpu端。

我们现在有3个Buffer分别存储VB,IB以及instance data buffer,现在我们还需要一个buffer用来存储间接绘制的command。另外我们还需要一个flag buffer来标志哪个物体被剔除了,还需要另外一个instance data buffer来保存实际被渲染的物体。

我们的思路是通过cs来剔除物体,如果物体没有通过测试,那么flag buffer中对应的flag就是0,通过测试的就是1,然后对flag buffer进行前缀和操作,这样就可以得到一个只包含通过测试的instance buffer了。

相机剔除的方法是在cs中直接转换到ndc空间,然后进行剔除。在cpu端我们不会这么做,因为这样做的乘法次数太多,但是在GPU中是可以的,第一并行计算,这对GPU来说小菜一碟,第二遮挡剔除也需要将其转换到ndc空间。

遮挡剔除使用hi-z算法,首先将遮挡物的深度渲染到一张纹理中,然后对这个纹理进行降采样,取深度值最大的值做下层mipmap的值。这里我们需要操作子资源,设置每一层的mipmap。

GPU Driven Pipeline的思路并不复杂,复杂的是自己用DX12写一个GPU Driven Pipeline,其实也就是对DX12熟悉的过程,当然使用DX11,Vulkan,Opengl也可以实现,只是针对不同的API进行微调。我没有很详细的介绍DX12的使用方法,如果你真的对这部分技术感兴趣,还是自己动手写一个理解的深刻。

总结一下疑问:

  1. 为什么GDC中要使用Cluster
  2. 为什么要自己管理VB和IB

后续,这里我一直没有提贴图,因为贴图也会影响批次的合并,为了配合GPU Driven Pipeline需要使用虚拟贴图技术,这个是我下一步要实现的功能。

 

 

  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值