DirectX12_初识之根签名、显存管理、资源屏障、栅栏同步、描述符与描述符堆、捆绑包

一、显卡架构与存储管理

现代的GPU上是有很多可以并行执行命令的引擎的,如下图所示(可参照官网介绍):
在这里插入图片描述
它很形象的说明了一个GPU上至少有三大类引擎,一个是复制引擎(Copy engine)、一个是计算引擎(Compute engine)、另一个是3D引擎(3D engine),实质上如果以最新的Nvidia的20xx系显卡GPU核来说,其上还有AI引擎、以及独立的专门做实时光追运算的内核,当然还有跟我们渲染没太大关系的视频处理引擎等,未来不排除D3D中会不会加入对这些独立的引擎以及对应类型的命令队列的支持。所以现在了解下GPU的架构及编程理念,至少不至于在未来因为不能理解编程框架而被淘汰掉。

同时这幅图上实际更进一步的示意说明的了CPU线程和GPU引擎之间如何交互命令,以及并行执行的原理。核心还是使用命令列表记录命令,再将命令列表在命令队列中排队,然后各引擎从命令队列中不断获取命令并执行的基本模式(回忆一下我们之前教程中提到的饭馆模型),至此我想关于这个CPU、GPU并行执行的概念大家应该是深刻理解并消化了。需要再次强调的就是虽然命令列表是分开录制的,但是它们被排队至命令队列之后,宏观上各个引擎在执行它们时仍然是串行顺序的,而在微观上针对一个个的原子数据,比如:纹理的像素、网格的顶点之类的则是并行的(SIMD)。

当然上图还是从线程角度或者说动态执行的角度来看现代CPU+GPU体系结构的视图。而另一个方面就需要我们进一步去了解CPU+GPU是如何管理和使用内存的,以便于我们深入掌握D3D12中的内存管理方法,从而为真正提高性能做好准备。

我们也可以从下图中看出三大引擎之间的调用关系:
在这里插入图片描述
从类型上来说现代计算机中CPU和GPU本质上都是独立的处理器,它们之间使用的是被称为SMP架构模型进行互联并相互协作的,SMP即共享存储型多处理机(Shared Memory mulptiProcessors),)也称为对称型多处理机(Symmetry MultiProcessors)。

或者直白的说CPU和GPU间的交互就是通过共享内存这种方式来进行的,但他们各自又有各自的内存控制器和管理器,甚至各自还有自己的片上高速缓存,因此最终要共享内存就需要一些额外的通信控制方式来进行,这也是我们使用D3D12进行存储管理编程的复杂性的根源。这里要注意的是从第一篇教程起我就特别说明是存储管理,而不是只说内存管理(CPU侧)、显存管理(GPU侧)或者共享内存(SMP中CPU和GPU共享的内存),这里大家要特别注意这个概念上的区别。因为在D3D12中我们需要管理的不仅仅是GPU上的显存,根据SMP的描述,我们还需要额外管理二者之间共享的内存。

更进一步的SMP架构又被细分为:均匀存储器存取(Uniform-Memory-Access,简称UMA)模型、非均匀存储器存取(Nonuniform-Memory-Access,简称NUMA)模型和高速缓存相关的存储器结构(cache-coherent Memory Architecture,简称CC-UMA)模型,这些模型的区别在于存储器和外围资源如何共享或分布。

1.1 UMA架构

UMA架构中物理存储器被所有处理机均匀共享。所有处理机对所有存储器具有相同的存取时间,这就是为什么称它为均匀存储器存取的原因。每个处理器(CPU或GPU)可以有私有高速缓存,外围设备也以一定形式共享(GPU因为没有访问外围其他设备的能力,实质就不共享外围设备了,这里主要指多个CPU的系统共享外围设备)。实质上UMA方式是目前已经很少见的主板集成显卡的方式之一。需要注意的是这里只是一个简化的示意图,里面只示意了一个CPU和GPU的情况,实质上它是可以扩展到任意多个CPU或GPU互通互联的情况的。

1.2 NUMA架构

NUMA的存储器物理上是分布在所有处理器的本地存储器上。本地存储器的一般具有各自独立的地址空间,因此一般不能直接互访问各自的本地存储。而处理器(CPU或GPU)访问本地存储器是比较快的,但要访问属于另一个处理器的远程存储器则比较慢,并且需要额外的方式和手段,因此其性能也是有额外的牺牲的。其实这也就是我们现在常见的“独显”的架构。当然一般来说现代GPU访问显存的速度是非常高的,甚至远高于CPU访问内存的速度。所以在编程中经常要考虑为了性能,而将尽可能多的纯GPU计算需要的数据放在显存中,从而提高GPU运算的效率和速度。

1.3 CC-UMA架构

CC-UMA是一种只用高速缓存互联互通的多处理器系统。CC-UMA模型是NUMA机的一种特例,只是将后者中分布主存储器换成了高速缓存, 在每个处理器上没有存储器层次结构,全部高速缓冲存储器组成了全局地址空间。通常这是现代CPU中集显最容易采取的架构方式。当然高速缓存共享或直连的方式拥有最高的互访性能。但其缺点就是高速缓存因为高昂的价格,所以往往空间很小,目前的集显上还只有几兆,最多到几十兆高速缓冲的样子,所以对于现代的渲染来说这点存储量实在是少的可怜了。另外因为高速缓存是在不同的处理器(CPU或GPU)之间直接共享或互联的,因此还有一个额外的问题就是存储一致性的问题,就是说高速缓冲的内容跟实质内存中的内容是否一致,比如CPU实质是将数据先加载进内存中然后再加载进高速缓冲的,而GPU在CPU还没有完成从内存到高速缓冲的加载时,就直接访问高速缓冲中的数据就会引起错误了,反之亦然。因此就需要额外的机制来保证存储一致性,当然这就导致一些额外的性能开销。

综上,实质上这些架构之间的主要区别是在各处理器访问存储的速度上,简言之就是说使用高速缓存具有最高的访问速度。其次就是访问各自独占的存储,而最慢的就是访问共享内存了,当然对于CPU来说访问共享内存与自己独占的内存在性能是基本没有差异的。这里的性能差异主要是从GPU的角度来说的。因此我们肯定愿意将一些CPU或GPU专有的数据首先考虑放在各自的独占存储中,其次需要多方来访问的数据就放在共享内存中。这也就是我们上一讲提到的D3D12中不同种类的存储堆的本质含义。

二、根签名

讲到根签名,我们应当想到的类似概念其实就是函数签名,也就是C/C++中常说的函数声明。

其实在概念上,你将整个GPU的渲染管线理解为一个大的函数执行体,其主要代码就是那些运行于GPU上的Shader程序,而根签名则是说明了这个渲染管线可以传入什么参数。更直白的说因为GPU渲染需要数据(比如:纹理、网格、索引、世界变换矩阵、动画矩阵调色板、采样器等等),而这些数据必须是GPU能够理解的专有格式,这些数据始终是CPU负责加载并传递给GPU的,此时就需要提前告诉GPU要传递那些数据,以及传递到什么地方,传递多少个等信息,而这些信息最终就被封装在了根签名里。实际也就是说根签名也是CPU向GPU传递渲染数据的一个契约。整体上看也就是CPU代码调用GPU渲染管线“函数”进行渲染,因此要传递参数给渲染管线,参数的格式就是根签名定义的。

那么为什么需要根签名这么一个结构化的描述呢?这是因为无论什么数据,在存储中都是线性化的按字节依次排列在存储(内存及显存)中的,如果不加额外的描述信息来说明,GPU甚至CPU本身根本无法分辨那块存储器中存储的是什么数据,即没有类型说明的数据,这类似C语言中的VOID*指针指向的一块数据,如果不额外说明,根本不知道里面到底存了些什么。所以根签名从根本上为这些存储的数据描述清楚了基本类型信息和位置信息。当数据按照指定的方式传递到指定的位置后,GPU就可以按照根签名中的约定访问这些数据了。而进一步的详细的类型信息则是由各种描述符来详细描述了。

或者换种说法,根签名描述清楚了渲染管线或者说Shader编译后的执行代码需要的各种资源以什么样的方式传入以及如何在内存、显存中布局,当然主要指定的是GPU上对应的寄存器。另一种等价的说法是说如何将这些数据绑定到渲染管线上,实质是说的一回事情,只是角度不同。

因此每一个执行不同渲染功能的渲染管线“函数”之间就需要不同的根签名来描述它的资源存储及传递情况。或者你可以理解为每个不同的渲染管线“大函数”都需要不同的对应的根签名来描述其“参数”。也就是我们定义了不同的函数,就需要配之以不同的函数声明。

以上是从宏观概念上具体去理解根签名的含义。具体的讲,根签名实际是描述了常量(类似默认参数)、常量缓冲区(CBV)、资源(SRV,纹理)、无序访问缓冲(UAV,随机读写缓冲)、采样器(Sample)等的寄存器(Register)存储规划的一个结构体。同时它还描述了每种资源针对每个阶段Shader的可见性。

并且常量(默认参数),根描述符、静态采样器等可以在根签名中直接被赋值,这有点像函数的默认参数一样。当然这些默认的“参数”在根签名中被理解为静态的,也就是他们被固化在这个根签名中,要动态修改他们所指资源位置及寄存器等信息是不行的。

当然像代码函数一样,根签名也需要编译一下才能被GPU正确理解。代码中调用API编译根签名的方法,在上一讲中已经有了介绍。另一种编译根签名的方法是使用HLSLI脚本的方法,我不打算介绍,因为它要借助独立的编译器和编译环境,对于引擎封装以及灵活性要求来说这都不是很方便的方法。同Shader的编译一样,我都推荐大家使用调用API的方法,因为这些API可以很方便的由你封装进工具或者再封装为脚本的API,甚至内置在引擎中,灵活性是很高的。

三、显存管理

按照在根签名介绍中的描述,我们接着要做的就是将数据传输到显存中作为渲染管线的纹理数据。根据我们前面描述的根签名的理解,那么这时我们首先需要做的就是将数据传输到GPU可以访问的存储(内存或显存)中。而这个过程在之前的D3D接口中就是创建一个Resource对象接口,接着Map之后,再memcpy图片数据进Resource中即可,或者可以直接在创建对应的资源时指定初始化的数据。

D3D12中的一个重要概念就是引入了完全的显存管理机制。明确的标志就是引入了“资源堆”的概念。当然这并不是说之前的D3D接口中就没有显存管理的概念了,而是说之前的接口中关于显存管理其实都封装在创建Resource接口方法的背后了,比如传统的D3D9中我们就使用创建资源时的标志来指定使用的是默认缓冲、动态缓冲、或者回写缓冲等,对应的文档中也只是简单的说默认缓冲对GPU有最高的访问性能,动态缓冲是CPU只写GPU只读权限,性能略差,而回写缓冲是CPU只读GPU只写,性能更差,主要用于流输出等。而在D3D12中,这些缓冲的概念都被“堆”缓冲的概念替换了,同时D3D12中暴露了更底层的控制方法。

首先在D3D中,GPU渲染需要的数据资源被分为两大类:一类是数据缓冲,另一类是纹理。对于数据缓冲来说,实际就是一维的某种类型数据的数组,比如:顶点缓冲、索引缓冲、常量缓冲等;而纹理则稍微复杂一些,从数据索引维度上来说可以分为1D纹理、2D纹理、和3D纹理,但其实本质上他们在存储中也是按照线性方式按行来存储的,就像C/C++中的多维数组一样,本质上也是线性存储,只是我们可以用多个索引来访问。从功能上来说纹理又分为普通纹理、渲染目标、以及深度缓冲和蜡板缓冲等,其中渲染目标的纹理存储一般是由DXGI的交换链来分配的,这只是说我们渲染的图形是为了最终在显示器上显示才这样做的,也只是个默认的做法,实际在一些高级的渲染场合渲染目标可能就是我们提前准备好的一个纹理而已,也就是常说的渲染到纹理的方法,这个将在后续的教程中再详细介绍。这里这样讲的目的是不想让大家在学习的过程中思路被禁锢,总以为渲染目标就只能从交换链来创建。

在之前的D3D版本中,这些资源都是统一以不同的对象接口来表达的,比如:ID3D11Texture2D、ID3D11Buffer等,但他们基本都是从ID3D11Resource这个统一的接口派生的,也就是说D3D12之前的版本中区分不同的资源使用了相对复杂的接口派生机制。但这种派生除了有概念表达上的区分意义之外,实际上也并没有更多实质性的意义。同时因为不同类型的资源使用了不同的接口来表达,这必然在代码编写上带来很多额外的问题,虽然我们可以使用基类型接口的指针来统一管理它们,但在具体使用时因为派生接口又会有不同的方法,而使得动态接口类型转换成为了一项非常具有挑战和风险的工作。或者更直白的说其实这种接口派生方式的封装都是一种很无聊的“过度设计”。或者更直白的说这种设计使得D3D接口中过多的考虑了“引擎”应该考虑和封装的事情。

所谓过犹不及,所以在D3D12中,就取消了这些通过复杂的接口派生类型来区分不同类型资源的接口设计,所有的资源统一使用一个接口ID3D12Resource来表达,而区分每种不同的Resource就是从创建它们的描述结构以及对应的创建函数,还有就是可以直接通过获取资源的描述信息来获知它们具体的类型。这样对于具体类型资源的封装设计就完全的变成了引擎设计或其它使用的他们的程序设计需要考虑的事情了,这样才真正体现了D3D12接口“低级”的真正含义。

另外更重要的就是在D3D12中加入了堆(Heap)的概念来表达Resource具体放在那里。也就是说要在D3D12中创建资源,一般就需要先创建堆,并明确在创建具体资源时指定放在哪个堆上。

3.1 D3D12中创建资源的三种方式

3.1.1 提交方式(CreateCommittedResource)

该方式是通过调用ID3D12Device::CreateCommittedResource方法来创建资源。这个方法其实主要还是为了兼容旧的存储管理方式而添加的,D3D12的文档中也不推荐使用这个方法。

使用它时,被创建的资源是被放在系统默认创建的堆上的,这种堆在代码中是看不到的,因此也被称为隐式堆,所以对其存储所能做的控制就比较局限了。当然调用时只需要通过堆属性参数来指定资源被放到那个默认堆上即可。因此调用它就不用额外自己去创建堆了。

也因为这个方法的这个特性,所以它具有调用上的方便性,很多简单的例子代码中都是使用这个方法来创建资源。

3.1.2 定位方式(CreatePlacedResource)

定位方式就是D3D12中新增的比较正统和创建资源方式了。要创建定位方式的资源,就要首先显示的调用ID3D12Device::CreateHeap来创建一个至少能容纳资源大小的堆,实质目前应该理解为申请了一块GPU可访问的存储(显存或共享内存)。然后再在此堆上调用ID3D12Device::CreatePlacedResource方法具体来创建资源。

如果你理解之前的提交方法创建资源的话,看到这里一定会有一个疑问,使用系统默认堆不是挺好?干嘛要自己费力创建个额外的堆,再来创建资源?其实这里的繁琐手续,要理解的话就需要你了解内存池的概念了。在一般的C/C++系统中,为了管理大量尺寸差不多且频繁分配和释放的对象时,我们往往采取的策略就是预先分配一大块内存,然后在其上“分配”和“释放”对象,这里的分配和释放其实就是一个指针的转换和标记下某块内存为空闲状态而已,其内部根本没有复杂耗时的内存分配和释放的真实底层调用。而换来的是性能上的极大提升。其核心理念就是“重用”内存。那么在D3D12中自己创建堆的目的跟这个内存池的目的非常类似,也就是要能够重用,即可以在这个堆上重复创建不同的资源,并且自己控制堆的生命期,从而提高性能。而使用默认堆,或者之前的D3D接口是无法做到这一点的,因为隐式堆在资源释放时就自动释放了。

更进一步理解,在D3D中分配或释放资源需要的往往是在显存或者共享内存式的显存上,对它的管理比一般的纯CPU的内存管理要复杂的多,因为这个存储的管理需要协调CPU和GPU,其额外耗费的存储管理调用成本是比一般的内存管理还要高的,或者直白的说它的性能耗费是很大的。所以在D3D12中,就干脆把这块管理工作的接口都暴露出来,让程序自己来管理,通过类似内存池的方式,从根本上提高性能。这也是D3D12较之前D3D接口核心改进扩展的主要方面之一。

3.1.3 保留方式(CreateReservedResource)

保留方式创建作为更高级的方法,就需要你对虚拟内存管理有所了解。说白了保留方式创建就是显存的虚拟管理。这个方式就很类似Windows系统中的VirtualAllloc系列函数族所提供的功能了。也就是说在分配时我们并不是直接保留显存或共享内存,而只是保留虚拟的地址空间,在需要的时候再一段段的真实分配显存或虚拟内存。

那么为什么需要这样的能力呢?其实现代的3D场景渲染中,随着显示分辨率的不断提高,以及画质细腻度的提高,通常纹理资源的尺寸和分辨率都是非常大的。甚至在使用D3D12之前的一些引擎中,为了显存管理和利用的高效性,都会要求将很多很小的纹理拼装成一个巨大的纹理,然后一次加载,供多个不同的渲染对象来使用。这是一种典型的空间换时间的性能优化的方法。

如果再加上为纹理设置不同的Mip等级,那么纹理的尺寸都是非常巨大的。同时它就会占用非常大的显存空间,所以现代显卡的一个重要特性就是都配置了动辄十几个G的显存,同时还会去共享一些系统中富裕的内存作为显存使用。但是虽然资源存储的大小问题解决了,但是实质上,这些巨大的纹理,并不一定在每帧场景中都被用到。比如不同人物角色的不同皮肤常常被拼装在一个巨大的纹理中,但实质在一个场景中可能只会显示一个角色的一套皮肤而已,过多的存储实质上都是被浪费了。但为了性能,我们又不能总是按需来加载不同的皮肤,那样额外的显存分配释放管理的成本就会造成性能上的严重下降。

那么有没有折中的方法来轻松达到即可以一次保留大的存储以提高性能,又可以按需提交来节约显存呢?这看似鱼和熊掌的问题,在D3D12中就通过资源的保留创建方式优雅高效的解决了。

或者直白的说,比如我们现在要加载一个1G大小的纹理,普通的方法就是我们要真实的分配和占用1G的显存或虚拟内存。如果再加上传递数据的中间缓冲,那么可能需要占用至少2G的存储。而使用保留方式我们就只需要先保留1G的地址空间,然后再按照场景渲染时的需要,分段来为某段地址空间分配真实的显存或共享内存,比如只分配其中某段256M的数据,这样真实占用的存储就只有256M了。这样在不断的渲染过程中,就会不断的为还没有真实分配存储的地址空间分配存储,直到所有的资源都按需加载进存储。当然如果某段地址空间中的资源在整个过程中都没有用到,那么就不会分配真实的存储,也就不会造成浪费。同时因为堆管理被独立了出来,那么这个保留方式的堆,也可以被反复重用。这样我们就做到了空间和性能优化上双赢的结果。

当然现代的硬件其地址空间是非常巨大的(CPU上是64bits的地址空间,即2^64这么多),保留地址空间本身,不会造成多大的浪费,就好像我们预留手机号一样,我们可以一次预留比如1000个号码,而实际上并不需要真实的购买1000台手机。

3.2 D3D12中堆的类型(默认堆、上传堆等)

在D3D12中,因为CPU和GPU访问同一块存储的方式不同,以及堆具体所在存储位置的不同,比如堆可以在显存中也可以在二者都可以访问的共享内存中,所以D3D12的堆还被细分为四种基本类型:1、默认堆;2、上传堆;3、回读堆;4、自定义堆。D3D12中使用一个枚举值来标识和区分这些类型:

typedef 
enum D3D12_HEAP_TYPE
{
    D3D12_HEAP_TYPE_DEFAULT            = 1,
    D3D12_HEAP_TYPE_UPLOAD             = 2,
    D3D12_HEAP_TYPE_READBACK           = 3,
    D3D12_HEAP_TYPE_CUSTOM              = 4
} D3D12_HEAP_TYPE;

其中默认堆就对应之前D3D中的创建缓冲时指定D3Dxx_USAGE参数为Default时的情形,直白的说就是这块缓冲只是供GPU访问的,CPU不做任何访问。通常它就驻留在显存中。因此默认堆中的数据是CPU无法直接访问的,因此向它直接传输数据就成为不可能的事情。也就是说它是只面向GPU的数据,因此从GPU的视角来看的话这就是自热而然的事情,故名默认堆。这样它就具备了GPU完全独占访问的权限,所以GPU在访问它时有最高的性能。通常我们将一些不易变的数据比如纹理之类的都放在这类堆中。

当然由于GPU自身不可能加载数据,所以怎样向默认堆中传输数据呢?这就要用到上传堆来做中介了。因此对于上传堆,顾名思义主要就是用来向默认堆上传数据用的。上传堆对于CPU来说是只写的访问权限,而对于GPU来说是只读的访问权限,因为CPU和GPU都要访问它,所以一般它都会被放在二者都能访问的共享内存中,所以对于CPU和GPU来说都不是独占访问的,因此GPU访问上传堆中的数据是有性能损失的。所以通常对于它里面的一些不易变的数据,我们就使用GPU上的复制引擎(回忆上一篇教程中关于现代显示适配器描述的内容)将数据复制到默认堆里去。但是对于一些几乎每个渲染周期或每帧都会变动的数据,比如:世界变换矩阵、动画矩阵调色板等,我们通常就直接放在上传堆里面了,此时如果每帧都在堆之间复制它们反而会损失性能(具体原因在资源屏障节中讲解)。

因为上传堆是映射在CPU和GUP都能访问的共享内存中,因此使用CPU向它里面复制数据就相对简单的多,也就是使用我们传统的Map、memcpy、Unmap大法(memcpy大法好!)。又因为堆的生命周期现在是由我们的程序完全控制的,所以对于一些经常要我们Map-memcpy-Unmap复制的数据,比如:每帧都变化的世界变换矩阵等,在D3D12程序中就干脆在一开始就Map一次,之后在每次反复memcpy新数据即可,程序退出前再调用Unmap并销毁堆即可。这样也可以提高不少的性能。在这里再里强调一次,D3D12接口相较于之前的D3D接口最核心的改进就是为了提高性能!

由此可以看出实质上要将数据从内存中彻底传递到默认堆中,至少需要两个Copy操作,一次在从内存到上传堆的Map-memcpy-Unmap中,一次在从上传堆到默认堆中。第一次Copy由CPU完成,而第二次Copy动作则由GPU上的复制引擎完成。并且根据上一讲的内容,第二个Copy动作就需要用到命令列表和命令队列了。

第三种回读堆则是用于GPU写入然后CPU读取类型数据的。通常用于多趟渲染中的流输出数据等,当然有时也用于读取离屏渲染画面数据的时候。从其用途就可以知道对于CPU来说这种堆中的数据是只读的,而对于GPU来说通常是只写的,并且往往需要向GPU标识其为UAV(Unorder Access View 无序访问视图)形式的数据。当然也因为CPU和GPU都要访问这块数据,所以它也是驻留在共享内存中的。所以GPU访问这种类型的堆数据时性能也是有所损失的。

最后一种自定义堆类型就为我们提供了自由组合CPU和GPU访问方式的可能,同时我们还可以指定它在共享内存中,还是在显存中。因此它可以实现更多更丰富的组合访问形式。未来的教程中我们看情况会不会用到,如果用到我们在详细讲解。如果用不到讲不到大家也不用着急,因为前三种堆基本上就可以解决80%-90%的问题了。

四、资源屏障

那么让我们再来思考一个问题:图形命令引擎如何知道复制引擎已经将数据从上传堆复制进了默认堆中呢?具体的比如我们渲染需要用到一副纹理,尺寸可能有些大,复制过程需要耗费一些时间,但此时可能图形命令引擎已经开始执行Draw Call命令了,之后在使用纹理时,数据还没有复制完怎么办?这也就是我在之前系列文章中说到的“脏读”问题。

这时我们就要用到D3D12中的资源屏障这个同步对象了。它的基本设计思路就是针对每种资源的不同访问状态的转换来实现的。具体的比如在我们一开始创建一个默认堆上的资源时我们指定其访问权限为可作为复制目标(D3D12_RESOURCE_STATE_COPY_DEST),此时图形命令引擎中的命令就不能访问这块资源数据,或者说它会进入一个“等待”状态,等待其有权限访问。而复制引擎看到这个访问权限标志时就可以直接写入数据,这里再次说明因为复制引擎本质上也是在GPU中的,所以它访问上传堆和默认堆都是没有问题。最终我们发现虽然理论上复制引擎和图形引擎是独立的,并且可以完全并行运行,但是在真正需要协作的时候,就需要二者有一定的串行管线,即复制引擎工作完成后图形引擎才能继续执行。这也就是之前说对于一些经常需要变动的数据我们就不再强制放到默认堆里去的原因,主要就是为了避免这个复制动作造成强制的串行执行关系,而导致性能上的损失。

那么在复制引擎复制完成时,我们就放置一个资源屏障的权限转换同步对象,要求将权限变为图形引擎可以访问的权限标志,在本例中我们指定的是D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE,也就是Pixel Shader程序可以访问的标志。

五、栅栏同步

在之前的例子中,我们以及接触了很多次栅栏(Fence)对象了,我们反复的说它是用来同步的,我相信很多人在看到这个东西时是有点晕的,因为我们一直都只是一个单线程的程序,需要同步什么呢?其实本质上说虽然直到目前我们一直都使用的单线程(其实就是程序的默认主线程)程序,但实质上我们同时操作的是CPU和GPU。根据我们前面的描述,在D3D12中,其实CPU和GPU已经是完全的并行执行了,即CPU不论发送给GPU何种命令,以至于最终的ExecuteCommandLists函数都是立即返回,此时GPU和CPU就是真正的并行执行了,GPU上就是运行一个完整的渲染过程了,而CPU就是按照我们之后的C/C++代码继续运行。

最终我们实际需要知道的就是GPU运行到哪里了?那就需要栅栏来帮忙了。

从原理上来说,栅栏本身关联与一个值,也叫作栅栏值,我们通过下面的语句来关联他们:

const UINT64 fence = n64FenceValue;
GRS_THROW_IF_FAILED(pICommandQueue->Signal(pIFence.Get(), fence));
n64FenceValue++;

栅栏值其实是一个计数值。而Signal函数是命令队列本身为数不多的几个命令函数之一,也就是直接在GPU对应引擎上执行的命令。所以它是我们直接排队在命令队列中的命令,如我们之前教程中所说,虽然命令列表可能可以随时记录命令甚至可以并行乱序记录,但他们排队到命令队列中之后,各引擎在执行时却是串行的,也就是先进先出的执行命令,所以代表各引擎执行命令的对象就被命名为命令队列。因此当我们按顺序排队完需要的命令或命令列表之后,我们放置一个Signal命令,那么含义就是说前面的命令顺序执行完之后,就执行Signal,而当Signal执行后,对应的栅栏对象的栅栏值就被GPU变成了我们调用它排队进命令队列中的值。此时如果我们用CPU检测这个栅栏值变成我们指定的值时,就说明之前的命令已经全部被GPU执行完了,之后CPU就可以重新组织和编排新的命令或命令列表去执行了。

当然我们最好不要通过一个死循环不停的在CPU端取这个栅栏值来看GPU到底执行完之前的命令没有,因为这样完全失去了CPU和GPU并行执行的意义了,通常我们是为这个栅栏对象的栅栏值绑定一个CPU侧的同步对象,在我们例子中我们绑定的都是Event对象(通过调用ID3D12Fence::SetEventOnCompletion),当GPU命令执行到指定的栅栏值时,这个Event对象就被设置为有信号状态,此时在其上等待的Wait函数族就会退出等待信号状态而返回。这样CPU通过Wait函数的返回就知道了栅栏值已经变成了我们设定的那个值,从而进一步知道它之前的命令都已经被GPU执行完了。

5.1 例:创建默认堆资源和上传堆资源并上传纹理数据

5.1.1 创建默认堆上的2D纹理

有了上面一大堆的理论基础的准备之后,假如你都明白了,那么接下来就让我们看看真实的代码中需要怎样的调用。

首先,我们需要创建一个提交方式的默认堆纹理资源:

D3D12_RESOURCE_DESC stTextureDesc	= {};
stTextureDesc.Dimension				= D3D12_RESOURCE_DIMENSION_TEXTURE2D;
stTextureDesc.MipLevels				= 1;
stTextureDesc.Format				= stTextureFormat; //DXGI_FORMAT_R8G8B8A8_UNORM;
stTextureDesc.Width				= nTextureW;
stTextureDesc.Height				= nTextureH;
stTextureDesc.Flags				= D3D12_RESOURCE_FLAG_NONE;
stTextureDesc.DepthOrArraySize		        = 1;
stTextureDesc.SampleDesc.Count		        = 1;
stTextureDesc.SampleDesc.Quality	        = 0;
 
 
//创建默认堆上的资源,类型是Texture2D,GPU对默认堆资源的访问速度是最快的
//因为纹理资源一般是不易变的资源,所以我们通常使用上传堆复制到默认堆中
//在传统的D3D11及以前的D3D接口中,这些过程都被封装了,我们只能指定创建时的类型为默认堆 
GRS_THROW_IF_FAILED(pID3DDevice->CreateCommittedResource(
	&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT)
	, D3D12_HEAP_FLAG_NONE
	, &stTextureDesc				//可以使用CD3DX12_RESOURCE_DESC::Tex2D来简化结构体的初始化
	, D3D12_RESOURCE_STATE_COPY_DEST
	, nullptr
	, IID_PPV_ARGS(&pITexcute)));
 
//获取上传堆资源缓冲的大小,这个尺寸通常大于实际图片的尺寸
const UINT64 n64UploadBufferSize = GetRequiredIntermediateSize(pITexcute.Get(), 0, 1);

上面代码中的主体风格还是初始化结构体然后调用函数的形式。结构体D3D12_RESOURCE_DESC是描述资源类型信息的,如前所述,因为D3D12中已经不再使用派生接口方式来具体区分是纹理还是缓冲了,所以在这个结构体中,我们就要说清楚我们创建的是一个2D纹理,然后只有1个Mip等级,同时制定它的DXGI格式以及图片的宽和高等信息。Flags字段暂时设置为D3D12_RESOURCE_FLAG_NONE。因为我们的纹理也不使用MSAA,所以SampleDesc就要像这里这样指定,以表示关闭MSAA特性。DepthOrArraySize字段则在加载复杂的纹理数组时才用到,所以这里我们就指定1即可,表示一维纹理数组,实质也就是只有一副简单的图片的意思。

接着我们就调用CreateCommittedResource通过系统隐式堆的方式在默认堆上创建这个纹理。当然第一个参数我们依旧是使用了D3Dx12.h中的工具类做了简化处理。需要特别注意的就是我们在创建时就指定了D3D12_RESOURCE_STATE_COPY_DEST权限标志,这就表示之后的命令列表中的命令在访问时,只能是复制引擎的对应的复制命令才能访问它,并且把它作为复制目标。而如果你直接调用其它的3D图形命令来访问它,就有可能引起一个访问违例的异常。

上段代码的最后又使用一个D3Dx12.h中的工具方法GetRequiredIntermediateSize来获取了整个这个纹理资源的大小。其内部实质用到了一个重要的D3D12的方法GetCopyableFootprints。

这里需要补充说明的就是,在D3D12中或者说根据现代GPU访问存储的边界对齐要求,纹理的行大小必须是256字节边界对齐,而整个纹理的大小又必须是512字节大小边界对齐。比如在此例中使用了一副700700像素大小的图片,每个像素有RGBA格式各8位共32位大小,也就是每像素32/8=4字节大小,如果直接计算行大小的话是7004=2800字节大小,但如果要256字节边界对齐的话,就变成了(int)256*((700*4+(256-1))/256)=2816字节。

这里用到了一个号称是微软面试题的上取整算法:(A+B-1)/B的公式,比如:5/2=2.5直接取整就是2,这是下取整的结果,如果使用公式就变为(5+2-1)/2 = 3即上取整的结果。希望你看明白并牢记了这个公式,因为很多关于内存管理边界对齐的计算都需要用到这个公式。当然如果你敏而好学,一定想知道个为啥的话,那么想一下余数不能大于除数的定理,以及最小的非零余数为1,自己推导一下就明白了。

那么整个纹理数据边界对齐的大小就是:

(int)512 * ((2816 * 700 + (512-1))/512) = 1971200字节

5.1.2 创建上传堆上的资源

默认堆创建好了,但是实际上里面什么也没有,如前所述我们还需要创建一个中介——上传堆来向默认堆上的纹理上传数据,示例代码如下:

// 创建用于上传纹理的资源,注意其类型是Buffer
// 上传堆对于GPU访问来说性能是很差的,
// 所以对于几乎不变的数据尤其像纹理都是
// 通过它来上传至GPU访问更高效的默认堆中
GRS_THROW_IF_FAILED(pID3DDevice->CreateCommittedResource(
	&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
	D3D12_HEAP_FLAG_NONE,
	&CD3DX12_RESOURCE_DESC::Buffer(n64UploadBufferSize),
	D3D12_RESOURCE_STATE_GENERIC_READ,
	nullptr,
	IID_PPV_ARGS(&pITextureUpload)));

上面的代码中依然使用了D3Dx12.h中的工具类,初始化了一个隐式上传堆属性,然后初始化了一个Buffer类型的资源描述结构体。并且我们为这个资源设置了GPU访问初始化状态为D3D12_RESOURCE_STATE_GENERIC_READ也就是只读属性。这样GPU上的任何类型的引擎其实都可以直接从这个资源中读取数据。

这里需要强调的一个问题是,对于上传堆来说,其类型都必须是Buffer类型。这是因为如前所述,上传堆中放置的实质上是CPU和GPU都能访问的资源,而对于CPU来说,它其实不像GPU那么细致的区分每种资源的类型,也就是说无论纹理还是其他类型数据,它都认为是一段缓冲数据而已。所以为了迁就CPU的“粗犷”,那么上传堆无论是要放数据还是纹理,我们就都创建为Buffer(缓冲)类型。同时也因为这种粗犷,我们只需要指定一个大型的属性给这个资源即可。因为这里我们要利用这个上传堆中的资源来向默认堆上的纹理资源传递数据,所以我们指定的大小必须要大于纹理本身的大小。在此例中我们使用了前面从默认堆上的纹理资源获取的大小尺寸来指定了缓冲的大小,这是因为这个大小必定是大于等于我们图片尺寸的大小的,因为它被要求是向上边界对齐的。

5.1.3 复制纹理图片数据到上传堆

有了上传堆,那么我们就可以进行前面理论介绍部分的第一个Copy动作了,代码如下:

//按照资源缓冲大小来分配实际图片数据存储的内存大小
void* pbPicData = ::HeapAlloc(::GetProcessHeap(), HEAP_ZERO_MEMORY, n64UploadBufferSize);
if (nullptr == pbPicData)
{
	throw CGRSCOMException(HRESULT_FROM_WIN32(GetLastError()));
}
 
//从图片中读取出数据
GRS_THROW_IF_FAILED(pIBMP->CopyPixels(nullptr
	, nPicRowPitch
	, static_cast<UINT>(nPicRowPitch * nTextureH)   //注意这里才是图片数据真实的大小,这个值通常小于缓冲的大小
	, reinterpret_cast<BYTE*>(pbPicData)));
 
//获取向上传堆拷贝纹理数据的一些纹理转换尺寸信息
//对于复杂的DDS纹理这是非常必要的过程
UINT64 n64RequiredSize = 0u;
UINT   nNumSubresources = 1u;  //我们只有一副图片,即子资源个数为1
D3D12_PLACED_SUBRESOURCE_FOOTPRINT stTxtLayouts = {};
UINT64 n64TextureRowSizes = 0u;
UINT   nTextureRowNum = 0u;
 
D3D12_RESOURCE_DESC stDestDesc = pITexcute->GetDesc();
 
pID3DDevice->GetCopyableFootprints(&stDestDesc
	, 0
	, nNumSubresources
	, 0
	, &stTxtLayouts
	, &nTextureRowNum
	, &n64TextureRowSizes
	, &n64RequiredSize);
 
//因为上传堆实际就是CPU传递数据到GPU的中介
//所以我们可以使用熟悉的Map方法将它先映射到CPU内存地址中
//然后我们按行将数据复制到上传堆中
//需要注意的是之所以按行拷贝是因为GPU资源的行大小
//与实际图片的行大小是有差异的,二者的内存边界对齐要求是不一样的
BYTE* pData = nullptr;
GRS_THROW_IF_FAILED(pITextureUpload->Map(0, NULL, reinterpret_cast<void**>(&pData)));
 
BYTE* pDestSlice = reinterpret_cast<BYTE*>(pData) + stTxtLayouts.Offset;
const BYTE* pSrcSlice = reinterpret_cast<const BYTE*>(pbPicData);
for (UINT y = 0; y < nTextureRowNum; ++y)
{
	memcpy(pDestSlice + static_cast<SIZE_T>(stTxtLayouts.Footprint.RowPitch) * y
		, pSrcSlice + static_cast<SIZE_T>(nPicRowPitch) * y
		, nPicRowPitch );
}
//取消映射 对于易变的数据如每帧的变换矩阵等数据,可以撒懒不用Unmap了,
//让它常驻内存,以提高整体性能,因为每次Map和Unmap是很耗时的操作
//因为现在起码都是64位系统和应用了,地址空间是足够的,被长期占用不会影响什么
pITextureUpload->Unmap(0, NULL);
 
//释放图片数据,做一个干净的程序员
::HeapFree(::GetProcessHeap(), 0, pbPicData);

代码中的注释已经描述的比较清楚了。这里需要再次强调的就是因为我们上传的是一副纹理图片,它属于不易变的数据,所以我们复制完数据之后,就Unmap了事了。因为使用的是隐式堆,这个上传堆的重用性还不能显现出来,之后的教程示例中我们再做详细的介绍。

这里重点要大家掌握的就是那个按行memcpy图片数据的循环,注意上传堆中的行大小与实际图片数据中的行大小是有差异的,因此计算两个指针的行偏移时使用的是不同的行大小尺寸,而实际复制数据的大小就是真实图片的行大小。因为我们的图片使用的是简单的RGBA格式,所以复制可以按行进行,对于其他复杂格式的纹理数据的复制,就需要按照实际的数据情况区别对待了。

另外一个需要注意的地方就是我们再一次显式的调用了GetCopyableFootprints方法来得到资源中详细的尺寸信息。这个方法几乎是我们复制纹理时必须要调用的方法,主要用它来得到目标纹理数据的真实尺寸信息。因为目标纹理如我们前面所描述的主要都是存储在默认堆上的,而CPU是无法直接访问它的,所以我们就需要这个方法作为桥梁让我们获知最终存储在默认堆中的纹理的详细尺寸信息,以方便我们准备好上传堆中的数据。而上传堆因为都统一为了缓冲格式,被认为是一维存放的数据的,所以是没法获知这些详细的尺寸信息的。

5.1.4 调用复制命令并放置栅栏

纹理图片数据加载到上传堆之后,我们要做的的就是进行第二个Copy动作了,并且设置资源屏障,保证复制数据动作在GPU的复制引擎上完全执行结束。代码如下:

//向命令队列发出从上传堆复制纹理数据到默认堆的命令
CD3DX12_TEXTURE_COPY_LOCATION Dst(pITexcute.Get(), 0);
CD3DX12_TEXTURE_COPY_LOCATION Src(pITextureUpload.Get(), stTxtLayouts);
pICommandList->CopyTextureRegion(&Dst, 0, 0, 0, &Src, nullptr);
 
//设置一个资源屏障,同步并确认复制操作完成
//直接使用结构体然后调用的形式
D3D12_RESOURCE_BARRIER stResBar = {};
stResBar.Type			= D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
stResBar.Flags			= D3D12_RESOURCE_BARRIER_FLAG_NONE;
stResBar.Transition.pResource	= pITexcute.Get();
stResBar.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_DEST;
stResBar.Transition.StateAfter	= D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
stResBar.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
 
pICommandList->ResourceBarrier(1, &stResBar);

上面的代码我们在之前的理论介绍中已经介绍过了,需要额外补充说明的就是这里我们调用了CopyTextureRegion这个命令来复制纹理数据,实质上还有CopyBufferRegion、CopyResource、CopyTiles等复制引擎的复制命令。而最常用的就是CopyTextureRegion和CopyBufferRegion,前者主要用于纹理的复制,而后者如其名字所示主要用于缓冲的复制。这些方法在之后的教程中我们都会有更详细的介绍。目前了解本例中的用法即可。

之后我们就像下面这样先执行以下这个命令列表中的复制命令和资源屏障,做第一次同步,代码如下:

// 执行命令列表并等待纹理资源上传完成,这一步是必须的
GRS_THROW_IF_FAILED(pICommandList->Close());
ID3D12CommandList* ppCommandLists[] = { pICommandList.Get() };
pICommandQueue->ExecuteCommandLists(_countof(ppCommandLists), ppCommandLists);
 
//---------------------------------------------------------------------------------------------
// 创建一个同步对象——栅栏,用于等待渲染完成,因为现在Draw Call是异步的了
GRS_THROW_IF_FAILED(pID3DDevice->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&pIFence)));
n64FenceValue = 1;
 
//---------------------------------------------------------------------------------------------
// 创建一个Event同步对象,用于等待栅栏事件通知
hFenceEvent = CreateEvent(nullptr, FALSE, FALSE, nullptr);
if (hFenceEvent == nullptr)
{
	GRS_THROW_IF_FAILED(HRESULT_FROM_WIN32(GetLastError()));
}
 
//---------------------------------------------------------------------------------------------
// 等待纹理资源正式复制完成先
const UINT64 fence = n64FenceValue;
GRS_THROW_IF_FAILED(pICommandQueue->Signal(pIFence.Get(), fence));
n64FenceValue++;
 
//---------------------------------------------------------------------------------------------
// 看命令有没有真正执行到栅栏标记的这里,没有就利用事件去等待,注意使用的是命令队列对象的指针
if (pIFence->GetCompletedValue() < fence)
{
	GRS_THROW_IF_FAILED(pIFence->SetEventOnCompletion(fence, hFenceEvent));
	WaitForSingleObject(hFenceEvent, INFINITE);
}

上面的代码其实就是执行一个命令列表,然后使用栅栏同步CPU和GPU的执行。到WaitForSingleObject返回时,我们就可以确定从上传堆复制纹理数据到默认堆的操作已经完全执行完了。也就是纹理数据已经可以使用了,并且已经变成了D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE访问权限,即我们的Pixel Shader程序中就可以访问这个纹理了。当然在D3D12中,几乎所有的Shader阶段中都可以访问纹理,这里只是一个示例,将纹理用于了Pixel Shader中而已。

5.1.5 创建默认堆上的2D纹理

5.1.6 创建默认堆上的2D纹理

六、描述符与描述符堆

按照之前根签名的描述,那么加载完资源之后,我们需要做的就是准备好资源描述符了。前一讲中我们以及简单介绍过资源描述符了。在这里我们在补充一些内容,因为我始终认为学习的过程就是一个不断重复加深的螺旋式上升的过程。

实质上按照本讲中的概念来说,我们可以将资源描述符理解为一个指向实际资源的一次指针,而资源描述符堆则可以理解为描述符指针的数组。这样我们就从代码的角度深入的理解了资源描述符的本质。

其实除了起到“指针”的作用,资源描述符还起到详细描述资源类型信息的作用,比如被描述的资源是一个纹理,还是一块纯缓冲数据,又或者资源是渲染目标还是深度缓冲等等。

这里我们创建的资源描述符堆使用的是D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV,表示这个堆上可以放置CBV、SRV或UAV。之所以资源描述符也需要这样的堆式创建,其目的依然很简单就是为了描述符堆的“重用”,我们可以简单的释放具体资源描述符,而不用释放描述符堆,通过重用描述符堆,从而提升性能。这与我们使用资源堆的目的相一致。同时也带来了与资源管理在编码框架上的一致性。

比如:

//创建SRV堆 (Shader Resource View Heap)
D3D12_DESCRIPTOR_HEAP_DESC stSRVHeapDesc = {};
stSRVHeapDesc.NumDescriptors = 1;
stSRVHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
stSRVHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
GRS_THROW_IF_FAILED(pID3DDevice->CreateDescriptorHeap(&stSRVHeapDesc, IID_PPV_ARGS(&pISRVHeap)));
 
//......
 
// 最终创建SRV描述符
D3D12_SHADER_RESOURCE_VIEW_DESC stSRVDesc = {};
stSRVDesc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
stSRVDesc.Format = stTextureDesc.Format;
stSRVDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
stSRVDesc.Texture2D.MipLevels = 1;
pID3DDevice->CreateShaderResourceView(pITexcute.Get(), &stSRVDesc, pISRVHeap->GetCPUDescriptorHandleForHeapStart());

六、捆绑包

在之前的教程中,我们在讲解渲染管线状态对象(简称PSO)时提到过根签名+PSO实质上可以理解为完整描述了一个“渲染管线函数体”的静态结构。按我们一般的概念,函数是需要被执行的,执行的时候我们是需要传递参数的,等价的,如果按照我们之前的隐喻将渲染管线看做一个Shader代码组成的“大函数”来说,调用它来执行渲染的话,我们就需要传入网格数据、纹理数据、设置状态等,然后执行它并等待完成渲染,同时整个过程的传参调用的代码,都是放在渲染循环中反复进行的。如例子中的:

pICommandList->SetGraphicsRootSignature(pIRootSignature.Get());
ID3D12DescriptorHeap* ppHeaps[] = { pISRVHeap.Get(),pISamplerDescriptorHeap.Get() };
pICommandList->SetDescriptorHeaps(_countof(ppHeaps), ppHeaps);
//设置SRV
pICommandList->SetGraphicsRootDescriptorTable(0, pISRVHeap->GetGPUDescriptorHandleForHeapStart());
 
CD3DX12_GPU_DESCRIPTOR_HANDLE stGPUCBVHandle(pISRVHeap->GetGPUDescriptorHandleForHeapStart()
    , 1
    , pID3DDevice->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV));
//设置CBV
pICommandList->SetGraphicsRootDescriptorTable(1, stGPUCBVHandle);
 
CD3DX12_GPU_DESCRIPTOR_HANDLE hGPUSampler(pISamplerDescriptorHeap->GetGPUDescriptorHandleForHeapStart()
    , nCurrentSamplerNO
    , nSamplerDescriptorSize);
//设置Sample
pICommandList->SetGraphicsRootDescriptorTable(2, hGPUSampler);
 
//注意我们使用的渲染手法是三角形列表,也就是通常的Mesh网格
pICommandList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
pICommandList->IASetVertexBuffers(0, 1, &stVertexBufferView);
pICommandList->IASetIndexBuffer(&stIndexBufferView);
 
//Draw Call!!!
pICommandList->DrawIndexedInstanced(_countof(pBoxIndices), 1, 0, 0, 0);

上面的代码将在循环中反复被调用,如果你对性能问题很敏感的话,立刻就会想到,对于一些相对单调的物体来说,比如场景中的建筑物、树木、地形、天空盒子甚至一些怪物角色、人形角色来说,好像循环中上述的这些代码或者说命令列表中的命令,基本是不变的,注意每次循环变动的可能只是MVP或者动画矩阵调色板之类,它们只是数据不是渲染命令,而我们这里说的是渲染命令,那么问题就是,每次在循环中调用它们一遍是不是显得浪费了?何况最终不管我们记录了多少命令在最终执行完成后,我们都需要将命令列表,命令分配器Reset一下,以清除之前记录的内容,然后再开始记录新的一轮命令,有时候这些新的命令跟之前循环的命令基本上大同小异。

如果你还记得我们之前在讲解命令列表时举得餐馆点菜单的例子的话,你应该立即想到,假如这是我们经常去的一家馆子,我们就爱吃他家的那几道拿手菜,并且是每次必点的话,为了提高上菜速度,是不是考虑跟老板商量好一个比较固定的菜单?每次一去只需要跟老板说老样子来一套,只是调整几个小菜或主食即可,是不是很方便?那么我们有没有什么方法来简单做到类似的记录命令的效果呢?

这时就需要D3D12中为我们提供的捆绑包(Bundles)对象来Hold住这种局面了。

按照微软官方的说法:除了命令列表之外,D3D12中还通过添加第二级命令列表(称为bundle)来利用GPU硬件中的功能。捆绑包的目的是允许应用程序将少量API命令组合在一起,以便稍后执行。在绑定包创建时,驱动程序将执行尽可能多的预处理,以便在以后执行这些操作时开销更少。捆绑包被设计为可被使用和重用的任意次数。而普通的命令列表通常只执行一次。但实际上命令列表可以多次执行(只要应用程序确保在提交新的执行之前完成了以前的执行)。按照D3D12规范的要求,Direct3D 12驱动程序能够在记录命令的同时完成与捆绑包相关的大部分工作,从而使ExecuteBundle API能够以较低的开销运行捆绑包。但捆绑包引用的所有渲染管线状态对象都必须具有相同的渲染目标格式、深度蜡板缓冲格式和采样器描述符。

下面的渲染命令调用不允许在捆绑包中记录和执行:

任何Clear方法(ClearRenderTargetView等)

任何Copy方法(CopyTextureRegion等)

DiscardResource

ExecuteBundle

ResourceBarrier

ResolveSubresource

SetPredication

BeginQuery

EndQuery

SOSetTargets

OMSetRenderTargets

RSSetViewports

RSSetScissorRects

上述方法不能在捆绑包中记录。

SetDescriptorHeap可以被捆绑包调用,但捆绑包中的描述符堆必须和调用命令列表描述堆一致。如果在捆绑包上调用了这些被禁止的渲染命令中的任何一个,运行时就会停止调用。当发生这种情况时,如果在调试状态,调试层将发出一个错误。

OK,通过上面的非官方和官方的描述之后,我想你对捆绑包应该有所理解了。要使用捆绑包,首先就需要创建捆绑包,其实本质上它任然是一个命令队列,就好像我们举得那个餐馆例子中所说,为你定制的菜单任然是一个菜单一样。创建捆绑包代码如下:

GRS_THROW_IF_FAILED(pID3DDevice->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_BUNDLE
    , IID_PPV_ARGS(&pICmdAllocEarth)));
GRS_THROW_IF_FAILED(pID3DDevice->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_BUNDLE
    , pICmdAllocEarth.Get(), nullptr, IID_PPV_ARGS(&pIBundlesEarth)));
 
GRS_THROW_IF_FAILED(pID3DDevice->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_BUNDLE
    , IID_PPV_ARGS(&pICmdAllocSkybox)));
GRS_THROW_IF_FAILED(pID3DDevice->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_BUNDLE
, pICmdAllocSkybox.Get(), nullptr, IID_PPV_ARGS(&pIBundlesSkybox)));

接着,我们就使用捆绑包来记录渲染的命令,代码如下:

//球体的捆绑包
pIBundlesEarth->SetGraphicsRootSignature(pIRootSignature.Get());
pIBundlesEarth->SetPipelineState(pIPSOEarth.Get());
ID3D12DescriptorHeap* ppHeapsEarth[] = { pISRVHpEarth.Get(),pISampleHpEarth.Get() };
pIBundlesEarth->SetDescriptorHeaps(_countof(ppHeapsEarth), ppHeapsEarth);
//设置SRV
pIBundlesEarth->SetGraphicsRootDescriptorTable(0, pISRVHpEarth->GetGPUDescriptorHandleForHeapStart());
 
CD3DX12_GPU_DESCRIPTOR_HANDLE stGPUCBVHandleEarth(pISRVHpEarth->GetGPUDescriptorHandleForHeapStart()
    , 1
    , pID3DDevice->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV));
//设置CBV
pIBundlesEarth->SetGraphicsRootDescriptorTable(1, stGPUCBVHandleEarth);
 
CD3DX12_GPU_DESCRIPTOR_HANDLE hGPUSamplerEarth(pISampleHpEarth->GetGPUDescriptorHandleForHeapStart()
    , nCurrentSamplerNO
    , nSamplerDescriptorSize);
//设置Sample
pIBundlesEarth->SetGraphicsRootDescriptorTable(2, hGPUSamplerEarth);
//注意我们使用的渲染手法是三角形列表,也就是通常的Mesh网格
pIBundlesEarth->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
pIBundlesEarth->IASetVertexBuffers(0, 1, &stVBVEarth);
pIBundlesEarth->IASetIndexBuffer(&stIBVEarth);
//Draw Call!!!
pIBundlesEarth->DrawIndexedInstanced(nSphereIndexCnt, 1, 0, 0, 0);
pIBundlesEarth->Close();
 
 
//Skybox的捆绑包
pIBundlesSkybox->SetPipelineState(pIPSOSkyBox.Get());
pIBundlesSkybox->SetGraphicsRootSignature(pIRootSignature.Get());
ID3D12DescriptorHeap* ppHeaps[] = { pISRVHpSkybox.Get(),pISampleHpSkybox.Get() };
pIBundlesSkybox->SetDescriptorHeaps(_countof(ppHeaps), ppHeaps);
//设置SRV
pIBundlesSkybox->SetGraphicsRootDescriptorTable(0, pISRVHpSkybox->GetGPUDescriptorHandleForHeapStart());
CD3DX12_GPU_DESCRIPTOR_HANDLE stGPUCBVHandleSkybox(pISRVHpSkybox->GetGPUDescriptorHandleForHeapStart()
    , 1
    , pID3DDevice->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV));
//设置CBV
pIBundlesSkybox->SetGraphicsRootDescriptorTable(1, stGPUCBVHandleSkybox);
pIBundlesSkybox->SetGraphicsRootDescriptorTable(2, pISampleHpSkybox->GetGPUDescriptorHandleForHeapStart());
pIBundlesSkybox->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
pIBundlesSkybox->IASetVertexBuffers(0, 1, &stVBVSkybox);
//Draw Call!!!
pIBundlesSkybox->DrawInstanced(_countof(stSkyboxVertices), 1, 0, 0);
pIBundlesSkybox->Close();

从代码中可以看出,捆绑包跟普通的命令列表没什么区别,都是记录命令,最后Close即可。
可以看到执行捆绑包之前,为了保持命令列表中的Heaps(描述符堆列表)和捆绑包中的一致,我们就重新设置了一下,然后调用直接命令列表的ExecuteBundle执行捆绑包即可。需要注意的就是只有直接命令列表可以执行捆绑包,其实这很好理解,因为捆绑包中记录的就是关于渲染的命令,当然需要能够执行3D命令的直接命令列表来执行。

从代码中我们可以直观的看出,这样的渲染循环较之前渲染循环中的代码要简洁多了。大家可以试着将记录在捆绑包中的命令直接复制粘贴到对应的捆绑包被执行的位置,改为直接使用命令列表记录执行的样子,看看代码的复杂度,就可以直观的感受到捆绑包除了在效率上带来改进以外,在代码的简洁性上也带来了很大的改观。

这里还需要注意的一个问题就是如我们之前介绍的内容中说的,捆绑包记录的是渲染命令,而不是渲染需要的数据,比如MVP啥的,所以可以被固化,而渲染需要的数据任然需要在每帧开始的时候更新,代码如下:

n64tmCurrent = ::GetTickCount();
//计算旋转的角度:旋转角度(弧度) = 时间(秒) * 角速度(弧度/秒)
//下面这句代码相当于经典游戏消息循环中的OnUpdate函数中需要做的事情
dModelRotationYAngle += ((n64tmCurrent - n64tmFrameStart) / 1000.0f) * fPalstance;
n64tmFrameStart = n64tmCurrent;
//旋转角度是2PI周期的倍数,去掉周期数,只留下相对0弧度开始的小于2PI的弧度即可
if (dModelRotationYAngle > XM_2PI)
{
    dModelRotationYAngle = fmod(dModelRotationYAngle, XM_2PI);
}
 
//计算 视矩阵 view * 裁剪矩阵 projection
XMMATRIX xmMVP = XMMatrixMultiply(XMMatrixLookAtLH(XMLoadFloat3(&f3EyePos)
        , XMLoadFloat3(&f3LockAt)
        , XMLoadFloat3(&f3HeapUp))
        , XMMatrixPerspectiveFovLH(XM_PIDIV4
        , (FLOAT)iWndWidth / (FLOAT)iWndHeight, 0.1f, 1000.0f));
 
//设置Skybox的MVP
XMStoreFloat4x4(&pMVPBufSkybox->m_MVP, xmMVP);
 
//模型矩阵 model 这里是放大后旋转
XMMATRIX xmRot = XMMatrixMultiply(XMMatrixScaling(fSphereSize, fSphereSize, fSphereSize)
    , XMMatrixRotationY(static_cast<float>(dModelRotationYAngle)));
//计算球体的MVP
xmMVP = XMMatrixMultiply(xmRot, xmMVP);
XMStoreFloat4x4(&pMVPBufEarth->m_MVP, xmMVP);

这里我们可以看到数据通道和命令管道其实是完全分离的,这也是D3D12为我们带来的又一个重大改进之一。也为最终实现相对独立渲染管线状态对象提供了有力支撑。

  • 13
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值