从零开始的D3D12渲染框架 第0篇 设计思路

发布时间 2023-10-07 11:20:01作者: icysky

DirectX 12、Vulkan等下一代的渲染API在设计上相比OpenGL等上一代API有了很大的不同。下一代渲染API暴露了更多的GPU相关的细节部分,这允许程序员对GPU进行更加细致的控制,但同时也使得API本身变得更加琐碎与难用。这一系列文章用来记录我封装DirectX 12的思路与心得,篇章之间不会有很强的关联性。

在动笔之前,我的框架已经处于可用的状态了,至少你可以很容易地画一个彩色的三角形。你可以在GitHub找到它的源代码:ink。我想,对照着源代码会更容易理解文章的内容。

本文是这一系列的第0篇,我打算在这里讲讲设计思路,因此本文不会沉溺于细节中,也不会变得很难,这意味着这些内容应当同样适用于Vulkan与Metal。不过,这一系列的文章并不是面向小白的教程。如果你没有接触过渲染,我建议你至少先去学习一下计算机图形学的基础知识,并且试一试OpenGL

在文章的开头我们提到了,下一代渲染API暴露了更多与硬件相关的细节部分。因此,本文首先会简要介绍CPU与GPU是如何协同工作的,随后讲一讲DirectX 12的封装思路。

绘制一个三角形

要如何在屏幕上绘制一个三角形呢?我们很容易想到渲染管线:

  1. 首先,在输入装配阶段,需要装配顶点数据;
  2. 然后,在顶点着色器阶段,根据我们编写的vertex shader对每个顶点进行处理;
  3. 在光栅化阶段,对模型执行光栅化;
  4. 在像素着色器阶段,根据我们编写的pixel shader(fragment shader)对每个像素(片元)进行处理;
  5. 在输出合并阶段,将最终的颜色输出到屏幕上。

这是一个普通的Hello Trangle的渲染管线的流程,不过这不是本文的重点。根据每个阶段GPU要做的工作,如果我们深入思考一下,不难想到这么一些问题:

  1. 输入装配阶段需要访问顶点的数据,这些顶点的数据存在于哪里呢?
  2. GPU是不能访问内存的,那么如何让GPU访问到内存中的数据呢?(请不要深究UMA架构之类的问题)
  3. GPU最终要将颜色输出到屏幕上,那么如果渲染只完成了一半,为什么屏幕画面不会撕裂?
  4. ……

关于第一个问题,我们很容易想到顶点数据应当被放在显存里,GPU是能够直接访问显存的。由此我们可以引出第一个话题——

资源管理

在使用OpenGL的时候,我们可以很容易地glGenBuffers,然后很容易地使用glBufferData上传数据,而不用关心细节。但在使用DirectX 12的时候,我们需要熟悉各种资源才能正确地使用它们。

从硬件的角度来讲,DirectX 12中需要管理的资源可以分为GPU资源(各种resource)描述符(Descriptor)。由于各种视图(view)都是描述符,因此在这一系列文章中,我会混用descriptor与view。

GPU资源

当创建DirectX 12的资源时,ID3D12Device::CreateCommittedResource要求我们传入两个大结构体,一个是D3D12_HEAP_PROPERTIES,另一个是D3D12_RESOURCE_DESC。前者用来描述资源的存储空间信息,后者用来描述资源的类型等元数据。DirectX 12中所有的GPU资源都可以从这两个方面考虑。

存储空间

我们很容易想到存储空间分为内存与显存两类,实际上通常还存在一个容量不大的高速缓存区域,CPU与GPU都可以高速地访问这块缓存。

DirectX 12将存储空间划分成了三类:默认堆(Default Heap)上传堆(Upload Heap)回读堆(Readback Heap)

默认堆里的数据被当做会频繁地被GPU使用,通常我们可以认为默认堆等价于显存。而在使用DirectX 12的时候,我们也不能直接读写默认堆中的数据,必须借助上传堆与回读堆间接地访问默认堆。

上传堆正如其名,是用来上传数据的。CPU端可以向其中写入数据,但不能读取数据;GPU端可以从其中读取数据,但不能写入数据。驱动程序在实现时,上传堆可能会占用高速缓存区域,也可能使用内存与显存同步的方法等。需要注意的是,此处提出的只是可能的实现方案,DirectX并不规定驱动程序具体如何实现,回读堆同理。

回读堆恰好与上传堆相反。CPU端可以从其中读取数据,但不能写入数据;而GPU端可以向其中写入数据,但不能读取数据。驱动程序在实现时,可以将显存的一部分直接映射到内存供其访问,也可能是将显存中的数据拷贝直接到内存再使用。

DirectX 12在渲染时用到的各种资源,不论是各种buffer还是纹理等,都必须存放于这三种存储空间之一才能被GPU使用。

在这里顺便提一下,DirectX 12与Vulkan是允许乃至推荐手动管理这些存储资源的,所以你会发现这三类存储都被称为堆(Heap)。不过管理存储资源是一个十分复杂的话题,而我并不是这方面的专家,因此不会讨论手动管理存储相关的内容。

资源类型

存储资源并不能告诉GPU如何使用这些资源,我们还需要提供一些元数据,比如纹理的宽与高。我们不妨首先考虑一下常用的一些资源类型:

  • 缓冲区(Buffer),比如顶点缓冲区、索引缓冲区等;
  • 2D纹理,最常用的纹理资源,可以用作纹理贴图,也可以用作法线贴图、G-Buffer等;
  • 深度缓冲,用于进行深度测试与剔除;
  • 其他一些资源类型,比如各种纹理数组等。

DirectX 12对于资源的区分实际上没有这么细致,在创建资源时只需要确定资源是缓冲区、纹理还是纹理数组以及像素格式(pixel format)就足够了。资源究竟应当怎样被使用是由描述符来确定的,我会在讨论描述符时解释这些内容。

资源的类型与存储空间类型实际上是互不相干的。比如,我们既可以把缓冲区放在上传堆上,也可以放在默认堆上。它们主要的区别在于程序性能。

描述符

描述符是什么

其实称之为视图对于C++开发者要更亲切一些,因为描述符与std::string_view的作用非常类似。std::string_view的作用是指向一个字符串片段。比如,它可以只指向std::string的某一部分而不是全部,描述符也是如此。不同之处在于,GPU资源要比字符串复杂得多,描述符除了引用GPU资源的一部分,还需要描述这部分资源会怎样被使用。比如,是只读还是可读写、应当以怎样的像素格式(pixel format)解释这段资源等等。

DirectX对于资源的这种设计很容易让初次接触的人摸不着头脑——既然我们都有了资源了,那么直接使用资源就好了,为什么还要拐弯抹角地弄出个描述符?

首先,还是类比std::string_view——恰当地使用std::string_view要比全部使用std::string性能更好。一方面避免了拷贝与内存分配,另一方面也避免了相同的字符串反复占用内存。

从另一方面来说,我们经常需要以不同的方式来解释资源。比如,也许在上一个render pass,这个资源是深度缓冲区,但在下一个render pass,我希望它能作为纹理被采样。在这种情况下,反复创建与拷贝同一个资源是不现实的。

描述符的管理

在DirectX 12中有Constant Buffer View、Shader Resource View、Unordered Access View、Sampler View、Render Target View和Depth Stencil View六种描述符,它们各自代表一类资源的使用方式。从硬件角度上来讲,又分成Shader-Visible与Non-Shader-Visible两类。关于描述符管理的具体内容,我计划另起一篇文章专门讲一讲。

DirectX 12中的描述符完全需要手动管理,这个管理主要表现在描述符堆(Descriptor Heap)上。描述符堆相当于一段连续的内存,我们需要决定哪一段内存(哪一些描述符)分配给谁,并且自己处理资源的回收。并且,要将资源绑定到根描述符表(Root Descriptor Table)上,我们会需要一段连续的描述符,这使描述符的管理变得非常复杂。如果对于操作系统的内存管理比较熟悉的话,你也许会联想到伙伴算法和slab算法,不过在描述符堆上实现这两种算法过于复杂。DynamicDescriptorHeap就是用来专门管理描述符表所需的描述符的,同样会在以后的文章中详细讲解。

让CPU与GPU协同工作

CPU与GPU是计算机中两个不同的部件,它们使用不同的时钟,互不干扰地并行工作。这意味着——它们是异步的!既然它们异步地工作,那么自然就需要一些方法来同步二者之间的工作。不过在研究它们如何“协同”之前,我们需要先搞明白它们之间如何“工作”。

命令列表

让我们看一下OpenGL的API是如何工作的。比如,CPU端调用了glDrawElements

+-----------------+                +-----------------------+
|       CPU       |                |          GPU          |
+-----------------+                +-----------------------+
| glDrawElements  | -------------> |   Received Command    |
+-----------------+                +-----------------------+
|                 |                |                       |
|  Wait and Idle  |                |    Execute Command    |
|                 |                |                       |
+-----------------+                +-----------------------+
| Restore Program | <------------- | Report Task Completed |
+-----------------+                +-----------------------+

当CPU端调用一条命令时,驱动程序将这条命令发送给GPU,同时阻塞CPU端,直到GPU通知该命令已经完成。这个API做了两件事——将命令发送给GPU、等待GPU完成。这当然很不好——CPU等待GPU的时候明明空闲着,但什么也没法干。除此之外,CPU每调用一次API都需要经过这么一个流程,而“将命令发送给GPU端”这个操作本身是很费时的。

下一代API在设计上考虑到了这个问题,命令的记录与提交、CPU与GPU的同步被区分开来。CPU要控制GPU仍然需要通过发送命令来完成,但不再需要一条命令向GPU端提交一次。以DirectX 12为例,我们首先使用ID3D12GraphicsCommandList在CPU这边记录下我们想让GPU做的工作,然后通过ID3D12CommandQueue将记录的命令一次性全部提交给GPU。命令提交给GPU后立刻返回,不会等待GPU执行完成。这使CPU与GPU的协同工作变成了真正意义上的异步工作。

任务同步

既然任务模型相比以往有所不同,那么同步方法也会有所变化。基于CommandBufferCommandList)的任务提交方式很难实现逐命令粒度的同步,不过好在我们在实际使用中几乎没有这种需求。DirectX 12与Vulkan的同步方法都是以CommandBufferCommandList)为粒度的,如下图所示:

+------------------------+
| Submit CommandBuffer 0 |
+------------------------+
|     Signal Fence 0     |
+------------------------+
| Submit CommandBuffer 1 |
+------------------------+
|     Signal Fence 1     |
+------------------------+
|    Wait for Fence 0    |
+------------------------+

Signal Fence的意思是在提交的CommandBuffer以后做一个标记。比如,Signal Fence 0表示CommandBuffer 0完成执行的时间节点,而Wait for Fence 0就表示阻塞CPU,等待CommandBuffer 0中的所有命令执行完成。那么自然,Wait for Fence 1(上表中没有这条命令)就是等待CommandBuffer 0CommandBuffer 1都完成了。

这里需要注意一点,Vulkan与DirectX 12在这里稍有不同。DirectX 12要求先提交到CommandQueueCommandList必须先完成,因此自然满足上述同步模型。而Vulkan需要手动使用VkSemaphore控制CommandBuffer之间的依赖顺序。

设计思路

Windows生IDXGIFactoryIDXGIFactoryID3D12DeviceID3D12Device生万物——我自己说的。

IDXGISwapChainIDXGIFactory生的,你这话不对——还是我自己说的。

我们来整理一下上文提到的各种内容,并且将其分类如下:

  • 存储资源,包括ID3D12HeapID3D12Resource等,是各种缓冲区、纹理的实际载体。
  • 描述符资源,包括各种描述符。
  • 管线资源,包括渲染管线、计算管线以及根签名。其中,根签名与描述符资源是强相关的。上文没有提到管线资源,这是因为管线资源本身的管理比较简单。而且DirectX 12中的渲染管线与教科书中讲的完全一致——可编程阶段需要提供着色器代码,可配置阶段需要填写配置项,固定阶段则无需关心。
  • 命令的提交与同步,包括ID3D12CommandListID3D12CommandQueueID3D12Fence
  • 对GPU的抽象,ID3D12Device

我们来理一理这些内容之间的关系:

  1. 存储资源
  • 存储资源是资源的实际载体,而描述符是对存储资源的引用,因此实际上的描述符应当由存储资源来派生;
  • 有时候需要在GPU端操作存储资源本身,因此命令列表需要访问存储资源;
  1. 描述符资源
  • 描述符资源是存储资源的引用;
  • 根签名决定了描述符资源的排布,但根签名不需要直接访问描述符资源,根签名与描述符资源的耦合发生在命令列表处;
  • 命令列表需要向根签名中填入实际的描述符。其中,描述符表需要一段连续的描述符,这一部分由命令列表来管理;
  1. 管线资源
  • 命令列表需要设置管线才能执行渲染与计算任务;
  • 根签名决定了描述符的排布;
  1. 命令与同步资源
  • 应用程序只有通过命令列表才能与GPU交互,因此命令列表需要访问上述所有资源;
  • 提交了命令列表才需要同步,同步的节点由命令列表确定,二者具有强相关性;
  • 命令列表需要管理一些临时资源,比如临时的上传缓冲、DynamicDescriptorHeap用于描述符表等,这些内容与其他资源无关;

根据此,也就不难理解ink的设计思路。首先,我们需要一个RenderDevice,它是一切的基础。同时,我将所有需要全局处理的状态与资源管理都塞到RenderDevice了,所以你会发现这个类很大,而且有些代码写得很脏。

存储资源我首先分成了两类,一类是GpuBuffer,另一类是PixelBuffer。前者作为各种缓冲区的抽象,后者作为各种纹理(广义上的)的抽象。PixelBuffer又分成了ColorBufferDepthBufferTexture2DColorBuffer可以用作render target,DepthBuffer可以用作深度缓冲,Texture2D则是各种2D纹理与纹理数组。

针对不同类型的存储资源,它们各自带着不同的Non-Shader-Visible描述符。比如,GpuBuffer必然支持unordered access,因此有一个用于逐字节访问的UAV;ColorBuffer可以用作render target,因此带有RTV等等。这些资源自带的描述符都引用这些资源的全部内容(前文提到过,描述符可以部分引用资源)。

对于渲染与计算管线只有浅浅的一层封装,从源代码可以很容易看出是如何设计的,这里不再赘述。

CommandBuffer进行了非常复杂的封装,因为要在这里管理各种临时资源,以及GPU命令的提交与同步。不过CommandBuffer本身与其他模块相对独立,封装难度并不大。但是,与命令和同步相关的另一部分存在于RenderDevice,即CommandQueueFence。我只给每个RenderDevice创建了一个Direct类型的ID3D12CommandQueue,一是因为Direct类型能够覆盖所有其他类型的命令,二是多个CommandQueue之间的命令同步非常复杂。关于ID3D12Fence,我同样在RenderDevice中维护了与唯一一个CommandQueue关联的Fence,以及它的下一个Fence Value。

inkrender文件夹下有5个头文件,基本上恰好与这几个部分相对应。

后记

你会发现我在渲染部分的代码里用到了大量的友元,一方面我认为渲染部分本身就是高内聚的,这么写影响并不大。另一方面,不暴露内部方法能使得这些接口更难以被用错。实际上我在代码中用到友元时,大部分时候只是想把某个特定的方法暴露给另一个类而已。

关于错误处理,我使用了异常。实际上,当渲染API返回错误时,大部分时候都是不可恢复的,而人们往往也是使用断言或者打日志来处理,然后让程序崩溃。异常一方面是用起来很方便,另一方面,我仍然希望给调用者选择处理错误的权利,尽管我也建议当遇到错误时,应当让程序迅速崩溃。