DX12 快速教程(2) —— 渲染天蓝色窗口


新建项目 “002-DrawSkyblueWindow”


  • 打开原来的解决方案 “DX12”,右键 “解决方案” -> “添加” -> “新建项目”

在这里插入图片描述

  • 选"空项目" -> 项目名称为 “002-DrawSkyblueWindow” -> “创建”

在这里插入图片描述
在这里插入图片描述

  • 右键刚刚创建的 “002-DrawSkyblueWindow” -> “设为启动项目”

在这里插入图片描述


  • 右键项目 -> “链接器” -> “系统” -> “子系统” -> 选择"窗口" -> 按"确定"

在这里插入图片描述
在这里插入图片描述

  • 右键项目 -> “添加” -> “新建项” -> 命名为"main.cpp" -> “添加”

在这里插入图片描述
在这里插入图片描述

  • 将上一节的代码复制到本项目的 main.cpp 上

在这里插入图片描述

之后新建项目都是重复这里的操作即可。



DirectX 12 入门


后文提到的 DX12、D3D12 都是对 Direct 3D 12 (DirectX 12) 的简称,表示它是 微软 DirectX 3D 的第12代技术。


1. COM 技术:DirectX 的中流砥柱


什么是 COM 技术


COM (Component Object Model,组件对象模型) 技术是微软推出的一种组件编程模型,它支持可重用的组件开发,并具有跨语言和跨平台的特性。



什么是COM组件技术?插件技术就是COM技术,COM技术,其实是程序员想偷懒才产生的,因为它不仅可重用,方便更新和维护;而且一旦编写出来,可以被各种编程语言所使用

以C++为例,COM组件实际上就是一些实现了特定接口的类,而接口都是抽象类组件从接口派生而来。我们可以简单的用C++的语法形式来描述COM是个什么东西:

class IObject		// 接口,这个类是抽象类,不能实例化
{
  public:
    virtual Function1() = 0;
    virtual Function2() = 0;
};
  
class MyObject : public IObject		// 组件,继承并实现接口
{
  public:		// 实现接口的纯虚函数
    virtual Function1(){}
    virtual Function2(){}
}; 

看清楚了吗?IObject 就是我们常说的接口,MyObject 就是所谓的COM组件。切记切记接口都是纯虚类,它所包含的函数都是纯虚函数,而且它没有成员变量。而COM组件就是从这些纯虚类继承下来的派生类,它实现了这些虚函数,仅此而已。从上面也可以看出,COM组件是以 C++为基础的,特别重要的是虚函数多态性的概念,COM中所有函数都是虚函数,都必须通过虚函数表VTable来调用,这一点是无比重要的,必需时刻牢记在心。

COM组件由以 Win 32动态链接库(DLL)可执行文件(EXE)形式发布的可执行代码所组成。DirectX 家族都是基于 COM技术的,这就是我们包含了头文件还要链接 DLL 的原因:

#pragma comment(lib,"d3d12.lib")	// 链接 DX12 核心 DLL
#pragma comment(lib,"dxgi.lib")		// 链接 DXGI DLL
#pragma comment(lib,"dxguid.lib")	// 链接 DXGI 必要的设备 GUID

COM 技术涉及的原理非常复杂,本文不再详细展开,感兴趣可以百度查阅一下相关资料


COM 智能指针


在这里插入图片描述


COM技术不仅规定了组件“如何写”,还规定了这些组件“如何用”。COM组件有严格的生命周期管理,注册和卸载服务稍有不慎就会写错,造成程序错误甚至崩溃。

为了解决这个问题,实现 COM 组件生命周期的自动管理,微软在 WRL库 (Windows Runtime C++ Template Library,Windows 运行时 C++ 模板库) 提供了一套 C++ 风格的 COM 智能指针模板

ComPtr<ID3D12Device4> m_D3D12Device; 

COM接口都以大写字母 “I” 开头。使用 ComPtr<COM接口类型> 变量名 可以轻松创建一个 COM 组件,这是下文 DirectX 12 的基础。


在这里插入图片描述

COM接口后面的数字表示它的版本,高版本接口和低版本接口共用一套 DLL (COM组件的基础是 DLL,DLL 方便远程维护和更新)
有时候常规方法是不能直接创建高版本接口的,需要通过继承低版本接口对象的数据来创建。


被这个模板包裹的组件主要有以下成员方法:

方法名说明示例 (以 ComPtr<T> comp 为例)
ComPtr<T> comp;COM 智能指针对象相当于 T* comp;
.Get()返回指向此底层COM接口的一级指针,常常用于函数的输入参数comp.Get() 等价于 comp
.GetAddressOf()返回指向此底层COM接口指针的地址 (二级指针),常常用于函数的输出参数comp.GetAddressOf() 等价于 &comp
.Reset()重置对象,等价于将 ComPtr 对象赋值为 nullptrcomp.Reset() 等价于 comp = nullptr
&返回重置后的对象指针,相当于调用了 .ReleaseAndGetAddressOf() 方法
常用于创建一个新的COM组件对象,此方法会重置原来的对象,慎用
&comp 等价于
comp.Reset() + comp.GetAddressOf()
->调用底层COM接口指针的具体成员方法调用 comp 里面具体的成员方法 comp->func()
.As(&Interface)查询对应接口 Interface 的实现,相当于 QueryInterface()
常常用于数据继承到高版本接口,或使用原有接口创建相关设备
T3继承于T,是T的高版本接口
创建 T3 对象 comp.As(&T3_comp)

2.创建 D3D12 调试层设备:CreateDebugDevice


进入正篇之前,我们还需要初始化所有的 COM 接口指针:

::CoInitialize(nullptr);	// 注意这里!DX12 的所有设备接口都是基于 COM 接口的,我们需要先全部初始化为 nullptr,否则会抛出组件引用错误!

什么是调试层


为了方便调试查找错误,从 DirectX 10 开始,设计者把渲染和调试分离成两层:用于 3D 图形渲染的叫核心层,用于调试的叫调试层



调试层对于做 3D 程序非常重要,它能在程序调试运行时输出调试信息,在下面的输出窗口提供优化建议与报错提示,帮助我们更快定位和纠正错误

发生了可能导致程序 Crash (崩溃) 的重大错误就会输出 D3D12 ERROR 错误,有时会强制移除核心层设备,防止程序继续运行导致系统崩溃:


在这里插入图片描述


发生了可能影响程序后续运行的行为就会输出 D3D12 WARNING 警告,如果不正确处理,警告也可能会变成错误:


在这里插入图片描述


输出窗口字体太难看?推荐看这篇教程: VS2022 自定义字体大小 - Sky-stars 的博客


如何创建并使用调试层


使用调试层接口 ID3D12Debug 创建设备,然后使用里面的成员方法 EnableDebugLayer() 开启调试层:

	ComPtr<ID3D12Debug> m_D3D12DebugDevice;				// D3D12 调试层设备
	UINT m_DXGICreateFactoryFlag = NULL;				// 创建 DXGI 工厂时需要用到的标志
	
	::CoInitialize(nullptr);	// 注意这里!DX12 的所有设备接口都是基于 COM 接口的,我们需要先全部初始化为 nullptr

#if defined(_DEBUG)		// 如果是 Debug 模式下编译,就执行下面的代码

	// 获取调试层设备接口
	D3D12GetDebugInterface(IID_PPV_ARGS(&m_D3D12DebugDevice));
	// 开启调试层
	m_D3D12DebugDevice->EnableDebugLayer();
	// 开启调试层后,创建 DXGI 工厂也需要 Debug Flag
	m_DXGICreateFactoryFlag = DXGI_CREATE_FACTORY_DEBUG;

#endif

如果创建调试层时抛出访问到 NULL 指针的错误,输出窗口出现“找不到 d3d12sdklayer.dll”,请回看教程第一节的“安装必要组件”DX12 快速教程(1) —— 做窗口


3.创建 D3D12 设备:CreateDevice


认识 CPU 和 GPU




CPU 是英文“Central Processing Unit”的缩写,翻译成中文是“中央处理单元”它是电脑(计算机)的控制核心,是计算机的"大脑"。从用户按下电脑的开机键那一刻起,电脑进行的每一步操作,都离不开 CPU 的参与,它是电脑的核心部件,主要负责电脑系统的运算、控制、处理、执行,无论用户使用计算机干什么,哪怕是打一个字母或一个汉字,都必须通过 CPU 来完成。

相比 CPU,GPU(Graphics Processing Unit,GPU,图像处理单元)更多的是专注于图像计算。GPU 与 CPU 的架构不同,它不能像 CPU 那样可以执行复杂的命令。相反,它可以批量处理简单命令(例如矩阵运算,向量张量运算等等)并行运算是 GPU 最大的特点。GPU 更多的是一个优秀的助手,可以弥补 CPU 对于海量数据计算 (尤其是 3D 渲染科学计算) 的天生缺陷,从而让 CPU 专注于关键命令的执行。


举个不恰当的例子,把 CPU 比作一台摩托车,GPU 比作一辆公交车


现在路上塞车,一个胖子和一个瘦子想要搭车从A地到B地,摩托车和公交车谁快?答案是摩托车,一次就能将两人送到目的地,而且比公交车更快。


但是,现在有一群人想要搭车,摩托车还是一个好的选择吗?摩托车搭一群人可费劲多了,要往返很多次。但公交车搭他们,只需要一次,花的时间还更少。


把搭载的乘客类比于命令,CPU 和 GPU 擅长的领域是不同的,CPU 更适合串行处理各种复杂的命令,在处理日常办公、编程、数据库管理等任务时游刃有余;而 GPU 更适合并行处理大量、重复的数据运算,尤其是图形渲染深度学习等需要大规模并行计算的任务。


认识显卡


NVIDIA RTX 3080

如图所示,这就是显卡。显卡主要承担输出显示图形的任务,性能好的显卡还支持并行运算,可以用于科学计算和 AI 深度学习

显卡分为两种:集成显卡、独立显卡。



  • 集成显卡


集成显卡,简单来说,就是直接集成在主板或者 CPU 处理器里的显卡兼有 CPU 和 GPU 的功能(不过 GPU 性能很差)。它就像是电脑里的一个“兼职员工”,除了干好自己的本职工作——显示图像外,还得帮 CPU 处理器分担一些其他任务。

集显的性能一般都很低,专用显示内存(显存)一般只有 128 MB,支持的 DirectX 版本也只是达到"刚好能够兼容"的级别(DX12 最低支持到 11.0 版本,11.011.1 版本支持的特性不多)。如果你喜欢玩一些大型3D游戏,或者需要进行一些专业的图形设计、视频剪辑等工作,集成显卡可能就有点力不从心了。它可能会让你的游戏画面卡顿、延迟,或者让你的设计作品看起来不够细腻、流畅。


在这里插入图片描述


常见的集显有 Intel 芯片自带的 Intel HD 系列 和 集成于 AMD 处理器的 AMD Radeon Graphics 系列


在这里插入图片描述

Intel HD Graphics 集显

AMD Radeon Graphics 集显

  • 独立显卡


接下来再看看独立显卡。独立显卡,顾名思义,就是一块独立的显卡,它有自己的处理器(GPU)、内存(显存)和散热系统。它就像是电脑里的一个“专业团队”,专门负责处理图像显示的任务。因为独立显卡是独立的,所以它的性能通常比集成显卡要强得多,专用显存更大。它可以轻松应对那些对图形处理要求很高的游戏和应用程序,让你的游戏体验更加流畅,设计作品更加精美。

DX12 设计的目的就是在软件层面上发掘独显的最大潜能,逼近独显性能的极限。所以相对于集显,独显能支持的 DX12 版本更多(多了 12.112.0),支持的功能也随之增加。


在这里插入图片描述

常见的独显有 NVIDIA 的 NVIDIA RTX 系列(俗称N卡)和 AMD 的 AMD Radeon RX 系列(俗称A卡):


NVIDIA RTX 4080 SUPER

在这里插入图片描述

AMD Radeon RX 6950

要查看你设备的 Direct 3D 配置,可以按 WIN + R,然后输入 dxdiag,就可以查看当前你的显卡对 Direct 3D 的支持程度了。


DXGI:软件与硬件之间的桥梁


回到 DX12 部分,DX12 的渲染需要 GPU 硬件。那么软件层面的 Direct 3D 接口,是如何与硬件层面的显卡和图形驱动联系起来呢?

DirectX 是一个很庞大的家族,包含了 3D 图形渲染 (Direct 3D),2D 图形渲染 (Direct 2D),音频合成 (Xaudio2),文本字体处理 (DirectWrite),手柄管理 (XInput) 等等,这些组件依赖的底层硬件和驱动在每台电脑中各不相同,DirectX 的设计者们为了能统一管理这些硬件和驱动,写出了一套规范化的 API 接口:DXGI (DirectX Graphics Infrastructure,DirectX 图形基础结构)




DX12 需要依赖 DXGI 提供的接口,找到对应的显卡(也叫显示适配器,Display Adapter),用这个显卡来创建核心层设备并渲染:


在这里插入图片描述

右键"此电脑" -> 点击"管理" -> "设备管理器" -> "显示适配器" 可以见到电脑上的所有显卡

首先,我们需要使用 CreateDXGIFactory2() 来创建一个 IDXGIFactory5 对象,这个 DXGI 工厂是 DXGI 的核心设备:

ComPtr<IDXGIFactory5> m_DXGIFactory;				// DXGI 工厂

// 创建 DXGI 工厂
CreateDXGIFactory2(m_DXGICreateFactoryFlag, IID_PPV_ARGS(&m_DXGIFactory));

m_DXGICreateFactoryFlag用来创建 DXGI 工厂的标志,变量定义在 如何创建并使用调试层
如果开启了 DX12 的调试层,这个 Flag 必须指定为 DXGI_CREATE_FACTORY_DEBUG,否则会创建失败。
后两个参数 riidppFactory 分别表示创建工厂需要用到的 GUID值 指向对象的二级指针
这个 GUID (Globally Unique Identifier,全局唯一标识符) 相当于 COM 接口的身份证,它标识了这个接口的身份。这个 GUID 值是不可能重复的,从而保证不可能有第二个重复的接口,这个接口的身份是唯一的。
后续创建接口都会有 riidppFactory,我们可以使用 IID_PPV_ARGS(&comp) 来简化接口的创建,让编译器来帮我们进行等价替换,提高开发效率。


创建 D3D12 核心设备


在这里插入图片描述


我们需要创建 DX12 的核心设备:ID3D12Device4

  1. 通过 IDXGIFacory->EnumAdapters1 枚举合适的显卡,获得 IDXGIAdapter1 显卡 (显示适配器) 对象
  2. 用这个 IDXGIAdapter1 通过 D3D12CreateDevice 创建 ID3D12Device4 核心设备
ComPtr<IDXGIAdapter1> m_DXGIAdapter;				// 显示适配器 (显卡)
ComPtr<ID3D12Device4> m_D3D12Device;				// D3D12 核心设备

// 枚举 0 号显卡 (一般都是性能最高的显卡),创建 Adapter 显卡对象
m_DXGIFactory->EnumAdapters1(0, &m_DXGIAdapter);
// 创建 D3D12 设备
D3D12CreateDevice(m_DXGIAdapter.Get(), D3D_FEATURE_LEVEL_12_1, IID_PPV_ARGS(&m_D3D12Device));

D3D12CreateDevice 创建 D3D12 核心设备对象
第一个参数 pAdapter 经过枚举后的显卡对象
第二个参数 MinimumFeatureLevel D3D12设备要支持的最低版本,如果超过了 pAdapter 支持的最高版本,就会创建失败
返回值是 HRESULT 一个表示状态的整数值,创建成功会返回 S_OK,否则会返回其他值(具体错误可看微软文档)


问题来了,我们用的时候一般都不会在意显卡最高支持 DX12 到什么版本,上文的 D3D_FEATURE_LEVEL_12_1 要求最低版本需要支持到 12.1。问题是我并不知道 0 号显卡是啥,我电脑没有独显,支持不了这么高版本,这些都会导致设备创建失败,怎么办?

我们可以对每一个显卡,从高版本到低版本循环遍历,如果创建成功就直接返回:

ComPtr<IDXGIFactory5> m_DXGIFactory;				// DXGI 工厂
ComPtr<IDXGIAdapter1> m_DXGIAdapter;				// 显示适配器 (显卡)
ComPtr<ID3D12Device4> m_D3D12Device;				// D3D12 核心设备
bool isSucceed = false;								// 是否成功创建设备

// 创建 DXGI 工厂
CreateDXGIFactory2(m_DXGICreateFactoryFlag, IID_PPV_ARGS(&m_DXGIFactory));

// DX12 支持的所有功能版本,你的显卡最低需要支持 11.0
const D3D_FEATURE_LEVEL dx12SupportLevel[] =
{
	D3D_FEATURE_LEVEL_12_2,		// 12.2
	D3D_FEATURE_LEVEL_12_1,		// 12.1
	D3D_FEATURE_LEVEL_12_0,		// 12.0
	D3D_FEATURE_LEVEL_11_1,		// 11.1
	D3D_FEATURE_LEVEL_11_0		// 11.0
};


// 用 EnumAdapters1 先遍历电脑上的每一块显卡
// 每次调用 EnumAdapters1 找到显卡会自动创建 DXGIAdapter 接口,并返回 S_OK
// 找不到显卡会返回 ERROR_NOT_FOUND

for (UINT i = 0; m_DXGIFactory->EnumAdapters1(i, &m_DXGIAdapter) != ERROR_NOT_FOUND; i++)
{
	// 找到显卡,就创建 D3D12 设备,从高到低遍历所有功能版本,创建成功就跳出
	for (const auto& level : dx12SupportLevel)
	{
		// 创建 D3D12 核心层设备,创建成功就跳出循环
		if (SUCCEEDED(D3D12CreateDevice(m_DXGIAdapter.Get(), level, IID_PPV_ARGS(&m_D3D12Device))))
		{
			isSucceed = true;
			break;			// 跳出小循环
		}
	}
	if(isSucceed) break;	// 跳出大循环
}

// 如果找不到任何能支持 DX12 的显卡,就退出程序
if (m_D3D12Device == nullptr)
{
	MessageBox(NULL, L"找不到任何能支持 DX12 的显卡,请升级电脑上的硬件!", L"错误", MB_OK | MB_ICONERROR);
	exit(0);
}

4.创建命令三件套:CreateCommandComponents


认识命令三件套


前文我们提到,GPU 是专用于图像计算的,负责执行图形渲染命令,画东西。但我们写代码是在 CPU 上写的,C++ 代码在 CPU 端上运行,我们需要通知 GPU ,让它执行渲染命令,画东西:


在这里插入图片描述


问题来了,CPUGPU 是两个相互独立的单元如何记录渲染命令?如何将渲染命令发送给 GPU?如何通知 GPU 让它执行渲染命令?

为了解决上述问题,DX12 设计了三个东西:命令列表 (CommandList),命令分配器 (CommandAllocator),命令队列 (CommandQueue)。

  • 命令列表 (CommandList):它是命令的记录者,用来在 CPU 记录需要执行的命令,记录的命令会存储在命令分配器中。命令列表有两种状态,一种叫 Record 录制状态,用于录制命令;另一种叫 Close 关闭状态,用于关闭录制,等待提交到命令队列。命令列表有很多种类型,包括 用于3D渲染的 图形命令列表 (GraphicsCommandList)、用于复制命令的 复制命令列表 (CopyCommandList)、用于音视频解码的 视频命令列表 (VideoCommandList) 等等。
  • 命令分配器 (CommandAllocator):它是命令的存储容器,负责绑定命令列表,存储命令列表中的命令。它位于 CPU 端的共享内存 上,所以可以被 GPU 端读取、引用里面的命令。一个命令分配器可以绑定多个命令列表
  • 命令队列 (CommandQueue):它位于 GPU 端,是命令的执行者,负责读取并执行 命令分配器 中的命令。它会从头到尾一条一条地执行命令,像数据结构中的 队列。所以叫 命令队列

在这里插入图片描述

一个命令分配器可以绑定多个命令列表,但只能有一个处于 Record 录制状态

创建并使用命令三件套


我们需要利用上文的核心设备 ID3D12Device 对象,依照 “命令队列” -> “命令分配器” -> “命令列表” 的顺序来逐个创建:

ComPtr<ID3D12CommandQueue> m_CommandQueue;			// 命令队列
ComPtr<ID3D12CommandAllocator> m_CommandAllocator;	// 命令分配器
ComPtr<ID3D12GraphicsCommandList> m_CommandList;	// 命令列表

// 队列信息结构体,这里只需要填队列的类型 type 就行了
D3D12_COMMAND_QUEUE_DESC queueDesc = {};
// D3D12_COMMAND_LIST_TYPE_DIRECT 表示将命令都直接放进队列里,不做其他处理
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
// 创建命令队列
m_D3D12Device->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&m_CommandQueue));

// 创建命令分配器,它的作用是开辟内存,存储命令列表上的命令,注意命令类型要一致
m_D3D12Device->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&m_CommandAllocator));

// 创建图形命令列表,注意命令类型要一致
m_D3D12Device->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, m_CommandAllocator.Get(),
			nullptr, IID_PPV_ARGS(&m_CommandList));

// 命令列表创建时处于 Record 录制状态,我们需要关闭它,这样下文的 Reset 才能成功
m_CommandList->Close();

CreateCommandList 创建命令列表
第一个参数 nodeMask 要使用的显卡编号DX12 支持多显卡渲染,我们这里填 0 就行。
第二个参数 type 命令列表里面的命令类型,有 DIRECT 直接命令BUNDLE 捆绑包 等等,我们这里直接选 DIRECT 直接命令 类型,表示命令直接添加到分配器,不需要打包
第三个参数 pInitialState 初始渲染管线状态,这个渲染管线状态是下一节教程的内容,这里填 nullptr 就行。

注意这里!
m_CommandList->Close(); 命令列表创建时,初始状态是 Record 状态,Record 状态下是不能重置命令列表和命令分配器的,所以要先关闭。


5.创建渲染目标:CreateRenderTarget


资源管理


接下来我们来探讨 DX12 资源 (Resource) 的问题。 资源 就是渲染要用到的东西:缓冲 (Buffer)纹理 (Texture),资源统一用 ID3D12Resource 表示。


在这里插入图片描述


但是"资源"只是一块数据它本身只是写明了存储的格式和大小,没有写明它的作用和用法。

那如何告诉 CPU 和 GPU 这些资源应该怎么用、有什么用 呢?描述符 (View/Descriptor,两者都是"描述符"的意思) 用于标识一个 Resource 资源,是资源作用和用法的说明


在这里插入图片描述


描述符说明
RTV (Render Target View 渲染目标描述符)标识资源为渲染目标程序将要渲染到的目标对象,例如窗口纹理贴图
CBV (Constant Buffer View 常量缓冲描述符)标识资源为常量缓冲常量缓冲是一段预先分配的高速显存,用于存储 Shader (着色器) 中的常量数据
例如矩阵、向量骨骼板
SRV (Shader Resource View 着色器资源描述符)标识资源为着色器资源Shader (着色器) 是 GPU 上的可编辑程序
着色器资源会预先放在 GPU 的寄存器上,GPU 读写会非常快,例如静态纹理贴图
Sampler 采样器描述符标识资源为采样器,用于纹理采样与纹理过滤,决定纹理图像的清晰度
DSV (Depth Stencil View 深度模板描述符)标识资源为深度模板缓冲,表示这是一块用于深度测试模板测试的缓冲
例如物体遮挡渲染、环境光遮蔽、平面镜效果物体阴影
UAV (Unordered Access View 无序访问描述符)标识资源为无序访问资源,表示这是 CPU 可读写的 GPU 资源
常用于计算着色器 (Compute Shader)动态纹理贴图
VBV (Vertex Buffer View 顶点缓冲描述符)标识资源为顶点缓冲,表示这块缓冲存储了一堆顶点
IBV (Index Buffer View 索引缓冲描述符)标识资源为索引缓冲,表示这块缓冲存储了一堆顶点索引,顶点索引决定了顶点绘制的顺序

View 视图Descriptor 描述符 其实是两个一样的东西,都用来描述一块资源,只不过前者是老版本的叫法,后者是 DX12 新增的写法。这里为了防止混淆,统一叫 Descriptor 描述符


那么这些资源和描述符该放哪里呢?DirectX 为了在资源管理上更好支持多线程渲染,设计了叫 堆 (Heap) 的东西来 存储资源和描述符

堆有两大种:一种是专门用来存储 Resource 资源资源堆 (Heap) ,另一种是专门用来存储 Descriptor 描述符描述符堆 (DescriptorHeap)


在这里插入图片描述


本节教程我们只碰 RTV (Render Target View) 渲染目标描述符

创建 RTV 描述符堆


描述符堆本质上是一个数组,里面的元素是描述符。我们要指定渲染目标为 窗口 (窗口缓冲),就要创建一个长度为 3 的 RTV 描述符堆,并将每个 RTV 描述符分别绑定到对应的窗口缓冲上


在这里插入图片描述

为什么要创建 3 个窗口缓冲呢?详情请看:游戏中的“垂直同步”与“三重缓冲”究竟是个啥? - 萧戈 的博客


首先我们先创建 RTV 描述符堆,描述符堆用 ID3D12DescriptorHeap 表示:

ComPtr<ID3D12DescriptorHeap> m_RTVHeap;					// RTV 描述符堆

// 创建 RTV 描述符堆 (Render Target View,渲染目标描述符)
D3D12_DESCRIPTOR_HEAP_DESC RTVHeapDesc = {};
RTVHeapDesc.NumDescriptors = 3;							// 渲染目标的数量
RTVHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;		// 描述符堆的类型:RTV

// 创建一个 RTV 描述符堆,创建成功后,会自动开辟三个描述符的内存
m_D3D12Device->CreateDescriptorHeap(&RTVHeapDesc, IID_PPV_ARGS(&m_RTVHeap));

创建交换链


描述符堆创建好了,现在我们要处理渲染目标,也就是如何创建并获得窗口缓冲呢?DXGI提供了一个叫 IDXGISwapChain 交换链 的东西,用于绑定窗口,并创建、获取、交换窗口缓冲


在这里插入图片描述

// 创建 DXGI 交换链,用于将窗口缓冲区和渲染目标绑定
DXGI_SWAP_CHAIN_DESC1 swapchainDesc = {};
swapchainDesc.BufferCount = 3;								// 缓冲区数量
swapchainDesc.Width = WindowWidth;							// 缓冲区 (窗口) 宽度
swapchainDesc.Height = WindowHeight;						// 缓冲区 (窗口) 高度
swapchainDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;			// 缓冲区格式,指定缓冲区每个像素的大小
swapchainDesc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;	// 交换链类型,有 FILP 和 BITBLT 两种类型
swapchainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;// 缓冲区的用途,这里表示把缓冲区用作渲染目标的输出
swapchainDesc.SampleDesc.Count = 1;							// 缓冲区像素采样次数

// 临时低版本交换链接口,用于创建高版本交换链,因为下文的 CreateSwapChainForHwnd 不能直接用于创建高版本接口
ComPtr<IDXGISwapChain1> _temp_swapchain;

// 创建交换链,将窗口与渲染目标绑定
// 注意:交换链需要绑定到命令队列来刷新,所以第一个参数要填命令队列,不填会创建失败!
m_DXGIFactory->CreateSwapChainForHwnd(m_CommandQueue.Get(), m_hwnd,
			&swapchainDesc, nullptr, nullptr, &_temp_swapchain);

ComPtr<IDXGISwapChain3> m_DXGISwapChain;					// DXGI 高版本交换链

// 通过 As 方法,将低版本接口的信息传递给高版本接口
_temp_swapchain.As(&m_DXGISwapChain);

CreateSwapChainForHwnd 创建交换链,并将窗口绑定到交换链上
第一个参数 pDevice 要关联的设备,对于 DX12 而言,每次渲染完成后命令队列都要发送"交换缓冲"的指令,告诉交换链交换窗口缓冲,让图像呈现到窗口上,所以这里必须要填命令队列 CommandQueue,否则会创建失败!
第二个参数 hWnd 要绑定的窗口句柄,交换链创建成功后,会自动创建几个与绑定窗口大小一致的窗口缓冲
第三个参数 pDesc 交换链信息结构体
第四个参数 pFullScreenDesc 全屏交换链信息结构体,游戏一般都会有 “全屏模式” 和 “窗口模式” 两种显示模式,游戏全屏就要用到全屏交换链,我们暂时不需要游戏全屏,所以填 nullptr
第五个参数 pRestrictToOutput 输出目标限制结构体,我们这里不管它,直接填 nullptr
第六个参数 ppSwapChain 要输出到的 DXGISwapChain1 的二级指针,交换链创建完后,会输出到此二级指针上

注意!我们需要调用 IDXGISwapChain3GetCurrentBufferIndex 方法来获取当前正在渲染的后台缓冲区CreateSwapChainForHwnd 只能创建 IDXGISwapChain1 接口的对象,我们需要调用 As() 方法查询接口,使用低版本接口的数据创建高版本接口。


通过交换链创建渲染目标资源,并创建 RTV 描述符


最后一步,就是将 RTV 描述符和窗口缓冲逐一绑定了:


在这里插入图片描述


// 创建完交换链后,我们还需要令 RTV 描述符 指向 渲染目标
// 因为 ID3D12Resource 本质上只是一块数据,它本身没有对数据用法的说明
// 我们要让程序知道这块数据是一个渲染目标,就得创建并使用 RTV 描述符

ComPtr<ID3D12Resource> m_RenderTarget[3];			// 渲染目标数组,每一副渲染目标对应一个窗口缓冲区
D3D12_CPU_DESCRIPTOR_HANDLE RTVHandle;				// RTV 描述符句柄
UINT RTVDescriptorSize = 0;							// RTV 描述符的大小

// 获取 RTV 堆指向首描述符的句柄
RTVHandle = m_RTVHeap->GetCPUDescriptorHandleForHeapStart();
// 获取 RTV 描述符的大小
RTVDescriptorSize = m_D3D12Device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV);

for (UINT i = 0; i < 3; i++)
{
	// 从交换链中获取第 i 个窗口缓冲,创建第 i 个 RenderTarget 渲染目标
	m_DXGISwapChain->GetBuffer(i, IID_PPV_ARGS(&m_RenderTarget[i]));

	// 创建 RTV 描述符,将渲染目标绑定到描述符上
	m_D3D12Device->CreateRenderTargetView(m_RenderTarget[i].Get(), nullptr, RTVHandle);

	// 偏移到下一个 RTV 句柄
	RTVHandle.ptr += RTVDescriptorSize;
}

6.创建围栏和资源屏障:CreateFenceAndBarrier


GPU 与 CPU 的同步


在此之前,我们先要了解两个名词:同步 (synchronous) 异步 (asynchronous)这两个名词对现代 3D 图形 API 相当重要



DX12 完全是基于异步渲染的,也就是说,CPUGPU 发送完渲染指令后立即返回,然后 CPUGPU 分别在两个相互独立的子任务上运行,这也是 DX12 相比之前版本的最明显的不同:

在这里插入图片描述


异步渲染 本质上是 多线程渲染,都是为了最终目标 解放 CPU 和 GPU 的生产力,提高渲染效率 而生的!


为什么我们要在异步渲染中引入同步机制呢?

这就不得不提命令分配器了,DX12 有好多像命令分配器这种放在共享内存上的东西是既可以被 CPU 访问,也可以被 GPU 访问的。但是 CPUGPU 各自的访问速度不同,有可能会出现 CPUGPU 同时访问造成资源冲突,或者是 CPU 错序访问了 (比 GPU 快好几帧),导致跳帧、闪屏、或者画面撕裂


在这里插入图片描述

在这里插入图片描述


情况1:GPU 和 CPU 同时访问同一资源导致资源冲突


在这里插入图片描述

情况2:CPU 错序访问(正常情况下最多快一帧),导致画面下半部分渲染比上半部分快,出现了严重的画面撕裂

如何解决同步问题呢?这就要 围栏 (Fence) 资源屏障 (ResourceBarrier) 发挥作用了。


围栏事件


在这里插入图片描述


围栏 (Fence) 的作用是 维护一个 “围栏值”CPU/GPU 需要等待事件完成时,就向围栏标记一个预定值和一处预定事件,如果完成了事件,围栏会更新 围栏值 为 预定值,并激发 预定事件。 围栏用 ID3D12Fence 接口表示:

ComPtr<ID3D12Fence> m_Fence;						// 围栏
UINT64 FenceValue = 0;								// 用于围栏等待的围栏值

// 创建围栏,设定初始值为 0
m_D3D12Device->CreateFence(FenceValue, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&m_Fence));

但是围栏只是标记一处事件,设置事件有无信号,它本身不能直接让 CPU 等待,这个时候就需要 Windows.h 的内置对象 Event 事件 来解决这个问题了。


在这里插入图片描述


事件对象就像一个开关:它仅仅有两种状态—— 开 (有信号)关 (无信号)


在这里插入图片描述

我们把 无信号 称为 渲染未完成状态有信号 称为 渲染已完成状态

HANDLE RenderEvent = NULL;							// GPU 渲染事件

// 创建 CPU 上的等待事件
RenderEvent = CreateEvent(nullptr, false, true, nullptr);

CreateEvent 创建 Windows Event 事件
第一个参数 lpEventAttributes 安全属性,我们这里不用管它,直接填 nullptr
第二个参数 bManualReset 事件是否自动复位,填 true 表示等待线程释放后需要显式调用 ResetEvent 手动复位 (有信号 -> 无信号);填 false 表示等待线程释放后系统自动帮你复位。我们这里让系统帮我们做,直接填 false 就行
第三个参数 bInitialState 初始状态,填 true 表示 有信号状态;填 false 表示 无信号状态,可以通过 SetEvent 设置信号 (无信号 -> 有信号)。因为我们开始就要让 GPU 渲染,所以此处填 true
第四个参数 lpName 事件名称,填 NULL 系统会创建一个匿名事件,我们这里直接填 NULL 即可
返回值是 HANDLE 一个句柄,创建成功会返回 事件句柄,否则会返回 NULL 空句柄


下文我们要在 CPU 上来等待 GPU 完成渲染操作,使用 WaitForSingleObject(RenderEvent) 即可。


资源屏障


上文的围栏同步我们基本解决了第二种情况,但如果很不巧,碰上了第一种情况:CPUGPU 同时访问同一资源 (注意!这里的"同时访问"是指 CPU 和 GPU 两方同时进行写入和读出的操作!),光靠围栏也不能完美解决,这就很麻烦了。

为了解决这一棘手问题,DX12 引入了 资源屏障 (Resource Barrier) 的概念。


在这里插入图片描述


什么是 资源屏障 呢?我们先了解一下 资源状态

在 DX12 中,每个资源都有资源状态 (Resource State),有些是用作渲染目标只写用的,所以处于 渲染目标状态 (D3D12_RESOURCE_STATE_RENDER_TARGET);有些是渲染好在窗口上显示用的,不能再修改(只读),所以处于 呈现状态 (D3D12_RESOURCE_STATE_PRESENT);有些是共享型只读资源CPUGPU 都能快速读取 (但不可写),所以处于 只读状态 (D3D12_RESOURCE_STATE_GENERIC_READ);还有些是复制用的,例如后文我们将要讲的纹理贴图,所以处于 复制状态 (D3D12_RESOURCE_STATE_COPY_DEST) 等等。

再说一下 DX12 的前代 DX11,DX11 的资源状态是由图形驱动自动管理的,但后面人们发现驱动还包含了大量无关的资源状态验证与转换,游戏要加载的资源一旦大起来,这些验证与转换就会降低帧率。

为了解决这一问题,DX12 的资源状态管理全部从驱动层移动到代码可触碰的应用层,让开发者自行管理,造化全看开发者的功力。而为了防止资源就像第一种情况那样同时被写入读出,造成资源冲突,DX12 设计了资源屏障,它仅允许一个线程对资源进行指定操作,其余线程想要操作同一个资源,需要等那个线程操作完,通过资源屏障转换后才行 (作用类似互斥锁,互斥锁是什么可以自行百度)。这就是资源屏障的由来。


在这里插入图片描述

资源屏障的原理类似于互斥锁,但比互斥锁更安全 (无死锁)

资源屏障用一个结构体 D3D12_RESOURCE_BARRIER 表示:


D3D12_RESOURCE_BARRIER beg_barrier = {};			// 渲染开始的资源屏障,呈现 -> 渲染目标
D3D12_RESOURCE_BARRIER end_barrier = {};			// 渲染结束的资源屏障,渲染目标 -> 呈现

// 设置资源屏障
// beg_barrier 起始屏障:Present 呈现状态 -> Render Target 渲染目标状态
beg_barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;					// 指定类型为转换屏障		
beg_barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT;
beg_barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET;

// end_barrier 终止屏障:Render Target 渲染目标状态 -> Present 呈现状态
end_barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
end_barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET;
end_barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PRESENT;

设计资源屏障的另一个目的是,GPU 是由多个引擎 (Engine) 组成的,例如专用于 3D 图形渲染的 3D 引擎 (3D Engine) ,专用于复制的 复制引擎 (Copy Engine) ,专用于计算的 计算引擎 (Compute Engine) ,专用于视频解码/编码用的 视频引擎 (Video Decode/Encode Engine) 等等。


在这里插入图片描述

在这里插入图片描述

任务管理器 -> 性能 这块就可以看到 GPU 多个引擎上的显存占用情况

这些引擎也是各自相互独立的,不可避免要遇到资源同时访问的问题。CPU 里面有各种 互斥锁、临界区、原子操作 实现同步安全访问,GPUCPU 之间有 围栏 实现同步安全访问,那么 GPU 里面呢?


在这里插入图片描述


这就要 资源屏障 发挥作用了,本节我们只讲简单应用,复杂东西留到后面再说。


7.渲染:Render


最后一步就是渲染了,我们这里不多说,直接上图和代码:


将渲染命令提交到命令队列


在这里插入图片描述


UINT FrameIndex = 0;		// 帧索引,表示当前渲染的第 i 帧 (第 i 个渲染目标)

// 获取 RTV 堆首句柄
RTVHandle = m_RTVHeap->GetCPUDescriptorHandleForHeapStart();
// 获取当前渲染的后台缓冲序号
FrameIndex = m_DXGISwapChain->GetCurrentBackBufferIndex();
// 偏移 RTV 句柄,找到对应的 RTV 描述符
RTVHandle.ptr += FrameIndex * RTVDescriptorSize;


// 先重置命令分配器
m_CommandAllocator->Reset();
// 再重置命令列表,Close 关闭状态 -> Record 录制状态
m_CommandList->Reset(m_CommandAllocator.Get(), nullptr);

// 将起始转换屏障的资源指定为当前渲染目标
beg_barrier.Transition.pResource = m_RenderTarget[FrameIndex].Get();
// 调用资源屏障,将渲染目标由 Present 呈现(只读) 转换到 RenderTarget 渲染目标(只写)
m_CommandList->ResourceBarrier(1, &beg_barrier);


// 用 RTV 句柄设置渲染目标
m_CommandList->OMSetRenderTargets(1, &RTVHandle, false, nullptr);

// 清空当前渲染目标的背景为天蓝色
m_CommandList->ClearRenderTargetView(RTVHandle, DirectX::Colors::SkyBlue, 0, nullptr);


// 将终止转换屏障的资源指定为当前渲染目标
end_barrier.Transition.pResource = m_RenderTarget[FrameIndex].Get();
// 再通过一次资源屏障,将渲染目标由 RenderTarget 渲染目标(只写) 转换到 Present 呈现(只读)
m_CommandList->ResourceBarrier(1, &end_barrier);
		
// 关闭命令列表,Record 录制状态 -> Close 关闭状态,命令列表只有关闭才可以提交
m_CommandList->Close();

// 用于传递命令用的临时 ID3D12CommandList 数组
ID3D12CommandList* _temp_cmdlists[] = { m_CommandList.Get() };

// 执行上文的渲染命令!
m_CommandQueue->ExecuteCommandLists(1, _temp_cmdlists);

// 向命令队列发出交换缓冲的命令,此命令会加入到命令队列中,命令队列执行到该命令时,会通知交换链交换缓冲
m_DXGISwapChain->Present(1, NULL);

OMSetRenderTargetsClearRenderTargetView 每个参数的作用可以自行上 Microsoft Learn 微软官方文档 查,遇到问题善用搜索引擎和官方文档是最快的解决方法,后文我们只会提供关键函数和结构体的说明,其他函数我们只会注释它的作用,想刨根问底可以自行搜索。
微软官方文档 - ID3D12GraphicsCommandList::OMSetRenderTargets()
微软官方文档 - ID3D12GraphicsCommandList::ClearRenderTargetView()


围栏等待


在这里插入图片描述


// 将围栏预定值设定为下一帧
FenceValue++;
// 在命令队列 (命令队列在 GPU 端) 设置围栏预定值,此命令会加入到命令队列中
// 命令队列执行到这里会修改围栏值,表示渲染已完成,"击中"围栏
m_CommandQueue->Signal(m_Fence.Get(), FenceValue);
// 设置围栏的预定事件,当渲染完成时,围栏被"击中",激发预定事件,将事件由无信号状态转换成有信号状态
m_Fence->SetEventOnCompletion(FenceValue, RenderEvent);

// 令 CPU 强制等待 GPU 渲染完,第二个参数是等待时间,INFINITE 表示无限等待
// 当 RenderEvent 有信号时会立即返回,停止等待
// 经过该函数后 RenderEvent 也会自动重置为无信号状态,因为我们创建事件的时候指定了第二个参数为 false
WaitForSingleObject(RenderEvent, INFINITE);

修改渲染循环


将上文的 “将渲染命令提交到命令队列”“围栏等待” 写进一个叫 void Render() 的函数里,然后在消息循环中添加 Render() 即可:

bool isExit = false;	// 是否退出
MSG msg = {};			// 消息结构体

while (isExit != true)
{
	Render();			// 进行渲染
	
	// 查看消息队列是否有消息,如果有就获取。 PM_REMOVE 表示获取完消息,就立刻将该消息从消息队列中移除
	while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
	{
		// 如果程序没有收到退出消息,就向操作系统发出派发消息的命令
		if (msg.message != WM_QUIT)
		{
			TranslateMessage(&msg);					// 翻译消息,将虚拟按键值转换为对应的 ASCII 码 (后文会讲)
			DispatchMessage(&msg);					// 派发消息,通知操作系统调用回调函数处理消息
		}
		else
		{
			isExit = true;							// 收到退出消息,就退出消息循环
		}
	}
}

多线程渲染优化:MsgWaitForMultipleObjects()


回顾上文,我们是如何令 CPU 等待 GPU 的呢?

没错,用的是 WaitForSingleObject 函数。

这个函数有一个很致命的缺点,就是会造成 CPU 主线程阻塞

阻塞不是什么大问题,资源屏障也阻塞,但关键是它会阻塞主线程,主线程阻塞意味着窗口完成不了整个消息循环,短时间内整个窗口会"瘫痪",无法响应窗口消息


在这里插入图片描述

另一方面是,现在很多游戏的大场景都用到了多线程渲染,例如加了光追 MOD 的 Minecraft 大地图,黑吗喽的黄沙岭,GTA5 整个纽约城 等等。这些游戏一般都会将大场景拆分成几个小任务,分配给 GPU 端的几个线程,最后合并结果就能实现快速渲染了。

WaitForSingleObject 并不符合多线程渲染,它的阻塞会浪费许多 CPU 资源,CPU 不能同时管理多个 GPU 渲染小任务,渲染大场景下帧率会大幅降低;而且还要包含玩家的键鼠交互操作,但 WaitForSingleObject 的阻塞会导致主线程无法正常处理窗口消息,游戏常常卡顿,点一下卡一下。


在这里插入图片描述

黑神话处处都有大场景渲染。游戏能保证渲染高速同步的前提下,还能正常处理玩家的键鼠操作

那么如何才能让 CPU 与 GPU 既能保持同步,又能让 CPU 端正常处理窗口消息呢?

答案:MsgWaitForMultipleObjects()

Msg 就是 Message 消息 的意思, MultipleObjects 就是 多个对象 (对象包括 Process 进程,Thread 线程,Event 事件,Mutex 互斥锁,Semaphore 信号量)

MsgWaitForMultipleObjects()有窗口消息关联的对象有信号时唤醒所属线程,并立即返回一个整数值。该函数阻塞时仍然可以响应窗口消息,常用于游戏多线程渲染,同时也是整个多线程架构的核心

DWORD ActiveEvent = MsgWaitForMultipleObjects(nCount, pHandles, fWaitAll, dwMilliseconds, dwWakeMask);

MsgWaitForMultipleObjects() 多对象等待函数
第一个参数 nCount 要等待对象 (第二个参数 pHandles) 的数量
第二个参数 pHandles 要等待的若干对象的指针,如果对象设置为自动复位 (CreateEvent 的第二个参数设置为 false),那么经过此函数后对象会自动从有信号状态转换成无信号状态,否则就需要用 ResetEvent 手动复位。
第三个参数 fWaitAll 是否等待全部对象有信号+有窗口消息再返回,选 true 表示要等待所有对象有信号,并且有窗口消息才能返回,而且只能返回 WAIT_OBJECT_0;选 false 表示任一对象有信号或有窗口消息,都立即返回,并且能返回有信号对象的索引。我们这里选 false
第四个参数 dwMilliseconds 最长等待时间,如果等待超时就会直接返回 WAIT_TIMEOUT,我们这里暂时用 INFINITE 不限时间
第五个参数 dwWakeMask 欲观察的用户输入消息类型,我们这里选 QS_ALLINPUT,表示任何消息在消息队列中都可以激发 MsgWaitForMultipleObjects() 函数

返回值 ActiveEvent 一个整数值,表示激发的对象:
WAIT_OBJECT_0 to (WAIT_OBJECT_0 + nCount – 1) 将返回值减去 WAIT_OBJECT_0,就表示第二个参数 pHandles 中哪一个 handle 被激发了
WAIT_OBJECT_0 + nCount 窗口消息到达队列
WAIT_TIMEOUT 超时返回


“围栏等待” 中的 WaitForSingleObject 删除,“渲染循环” 一块修改成一下代码:

bool isExit = false;	// 是否退出
MSG msg = {};			// 消息结构体

while (isExit != true)
{
	// MsgWaitForMultipleObjects 用于多个线程的无阻塞等待,返回值是激发事件 (线程) 的 ID
	// 经过该函数后 RenderEvent 也会自动重置为无信号状态,因为我们创建事件的时候指定了第二个参数为 false
	DWORD ActiveEvent = ::MsgWaitForMultipleObjects(1, &RenderEvent, false, INFINITE, QS_ALLINPUT);

	switch (ActiveEvent - WAIT_OBJECT_0)
	{
	case 0:				// ActiveEvent 是 0,说明渲染事件已经完成了,进行下一次渲染
		Render();
		break;

	case 1:				// ActiveEvent 是 1,说明渲染事件未完成,CPU 主线程同时处理窗口消息,防止界面假死
		// 查看消息队列是否有消息,如果有就获取。 PM_REMOVE 表示获取完消息,就立刻将该消息从消息队列中移除
		while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
		{
			// 如果程序没有收到退出消息,就向操作系统发出派发消息的命令
			if (msg.message != WM_QUIT)
			{
				TranslateMessage(&msg);					// 翻译消息,将虚拟按键值转换为对应的 ASCII 码 (后文会讲)
				DispatchMessage(&msg);					// 派发消息,通知操作系统调用回调函数处理消息
			}
			else
			{
				isExit = true;							// 收到退出消息,就退出消息循环
			}
		}
		break;

	case WAIT_TIMEOUT:	// 渲染超时
		break;
	}
}

第二节全代码



// (2) DrawSkyblueWindow:用 DirectX 12 渲染一个天蓝色窗口

#include<Windows.h>			// Windows 窗口编程核心头文件
#include<d3d12.h>			// DX12 核心头文件
#include<dxgi1_6.h>			// DXGI 头文件,用于管理与 DX12 相关联的其他必要设备,如 DXGI 工厂和 交换链
#include<DirectXColors.h>	// DirectX 颜色库

#include<wrl.h>				// COM 组件模板库,方便写 DX12 和 DXGI 相关的接口

#pragma comment(lib,"d3d12.lib")	// 链接 DX12 核心 DLL
#pragma comment(lib,"dxgi.lib")		// 链接 DXGI DLL
#pragma comment(lib,"dxguid.lib")	// 链接 DXGI 必要的设备 GUID

using namespace Microsoft;
using namespace Microsoft::WRL;		// 使用 wrl.h 里面的命名空间,我们需要用到里面的 Microsoft::WRL::ComPtr COM智能指针
using namespace DirectX;			// DirectX 命名空间


// DX12 引擎
class DX12Engine
{
private:

	int WindowWidth = 640;		// 窗口宽度
	int WindowHeight = 480;		// 窗口高度
	HWND m_hwnd;				// 窗口句柄

	ComPtr<ID3D12Debug> m_D3D12DebugDevice;				// D3D12 调试层设备
	UINT m_DXGICreateFactoryFlag = NULL;				// 创建 DXGI 工厂时需要用到的标志

	ComPtr<IDXGIFactory5> m_DXGIFactory;				// DXGI 工厂
	ComPtr<IDXGIAdapter1> m_DXGIAdapter;				// 显示适配器 (显卡)
	ComPtr<ID3D12Device4> m_D3D12Device;				// D3D12 核心设备

	ComPtr<ID3D12CommandQueue> m_CommandQueue;			// 命令队列
	ComPtr<ID3D12CommandAllocator> m_CommandAllocator;	// 命令分配器
	ComPtr<ID3D12GraphicsCommandList> m_CommandList;	// 命令列表

	ComPtr<IDXGISwapChain3> m_DXGISwapChain;			// DXGI 交换链
	ComPtr<ID3D12DescriptorHeap> m_RTVHeap;				// RTV 描述符堆
	ComPtr<ID3D12Resource> m_RenderTarget[3];			// 渲染目标数组,每一副渲染目标对应一个窗口缓冲区
	D3D12_CPU_DESCRIPTOR_HANDLE RTVHandle;				// RTV 描述符句柄
	UINT RTVDescriptorSize = 0;							// RTV 描述符的大小
	UINT FrameIndex = 0;								// 帧索引,表示当前渲染的第 i 帧 (第 i 个渲染目标)

	ComPtr<ID3D12Fence> m_Fence;						// 围栏
	UINT64 FenceValue = 0;								// 用于围栏等待的围栏值
	HANDLE RenderEvent = NULL;							// GPU 渲染事件
	D3D12_RESOURCE_BARRIER beg_barrier = {};			// 渲染开始的资源屏障,呈现 -> 渲染目标
	D3D12_RESOURCE_BARRIER end_barrier = {};			// 渲染结束的资源屏障,渲染目标 -> 呈现

public:

	// 初始化窗口
	void InitWindow(HINSTANCE hins)
	{
		WNDCLASS wc = {};					// 用于记录窗口类信息的结构体
		wc.hInstance = hins;				// 窗口类需要一个应用程序的实例句柄 hinstance
		wc.lpfnWndProc = CallBackFunc;		// 窗口类需要一个回调函数,用于处理窗口产生的消息
		wc.lpszClassName = L"DX12 Game";	// 窗口类的名称

		RegisterClass(&wc);					// 注册窗口类,将窗口类录入到操作系统中

		// 使用上文的窗口类创建窗口
		m_hwnd = CreateWindow(wc.lpszClassName, L"DX12画窗口", WS_SYSMENU | WS_OVERLAPPED,
			10, 10, WindowWidth, WindowHeight,
			NULL, NULL, hins, NULL);

		// 因为指定了窗口大小不可变的 WS_SYSMENU 和 WS_OVERLAPPED,应用不会自动显示窗口,需要使用 ShowWindow 强制显示窗口
		ShowWindow(m_hwnd, SW_SHOW);
	}

	// 创建调试层
	void CreateDebugDevice()
	{
		::CoInitialize(nullptr);	// 注意这里!DX12 的所有设备接口都是基于 COM 接口的,我们需要先全部初始化为 nullptr

#if defined(_DEBUG)		// 如果是 Debug 模式下编译,就执行下面的代码

		// 获取调试层设备接口
		D3D12GetDebugInterface(IID_PPV_ARGS(&m_D3D12DebugDevice));
		// 开启调试层
		m_D3D12DebugDevice->EnableDebugLayer();
		// 开启调试层后,创建 DXGI 工厂也需要 Debug Flag
		m_DXGICreateFactoryFlag = DXGI_CREATE_FACTORY_DEBUG;

#endif
	}

	// 创建设备
	bool CreateDevice()
	{
		// 创建 DXGI 工厂
		CreateDXGIFactory2(m_DXGICreateFactoryFlag, IID_PPV_ARGS(&m_DXGIFactory));

		// DX12 支持的所有功能版本,你的显卡最低需要支持 11.0
		const D3D_FEATURE_LEVEL dx12SupportLevel[] =
		{
			D3D_FEATURE_LEVEL_12_2,		// 12.2
			D3D_FEATURE_LEVEL_12_1,		// 12.1
			D3D_FEATURE_LEVEL_12_0,		// 12.0
			D3D_FEATURE_LEVEL_11_1,		// 11.1
			D3D_FEATURE_LEVEL_11_0		// 11.0
		};


		// 用 EnumAdapters1 先遍历电脑上的每一块显卡
		// 每次调用 EnumAdapters1 找到显卡会自动创建 DXGIAdapter 接口,并返回 S_OK
		// 找不到显卡会返回 ERROR_NOT_FOUND

		for (UINT i = 0; m_DXGIFactory->EnumAdapters1(i, &m_DXGIAdapter) != ERROR_NOT_FOUND; i++)
		{
			// 找到显卡,就创建 D3D12 设备,从高到低遍历所有功能版本,创建成功就跳出
			for (const auto& level : dx12SupportLevel)
			{
				// 创建 D3D12 核心层设备,创建成功就返回 true
				if (SUCCEEDED(D3D12CreateDevice(m_DXGIAdapter.Get(), level, IID_PPV_ARGS(&m_D3D12Device))))
				{
					return true;
				}
			}
		}

		// 如果找不到任何能支持 DX12 的显卡,就退出程序
		if (m_D3D12Device == nullptr)
		{
			MessageBox(NULL, L"找不到任何能支持 DX12 的显卡,请升级电脑上的硬件!", L"错误", MB_OK | MB_ICONERROR);
			return false;
		}
	}

	// 创建命令三件套
	void CreateCommandComponents()
	{
		// 队列信息结构体,这里只需要填队列的类型 type 就行了
		D3D12_COMMAND_QUEUE_DESC queueDesc = {};
		// D3D12_COMMAND_LIST_TYPE_DIRECT 表示将命令都直接放进队列里,不做其他处理
		queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
		// 创建命令队列
		m_D3D12Device->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&m_CommandQueue));

		// 创建命令分配器,它的作用是开辟内存,存储命令列表上的命令,注意命令类型要一致
		m_D3D12Device->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&m_CommandAllocator));

		// 创建图形命令列表,注意命令类型要一致
		m_D3D12Device->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, m_CommandAllocator.Get(),
			nullptr, IID_PPV_ARGS(&m_CommandList));

		// 命令列表创建时处于 Record 录制状态,我们需要关闭它,这样下文的 Reset 才能成功
		m_CommandList->Close();
	}

	// 创建渲染目标,将渲染目标设置为窗口
	void CreateRenderTarget()
	{
		// 创建 RTV 描述符堆 (Render Target View,渲染目标描述符)
		D3D12_DESCRIPTOR_HEAP_DESC RTVHeapDesc = {};
		RTVHeapDesc.NumDescriptors = 3;							// 渲染目标的数量
		RTVHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;		// 描述符堆的类型:RTV
		// 创建一个 RTV 描述符堆,创建成功后,会自动开辟三个描述符的内存
		m_D3D12Device->CreateDescriptorHeap(&RTVHeapDesc, IID_PPV_ARGS(&m_RTVHeap));


		// 创建 DXGI 交换链,用于将窗口缓冲区和渲染目标绑定
		DXGI_SWAP_CHAIN_DESC1 swapchainDesc = {};
		swapchainDesc.BufferCount = 3;								// 缓冲区数量
		swapchainDesc.Width = WindowWidth;							// 缓冲区 (窗口) 宽度
		swapchainDesc.Height = WindowHeight;						// 缓冲区 (窗口) 高度
		swapchainDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;			// 缓冲区格式,指定缓冲区每个像素的大小
		swapchainDesc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;	// 交换链类型,有 FILP 和 BITBLT 两种类型
		swapchainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;// 缓冲区的用途,这里表示把缓冲区用作渲染目标的输出
		swapchainDesc.SampleDesc.Count = 1;							// 缓冲区像素采样次数

		// 临时低版本交换链接口,用于创建高版本交换链,因为下文的 CreateSwapChainForHwnd 不能直接用于创建高版本接口
		ComPtr<IDXGISwapChain1> _temp_swapchain;

		// 创建交换链,将窗口与渲染目标绑定
		// 注意:交换链需要绑定到命令队列来刷新,所以第一个参数要填命令队列,不填会创建失败!
		m_DXGIFactory->CreateSwapChainForHwnd(m_CommandQueue.Get(), m_hwnd,
			&swapchainDesc, nullptr, nullptr, &_temp_swapchain);

		// 通过 As 方法,将低版本接口的信息传递给高版本接口
		_temp_swapchain.As(&m_DXGISwapChain);

		
		// 创建完交换链后,我们还需要令 RTV 描述符 指向 渲染目标
		// 因为 ID3D12Resource 本质上只是一块数据,它本身没有对数据用法的说明
		// 我们要让程序知道这块数据是一个渲染目标,就得创建并使用 RTV 描述符

		// 获取 RTV 堆指向首描述符的句柄
		RTVHandle = m_RTVHeap->GetCPUDescriptorHandleForHeapStart();
		// 获取 RTV 描述符的大小
		RTVDescriptorSize = m_D3D12Device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV);

		for (UINT i = 0; i < 3; i++)
		{
			// 从交换链中获取第 i 个窗口缓冲,创建第 i 个 RenderTarget 渲染目标
			m_DXGISwapChain->GetBuffer(i, IID_PPV_ARGS(&m_RenderTarget[i]));

			// 创建 RTV 描述符,将渲染目标绑定到描述符上
			m_D3D12Device->CreateRenderTargetView(m_RenderTarget[i].Get(), nullptr, RTVHandle);

			// 偏移到下一个 RTV 句柄
			RTVHandle.ptr += RTVDescriptorSize;
		}
	}

	// 创建围栏和资源屏障,用于 CPU-GPU 的同步
	void CreateFenceAndBarrier()
	{
		// 创建 CPU 上的等待事件
		RenderEvent = CreateEvent(nullptr, false, true, nullptr);
		
		// 创建围栏,设定初始值为 0
		m_D3D12Device->CreateFence(FenceValue, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&m_Fence));
		

		// 设置资源屏障
		// beg_barrier 起始屏障:Present 呈现状态 -> Render Target 渲染目标状态
		beg_barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;					// 指定类型为转换屏障		
		beg_barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT;
		beg_barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET;

		// end_barrier 终止屏障:Render Target 渲染目标状态 -> Present 呈现状态
		end_barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
		end_barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET;
		end_barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PRESENT;
	}

	// 渲染
	void Render()
	{
		// 获取 RTV 堆首句柄
		RTVHandle = m_RTVHeap->GetCPUDescriptorHandleForHeapStart();
		// 获取当前渲染的后台缓冲序号
		FrameIndex = m_DXGISwapChain->GetCurrentBackBufferIndex();
		// 偏移 RTV 句柄,找到对应的 RTV 描述符
		RTVHandle.ptr += FrameIndex * RTVDescriptorSize;


		// 先重置命令分配器
		m_CommandAllocator->Reset();
		// 再重置命令列表,Close 关闭状态 -> Record 录制状态
		m_CommandList->Reset(m_CommandAllocator.Get(), nullptr);

		// 将起始转换屏障的资源指定为当前渲染目标
		beg_barrier.Transition.pResource = m_RenderTarget[FrameIndex].Get();
		// 调用资源屏障,将渲染目标由 Present 呈现(只读) 转换到 RenderTarget 渲染目标(只写)
		m_CommandList->ResourceBarrier(1, &beg_barrier);


		// 用 RTV 句柄设置渲染目标
		m_CommandList->OMSetRenderTargets(1, &RTVHandle, false, nullptr);

		// 清空当前渲染目标的背景为天蓝色
		m_CommandList->ClearRenderTargetView(RTVHandle, DirectX::Colors::SkyBlue, 0, nullptr);


		// 将终止转换屏障的资源指定为当前渲染目标
		end_barrier.Transition.pResource = m_RenderTarget[FrameIndex].Get();
		// 再通过一次资源屏障,将渲染目标由 RenderTarget 渲染目标(只写) 转换到 Present 呈现(只读)
		m_CommandList->ResourceBarrier(1, &end_barrier);
		
		// 关闭命令列表,Record 录制状态 -> Close 关闭状态,命令列表只有关闭才可以提交
		m_CommandList->Close();

		// 用于传递命令用的临时 ID3D12CommandList 数组
		ID3D12CommandList* _temp_cmdlists[] = { m_CommandList.Get() };

		// 执行上文的渲染命令!
		m_CommandQueue->ExecuteCommandLists(1, _temp_cmdlists);

		// 向命令队列发出交换缓冲的命令,此命令会加入到命令队列中,命令队列执行到该命令时,会通知交换链交换缓冲
		m_DXGISwapChain->Present(1, NULL);




		// 将围栏预定值设定为下一帧
		FenceValue++;
		// 在命令队列 (命令队列在 GPU 端) 设置围栏预定值,此命令会加入到命令队列中
		// 命令队列执行到这里会修改围栏值,表示渲染已完成,"击中"围栏
		m_CommandQueue->Signal(m_Fence.Get(), FenceValue);
		// 设置围栏的预定事件,当渲染完成时,围栏被"击中",激发预定事件,将事件由无信号状态转换成有信号状态
		m_Fence->SetEventOnCompletion(FenceValue, RenderEvent);
	}

	// 渲染循环
	void RenderLoop()
	{
		bool isExit = false;	// 是否退出
		MSG msg = {};			// 消息结构体

		while (isExit != true)
		{
			// MsgWaitForMultipleObjects 用于多个线程的无阻塞等待,返回值是激发事件 (线程) 的 ID
			// 经过该函数后 RenderEvent 也会自动重置为无信号状态,因为我们创建事件的时候指定了第二个参数为 false
			DWORD ActiveEvent = ::MsgWaitForMultipleObjects(1, &RenderEvent, false, INFINITE, QS_ALLINPUT);

			switch (ActiveEvent - WAIT_OBJECT_0)
			{
			case 0:				// ActiveEvent 是 0,说明渲染事件已经完成了,进行下一次渲染
				Render();
				break;

			case 1:				// ActiveEvent 是 1,说明渲染事件未完成,CPU 主线程同时处理窗口消息,防止界面假死
				// 查看消息队列是否有消息,如果有就获取。 PM_REMOVE 表示获取完消息,就立刻将该消息从消息队列中移除
				while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
				{
					// 如果程序没有收到退出消息,就向操作系统发出派发消息的命令
					if (msg.message != WM_QUIT)
					{
						TranslateMessage(&msg);					// 翻译消息,将虚拟按键值转换为对应的 ASCII 码 (后文会讲)
						DispatchMessage(&msg);					// 派发消息,通知操作系统调用回调函数处理消息
					}
					else
					{
						isExit = true;							// 收到退出消息,就退出消息循环
					}
				}
				break;

			case WAIT_TIMEOUT:	// 渲染超时
				break;
			}
		}
	}

	// 回调函数
	static LRESULT CALLBACK CallBackFunc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
	{
		// 用 switch 将第二个参数分流,每个 case 分别对应一个窗口消息
		switch (msg)
		{
		case WM_DESTROY:			// 窗口被销毁 (当按下右上角 X 关闭窗口时)
			PostQuitMessage(0);		// 向操作系统发出退出请求 (WM_QUIT),结束消息循环
			break;

		// 如果接收到其他消息,直接默认返回整个窗口
		default: return DefWindowProc(hwnd, msg, wParam, lParam);
		}

		return 0;	// 注意这里!
	}

	// 运行窗口
	static void Run(HINSTANCE hins)
	{
		DX12Engine engine;
		engine.InitWindow(hins);
		engine.CreateDebugDevice();
		engine.CreateDevice();
		engine.CreateCommandComponents();
		engine.CreateRenderTarget();
		engine.CreateFenceAndBarrier();

		engine.RenderLoop();
	}
};


// 主函数
int WINAPI WinMain(HINSTANCE hins, HINSTANCE hPrev, LPSTR cmdLine, int cmdShow)
{
	DX12Engine::Run(hins);
}



在这里插入图片描述


下一节,我们将正式踏入 DirectX 12 的大门,学习 DX12 真正的 HelloWorld


下一篇教程:DX12 快速教程(3) —— 画矩形

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

dgaf

谢谢大佬打赏

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值