D3D12的资源管理已经移交到上层应用了,为此我们需要自己做资源管理,我们先来看一下一个资源从创建到销毁需要经过哪些步骤:
- 磁盘的文件读取,不同的资源有自己的文件格式,其中还可能涉及文件的压缩,因此一个文件首先要从磁盘加载到内存,然后解压缩,解析,最后再转换成显卡识别的内存布局。
- 申请上传堆或者默认堆的内存空间,也就是创建ID3D12Resource资源。
- 接下来使用memcpy函数将资源上传到驱动内存(cpu和gpu共享的内存)。
- 将资源通过PCIE总线上传到默认堆。
- 创建资源的View,也就是通过资源描述符告诉显卡这个内存里面存的是什么,内存布局是怎样的
- 将资源的View设置到根参数中,资源与Shader绑定
- 设置围栏来侦测资源是否已经被显卡使用完毕
- 调用DrawCall使用资源
- 上传指令
- GPU执行Shader
- 销毁资源
从上面的过程中我们可以看到,一个资源其实占用多份内存,比如最先读取的压缩资源,解析后的资源,上传堆中的资源,默认堆中的资源。我们在做资源管理的时候不仅要考虑最终的资源,还要考虑中间生成的临时资源。对于资源的生命周期是逻辑决定的,比如这个资源已经没有Pass使用了,那么我们是否需要销毁它呢?从正确性的角度这个资源已经没有用了,完全可以销毁,但是从逻辑的角度来说下一帧还会用到这个资源,那这个资源是不是应该销毁呢?假如内存和显存足够大,我们其实完全没有必要销毁资源,可是往往我们做的游戏都是在有限的资源里面运行超额的数据,因此所谓资源管理的本质就是尽量减少资源创建和销毁的次数,保证游戏能够正常运行。
资源的创建,阻塞与非阻塞
资源的创建过程有两种,一种是阻塞式(同步)和另一种是非阻塞式(异步),所谓阻塞就是创建资源的时候CPU或者GPU需要等待资源创建完毕后再执行接来的逻辑。非阻塞就是不需要等待创建的过程,直接执行接下来的逻辑。这两种方式其实对上层逻辑的使用影响很大,在多核的硬件环境下,除非必须要阻塞创建资源,最好使用非阻塞的方式来提高游戏性能。因此资源管理器需要提供两种创建资源的方法,从上面的步骤来看资源的创建有很多步骤是很耗时的比如磁盘读写,解析资源,大量memcpy等等。对于非阻塞式创建,我们需要将创建过程分解成几个线程来执行呢?如果只分一个线程,那么资源创建的吞吐量太低了,因为一个步骤的拥堵都会导致整个资源加载的拥堵,如果每一个步骤一个线程,过程过于复杂,我拆分的方式如下:
步骤1 一个线程,负责将资源加载到内存并解析成显卡识别的内存布局,其实这里的一个线程其实并不准确,如果细说的话也可以分成多个线程,比如解压一个线程,解析一个线程,或者加载不同类型的资源使用不同的线程,这里就简称为一个线程。
步骤2,3,4 一个线程,其中步骤4调用端是CPU,执行端是GPU,GPU的异步使用CopyEngine
步骤5 主线程,在模块编译的时候创建
步骤6,7,8 多个线程,这个是在不同模块收集指令的阶段处理,这个阶段每个模块一个线程
步骤9 主线程
步骤10 GPU端
步骤11 对于托管式的资源有一个额外的线程来跟踪资源的生命周期
线程与线程之间的关系就是生成者与消费者,因此需要一个存储产品的队列。
资源的销毁,托管和非托管
对于托管和非托管的理解举一个简单的例子就可以了,堆内存是非托管,栈内存是托管,所谓的托管就是系统自动追踪资源的生命周期,如果发现资源生命周期结束,那么自动销毁,比如引用计数为零代表这个资源没有人使用可以销毁,或者是函数返回,临时变量已经没用了,托管一定有一个侦测资源生命周期的规则。非托管就是逻辑自己手动销毁资源。D3D12的好处就是我们可以非托管式的管理资源,从而给予我们最大的灵活度。但是非托管的资源不容易管理,容易出现内存泄漏,这就好比C++的裸指针,强大但是危险,因此大家都用智能指针,关键是我们如何制定生命周期的规则。引用计数可以跟踪资源的使用情况,这个逻辑很清晰,就是判断资源是否有程序正在使用,但是引用计数解决不了销毁后立即创建的问题(这个是缓存解决的)。对于渲染的资源来说我们怎么判断一个资源的生命周期呢?没有Pass使用么?这个没有Pass使用是指那一时刻,还是指一帧内?这个逻辑说不清除。在D3D12资源销毁中我们只需要遵守一条基本原则,等到GPU处理完毕后再销毁资源。一个资源的创建是非常耗时的,如果一个体量小的游戏,它的场景加载后,场景中的所有资源是不会被销毁的。如果是开放世界地图,也会用上层逻辑来保证资源正常的使用,比如使用虚拟贴图技术。