DX12笔记 - DX12渲染流程

如何包装DX12到渲染引擎

Posted Kongouuu's Blog on November 26, 2021

前言

前阵子看了DX12龙书,并且感觉自己对整个流程有一定的理解,可以修改示例来达到自己想要的效果。这几天想搓个小渲染器,发现其实自己 根本不懂DX12。之前写的笔记其实用处也不大。是的,笔记也是写的很离谱的杂项记录。

这里的话主要还是整理一下整个DX12最基础的一个渲染流程,了解一下每个阶段应该做什么,并且了解什么数据是可以运行时动态的去更改(添加)的。这样有益于之后的渲染引擎的开发,和对Renderer的包装 这里的主要把DX12的整个渲染流程分为3部分:

  1. 初始化

  2. 数据初始化与更新

  3. 渲染(每个Tick)

DX12渲染流程

初始化

系统初始化

这里主要是对DX12和窗口与硬件的一些绑定工作。

  1. 创建D3D12Device接口:

    它可以负责我们对我们整个DX12的一个管理。

  2. 创建Fence

    Fence这里是保持CPU/GPU同步的一个主要机制。创建了Fence可以保证我们的资源在被绘制之前,并不会有任何更改它的指令被传入GPU。

  3. 通过向d3d12device把不同类型的描述符的大小存起来。[RTV], [DSV], [CBV,SRV,UAV] 各一个大小

  4. 检测MSAA的支持度:

    并不是每一个硬件都合适使用MSAA,这里只是简单的通过DX12去进行检查,并不是设定MSAA是否应用。

  5. 创建CommandQueue, CommandList,CommandAllocator

    CommandQueue是在GPU上的,CommandList是我们提交指令到CPU的接口。这里主要也是对CPU/GPU同步的一个管理,整体流程就是我们通过程序把指令上传到CommandList,然后再调用CommandQueue去运行我们提交的指令

  6. 建立Swap Chain

    大致上他是用来管理后缓冲规格和交替方式的,也就是: [Render Target的大小格式] + [画面刷新率] + [MSAA是否启用] + [有几个后缓冲] + [渲染到哪个窗口]

  7. 创建RTV/DSV描述符堆:

    我们在Swap Chain只定义了有几个后缓存和对应的格式,但没实际去创建让我们可以对其操作的描述符堆。

    所以这里假设我们只是双重缓冲(1个显示,1个后缓冲),我们需要建立数量为2的RTV描述符。

    并且我们需要一个深度缓冲描述符用于当前GPU计算使用

  8. 创建RTV/DSV

    我们通过上面的描述符堆可以得到一个用来创建实际的缓冲的Handle。这里我们只需要给对应的Handle建立好实际的渲染目标/深度缓冲就可以了。

  9. 设置视口:

    其实这个比起系统初始化更偏向数据方面的初始化。因为我们在这里并没有真的去设置任何显示的视口。DX12管线里面的视口信息会在CommandList里面传过去GPU,所以我们需要在每一次的渲染中重新的去配置同一个视口。这里还是提前的去决定好视口的值。

可运行时调整的设定

要特别注意的是,虽然这里是在进行初始化,但上面的操作并不是全部都是设定好后无法更改的。我们用来渲染到屏幕上的RTV本身的格式就是可以在运行时进行更改的,也就是说我们如果需要更改渲染贴图的大小什么的,只需要在运行时直接更改RTV描述符并且替换掉原本的RTV就可以了。

Swap Chain 也可以在运行时更改设定,比如说运行时启用/停用MSAA

数据初始化与更新

初始化的部分我们只对DX12的系统进行了一定程度的初始化,我们还没有决定我们要怎么加载数据,然后怎么准备在渲染流程中使用数据。这里并没有放在单纯的初始化阶段,因为大部分的数据都是可在运行中进行变化/添加的。

  1. 添加需要用到的Shader
  2. 添加对应Shader输入格式的根签名
  3. Shader与需要启用的宏存储到PSO里面
  4. 添加物体的Vertex Buffer和对应的Index Buffer
  5. 添加纹理
  6. 初始化其他需要用到的渲染目标/深度图

要注意的是上面的所有的操作,都是可以在运行时改变的,所以这里比起单纯的初始化,也更像是一个更新。

很多需要内存开销的东西,比如说SSAO Map/ Shadow Map等,当然是在我们初始化的时候就提前分配好比较好使用。不过如果真的需要的时候也可以在运行时动态的创建。比如我们想要动态的去建立SSAO的效果,我们完全可以在运行时通过一个按键输入来调用以下操作:

  • ​ 添加SSAO Shader -> 添加需要用到的根签名 -> 建立对应的渲染目标资源 -> 添加对应的PSO

我目前对DX12运行的整个优化方面并没有很了解,所有暂时认为这样的效果还是提前初始化好就行,渲染部分只需要用bool去切换效果。只要抽象做得够好的话动态的去添加物体(顶点)和纹理其实也是比较方便的,所以做渲染器的时候也要考虑到这方便的构造。

渲染

渲染前准备[单Pass]

Pass的情况我们可以把渲染流程写的比较固定,提前准备的可以是如下:

  1. 清理CommandList,并且重置让他打开。这时候可以先重置到我们第一个要用到的PSO,不过不设定的话写个nullptr也没事。
  2. 设置好视口/把之前弄好的视口设置提交到CommandList
  3. 清理需要用到的后缓冲和深度缓冲【多Pass另说】
  4. 把后缓冲通过ResourceBarrier设置成可写状态,设置目标缓冲为后缓冲(并且绑定好深度缓冲)【多Pass另说】

实际渲染[单Pass]

即使是单Pass的渲染(直接渲染到后缓冲),可自定义的部分也特别多,因为我们需要考虑到每个物体的不同渲染方式,不同的Shader,和不同的Shader格式。

  1. 设置使用的根签名:

    根签名这里实际上就是我们Shader所使用的常量输入格式(或者贴图输入)。很多情况可以保证基础的单Pass程序可以使用同一个根签名,但这并不代表这部分可以放到提前准备。

  2. 设置使用的参数:

    在同一个根签名格式下面,我们也许多个流程,或者是多个物体会享用相同的一个输入,比如说光的数据。这时候就要提前先绑定好。

  3. 设置Stencil参考值:

    Stencil参考值就是我们模板测试用的,在一个比较基础的镜面反射的情况我们可能得把特定的物体/贴图只在镜子内部画出来,这时候就需要进行模板测试,并且调整这个(0~255)的值来达到效果

  4. 设置PSO:

    PSO是整个渲染管线的一个设置,比如说我们的遮罩,使用的Shader等,都需要通过设置那个这个重新绑定。

  5. 绘制:

    绘制的流程大致上为: 绑定顶点->绑定索引->设定光栅的形状(三角/点/其他)->设置参数与使用的贴图->画!

收尾

我们上面的操作实际上只是把指令放到我们CPU上的CommandList,还没有真的进行绘制,加上还需要把绘制的画面替换到显示端,需要下面的操作:

  1. 把我们刚才渲染出来的后缓冲通过ResourceBarrier设置成只读

  2. 关闭CommandList

  3. 把指令传到CommandQueue去执行

  4. 把前后缓冲替换,顺便更新一下后缓冲索引

  5. Fence往后推,并且设定新的Fence位置

单Pass与多Pass的差别

我们在单个Pass的时候很自然的把后缓冲设置成了渲染目标,但我们在需要实现更多功能的时候没有办法这样进行操作。拿最简单的两个例子就是比如我们如果要进行阴影绘制和后处理,我们都不能通过直接渲染到后缓冲去达到这样的效果。

所有在多Pass的时候,实际上上面的实际渲染的流程会变成

  1. 清理需要用到的渲染目标(比如说Shadow Mapping用的的深度图)。
  2. 把渲染目标设置成Shadow Mapping需要用到的目标。
  3. 剩下的流程。

相对于单Pass来说还有一些额外的讲究,尤其是深度图里面的Depth Stencil Buffer。由于我们单Pass应该不需要用到把深度图当成一个SRV输入送给Shader,所以在渲染前的初始化阶段就可以直接用ResourceBarrier把他设置成可写的状态。但是ShadowMapping不同,我们在抽象化这个类的时候会:

  1. 建立唯一的Resource
  2. Resource绑定到一个SRVHandle,和一个DSVHandle上共用

之后在渲染流程中也需要根据需要去把这个资源本身从只读和只写状态之间切换

反思

前几天跟着油管大佬Cherno的用Opengl做游戏引擎的教学学了一下,不过是尝试用DX12实现。不过学完基础架构到要添加渲染接口的时候就停下来了,因为API上有些差距,而且人家是偏向游戏引擎的,而我只是想做个渲染玩具。

之后看了不少Github上使用DX12的引擎去学习包装,觉得看的不是很懂,然后对渲染器功能的想象也太过复杂了迟迟没有进展。这里主要是犯了一个错,就是我还没有对DX12的基础渲染流程有一定掌握就想的太远,想当然也看不懂别人的框架。做人还是得脚踏实地。

其实龙书写的还是不错的,这一次要重新学习DX12从初始化到渲染流程时才发现当时看不是很明白的龙书为什么这样去设计代码。写完流程之后对渲染器的包装也渐渐的明朗,爽死了。