D3D12支持三个引擎分别是渲染引擎,计算引擎和复制引擎,这篇文章主要是对Copy引擎的总结,先上一张MSDN官方图片:

gpucopy如何改为3d gpu页面上的3d和copy_上传

这张图片可以说明很多问题,首先可以看到D3D12对多线程渲染的支持,每一个线程都可以操作三种引擎,每个引擎是通过Command Queue来顺序执行指令的,每个引擎有自己独立的Queue,因此它们三个是可以并行操作的。渲染队列可以操作三个引擎,计算队列可以操作计算和复制引擎,而复制队列只能操作复制引擎。

既然渲染和计算队列都可以支持Copy操作,为什么还要单独弄出一个复制引擎呢?我们先来开一张图:

gpucopy如何改为3d gpu页面上的3d和copy_gpucopy如何改为3d_02

这张图还少了一步就是磁盘的读写,一张纹理如果想要被GPU使用,要经过如下步骤:

  1. 磁盘的读取到内存,这个步骤涉及IO操作通常会很慢,为了防止主线程被阻塞,通常我们会用一个后台线程来处理磁盘的读写。
  2. 从用户内存拷贝到驱动内存,如果按照D3D12的内存分类的话,也就是从内存拷贝到上传堆中,这一步也是需要CPU操作的,和上一步合并到同一个线程处理。
  3. 从驱动内存拷贝到显存,也就是从上传堆拷贝到默认堆中。可以看到显卡读取显存的速度要比读取上传堆的速度快一个数量级,因此对于贴图这种需要随机存储的资源,最好放到默认堆中处理。这一步的上传需要通过PCIE总线进行传输,而PCIE的传输需要CPU进行控制,如果显卡里面有DMA控制器,那么DMA可以将总线的控制权从CPU转移到DMA中,这部分的操作就可以托管给GPU来处理。

最原始的传输类似于下面这种图:

gpucopy如何改为3d gpu页面上的3d和copy_贴图_03

可以看到GPU,Bus,CPU完全串行工作,这样效率非常低。如果我们的显卡支持DMA,那么CPU就可以不处理Bus的传输,因此CPU和BUS就可以并行处理了。

gpucopy如何改为3d gpu页面上的3d和copy_贴图_04

因为GPU在处理BUS传输的时候会被阻塞,因此GPU和BUS是无法并行的。回想一下D3D12的接口,我们Copy一份资源后需要进行Barrier转换,而进行Barrier转换会导致GPU阻塞,这就和底层硬件的设计对应上了。其实从逻辑层上来讲,如果我们使用一个Queue来进行拷贝,渲染操作,那么就需要等待资源处理完毕后再使用,因为使用Queue来处理指令,那么所有操作就是串行的,不能并行。

为了能让GPU和BUS并行处理,有些显卡(现在的显卡估计都有了吧)会额外增加一个DMA控制器,如下图:

gpucopy如何改为3d gpu页面上的3d和copy_贴图_05

此时的GPU有两个DAM控制器,为什么有两个DMA控制器才能并行呢?这个我不太了解底层硬件的设计细节,我只能从功能的角度分析,如果render引擎和copy引擎共享一个DMA,那么一定就会涉及到两个引擎的切换问题,假如copy引擎正在使用DMA控制器进行传输,此时的render引擎进行DMA请求,时间片轮转到render引擎,这时的copy引擎就要被中断,有中断就要有恢复,这个恢复的过程可能会很复杂,与其扩展DMA的功能来处理中断,不如再增加一个DMA控制器。这两个控制器只需要轮流获取PCIE的控制权即可。总之有了两个DMA控制器,GPU就可以将渲染和传输操作并行处理。

gpucopy如何改为3d gpu页面上的3d和copy_上传_06

原理分析完毕了,做为游戏引擎我们该如何使用这个CopyEngine呢?

  • 我将资源分成两种类型,一种类型的资源是当前帧必须使用的比如顶点缓存,索引缓存,材质的基本贴图。另一种是可以延迟几帧使用的资源,比如细节贴图。
  • 对于必须使用的资源,我们采用同步加载,也就是需要阻塞式的加载(使用RenderQueue或者ComputeQueue),对于可延迟的资源,我使用异步加载(CopyQueue)。

下面是一些设计的细节问题:

  • D3D12我们需要自己跟踪资源的生命周期,对于上传堆的资源它的生命周期的结束时间是上传完毕,也就是GPU处理完Barrier指令。我们需要通过围栏来获取GPU是否处理完该指令,但是我们没必要针对每一个资源设置一次signal,也没必要专门针对上传的行为设置围栏,如果默认堆的资源已经被使用,那么上传堆的资源肯定也使用完毕了,为此在每次调用ID3D12CommandQueue::ExecuteCommandLists的时候我们都会设置一个signal,在这个CommandLists中的所有上传堆的资源都会被分配一个FenceValue对应该signal。这样在下一次执行ExecuteCommandLists的时候我们获取一下当前GPU执行到了哪里,从而判断资源是否使用完毕,最后在每帧再次启用(如果该帧没有完成会一直等待)的时候我们再调用一次查询。
  • 为了减少申请上传堆的次数,我们会申请一个大的资源堆然后在这个堆上进行二次分配。
  • 对于同步的资源创建,我们会使用同步调用,因此创建资源的线程会一直阻塞(读取磁盘,内存拷贝到上传堆,直到将上传指令放入到Graphic或者compute engine中)。
  • 对于异步的资源创建,我们会专门开启一个工作线程,这个线程负责创建资源的全过程,并且将上传资源使用copy engine。

后续思考,在编写上传堆代码的时候发现这里的逻辑还是比较复杂的,因此把思路记录下来。

思考的问题是我应该创建几个上传堆?

在架构上一帧Frame被拆分成几个模块,每个模块会分配一个线程,一个CommandList来上传指令。如果我们只使用一个上传堆,那么多个线程就一定会按顺序等待使用这个上传堆。这就会让上传资源这件事情由并行变成串行,因此就需要为每一个模块(线程)分配一个上传堆。另外资源被逻辑上分为立即和延迟,立即的资源不希望和延迟的资源有竞争,因此每一个线程应该有两个上传堆才是最高效的,一个负责上传立即资源,一个负责上传延迟资源。

渲染逻辑可以分成上下两个层次,我也不知道叫什么好,就叫渲染前端逻辑和渲染后端逻辑吧。渲染前端逻辑是负责处理内存中的数据,渲染后端逻辑是负责处理驱动内存以及显存中的数据。也就是说渲染前端负责准备渲染需要的数据,渲染后端负责将这些数据传入显卡并按照顺序进行渲染。就拿创建一张贴图资源来说吧,磁盘的加载就是前端逻辑,它负责把数据读取到内存中。而将资源放入上传堆,并传输到显卡中,这属于渲染后端逻辑。有了这个层次的划分,在写框架的时候就会很清晰。

资源加载需要的模块

  1. 渲染前端逻辑-磁盘的读取模块,专注加载各种资源比如贴图,模型,动画文件,主要的设计就是流式加载,输出是一堆内存块。
  2. 渲染后端逻辑-将资源上传到指定的显存中。比如上传堆模块,专注资源从上传堆传输到默认堆。
  3. 渲染前端逻辑框架-ECS(整个游戏逻辑都可以使用ECS架构)
  4. 渲染后端逻辑框架-FrameGraph