《DirectX12 3D游戏开发实战》第四章 :Direct3D的初始化

一、 预备知识

1. Direct3D 12 概述

通过Direct3D这种底层图形应用程序编程接口API,即可在应用程序中对图形处理器GPU进行控制和编程。我们能够借此以硬件加速的方式渲染出虚拟的3D场景。

Direct3D12 新特性:性能优化提升,减少了CPU开销,改进了对多线程的支持。为此Direct3D 12的API较11更偏于底层。

2. 组件对象模型COM (Component Object Model)

COM是一种令DirectX不受编程语言束缚,并且使之向后兼容的技术。

通常将COM对象视为一种接口,也可以当作一个C++类来使用。用C++语言编写DirectX程序时,COM帮我们隐藏了大量底层细节。我们只需知道:要获取指向某COM接口的指针,需借助特定函数或另一COM接口的方法------而不是用C++语言中的关键字new去创建一个COM接口。另外,COM对象会统计其引用次数;因此,在使用完某接口时,我们便应调用它的Release 方法(COM接口的所有功能都是从IUnknown这个COM接口继承而来的),而不是用delete来删除------当COM对象的引用计数为0时,它将自行释放自己所占用的内存。

为了辅助用户管理COM对象的生命周期,Windows运行时库(Windows Runtime Library,WRL)专门为此提供了Microsoft::WRL::ComPtr 类(#include <wrl.h>),我们可以把它当作是COM对象的**智能指针**。当一个ComPtr实例超出作用域范围时,它便会自动调用相应COM对象的Release方法。本书中常用的3个ComPtr方法如下。

  1. Get : 返回一个指向此底层COM接口的指针。此方法常用于把原始的COM接口指针作为参数传递给函数。例子:

    ComPtr<ID3D12RootSignature> mRootSignature;
    ...
    // SetGraphicsRootSignature需要获取ID3D12RootSignature*类型的参数
    mCommandList->SetGraphicsRootSignature(mRootSignature.Get());

  2. GetAddressOf :返回指向此底层COM接口指针的地址。凭此方法即可利用函数参数返回COM接口的指针。例如:

    ComPtr<ID3D12CommandAllocator> mDirectCmdListAlloc;
    ...
    ThrowIfFailed(md3dDevice->CreateCommandAllocator(
    D3D12_COMMAND_LIST_TYPE_DIRECT,
    mDirectCmdListAlloc.GetAddressOf()));

  3. Reset :将此ComPtr实例设置为nullptr释放与之相关的所有引用(同时减少其底层COM接口的引用计数)。此方法的功能与将ComPtr目标实例赋值为nullptr的效果相同。

COM接口都以大写字母 I 开头。ID3D12GraphicsCommandList表示命令列表的COM接口

3. 纹理格式

1D、2D、3D纹理就相当于特定数据元素所构成1D、2D、3D数组。

纹理其实还不只是像"数据数组"那样简单。它们可能还具有多种mipmap层级,而GPU则会据此对它们进行特殊的处理,例如运用过滤器(filter)和进行多重采样(multisample)。不是任意类型的数据元素都能用于组成纹理,它只能存储DXGI_FORMAT枚举类型中描述的特定格式的数据元素。下面是一些相关的格式示例:

  1. DXGI_FORMAT_R32G32B32_FLOAT:每个元素由3个32位浮点数分量构成。
  2. DXGI_FORMAT_R16G16B16A16_UNORM:每个元素由4个16位分量构成,每个分量都被映射到 [0, 1] 区间。
  3. DXGI_FORMAT_R32G32_UINT:每个元素由2个32位无符号整数分量构成。
  4. DXGI_FORMAT_R8G8B8A8_UNORM:每个元素由4个8位无符号分量构成,每个分量都被映射到 [0, 1] 区间。
  5. DXGI_FORMAT_R8G8B8A8_SNORM:每个元素由4个8位有符号分量构成,每个分量都被映射到 [−1, 1] 区间。
  6. DXGI_FORMAT_R8G8B8A8_SINT:每个元素由4个8位有符号整数分量构成,每个分量都被映射到 [−128, 127] 区间。
  7. DXGI_FORMAT_R8G8B8A8_UINT:每个元素由4个8位无符号整数分量构成,每个分量都被映射到 [0, 255] 区间。

4. 交换链和页面翻转

为了避免渲染中出现画面闪烁的现象,最好将一帧完整地绘制在一种称为后台缓冲区的离屏纹理内。

当后台缓冲区中的动画帧绘制完成之后,后台缓冲区和前台缓冲区的角色互换:后台缓冲区变为前台缓冲区呈现新一帧的画面,而前台缓冲区则为了展示动画的下一帧转为后台缓冲区,等待填充数据。前后台缓冲的这种互换操作称为呈现presenting提交 )。呈现是一种高效的操作,只需交换指向当前前台缓冲区和后台缓冲区的两个指针即可实现

前台缓冲区和后台缓冲区互相交换显示构成了交换链 (swap chain)。Direct3D中用IDXGISwapChain 接口表示。这个接口不仅存储了前台缓冲区和后台缓冲区两种纹理,而且还提供了修改缓冲区大小(IDXGISwapChain::ResizeBuffers )和呈现缓冲区内容(IDXGISwapChain::Present)的方法。

使用两个缓冲区(前台和后台)的情况称为双缓冲( double buffering,也有叫双重缓冲、双倍缓冲)。也可以运用更多的缓冲区。例如,使用3个缓冲区就叫作 三重缓冲(三倍缓冲等)。(zxz : 想不到什么需求下要启用三重缓冲)

5. 深度缓冲(depth buffer)或z缓冲(z-buffering)

这种纹理资源存储的并非图像数据,而是特定像素的深度信息,值的范围[0.0, 1.0]。0.0代表观察者在视锥体中能看到离自己最近的物体,1.0代表最远的物体。深度缓冲区中的元素与后台缓冲区内的像素呈一一对应关系。

深度缓冲技术的原理是计算每个像素的深度值,并执行深度测试(depth test)。而深度测试将具有最小深度值的像素(说明该像素离观察者最近)写入后台缓冲区中。

深度缓冲区也是一种纹理,所以一定要用明确的数据格式来创建它:

  1. DXGI_FORMAT_D32_FLOAT_S8X24_UINT:该格式共占用64位,取其中的32位指定一个浮点型深度缓冲区,另有8位(无符号整数)分配给模板缓冲区(stencil buffer),并将该元素映射到[0, 255]区间,剩下的24位仅用于填充对齐(padding)不作他用。
  2. DXGI_FORMAT_D32_FLOAT:指定一个32位浮点型深度缓冲区。
  3. DXGI_FORMAT_D24_UNORM_S8_UINT :指定一个无符号24位深度缓冲区,并将该元素映射到[0, 1]区间。另有8位(无符号整型)分配给模板缓冲区,将此元素映射到[0, 255]区间。
  4. DXGI_FORMAT_D16_UNORM:指定一个无符号16位深度缓冲区,把该元素映射到[0, 1]区间。

6. 资源与描述符(descriptor)

渲染时GPU可能会对资源进行读和写(向后台缓冲区或深度/模板缓冲区写入数据)。在发出绘制命令之前,我们需要将与本次绘制调用(Draw Call )相关的资源绑定bind 或称链接link )到渲染流水线上。GPU资源并非直接与渲染流水线相绑定,而是要通过一种名为描述符 的对象来对它间接引用,我们可以把描述符视为一种对送往GPU的资源进行描述的轻量级结构。

为什么要额外使用这个呢?

它本质上是一个中间层;若指定了资源描述符,GPU将既能获得实际的资源数据,也能了解到资源的必要信息。不管是充当渲染目标、深度/模板缓冲区还是着色器资源等角色,仅靠资源本身是无法体现出来的。而且,我们有时也许只希望将资源中的部分数据绑定至渲染流水线,但如何从整个资源中将它们选取出来呢?再者,创建一个资源可能用的是无类型格式,这样的话,GPU甚至不会知道这个资源的具体格式。

视图(view)与描述符(descriptor 是同义词。"视图"虽是Direct3D先前版本里的常用术语,但它仍然沿用在Direct3D 12的部分API中。在本书里,两者交替使用,例如," 常量缓冲区视图 (constant buffer view)"与" 常量缓冲区描述符 (constant buffer descriptor)"表达的是同一事物。
妈的。

常用的描述符如下:

1.CBV描述符:常量缓冲区视图(constant buffer view)

SRV描述符:着色器资源视图(shader resource view)

UAV描述符:无序访问视图(unordered access view)

2.采样器(sampler / 取样器)描述符表示的是采样器资源(用于纹理贴图)。

3.RTV描述符:渲染目标视图资源(render target view)。

4.DSV描述符:深度/模板视图资源(depth/stencil view)。

描述符堆(descriptor heap):

描述符堆中存有一系列描述符(可将其看作是描述符数组)。我们需要为每一种类型的描述符都创建出单独的描述符堆。另外,也可以为同一种描述符类型创建出多个描述符堆。

我们能用多个描述符来引用同一个资源。

例如,可以通过多个描述符来引用同一个资源中不同的局部数据。

一种资源可以绑定到渲染流水线的不同阶段。因此,对于每个阶段都需要设置独立的描述符。

例如,当一个纹理需要被用作渲染目标与着色器资源时,我们就要为它分别创建两个描述符:一个RTV描述符和一个SRV描述符。

类似地,如果以无类型格式创建了一个资源,又希望该纹理中的元素可以根据需求当作浮点值或整数值来使用,那么就需要为它分别创建两个描述符:一个指定为浮点格式,另一个指定为整数格式。

创建描述符的最佳时机为初始化期间。由于在此过程中需要执行一些类型的检测和验证工作,所以最好不要在运行时(runtime)才创建描述符。

7. 多重采样技术的原理

aliasing 锯齿状走样及反走样

反走样直线,是通过对每个像素周围的像素进行采样,并生成其最终的颜色而得到的。利用这种方法能够在一定程度上缓解阶梯效应并得到更加平滑的图像。

1. 超级采样 (supersampling,可简记作SSAA ,即Super Sample Anti-Aliasing)的反走样技术:它使用4倍于屏幕分辨率大小的后台缓冲区和深度缓冲区。当数据要从后台缓冲区调往屏幕显示的时候,会将后台缓冲区按4个像素一组进行解析(resolve,或称降采样 ,downsample。把放大的采样点数降低回原采样点数):每组用求平均值的方法得到一种相对平滑的像素颜色。因此,超级采样实际上是通过软件的方式提升了画面的分辨率

2. 多重采样 (multisampling,可简记作MSAA,MultiSample Anti-Aliasing)

超级采样将像素的处理数量和占用的内存大小都增加到之前的4倍,由此可知它是一种开销高昂的操作。因此Direct3D还支持多重采样,一种在性能与效果等方面都折中的反走样技术。

多重采样技术通过跨子像素(比单独像素更小的虚拟子像素)共享一些计算信息,从而使它比超级采样的开销更低。现假设采用4X多重采样(即每个像素中都有4个子像素),并同样使用4倍于屏幕分辨率的后台缓冲区和深度缓冲区。值得注意的是,这种技术并不需要对每一个子像素都进行计算,而是仅计算一次像素中心处的颜色,再基于可视性 (每个子像素经深度/模板测试的结果)和覆盖性 (子像素的中心在多边形的里面还是外面?)将得到的颜色信息分享给其子像素。下图展示了一个多重采样的相关实例:

考虑将一个位于多边形边沿上的像素进行多重采样处理。图a中,我们采集该像素中心的绿色数据,并将它存于此多边形所覆盖的3个可见子像素中。由于第4个子像素不在该多边形的范围之内,因此并不将它更新为绿色,而是令其继续保持之前几何体绘制时所计算出的颜色或是清除缓冲区时所得到的颜色。图b中,为了计算降采样的像素颜色,通过对4个子像素(3个绿色像素以及1个白色像素)求取平均值的方式,获得多边形边沿上的一种浅绿色。由于抗锯齿方法有效地缓解了多边形边沿处的阶梯效应,因此图像看起来更为平滑。

对比:

超级采样和多重采样的关键区别是显而易见的。对于超级采样来说,图像颜色要根据每一个子像素来计算,因此每个子像素都可能各具不同的颜色。而以多重采样的方式(见图b)来求取图像颜色时,每个像素只需计算一次,最后,再将得到的颜色数据复制到多边形覆盖的所有可见子像素之中。

8. 利用Direct3D进行多重采样MSAA

typedef struct DXGI_SAMPLE_DESC
{
  UINT Count; //每个像素的采样次数
  UINT Quality; //用户期望的图像质量级别
} DXGI_SAMPLE_DESC;

根据给定的纹理格式和采样数量,我们就能用ID3D12Device::CheckFeatureSupport方法查询到对应的质量级别:

typedef struct D3D12_FEATURE_DATA_MULTISAMPLE_QUALITY_LEVELS {
 DXGI_FORMAT              Format;
 UINT                     SampleCount;
 D3D12_MULTISAMPLE_QUALITY_LEVEL_FLAGS Flags;
 UINT                     NumQualityLevels;
} D3D12_FEATURE_DATA_MULTISAMPLE_QUALITY_LEVELS;

D3D12_FEATURE_DATA_MULTISAMPLE_QUALITY_LEVELS msQualityLevels;
msQualityLevels.Format = mBackBufferFormat;
msQualityLevels.SampleCount = 4;
msQualityLevels.Flags = D3D12_MULTISAMPLE_QUALITY_LEVELS_FLAG_NONE;
msQualityLevels.NumQualityLevels = 0;

ThrowIfFailed(md3dDevice->CheckFeatureSupport(
  D3D12_FEATURE_MULTISAMPLE_QUALITY_LEVELS,
  &msQualityLevels,
  sizeof(msQualityLevels)));

上面代码中的方法的第二个参数兼具输入和输出的属性。当它作为输入参数时,我们必须指定纹理格式、采样数量以及希望查询的多重采样所支持的标志(即立flag,或作旗标)。接着,待函数执行后便会填写图像质量级别作为输出。对于某种纹理格式和采样数量的组合来讲,其质量级别的有效范围为 0 至NumQualityLevels -- 1。

#define D3D12_MAX_MULTISAMPLE_SAMPLE_COUNT ( 32 ) //每个像素的最大采样数量。通常会把采样数量设定为4或8。如果不希望使用多重采样,则可将采样数量设置为1,并令质量级别为0。

注意:在创建交换链缓冲区和深度缓冲区时都需要填写DXGI_SAMPLE_DESC结构体。当创建后台缓冲区和深度缓冲区时,多重采样的有关设置一定要相同。

9. 功能级别(feature level)

这个概念是在Dircet3D 11版本引入的,在代码里用枚举类型D3D_FEATURE_LEVEL表示。

enum D3D_FEATURE_LEVEL
{
  D3D_FEATURE_LEVEL_9_1              = 0x9100,
  D3D_FEATURE_LEVEL_9_2              = 0x9200,
  D3D_FEATURE_LEVEL_9_3              = 0x9300,
  D3D_FEATURE_LEVEL_10_0             = 0xa000,
  D3D_FEATURE_LEVEL_10_1             = 0xa100,
  D3D_FEATURE_LEVEL_11_0             = 0xb000,
  D3D_FEATURE_LEVEL_11_1             = 0xb100
}D3D_FEATURE_LEVEL;

"功能级别"为不同级别所支持的功能进行了严格的界定。每级功能所支持的特定功能详情参考SDK文档。如果用户的硬件不支持某特定功能级别,程序会回退至版本更低的功能级别。

例如,一款应用程序可能会支持Direct3D 11、10乃至9.3级别的硬件。程序从最新到最旧的级别顺序展开检测:首先检测Direct3D 11是否被支持,其次检测Direct3D 10,最后检测Direct3D 9.3。在本书中,需要支持的功能级别为D3D_FEATURE_LEVEL_11_0。但是在现实的应用程序中,我们往往需要考虑支持稍旧的硬件,以获得更多的用户。

(zxz:感觉跟Shader中的检测OpenGL 3.0效果和托底的OpenGL 2.0效果一样。)

10. DirectX图形基础结构 DXGI(DirectX Graphics Infrastructure)

DXGI 使多种图形API中所共有的底层任务能借助一组通用API来进行处理。或者处理一些其他常用的图形功能,如切换全屏模式或者窗口模式,枚举显示适配器、显示设备及其支持的显示模式(分辨率、刷新率等)等这类图形系统信息,等等。

下面简单介绍下在Direct3D初始化时会用到的相关接口:

IDXGIFactory 是DXGI中的关键接口之一,主要用于创建IDXGISwapChain接口以及枚举显示适配器。而显示适配器则真正实现了图形处理能力。

显示适配器(display adapter)是一种硬件设备(例如独立显卡),然而系统也可以用软件显示适配器来模拟硬件的图形处理功能。一个系统中可能会存在数个适配器(比如装有数块显卡)。适配器用接口IDXGIAdapter来表示。我们可以用下面的代码来枚举一个系统中的所有适配器:

void D3DApp::LogAdapters()
{
  UINT i = 0;
  IDXGIAdapter* adapter = nullptr;
  std::vector<IDXGIAdapter*> adapterList;
  while(mdxgiFactory->EnumAdapters(i, &adapter) != DXGI_ERROR_NOT_FOUND)
  {
    DXGI_ADAPTER_DESC desc;
    adapter->GetDesc(&desc);

    std::wstring text = L"***Adapter: ";
    text += desc.Description;
    text += L"\n";

    OutputDebugString(text.c_str());

    adapterList.push_back(adapter);

    ++i;
  }

  for(size_t i = 0; i < adapterList.size(); ++i)
  {
    LogAdapterOutputs(adapterList[i]);
    ReleaseCom(adapterList[i]);
  }
}

15:43:20:010	***Adapter: NVIDIA GeForce RTX 2070
15:43:20:010	***Adapter: Intel(R) UHD Graphics 630
15:43:20:010	***Adapter: NVIDIA GeForce RTX 2070
15:43:20:010	***Adapter: Microsoft Basic Render Driver

其中: Microsoft Basic Render Driver 是软件模拟硬件

一个系统也能装数个显示设备。每一台显示设备都是一个显示输出(适配器输出)实例,用IDXGIOutput接口来表示。每个适配器都与一组显示输出相关联。

举个例子:一个系统共有两块显卡和3台显示器。其中一块显卡与两台显示器相连,第三台显示器则与另一块显卡相连。在这种情况下,一块适配器与两个显示输出相关联,而另一块则仅有一个显示输出与之关联。通过以下代码,我们就可以枚举出与某块适配器关联的所有显示输出:

void D3DApp::LogAdapterOutputs(IDXGIAdapter* adapter)
{
  UINT i = 0;
  IDXGIOutput* output = nullptr;
  while(adapter->EnumOutputs(i, &output) != DXGI_ERROR_NOT_FOUND)
  {
    DXGI_OUTPUT_DESC desc;
    output->GetDesc(&desc);

    std::wstring text = L"***Output: ";
    text += desc.DeviceName;
    text += L"\n";
    OutputDebugString(text.c_str());

    LogOutputDisplayModes(output, DXGI_FORMAT_B8G8R8A8_UNORM);

    ReleaseCom(output);

    ++i;
  }
}

每种显示设备都有一系列它所支持的显示模式,可以用下列DXGI_MODE_DESC结构体中的数据成员来加以表示:

typedef struct DXGI_MODE_DESC
{
  UINT Width;                   // 分辨率宽度
  UINT Height;                  // 分辨率高度
  DXGI_RATIONAL RefreshRate;    // 刷新率,单位为赫兹Hz
  DXGI_FORMAT Format;           // 显示格式
  DXGI_MODE_SCANLINE_ORDER ScanlineOrdering; // 逐行扫描vs.https://zhida.zhihu.com/search?content_id=182488610&content_type=Article&match_order=1&q=%E9%9A%94%E8%A1%8C%E6%89%AB%E6%8F%8F&zhida_source=entity
  DXGI_MODE_SCALING Scaling;    // 图像如何相对于屏幕进行拉伸
} DXGI_MODE_DESC;

typedef struct DXGI_RATIONAL
{
  UINT Numerator;
  UINT Denominator;
} DXGI_RATIONAL;

typedef enum DXGI_MODE_SCANLINE_ORDER
{
  DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED    = 0,
  DXGI_MODE_SCANLINE_ORDER_PROGRESSIVE    = 1,
  DXGI_MODE_SCANLINE_ORDER_UPPER_FIELD_FIRST = 2,
  DXGI_MODE_SCANLINE_ORDER_LOWER_FIELD_FIRST = 3
} DXGI_MODE_SCANLINE_ORDER;[19]

typedef enum DXGI_MODE_SCALING
{
  DXGI_MODE_SCALING_UNSPECIFIED  = 0, 
  DXGI_MODE_SCALING_CENTERED   = 1,        // 不做缩放,将图像显示在屏幕正中
  DXGI_MODE_SCALING_STRETCHED   = 2        // 根据屏幕的分辨率对图像进行拉伸缩放
} DXGI_MODE_SCALING; 

一旦确定了显示模式的具体格式DXGI_FORMAT,我们就能通过下列代码,获得某个显示输出对此格式所支持的全部显示模式:

void D3DApp::LogOutputDisplayModes(IDXGIOutput* output, DXGI_FORMAT format)
{
  UINT count = 0;
  UINT flags = 0;

  // 以nullptr作为参数调用此函数来获取符合条件的显示模式的个数
  output->GetDisplayModeList(format, flags, &count, nullptr);

  std::vector<DXGI_MODE_DESC> modeList(count);
  output->GetDisplayModeList(format, flags, &count, &modeList[0]);

  for(auto& x : modeList)
  {
    UINT n = x.RefreshRate.Numerator;
    UINT d = x.RefreshRate.Denominator;
    std::wstring text =
      L"Width = " + std::to_wstring(x.Width) + L" " +
      L"Height = " + std::to_wstring(x.Height) + L" " +
      L"Refresh = " + std::to_wstring(n) + L"/" + std::to_wstring(d) +
      L"\n";

    ::OutputDebugString(text.c_str());
  }
}

运行结果:(本人两个显示屏,)

16:24:21:786	***Output: \\.\DISPLAY2
16:24:21:786	Width = 720 Height = 480 Refresh = 60/1
16:24:21:786	Width = 720 Height = 480 Refresh = 59940/1000
16:24:21:786	Width = 720 Height = 576 Refresh = 56250/1000
16:24:21:786	Width = 720 Height = 576 Refresh = 56250/1000
16:24:21:786	Width = 720 Height = 576 Refresh = 60317/1000
16:24:21:786	Width = 720 Height = 576 Refresh = 60317/1000
16:24:21:786	Width = 800 Height = 600 Refresh = 56250/1000
16:24:21:786	Width = 800 Height = 600 Refresh = 60317/1000
16:24:21:786	Width = 1024 Height = 768 Refresh = 60004/1000
16:24:21:786	Width = 1176 Height = 664 Refresh = 60/1
16:24:21:786	Width = 1176 Height = 664 Refresh = 60/1
16:24:21:786	Width = 1176 Height = 664 Refresh = 59940/1000
16:24:21:786	Width = 1176 Height = 664 Refresh = 59940/1000
16:24:21:786	Width = 1280 Height = 720 Refresh = 60/1
16:24:21:786	Width = 1280 Height = 720 Refresh = 59940/1000
16:24:21:786	Width = 1280 Height = 1024 Refresh = 60020/1000
16:24:21:786	Width = 1440 Height = 900 Refresh = 59887/1000
16:24:21:786	Width = 1680 Height = 1050 Refresh = 59954/1000
16:24:21:786	Width = 1920 Height = 1080 Refresh = 60/1
16:24:21:786	Width = 1920 Height = 1080 Refresh = 59940/1000
16:24:21:786	Width = 2560 Height = 1440 Refresh = 59951/1000
16:24:21:786	Width = 3840 Height = 2160 Refresh = 30/1
16:24:21:786	Width = 3840 Height = 2160 Refresh = 50/1
16:24:21:786	Width = 3840 Height = 2160 Refresh = 60/1
16:24:21:786	Width = 3840 Height = 2160 Refresh = 29970/1000
16:24:21:786	Width = 3840 Height = 2160 Refresh = 59940/1000
16:24:21:786	***Output: \\.\DISPLAY1
16:24:21:786	Width = 720 Height = 480 Refresh = 60/1
16:24:21:786	Width = 720 Height = 480 Refresh = 59940/1000
16:24:21:786	Width = 720 Height = 576 Refresh = 50/1
16:24:21:786	Width = 800 Height = 600 Refresh = 56250/1000
16:24:21:786	Width = 800 Height = 600 Refresh = 60317/1000
16:24:21:786	Width = 1024 Height = 768 Refresh = 60004/1000
16:24:21:786	Width = 1176 Height = 664 Refresh = 60/1
16:24:21:786	Width = 1176 Height = 664 Refresh = 60/1
16:24:21:786	Width = 1280 Height = 720 Refresh = 60/1
16:24:21:786	Width = 1280 Height = 1024 Refresh = 60020/1000
16:24:21:786	Width = 1440 Height = 900 Refresh = 59887/1000
16:24:21:786	Width = 1680 Height = 1050 Refresh = 59954/1000
16:24:21:786	Width = 1920 Height = 1080 Refresh = 60/1
16:24:21:786	Width = 2560 Height = 1440 Refresh = 59951/1000
16:24:21:786	Width = 3840 Height = 2160 Refresh = 30/1
16:24:21:786	Width = 3840 Height = 2160 Refresh = 50/1
16:24:21:786	Width = 3840 Height = 2160 Refresh = 29970/1000
16:24:21:786	Width = 3840 Height = 2160 Refresh = 59997/1000

在进入全屏模式之时,枚举显示模式就显得尤为重要。为了获得最优的全屏性能,我们所指定的显示模式(包括刷新率)一定要与显示器支持的显示模式完全匹配。根据枚举出来的显示模式进行选定,便可以保证这一点。

11. 功能支持的检测

之前通过ID3D12Device::CheckFeatureSupport方法,检测了当前图形驱动对多重采样的支持:

HRESULT ID3D12Device::CheckFeatureSupport(
  D3D12_FEATURE Feature,
  void *pFeatureSupportData,
  UINT FeatureSupportDataSize);

1.Feature:枚举类型D3D12_FEATURE中的成员,用于指定我们希望检测的功能支持类型。

  • a)D3D12_FEATURE_D3D12_OPTIONS:检测当前图形驱动对Direct3D 12各种功能的支持情况。
  • b)D3D12_FEATURE_ARCHITECTURE:检测图形适配器中GPU的硬件体系架构特性。
  • c)D3D12_FEATURE_FEATURE_LEVELS:检测对功能级别的支持情况。
  • d)D3D12_FEATURE_FORMAT_SUPPORT:检测对给定纹理格式的支持情况(例如,指定的格式能否用于渲染目标?或,指定的格式能否用于混合技术?)。
  • e)D3D12_FEATURE_MULTISAMPLE_QUALITY_LEVELS:检测对多重采样功能的支持情况。

2.pFeatureSupportData:指向某种数据结构的指针,该结构中存有检索到的特定功能支持的信息。此结构体的具体类型取决于Feature参数。

  • a)如果将Feature参数指定为D3D12_FEATURE_D3D12_OPTIONS ,则传回的是一个D3D12_FEATURE_DATA_D3D12_OPTIONS实例。
  • b)如果将Feature参数指定为D3D12_FEATURE_ARCHITECTURE ,则传回的是一个D3D12_FEATURE_DATA_ARCHITECTURE实例。
  • c)如果将Feature参数指定为D3D12_FEATURE_FEATURE_LEVELS ,则传回的是一个D3D12_FEATURE_DATA_FEATURE_LEVELS实例。
  • d)如果将Feature参数指定为D3D12_FEATURE_FORMAT_SUPPORT ,则传回的是一个D3D12_FEATURE_DATA_FORMAT_SUPPORT实例。
  • e)如果将Feature参数指定为D3D12_FEATURE_MULTISAMPLE_QUALITY_LEVELS ,则传回的是一个D3D12_FEATURE_DATA_MULTISAMPLE_QUALITY_LEVELS实例。

3.FeatureSupportDataSize:传回pFeatureSupportData参数中的数据结构的大小。

ID3D12Device::CheckFeatureSupport函数能检测的支持功能有很多。

下面举一个例子,里面展示了如何对 功能级别 的支持情况进行检测:

typedef struct D3D12_FEATURE_DATA_FEATURE_LEVELS {
 UINT          NumFeatureLevels;
 const D3D_FEATURE_LEVEL *pFeatureLevelsRequested;
 D3D_FEATURE_LEVEL    MaxSupportedFeatureLevel;
} D3D12_FEATURE_DATA_FEATURE_LEVELS;

D3D_FEATURE_LEVEL featureLevels[3] =
{
  D3D_FEATURE_LEVEL_11_0,  // 首先检测是否支持D3D 11
  D3D_FEATURE_LEVEL_10_0,  // 其次检测是否支持D3D 10
  D3D_FEATURE_LEVEL_9_3    // 最后检测是否支持D3D 9.3
};

D3D12_FEATURE_DATA_FEATURE_LEVELS featureLevelsInfo;
featureLevelsInfo.NumFeatureLevels = 3;
featureLevelsInfo.pFeatureLevelsRequested = featureLevels;
md3dDevice->CheckFeatureSupport(
  D3D12_FEATURE_FEATURE_LEVELS,
  &featureLevelsInfo,
  sizeof(featureLevelsInfo));

注意,CheckFeatureSupport 方法的第二个参数兼有输入和输出的属性。作为输入的时候,先要指定功能级别数组中元素的个数(NumFeatureLevels),再令(pFeatureLevelsRequested)指针指向功能级别数组,其中应包括我们希望检测的一系列硬件支持功能级别。最后,此函数将用MaxSupportedFeatureLevel字段返回当前硬件可支持的最高功能级别。

12. 资源驻留 Residency

复杂的场景有大量的纹理和Mesh资源,但不需要全部加载在显存中供GPU使用。Direct3D 12中,应用程序通过控制资源在显存中的去留,主动管理资源的驻留情况(无论资源是否本已位于显存中,都可对其进行管理。在Direct3D 11中则由系统自动管理)。

该技术的基本目标是:让应用程序尽可能占用最小的显存空间。这是因为显存的空间有限,很可能不足以容下整个游戏的所有资源。

Tips : 与性能相关,程序应当避免在短时间内于显存中交换进出相同的资源,这会引起过高的开销。理想的情况是,清出的资源在短时间内不会被再次使用。游戏关卡或游戏场景的切换是关于常驻资源的好例子。

一般来说,资源在创建时就会驻留在显存中,而当它被销毁时则清出。但是通过下面的方法,我们就可以自己来控制资源的驻留:

//这两种方法的第二个参数都是ID3D12Pageable资源数组,第一个参数则表示该数组中资源的数量。
HRESULT ID3D12Device::MakeResident(
  UINT         NumObjects,
  ID3D12Pageable *const *ppObjects);

HRESULT ID3D12Device::Evict(
  UINT         NumObjects,
  ID3D12Pageable *const *ppObjects);

二、 CPU与GPU的交互

CPU和GPU两者并行工作,但时而也需同步。为了获得最佳性能,最好的情况是让两者尽量同时工作,少同步。同步是一种我们不乐于执行的操作,因为这意味着一种处理器要以空闲状态等待另一种处理器完成某些任务,换句话说,它破坏了两者并行工作的机制。

1. 命令队列和命令列表

每个GPU都至少维护着一个命令队列(command queue,本质上是环形缓冲区)。CPU可利用命令列表(command list)将命令提交到这个队列中去。当一系列命令被提交至命令队列之时,它们并不会被GPU立即执行。由于GPU可能正在处理先前插入命令队列内的命令,因此,后来新到的命令会一直在这个队列之中等待执行。

命令队列

在Direct3D 12中,命令队列被抽象为ID3D12CommandQueue 接口来表示。要通过填写D3D12_COMMAND_QUEUE_DESC 结构体来描述队列,再调用ID3D12Device::CreateCommandQueue方法创建队列。采用以下流程:

Microsoft::WRL::ComPtr<ID3D12CommandQueue> mCommandQueue;
D3D12_COMMAND_QUEUE_DESC queueDesc = {};
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
queueDesc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;
ThrowIfFailed(md3dDevice->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&mCommandQueue)));

IID_PPV_ARGS辅助宏的定义如下:

#define IID_PPV_ARGS(ppType) __uuidof(**(ppType)), IID_PPV_ARGS_Helper(ppType)

其中,__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接口方法,利用它可将命令列表里的命令添加到命令队列之中:

void ID3D12CommandQueue::ExecuteCommandLists( 
  UINT Count,  // 第二个参数里命令列表数组中命令列表的数量
  // 待执行的命令列表数组,指向命令列表数组中第一个元素的指针
  ID3D12CommandList *const *ppCommandLists);

GPU将从数组里的第一个命令列表开始顺序执行。

ID3D12GraphicsCommandList接口封装了一系列图形渲染命令,它实际上继承于ID3D12CommandList 接口。ID3D12GraphicsCommandList 接口有数种方法向命令列表添加命令。例如,下面的代码依次就向命令列表中添加了设置视口、清除渲染目标视图和发起绘制调用的命令:

// mCommandList为一个指向ID3D12CommandList接口的指针
mCommandList->RSSetViewports(1, &mScreenViewport);   //设置视口
mCommandList->ClearRenderTargetView(mBackBufferView, //清除渲染目标视图
  Colors::LightSteelBlue, 0, nullptr);
mCommandList->DrawIndexedInstanced(36, 1, 0, 0, 0);  // 发起绘制调用

//-----------------
// 结束记录命令
mCommandList->Close();

虽然这些方法的名字看起来像是会使对应的命令立即执行,但事实却并非如此,上面的代码仅仅是将命令加入命令列表而已 。调用 ExecuteCommandLists 方法才会将命令真正地送入命令队列,供GPU在合适的时机处理。当命令都被加入命令列表之后,如上述的最终代码,我们必须调用ID3D12GraphicsCommandList::Close 方法来结束命令的记录。在调用 ID3D12CommandQueue::ExecuteCommandLists 方法提交命令列表之前,一定要将其关闭。

还有一种与命令列表有关的名为ID3D12CommandAllocator的内存管理类接口。记录在命令列表内的命令,实际上是存储在与之关联的命令分配器(command allocator)上。当通过ID3D12CommandQueue::ExecuteCommandLists方法执行命令列表的时候,命令队列就会引用分配器里的命令。而命令分配器则由ID3D12Device接口来创建:

HRESULT ID3D12Device::CreateCommandAllocator( 
  D3D12_COMMAND_LIST_TYPE type,
  REFIID riid,
  void **ppCommandAllocator);

1.type:指定与此命令分配器相关联的命令列表类型。以下是常用的两种命令列表类型。

A、D3D12_COMMAND_LIST_TYPE_DIRECT。存储的是一系列可供GPU直接执行的命令(这种类型的命令列表我们之前曾提到过)。

B、D3D12_COMMAND_LIST_TYPE_BUNDLE。将命令列表打包(bundle ,也有译作集合)。构建命令列表时会产生一定的CPU开销,为此,Direct3D 12提供了一种优化的方法,允许我们将一系列命令打成所谓的包。当打包完成(命令记录完毕)之后,驱动就会对其中的命令进行预处理,以使它们在渲染期间的执行过程中得到优化。因此,我们应当在初始化期间就用包记录命令 。如果经过分析,发现构造某些命令列表会花费大量的时间,就可以考虑使用打包技术对其进行优化。Direct3D 12中的绘制API的效率很高,所以一般不会用到打包技术。因此,也许在证明其确实可以带来性能的显著提升时才会用到它。这就是说,在大多数情况下,我们往往会将其束之高阁。

2.riid:待创建ID3D12CommandAllocator接口的COM ID。

3.ppCommandAllocator:输出指向所建命令分配器的指针。


命令列表同样由ID3D12Device接口创建:

HRESULT ID3D12Device::CreateCommandList( 
  UINT nodeMask,
  D3D12_COMMAND_LIST_TYPE type,
  ID3D12CommandAllocator *pCommandAllocator,
  ID3D12PipelineState *pInitialState,
  REFIID riid,
  void **ppCommandList);

1.nodeMask:对于仅有一个GPU的系统而言,要将此值设为0;对于具有多GPU的系统而言,此节点掩码(node mask)指定的是与所建命令列表相关联的物理GPU。(我们可以通过ID3D12Device::GetNodeCount方法来查询系统中GPU适配器节点(物理GPU)的数量。)

2.type:命令列表的类型,常用的选项为D3D12_COMMAND_LIST_TYPE_DIRECT和D3D12_COMMAND_LIST_TYPE_BUNDLE。

3.pCommandAllocator:与所建命令列表相关联的命令分配器。它的类型必须与所创命令列表的类型相匹配。

4.pInitialState:指定命令列表的渲染流水线初始状态。对于打包技术来说可将此值设为nullptr,另外,此法同样适用于执行命令列表中不含有任何绘制命令,即执行命令列表是为了达到初始化的目的的特殊情况。我们将在第6章中详细讨论ID3D12PipelineState接口。

5.riid:待创建ID3D12CommandList接口的COM ID。

6.ppCommandList:输出指向所建命令列表的指针。


我们可以创建出多个关联于同一命令分配器的命令列表,但是不能同时用它们来记录命令。因此,当其中的一个命令列表在记录命令时,必须关闭同一命令分配器的其他命令列表。要保证命令列表中的所有命令都会按顺序连续地添加到命令分配器内。还要注意的一点是,当创建或重置一个命令列表的时候,它会处于一种"打开"的状态。所以,当尝试为同一个命令分配器连续创建两个命令列表时,我们会得到这样的一个错误消息:

D3D12 ERROR: ID3D12CommandList::{Create,Reset}CommandList: The command allocator is currently in-use by another command list.
(D3D12 错误: ID3D12CommandList::{Create,Reset}CommandList:此命令分配器正在被另一个命令列表占用)

在调用 ID3D12CommandQueue::ExecuteCommandList(C) 方法之后,我们就可以通过ID3D12GraphicsCommandList::Reset方法,安全地复用命令列表C占用的相关底层内存来记录新的命令集。Reset方法中的参数对应于以ID3D12Device::CreateCommandList方法创建命令列表时所用的参数。

HRESULT ID3D12GraphicsCommandList::Reset( 
  ID3D12CommandAllocator *pAllocator,
  ID3D12PipelineState *pInitialState);

此方法将命令列表恢复为刚创建时的初始状态,我们可以借此继续复用其低层内存,也可以避免释放旧列表再创建新列表这一系列的烦琐操作。注意,重置命令列表并不会影响命令队列中的命令,因为相关的命令分配器仍在维护着其内存中被命令队列引用的系列命令。

向GPU提交了一整帧的渲染命令后,我们可能还要为了绘制下一帧而复用命令分配器中的内存。ID3D12CommandAllocator::Reset方法由此应运而生:

HRESULT ID3D12CommandAllocator::Reset(void);

这种方法的功能类似于向量类中的std::vector::clear方法,后者使向量的大小(size)归零,但是仍保持其当前的容量(capacity)。然而,由于命令队列可能会引用命令分配器中的数据,所以在没有确定GPU执行完命令分配器中的所有命令之前,千万不要重置命令分配器

2. CPU与GPU间的同步

例子:CPU向命令队列里添加绘制Cube位置信息P1的命令A1,由于向命令队列添加命令并不会阻塞CPU,所以CPU会继续执行后序指令。在GPU执行绘制命令A1之前,如果CPU率先覆写了数据,提前把其中的位置信息修改为P2,那么这个行为就会造成一个严重的错误。这时不管是按原命令绘制,还是在绘制的过程中更新资源都是错误的行为。

解决此问题的一种办法是:强制CPU等待,直到GPU完成所有命令的处理,达到某个指定的围栏点(fence point)为止。我们将这种方法称为刷新命令队列 (flushing the command queue),可以通过围栏fence)来实现这一点。围栏用 ID3D12Fence 接口来表示,此技术能用于实现GPU和CPU间的同步。创建一个围栏对象的方法如下:

HRESULT ID3D12Device::CreateFence( 
  UINT64 InitialValue,
  D3D12_FENCE_FLAGS Flags,
  REFIID riid,
  void **ppFence);

// 示例
ThrowIfFailed(md3dDevice->CreateFence(
  0, 
  D3D12_FENCE_FLAG_NONE,
  IID_PPV_ARGS(&mFence)));

每个围栏对象都维护着一个UINT64类型的值,此为用来标识围栏点的整数。起初,我们将此值设为0,每当需要标记一个新的围栏点时就将它加1。现在,我们用代码和注释进行展示,看看如何用一个围栏来刷新命令队列。

UINT64 mCurrentFence = 0;
void D3DApp::FlushCommandQueue()
{
  // 增加围栏值,接下来将命令标记到此围栏点
  mCurrentFence++;

  // 向命令队列中添加一条用来设置新围栏点的命令
  // 由于这条命令要交由GPU处理(即由GPU端来修改围栏值),所以在GPU处理完
  // 以前的所有命令之前,它并不会设置新的围栏点[27]
  ThrowIfFailed(mCommandQueue->Signal(mFence.Get(), mCurrentFence));

  // 在CPU端等待GPU,直到后者执行完这个围栏点之前的所有命令
  if(mFence->GetCompletedValue() < mCurrentFence)
  {
    HANDLE eventHandle = CreateEventEx(nullptr, false, false, EVENT_ALL_ACCESS);

    // 若GPU命中当前的围栏(即执行到Signal()指令,修改了围栏值),则激发预定事件
    ThrowIfFailed(mFence->SetEventOnCompletion(mCurrentFence, eventHandle));

    // 等待GPU命中围栏,激发事件
    WaitForSingleObject(eventHandle, INFINITE);
    CloseHandle(eventHandle);
  }
}

这样一来,举的例子中,当CPU发出绘制命令后,在将内的位置信息改写为之前,应率先刷新命令队列。这种解决方案其实并不完美,因为这意味着在等待GPU处理命令的时候,CPU会处于空闲状态,但在第7章以前也只能暂时使用这个简单的办法了。我们几乎可以在任何时间点上刷新命令队列(当然,不一定仅在渲染每一帧时才刷新一次)。例如,若有一些GPU初始化命令有待执行,我们便可以在进入渲染主循环之前刷新命令队列,从而进行这些初始化操作。

3. 资源转换

GPU对某个资源按顺序进行先写后读这两种操作。

资源冒险(resource hazard):当GPU的写操作还没有完成甚至还没有开始,却开始读取资源导致的后果。

为了防止资源冒险,Direct3D专门针对资源设计了一组相关状态。资源在创建伊始会处于默认状态,该状态将一直持续到应用程序通过Direct3D将其转换为另一种状态为止。GPU能够针对状态的转换做出适当的行为,以防止资源冒险。

例子:在读取某个资源之前,它会等待所有与之相关的写操作执行完毕。资源转换行为会造成程序性能下降。

通过命令列表设置转换资源屏障 (transition resource barrier)数组,即可指定资源的转换;当我们希望以一次API调用来转换多个资源的时候,这种数组就派上了用场。在代码中,资源屏障用D3D12_RESOURCE_BARRIER结构体来表示。

下列辅助函数(d3dx12.h)将根据资源和指定的前后转换状态,返回对应的转换资源屏障描述:

struct CD3DX12_RESOURCE_BARRIER : public D3D12_RESOURCE_BARRIER
{
  // [...] 辅助方法

static inline CD3DX12_RESOURCE_BARRIER Transition(
    _In_ ID3D12Resource* pResource,
    D3D12_RESOURCE_STATES stateBefore,
    D3D12_RESOURCE_STATES stateAfter,
    UINT subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES,
    D3D12_RESOURCE_BARRIER_FLAGS flags = D3D12_RESOURCE_BARRIER_FLAG_NONE)
  {
    CD3DX12_RESOURCE_BARRIER result;
    ZeroMemory(&result, sizeof(result));
    D3D12_RESOURCE_BARRIER &barrier = result;
    result.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
    result.Flags = flags;
    barrier.Transition.pResource = pResource;
    barrier.Transition.StateBefore = stateBefore;
    barrier.Transition.StateAfter = stateAfter;
    barrier.Transition.Subresource = subresource;
    return result;
  }
  // [...] 其他辅助方法
};

可以看到,CD3DX12_RESOURCE_BARRIER继承自D3D12_RESOURCE_BARRIER结构体,并添加了一些辅助方法。Direct3D 12中的许多结构体都有其对应的扩展辅助结构变体,考虑到使用上的方便性,我们更偏爱于运用那些变体。

以CD3DX12作为前缀的变体全都定义在d3dx12.h头文件当中,这个文件并不属于DirectX 12 SDK的核心部分,但是可以通过微软的官方网站下载获得。为了方便起见,本书源代码的Common目录里附有一份d3dx12.h头文件。

在本章的示例程序中,此辅助函数的用法如下:

mCommandList->ResourceBarrier(1,
  &CD3DX12_RESOURCE_BARRIER::Transition(
    CurrentBackBuffer(),
    D3D12_RESOURCE_STATE_PRESENT, 
    D3D12_RESOURCE_STATE_RENDER_TARGET));

上述段代码将以图片形式显示在屏幕中的纹理,从呈现状态转换为渲染目标状态。那么,这个添加到命令列表中的资源屏障究竟是何物呢?事实上,我们可以将此资源屏障转换看作是一条告知GPU某资源状态正在进行转换的命令。所以在执行后续的命令时,GPU便会采取必要措施以防资源冒险。

4. 命令与多线程(略)

命令列表是一种发挥Direct3D多线程优势的途径。

大的游戏场景包含很多物体,仅通过一个构建命令列表来绘制整个场景会占用不少的CPU时间。因此,可以采取一种并行创建命令列表的思路。例如,我们可以创建4条线程,每条分别负责构建一个命令列表来绘制25%的场景物体。

以下是一些在多线程环境中使用命令列表要注意的问题。

1.命令列表并非自由线程(not free-threaded)对象。也就是说,多线程既不能同时共享相同的命令列表,也不能同时调用同一命令列表的方法。所以,每个线程通常都只使用各自的命令列表。

2.命令分配器亦不是线程自由的对象。这就是说,多线程既不能同时共享同一个命令分配器,也不能同时调用同一命令分配器的方法。所以,每个线程一般都仅使用属于自己的命令分配器。

3.命令队列是线程自由对象,所以多线程可以同时访问同一命令队列,也能够同时调用它的方法。特别是每个线程都能同时向命令队列提交它们自己所生成的命令列表。

4.出于性能的原因,应用程序必须在初始化期间,指出用于并行记录命令的命令列表最大数量。

为了简单起见,本书不会使用多线程技术。完成本书的阅读后,读者可以通过查阅SDK中的Multithreading12示例来学习怎样并行生成命令列表。如果希望应用程序充分利用系统资源,应该通过多线程技术来发挥CPU多核心的并行处理能力。

三、初始化Direct3D

大纲:这一节我们会利用自己编写的演示框架来展示Direct3D的初始化过程。这是一个比较冗长的流程,但每个程序只需执行一次即可。我们对Direct3D进行初始化的过程可以分为以下几个步骤:

1.用D3D12CreateDevice函数创建ID3D12Device接口实例。

2.创建一个ID3D12Fence对象,并查询描述符的大小。

3.检测用户设备对4X MSAA质量级别的支持情况。

4.依次创建命令队列、命令列表分配器和主命令列表。

5.描述并创建交换链。

6.创建应用程序所需的描述符堆。

7.调整后台缓冲区的大小,并为它创建渲染目标视图。

8.创建深度/模板缓冲区及与之关联的深度/模板视图。

9.设置视口(viewport)和裁剪矩形(scissor rectangle)。

1. 创建设备

首先创建Direct3D 12设备(ID3D12Device)。

此设备代表着一个显示适配器。它是一种3D图形硬件(如显卡)。但是,一个系统也能用软件显示适配器来模拟3D图形硬件的功能。Direct3D 12设备既可检测系统环境对功能的支持情况,又能创建所有其他的Direct3D接口对象(如资源、视图和命令列表)。

//创建Direct3D 12设备
HRESULT WINAPI D3D12CreateDevice(
  IUnknown* pAdapter,
  D3D_FEATURE_LEVEL MinimumFeatureLevel,
  REFIID riid, // ID3D12Device的COM ID
  void** ppDevice );

1.pAdapter:指定在创建设备时所用的显示适配器。若将此参数设定为空指针,则使用主显示适配器。我们在本书的示例中总是采用主适配器。在4.1.10节中,我们已展示了怎样枚举系统中所有的显示适配器。

2.MinimumFeatureLevel:应用程序需要硬件所支持的最低功能级别。如果适配器不支持此功能级别,则设备创建失败。在我们的框架中指定的是D3D_FEATURE_LEVEL_11_0(即支持Direct3D 11的特性)。

3.riid:所建ID3D12Device接口的COM ID。

4.ppDevice:返回所创建的Direct3D 12设备。

这个函数的例子:

#if defined(DEBUG) || defined(_DEBUG) 
// 启用D3D12的调试层
{
  ComPtr<ID3D12Debug> debugController;
  ThrowIfFailed(D3D12GetDebugInterface(IID_PPV_ARGS(&debugController)));
  debugController->EnableDebugLayer();
}
#endif

ThrowIfFailed(CreateDXGIFactory1(IID_PPV_ARGS(&mdxgiFactory)));

// 尝试创建硬件设备
HRESULT hardwareResult = D3D12CreateDevice(
  nullptr,       // 默认适配器
  D3D_FEATURE_LEVEL_11_0,
  IID_PPV_ARGS(&md3dDevice));

// 如果调用D3D12CreateDevice失败,程序就回退至WARP软件适配器
if(FAILED(hardwareResult))
{
  ComPtr<IDXGIAdapter> pWarpAdapter;
  ThrowIfFailed(mdxgiFactory->EnumWarpAdapter(IID_PPV_ARGS(&pWarpAdapter)));

  ThrowIfFailed(D3D12CreateDevice(
    pWarpAdapter.Get(),
    D3D_FEATURE_LEVEL_11_0,
    IID_PPV_ARGS(&md3dDevice)));
}

可以看到,为了进入调试模式,我们首先开启了调试层(debug layer)。随后,Direct3D便会开启额外的调试功能,并在错误发生时向VC++的输出窗口发送类似于下面的调试信息:

D3D12 ERROR: ID3D12CommandList::Reset: Reset fails because the command list was not closed.
// 由于没有关闭命令列表因此重置失败。

WARP :Windows Advanced Rasterization Platform(Windows高级光栅化平台)。为了创建WARP适配器,需要先创建一个IDXGIFactory4对象,并通过它来枚举WARP适配器:

ComPtr<IDXGIFactory4> mdxgiFactory;
CreateDXGIFactory1(IID_PPV_ARGS(&mdxgiFactory));
mdxgiFactory->EnumWarpAdapter(
  IID_PPV_ARGS(&pWarpAdapter));

2. 创建围栏并获取描述符的大小

一旦创建好设备,下一步就是为CPU/GPU的同步而创建围栏。

另外,若用描述符进行工作,还需要了解它们的大小。但描述符在不同的GPU平台上大小各异,这就需要我们去查询相关的信息。随后,我们会把描述符的大小缓存起来,需要时即可直接引用:

ThrowIfFailed(md3dDevice->CreateFence(
  0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&mFence)));
mRtvDescriptorSize = md3dDevice->GetDescriptorHandleIncrementSize(
  D3D12_DESCRIPTOR_HEAP_TYPE_RTV);
mDsvDescriptorSize = md3dDevice->GetDescriptorHandleIncrementSize(
  D3D12_DESCRIPTOR_HEAP_TYPE_DSV);
mCbvUavDescriptorSize = md3dDevice->GetDescriptorHandleIncrementSize(
  D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);

3. 检测对4X MSAA质量级别的支持

在本书中,我们要对4X MSAA的支持情况进行检测。这里选择4X,是因为借此采样数量就可以获得开销不高却性能不凡的效果。而且,在一切支持Direct3D 11的设备上,所有的渲染目标格式就皆已支持4X MSAA了。因此,凡是支持Direct3D 11的硬件,都会保证此项功能的正常开启,我们也就无须再对此进行检验了。但是,对质量级别的检测还是不可或缺,为此,可采取下列方法加以实现:

D3D12_FEATURE_DATA_MULTISAMPLE_QUALITY_LEVELS msQualityLevels;
msQualityLevels.Format = mBackBufferFormat;
msQualityLevels.SampleCount = 4;
msQualityLevels.Flags = D3D12_MULTISAMPLE_QUALITY_LEVELS_FLAG_NONE;
msQualityLevels.NumQualityLevels = 0;

ThrowIfFailed(md3dDevice->CheckFeatureSupport(
  D3D12_FEATURE_MULTISAMPLE_QUALITY_LEVELS,
  &msQualityLevels,
  sizeof(msQualityLevels)));

m4xMsaaQuality = msQualityLevels.NumQualityLevels;

//平台肯定能支持4X MSAA,所以返回值应该也总是大于0。
assert(m4xMsaaQuality > 0 && "Unexpected MSAA quality level.");

4. 创建命令队列和命令列表

ID3D12CommandQueue 接口表示命令队列

ID3D12CommandAllocator 接口代表命令分配器

ID3D12GraphicsCommandList 接口表示命令列表。

ComPtr<ID3D12CommandQueue> mCommandQueue;
ComPtr<ID3D12CommandAllocator> mDirectCmdListAlloc;
ComPtr<ID3D12GraphicsCommandList> mCommandList;
void D3DApp::CreateCommandObjects()
{
  D3D12_COMMAND_QUEUE_DESC queueDesc = {};
  queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
  queueDesc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;

  ThrowIfFailed(md3dDevice->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&mCommandQueue)));
  ThrowIfFailed(md3dDevice->CreateCommandAllocator(
    D3D12_COMMAND_LIST_TYPE_DIRECT,
    IID_PPV_ARGS(mDirectCmdListAlloc.GetAddressOf())));

    ThrowIfFailed(md3dDevice->CreateCommandList(
    0,
    D3D12_COMMAND_LIST_TYPE_DIRECT,
    mDirectCmdListAlloc.Get(), // 关联命令分配器
    nullptr,// 初始化流水线状态对象;因为在这里不会发起绘制命令,所以不会用到流水线状态对象
    IID_PPV_ARGS(mCommandList.GetAddressOf())));

  // 首先要将命令列表置于关闭状态。这是因为在第一次引用命令列表时,我们要对它进行重置,而在调用
  // 重置方法之前又需先将其关闭
  mCommandList->Close();
}

5. 描述并创建交换链

填写一份DXGI_SWAP_CHAIN_DESC结构体实例,它用来描述创建后的交换链的特性:

typedef struct DXGI_SWAP_CHAIN_DESC
{
  DXGI_MODE_DESC BufferDesc;
  DXGI_SAMPLE_DESC SampleDesc;
  DXGI_USAGE BufferUsage;
  UINT BufferCount;
  HWND OutputWindow;
  BOOL Windowed;
  DXGI_SWAP_EFFECT SwapEffect;
  UINT Flags;
} DXGI_SWAP_CHAIN_DESC;

1.BufferDesc:这个结构体描述了待创建后台缓冲区的属性。在这里我们仅关注它的宽度、高度和像素格式属性。至于其他成员的细节可查看SDK文档。

BufferDesc 的 定义是一个结构体 DXGI_MODE_DESC :

typedef struct DXGI_MODE_DESC
{
  UINT Width;                                     // 缓冲区分辨率的宽度
  UINT Height;                                    // 缓冲区分辨率的高度
  DXGI_RATIONAL RefreshRate;
  DXGI_FORMAT Format;                           // 缓冲区的显示格式
  DXGI_MODE_SCANLINE_ORDER ScanlineOrdering;  // 逐行扫描 vs. 隔行扫描
  DXGI_MODE_SCALING Scaling;                     // 图像如何相对于屏幕进行拉伸
} DXGI_MODE_DESC;

2.SampleDesc:多重采样的质量级别以及对每个像素的采样次数,可参见4.1.8节。对于单次采样来说,我们要将采样数量指定为1,质量级别指定为0。

3.BufferUsage:由于我们要将数据渲染至后台缓冲区(即用它作为渲染目标),因此将此参数指定为DXGI_USAGE_RENDER_TARGET_OUTPUT

4.BufferCount:交换链中所用的缓冲区数量。我们将它指定为2,即采用双缓冲。

5.OutputWindow:渲染窗口的句柄

6.Windowed:若为true,程序将在窗口模式下运行;如果指定为false,则采用全屏模式。

7.SwapEffect:指定为DXGI_SWAP_EFFECT_FLIP_DISCARD。

8.Flags:可选标志。如果是DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH,那么,当程序切换为全屏模式时,它将选择最适于当前应用程序窗口尺寸的显示模式。如果没有指定该标志,当程序切换为全屏模式时,将采用当前桌面的显示模式。

描述完后,用IDXGIFactory::CreateSwapChain方法来创建交换链:

HRESULT IDXGIFactory::CreateSwapChain(
 IUnknown *pDevice,              // 指向ID3D12CommandQueue接口的指针
 DXGI_SWAP_CHAIN_DESC *pDesc,   // 指向描述交换链的结构体的指针
 IDXGISwapChain **ppSwapChain); // 返回所创建的交换链接口

例子:

DXGI_FORMAT mBackBufferFormat = DXGI_FORMAT_R8G8B8A8_UNORM;

void D3DApp::CreateSwapChain()
{
  // 释放之前所创的交换链,随后再进行重建
  mSwapChain.Reset();

  DXGI_SWAP_CHAIN_DESC sd;
  sd.BufferDesc.Width = mClientWidth;
  sd.BufferDesc.Height = mClientHeight;
  sd.BufferDesc.RefreshRate.Numerator = 60;
  sd.BufferDesc.RefreshRate.Denominator = 1;
  sd.BufferDesc.Format = mBackBufferFormat;
  sd.BufferDesc.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED;
  sd.BufferDesc.Scaling = DXGI_MODE_SCALING_UNSPECIFIED;
  sd.SampleDesc.Count = m4xMsaaState ? 4 : 1;
  sd.SampleDesc.Quality = m4xMsaaState ? (m4xMsaaQuality - 1) : 0;
  sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
  sd.BufferCount = SwapChainBufferCount;
  sd.OutputWindow = mhMainWnd;
  sd.Windowed = true;
  sd.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
  sd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH;

  //注意,交换链需要通过命令队列对其进行刷新
  ThrowIfFailed(mdxgiFactory->CreateSwapChain(
    mCommandQueue.Get(),
    &sd,
    mSwapChain.GetAddressOf()));
}

从上可以看出,在创建新的交换链之前,先要销毁旧的交换链。这样一来,我们就可以用不同的设置来重新创建交换链,借此在运行时修改多重采样的配置。

6. 创建描述符堆

我们需要通过创建描述符堆来存储程序中要用到的描述符/视图(参见1.6节)。

Direct3D12 以ID3D12DescriptorHeap 接口表示描述符堆,并用ID3D12Device::CreateDescriptorHeap方法来创建它。

在下面示例程序中,我们将为交换链中SwapChainBufferCount个用于渲染数据的缓冲区资源创建对应的渲染目标视图(Render Target View,RTV),并为用于深度测试的深度/模板缓冲区资源创建一个深度/模板视图(Depth/Stencil View,DSV)。所以,我们此时需要创建两个描述符堆,其一用来存储SwapChainBufferCount个RTV,而那另一个描述堆则用来存储那1个DSV。

ComPtr<ID3D12DescriptorHeap> mRtvHeap;
ComPtr<ID3D12DescriptorHeap> mDsvHeap;
void D3DApp::CreateRtvAndDsvDescriptorHeaps()
{
  D3D12_DESCRIPTOR_HEAP_DESC rtvHeapDesc;
  rtvHeapDesc.NumDescriptors = SwapChainBufferCount;
  rtvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;
  rtvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
    rtvHeapDesc.NodeMask = 0;
   ThrowIfFailed(md3dDevice->CreateDescriptorHeap(
    &rtvHeapDesc, IID_PPV_ARGS(mRtvHeap.GetAddressOf())));

  D3D12_DESCRIPTOR_HEAP_DESC dsvHeapDesc;
  dsvHeapDesc.NumDescriptors = 1;
  dsvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_DSV;
  dsvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
    dsvHeapDesc.NodeMask = 0;
  ThrowIfFailed(md3dDevice->CreateDescriptorHeap(
    &dsvHeapDesc, IID_PPV_ARGS(mDsvHeap.GetAddressOf())));
}

...
...
...

static const int SwapChainBufferCount = 2;
int mCurrBackBuffer = 0;

mCurrBackBuffer是用来记录当前后台缓冲区的索引(由于利用页面翻转技术来交换前台缓冲区和后台缓冲区,所以我们需要对其进行记录,以便搞清楚哪个缓冲区才是当前正在用于渲染数据的后台缓冲区)。

创建描述符堆之后,还要能访问内部的描述符。程序中我们是通过句柄 来引用描述符的,并以ID3D12DescriptorHeap::GetCPUDescriptorHandleForHeapStart方法来获得描述符堆中第一个描述符的句柄。借助下列函数即可获取当前后台缓冲区的RTV与DSV:

D3D12_CPU_DESCRIPTOR_HANDLE D3DApp::CurrentBackBufferView()const
{
  // CD3DX12构造函数根据给定的偏移量找到当前后台缓冲区的RTV
  return CD3DX12_CPU_DESCRIPTOR_HANDLE(
    mRtvHeap->GetCPUDescriptorHandleForHeapStart(),// 堆中的首个句柄
    mCurrBackBuffer,   // 偏移至后台缓冲区描述符句柄的索引
    mRtvDescriptorSize); // 描述符所占字节的大小
}

D3D12_CPU_DESCRIPTOR_HANDLE D3DApp::DepthStencilView()const
{
  return mDsvHeap->GetCPUDescriptorHandleForHeapStart();
}

为了用偏移量找到当前后台缓冲区的RTV描述符,我们就必须知道RTV描述符的大小。

7. 创建渲染目标视图

1.6节所述,资源不能与渲染流水线中的阶段直接绑定,所以我们必须先为资源创建描述符(视图),并将其绑定到流水线阶段。

例如,为了将后台缓冲区绑定到流水线的输出合并阶段,需要为该后台缓冲区创建一个渲染目标视图。而这第一个步骤就是要获得存于交换链中的缓冲区资源。

HRESULT IDXGISwapChain::GetBuffer(
  UINT Buffer,
  REFIID riid,
  void **ppSurface);

1.Buffer:指定的特定后台缓冲区的索引。

2.riid:希望获得的ID3D12Resource接口的COM ID。

3.ppSurface:返回一个指向ID3D12Resource接口的指针,这便是希望获得的后台缓冲区。

**注意:**调用IDXGISwapChain::GetBuffer方法会增加相关后台缓冲区的COM引用计数,所以在每次使用后一定要将其释放。通过ComPtr便可以自动做到这一点。

接下来,使用ID3D12Device::CreateRenderTargetView方法来为获取的后台缓冲区创建渲染目标视图。

void ID3D12Device::CreateRenderTargetView(
  ID3D12Resource *pResource,
  const D3D12_RENDER_TARGET_VIEW_DESC *pDesc,
  D3D12_CPU_DESCRIPTOR_HANDLE DestDescriptor);

1.pResource:指定用作渲染目标的资源。在上面的例子中是后台缓冲区(即为后台缓冲区创建了一个渲染目标视图)。

2.pDesc:指向D3D12_RENDER_TARGET_VIEW_DESC数据结构实例的指针。该结构体描述了资源中元素的数据类型(格式)。如果该资源在创建时已指定了具体格式(即此资源不是无类型格式,not typeless),那么就可以把这个参数设为空指针,表示采用该资源创建时的格式,为它的第一个mipmap层级(后台缓冲区只有一种mipmap层级,有关mipmap的内容将在第9章展开讨论)创建一个视图。由于已经指定了后台缓冲区的格式,因此就将这个参数设置为空指针。

3.DestDescriptor:引用所创建渲染目标视图的描述符句柄。

例子:

例子通过调用这两种方法为交换链中的每一个缓冲区都创建了一个RTV。

ComPtr<ID3D12Resource> mSwapChainBuffer[SwapChainBufferCount];
CD3DX12_CPU_DESCRIPTOR_HANDLE rtvHeapHandle(
  mRtvHeap->GetCPUDescriptorHandleForHeapStart());
for (UINT i = 0; i < SwapChainBufferCount; i++)
{
  // 获得交换链内的第i个缓冲区
  ThrowIfFailed(mSwapChain->GetBuffer(
    i, IID_PPV_ARGS(&mSwapChainBuffer[i])));

  // 为此缓冲区创建一个RTV
  md3dDevice->CreateRenderTargetView(
    mSwapChainBuffer[i].Get(), nullptr, rtvHeapHandle);

  // 偏移到描述符堆中的下一个缓冲区
  rtvHeapHandle.Offset(1, mRtvDescriptorSize);
}

8. 创建深度/模板缓冲区及其视图

深度缓冲区其实就是一种2D纹理,它存储着离观察者最近的可视对象的深度信息(如果使用了模板,还会附有模板信息)。纹理是一种GPU资源,因此我们要通过填写D3D12_RESOURCE_DESC结构体来描述纹理资源,再用ID3D12Device::CreateCommittedResource方法来创建它。

D3D12_RESOURCE_DESC结构体的定义如下:

// 描述纹理资源
typedef struct D3D12_RESOURCE_DESC
{
  D3D12_RESOURCE_DIMENSION Dimension;
  UINT64 Alignment;
  UINT64 Width;
  UINT Height;
  UINT16 DepthOrArraySize;
  UINT16 MipLevels;
  DXGI_FORMAT Format;
  DXGI_SAMPLE_DESC SampleDesc;
  D3D12_TEXTURE_LAYOUT Layout;
  D3D12_RESOURCE_MTSC_FLAG Misc Flags;
} D3D12_RESOURCE_DESC;

1.Dimension:资源的维度,即为下列枚举类型中的成员之一。

 enum D3D12_RESOURCE_DIMENSION
  {
    D3D12_RESOURCE_DIMENSION_UNKNOWN = 0,
    D3D12_RESOURCE_DIMENSION_BUFFER = 1,
    D3D12_RESOURCE_DIMENSION_TEXTURE1D = 2,
    D3D12_RESOURCE_DIMENSION_TEXTURE2D = 3,
    D3D12_RESOURCE_DIMENSION_TEXTURE3D = 4
  } D3D12_RESOURCE_DIMENSION;

2.Width:以纹素为单位来表示的纹理宽度。对于缓冲区资源来说,此项是缓冲区占用的字节数。

3.Height:以纹素为单位来表示的纹理高度。

4.DepthOrArraySize:以纹素为单位来表示的纹理深度,或者(对于1D纹理和2D纹理来说)是纹理数组的大小。注意,Direct3D中并不存在3D纹理数组的概念。

5.MipLevels:mipmap层级的数量。我们会在第9章讲纹理时介绍mipmap。对于深度/模板缓冲区而言,只能有一个mipmap级别。

6.Format:DXGI_FORMAT枚举类型中的成员之一,用于指定纹素的格式。对于深度/模板缓冲区来说,此格式需要从4.1.5节介绍的格式中选择。

7.SampleDesc:多重采样的质量级别以及对每个像素的采样次数,详情参见4.1.7节和4.1.8节。先来回顾一下4X MSAA技术:为了存储每个子像素的颜色和深度/模板信息,所用后台缓冲区和深度缓冲区的大小要4倍于屏幕的分辨率。因此,深度/模板缓冲区与渲染目标的多重采样设置一定要相匹配。

8.Layout:D3D12_TEXTURE_LAYOUT枚举类型的成员之一,用于指定纹理的布局。我们暂时还不用考虑这个问题,在此将它指定为D3D12_TEXTURE_LAYOUT_UNKNOWN即可。

9.Flags:与资源有关的杂项标志。对于一个深度/模板缓冲区资源来说,要将此项指定为D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL。

GPU资源都存于(heap)中,其本质是具有特定属性的GPU显存块。ID3D12Device::CreateCommittedResource方法将根据我们所提供的属性创建一个资源与一个堆,并把该资源提交到这个堆中:

HRESULT ID3D12Device::CreateCommittedResource(
  const D3D12_HEAP_PROPERTIES *pHeapProperties,
  D3D12_HEAP_FLAGS HeapFlags,
  const D3D12_RESOURCE_DESC *pDesc,
  D3D12_RESOURCE_STATES InitialResourceState,
  const D3D12_CLEAR_VALUE *pOptimizedClearValue,
  REFIID riidResource,
  void **ppvResource);

typedef struct D3D12_HEAP_PROPERTIES {
 D3D12_HEAP_TYPE      Type;
 D3D12_CPU_PAGE_PROPERTY CPUPageProperty;
 D3D12_MEMORY_POOL    MemoryPoolPreference;
 UINT CreationNodeMask;
 UINT VisibleNodeMask;
} D3D12_HEAP_PROPERTIES;

1.pHeapProperties:(资源欲提交至的)堆所具有的属性。有一些属性是针对高级用法而设。目前只需关心D3D12_HEAP_PROPERTIES中的D3D12_HEAP_TYPE枚举类型这一主要属性,其中的成员列举如下。

a) D3D12_HEAP_TYPE_DEFAULT:默认堆(default heap)。向这堆里提交的资源,唯独GPU可以访问。举一个有关深度/模板缓冲区的例子:GPU会读写深度/模板缓冲区,而CPU从不需要访问它,所以深度/模板缓冲区应被放入默认堆中。

b)D3D12_HEAP_TYPE_UPLOAD:上传堆(upload heap)。向此堆里提交的都是需要经CPU上传至GPU的资源。

c)D3D12_HEAP_TYPE_READBACK:回读堆(read-back heap)。向这种堆里提交的都是需要由CPU读取的资源。

d)D3D12_HEAP_TYPE_CUSTOM:此成员应用于高级场景------更多信息可详见MSDN文档。

2.HeapFlags:与(资源欲提交至的)堆有关的额外选项标志。通常将它设为D3D12_HEAP_FLAG_NONE。

3.pDesc:指向一个D3D12_RESOURCE_DESC实例的指针,用它描述待建的资源。

4.InitialResourceState:回顾4.2.3节的内容可知,不管何时,每个资源都会处于一种特定的使用状态。在资源创建时,需要用此参数来设置它的初始状态。对于深度/模板缓冲区来说,通常将其初始状态设置为D3D12_RESOURCE_STATE_COMMON,再利用ResourceBarrier方法辅以D3D12_RESOURCE_ STATE_DEPTH_WRITE状态,将其转换为可以绑定在渲染流水线上的深度/模板缓冲区。

5.pOptimizedClearValue:指向一个D3D12_CLEAR_VALUE对象的指针,它描述了一个用于清除资源的优化值。选择适当的优化清除值,可提高清除操作的执行速度。若不希望指定优化清除值,可把此参数设为nullptr。

struct D3D12_CLEAR_VALUE
{
  DXGI_FORMAT Format;
  union 
  {
    FLOAT Color[ 4 ];
    D3D12_DEPTH_STENCIL_VALUE DepthStencil;
  };
}     D3D12_CLEAR_VALUE;

6.riidResource:我们希望获得的ID3D12Resource接口的COM ID。

7.ppvResource:返回一个指向ID3D12Resource的指针,即新建的资源。


在使用深度/模板缓冲区之前,一定要创建相关的深度/模板视图,并将它绑定到渲染流水线上。这个流程类似于创建渲染目标视图。下面的代码演示了该如何创建深度/模板纹理及相应的深度/模板视图:

// 创建深度/模板缓冲区及其视图
D3D12_RESOURCE_DESC depthStencilDesc;
depthStencilDesc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D;
depthStencilDesc.Alignment = 0;
depthStencilDesc.Width = mClientWidth;
depthStencilDesc.Height = mClientHeight;
depthStencilDesc.DepthOrArraySize = 1;
depthStencilDesc.MipLevels = 1;
depthStencilDesc.Format = mDepthStencilFormat;
depthStencilDesc.SampleDesc.Count = m4xMsaaState ? 4 : 1;
depthStencilDesc.SampleDesc.Quality = m4xMsaaState ? (m4xMsaaQuality - 1) : 0;
depthStencilDesc.Layout = D3D12_TEXTURE_LAYOUT_UNKNOWN;
depthStencilDesc.Flags = D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL;

D3D12_CLEAR_VALUE optClear;
optClear.Format = mDepthStencilFormat;
optClear.DepthStencil.Depth = 1.0f;
optClear.DepthStencil.Stencil = 0;
ThrowIfFailed(md3dDevice->CreateCommittedResource(
  &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
  D3D12_HEAP_FLAG_NONE,
  &depthStencilDesc,
  D3D12_RESOURCE_STATE_COMMON,
  &optClear,
  IID_PPV_ARGS(mDepthStencilBuffer.GetAddressOf())));

// 利用此资源的格式,为整个资源的第0 mip层创建描述符
md3dDevice->CreateDepthStencilView(
  mDepthStencilBuffer.Get(),
  nullptr,
  DepthStencilView());

// 将资源从初始状态转换为深度缓冲区
mCommandList->ResourceBarrier(
  1, 
  &CD3DX12_RESOURCE_BARRIER::Transition(
    mDepthStencilBuffer.Get(),
    D3D12_RESOURCE_STATE_COMMON,
    D3D12_RESOURCE_STATE_DEPTH_WRITE));

注意,刚刚采用了CD3DX12_HEAP_PROPERTIES辅助构造函数来创建堆的属性结构体,它的具体实现如下:

explicit CD3DX12_HEAP_PROPERTIES( 
    D3D12_HEAP_TYPE type, 
    UINT creationNodeMask = 1, 
    UINT nodeMask = 1 )
{
  Type = type;
  CPUPageProperty = D3D12_CPU_PAGE_PROPERTY_UNKNOWN;
  MemoryPoolPreference = D3D12_MEMORY_POOL_UNKNOWN;
  CreationNodeMask = creationNodeMask;
  VisibleNodeMask = nodeMask;
}

CreateDepthStencilView方法的第二个参数是指向D3D12_DEPTH_STENCIL_VIEW_DESC结构体的指针。这个结构体描述了资源中元素的数据类型(格式)。如果资源在创建时已指定了具体格式(即此资源不是无类型格式),那么就可以把该参数设为空指针,表示以该资源创建时的格式为它的第一个mipmap层级创建一个视图(在创建深度/模板缓冲区时就只有一个mipmap层级,mipmap的相关知识将在第9章中进行讨论)。由于我们已经为深度/模板缓冲区设置了具体格式,所以向此参数传入空指针。

9. 设置视口

我们通常会将3D场景绘制到与整个屏幕(在全屏模式下)或整个窗口工作区大小相当的后台缓冲区中。但是,有时只是希望把3D场景绘制到后台缓冲区的某个矩形子区域当中,如下图

通过修改视口,我们就能将3D场景绘制到后台缓冲区内的矩形子区域当中,继而使后台缓冲区中的内容呈现在窗口的工作区范围之内。我们把后台缓冲区中的这种矩形子区域叫作视口(viewport),并通过下列结构体来描述它:

typedef struct D3D12_VIEWPORT {
  FLOAT TopLeftX;
  FLOAT TopLeftY;
  FLOAT Width;
  FLOAT Height;
  FLOAT MinDepth;
  FLOAT MaxDepth;
} D3D12_VIEWPORT;

结构体中的前4个数据成员定义了视口矩形相对于后台缓冲区的绘制范围(由于数据成员是用float类型表示的,所以我们能够以小数精度来指定像素坐标)。在Direct3D中,存储在深度缓冲区中的数据都是范围在0~1的归一化深度值。MinDepth和MaxDepth这两个成员负责将深度值从区间[0, 1]转换到区间[MinDepth, MaxDepth]。通过对深度范围进行转换即可实现某些特效,例如,我们可以依次设置MinDepth=0和MaxDepth=0,用此视口绘制的物体其深度值都为0,它们将比场景中其他物体的位置都更靠前。然而,在大多数情况下通常会把MinDepth与MaxDepth分别设置为0与1,也就是令深度值保持不变。

只要填写好D3D12_VIEWPORT结构体, 便可以用ID3D12GraphicsCommandList::RSSetViewports方法来设置Direct3D中的视口了。下面的示例是通过创建并设置一个视口,将场景绘至整个后台缓冲区:

D3D12_VIEWPORT vp;
vp.TopLeftX = 0.0f;
vp.TopLeftY = 0.0f;
vp.Width  = static_cast<float>(mClientWidth);
vp.Height  = static_cast<float>(mClientHeight);
vp.MinDepth = 0.0f;
vp.MaxDepth = 1.0f;

mCommandList->RSSetViewports(1, &vp);

第一个参数是要绑定的视口数量(有些高级效果需要使用多个视口),第二个参数是一个指向视口数组的指针。

10. 设置剪裁矩形

我们可以相对于后台缓冲区定义一个裁剪矩形(scissor rectangle),在此矩形外的像素都将被剔除(即这些图像部分将不会被光栅化(rasterize)至后台缓冲区)。这个方法能用于优化程序的性能。例如,假设已知有一个矩形的UI(user interface,用户界面)元素覆于屏幕中某块区域的最上层,那么我们也就无须对3D空间中那些被它遮挡的像素进行处理了。

裁剪矩形由类型为RECT的D3D12_RECT结构体(typedef RECT D3D12_RECT;)定义而成:

typedef struct tagRECT
{
  LONG  left;
  LONG  top;
  LONG  right;
  LONG  bottom;
} RECT;

在Direct3D中,要用ID3D12GraphicsCommandList::RSSetScissorRects方法来设置裁剪矩形。下面的示例将创建并设置一个覆盖后台缓冲区左上角1/4区域的裁剪矩形:

mScissorRect = { 0, 0, mClientWidth/2, mClientHeight/2 };
mCommandList->RSSetScissorRects(1, &mScissorRect);

类似于RSSetViewports方法,RSSetScissorRects方法的第一个参数是要绑定的裁剪矩形数量(为了实现一些高级效果有时会采用多个裁剪矩形),第二个参数是指向一个裁剪矩形数组的指针。

注意:裁剪矩形需要随着命令列表的重置而重置。

四、计时与动画

第四节和Direct3D初始化无关,只是提供一个时间信息的辅助工具。

动画效果需要精确地计量每帧画面之间的时间间隔。如果帧率较高,那么帧与帧之间的间隔就会比较短,此时我们就要用到高精度的计时器。

1. 性能计时器

为了精确地度量时间,我们将采用性能计时器(性能计数器)。如果希望调用查询性能计时器的Win32函数,我们必须引入头文件**#include <windows.h>**。

性能计时器所用的时间度量单位叫作计数(count) 。可调用QueryPerformanceCounter函数来获取性能计时器当前时刻值(以计数为单位):

  __int64 currTime;
  QueryPerformanceCounter((LARGE_INTEGER*)&currTime);

观察可知,此函数通过参数返回的当前时刻值是个64位的整数。

再用QueryPerformanceFrequency函数来获取性能计时器的频率(单位:计数/秒):

 __int64 countsPerSec;
  QueryPerformanceFrequency((LARGE_INTEGER*)&countsPerSec);

每个计数所代表的秒数(或称几分之一秒),即为上述性能计时器频率的倒数:

mSecondsPerCount = 1.0 / (double)countsPerSec;

因此,只需将读取的时刻计数值valueInCounts乘以转换因子mSecondsPerCount,就可以将其单位转换为秒:

 valueInSecs = valueInCounts * mSecondsPerCount;

对我们而言,单次调用QueryPerformanceCounter函数所返回的时刻值并没有什么特别的意义。如果隔一小段时间,再调用一次该函数,并得到此时的时刻值,我们就会发现这两次调用的时刻间隔即为两个返回值的差。因此,我们总是以两个时间戳(time stamp)的相对差值,而非性能计数器单次返回的实际值来度量时间:

  __int64 A = 0;
  QueryPerformanceCounter((LARGE_INTEGER*)&A);

  // 执行预定的逻辑 

  __int64 B = 0;
  QueryPerformanceCounter((LARGE_INTEGER*)&B);

利用(B-A)即可获得代码执行期间的计数值,或以(B-A)*mSecondsPerCount获取代码运行期间所花费的秒数。

**注意:**MSDN对QueryPerformanceCounter函数作有如下备注:"按道理来讲,对于一台具有多个处理器的计算机而言,无论在哪一个处理器上调用此函数都应返回当前时刻的计数值。然而,由于基本输入/输出系统(BIOS)或硬件抽象层(HAL)上的缺陷,导致了在不同的处理器上可能会得到不同的结果。"对此,我们可以通过SetThreadAffinityMask函数,防止应用程序的主线程切换到其他的处理器上去执行指令,从而实现每次都能在同一处理器上两次调用QueryPerformanceCounter函数,得到正确的计数差值。

2. 游戏计时器类

在接下来的两小节中,我们将讨论以下GameTimer类的实现:

class GameTimer
{
public:
    GameTimer();

    float TotalTime()const; // 用秒作为单位
    float DeltaTime()const; // 用秒作为单位

    void Reset(); // 在开始消息循环之前调用
    void Start(); // 解除计时器暂停时调用
    void Stop();  // 暂停计时器时调用
    void Tick();  // 每帧都要调用

private:
    double mSecondsPerCount;
    double mDeltaTime;

    __int64 mBaseTime;
    __int64 mPausedTime;
    __int64 mStopTime;
    __int64 mPrevTime;
    __int64 mCurrTime;

    bool mStopped;
};

此类的构造函数会查询性能计数器的频率。另外几个成员函数将在后面的两小节中讨论。

GameTimer::GameTimer()
: mSecondsPerCount(0.0), mDeltaTime(-1.0), mBaseTime(0), 
 mPausedTime(0), mPrevTime(0), mCurrTime(0), mStopped(false)
{
    __int64 countsPerSec;
    QueryPerformanceFrequency((LARGE_INTEGER*)&countsPerSec);
    mSecondsPerCount = 1.0 / (double)countsPerSec;
}

GameTimer类的实现位于GameTimer.h和GameTimer.cpp文件之中,可以在本书源代码的Common目录里找到。

3. 帧与帧之间的时间间隔

当渲染动画帧时,我们需要知道每帧之间的时间间隔,以此来根据时间的流逝对游戏对象进行更新。计算帧与帧之间间隔的流程如下。假设在开始显示第 i 帧画面时,性能计数器返回的时刻为 ti ;而此前的一帧开始显示时,性能计数器返回的时刻为 ti−1 。那么这两帧的时间间隔就是 △t = ti - ti−1。对于实时渲染来说,为了保证动画的流畅性至少需要每秒刷新30帧,所以△t = ti - ti−1往往是个较小的数值。

计算 △t 的代码如下:

void GameTimer::Tick()
{
    if( mStopped )
    {
        mDeltaTime = 0.0;
        return;
    }

    // 获得本帧开始显示的时刻
    __int64 currTime;
    QueryPerformanceCounter((LARGE_INTEGER*)&currTime);
    mCurrTime = currTime;

    // 本帧与前一帧的时间差
    mDeltaTime = (mCurrTime - mPrevTime)*mSecondsPerCount;

    // 准备计算本帧与下一帧的时间差
    mPrevTime = mCurrTime;

    // 使时间差为非负值。DXSDK中的CDXUTTimer示例注释里提到:如果处理器处于节能模式,或者在
    // 计算两帧间时间差的过程中切换到另一个处理器时(即QueryPerformanceCounter函数的两次调
    // 用并非在同一处理器上),则mDeltaTime有可能会成为负值
    if(mDeltaTime < 0.0)
    {
        mDeltaTime = 0.0;
    }
}

float GameTimer::DeltaTime()const
{
    return (float)mDeltaTime;
}

Tick函数被调用于程序的消息循环之中:

int D3DApp::Run()
{
  MSG msg = {0};

  mTimer.Reset();

  while(msg.message != WM_QUIT)
  {
    // 如果有窗口消息就进行处理
    if(PeekMessage( &msg, 0, 0, 0, PM_REMOVE ))
    {
      TranslateMessage( &msg );
      DispatchMessage( &msg );
    }
    // 否则就执行动画与游戏的相关逻辑
    else
    { 
      mTimer.Tick();

      if( !mAppPaused )
      {
        CalculateFrameStats();
        Update(mTimer); 
        Draw(mTimer);
      }
      else
      {
        Sleep(100);
      }
    }
  }

  return (int)msg.wParam;
}

采用这种方案时,我们需要在每一帧都计算 △t ,并将其送入Update方法。只有这样,才可以根据前一动画帧所花费的时间对场景进行更新。以下是Reset方法的具体实现:

void GameTimer::Reset()
{
    __int64 currTime;
    QueryPerformanceCounter((LARGE_INTEGER*)&currTime);

    mBaseTime = currTime;
    mPrevTime = currTime;
    mStopTime = 0;
    mStopped = false;
}

可以看出,在调用Reset时会将mPrevTime初始化为当前时刻。这一步十分关键,由于在第一帧画面之前没有任何的动画帧,所以此帧的前一个时间戳并不存在。因此,在消息循环开始之前,需要通过Reset方法对mPrevTime的值进行初始化。

4. 总时间

除此之外,我们还开展了一项名为总时间(total time)的实用时间统计:这是一种自应用程序开始,不计其中暂停时间的时间总和。下面的情景展示了它所起到的作用。假设我们制作的游戏需要玩家在300秒内打通一个关卡。关卡开始时,先来获取时间 tstart ,它表示自程序开始至此关卡开始所经过的时间。在关卡开始后,我们会经常检测由程序开始至当前的时间 t 。如果 t−tstart>300 秒 (见下图),就说明玩家在此关卡停留的时间已经超过300秒,挑战失败。不难发现,在这种情景中,我们并不希望把玩家在游戏过程中的暂停时间也统计在关卡停留的时间内,而总时间则刚好可以满足这一点。

计算自游戏关卡开始至当前的时间。注意,我们将此应用程序的开始时刻选择为坐标原点(0),并以此作为参考系来统计时间

总时间的另一个应用情景是:驱使某量随时间函数而变化。举个例子,假设需要根据某个时间函数来得到光源环绕场景的运动轨迹,那么它的位置可以用下列参数方程表示:

这里的 t 表示时间,随着 t (时间)的增加,光源的坐标也在不断更新,从而使它在 y=20 这一平面内半径为10的圆周上运动。对于这种动画,我们也不希望把暂停时间记录在变化时间内,如图所示:

如果我们在 t1 时暂停计时器,在 t2 时重启计时器,并把暂停时间记录在内,那么用户就会看到光点从 p(t1) 突然跳到 p(t2) 处

为了统计总时间,我们将使用下列变量:

__int64 mBaseTime;
__int64 mPausedTime;
__int64 mStopTime;

正如4.3节中所述,在调用Reset函数之时,会将mBaseTime初始化为当前时刻。我们可以把这个时刻当作应用程序的开始时刻。在大多数情况下,Reset函数只会在消息循环开始之前调用一次,所以在应用程序的整个生命周期里,mBaseTime一般会保持不变。变量mPausedTime存储的是所有暂停时间之和。这个累积时间很有存在的必要:为了得到不统计暂停时间的总时间,我们可以用程序的总运行时间减去这个累加时间算出。变量mStopTime会给出计时器停止(暂停)的时刻,借此即可记录暂停的时间。

Stop和Start是GameTimer类中的两个关键方法。当应用程序分别处于暂停或未暂停的状态时,我们就可以依情况调用它们,以此令GameTimer能够记录暂停的时间。代码注释介绍了这两个方法的相关细节:

void GameTimer::Stop()
{
    // 如果已经处于停止状态,那就什么也不做
    if( !mStopped )
    {
        __int64 currTime;
        QueryPerformanceCounter((LARGE_INTEGER*)&currTime);

        // 否则,保存停止的时刻,并设置布尔标志,指示计时器已经停止
        mStopTime = currTime;
        mStopped = true;
    }
}

void GameTimer::Start()
{
    __int64 startTime;
    QueryPerformanceCounter((LARGE_INTEGER*)&startTime);

    // 累加调用stop和start这对方法之间的暂停时刻间隔
    //
    //                         |<-------d------->|
    // ----*---------------*-----------------*------------> 时间
    // mBase Time        mStopTime            startTime

    // 如果从停止状态继续计时的话......
    if( mStopped )
    {
        // 累加暂停时间
        mPausedTime += (startTime - mStopTime); 

        // 在重新开启计时器时,前一帧的时间mPrevTime是无效的,这是因为它存储的是暂停时前一
        // 帧的开始时刻,因此需要将它重置为当前时刻

        mPrevTime = startTime;

        // 已不再是停止状态......
        mStopTime = 0;
        mStopped = false;
    }
}

最后,我们就可以用成员函数TotalTime返回自调用Reset函数开始不计暂停时间的总时间了,它的具体实现如下:

float GameTimer::TotalTime()const
{
// 如果正处于停止状态,则忽略本次停止时刻至当前时刻的这段时间。
// 此外,如果之前已有过暂停的情况,
// 那么也不应统计mStopTime -- mBaseTime这段时间内的暂停时间
// 为了做到这一点,可以从mStopTime中再减去暂停时间mPausedTime
//
//                            前一次暂停时间           当前的暂停时间
//                         |<--------------->|              |<---------->|
// ----*---------------*-----------------*------------*------------*------> 时间
// mBase Time      mStopTime0            startTime    mStopTime     mCurrTime

    if( mStopped )
    {
        return (float)(((mStopTime - mPausedTime)-
                mBaseTime)*mSecondsPerCount);
    }

// 我们并不希望统计mCurrTime -- mBaseTime内的暂停时间
// 可以通过从mCurrTime中再减去暂停时间mPausedTime来实现这一点
//
// (mCurrTime - mPausedTime) - mBaseTime 
//
//                       |<--  暂停时间  -->|
// ----*---------------*-----------------*------------*------> 时间
// mBaseTime        mStopTime          startTime     mCurrTime

    else
    {
        return (float)(((mCurrTime-mPausedTime)-
                mBaseTime)*mSecondsPerCount);
    }
}

在我们的演示框架中,为了度量从程序开始到某时刻的总时间而创建了一个GameTimer实例,与此同时,也对每帧之间的时间间隔进行了测量。其实,我们也可以再创建一个GameTimer实例,把它当作一个通用的"秒表"。例如,当游戏中的炸弹被点燃时,我们可以开启一个新的GameTimer实例,当TotalTime达到5秒时就触发爆炸事件。

五、应用程序框架示例

全书的程序都使用了d3dUtil.h、d3dUtil.cpp、d3dApp.h和d3dApp.cpp中的框架代码。

d3dUtil.hd3dUtil.cpp文件中含有程序所需的实用工具代码。

d3dApp.hd3dApp.cpp文件内包含Direct3D应用程序类核心代码。

1. D3DApp类

D3DApp类是一种基础的Direct3D应用程序类,它提供了创建应用程序主窗口、运行程序消息循环、处理窗口消息以及初始化Direct3D等多种功能的函数。此外,该类还为应用程序例程定义了一组框架函数。我们可以根据需求通过实例化一个继承自D3DApp的类,重写(override)框架的虚函数,以此从D3DApp类中派生出自定义的用户代码。D3DApp类的定义如下:

#include "d3dUtil.h"
#include "GameTimer.h"

// 链接所需的d3d12库
#pragma comment(lib,"d3dcompiler.lib")
#pragma comment(lib, "D3D12.lib")
#pragma comment(lib, "dxgi.lib")

class D3DApp
{
protected:

  D3DApp(HINSTANCE hInstance);
  D3DApp(const D3DApp& rhs) = delete;
  D3DApp& operator=(const D3DApp& rhs) = delete;
  virtual ~D3DApp();

public:

  static D3DApp* GetApp();

  HINSTANCE AppInst()const;
  HWND   MainWnd()const;
  float   AspectRatio()const;

  bool Get4xMsaaState()const;
  void Set4xMsaaState(bool value);

  int Run();

  virtual bool Initialize();
  virtual LRESULT MsgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);

protected:
  virtual void CreateRtvAndDsvDescriptorHeaps();
  virtual void OnResize(); 
  virtual void Update(const GameTimer& gt)=0;
  virtual void Draw(const GameTimer& gt)=0;

  // 便于重写鼠标输入消息的处理流程
  virtual void OnMouseDown(WPARAM btnState, int x, int y){ }
  virtual void OnMouseUp(WPARAM btnState, int x, int y) { }
  virtual void OnMouseMove(WPARAM btnState, int x, int y){ }

protected:

  bool InitMainWindow();
  bool InitDirect3D();
  void CreateCommandObjects();
  void CreateSwapChain();

  void FlushCommandQueue();

  ID3D12Resource* CurrentBackBuffer()const
  {
    return mSwapChainBuffer[mCurrBackBuffer].Get();
  }

  D3D12_CPU_DESCRIPTOR_HANDLE CurrentBackBufferView()const
  {
    return CD3DX12_CPU_DESCRIPTOR_HANDLE(
      mRtvHeap->GetCPUDescriptorHandleForHeapStart(), 
      mCurrBackBuffer, 
      mRtvDescriptorSize);
  }

  D3D12_CPU_DESCRIPTOR_HANDLE DepthStencilView()const
  {
    return mDsvHeap->GetCPUDescriptorHandleForHeapStart();
  }

  void CalculateFrameStats();

  void LogAdapters();
  void LogAdapterOutputs(IDXGIAdapter* adapter);
  void LogOutputDisplayModes(IDXGIOutput* output, DXGI_FORMAT format);

protected:

  static D3DApp* mApp;

  HINSTANCE mhAppInst = nullptr; // 应用程序实例句柄
  HWND   mhMainWnd = nullptr; // 主https://zhida.zhihu.com/search?content_id=182488610&content_type=Article&match_order=1&q=%E7%AA%97%E5%8F%A3%E5%8F%A5%E6%9F%84&zhida_source=entity
  bool   mAppPaused = false; // 应用程序是否暂停
  bool   mMinimized = false; // 应用程序是否最小化
  bool   mMaximized = false; // 应用程序是否最大化
  bool   mResizing = false;  // 大小调整栏是否受到拖拽
  bool   mFullscreenState = false;// 是否开启全屏模式

  // 若将该选项设置为true,则使用4X MSAA技术(参见4.1.8节)。默认值为false
  bool   m4xMsaaState = false;  // 是否开启4X MSAA
  UINT   m4xMsaaQuality = 0;    // 4X MSAA的质量级别

  // 用于记录"delta-time"(帧之间的时间间隔)和游戏总时间(参见4.4节)
  GameTimer mTimer;

  Microsoft::WRL::ComPtr<IDXGIFactory4> mdxgiFactory;
  Microsoft::WRL::ComPtr<IDXGISwapChain> mSwapChain;
  Microsoft::WRL::ComPtr<ID3D12Device> md3dDevice;

  Microsoft::WRL::ComPtr<ID3D12Fence> mFence;
  UINT64 mCurrentFence = 0;

  Microsoft::WRL::ComPtr<ID3D12CommandQueue> mCommandQueue;
  Microsoft::WRL::ComPtr<ID3D12CommandAllocator> mDirectCmdListAlloc;
  Microsoft::WRL::ComPtr<ID3D12GraphicsCommandList> mCommandList;

  static const int SwapChainBufferCount = 2;
  int mCurrBackBuffer = 0;
  Microsoft::WRL::ComPtr<ID3D12Resource> mSwapChainBuffer[SwapChainBufferCount];
  Microsoft::WRL::ComPtr<ID3D12Resource> mDepthStencilBuffer;
 
  Microsoft::WRL::ComPtr<ID3D12DescriptorHeap> mRtvHeap;
  Microsoft::WRL::ComPtr<ID3D12DescriptorHeap> mDsvHeap;

  D3D12_VIEWPORT mScreenViewport; 
  D3D12_RECT mScissorRect;

  UINT mRtvDescriptorSize = 0;
  UINT mDsvDescriptorSize = 0;
  UINT mCbvSrvUavDescriptorSize = 0;

  // 用户应该在派生类的派生构造函数中自定义这些初始值
  std::wstring mMainWndCaption = L"d3d App";
  D3D_DRIVER_TYPE md3dDriverType = D3D_DRIVER_TYPE_HARDWARE;
  DXGI_FORMAT mBackBufferFormat = DXGI_FORMAT_R8G8B8A8_UNORM;
  DXGI_FORMAT mDepthStencilFormat = DXGI_FORMAT_D24_UNORM_S8_UINT;
  int mClientWidth = 800;
  int mClientHeight = 600;
};

2. 非框架方法

1.D3DApp:这个构造函数只是简单地将数据成员初始化为默认值。

2.~D3DApp:这个析构函数用于释放D3DApp中所用的COM接口对象并刷新命令队列。在析构函数中刷新命令队列的原因是:在销毁GPU引用的资源以前,必须等待GPU处理完队列中的所有命令。否则,可能造成应用程序在退出时崩溃。

D3DApp::~D3DApp()
{
  if(md3dDevice != nullptr)
    FlushCommandQueue();
}

3.AppInst:简单的存取函数,返回应用程序实例句柄。

4.MainWnd:简单的存取函数,返回主窗口句柄。

5.AspectRatio:这个纵横比(亦有译作长宽比、宽高比)定义的是后台缓冲区的宽度与高度之比。第5章会用到这个比值。它的实现比较简单:

 float D3DApp::AspectRatio()const
  {
    return static_cast<float>(mClientWidth) / mClientHeight;
  }

6.Get4xMsaaState:如果启用4X MSAA就返回true,否则返回false。

7.Set4xMsaaState:开启或禁用4X MSAA功能。

8.Run:这个方法封装了应用程序的消息循环。它使用的是Win32的PeekMessage函数,当没有窗口消息到来时就会处理我们的游戏逻辑部分。该方法的实现可见4.4.3节。

9.InitMainWindow:初始化应用程序主窗口。本书假设读者熟悉基本的Win32窗口初始化流程。

10.InitDirect3D:通过实现4.3节中讨论的步骤来完成Direct3D的初始化。

11.CreateSwapChain:创建交换链(参见4.3.5节)。

12.CreateCommandObjects:依4.3.4节中所述的流程创建命令队列、命令列表分配器和命令列表。

13.FlushCommandQueue:强制CPU等待GPU,直到GPU处理完队列中所有的命令(详见4.2.2节)。

14.CurrentBackBuffer:返回交换链中当前后台缓冲区的ID3D12Resource。

15.CurrentBackBufferView:返回当前后台缓冲区的RTV(渲染目标视图,render target view)。

16.DepthStencilView:返回主深度/模板缓冲区的DSV(深度/模板视图,depth/stencil view)。

17.CalculateFrameStats:计算每秒的平均帧数以及每帧平均的毫秒时长。实现方法将在4.5.4节中讨论。

18.LogAdapters:枚举系统中所有的适配器(参见4.1.10节)。

19.LogAdapterOutputs:枚举指定适配器的全部显示输出(参见4.1.10节)。

20.LogOutputDisplayModes:枚举某个显示输出对特定格式支持的所有显示模式(参见4.1.10节)。

3. 框架方法

对于本书的所有示例程序来说,我们每次都会重写D3DApp中的6个虚函数。这6个函数用于针对特定的示例来实现所需的具体功能。这种设定的好处是把初始化代码、消息处理等流程都统一实现在D3DApp类中,继而使我们可以把精力集中在特定例程中的关键代码之上。以下是对这6个框架方法的概述。

1.Initialize:通过此方法为程序编写初始化代码,例如分配资源、初始化对象和建立3D场景等。D3DApp类实现的初始化方法会调用InitMainWindow和InitDirect3D,因此,我们在自己实现的初始化派生方法中,应当首先像下面那样来调用D3DApp类中的初始化方法:

bool TestApp::Initialize()
{
  if(!D3DApp::Initialize ())
    return false;

  /* 其他的初始化代码请置于此 */
}

这样一来,我们的初始化代码才能访问到D3DApp类中的初始化成员。

2.MsgProc:该方法用于实现应用程序主窗口的窗口过程函数(procedure function)。一般来说,如果需要处理在D3DApp::MsgProc中没有得到处理(或者不能如我们所愿进行处理)的消息,只要重写此方法即可。该方法的实现在4.5.5节中有相应的讲解。此外,如果对该方法进行了重写,那么其中并未处理的消息都应当转交至D3DApp::MsgProc。

3.CreateRtvAndDsvDescriptorHeaps:此虚函数用于创建应用程序所需的RTV和DSV描述符堆。默认的实现是创建一个含有SwapChainBufferCount个RTV描述符的RTV堆(为交换链中的缓冲区而创建),以及具有一个DSV描述符的DSV堆(为深度/模板缓冲区而创建)。该方法的默认实现足以满足大多数的示例,但是,为了使用多渲染目标(multiple render targets)这种高级技术,届时仍将重写此方法。

4.OnResize:当D3DApp::MsgProc函数接收到WM_SIZE消息时便会调用此方法。若窗口的大小发生了改变,一些与工作区大小有关的Direct3D属性也需要随之调整。特别是后台缓冲区以及深度/模板缓冲区,为了匹配窗口工作区调整后的大小需要对其重新创建。我们可以通过调用IDXGISwapChain::ResizeBuffers方法来调整后台缓冲区的尺寸。对于深度/模板缓冲区而言,则需要在销毁后根据新的工作区大小进行重建。另外,渲染目标和深度/模板的视图也应重新创建。D3DApp类中OnResize方法实现的功能即为调整后台缓冲区和深度/模板缓冲区的尺寸,我们可直接查阅其源代码来研究相关细节。除了这些缓冲区以外,依赖于工作区大小的其他属性(如投影矩阵,projection matrix)也要在此做相应的修改。由于在调整窗口大小时,客户端代码可能还需执行一些它自己的逻辑代码,因此该方法亦属于框架的一部分。

5.Update:在绘制每一帧时都会调用该抽象方法,我们通过它来随着时间的推移而更新3D应用程序(如呈现动画、移动摄像机、做碰撞检测以及检查用户的输入等)。

6.Draw:在绘制每一帧时都会调用的抽象方法。我们在该方法中发出渲染命令,将当前帧真正地绘制到后台缓冲区中。当完成帧的绘制后,再调用IDXGISwapChain::Present方法将后台缓冲区的内容显示在屏幕上。

注意:除了上述6个框架方法之外,我们为了便于处理鼠标的按下、释放和移动事件,还分别提供了3个相关的虚函数: virtual void OnMouseDown(WPARAM btnState, int x, int y){ } virtual void OnMouseUp(WPARAM btnState, int x, int y) { } virtual void OnMouseMove(WPARAM btnState, int x, int y){ } 如此一来,若希望处理鼠标消息,我们只需重写这几种方法,而不必重写MsgProc方法。这3个处理鼠标消息方法的第一个参数都是WPARAM,它存储了鼠标按键的状态(即鼠标事件发生时,哪个键被按下)。第二和第三个参数则表示鼠标指针在工作区的坐标(x,y )。

4. 帧的统计信息

游戏和图形应用程序往往都会测量每秒渲染的帧数FPS作为一种画面流畅度的标杆。为此我们仅需统计在特定时段 t 内所处理的帧数(并将帧数存于变量 n 中)即可。因此,时段 t 内的平均FPS值即为 FPS=n/t 。如果设 t=1 ,那么 FPS=n/1=n 。在我们的代码中,实际所用的时段就是 t=1 (秒),这样做可省去一次除法运算。再者,用1秒为限会取到一个比较合理的平均值------这段时间不长不短,刚好合适。D3DApp::CalculateFrameStats方法提供了计算FPS的相关代码:

void D3DApp::CalculateFrameStats()
{
    // 这段代码计算了每秒的平均帧数,也计算了每帧的平均渲染时间
    // 这些统计值都会被附加到窗口的标题栏中

    static int frameCnt = 0;
    static float timeElapsed = 0.0f;

    frameCnt++;

    // 以1秒为统计周期来计算平均帧数以及每帧的平均渲染时间
    if( (mTimer.TotalTime() - timeElapsed) >= 1.0f )
    {
        float fps = (float)frameCnt; // fps = frameCnt / 1
        float mspf = 1000.0f / fps;

        wstring fpsStr = to_wstring(fps);
        wstring mspfStr = to_wstring(mspf);

        wstring windowText = mMainWndCaption +
           L"  fps: " + fpsStr +
           L"  mspf: " + mspfStr;
           SetWindowText(mhMainWnd, windowText.c_str());

           // 为计算下一组平均值而重置
           frameCnt = 0;
           timeElapsed += 1.0f;
    }
}

为了统计帧数,在每一帧中都要调用此方法。

除了计算FPS外,以上代码也统计了渲染一帧所花费的平均时间(以毫秒计):

float mspf = 1000.0f / fps;

每帧所花费的秒数即FPS的倒数,我们可通过将此倒数乘以1000 ms / 1 s来把单位从秒转换到毫秒(1s为1000ms)。

这一行代码的意思是计算渲染一帧画面所花费的毫秒数,这是一种与FPS截然不同的统计量(但是此值可由FPS推导出来)。事实上,知道渲染一帧所花费的时间要比了解FPS更为有效,因为随着场景的转换,我们通过前者就能直观地看出每一帧渲染时长的增减。而FPS却不能在场景改变后立即反映出渲染时间的变化。此外,正如[Dunlop03]在《FPS versus Frame Time(FPS vs. 帧时间)》一文中所指出的,由于FPS曲线图(FPS curve)的非线性特征,使得采用FPS进行分析可能会得到误导性的结果。例如,请考虑场景(一):假设我们的应用程序跑到了1000 FPS,利用1 ms(毫秒)就可以渲染1帧。那么,当帧率降到了250 FPS时,渲染1帧就要用4 ms。现在再来思考情景(二):设想我们的应用程序跑到100 FPS,花10 ms渲染1帧。如果帧率降到了76.9 FPS,那么渲染1帧将花费约13 ms。这两种情景中每帧的渲染时间都增加了3 ms,也就表示它们在渲染每一帧的过程中都增加了同样多的时间。然而,FPS所反映出的统计值却并不直观。虽然从1000 FPS跌到250 FPS看起来要比从100 FPS下降到76.9 FPS的幅度大得多,但诚如我们所看到的,它们渲染每帧所增加的时间实际上却是相同的。

5. 消息处理函数

我们对应用程序框架中的窗口过程进行了大量的简化工作。在一般情况下,本书的程序并不会过多地涉及Win32消息。事实上,应用程序的核心代码都是在空闲处理期间(即没有窗口消息可处理时)执行的。但是,仍有一些重要的消息需要我们亲自去处理。鉴于窗口过程代码的篇幅,我们并不打算将所有的代码都罗列于此,而是仅解释处理这些消息背后的动机。由于应用框架是本书所有示例的基石,所以我们鼓励读者下载其源代码文件,并花费些时间来熟悉它。

我们要处理的第一个消息是WM_ACTIVATE。当一个程序被激活(activate)或进入非活动状态(deactivate)时便会发送此消息。我们以下列方式来对它进行处理:

case WM_ACTIVATE:
  if( LOWORD(wParam) == WA_INACTIVE )
  {
    mAppPaused = true;
    mTimer.Stop();
  }
  else
  {
    mAppPaused = false;
    mTimer.Start();
  }
  return 0;

如您所见,当程序变为非活动状态时,我们会将数据成员mAppPaused设置为true,而当程序被激活时,则把数据成员mAppPaused设置为false。另外,当暂停使用应用程序时,我们就停止计时器,一旦程序被再次激活,再令计时器继续工作。如果回顾D3DApp::Run(4.4.3节)的实现,我们会发现:当程序暂停时,将不会再执行后续更新场景的代码,而是把空闲出来的CPU周期返还给操作系统。这样一来,我们的程序就不会在非活动的状态中占用CPU资源了。

下一个要处理的消息是WM_SIZE。前文曾提到过,当用户调整窗口的大小时便会产生此消息。处理这个消息的主要目的是:我们希望使后台缓冲区和深度/模板缓冲区的大小与工作区矩形范围的大小保持一致(从而使图像不会发生拉伸的现象)。所以,在每一次调整窗口大小的时候,我们都要记住改变缓冲区的尺寸。调整缓冲区尺寸的代码实现于D3DApp::OnResize方法之中。正如前文中所述,调用IDXGISwapChain::ResizeBuffers方法即可改变后台缓冲区的尺寸。而深度/模板缓冲区则需要在销毁之后,根据新的窗口尺寸来重新创建。此外,渲染目标和深度/模板的视图也需随之重建。对于用户拖动调整栏的操作,我们一定要小心对待,因为这个行为会连续发出WM_SIZE消息,但我们不希望随之连续调整缓冲区。因此,如若确定用户正在拖动边框调整窗口大小,我们理应什么也不做(暂停应用程序除外),直到用户完成调整操作后再执行修改缓冲区等操作。通过处理WM_EXITSIZEMOVE消息就可以实现这一点。这条消息会在用户释放调整栏时发送。

// 当用户抓取调整栏时发送WM_ENTERSIZEMOVE消息
case WM_ENTERSIZEMOVE:
  mAppPaused = true;
  mResizing = true;
  mTimer.Stop();
  return 0;

// 当用户释放调整栏时发送WM_EXITSIZEMOVE消息
// 此处将根据新的窗口大小重置相关对象(如缓冲区、视图等)
case WM_EXITSIZEMOVE:
  mAppPaused = false;
  mResizing = false;
  mTimer.Start();OnResize();
  return 0; 

下面3个消息的处理过程比较简单,我们直接来看代码:

// 当窗口被销毁时发送WM_DESTROY消息
case WM_DESTROY:
  PostQuitMessage(0);
  return 0;

// 当某一菜单处于激活状态,而且用户按下的既不是助记键(mnemonic key)也不是加速键
// (acceleratorkey)时,就发送WM_MENUCHAR消息
case WM_MENUCHAR:
  // 当按下组合键alt-enter时不发出beep蜂鸣声
  return MAKELRESULT(0, MNC_CLOSE);

// 捕获此消息以防窗口变得过小
case WM_GETMINMAXINFO:
  ((MINMAXINFO*)lParam)->ptMinTrackSize.x = 200;
  ((MINMAXINFO*)lParam)->ptMinTrackSize.y = 200; 
  return 0;

为了使用GET_X_LPARAM和GET_Y_LPARAM两个宏,我们必须引入#include <Windowsx.h>。

6. 初始化Direct3D演示程序

应用框架已经讨论完毕,现在就让我们用它来实现一个小程序。在这个程序中,我们基本不用去做什么,因为父类D3DApp几乎替我们完成了所有的工作。在这里,我们的主要任务是从D3DApp中派生出自己的类,实现框架函数并在此为示例编写特定的代码。本书的所有程序都将遵循下面的模板。

#include "../../Common/d3dApp.h"
#include <DirectXColors.h>

using namespace DirectX;

class InitDirect3DApp : public D3DApp
{
public:
  InitDirect3DApp(HINSTANCE hInstance);
  ~InitDirect3DApp();

  virtual bool Initialize()override;

private:
  virtual void OnResize()override;
  virtual void Update(const GameTimer& gt)override;
  virtual void Draw(const GameTimer& gt)override;
};

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE prevInstance,
                         PSTR cmdLine, int showCmd)
{
  // 为调试版本开启运行时内存检测,方便监督内存泄露的情况
#if defined(DEBUG) | defined(_DEBUG)
  _CrtSetDbgFlag( _CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF );
#endif

  try
  {
    InitDirect3DApp theApp(hInstance);
    if(!theApp.Initialize())
      return 0;

    return theApp.Run();
  }
  catch(DxException& e)
  {
    MessageBox(nullptr, e.ToString().c_str(), L"HR Failed", MB_OK);
    return 0;
  }
}

InitDirect3DApp::InitDirect3DApp(HINSTANCE hInstance)
: D3DApp(hInstance) 
{
}

InitDirect3DApp::~InitDirect3DApp()
{
}

bool InitDirect3DApp::Initialize()
{
  if(!D3DApp::Initialize())
    return false;

  return true;
}

void InitDirect3DApp::OnResize()
{
  D3DApp::OnResize();
}

void InitDirect3DApp::Update(const GameTimer& gt)
{
}

void InitDirect3DApp::Draw(const GameTimer& gt)
{
  // 重复使用记录命令的相关内存
  // 只有当与GPU关联的命令列表执行完成时,我们才能将其重置
  ThrowIfFailed(mDirectCmdListAlloc->Reset());

  // 在通过ExecuteCommandList方法将某个命令列表加入命令队列后,我们便可以重置该命令列表。以
  // 此来复用命令列表及其内存
  ThrowIfFailed(mCommandList->Reset(
    mDirectCmdListAlloc.Get(), nullptr));

  // 对资源的状态进行转换,将资源从呈现状态转换为渲染目标状态
  mCommandList->ResourceBarrier(
    1, &CD3DX12_RESOURCE_BARRIER::Transition(
    CurrentBackBuffer(),
    D3D12_RESOURCE_STATE_PRESENT, 
    D3D12_RESOURCE_STATE_RENDER_TARGET));

  // 设置视口和裁剪矩形。它们需要随着命令列表的重置而重置
  mCommandList->RSSetViewports(1, &mScreenViewport);
  mCommandList->RSSetScissorRects(1, &mScissorRect);

  // 清除后台缓冲区和深度缓冲区
  mCommandList->ClearRenderTargetView(
    CurrentBackBufferView(), 
    Colors::LightSteelBlue, 0, nullptr);
  mCommandList->ClearDepthStencilView(
    DepthStencilView(), D3D12_CLEAR_FLAG_DEPTH | 
    D3D12_CLEAR_FLAG_STENCIL, 1.0f, 0, 0, nullptr);

  // 指定将要渲染的缓冲区
  mCommandList->OMSetRenderTargets(1, &CurrentBackBufferView(), 
    true, &DepthStencilView());

  // 再次对资源状态进行转换,将资源从渲染目标状态转换回呈现状态
  mCommandList->ResourceBarrier(
    1, &CD3DX12_RESOURCE_BARRIER::Transition(
    CurrentBackBuffer(),
    D3D12_RESOURCE_STATE_RENDER_TARGET,
    D3D12_RESOURCE_STATE_PRESENT));

  // 完成命令的记录
  ThrowIfFailed(mCommandList->Close());

  // 将待执行的命令列表加入命令队列
  ID3D12CommandList* cmdsLists[] = { mCommandList.Get() };
  mCommandQueue->ExecuteCommandLists(_countof(cmdsLists), cmdsLists);

  // 交换后台缓冲区和前台缓冲区
  ThrowIfFailed(mSwapChain->Present(0, 0));
  mCurrBackBuffer = (mCurrBackBuffer + 1) % SwapChainBufferCount;

  // 等待此帧的命令执行完毕。当前的实现没有什么效率,也过于简单
  // 我们在后面将重新组织渲染部分的代码,以免在每一帧都要等待
  FlushCommandQueue();
}

其中的一些方法我们还没有讨论过。ClearRenderTargetView方法会将指定的渲染目标清理为给定的颜色,ClearDepthStencilView方法则用于清理指定的深度/模板缓冲区。在每帧为了刷新场景而开始绘制之前,我们总是要清除后台缓冲区渲染目标和深度/模板缓冲区。这两个方法的声明如下。

void ID3D12GraphicsCommandList::ClearRenderTargetView( 
  D3D12_CPU_DESCRIPTOR_HANDLE RenderTargetView,
  const FLOAT ColorRGBA[ 4 ],
  UINT NumRects,
  const D3D12_RECT *pRects);

1.RenderTargetView:待清除的资源RTV。

2.ColorRGBA:定义即将为渲染目标填充的颜色。

3.NumRects:pRects数组中的元素数量。此值可以为0。

4.pRects:一个D3D12_RECT类型的数组,指定了渲染目标将要被清除的多个矩形区域。若设定此参数为nullptr,则表示清除整个渲染目标。

void ID3D12GraphicsCommandList::ClearDepthStencilView( 
  D3D12_CPU_DESCRIPTOR_HANDLE DepthStencilView,
  D3D12_CLEAR_FLAGS ClearFlags,
  FLOAT Depth,
  UINT8 Stencil,
  UINT NumRects,
  const D3D12_RECT *pRects);

1.DepthStencilView:待清除的深度/模板缓冲区DSV。

2.ClearFlags:该标志用于指定即将清除的是深度缓冲区还是模板缓冲区。我们可以将此参数设置为D3D12_CLEAR_FLAG_DEPTH或D3D12_CLEAR_FLAG_STENCIL,也可以用按位或运算符连接两者,表示同时清除这两种缓冲区。

3.Depth:以此值来清除深度缓冲区。

4.Stencil:以此值来清除模板缓冲区。

5.NumRects:pRects数组内的元素数量。可以将此值设置为0。

6.pRects:一个D3D12_RECT类型的数组,用以指定资源视图将要被清除的多个矩形区域。将此值设置为nullptr,则表示清除整个渲染目标。

另一个新出现的方法是ID3D12GraphicsCommandList::OMSetRenderTargets,通过此方法即可设置我们希望在渲染流水线上使用的渲染目标和深度/模板缓冲区。(到目前为止,我们仅是把当前的后台缓冲区作为渲染目标,并只设置了一个主深度/模板缓冲区。但在本书的后续章节里,我们还将运用多渲染目标技术)。此方法的原型如下。

void ID3D12GraphicsCommandList::OMSetRenderTargets( 
  UINT NumRenderTargetDescriptors,
  const D3D12_CPU_DESCRIPTOR_HANDLE *pRenderTargetDescriptors,
  BOOL RTsSingleHandleToDescriptorRange,
  const D3D12_CPU_DESCRIPTOR_HANDLE *pDepthStencilDescriptor);

1.NumRenderTargetDescriptors:待绑定的RTV数量,即pRenderTargetDescriptors数组中的元素个数。在使用多渲染目标这种高级技术时会涉及此参数。就目前来说,我们总是使用一个RTV。

2.pRenderTargetDescriptors:指向RTV数组的指针,用于指定我们希望绑定到渲染流水线上的渲染目标。

3.RTsSingleHandleToDescriptorRange:如果pRenderTargetDescriptors数组中的所有RTV对象在描述符堆中都是连续存放的,就将此值设为true,否则设为false。

4.pDepthStencilDescriptor:指向一个DSV的指针,用于指定我们希望绑定到渲染流水线上的深度/模板缓冲区。

最后要通过IDXGISwapChain::Present方法来交换前、后台缓冲区。与此同时,我们也必须对索引进行更新,使之一直指向交换后的当前后台缓冲区。这样一来,我们才可以正确地将下一帧场景渲染到新的后台缓冲区。

ThrowIfFailed(mSwapChain->Present(0, 0));
mCurrBackBuffer = (mCurrBackBuffer + 1) % SwapChainBufferCount;

六、调试Direct3D应用程序

大多数的Direct3D函数会返回HRESULT错误码。我们的示例程序实则采用简单的错误处理机制来检测返回的HRESULT值。如果检测失败,则抛出异常,显示调用出错的错误码、函数名、文件名以及发生错误的行号。这些操作具体由d3dUtil.h中的代码实现:

class DxException
{
public:
  DxException() = default;
  DxException(HRESULT hr, const std::wstring& functionName, 
    const std::wstring& filename, int lineNumber);

  std::wstring ToString()const;

  HRESULT ErrorCode = S_OK;
  std::wstring FunctionName;
  std::wstring Filename;
  int LineNumber = -1;
};

#ifndef ThrowIfFailed
#define ThrowIfFailed(x) \
{ \
  HRESULT hr__ = (x); \
  std::wstring wfn = AnsiToWString(__FILE__); \
  if(FAILED(hr__)) { throw DxException(hr__, L#x, wfn, __LINE__); } \
}
#endif

不难看出ThrowIfFailed必定是一个宏,而不是一个函数;若非如此,__FILE__和__LINE__将定位到ThrowIfFailed所在的文件与行,而非出错函数的文件与行。L#x会将宏ThrowIfFailed的参数转换为Unicode字符串。这样一来,我们就能将函数调用所产生的错误信息输出到消息框当中。

对于Direct3D函数返回的HRESULT值,我们是这样使用宏对其进行检测的:

ThrowIfFailed(md3dDevice->CreateCommittedResource(
  &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
  D3D12_HEAP_FLAG_NONE,
  &depthStencilDesc,
  D3D12_RESOURCE_STATE_COMMON,
  &optClear,
  IID_PPV_ARGS(mDepthStencilBuffer.GetAddressOf())));

整个程序逻辑都位于一个try/catch块之中:

 try
  {
    InitDirect3DApp theApp(hInstance);
    if(!theApp.Initialize())
      return 0;

    return theApp.Run();
  }
  catch(DxException& e)
  {
    MessageBox(nullptr, e.ToString().c_str(), L"HR Failed", MB_OK);
    return 0;
  }

如果返回的HRESULT是个错误值,则抛出异常,通过MessageBox函数输出相关信息,并退出程序。例如,在向CreateCommittedResource方法传递了一个无效参数时,我们便会看到如下图的消息框。

当返回的HRESULT是个错误码时,会弹出消息框并显示类似的错误信息

七、小结

1.可以把Direct3D 看作是一种介于程序员和图形硬件之间的"桥梁"。借此,程序员便可以通过调用Direct3D函数来实现把资源视图绑定到硬件渲染流水线、配置渲染流水线的输出以及绘制3D几何体等操作。

2.组件对象模型(COM)是一种可以令DirectX不依赖于特定语言且向后兼容的技术。Direct3D程序员不需知道COM的具体实现细节,也无需了解其工作原理,只需知晓如何获取和释放COM接口即可。

3.1D、2D、3D纹理分别类似于由数据元素所构成的1D、2D、3D数组。纹理元素的格式必定为DXGI_FORMAT枚举类型中的成员之一。除了常见的图像数据,纹理也能存储像深度信息等其他类型的数据(如深度缓冲区就是一种存储深度值的纹理)。GPU可以对纹理进行特殊的操作,比如运用过滤器和进行多重采样。

4.为了避免动画中发生闪烁的问题,最好将动画帧完全绘制到一种称为后台缓冲区的离屏纹理中。只要依此行事,显示在屏幕上的就会是一个完整的动画帧,观者也就不会察觉到帧的绘制过程。当动画帧被绘制在后台缓冲区后,前台缓冲区与后台缓冲区的角色也就该互换了:为了显示下一帧动画,此前的后台缓冲区将变为前台缓冲区,而此前的前台缓冲区亦会变成后台缓冲区。后台和前台缓冲区交换角色的行为称为呈现(present)。前台和后台缓冲区构成了交换链,在代码中通过IDXGISwapChain接口来表示。使用两个缓冲区(前台和后台)的情况称作双缓冲。

5.假设场景中有一些不透明的物体,那么离摄像机最近的物体上的点便会遮挡住它后面一切物体上的对应点。深度缓冲就是一种用于确定在场景中离摄像机最近点的技术。通过这种技术,我们就不必再担心场景中物体的绘制顺序了。

6.在Direct3D中,资源不能直接与渲染流水线相绑定。为此,我们需要为绘制调用时所引用的资源指定描述符。我们可将描述符对象看作是GPU识别以及描述资源的一种轻量级结构体。而且,我们还可以为同一种资源创建不同的描述符。如此一来,一种资源就可以具有多种用途。例如,我们可以借此将同一种资源绑定到渲染流水线的不同阶段,或者用不同的DXGI_FORMAT成员将它描述为不同的格式。应用程序可通过创建描述符堆来为描述符分配所需的内存。

7.ID3D12Device是Direct3D中最重要的接口,我们可以把它看作是图形硬件设备的软件控制器。我们能够通过它来创建GPU资源以及其他用于控制图形硬件的特定接口。

8.每个GPU中都至少有一个命令队列。CPU可通过Direct3D API用命令列表向该队列提交命令,而这些命令则指挥GPU执行某些操作。在命令没有到达队列首部以前,用户所提交的命令是无法被执行的。如果命令队列内为空,则GPU会因为没有任务要去处理而处于空闲状态;但若命令队列被装得太满,则CPU将在某个时刻因提交命令的速度追上GPU执行命令的速度而进入空闲状态。值得一提的是,这两种情景其实都没有充分地利用系统资源。

9.GPU是系统中与CPU一起并行工作的第二种处理器。有时,我们需要对CPU与GPU进行同步。例如,若GPU命令队列中有一条引用某资源的命令,那么在GPU完成此命令的处理之前,CPU就不能修改或销毁这一资源。任何同步方法都会导致其中的一种处理器处于一段等待和空闲的状态,这意味着两种处理器并没有被充分利用,因此,我们应尽量减少同步的次数,并缩短同步的时间。

10.性能计数器是一种高精度的计时器,它是测量微小时间差的一种有效工具。例如,我们可以用它来测量两帧之间的间隔时间。性能计时器使用的时间单位称为计数(count)。QueryPerformanceFrequency函数输出的是性能计时器每秒的计数,可用它将计数单位转换为秒。性能计时器的当前时间值(以计数为单位测量)可用QueryPerformanceCounter函数获得。

11.通过统计时间段内处理的帧数即可计算出每秒的平均帧数(FPS)。设 n 为时间 Δt 内处理的帧数,那么该时间段内每秒的平均帧数为 FPS=n/Δt 。采用帧率进行考量可能会对性能造成一些误判,相对而言,"处理一帧所花费时间"这个统计信息可能更加精准、直观。以秒为单位表示的每帧平均处理时间可以用帧率的倒数来计算,即 1/FPS 。

12.示例框架为本书的全部例程都提供了统一的接口。d3dUtil.h、d3dUtil.cpp、d3dApp.h和d3dApp.cpp文件封装了所有应用程序必须实现的标准初始化代码。封装这些代码便隐藏了相关的细节,这样的话,我们就可以将精力集中在不同示例的特定主题之上。

13.为了开启调试模式需要启用调试层(debugController->EnableDebugLayer())。如此一来,Direct3D就会把调试信息发往VC++的输出窗口。

相关推荐
闻缺陷则喜何志丹12 分钟前
【C++动态规划】1547. 切棍子的最小成本|2116
c++·算法·动态规划·力扣·最小·成本·棍子
兵哥工控36 分钟前
MFC读写文件实例
c++·mfc
c-c-developer2 小时前
C++Primer const限定符
c++
不是仙人的闲人5 小时前
数据结构之栈和队列
数据结构·c++
重生之我是数学王子5 小时前
内核链表 例题 C语言实现
linux·c++
salsm11 小时前
使用 C++ 和函数式编程构建高效的 AI 模型
c++·人工智能
程序猿(雷霆之王)11 小时前
C++——继承
开发语言·c++
xianwu54311 小时前
mysql入门篇
开发语言·网络·c++·git
qq_140303414413 小时前
数据结构9.3 - 文件基础(C++)
数据结构·c++
Lenyiin14 小时前
第431场周赛:最长乘积等价子数组、计算字符串的镜像分数、收集连续 K 个袋子可以获得的最多硬币数量、不重叠区间的最大得分
c++·算法·leetcode·周赛·lenyiin