游戏引擎的设计是随着硬件的迭代而迭代的,当然硬件的迭代也需要考虑软件的功能需求,目前硬件有两大功能需要我们花精力去处理:

  1. CPU-多核,现在的CPU都是多核的,为了充分利用硬件资源,我们需要使用多线程渲染
  2. GPU-异步计算,GPU的硬件设计是有功能区分的(CPU每一个核都一样),比如处理VS阶段的硬件和处理PS阶段的硬件是不统一的,如果一个任务集中在PS阶段比如后处理,那么VS的硬件就会被浪费掉,因此异步计算是充分利用GPU的一种手段,比如后处理可以使用Compute Engine来处理,然后Graphic Engine可以继续处理下一帧VS的任务,这样就可以充分利用GPU资源,现在GPU架构也朝着统一着色器架构发展也就是VS和PS使用相同的硬件,如果是这种GPU架构的话,异步计算好像就没什么用了。

对于游戏引擎来说不仅仅包含渲染,还需要处理逻辑,物理,声音等其他模块。因此考虑多线程的时候也要考虑这些模块对线程的使用情况,以免出现过多线程导致性能下降。通常我们考虑的是同一时刻线程并发的情况,一般逻辑引擎和物理引擎都会在渲染引擎之前处理数据,因此到渲染的时候,极端情况下可能逻辑和物理引擎已经不占用线程了(除非逻辑和物理也会处理多个帧的数据,这个要具体看引擎怎么架构的了)。

D3D12可以很好的支持多线程渲染,这里体现在我们可以使用多个CommandList多个线程中处理渲染逻辑。因此引擎会根据具体渲染的功能来划分多个模块,每个模块分配一个CommandList和一个线程,这样渲染模块就可以充分利用CPU的多核资源了。但是因为提交CommandList操作是一个比较昂贵的API调用(这个API调用会涉及到用户态切换到系统态)因此模块的划分不能太多,太多就会在一帧内调用多次ExcuteCommandList,同时也会有太多的线程被占用。因此模块划分的粒度是需要包含多个Pass的,在逻辑上最好也是一个完整的独立模块。虽然模块可以并行处理,但是因为Command Queue是串行工作的,因此我们需要根据功能需求保证每个模块调用ExcuteCommandList的顺序,实际上并行的是收集指令阶段指令提交阶段是在主线程处理的。这里有一个细节问题,D3D12资源的生命周期是由我们来控制的,一个资源是否被GPU使用完毕,需要通过围栏来判断。如果你做的游戏很复杂,一帧的资源量很大,那么你就需要精确的跟踪资源的生命周期,尽早的释放掉使用完毕的资源。每个资源都是被Pass使用的,最容易想到的是在每一个Pass中设置一个围栏值,如果一个资源被某个Pass使用完毕后不再被使用,那么就可以通过这个Pass的围栏值来销毁资源,因此我们需要在执行每个Pass之前,为每个Pass分配一个围栏值。为了实现异步计算,render engine和compute engine会相互等待,两个模块之间的同步也是需要通过Pass的围栏进行同步的。基本上一帧的渲染有两个队列组成分别是Graphic queue和Compute queue,这两个队列并行性越好,GPU的利用率越高。CPU则是按照模块的划分并行收集指令,这样也就提高了每帧CPU的性能。综上所述,一帧的渲染过程大概如下:

  1. 等待前一个FreamResouce执行完毕。
  2. 收集所有需要渲染的物体
  3. 更新所有渲染物体的数据(内存中的数据)
  4. 重新编译RenderGraph分别获得Graphic和Compute的Pass排序
  5. 为每个Pass分配FenceValue以及依赖的Pass,这里的依赖是Graphic中的Pass和Compute中的Pass之间的相互依赖,同一个队列中的依赖关系已经通过拓扑排序保证了。注意这里的依赖关系有可能会出现死锁现象,比如G1等待C2,C1等待G2。
  6. 以模块为代码启动多线程收集渲染指令。
  7. 在主线程中提交指令
  8. 调用Present
  9. 设置FrameResource的围栏

关于Pass的排序是一个很复杂的问题,它不仅仅是依靠依赖关系排序,有些Pass之间还存在逻辑关系,比如先渲染非透明物体,再渲染透明物体,再比如两个Pass之间渲染状态切换的比较少,那么这两个Pass最好放在一起,因此Pass排序绝对不是一个有向无环图就能解决的(有向无环图还有更重要的作用)。我并没有做过一个3A游戏的全部Pass,因此以我现在的认知给出一个完美的Pass排序是不现实的。如果忽略细节,只是设计一种Pass排序的规则,这个工作还是可以做的,首先Pass排序需要满足以下需求:

  1. 保证Pass之间正确的依赖关系。
  2. 保证Pass之间逻辑上的正确。

Pass之间的依赖关系可以人工排序,但是这种排序的后果是Pass的数量不能改变,需要针对每种数量进行排序定制,这非常麻烦,以前效果少还可以,效果多了,这个方法就太麻烦了。

使用有向无环图可以解决依赖排序问题,也可以根据Pass的数量,自动重新排序,但是有向无环图不是万能钥匙。在此基础上我希望加入人工的干预来解决特殊情况下的Pass排序。为此我的策略是:

  1. 模块之间人工排序
  2. 模块内部Pass使用有向无环图排序

引入模块的概念不仅仅是为了Pass排序,还为了多线程渲染。有向循环图也不仅仅是为了Pass排序,还为了跟踪资源的生命周期,模块之间可以使用MoveNode来进行资源的更替。接下来我们来聊一聊游戏中资源的生命周期,这个D3D12已经不管了,需要我们自己处理。

引擎中的资源,按照生命周期可以分为四类:

  1. Pass内部使用的资源,这类资源的生命周期没有跨越Pass,因此由Pass的内部逻辑负责创建和销毁,销毁的方式就是为这类资源设置当前Pass的围栏值,然后由资源管理器定期自动回收。
  2. GPU写的资源(Pass生成的资源),其他Pass读的资源,这类资源的生命周期是写Pass到最后一个读Pass,这类资源需要在RenderGraph编译阶段确定由哪个Pass创建,哪个Pass销毁。
  3. CPU写的资源,GPU读的资源,并且生命周期超过一帧,比如一些常量Buffer,它的生命周期可能是整个游戏的生命周期,这类资源由渲染上层逻辑(ECS的System)负责,不由Pass负责。
  4. CPU写的资源,GPU读的资源,并且生命周期在一帧以内的资源,这类资源的生命周期是最后一个读的Pass,也是由RenderGraph编译阶段确定由哪个Pass销毁。

根据上面的需求,资源管理器,需要提供两种销毁资源的接口,一种是立即销毁(上面第3类),一种是通过一个后台线程不断侦测该资源是否被GPU使用完毕(上面1,2,4类)。创建与销毁的接口如下:

  1. RenderResource* CreateResource(RESOURCE_DESC resDesc)//创建资源
  2. RenderResource::Release()  // 立即销毁
  3. RenderResource::Release(UINT64 fenceValue)  //围栏值到了销毁
  4. RenderResource& GetResource(string resName)  //获取资源

每一种情况接口的使用方式:

第1种情况发生在Pass的处理函数中,创建和销毁都在同一个函数中,只要记得别忘记调用Release即可。

RenderResource* tempRenderResource = CreateResource(resDesc);

......

tempRenderResource->Release(xxx);

第2种发生在两个Pass函数中,一个负责创建,一个负责销毁,因为这类资源的创建和销毁是FrameGraph编译自动生成的,理论上不会内存泄漏。

Pass1

遍历创建队列

string resNmae = xxx; //获得要创建资源的名字

RESOURCE_DESC desc = ResourceManager("resNmae"); //获得资源的创建信息

RenderResource* resource = CreateResource(resDesc); //这个资源会根据名字保存在资源管理器中,所以不需要销毁

......

Pass2

RenderResource& resource= Pass::GetResource("resNmae"); //Pass类会再封装一层,如果这个资源需要被销毁则放入销毁队列

.......

遍历销毁队列

resource->Release(xxx);

第3种情况

这个发生在Pass之外,就是简单的创建销毁,这个要注意内存泄漏,可以使用智能指针

第4种情况

这种情况容易造成内存泄漏,Pass外部逻辑创建资源,Pass内部逻辑销毁资源,可以将资源消耗时间延迟到下一帧,这样解可以在Pass外部逻辑中统一处理创建和销毁了。

说了这么多好像很多内容都和多线程渲染没有关系,但是其实这些东西又和它有关系,如果不思考许多细节问题,多线程渲染就会是场噩梦。