【龙书】DX12理论
1 概念
DX12较之上个版本主要改变:性能优化方面大大减少了 CPU 开销,改进了对多线程的支持。性能提升!
通过 Direct3D 这种底层图形应用程序编程接口(Application Programming Interface, API), 即可在在应用程序中对图形处理器 (Graphics Processing Unit, GPU) 进行控制和编程。我们能够借此以硬件加速的方式渲染出虚拟的 3D 场景。
渲染目标(render target) 是为了渲染场景而将像素绘制到的特定缓冲区 (buffer)。通常是占用部分显存的后台缓冲区,以及纹理(详见后文)。
像素(pixl) 是构成图像的基本元素。从图形角度来讲,可认为像素是一种图像的采样单位(将图像以像素为基础进行划分,再于像素中进行采样)。因此,两张同样大小的图片,分辨率高者,意味着像素数量越多,细节越丰富,画面就越清晰。由于实际显示上的原因(后面注释会提到),也赋予了像素“大小”的概念。在 Direct3D 中,像素被抽象为具有一定长宽的色块。
虎书:Pixel is short for “picture element.” Pixel 是“图片元素”的缩写
纹素 (texel) :构成纹理的基本元素又称纹素,纹理的像素就是纹素。尽管后台缓冲区是一个纹理, 但我们仍常将其组成元素称为像素,因为就后台缓冲区这种情况而言,它所存储的内容是颜色信息。即便纹理中存储的不是颜色信息,大家有时也称纹理的元素为像素(如“法线图中的像素”)。至于二者是否需要区分,具体还要看应用场景。 比如谈到像素与纹素的映射关系时,必须将这两个概念予以区分。文中谈到的基本上是约定俗成的叫法。
**采样 ( sampling )**:又称取样,本是信号处理方面的术语。在本书中, 可认为该操作是以特定的模式,从连续的图像数据中采集出离散的关键颜色信息。
显存(GPU memory):也有直译作 GPU 内存等。显卡通常是一块带有 PCIe 总线接口的物理电路(这里仅谈独立显卡)。
GPU 较之于显卡的地位大致相当于 CPU 较之于主板。相应的,GPU 控制的显存基本相当于 CPU 控制的内存,而后者在本书中也常被称为系统内存 ( system memory)。CPU 内部有多级缓存与寄存器,分别用于缓存指令与控制 CPU; GPU 内部亦有缓存与寄存器,分别用于缓存纹理、缓存着色器指令等以及控制 GPU。有的文献在划分 GPU 的组成结构时,会把 GPU 的寄存器及其控制的内存统称为 GPU memory ( GPU 存储器)。
组件对象模型
组件对象模型 (Component Object Model, COM) 是一种令 DirectX 不受编程语言束缚,并且使之向后兼容的技术。
我们通常将 COM 对象视为一种接口,但考虑当前编程的目的,遂将它当作一个 C++类来使用。用 C++语言编写 DirectX 程序时,COM 帮我们隐藏了大量底层细节。
不使用 C++的 new 和 delete:
要获取指向某 COM 接口的指针,需借助特定函数或另一 COM 接口的方法一而不是用 C+语言中的关键字 new 去创建一个 COM 接口。
COM 对象会统计其引用次数;因此,在使用完某接口时,我们便应调用它的 Release 方法 (COM 接口的所有功能都是从 IUnknown 这个 COM 接口继承而来的,包括 Release 方法在内),而不是用 delete 来别删除——当 COM 对象的引用计数为 0 时,它将自行释放自己所占用的内存。
为了辅助用户管理 COM 对象的生命周期,Windows 运行时库 (Windows Runtime Library, WRL)专门为此提供了 Microsoft::WRL: ComPtr
类 ( #include <wrl.h>
), 我们可以把它当作是 COM 对象的智能指针。当一个 ComPtr
实例超出作用域范围时,它便会自动调用相应 COM 对象的 Release方法,继而省掉了我们手动调用的麻烦。本书中常用的 3 个 ComPtr
方法如下。
Get
:返回一个指向此底层 COM 接口的指针。此方法常用于把原始的 COM 接口指针作为参数传递给函数。例如:nums 1
2
3
4ComPtr<ID3D12Rootsignature> mRootsignature;
......
//SetGraphicsRootsignature 需要获取 ID3D12 RootSignature*类型的参数
mCommandList->SetGraphicsRootsignature (mRootsignature.Get());GetAddressof
: 返回指向此底层 COM 接口指针的地址。凭此方法即可利用函数参数返回 COM接口的指针。例如:nums 1
2
3
4
5ComPtr<ID3D12CommandAllocator> mDirectCmdListAlloc;
ThrowIfFailed(md3dDevice->CreateCommandAllocator (
D3D12 COMMAND LIST TYPE DIRECT,
mDirectCmdListAlloc. GetAddressof ())
);
[!NOTE]
COM 接口都以大写字母“I”作为开头。例如,表示命令列表的 COM 接口为ID3D12GraphicsCommandList
纹理格式
2D 纹理(2D texture) 是一种由数据元素构成的矩阵(可将此“矩阵”看作 2D 数组)。它的用途之一是存储 2D 图像数据,在这种情况下,纹理中每个元素存储的都是一个像素的颜色。
然而,纹理的用处并非仅此而已。例如,有种称作法线贴图 (normal mapping)的高级技术,其纹理内的每个元素存储的就是一个 3D 向量而不是颜色信息。因此,尽管纹理给人的第一印象通常是用来存储图像数据,但其实际用途却十分广泛。简单来讲,1D、2D、3D 纹理就相当于特定数据元素所构成 1D、2D、3D 数组。但随着后续章节中对纹理讨论的逐渐深入,我们便会知道,纹理其实还不只是像“数据数组”那样简单。它们可能还具有多种 mipmap 层级,而 GPU 则会据此对它们进行特殊的处理,例如运用过滤器(filter)和进行多重采样(multisample)。另外,并不是任意类型的数据元素都能用于组成纹理,它只能存储 DXGI FORMAT
枚举类型中描述的特定格式的数据元素。 下面是一些相关的格式示例:
DXGI FORMAT R32G32B32 FLOAT
: 每个元素由 3 个 32 位浮点数分量构成。DXGI FORMAT R16G16B16A16 UNORM
: 每个元素由 4 个 16 位分量构成,每个分量都被映射到$[0,1]$区间。DXGI FORMAT R32G32UINT
: 每个元素由 2 个 32 位无符号整数分量构成。DXGI FORMAT R8G8B8A8 UNORM
: 每个元素由 4 个 8 位无符号分量构成,每个分量都被映射到$[0,1]$区间。DXGI FORMAT R8G8B8A8 SNORM
: 每个元素由 4 个 8 位有符号分量构成,每个分量都被映射到$[-1,1]$ 小区间。DXGI FORMAT R8G8B8A8SINT
: 每个元素由 4 个 8 位有符号整数分量构成,每个分量都被
映射到 $[-128,127]$ 区间。DXGI FORMAT R8G8B8A8UINT
: 每个元素由 4 个 8 位无符号整数分量构成,每个分量都被
映射到 $[0,255]$ 区间。
颜色都是由红、绿、蓝“三基色” (注意美术中的“三原色”是指红黄蓝!)组合而成(例如,红色和绿色混合成黄色)。alpha 通道(或称为 alpha 分量)则通常用于控制透明度。
然而,正如前文所述,尽管格式名称在字面上指示的是颜色和 lpha 值,但纹理存储的却不一定是颜色信息。例如,格式 DXGI FORMAT R32G32B32 FLOAT
中含有 3 个浮点数分量,因此可以利用坐标格式为浮点数的方式存储任意 3D 向量。除此之外,亦有无类型 (typeless)格式的纹理,我们仅用它来预留内存,待纹理被绑定到渲染管线 (rendering pipeline, 详见第 5 章)之后,再具体解释它的数据类型(有点像 C++语言里的强制转换 2)。例如,下面的无类型格式保留的是由 4 个 16 位分量组成的元素,但并没有指出数据的具体类型(例如,是整数、浮点数还是无符号整数?):DXGI FORMAT R16G16B16A16 TYPELESS
我们将在第 6 章中看到 DXGI FORMAT
枚举类型也可用于描述顶点以及索引的数据格式。
交换链和页面翻转
为了避免动画中出现画面闪烁的现象,最好将动画帧完整地绘制在一种称为后台缓冲区的离屏(off-screen, 即不可直接呈现在显示设备上之意)纹理内。 只要将指定动画帧的整个场景绘到后台缓冲区中,它就会以一个完整的帧画面展现在屏幕上;依照此法,观者便不会察觉出帧的绘制过程一而只会观赏到完整的动画帧。为此,需要利用由硬件管理的两种纹理缓冲区:即所谓的前台缓冲区 (front buffer) 和后台缓冲区 (back buffer) 。
前台缓冲区存储的是当前显示在屏幕上的图像数据,而动画的下一帧则被绘制在后台缓冲区里。
当后台缓冲区中的动画帧绘制完成之后,两种缓冲区的角色互换:后台缓冲区变为前台缓冲区呈现新一帧的画面,而前台缓冲区则为了展示动画的下一帧转为后台缓冲区,等待填充数据。前后台缓冲的这种互换操作称为**呈现 (presenting, 亦有译作提交、显示等)**。呈现是一种高效的操作,只需交换指向当前前台缓冲区和后台缓冲区的两个指针即可实现。
图 4.1 对于第 n 帧来讲,当前显示的是缓冲区 A 中的内容,我们将把下一帧的数据渲染到此时的后台缓冲区 B 内。一旦后台缓冲区绘制完毕,两个缓冲区的指针将互换,即缓冲区 B 将变成前台缓冲区,而缓冲区 A 则成为新的后台缓冲区。接下来,我们会把下一帧的内容渲染到缓冲区 A 中。待后台缓冲区(即此时的缓冲区 A)完成绘制,两个缓冲区的指针再次互换,即在第+2 帧中,缓冲区 A 重新成为前台缓冲区,缓冲区 B 则再次客串后台缓冲区
前台缓冲区和后台缓冲区构成了交换链(swap chain), 在 Direct3D 中用 IDXGISwapChain
接口来表示。这个接口不仅存储了前台缓冲区和后台缓冲区两种纹理,而且还提供了修改缓冲区大小(IDXGISwapChain::ResizeBuffers
)和呈现缓冲区内容 (IDXGISwapChain::Present
)的方法。使用两个缓冲区(前台和后台)的情况称为双缓冲 (double buffering, 亦有译作双重缓冲、双倍缓冲等)。 当然,也可以运用更多的缓冲区。例如,使用 3 个缓冲区就叫作三重缓冲(triple buffering, 亦有译作三倍缓冲等)。对于一般的应用来说,使用两个缓冲区就足够了。
[!NOTE]
尽管后台缓冲区是一个纹理(因而构成纹理的基本元素又称纹素,texel), 但我们仍常将其组成元素称为像素,因为就后台缓冲区这种情况而言,它所存储的内容是颜色信息。即便纹理中存储的不是颜色信息,大家有时也称纹理的元素为像素(如“法线图中的像素”)。
至于二者是否需要区分,具体还要看应用场景。比如谈到像素与纹素的映射关系时,必须将这两个概念予以区分。文中谈到的基本上是约定俗成的叫法。
深度缓冲
深度缓冲区(depth buffer)这种纹理资源存储的并非图像数据,而是特定像素的深度信息。
深度值的范围为 0.0~1.0。0.0 代表观察者在视锥体(view frustum)中能看到离自己最近的物体,1.0 则代表观察者在视锥体中能看到离自己最远的物体。
深度缓冲区中的元素与后台缓冲区内的像素呈一一对应关系(即后台缓冲区中第 i 行第 j 列的元素对应于深度缓冲区内第 i 行第 j 列的元素)。 所以,如果后台缓冲区的分辨率为 1280×1024,那么深度缓冲区中就应当有 1280×1024 个深度元素。
为了确定不同物体间的像素前后顺序,Direct3D 采用了一种叫作深度缓冲 (depthbuffering)或 z 缓冲 (z-buffering, 其中的 z 指 z 坐标)的技术。这里要着重强调一个细节:若使用了深度缓冲,则物体的绘制顺序也就变得无关紧要了。
总而言之,深度缓冲技术的原理是计算每个像素的深度值,并执行深度测试 (depth test)。而深度测试则用于对竞争写入后台缓冲区中同一像素的多个像素深度值进行比较。具有最小深度值的像素(说明该像素离观察者最近)会获得最终的胜利,它将被写入后台缓冲区中。这样做也是合乎情理的,因为离观察者较近的像素无疑会遮挡其后面的像素。
深度缓冲区也是一种纹理,所以一定要用明确的数据格式来创建它。深度缓冲可用的格式包括以下几种:
DXGI_FORMAT_D32_FLOAT_S8X24_UINT
: 该格式共占用 64 位,取其中的 32 位指定一个浮点型深度缓冲区,另有 8 位(无符号整数)分配给模板缓冲区(stencil buffer), 并将该元素映射到$[0,255]$区间,剩下的 24 位仅用于填充对齐(padding)不作他用。DXGI_FORMAT_D32_FLOAT
: 指定一个 32 位浮点型深度缓冲区。DXGI_FORMAT_D24_UNORM_S8_UINT
: 指定一个无符号 24 位深度缓冲区,并将该元素映射
到 $[0,1]$ 区间。另有 8 位(无符号整型)分配给模板缓冲区,将此元素映射到$[0,255]$区间。DXGI_FORMAT_D16_UNORM
: 指定一个无符号 16 位深度缓冲区,把该元素映射到 $[0, 1]$ 区间。
[!NOTE]
一个应用程序不一定要用到模板缓冲区。但一经使用,则深度缓冲区将总是与模板缓冲区如影随形,共同进退。例如,32 位格式DXGI_FORMAT_D24_UNORM_S8_UINT
使用 24 位作为深度缓冲区,其他 8 位作为模板缓冲区。出于这个原因,深度缓冲区叫作深度/模板缓冲区更为得体。
资源与描述符
在渲染处理的过程中,GPU 可能会对资源进行读和写两种操作。在发出绘制命令之前,我们需要将与本次绘制调用 (draw call) 相关的资源绑定 (bind 或称链接,link) 到渲染管线上。
部分资源可能在每次绘制调用时都会有所变化,所以我们也就要每次按需更新绑定。但是,GPU 资源并非直接与渲染管线相绑定,而是要通过一种名为描述符(descriptor) 的对象来对它间接引用,我们可以把描述符视为一种对送往 GPU 的资源进行描述的轻量级结构。
从本质上来讲,它实际上即为一个中间层; 若指定了资源描述符,GPU 将既能获得实际的资源数据,也能了解到资源的必要信息。因此,我们将把绘制调用需要引用的资源,通过指定描述符的方式绑定到渲染管线。
为什么我们要额外使用描述符这个中间层呢? 究其原因,GPU 资源实质都是一些普通的内存块。由于资源的这种通用性,它们便能被设置到渲染管线的不同阶段供其使用。一个常见的例子是先把纹理用作**渲染目标 (即 Direct3D 的绘制到纹理技术)**,随后再将该纹理作为一个着色器资源(即此纹理会经采样而用作着色器的输入数据)。不管是充当渲染目标、深度/模板缓冲区还是着色器资源等角色,仅靠资源本身是无法体现出来的。而且,我们有时也许只希望将资源中的部分数据绑定至渲染管线,但如何从整个资源中将它们选取出来呢? 再者,创建一个资源可能用的是无类型格式,这样的话,GPU 甚至不会知道这个资源的具体格式。
解决上述问题就是引入描述符的原因。除了指定资源数据, 描述符还会为 GPU 解释资源: 它们会告知 Direct3D 某个资源将如何使用 (即此资源将被绑定在流水线的哪个阶段上),而且我们可借助描述符来指定欲绑定资源中的局部数据。这就是说,如果某个资源在创建的时候采用了无类型格式,那么我们就必须在为它创建描述符时指明其具体类型。
[!NOTE] 视图(view)与描述符(descriptor)是同义词
“视图”虽是 Direct3D 先前版本里的常用术语,但它仍然沿用在 Direct3D 12 的部分 API 中。在本书里,两者交替使用**,例如,“常量缓冲区视图 ( constant buffer view)”与“常量缓冲区描述符 ( constant buffer descriptor )”表达的是同一事物。
每个描述符都有一种具体类型,此类型指明了资源的具体作用。 本书常用的描述符如下:
- CBV/SRV/UAV 描述符分别表示的是常量缓冲区视图( constant buffer view)、着色器资源视图( shader resource view)和无序访问视图 ( unordered access view )这 3 种资源。
- SAMPLER采样器(亦有译为取样器)描述符表示的是采样器资源(用于纹理贴图)。
- RTV 描述符表示的是渲染目标视图资源 ( render target view )。
- DSV 描述符表示的是深度/模板视图资源 ( depth/stencil view )。
描述符堆 ( descriptor heap ) 中存有一系列描述符(可将其看作是描述符数组),本质上是存放用户程序中某种特定类型描述符的一块内存。我们需要为每一种类型的描述符都创建出单独的描述符堆。另外,也可以为同一种描述符类型创建出多个描述符堆。
[!NOTE] 堆
GPU 资源都存在堆中,本质是具有特定属性的显存块
我们能用多个描述符来引用同一个资源。例如,可以通过多个描述符来引用同一个资源中不同的局部数据。而且,前文曾提到过,一种资源可以绑定到渲染管线的不同阶段。因此,对于每个阶段都需要设置独立的描述符。例如,当一个纹理需要被用作渲染目标与着色器资源时,我们就要为它分别创建两个描述符: 一个 RTV 描述符和一个 SRV 描述符。类似地,如果以无类型格式创建了一个资源,又希望该纹理中的元素可以根据需求当作浮点值或整数值来使用,那么就需要为它分别创建两个描述符: 一个指定为浮点格式,另一个指定为整数格式。
创建描述符的最佳时机为初始化期间。由于在此过程中需要执行一些类型的检测和验证工作,所以最好不要在运行时 ( runtime)才创建描述符。
[!NOTE]
2009 年 8 月的 SDK 文档写到:“所谓创建一个完整类型的资源,即在资源创建的伊始就确定了它的具体格式。这将使运行时的访问操作得到优化。”因此,当确实需要用到无类型资源所带来的灵活性时 (即根据不同的视图对同一种数据进行多种不同解释的能力),再以这种方式来创建资源,否则应创建完整类型的资源。
多重采样技术原理
由于屏幕中显示的像素不可能是无穷小的”,所以并不是任意一条直线都能在显示器上“平滑”而完美地呈现出来。图 4.4 所示的,即为以像素矩阵 ( matrix of pixels,可以理解为“像素 2D 数组”)逼近直线的方法所产生的“阶梯”**( aliasing,锯齿状走样)** 效果。类似地,显示器中呈现的三角形之边也存在着不同程度的锯齿效应。
通过提高显示器的分辨率就能够缩小像素的大小, 继而使上述问题得到显著地改善, 使阶梯效应在很大程度上不易被用户所察觉。
在不能提升显示器分辨率, 或在显示器分辨率受限的情况下,我们就可以运用各种反走样 ( antialiasing, 也有译作抗锯齿、反锯齿、反失真等) 技术。
有一种名为超级采样( supersampling,可简记作 SSAA,即 Super Sample Anti-Aliasing) 的反走样技术,它使用 4 倍于屏幕分辨率大小的后台缓冲区和深度缓冲区。3D 场景将以这种更大的分辨率渲染到后台缓冲区中。当数据要从后台缓冲区调往屏幕显示的时候, 会将后台缓冲区按 4 个像素一组进行**解析 ( resolve,或称降采样,downsample )**。把放大的采样点数降低回原采样点数每组用求平均值的方法得到一种相对平滑的像素颜色。因此,超级采样实际上是通过软件的方式提升了画面的分辨率。
超级采样是一种开销高昂的操作,因为它将像素的处理数量和占用的内存大小都增加到之前的 4 倍。对此,Direct3D 还支持一种在性能与效果等方面都较为折中的反走样技术,叫作多重采样 (multisampling, 可简记作 MSAA,即 MultiSample Anti-Aliasing )。这种技术通过跨子像素共享一些计算信息,从而使它比超级采样的开销更低。现假设采用 4X 多重采样(即每个像素中都有 4 个子像素),并同样使用 4 倍于屏幕分辨率的后台缓冲区和深度缓冲区。值得注意的是,这种技术并不需要对每一个子像素都进行计算,而是仅计算一次像素中心处的颜色,再基于可视性 (每个子像素经深度/模板测试的结果)和覆盖性 (子像素的中心在多边形的里面还是外面?)将得到的颜色信息分享给其子像素 。图 4.5 展示了一个多重采样的相关实例。
[!NOTE] 超级采样和多重采样的关键区别
对于超级采样来说,图像颜色要根据每一个子像素来计算,因此每个子像素都可能各具不同的颜色。
而以多重采样的方式来求取图像颜色时,每个像素只需计算一次,最后,再将得到的颜色数据复制到多边形覆盖的所有可见子像素之中。由于计算图像颜色是图形流水线中开销最大的步骤之一,所以用多重采样来代替超级采样对节省资源而言意义非凡。但是话说回来,超级采样的精准度确实更高一筹。
图 4.5 所示的是一种将每个像素都以均匀栅格划分为 4 个子像素的反锯齿采样模式。实际上,每家硬件厂商所采用的模式 (即选定的子像素位置,可以说决定了采样的位置)可能会各不相同,而 Direct3D 也并没有定义子像素的具体布局。在各种特定的情况下,不同的布局模式各有千秋。
利用 Direct3D 进行多重采样:P87
功能级别
从 Direct3D 11 开始便引进了功能级别 ( feature level ) 的概念(在代码里用枚举类型 D3D_ FEATURE_LEVEL 表示),以下参数大致对应于 Direct3D 9 到 Direct3D 11 之间的各种版本”:
1 | enum D3D_FEATURE_LEVEL |
“功能级别”为不同级别所支持的功能进行了严格的界定(每个功能级别所支持的特定功能可参见 SDK 文档)。例如,一款支持功能级别 11 的 GPU,除了个别特例之外 (像类似于多重采样数量这样的信
息仍然需要查询,因为 Direct3D 规范允许这些 Direct3D 11硬件在此方面有各自不同的实现),必须支持完整的 Direct3D 11 功能集。功能集使程序员的开发工作更加便捷——只要了解所支持的功能集, 就能知道有哪些 Direct3D 功能可供使用。
如果用户的硬件不支持某特定功能级别,应用程序理当回退至版本更低的功能级别。例如,为了照顾更多用户,一款应用程序可能会支持 Direct3D 11、10 乃至 9.3 级别的硬件。应用程序当按照从最新到最旧的级别支持顺序展开检测: 首先检测 Direct3D 11 是否被支持,其次检测 Direct3D 10,最后检测 Direct3D 9.3。在本书中,我们总是假设需要支持的功能级别为 D3D_FEATURE_LEVEL_11_0。但是在现实的应用程序中,我们往往需要考虑支持稍旧的硬件,以获得更多的用户。
DirectX 图形基础结构
DirectX 图形基础结构 (DirectX Graphics Infrastructure,DXGI,也有译作 DirectX 图形基础设施),是一种与 Direct3D 配合使用的 API。设计 DXGI 的基本理念是使多种图形 API 中所共有的底层任务能借助一组通用 API 来进行处理。例如,为了保证动画的流畅性,2D 渲染与 3D 渲染两组 API 都要用到交换链和页面翻转功能,这里所用的交换链接口 IDXGISwapChain
(详见 4.1.4 节)实际上就属于 DXGI API。
DXGI 还用于处理一些其他常用的图形功能,如切换全屏模式 ( full-screen mode。另一种是窗口模式,windowed mode ),枚举显示适配器、显示设备及其支持的显示模式 (分辨率、刷新率等)等这类图形系统信息。除此之外,它还定义了 Direct3D 支持的各种表面格式信息( DXGI_FORMAT
)。
我们刚刚简单地叙述了 DXGI 的概念,下面来介绍一些在 Direct3D 初始化时会用到的相关接口。IDXGIFactory
是 DXGI 中的关键接口之一, 主要用于创建 IDXGISwapChain
接口以及枚举显示适配器。而显示适配器则真正实现了图形处理能力。通常来说,显示适配器( display adapter ) 是一种硬件设备 (例如独立显卡), 然而系统也可以用软件显示适配器来模拟硬件的图形处理功能。一个系统中可能会存在数个适配器(比如装有数块显卡)。适配器用接口 IDXGIAdapter
来表示。
另外,一个系统也可能装有数个显示设备。我们称每一台显示设备都是一个显示输出 ( display output 有的文档也作 adapter output,适配器输出)实例,用 IDXGIOutput
接口来表示。每个适配器都与一组显示输出相关联。
设置代码见 P89
功能支持的检测
P92
1 | HRESULT ID3D12Device::CheckFeatureSupport ( |
资源驻留
复杂的游戏会运用大量纹理和 3D mesh 等资源,但是其中的大多数并不需要总是置于显存中供 GPU 使用。例如,让我们来构想这样一个游戏场景: 在野外的森林中,有一个巨大的洞穴。在玩家进入洞穴之前,绘制画面并不会用到与洞穴相关的资源; 当玩家进入洞穴之后,又不再需要森林数据资源。
在 Direct3D 12 中, 应用程序通过控制资源在显存中的去留,主动管理资源的驻留情况 (即 residency 。无论资源是否本已位于显存中,都可对其进行管理。在 Direct3D 11 中则由系统自动管理)。 该技术的基本思路为使应用程序占用最小的显存空间。这是因为显存的空间有限,很可能不足以容下整个游戏的所有资源,或者用户还有运行中的程序也在同时使用显存。这里给出一条与性能相关的提示: 程序应当避免在短时间内于显存中交换进出相同的资源,这会引起过高的开销。最理想的情况是,所清出的资源在短时间内不会再次使用。 游戏关卡或游戏场景的切换是关于常驻资源的好例子。
一般来说,资源在创建时就会驻留在显存中,而当它被销毁时则清出。但是通过下面的方法,我们就可以自己来控制资源的驻留:
1 | HRESULT ID3D12Device::MakeResident ( |
这两种方法的第二个参数都是 ID3D12Pageable
资源数组,第一个参数则表示该数组中资源的数量。
为了简单起见,我们会把本书冲演示程序的规模控制得比游戏小得多,所以也就不必对资源的驻留进行管理。
2 CPU 与 GPU 间的交互
在进行图形编程的时候,我们一定要了解有两种处理器在参与处理工作,即 CPU 和 GPU,两者并行工作,但时而也需同步。为了获得最佳性能,最好的情况是让两者尽量同时工作,少同步。同步是一种我们不乐于执行的操作,因为这意味着一种处理器要以空闲状态等待另一种处理器完成某些任务,话句话说, 它破坏了两者并行工作的机制。
命令队列和命令列表
每个 GPU 都至少维护着一个命令队列 ( command queue,本质上是环形缓冲区,即 ring buffer )。借助 Direct3D API,CPU 可利用命令列表 ( command list) 将命令提交到这个队列中去 (见图 4.6 )。
[!NOTE]
Direct3D 11 支持两种绘制方式:即立即渲染 ( immediate rendering, 利用 immediate context 实现)以及延迟渲染( deferred rendering,利用 deferred context 实现)。前者将缓冲区中的命令直接借驱动层发往 GPU 执行,后者则与本文中介绍的命令列表模型相似(但执行命令列表时仍然要依赖 immediate context)。前者延续了 Direct3D11 之前一贯的绘制方式,而后者则为 Direct3D 11 中新添加的绘制方式。Direct3D 12 取消了立即渲染方式,完全采用“命令列表->命令队列”模型,使多个命令列表同时记录命令,借此充分发挥多核心处理器的性能。 可见,Direct3D 11 在绘制方面乃承上启下之势,而 Direct3D 12则进行了彻底的革新。
当一系列命令被提交至命令队列之时,它们并不会被 GPU 立即执行,理解这一点至关重要。由于 GPU 可能正在处理先前插入命令队列内的命令,因此,后来新到的命令会一直在这个队列之中等待执行。
假如命令队列中变得空空如也,那么没有任务可执行的 GPU 只能空闲下来; 相反地,如果命令队列被填满,那么 CPU 必将随着 GPU 的工作步伐在某些时刻保持空闲。这两种情况都是我们不希望碰到的。对于像游戏这样的高性能应用程序来说,它们的目标是充分利用硬件资源,保持 CPU 和 GPU 同时忙碌。
在 Direct3D 12 中,命令队列被抽象为 ID3D12CommandQueue
接口来表示。要通过填写 D3D12_COMMAND_QUEUE_DESC
结构体来描述队列,再调用 ID3D12Device::CreateCommandQueue
方法创建队列。我们在本书中将实际采用以下流程:
1 | Microsoft::WRL::ComPtr<ID3D12CommandQueue> mCommandQueue; |
1 | //IIIDPPV ARGS 辅助宏的定义如下: |
其中,_uuidof (**(ppType))
将获取 (**(ppType))
的 COM 接口 **ID ( globally unique identifier,全局唯一标识符,GUID)**,在上述代码段中得到的即为 ID3D12CommandQueue
接口的 COM ID。IID_PPV_ARGS
辅助函数的本质是将 ppType
强制转换为 void**
类型。我们在全书中都会见到此宏的身影,这是因为在调用 Direct3D 12 中创建接口实例的 API 时,大多都有一个参数是类型为 void 的待创接口 COM ID,ExecuteCommandLists
是一种常用的 ID3D12CommandQueue
接口方法,利用它可将命令列表里的命令添加到命令队列之中:
1 | void ID3D12CommandQueue::ExecuteCommandLists ( |
GPU 将从数组里的第一个命令列表开始顺序执行。ID3D12GraphicsCormandList
接口封装了一系列图形渲染命令, 它实际上继承于 ID3D12CommandList
接口。ID3D12GraphicsCommandList
接口有数种方法向命令列表添加命令。例如,下面的代码依次就向命令列表中添加了设置视口、清除渲染目标视图和发起绘制调用的命令:
1 | //mCommandList为一个指向IID3D12CommandList接口的指针 |
虽然这些方法的名字看起来像是会使对应的命令立即执行,但事实却并非如此,上面的代码仅仅是将命令加入命令列表而已。调用 ExecuteCommandLists
方法才会将命令真正地送入命令队列, 供 GPU 在合适的时机处理。
当命令都被加入命令列表之后,我们必须调用 ID3D12GraphicsCommandList::Close
方法来结束命令的记录:
1 | //结束记录命令 |
在调用 ID3D12CommandQueue::ExecuteCommandLists
方法提交命令列表之前,一定要将其关闭。
还有一种与命令列表有关的名为 ID3D12CommandAllocator
的内存管理类接口。记录在命令列表内的命令,实际上是存储在与之关联的命令分配器 ( command allocator ) 上。当通过 ID3D12CommandQueue:: ExecuteCommandLists
方法执行命令列表的时候,命令队列就会引用分配器里的命令。而命令分配器则由 ID3D12Device
接口来创建:
1 | HRESULT ID3D12Device::CreateCommandAllocator ( |
type
: 指定与此命令分配器相关联的命令列表类型。以下是本书常用的两种命令列表类型:
D3D12_COMMAND_LIST_TYPE_DIRECT
。存储的是一系列可供 GPU 直接执行的命令(这种类型的命令列表我们之前曾提到过)。D3D12_COMMAND_LIST_TYPE_BUNDLE
。将命令列表打包 ( bundle,也有译作集合)。构建命令列表时会产生一定的 CPU 开销,为此,Direct3D 12 提供了一种优化的方法,允许我们将一系列命令打成所谓的包。当打包完成 (命令记录完毕)之后,驱动就会对其中的命令进行预处理, 以使它们在渲染期间的执行过程中得到优化。因此,我们应当在初始化期间就用包记录命令。如果经过分析,发现构造某些命令列表会花费大量的时间, 就可以考虑使用打包技术对其进行优化。Direct3D 12 中的绘制 API 的效率很高,所以一般不会用到打包技术。因此,也许在证明其确实可以带来性能的显著提升时才会用到它。这就是说,在大多数情况下,我们往往将其束之高阁。本书中不会使用打包技术,关于它的详情可参见 DirectX 12文档。
riid
: 待创建 ID3D12CommandAllocator 接口的 COM ID。ppCommandAllocator
: 输出指向所建命令分配器的指针。 命令列表同样由ID3D12Device
接口创建:nums 1
2
3
4
5
6HRESULT ID3D12Device::CreateCommandList (
UINT nodeMask,
D3D12_COMMAND_LIST_TYPE type,
ID3D12CommandAllocator *pCommandAllocator, ID3D12Pipelinestate *pInitialstate,
REFIID riid,
void **ppCommandList) ;nodeMask
: 对于仅有一个 GPU 的系统而言,要将此值设为 0; 对于具有多 GPU 的系统而
言,此节点掩码 ( node mask)指定的是与所建命令列表相关联的物理 GPU。本书中假设我们使用的是单 GPU 系统。
2.type
: 命令列表的类型,常用的选项为D3D12_COMMAND_LIST_TYPE_DIRECT
和D3D12COMMAND_LIST_TYPE_BUNDLE
。pCommandAllocator
: 与所建命令列表相关联的命令分配器。它的类型必须与所创命令列表的类型相匹配。pInitialstate
: 指定命令列表的渲染管线初始状态。对于打包技术来说可将此值设为
nullptr,另外,此法同样适用于执行命令列表中不含有任何绘制命令,即执行命令列表是了达到初始化的目的的特殊情况。我们将在第 6 章中详细讨论 ID3D12Pipelinestate 接口 5. riid: 待创建 ID3D12CommandList 接口的 COM ID。ppCommandList
: 输出指向所建命令列表的指针。
[!NOTE]
我们可以通过ID3D12Device :: GetNodeCount
方法来查询系统中 GPU 适配器节点(物理 GPU)的数量。
我们可以创建出多个关联于同一命令分配器的命令列表,但是不能同时用它们来记录命令。因此,当其中的一个命令列表在记录命令时,必须关闭同一命令分配器的其他命令列表。换句话说,要保证命令列表中的所有命令都会按顺序连续地添加到命令分配器内。
还要注意的一点是,当创建或重置一个命令列表的时候,它会处于一种“打开”的状态。所以,当尝试为同一个命令分配器连续创建两个命令列表时,我们会得到这样的一个错误消息:
1 | D3D12ERROR: ID3D12CommandList::{Create,Reset }CommandList: The command allocator iscurrently in-use by another command list. |
在调用 ID3D12CommandQueue::ExecuteCommandList (C)
方法之后,我们就可以通过 ID3D12GraphicsCommandList :: Reset
方法,安全地复用命令列表 c 占用的相关底层内存来记录新的命令集。Reset
方法中的参数对应于以 ID3D12Device::createCommandList
方法创建命令列时所用到的参数。
1 | HRESULT ID3D12GraphicsCommandList::Reset ( |
此方法将命令列表恢复为刚创建时的初始状态,我们可以借此继续复用其低层内存,也可以避免释放旧列表再创建新列表这一系列的烦琐操作。注意,重置命令列表并不会影响命令队列中的命令,因为相关的命令分配器仍在维护着其内存中被命令队列引用的系列命令。
向 GPU 提交了一整帧的渲染命令后,我们可能还要为了绘制下一帧而复用命令分配器中的内存。ID3D12CommandAllocator::Reset
方法由此应运而生:
1 | HRESULT ID3D12CommandAllocator : : Reset (void) ; |
这种方法的功能类似于向量类中的 std::vector::clear
方法,后者使向量的大小 ( size )归零,但是仍保持其当前的容量 ( capacity )。
[!warning]
由于命令队列可能会引用命令分配器中的数据,所以在没有确定 GPU 执行完命令分配器中的所有命令之前,千万不要重置命令分配器!
CPU 与 GPU 的同步
当两种处理器并行工作时,自然而然地就会产生一系列的同步问题。
假设有一资源 $R$,里面存有待绘制几何体的位置信息。现在,令 CPU 对 R 中的数据进行更新, 先把 R 中的几何体位置信息改为 p1,再向命令队列里添加绘制资源 R 的命令 c,以此将几何体绘制到位置 p1。由于向命令队列添加命令并不会阻塞 CPU,所以 CPU 会继续执行后序指令。在 GPU 执行绘制命令 C 之前,如果 CPU 率先覆写了数据 R, 提前把其中的位置信息修改为 p2,那么这个行为就会造成一个严重的错误。
解决此问题的一种办法是: 强制 CPU 等待, 直到 GPU 完成所有命令的处理,达到某个指定的围栏点 ( fence point)为止。我们将这种方法称为刷新命令队列 ( flushing the command queue ),可以通过围栏(fence) 来实现这一点。围栏用 ID3D12Fence
接口来表示,
此技术能用于实现 GPU 和 CPU 间的同步。创建一个围栏对象的方法如下:
1 | HRESULT ID3D12Device::CreateFence( |
每个围栏对象都维护着一个 UINT64
类型的值,此为用来标识围栏点的整数。起初,我们将此值设为 0,每当需要标记一个新的围栏点时就将它加 1。现在,我们用代码和注释进行展示,看看如何用一个围栏来刷新命令队列。
1 | UINT64 mCurrentFence = 0; |
这样一来,在本节开始给出的情景中,当 CPU 发出绘制命令 C 后,在将 R 内的位置信息改写为 p2 之前,应率先刷新命令队列。这种解决方案其实并不完美,因为这意味着在等待 GPU 处理命令的时候, CPU 会处于空闲状态,但在第 7 章以前也只能暂时使用这个简单的办法了。 我们几乎可以在任何时间点上刷新命令队列 (当然,不一定仅在渲染每一帧时才刷新一次)。例如,若有一些 GPU 初始化命令有待执行,我们便可以在进入渲染主循环之前刷新命令队列,从而进行这些初始化操作。
其实,用刷新命令队列的办法也可以解决上一小节末尾遇到的问题,即在重置命令分配器之前先刷新命令队列来确定 GPU 的命令都已执行完毕。
资源状态转换
为了实现常见的渲染效果,我们经常会通过 GPU 对某个资源 R 按顺序进行先写后读这两种操作。然而,当 GPU 的写操作还没有完成抑或甚至还没有开始,却开始读取资源,便会导致资源冒险 ( resource hazard )。
为此,Direct3D 专门针对资源设计了一组相关状态。资源在创建伊始会处于默认状态,该状态将一直持续到应用程序通过 Direct3D 将其转换 ( transition) 为另一种状态为止。这就使 GPU 能够针对资源状态转换与防止资源冒险作出适当的行为。
例如,如果要对某个资源(比如纹理)执行写操作时,需要将它的状态转换为渲染目标状态; 而要对该纹理进行读操作时,再把它的状态变为着色器资源状态。根据 Direct3D 给出的转换信息,GPU 就可以采取适当的措施避免资源冒险的发生。
譬如,在读取某个资源之前,它会等待所有与之相关的写操作执行完毕。应用程序开发者应当知道,资源转换所带来的负荷会造成程序性能的下降。除此之外,一个自动跟踪状态转换的系统也在强行增加程序的额外开销。
通过命令列表设置转换资源屏障 ( transition resource barrier )数组,即可指定资源的转换; 当我们希望以一次 API 调用来转换多个资源的时候,这种数组就派上了用场。在代码中,资源屏障用 D3D12 RESOURCE BARRIER
结构体来表示 R。下列辅助函数 (定义于 d3dx12. h
头文件之中)将根据用户给出的资源和指定的前后转换状态,返回对应的转换资源屏障描述:
1 | struct CD3DX12_RESOURCE_BARRIER : public D3D12_RESOURCE_BARRIER{ |
可以看到,CD3DX12_RESOURCE_BARRIER
继承自 D3D12_RESOURCE_BARRIER
结构体,并添加了一些辅助方法。Direct3D 12 中的许多结构体都有其对应的扩展辅助结构变体 ( variation ),考虑到使用上的方便性,我们更偏爱于运用那些变体。以 CD3DX12
作为前缀的变体全都定义在 d3dx12. h
头文件当中,这个文件并不属于 DirectX 12 SDK 的核心部分,但是可以通过微软的官方网站下载获得。为了方便起见,本书源代码的 Common 目录里附有一份 d3dx12.h 头文件。
在本章的示例程序中,此辅助函数的用法如下:
1 | mCommandList->ResourceBarrier(1, |
这段代码将以图片形式显示在屏幕中的纹理,从呈现状态转换为渲染目标状态。那么,这个添加到命令列表中的资源屏障究竟是何物呢? 事实上, 我们可以将此资源屏障转换看作是一条告知 GPU 某资源状态正在进行转换的命令。所以在执行后续的命令时,GPU 便会采取必要措施以防资源冒险。
[!NOTE]
Direct3D 12 提供的转换类型不止文中提到寥寥几种。但是,我们暂时只会用到上述转换屏障。至于其他类型的屏障,我们将随用随讲。
命令与多线程
Direct3D 12 的设计目标是为用户提供一个高效的多线程环境, 命令列表也是一种发挥 Direct3D 多线程优势的途径。对于内含许多物体的庞大场景而言,仅通过一个构建命令列表来绘制整个场景会占用不少的 CPU 时间。因此,可以采取一种并行创建命令列表的思路。例如,我们可以创建 4 条线程,每条分别负责构建一个命令列表来绘制 25%的场景物体。
以下是一些在多线程环境中使用命令列表要注意的问题。
- 命令列表并非自由线程 ( not free-threaded)对象。也就是说,多线程既不能同时共享相同的命令列表,也不能同时调用同一命令列表的方法。所以,每个线程通常都只使用各自的命令列表。
- 命令分配器亦不是线程自由的对象。这就是说,多线程既不能同时共享同一个命令分配器也不能同时调用同一命令分配器的方法。所以,每个线程一般都仅使用属于自己的命令分配器。
- 命令队列是线程自由对象,所以多线程可以同时访问同一命令队列,也能够同时调用它的方法。特别是每个线程都能同时向命令队列提交它们自己所生成的命令列表。
- 出于性能的原因,应用程序必须在初始化期间,指出用于并行记录命令的命令列表最大数量。为了简单起见,本书不会使用多线程技术。。
3 渲染管线
3.1 定义
渲染管线定义:如果给出一台具有确定位置和朝向的虚拟摄像机 ( virtual camera )以及某个 3D 场景的几何描述,那么渲染管线则是以此虚拟摄像机作为视角进行观察,并据此生成给定 3D 场景 2D 图像的一整套处理步骤。
渲染管线亦有译作渲染管道、渲染流水线、绘制流水线等。有时也称之为图形流水线,即 graphics pipeline。不管何种译法,希望会给读者这样一个印象: 把渲染管线想象为一个工厂里的流水线,里面有不同的加工环节 (也就是渲染阶段),可以根据用户需求对每个环节灵活改造或拆卸 (可编程流水线, 程序员可在不同的着色器中编写自定义的函数,早期均为固定功能流水线,后加入可编程处理器予以实现。以及开启或禁用某些渲染阶段, 如曲面细分阶段与几何着色器阶段等)。以此把原始材料 ( CPU 端向 GPU 端提交的纹理等资源以及指令等)加工为成品出售给消费者 (在 GPU 端,资源流经流水线里的各个阶段,经指令的调度对其进行处理,最终计算出像素的颜色,将其呈现在用户屏幕上)。
事实上,渲染管线是种模型, 将 3D 场景变换至 2D 场景的处理流程抽象分离为不同的流水线阶段,供用户使用。其本质即指令从 CPU 端的应用程序层发送至 Direct3D 运行时、驱动层及至 GPU 端 (包括二者间的通信,连接都靠 PCIe 接口,实质上就是围绕这种总线传递数据),资源数据在内存与显存间游走,最后是 GPU 内部各种引擎、缓存、命令队列等根据指令配合运作将数据转化为显示器可视信号。
3D 视觉要素(即通过扁平的 2D 显示器屏幕却能观察到 3D 立体场景的视觉因素)
- 近大远小
- 物体重叠关系
- 光照
- 阴影
3.2 概述
- 左侧展示的是组成渲染管线的所有阶段。
- 右侧则是显存资源:
从资源内存池指向渲染管线阶段的箭头,表示该阶段可以访问资源并以此作为输入。 例如,在像素着色器阶段 ( pixel shader stage ),渲染管线为了完成用户分派的任务,可以从显存所存的纹理资源中读取数据。
从渲染管线阶段指向内存的箭头,则意味着该阶段可以向 GPU 资源写入数据。 例如,在输出合并 (器)阶段 ( output merger stage)把数据写到像后台缓冲区和深度/模板缓冲区这样的纹理之中。 - 位于输出合并阶段的箭头是**双向的 (说明此阶段可读写 GPU 资源)**。
如我们所见,大多数阶段都是不能向 GPU 资源进行写操作的。事实上,渲染管线中每个阶段所输出的数据往往都是作为其下个阶段的输入。例如,顶点着色器阶段( vertex shaderstage)从输入装配器阶段 ( input assembler stage)获得输入数据,待完成相应工作后,再将结果输出至几何着色器阶段 ( geometry shader stage)。
3.3 输入装配器阶段
输入装配器 (Input Assembler,IA)阶段会从显存中读取几何数据 (顶点和索引,vertex and index ),再将它们装配为几何图元 ( geometric primitive,亦译作几何基元,如三角形和线条这种构成图形的基本元素)。这些概念将在后文中陆续介绍,但简单来说,我们是通过索引来定义如何将顶点装配在一起,从而构成图元的方法。
顶点
除了空间位置之外,Direct3D 中的顶点还可以包含其他信息,这使我们能够用它表现出更为复杂的信息。如法向量、纹理坐标,DIrect3D 还允许我们自定义顶点格式。
图元拓扑
在 Direct3D 中,我们要通过一种名为顶点缓冲区 ( vertex buffer ) 的特殊数据结构,将顶点与渲染管线相绑定。顶点缓冲区利用连续的内存来存储一系列顶点。
我们要通过指定图元拓扑( primitive topology,或称基元拓扑) 来告知 Direct3D 如何用顶点数据来表示几何图元:
图元拓扑按类型分可以分为:点列表,线条带,线列表,三角形带,三角形列表,具有邻接数据的图元拓扑,控制点点面片列表,按顺序如下图所示。
点列表
D3D PRIMITIVE_TOPOLOGY_POINTLIST
所有顶点在绘制调用过程中被绘制为一个单独的点
线条带
D3D_PRIMITIVE_TOPOLOGY_LINESTRIP
所有顶点在绘制调用过程中被连接为一系列连续线段,$n+1$ 个顶点有 $n$ 条线段
线列表
D3D_ PRIMITIVE_TOPOLOGYLINELIST
每对顶点在绘制调用过程中被连接为单独的线段,$2n$ 个顶点有 $n$ 条线段
三角形带
D3D PRIMITIVETOPOLOGY_TRIANGLESTRIP
在三角形带中,次序为偶数的三角形与次序为奇数三角形的绕序 ( winding order, 也译作环绕顺序等,即装配图元的顶点顺序为逆时针或顺时针方向) 是不同的,这就是剔除( culling,亦称消隐)问题的由来 (参见 5.10.2 节)。为了解决这个问题,GPU 内部会对偶数三角形中后两个顶点的顺序进行调换,以此使它们与奇数三角形的绕序保持一致。
在 DirectX 12 中,图里三角形带的实际环绕顺序为: 012、132、234、354 。按道理来讲,次序为偶数的三角形的顶点绕序也应遵循默认的顶点编号顺序 (如第 2、4 个三角形的默认顶点编号顺序应为 123、345 ),但事实上并非如此。为什么要这样做呢? 作者讲到: 为了使绕序保持一致,都为顺时针。而绕序又与 5.10.2 节中所述的剔除技术有关。
三角形列表
D3D_PRIMITIVE_ TOPOLOGY_TRIANGLELIST
在绘制调用的过程中会将每 3 个顶点装配成独立的三角形; 所以每 3n 个顶点会生成 n 个三角形。
三角形列表与三角形带的区别是: 三角形列表中的三角形可以彼此分离,而三角形带中的三角形则是相连的。
具有邻接数据的图元拓扑
对于存有邻接数据的三角形列表而言,每个三角形都有 3 个与之相邻的邻接三角形 ( adjacenttriangle )。
在几何着色器中,往往需要访问这些邻接三角形来实现特定的几何着色算法。为了使几何着色器可以顺利地获得这些邻接三角形的信息,我们就需要借助顶点缓冲区与索引缓冲区 ( indedx buffer )将它们随主三角形一并提交至渲染管线。
另外,此时一定要将拓扑类型指定为 D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST_ADJ
,只有这样,渲染管线才能得知如何以顶点缓冲区中的顶点来构建主三角形及其邻接三角形。
注意,邻接图元的顶点只能用作几何着色器的输入数据,却并不会被绘制出来。即便程序没有用到几何着色器,但依旧不会绘制邻接图元。
控制点点面片列表
D3D_PRIMITIVE_TOPOLOGY_N_CONTROL_POINT_PATCHLIST
将顶点数据解释为具有 N 个控制点 ( control point )的面片列表 ( patch list )。此图元常用于渲染管线的曲面细分阶段 ( tessellation stage,此环节为可选阶段),因此,我们将这种列表拓扑延至第 14 章中再进行讨论。
索引
索引是顶点装配器的一个部分。一个多边形可以由多个三角形组成,而为三角形指定顺序是十分重要的过程,我们称它为**绕序(winding order)**。
对于一个多边形,我们不希望重复复制顶点,理由如下:
- 增加内存需求
- 增加图形硬件的处理负荷
三角形条带在某些情况下可以改善顶点的复制问题,前提是几何体能够被组织为带状结构。而三角形列表更为灵活(该拓扑中的三角形无需相互连接),所以,我们使用利用三角形列表移除重复顶点的设计方案。
我们采取的解决办法叫索引,整个工作流程是这样的: 先创建一个顶点列表和一个索引列表。在顶点列表中收录一份所有独立的顶点,并在索引列表中存储顶点列表的索引值,这些索引定义了顶点列表中的顶点是如何组合在一起,从而构成三角形的。构建四边形的顶点列表如下:
1 | vertex V[4]={ v0, v1, v2, v3 }; |
接下来,我们需要创建索引列表,以此来定义如何将顶点列表中的顶点组合成两个三角形。
1 | UINT indexList[6]= {0,1,2,//三角形0 |
在索引列表中, 每 3 个元素定义了一个三角形。所以上面的索引意为:“通过顶点 v[0], v[1]和 v[2]来组成三角形 0,再借助顶点 v[ 0], v [2]和 v[3]来构成三角形1。”
待处理完顶点列表中那些独立的顶点之后,显卡就能通过索引列表把顶点组合成一系列三角形。
可以看到,我们已经将“复用的顶点数据”转化为索引列表,但是这样做的效果要比之前的方法更好,这是因为:
- 索引皆是简单的整数,不会像使用整个顶点结构体那样占用更多的内存(而且,随着顶点结构体中分量的不断增多,将会使内存的需求变得更为急迫)。
- 若辅以适当的顶点缓存排序,则图形硬件将不必再次处理重复使用的顶点,从缓存中直接取得即可 (这种情况十分普遍)。
每个图形适配器都具有特定大小的缓存 ( cache ) ,刚处理过的顶点可以被临时存储在缓存当中,由于缓存的读取速度较顶点缓冲区快,因此可以利用这一点来提升软件的性能。不同硬件的缓存大小有别,因此应安排好顶点顺序,首先引用需要复用的顶点,在这些顶点仍位于缓存之中时尽快引用。
3.4 顶点着色器阶段
待图元被装配完毕后,其顶点就会被送入顶点着色器阶段 ( vertex shader stage,简记作 VS)。我们可以把顶点着色器看作一种输入与输出数据皆为单个顶点的函数。每个要被绘制的顶点都须经过顶点着色器的处理再送往后续阶段。
事实上,我们可以认为在硬件中执行的是下列处理过程:
1 | for (UINT i = 0; i < numvertices; ++i) |
其中的顶点着色器函数 (VertexShader)就是我们要实现的那一部分,因为在这一阶段中对顶点的操作实际是由 GPU 来执行的,所以速度很快。
我们可以利用顶点着色器来实现许多特效,例如变换、光照和位移贴图 ( displacement mapping,也译作置换贴图。map 有映射之意,因此也有译作位移映射,类似的还有在后面将见到的纹理贴图、法线贴图等)。请牢记: 在顶点着色器中,不但可以访问输入的顶点数据,也能够访问纹理和其他存于显存中的数据(如变换矩阵与场景的光照信息)。
3.5 曲面细分阶段
曲面细分是一个可选的渲染阶段 (可在用户需要之时才开启)。
曲面细分阶段 ( tessellation stages )是利用镶嵌化处理技术对网格中的三角形进行细分 ( subdivide ), 以此来增加物体表面上的三角形数量。再将这些新增的三角形偏移到适当的位置,使网格表现出更加细腻的细节。
使用曲面细分的优点有以下几方面。
1.我们能借此实现一种细节层次 (level-of-detail,LOD) 机制,使离虚拟摄像机较近的三角形经镶嵌化处理得到更加丰富的细节,而对距摄像机较远的三角形不进行任何更改。通过这种方式,即可只针对用户关注度高的部分网格增添三角形,从而提升其细节效果。
2.我们在内存中仅维护简单的低模 ( low-poly,低精度模型, 也有译作低面多边形、低面片等)网格 (低模网格是指三角形数量较少的网格, 已逐渐形成一门独特画风的艺术制作手段), 再根据需求为它动态地增添额外的三角形, 以此节省内存资源。
3.我们可以在处理动画和物理模拟之时采用简单的低模网格,而仅在渲染的过程中使用经镶嵌化处理的高模( high-poly,与低模对应)网格。
曲面细分是 Direct3D 11 中新引入的处理阶段,它们为我们提供了一种利用 GPU 即可对几何体进行镶嵌化处理的手段。在 Direct3D 11 之前,如果我们希望实现曲面细分操作,只能在 CPU 上实现这项任务,而且经细分后几何体必须上传回 GPU 中,方可进行渲染。然而,将新几何体从 CPU 端的内存上传至 GPU 显存的过程十分缓慢,而且曲面细分计算也会增加 CPU 的负担。由于这些原因,在 Direct3D 11 之前,曲面细分方法在实时渲染图像方面并没有流行开来。自 Direct3D 11 提供了一组相关的 API 起, 才使得曲面细分技术完全可以在与 Direct3D 11 兼容的显卡中得以实现。如此一来就大大提高了曲线细分技术的魅力。
3.6 几何着色器阶段
几何着色器 ( geometry shader stage,GS)是一个可选渲染阶段。
几何着色器接受的输入应当是完整的图元。例如,假设我们正在绘制三角形列表, 那么向几何着色器传入的将是定义三角形的 3 个顶点。(注意,这 3 个顶点在此之前已经过了顶点着色器阶段的处理)几何着色器的主要优点是可以创建或销毁几何体。比如说,我们可以利用几何着色器将输入的图元拓展为一个或多个其他图元,抑或根据某些条件而选择不输出任何图元。顶点着色器与之相比,则不能创建顶点: 它只能接受输入的单个顶点,经处理后再将该顶点输出。几何着色器的常见拿手好戏是将一个点或一条线扩展为一个四边形。
我们也可以留心观察一下图 5.11 中那条“流输出 ( stream-out)”阶段的箭头。这也就意味着,几何着色器能够为后续的绘制操作,而将顶点数据流输出至显存中的某个缓冲区之内,我们将在后续章节中对这种高级技术展开讨论。
3.7 光栅化阶段
光栅化阶段( rasterization stage,RS,亦有将 rasterization 译作像素化或栅格化)的主要任务是为投影主屏幕上的 3D 三角形计算出对应的像素颜色。
3.8 像素着色器阶段
我们编写的像素着色器 ( pixel shader,PS)是一种由 GPU 来执行的程序。它会针对每一个像素片段“pixel fragment,亦有译作片元)进行处理(即每处理一个像素就要执行一次像素着色器),并根据顶点的插值属性作为输入来计算出对应的像素颜色。像素着色器既可以直接返回一种单一的恒定颜色,也可以实现如逐像素光照 ( per-pixel lighting)、反射 ( reflection )以及阴影(shadow)等更为复杂的效果。
像素片段:即像素的采样点。如开启多重采样后,会对每个像素按设置的采样数量进行采集,而这采集的样本即为像素片段。此时,每个像素将由从中采集出来的像素片段插值计算得出。如若未开启多重采样,则像素将映射为像素片段。题外话, OpenGL 则通常将像素着色器称为片段着色器。
3.9 输出合并阶段
通过像素着色器生成的像素片段会被移送至渲染管线的输出合并 ( Output Merger,OM)阶段。
在此阶段中,一些像素片段可能会被丢弃(例如,那些未通过深度缓冲区测试或模板缓冲区测试的像素片段)。而后,剩下的像素片段将会被写入后台缓冲区中。混合 ( blend,也有译作融合)操作也是在此阶段实现的,此技术可令当前处理的像素与后台缓冲区中的对应像素相融合,而不仅是对后者进行完全的覆写。一些如“透明”这样的特殊效果,也是由混合技术来实现的。
4 着色器
HLSL 中的函数具有以下属性。
- 函数采用类 C++语法。
- 参数只能按值传递,没有引用和指针。
- 不支持递归。
- 只有内联函数 inline。
1 | // 声明常量 |
SV_POSITION
SV 代表系统值(system value),它所修饰的顶点着色器输出元素存有齐次裁剪空间中的顶点位置信息。因此,我们必须为输出位置信息的参数附上 SV_POSITION
语义,使 GPU 可以在进行例如裁剪、深度测试和光栅化等处理之时,借此实现其他属性所无法介入的有关运算。值得注意的是,对于任何不具有系统值的输出参数而言,我们都可以根据需求以合法的语义名修饰它。
SV_TARGET
表示该返回值的类型应当与渲染目标格式 ( render target format)相匹配 (该输出值会被存于渲染目标之中)。
D3D12_INPUT_ELEMENT_DESC
数组为每个顶点元素都指定了与之关联的语义:
[!NOTE] Title
- 如果没有使用几何着色器,那么顶点着色器必须用
sv_POSITION
语义来输出顶点在齐次裁剪空间中的位置,因为 (在没有使用几何着色器的情况下)执行完顶点着色器之后,硬件期望获取顶点位于齐次裁剪空间之中的坐标。如果使用了几何着色器,则可以把输出顶点在齐次裁剪空间中位置的工作交给它来处理。- 在顶点着色器(或几何着色器)中是无法进行透视除法的,此阶段只能实现投影矩阵这一环节的运算。而透视除法将在后面交由硬件执行。
5 渲染到纹理技术
后台缓冲区其实就是一种位于交换链中的纹理,通过将后台缓冲区的渲染目标视图与渲染流水线的输出合并阶段相绑定,使得 Direct3D 将数据渲染至后台缓冲区。通过 IDXGISwapChain::Present
方法呈现(即交换前台缓冲区和后台缓冲区)后台缓冲区时,其中的数据就会显示在屏幕上。
我们还可以将数据绘制到一种 “离屏”纹理 中,而非后台缓冲区之内,这就是渲染到离屏纹理(render-to-off-screen-texture) 技术,简称渲染到纹理(render-to-texture)。仍可以给这种纹理创建渲染目标视图,并将它绑定到渲染流水线的输出合并阶段。不同的是,在执行提交操作(IDXGISwapChain::Present
方法)时。它无法显示在屏幕上。
在渲染到纹理执行完毕后,我们还可以将后台缓冲区重新绑定到输出合并阶段,继续讲几何图形绘制到后台缓冲区(即正常渲染和渲染到纹理可以同步进行)。
该技术的关键在于我么能够用渲染到纹理期间生成的纹理作为贴图使用,实现各种特殊效果:
- 小地图
- shadow mapping
- SSAO
- 动态反射与立方体贴图
- Render Target