快速导航
新建项目 “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 对象赋值为 nullptr | comp.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 更适合并行处理大量、重复的数据运算,尤其是图形渲染和深度学习等需要大规模并行计算的任务。
认识显卡
如图所示,这就是显卡。显卡主要承担输出显示图形的任务,性能好的显卡还支持并行运算,可以用于科学计算和 AI 深度学习。
显卡分为两种:集成显卡、独立显卡。
- 集成显卡
集成显卡,简单来说,就是直接集成在主板或者 CPU 处理器里的显卡,兼有 CPU 和 GPU 的功能(不过 GPU 性能很差)。它就像是电脑里的一个“兼职员工”,除了干好自己的本职工作——显示图像外,还得帮 CPU 处理器分担一些其他任务。
集显的性能一般都很低,专用显示内存(显存)一般只有 128 MB,支持的 DirectX 版本也只是达到"刚好能够兼容"的级别(DX12 最低支持到 11.0 版本,11.0 和 11.1 版本支持的特性不多)。如果你喜欢玩一些大型3D游戏,或者需要进行一些专业的图形设计、视频剪辑等工作,集成显卡可能就有点力不从心了。它可能会让你的游戏画面卡顿、延迟,或者让你的设计作品看起来不够细腻、流畅。
常见的集显有 Intel 芯片自带的 Intel HD 系列 和 集成于 AMD 处理器的 AMD Radeon Graphics 系列:
- 独立显卡
接下来再看看独立显卡。独立显卡,顾名思义,就是一块独立的显卡,它有自己的处理器(GPU)、内存(显存)和散热系统。它就像是电脑里的一个“专业团队”,专门负责处理图像显示的任务。因为独立显卡是独立的,所以它的性能通常比集成显卡要强得多,专用显存更大。它可以轻松应对那些对图形处理要求很高的游戏和应用程序,让你的游戏体验更加流畅,设计作品更加精美。
DX12 设计的目的就是在软件层面上发掘独显的最大潜能,逼近独显性能的极限。所以相对于集显,独显能支持的 DX12 版本更多(多了 12.1 和 12.0),支持的功能也随之增加。
常见的独显有 NVIDIA 的 NVIDIA RTX 系列(俗称N卡)和 AMD 的 AMD Radeon RX 系列(俗称A卡):
要查看你设备的 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,否则会创建失败。
后两个参数 riid,ppFactory 分别表示创建工厂需要用到的 GUID值 和 指向对象的二级指针
这个 GUID (Globally Unique Identifier,全局唯一标识符) 相当于 COM 接口的身份证,它标识了这个接口的身份。这个 GUID 值是不可能重复的,从而保证不可能有第二个重复的接口,这个接口的身份是唯一的。
后续创建接口都会有 riid,ppFactory,我们可以使用 IID_PPV_ARGS(&comp) 来简化接口的创建,让编译器来帮我们进行等价替换,提高开发效率。
创建 D3D12 核心设备
我们需要创建 DX12 的核心设备:ID3D12Device4:
- 通过 IDXGIFacory->EnumAdapters1 枚举合适的显卡,获得 IDXGIAdapter1 显卡 (显示适配器) 对象
- 用这个 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 ,让它执行渲染命令,画东西:
问题来了,CPU 和 GPU 是两个相互独立的单元。如何记录渲染命令?如何将渲染命令发送给 GPU?如何通知 GPU 让它执行渲染命令?
为了解决上述问题,DX12 设计了三个东西:命令列表 (CommandList),命令分配器 (CommandAllocator),命令队列 (CommandQueue)。
- 命令列表 (CommandList):它是命令的记录者,用来在 CPU 端 记录需要执行的命令,记录的命令会存储在命令分配器中。命令列表有两种状态,一种叫 Record 录制状态,用于录制命令;另一种叫 Close 关闭状态,用于关闭录制,等待提交到命令队列。命令列表有很多种类型,包括 用于3D渲染的 图形命令列表 (GraphicsCommandList)、用于复制命令的 复制命令列表 (CopyCommandList)、用于音视频解码的 视频命令列表 (VideoCommandList) 等等。
- 命令分配器 (CommandAllocator):它是命令的存储容器,负责绑定命令列表,存储命令列表中的命令。它位于 CPU 端的共享内存 上,所以可以被 GPU 端读取、引用里面的命令。一个命令分配器可以绑定多个命令列表。
- 命令队列 (CommandQueue):它位于 GPU 端,是命令的执行者,负责读取并执行 命令分配器 中的命令。它会从头到尾一条一条地执行命令,像数据结构中的 队列。所以叫 命令队列。
创建并使用命令三件套
我们需要利用上文的核心设备 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 的二级指针,交换链创建完后,会输出到此二级指针上
注意!我们需要调用 IDXGISwapChain3 的 GetCurrentBufferIndex 方法来获取当前正在渲染的后台缓冲区,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 完全是基于异步渲染的,也就是说,CPU 给 GPU 发送完渲染指令后立即返回,然后 CPU 与 GPU 分别在两个相互独立的子任务上运行,这也是 DX12 相比之前版本的最明显的不同:
异步渲染 本质上是 多线程渲染,都是为了最终目标 解放 CPU 和 GPU 的生产力,提高渲染效率 而生的!
为什么我们要在异步渲染中引入同步机制呢?
这就不得不提命令分配器了,DX12 有好多像命令分配器这种放在共享内存上的东西是既可以被 CPU 访问,也可以被 GPU 访问的。但是 CPU 和 GPU 各自的访问速度不同,有可能会出现 CPU 和 GPU 同时访问造成资源冲突,或者是 CPU 错序访问了 (比 GPU 快好几帧),导致跳帧、闪屏、或者画面撕裂:
如何解决同步问题呢?这就要 围栏 (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) 即可。
资源屏障
上文的围栏同步我们基本解决了第二种情况,但如果很不巧,碰上了第一种情况:CPU 与 GPU 同时访问同一资源 (注意!这里的"同时访问"是指 CPU 和 GPU 两方同时进行写入和读出的操作!),光靠围栏也不能完美解决,这就很麻烦了。
为了解决这一棘手问题,DX12 引入了 资源屏障 (Resource Barrier) 的概念。
什么是 资源屏障 呢?我们先了解一下 资源状态。
在 DX12 中,每个资源都有资源状态 (Resource State),有些是用作渲染目标只写用的,所以处于 渲染目标状态 (D3D12_RESOURCE_STATE_RENDER_TARGET);有些是渲染好在窗口上显示用的,不能再修改(只读),所以处于 呈现状态 (D3D12_RESOURCE_STATE_PRESENT);有些是共享型只读资源,CPU 和 GPU 都能快速读取 (但不可写),所以处于 只读状态 (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) 等等。
这些引擎也是各自相互独立的,不可避免要遇到资源同时访问的问题。CPU 里面有各种 互斥锁、临界区、原子操作 实现同步安全访问,GPU 与 CPU 之间有 围栏 实现同步安全访问,那么 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);
OMSetRenderTargets 和 ClearRenderTargetView 每个参数的作用可以自行上 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。