Notifications
Article
面向DirectX 12的多线程渲染架构
Updated 13 days ago
419
0
面向DirectX 12的多线程渲染架构
说到DirectX 12比起前几代API最大的提升,除了上篇文章提到的Execute Indirect的实践以外,最主要的就是对多线程的支持了,传统的API提交方法简单粗暴,无非是通过一个Device类,直接将Render State,Render Target,Mesh, Texture等基础元素提交进GPU然后直接运算,先进一点的有DX11提供的Deferred Context,然而这还不是非常彻底,最彻底的变革应该当属DX12这一代的将CommandQueue和CommandList彻底分开的操作,其中整个进程只应该有一个CommandQueue,负责向GPU提交指令,而CommandList则应该有多个,每个线程都应该有一个自己的CommandList,在完成指令准备工作后一起将命令提交到CommandQueue中,由于CommandQueue全局只有一个,所以这个操作毫无因为是主线程同步的,自不必说后续还有SwapChain等同样需要同步的消息。
因此我们初期构想的实现方案是:分配多线程任务->主线程等待分线程->提交结果,这样的实现有一个很不足的地方就是主线程需要休眠等待所有分线程完成任务,并最后以单线程运转的方式提交指令,这样对CPU的利用是很不充分的,所以需要改进一下,改进的方案就是,开启当前帧线程执行的同时,提交上一帧的CommandList,并准备3套Frame Buffer,也就是Triple Buffer,实现一帧算CommandList,一帧提交CommandQueue,一帧给GPU执行的操作,这样在提交任务繁重的情况下,几乎所有的线程都是永远在并行的,不会出现串行操作。
所以,我们将实现分为了大致3个部分,即多线程运行架构,渲染管线逻辑架构和渲染管线资源架构,这3个部分具有严格的先后顺序,所以本文中将会逐步讲解这3部分的实现。
首先是多线程的运行,与高并发那类异步多线程不同的是,我们这里注重的不是异步而是同步,即所有任务一起启动,所有任务一起结束,并开始下一帧的任务,同时,用户的CPU核心数和线程数都是有限的,而任务数量很有可能高于硬件线程数,因此我们必须提前开好数个固定的线程,每次将任务给这些线程调用。所以在任务方面,我们需要一个线程安全的FIFO的队列,我们最后锁定了CAS LinkedList和Ring Buffer两种方法,经过Benchmark发现Ring Buffer的性能远高于CAS LinkedList,即使Ring Buffer的出列阶段需要为空队列检测准备一个mutex,其性能依然遥遥领先,除了缓存友好,摆放逻辑简单,以及PC平台对互斥锁确实友好(本人没研究过主机,有了解的朋友请在评论指出)以外,更重要的一点则是,这个队列并不需要运行时扩容,因为最大的任务数量都已经是确定的值了,扩容部分在线程开始之前做完即可,入列和出列都是直接对队列的开头和结尾进行自增,这个运算量是远小于需要CAS进行循环比对并实时创建和销毁节点对象的。
完成了队列之后就是线程触发,在群友的帮助下本人也是解决了线程触发和暂停问题,在C#中,通过ManualResetEvent可以实现线程的暂停,且其他线程可以线程安全的遥控启动当前线程,然而在C++中std::condition_variable本身真的只是具有遥控功能,因为无论是wait还是notify操作,都应该伴随互斥锁,即unique_lock或lock_guard实现,这一点对于平时接触C++多线程不多的开发者来说,很容易造成混淆。
任务节点因为要直接传递指针以保证各个Task之间可以互相构建和依赖,需要呆在堆内存上,这也意味着每次都需要new delete创建和销毁一个任务节点,很显然直接new是消耗非常大而且会导致线程堵塞的操作,所以我们需要有一个对象池来解决这个问题,对象池中有A,B两条队列,如果需要new新的节点,就从A队列拿,如果需要delete旧节点就往B队列放置,每帧切换两个队列,除了队列需要扩容,或者池中元素不够需要回退到原始的new以外,并不会有其他的加锁的情况,这样就可以把多线程之间的锁的数量尽可能降低,实现无锁并行。Functor储存在任务节点中同样也是可变大小的,在std::function中一般也是直接暴力new delete的,这里我们用了一个Trick的方法来尽可能防止每次构建Task时都需要new functor,即在擦除类型时,提前比较和栈内存的大小,如果比数组小就直接放置到栈内存中,也就是不需要开辟新的堆内存了,因为functor一般体积较小,很难超过固定的64 byte大小,很多时候超过固定大小是说明设计模式出现一些问题的:
除了帧内复用的RenderTexture,还有些资源是需要保持的,比如Temporal AA需要保存上一帧的渲染结果和矩阵,Froxel需要保存上一帧的体素贴图等,这些就需要靠上方的GetPerCameraResource获取,通过传入一个当前事件的指针,获取到一个固定的数据结构,如果没有获取到则需要使用传入的functor生成一个新的,这种设计在之前的文章中同样有讲过,这里只是把相同的逻辑挪到了C++中,无需再多讲。至于GetPerFrameResource就比较有趣了,有一些数据如Constant Buffer,是需要每帧更新的,前边也提到我们用了3个Frame Resource类储存这类需要每帧更新的数据并放到ring buffer中,而这个方法就是获得每帧都改变的摄像机内储存的数据,这些数据都依靠Camera组件的RAII控制生命周期,因此没必要担心内存安全性。
接下来让我们测试一下这套系统的各个部分是否工作正常:
首先是数据传递,摄像机的矩阵更新,矩阵数据拷贝等逻辑任务,以及天空盒的提交,后处理的准备等渲染任务,都在不同的Job中完成,最后输出一个变色过的可以转动的天空盒,而因为Swap Chain本身是没有Depth buffer,所以我们就利用上边提到的临时RT作为中转,最后再将临时RT作为SRV,Blit到Swap Chain,这也是一个非常简单却五脏俱全的渲染管线的工作:
输出一个黑白化的天空球,可以随意转动:
到此说明我们的多线程框架已经大致完成,即使有一些细小的问题也需要在之后的开发中慢慢发现和改善,在接下来的文章中我们即将正式开始对渲染管线进行定制,并开始开发资源管理系统等其他必需的组件,下面是开源地址,注意,这只是临时仓库地址,日后有很大概率会修改,如果网址无法打开,请下方评论提醒,谢谢!
Github: https//github.com/MaxwellGengYF/MEngine
Tags:
MaxwellGeng
Technical Artist - Programmer
5
Comments