尽管我不知道为什么会有人想直接使用 DirectX 来做视频播放器,甚至还是 DirectX 12 这样一个麻烦的东西,总之这坨代码已经被我糊出了个雏形。虽然功能十分简陋,没有音频输出,视频的播放速度也不对,但总之能放了。为了避免经验快速从我的大脑流失,我还是决定记录一下我的整个实现思路历程。

把大象塞进冰箱

很显然,我们要做一个视频播放器,至少我们需要先有一个视频,然后把它播放出来。那么很好,就像把大象塞进冰箱一样,我们现在只需要两步,第一步是把视频的画面内容从文件里面提取出来,第二步是把视频的画面内容画到屏幕上。

好吧,我承认我在第一步上偷懒了。起初我是想用 Nvidia 的 NVDecoder 来干这活的,但由于我是倒着来干这活的——先实现第二步,再实现第一步,所以实现到后面就犯了懒,拿 FFmpeg 凑合过了。毕竟,为了验证第一步是否能够正确解码出画面内容,那必须先实现第二步的内容画面绘制才行。当然,一个更好的借口是,利用 FFmpeg 可以更加方便地实现对于多种软硬件编解码器实现的支持,不过这些重点并不在我这篇随记的内容范围内。避免篇幅过长,在这篇中我就仅对 DirectX 12 的渲染过程作记录,而 FFmpeg 的解码 API 调用则放到后续篇章中。

DirectX12 渲染一张图片

Win32 中文显示

在 Windows 上编程,总有几个比较烦人的点。其中字符编码就是一个比较大的问题。由于我想减少对于 Visual Studio 这一庞然大物的依赖,我选择使用 VSCode 和 CMake 来构建我的程序。由于对字符编码是在不甚了解,在这个项目中,折腾中文的显示也花费了我好一番功夫。最后,我沉淀出来的解决方案分为两个关键步骤。

首先,众所周知,Win32 大多数 API 都有两个版本,一个是后缀为 A 的 ANSI 版本,一个是后缀为 W 的宽字符版本。为了能让 Windows 正确处理源文件中的中文字符,我们需要使用 Win32 API 的宽字符版本。Win32 API 对具有两个版本的 API 都写了对应的宏,即不带后缀的版本,会根据当前是否定义了 UNICODE 宏来确定使用 A 版本或 W 版本。所以,为了让 Windows 的宏自动选择 W 版本的 Win32 API,在引入 Windows 的各项头文件前需要手动定义 UNICODE 宏。当然,如果你全部手动使用 W 版本的 Win32 API,倒也可以省去这一步。具体而言,头文件引入长得像下面这个样子:

#pragma once

#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#define UNICODE
#define _UNICODE

#include <Windows.h>
#include <d3d12.h>
#include <dxgi1_4.h>
#include <dxgidebug.h>
#include <tchar.h>
#include <wrl/client.h>

using Microsoft::WRL::ComPtr;

至于为什么有 UNICODE 又有_UNICODE,这又是另外的问题了,好奇的朋友们可以自行检索相关信息,这里我偷个懒。

使用宽字符版本的 API,我们能够正确输出 Unicode 中文字符了,但这时候还有另外一个问题,源文件中的字符可能并不是以正确的编码格式被读取的。如果源文件中的字符以 GBK 的格式读取,再用 Unicode 格式输出,最终的显示还是一团乱码。由于我们在 VSCode 中的源文件都以 UTF-8 的格式编辑,我们需要指定让编译器以 UTF-8 的格式来读取源文件才行。这一逻辑通过在CMakeLists.txt中添加编译器选项实现,长得像下面这个样子:

add_compile_options("$<$<C_COMPILER_ID:MSVC>:/source-charset:utf-8>")
add_compile_options("$<$<CXX_COMPILER_ID:MSVC>:/source-charset:utf-8>")

当然,这一操作似乎是针对 MSVC 的,如果使用 Clang 的话应该不会碰到这一问题。

一点概念

起初,神创造窗口并显示。那时候程序没有实体界面,运行在虚空中(或是命令行)。神说:“ShowWindows(hwnd_, SW_SHOW);”,就有了窗口。神看窗口是好的。

没错,在使用 DirectX 在 Windows 上画点什么的时候,我们至少需要先有一个落笔点,也就是一个 Win32 窗口。一个简易的 Win32 程序难度并不高,只要向 Windows 注册一下我们自定义的窗口类,提供一下相应的消息处理函数,在适当的时机调用显示窗口即可。由于 Win32 编程的相关技术只是也已经被各路大神讲得透彻,我就不再班门弄斧,只做的简单介绍。那么让我假设现在已经有一个 Win32 窗口的句柄hwnd_,以便于后续的操作说明。

在 DirectX 中,我看到的是什么?

在进入 DirectX12 的各项繁琐操作之前,我想先谈一个问题:DirectX12 怎么能够实现绘制视频画面?在这里,我仅从一个初学者的角度,对我在这一简单项目中对 DirectX 的浅薄理解作一个讲述,提供一个思路,具体的代码可以参考龙书中的相关章节。

首先,我们从最终输出的角度出发,在 Windows 上不管是利用 DirectX 做游戏,或是做视频播放器,或者是做一个自定义的 GUI 程序,其最终输出到屏幕上的东西都是一张二维的图片,毕竟承担显示任务的显示器也只是一个二维像素点构成的平面设备。在 DirectX 中,存储这么一张图片的地方就被称为后缓冲,我们做的一切就是为了将我们需要的画面渲染到这张图片上,最终贴到屏幕上面。从这个角度上来说,做一个视频播放器,我们只需要想办法将视频的每一帧画面放到后缓冲上就行。

在经过大范围的相关信息检索后,我有两种方法来做到这个事情:

  1. 直接把视频画面每一帧都按像素点对点画到后缓冲上面。这种方法的优势在于,你真的不用去做任何的思考,只要一股脑把画面塞到后缓冲让 DirectX 画出来就行。尽管优势不是很多,但缺点也不少,最大的问题是这种方法要求后缓冲的像素点数量和长宽比与视频画面严格一致,否则就需要手动进行升降采样才行——对于 CPU 来说可不是什么好消息。除此之外,如果我想在原本的视频画面上面糊一点别的东西,比如视频播放器经常看到的播放暂停按钮,或是字幕,都非常麻烦,需要手动混合后再写入后缓冲,那么 GPU 加速渲染岂不是变成了 GPU 加速风味渲染了?
  2. 将视频画面变为纹理,交给 DirectX 的流水线将这张纹理渲染到一个空间中的矩形上。这个方法看起来就像是……搭了个简易的投影幕布,将需要放的视频画面投影到幕布上面了。这中方法的优势在于,由于是基于是利用 DirectX 的流水线进行渲染,像缩放、变形这类操作可以很容易地利用 DirectX 的能力解决,其他 GUI 部件也可以在不同渲染阶段与视频画面混合叠加,有效利用了 DirectX 进行渲染加速。缺点则是,这一套操作执行起来更加地复杂了。

综合考虑,我觉得还是用第二种方法来实现播放器最为合适,毕竟很难想象在 2024 年还会有无法自由缩放画面的播放器存在。

如何让 DirectX12 渲染出一张图片?

好了,在确定了第一阶段的目标之后,我们终于可以开始着手鼓捣我们的 DirectX12 了。由于 DirectX12 试图通过开放更细粒度的 API 来让开发者能够最大限度优化性能,所以 DirectX12 的操作相比 DirectX11 要复杂许多。为了方便理解和描述,我将整个视频渲染的流程分为两大步骤,一个是资源准备,一个是资源操作。

了解过 DirectX 渲染的同学可能知道,DirectX 渲染过程就是一个流水线,我们只需要在流水线上放置渲染画面需要用到的资源和指令,DirectX 就能指挥显卡完成渲染工作,输出渲染结果。这个流程感觉上就和 CPU 上程序的执行有点类似,有个地方放数据,有个地方放指令,然后告诉 CPU 去哪读指令执行,CPU 就能把活都干了。CPU 上执行的程序指令是预先编写好载入内存的,同样显卡执行的渲染指令也是程序提前构建好传入显卡的。

如上所说,为了渲染视频画面,我们首先需要在 DirectX 的 3D 场景中生成一个四边形,通过合适的投影矩阵将其变换到与我们的窗口等大,再将视频画面作为纹理撑开来贴在这个四边形上,那么在视觉效果上,我们就能够看到一个占满窗口大小的图片。将视频拆分为图片之后,按照合适的帧率去逐一更换四边形上面的贴图,我们就能够看到窗口中的图片不断变化,也就成功播放出了视频。通常称这类贴图为纹理,也就是 3D 渲染中常见的 Texture。

渲染准备

幕布与视角

在开始进行渲染之前,我想先说明一下这个项目中用到的一些常量资源。

首先是我们的四边形幕布。一个典型的四边形幕布是一个矩形,具有四个点,我们称这些点为顶点,即 Vertex。为了在三维空间中能够定位这些顶点,每一个顶点需要一个长度为 3 的向量属性来存储三维坐标。当我们将纹理渲染到四边形幕布上的时候,由于图片是二维的,那么四边形的每一个顶点都能够与一个纹理的平面坐标系位置相对应。因此,每一个顶点还需要一个长度为 2 的向量属性来存储纹理坐标。一个简单的四边形顶点和索引集合如下所示:

Vertex vertices[] = {
    {DirectX::XMFLOAT3(-0.5f, 0.5f, 2.0f),DirectX::XMFLOAT2(0.0f, 0.0f)},
    {DirectX::XMFLOAT3(0.5f, 0.5f, 2.0f),DirectX::XMFLOAT2(1.0f, 0.0f)},
    {DirectX::XMFLOAT3(0.5f, -0.5f, 2.0f),DirectX::XMFLOAT2(1.0f, 1.0f)},
    {DirectX::XMFLOAT3(-0.5f, -0.5f, 2.0f),DirectX::XMFLOAT2(0.0f, 1.0f)},
};
uint16_t indices[] = {0, 1, 2, 0, 2, 3};

上述索引就表示 0,1,2 三个顶点构成一个三角形,而 0,2,3 三个顶点构成另一个三角形。如此两个三角形就构成了最后的矩形。

DXGI 工厂与 DirectX 设备

在开始使用 DirectX12 的一切之前,我们需要创建一个 DirectX 设备和 DXGI 工厂为我们执行后续资源准备操作,这两个对象是我们与 DirectX 12 API 交互的载体。

ComPtr<IDXGIFactory4> factory_;
ComPtr<ID3D12Device> device_;
CreateDXGIFactory1(IID_PPV_ARGS(&factory_));
D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device_));

CommandQueue 与 SwapChain

在创建完成工厂之后,我们需要创建一个交换链,来负责管理后缓冲的切换。这个交换链绑定了我们先前创建的窗口句柄,将后缓冲的内容正确地绘制到窗口上。由于交换链需要与指令队列相互绑定,所以在此之前需要先创建用于存储待执行指令的指令队列。

D3D12_COMMAND_QUEUE_DESC queue_desc = {};
queue_desc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
queue_desc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;
device_->CreateCommandQueue(&queue_desc, IID_PPV_ARGS(&command_queue_));

完成指令队列的创建之后,我们就可以创建相应的交换链了。

ComPtr<IDXGISwapChain1> swap_chain_;

DXGI_SWAP_CHAIN_DESC1 swap_chain_desc = {};
swap_chain_desc.Width = config.width;
swap_chain_desc.Height = config.height;
swap_chain_desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
swap_chain_desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
swap_chain_desc.BufferCount = 2;
swap_chain_desc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
swap_chain_desc.SampleDesc.Count = 1;

factory_->CreateSwapChainForHwnd(command_queue_.Get(), hwnd_,
                                &swap_chain_desc, nullptr, nullptr,
                                &swap_chain_);

在 DirectX 中,大部分资源需要通过填充特定的描述符结构体后调用 API 来创建,这些描述符描述了资源的相关属性。比如在上述交换链描述符中,我指定了交换链的宽高属性和像素格式,缓冲的数量与属性,交换效果和采样数量等。

资源缓冲区

为了在显存中保存我们上述提到的四边形顶点数据,我们需要创建一块缓冲区,将这些顶点数据复制过去。为了简化逻辑,我们先进行缓冲区的创建操作,并在后续的资源操作过程中再处理数据复制的问题。

同样地,为了创建顶端缓冲,我们需要一个缓冲区描述符。这里我使用 DirectXTK12 中的辅助函数进行描述符的填充。后文中所有以 CD3DX12 开头的函数全部都是 DirectXTK12 提供的辅助函数。

ComPtr<ID3D12Resource> vertex_buffer_gpu_;
D3D12_RESOURCE_DESC vertex_buffer_desc = CD3DX12_RESOURCE_DESC::Buffer(sizeof(vertices));
device_->CreateCommittedResource(
      &default_heap_properties, D3D12_HEAP_FLAG_NONE, &vertex_buffer_desc,
      D3D12_RESOURCE_STATE_COMMON, nullptr, IID_PPV_ARGS(&vertex_buffer_gpu_));

由于我们的顶点数量固定,这里就直接取顶点数组的大小作为缓冲区的大小。我们还需要一个索引缓冲来表示系统中的面片,其初始化与顶点缓冲极为相似。

ComPtr<ID3D12Resource> index_buffer_gpu_;
D3D12_RESOURCE_DESC index_buffer_desc =
    CD3DX12_RESOURCE_DESC::Buffer(sizeof(indices));
device_->CreateCommittedResource(
    &default_heap_properties, D3D12_HEAP_FLAG_NONE, &index_buffer_desc,
    D3D12_RESOURCE_STATE_COMMON, nullptr, IID_PPV_ARGS(&index_buffer_gpu_));

为了存储我们的投影矩阵,或者也可以说是摄像机视角矩阵,我们还需要一个常量缓冲区。

D3D12_RESOURCE_DESC constant_buffer_desc =
    CD3DX12_RESOURCE_DESC::Buffer(sizeof(ConstantBuffer));
device_->CreateCommittedResource(
    &upload_heap_properties, D3D12_HEAP_FLAG_NONE, &constant_buffer_desc,
    D3D12_RESOURCE_STATE_GENERIC_READ, nullptr,
    IID_PPV_ARGS(&constant_buffer_gpu_));

除此以外,为了存放我们每次需要渲染的图片数据,我们还需要一个纹理缓冲区。注意这里的颜色格式为 RGBA,后续再将图片上传到纹理缓冲区时需要注意颜色类型是否一致,否则可能画面会比较离谱。

D3D12_RESOURCE_DESC texture_desc = CD3DX12_RESOURCE_DESC::Tex2D(
    DXGI_FORMAT_R8G8B8A8_UNORM, config.width, config.height, 1, 1);
device_->CreateCommittedResource(
    &default_heap_properties, D3D12_HEAP_FLAG_NONE, &texture_desc,
    D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&texture_gpu_));

由于在渲染管线上还需要深度缓冲用于比对三维场景中不同物体的深度,进而绘制出正确的物体前后关系,我们还需要创建深度模板缓冲区。深度模板缓冲区就是一个特殊的纹理缓冲区,因此在创建深度模板缓冲区的时候,需要注意指定对应的宽高和数据格式,以及相应的 Flags。

D3D12_RESOURCE_DESC depth_stencil_desc = CD3DX12_RESOURCE_DESC::Tex2D(
    DXGI_FORMAT_D32_FLOAT, config.width, config.height, 1, 1, 1, 0,
    D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL);

D3D12_CLEAR_VALUE depth_clear_value = {};
depth_clear_value.Format = DXGI_FORMAT_D32_FLOAT;
depth_clear_value.DepthStencil.Depth = 1.0f;
depth_clear_value.DepthStencil.Stencil = 0;

device_->CreateCommittedResource(
    &default_heap_properties, D3D12_HEAP_FLAG_NONE, &depth_stencil_desc,
    D3D12_RESOURCE_STATE_COMMON, &depth_clear_value,
    IID_PPV_ARGS(&depth_stencil_buffer_));

描述符堆

DirectX 12 给资源绑定添加了一堆新的概念,包括描述符 descriptor,对应的描述符堆 descriptor heap 和描述符表 descriptor table 以及根签名 root signatures。简单来说,我们把 DirectX 12 的渲染过程看成 C++ 的一次函数执行,那么上面创建的缓冲区 buffer 对应变量在内存中的实际存储位置,descriptor 就对应变量的引用,descriptor heap 就是存放引用的数组,descriptor table 是函数的参数列表,而 root signatures 则对应函数的签名。为了能够将渲染中需要使用的资源正确地传递给渲染管线,我们就必须通过描述符来对资源的位置、格式等信息进行描述并将其传给渲染管线,而在使用描述符前,我们就需要先创建相对应的描述符堆。这些描述符堆包括了用于存储渲染目标 RenderTarget 的 RTV heap、用于存储深度模板 DepthStencil 的 DSV heap 和用于存储常量缓冲 ConstantBuffer 或着色器资源描述符 ShaderResource 的 CBV_SRV_UAV heap。

D3D12_DESCRIPTOR_HEAP_DESC rtv_heap_desc = {};
rtv_heap_desc.NumDescriptors = 2;
rtv_heap_desc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;
rtv_heap_desc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
rtv_heap_desc.NodeMask = 0;
THROW_IF_FAILED(
    device_->CreateDescriptorHeap(&rtv_heap_desc, IID_PPV_ARGS(&rtv_heap_)));
D3D12_DESCRIPTOR_HEAP_DESC dsv_heap_desc = {};
dsv_heap_desc.NumDescriptors = 1;
dsv_heap_desc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_DSV;
dsv_heap_desc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
dsv_heap_desc.NodeMask = 0;
THROW_IF_FAILED(
    device_->CreateDescriptorHeap(&dsv_heap_desc, IID_PPV_ARGS(&dsv_heap_)));
D3D12_DESCRIPTOR_HEAP_DESC cbv_srv_heap_desc = {};
cbv_srv_heap_desc.NumDescriptors = 1;
cbv_srv_heap_desc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
cbv_srv_heap_desc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
cbv_srv_heap_desc.NodeMask = 0;
THROW_IF_FAILED(
    device_->CreateDescriptorHeap(&cbv_srv_heap_desc, IID_PPV_ARGS(&cbv_srv_heap_)));

由于常量描述符和渲染资源描述符可以放在同一个堆中,这里就只创建了一个相应的堆并复用。

描述符

完成描述符堆以后,我们就可以开始创建每个资源对应的描述符了。在创建其他资源描述符之前,我想先创建用于绑定渲染管线输出的 RenderTargetView 描述符。我们知道 SwapChain 是负责管理和绘制后缓冲的对象,因此我们需要将渲染管线最终的输出绑定到 SwapChain 管理的后缓冲上。这一点通过 RenderTargetView 描述符实现,只要为 SwapChain 的后缓冲创建相应的 RenderTargetView,并绑定到渲染管线上,就能将管线渲染出的画面输出到后缓冲中了。我们将创建出的描述符存储在上述创建出的相应 RenderTarget 描述符堆中。

D3D12_CPU_DESCRIPTOR_HANDLE rtv_handle =
    rtv_heap_->GetCPUDescriptorHandleForHeapStart();
rtv_descriptor_size_ =
    device_->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV);

for (int i = 0; i < 2; i++) {
    swap_chain_->GetBuffer(i, IID_PPV_ARGS(&back_buffers_[i]));
    device_->CreateRenderTargetView(back_buffers_[i].Get(), nullptr,
                                    rtv_handle);
    rtv_handle.ptr += rtv_descriptor_size_;
}

接下来,我们创建指向先前创建好的深度模板缓冲对应的深度模板缓冲描述符,同样存储在上述创建出的相应深度模板描述符堆中。

D3D12_DEPTH_STENCIL_VIEW_DESC ds_view_desc = {};
ds_view_desc.Format = DXGI_FORMAT_D32_FLOAT;
ds_view_desc.ViewDimension = D3D12_DSV_DIMENSION_TEXTURE2D;
ds_view_desc.Flags = D3D12_DSV_FLAG_NONE;

device_->CreateDepthStencilView(
    depth_stencil_buffer_.Get(), &ds_view_desc,
    dsv_heap_->GetCPUDescriptorHandleForHeapStart());

还有指向之前创建的纹理缓冲区的纹理描述符,同样存储在上述创建出的相应着色器资源描述符堆中。

D3D12_SHADER_RESOURCE_VIEW_DESC srv_desc = {};
srv_desc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
srv_desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
srv_desc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
srv_desc.Texture2D.MostDetailedMip = 0;
srv_desc.Texture2D.MipLevels = 1;
srv_desc.Texture2D.ResourceMinLODClamp = 0.0f;
device_->CreateShaderResourceView(
    texture_gpu_.Get(), &srv_desc,
    cbv_srv_heap_->GetCPUDescriptorHandleForHeapStart());

创建根签名

如上所述,我们希望传递给 GPU 的参数主要有两个,一个是用于调整视角的投影矩阵,一个是用于绘制的纹理资源。因此,我们会为根签名创建大小为 2 的根参数,对于纹理资源,我们用描述符表的形式进行传递,而对于常量资源,我们直接用 CBV 的形式传递。

CD3DX12_DESCRIPTOR_RANGE srv_range = {};
srv_range.Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 0);
CD3DX12_ROOT_PARAMETER root_parameters[2] = {};
root_parameters[0].InitAsDescriptorTable(1, &srv_range,
                                        D3D12_SHADER_VISIBILITY_PIXEL);
root_parameters[1].InitAsConstantBufferView(0);

有了根参数,我们就可以着手创建根签名了。方便起见,我们在这里使用了一个静态的 Sampler。

auto static_samplers = CD3DX12_STATIC_SAMPLER_DESC(
    0, D3D12_FILTER_MIN_MAG_MIP_LINEAR, D3D12_TEXTURE_ADDRESS_MODE_WRAP,
    D3D12_TEXTURE_ADDRESS_MODE_WRAP, D3D12_TEXTURE_ADDRESS_MODE_WRAP);

D3D12_ROOT_SIGNATURE_DESC root_signature_desc = {};
root_signature_desc.NumParameters = _countof(root_parameters);
root_signature_desc.pParameters = root_parameters;
root_signature_desc.NumStaticSamplers = 1;
root_signature_desc.pStaticSamplers = &static_samplers;
root_signature_desc.Flags =
    D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT;

D3D12SerializeRootSignature(&root_signature_desc,
                                            D3D_ROOT_SIGNATURE_VERSION_1,
                                            &signature_, &error_message_);
device_->CreateRootSignature(
    0, signature_->GetBufferPointer(), signature_->GetBufferSize(),
    IID_PPV_ARGS(&root_signature_));

着色器

在 DirectX 中,GPU 对顶点、索引等的操作是使用 HLSL 着色器代码实现的。因此,我们在项目中需要预先编写着色器,并将其编译并载入到 GPU 中。由于我们目前只是在实现一个简单的播放器,我们的着色器代码也非常简单。

如下所示,在着色器开头,我们通过几个不同类型的寄存器分别获取了纹理变量、常量变量和采样器。然后我们规定了顶点着色器的输入格式,包括一个由 3 个 float 组成的向量用于存储顶点空间坐标,一个由 2 个 float 组成的向量用于存储该顶点对应的纹理坐标。而在像素着色器中,我们使用了一个由 4 个 float 组成的向量用于存储像素点的齐次空间坐标,一个由两个 float 组成的向量用于存储像素点的纹理坐标。这些变量和类型的详细解释可以参考龙书中的相应章节。在对应的顶点着色器中,我们使用投影矩阵对像素点的坐标进行变换,而在像素着色器中,我们针对像素在纹理中的对应位置对纹理进行采样,输出这个像素点的对应颜色。

Texture2D texture0 : register(t0);

cbuffer ConstantBuffer : register(b0) {
  matrix projectionMatrix;
};

SamplerState texture0Sampler : register(s0);

struct VSInput {
  float3 position : POSITION;
  float2 TexC: TEXCOORD;
};

struct PSInput {
  float4 position : SV_POSITION;
  float2 TexC: TEXCOORD;
};

PSInput VSMain(VSInput input) {
  PSInput output;
  output.position = mul(float4(input.position, 1.0f), projectionMatrix);
  output.TexC = input.TexC;
  return output;
}

float4 PSMain(PSInput input) : SV_TARGET {
  return texture0.Sample(texture0Sampler, input.TexC);
}

在编写好上述着色器代码并保存到指定位置后,我们就可以在初始化 DirectX 12 时对着色器进行编译,生成相应的字节码。

D3DCompileFromFile(L"resources/shaders.hlsl", nullptr,
                   nullptr, "VSMain", "vs_5_0", 0, 0,
                   &vertex_shader_, nullptr);
D3DCompileFromFile(L"resources/shaders.hlsl", nullptr,
                   nullptr, "PSMain", "ps_5_0", 0, 0,
                   &pixel_shader_, nullptr);

创建管线

在准备好所有资源以后,我们就可以开始创建管线状态对象 PSO 了。在 PSO 中,我们首先定义了输入的顶点布局,与上述的着色器代码相对应,我们在这里使用一个 3 个 float 组成的向量 POSITION 来表示位置,由 2 个 float 组成的向量 TEXCOORD 来表示。这里的变量名也需要与着色器代码中相互对应。

D3D12_INPUT_ELEMENT_DESC input_element_descs[] = {
    {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
    D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0},
    {"TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 12,
    D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0},
};

在 PSO 描述符中,我们绑定上述创建的多项资源,并创建对应的 PSO 对象。

D3D12_GRAPHICS_PIPELINE_STATE_DESC pso_desc = {};
pso_desc.InputLayout.pInputElementDescs = input_element_descs;
pso_desc.InputLayout.NumElements = ARRAYSIZE(input_element_descs);
pso_desc.pRootSignature = root_signature_.Get();
pso_desc.VS = CD3DX12_SHADER_BYTECODE(vertex_shader_.Get());
pso_desc.PS = CD3DX12_SHADER_BYTECODE(pixel_shader_.Get());
pso_desc.RasterizerState = CD3DX12_RASTERIZER_DESC(D3D12_DEFAULT);
pso_desc.BlendState = CD3DX12_BLEND_DESC(D3D12_DEFAULT);
pso_desc.DepthStencilState.DepthEnable = FALSE;
pso_desc.DepthStencilState.StencilEnable = FALSE;
pso_desc.SampleMask = UINT_MAX;
pso_desc.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE;
pso_desc.NumRenderTargets = 1;
pso_desc.RTVFormats[0] = DXGI_FORMAT_R8G8B8A8_UNORM;
pso_desc.DSVFormat = DXGI_FORMAT_D32_FLOAT;
pso_desc.SampleDesc.Count = 1;
pso_desc.SampleDesc.Quality = 0;
device_->CreateGraphicsPipelineState(
    &pso_desc, IID_PPV_ARGS(&pipeline_state_));

CPU 与 GPU 同步

最后,由于在 DirectX 12 中 CPU 和 GPU 是异步执行任务的,需要有一个机制来同步双方的状态,这个机制就是 Fence。

device_->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&fence_));

有了 Fence,我们可以让 GPU 在完成工作的时候通知 CPU,CPU 再继续进行后续的工作,保证双方状态同步。这里我们做了一个简单的包装。

void DXGraphic2::FlushCommandQueue() {
  command_queue_->Signal(fence_.Get(), fence_value_);
  if (fence_->GetCompletedValue() < fence_value_) {
    HANDLE event = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    fence_->SetEventOnCompletion(fence_value_, event);
    WaitForSingleObject(event, INFINITE);
    CloseHandle(event);
  }
}

资源上传

在创建完一堆缓冲区与描述符之后,忙碌了一天的 H 师傅终于着手上传资源到 GPU 了。由于目前只在初始化阶段,我们只需要先将那些不会发生修改的资源上传到 GPU 中,比如我们的投影矩阵,顶点和索引缓冲等。而每一帧都会发生变动的视频内容,则需要在每一帧开始渲染前上传到 CPU 中。

由于便于 GPU 读取,我们先前创建的顶点缓冲区、索引缓冲区和纹理缓冲区的类型都是默认堆,因此 CPU 不能够直接访问,需要借助一个上传堆将数据先从 CPU 上传到 GPU,再在 GPU 内部将数据从上传堆复制到默认堆中。在进行这些操作之前,我们还需要将缓冲区置为相应的状态。这些操作全部被记录在命令列表 CommandList 中。

device_->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, 
                                IID_PPV_ARGS(&command_allocator_));
device_->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT,
                           command_allocator_.Get(), nullptr,
                           IID_PPV_ARGS(&command_list_));

由于上述创建的顶点缓冲区和索引缓冲区为了方便 GPU 的频繁访问,是存储在显存里面的。为了将内存中的资源复制到显存中的相应位置上,我们需要使用 GPU 的 Copy Engine,先将资源上传到一个 CPU 和 GPU 都能够访问的上传堆,再将上传堆中的资源复制到默认堆中。调用 Copy Engine 的功能就需要通过 CommandList 来执行。因为 DirectX3D 为了避免多线程操作造成资源冒险,我们还需要在操作资源时通过 Barrier 设置相应的资源状态。

// Create vertex upload heap.
auto vertex_uploader_desc = CD3DX12_RESOURCE_DESC::Buffer(sizeof(vertices));
device_->CreateCommittedResource(
    &upload_heap_properties, D3D12_HEAP_FLAG_NONE, &vertex_uploader_desc,
    D3D12_RESOURCE_STATE_GENERIC_READ, nullptr,
    IID_PPV_ARGS(&vertex_buffer_uploader_));

// Upload vertex data to the GPU.
D3D12_SUBRESOURCE_DATA vertex_data = {};
vertex_data.pData = vertices;
vertex_data.RowPitch = sizeof(vertices);
vertex_data.SlicePitch = vertex_data.RowPitch;

auto vertex_common2copy = CD3DX12_RESOURCE_BARRIER::Transition(
    vertex_buffer_gpu_.Get(), D3D12_RESOURCE_STATE_COMMON,
    D3D12_RESOURCE_STATE_COPY_DEST);
command_list_->ResourceBarrier(1, &vertex_common2copy);
UpdateSubresources<1>(command_list_.Get(), vertex_buffer_gpu_.Get(),
                    vertex_buffer_uploader_.Get(), 0, 0, 1, &vertex_data);
auto vertex_copy2vertex = CD3DX12_RESOURCE_BARRIER::Transition(
    vertex_buffer_gpu_.Get(), D3D12_RESOURCE_STATE_COPY_DEST,
    D3D12_RESOURCE_STATE_GENERIC_READ);
command_list_->ResourceBarrier(1, &vertex_copy2vertex);

// Create index upload heap.
auto index_uploader_desc = CD3DX12_RESOURCE_DESC::Buffer(sizeof(indices));
device_->CreateCommittedResource(
    &upload_heap_properties, D3D12_HEAP_FLAG_NONE, &index_uploader_desc,
    D3D12_RESOURCE_STATE_GENERIC_READ, nullptr,
    IID_PPV_ARGS(&index_buffer_uploader_));

// Upload index data to the GPU.
D3D12_SUBRESOURCE_DATA index_data = {};
index_data.pData = indices;
index_data.RowPitch = sizeof(indices);
index_data.SlicePitch = index_data.RowPitch;

auto index_common2copy = CD3DX12_RESOURCE_BARRIER::Transition(
    index_buffer_gpu_.Get(), D3D12_RESOURCE_STATE_COMMON,
    D3D12_RESOURCE_STATE_COPY_DEST);
command_list_->ResourceBarrier(1, &index_common2copy);
UpdateSubresources<1>(command_list_.Get(), index_buffer_gpu_.Get(),
                    index_buffer_uploader_.Get(), 0, 0, 1, &index_data);
auto index_copy2index = CD3DX12_RESOURCE_BARRIER::Transition(
    index_buffer_gpu_.Get(), D3D12_RESOURCE_STATE_COPY_DEST,
    D3D12_RESOURCE_STATE_GENERIC_READ);
command_list_->ResourceBarrier(1, &index_copy2index);

将投影矩阵上传到常量缓冲区中,便于流水线读取。由于常量缓冲区是上传堆,我们只需要将其位置在内存中做一个映射,就可以向操作内存那样对常量缓冲区进行操作了。

D3D12_RANGE read_range = {0, 0};
constant_buffer_gpu_->Map(
    0, &read_range, reinterpret_cast<void**>(&constant_buffer_data_)));

proj_ = DirectX::XMMatrixOrthographicLH(1, 1, 0.1f, 100.0f);

ConstantBuffer cb = {};
cb.transform = XMMatrixTranspose(proj_);  // Transpose for HLSL
memcpy(constant_buffer_data_, &cb, sizeof(cb));

为了方便后续在视频的每一帧中对纹理进行更新,我们还需要提前创建一个用于更新纹理的上传堆。

const UINT64 texture_upload_buffer_size = GetRequiredIntermediateSize(
    texture_gpu_.Get(), 0, 1);
D3D12_RESOURCE_DESC texture_upload_desc =
    CD3DX12_RESOURCE_DESC::Buffer(texture_upload_buffer_size);
device_->CreateCommittedResource(
    &upload_heap_properties, D3D12_HEAP_FLAG_NONE, &texture_upload_desc,
    D3D12_RESOURCE_STATE_GENERIC_READ, nullptr,
    IID_PPV_ARGS(&texture_uploader_));

将深度缓冲的状态设置为可写。

auto depth_common2depth = CD3DX12_RESOURCE_BARRIER::Transition(
    depth_stencil_buffer_.Get(), D3D12_RESOURCE_STATE_COMMON,
    D3D12_RESOURCE_STATE_DEPTH_WRITE);
command_list_->ResourceBarrier(1, &depth_common2depth);

最后,我们对视口和裁剪进行设置,整个初始化流程就结束了。

// 设置视口
viewport_.TopLeftX = 0;
viewport_.TopLeftY = 0;
viewport_.Width = static_cast<float>(config.width);
viewport_.Height = static_cast<float>(config.height);
viewport_.MinDepth = 0.0f;
viewport_.MaxDepth = 1.0f;

command_list_->RSSetViewports(1, &viewport_);

// 设置裁剪矩形
scissor_rect_.left = 0;
scissor_rect_.top = 0;
scissor_rect_.right = config.width;
scissor_rect_.bottom = config.height;

command_list_->RSSetScissorRects(1, &scissor_rect_);

执行 CommandList 中存储的所有指令,结束初始化。

command_list_->Close();
ID3D12CommandList* cmd_lists[] = {command_list_.Get()};
command_queue_->ExecuteCommandLists(1, cmd_lists);
FlushCommandQueue();

在每一帧渲染一张图片

好,我们现在已经万事俱备,可以开始准备渲染每一帧画面了。先让我们假设某个神秘的地方会在该渲染每一帧的时间点给我们塞一个指针,指向存放了纹理数据的内存地址。我们现在要做的工作就是,将这份纹理复制到显存中的对应位置,并调整 PSO 绑定关系,最终让 PSO 将渲染结果写入交换链缓冲,并让交换链展示渲染结果到屏幕上。

// 重置 CommandList,并将其 与 PSO 绑定
command_allocator_->Reset();
command_list_->Reset(command_allocator_.Get(), pipeline_state_.Get());

// 上传纹理数据
auto texture_read2copy = CD3DX12_RESOURCE_BARRIER::Transition(
      texture_gpu_.Get(), D3D12_RESOURCE_STATE_GENERIC_READ,
      D3D12_RESOURCE_STATE_COPY_DEST);
command_list_->ResourceBarrier(1, &texture_read2copy);

D3D12_SUBRESOURCE_DATA texture_data = {};
texture_data.pData = data;// data 指向存放纹理的内存地址,大小为 1920*1080*4
texture_data.RowPitch = 1920 * 4;
texture_data.SlicePitch = texture_data.RowPitch * 1080;
UpdateSubresources<1>(command_list_.Get(), texture_gpu_.Get(),
                    texture_uploader_.Get(), 0, 0, 1, &texture_data);

auto texture_copy2read = CD3DX12_RESOURCE_BARRIER::Transition(
    texture_gpu_.Get(), D3D12_RESOURCE_STATE_COPY_DEST,
    D3D12_RESOURCE_STATE_GENERIC_READ);
command_list_->ResourceBarrier(1, &texture_copy2read);


// 将后缓冲转换为渲染目标
auto backbuffer_present2rtv = CD3DX12_RESOURCE_BARRIER::Transition(
    back_buffers_[current_back_buffer_index_].Get(),
    D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_RENDER_TARGET);
command_list_->ResourceBarrier(1, &backbuffer_present2rtv);

// 清除后缓冲和深度缓冲内容,这里使用传入的颜色
D3D12_CPU_DESCRIPTOR_HANDLE rtv_handle =
    rtv_heap_->GetCPUDescriptorHandleForHeapStart();
rtv_handle.ptr += current_back_buffer_index_ * rtv_descriptor_size_;
FLOAT color[4] = {0.0f, 0.0f, 0.0f, 1.0f};
command_list_->ClearRenderTargetView(rtv_handle, color, 0, nullptr);

D3D12_CPU_DESCRIPTOR_HANDLE dsv_handle =
    dsv_heap_->GetCPUDescriptorHandleForHeapStart();
command_list_->ClearDepthStencilView(dsv_handle, D3D12_CLEAR_FLAG_DEPTH, 1.0f,
                                    0, 0, nullptr);

// 创建顶点视图和索引视图
D3D12_VERTEX_BUFFER_VIEW vbv = {};
vbv.BufferLocation = vertex_buffer_gpu_->GetGPUVirtualAddress();
vbv.StrideInBytes = sizeof(Vertex);
vbv.SizeInBytes = sizeof(vertices);

D3D12_INDEX_BUFFER_VIEW ibv = {};
ibv.BufferLocation = index_buffer_gpu_->GetGPUVirtualAddress();
ibv.Format = DXGI_FORMAT_R16_UINT;
ibv.SizeInBytes = sizeof(indices);

// 创建描述符堆
ID3D12DescriptorHeap* heaps[] = {cbv_srv_heap_.Get()};

// 获取材质描述符
auto texture_gpu_handle = CD3DX12_GPU_DESCRIPTOR_HANDLE(
    cbv_srv_heap_->GetGPUDescriptorHandleForHeapStart());
texture_gpu_handle.Offset(0, cbv_srv_uav_descriptor_size_);

// 绑定资源到 PSO
command_list_->IASetVertexBuffers(0, 1, &vbv);
command_list_->IASetIndexBuffer(&ibv);
command_list_->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
command_list_->OMSetRenderTargets(1, &rtv_handle, true, &dsv_handle);
command_list_->RSSetViewports(1, &viewport_);
command_list_->RSSetScissorRects(1, &scissor_rect_);
command_list_->SetDescriptorHeaps(_countof(heaps), heaps);
command_list_->SetGraphicsRootDescriptorTable(0, texture_gpu_handle);
command_list_->SetGraphicsRootConstantBufferView(
command_list_->SetGraphicsRootSignature(root_signature_.Get());
    1, constant_buffer_gpu_->GetGPUVirtualAddress());

// 绘制
command_list_->DrawIndexedInstanced(6, 1, 0, 0, 0);

// 将后缓冲状态转换回展示
auto backbuffer_rtv2present = CD3DX12_RESOURCE_BARRIER::Transition(
    back_buffers_[current_back_buffer_index_].Get(),
    D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT);
command_list_->ResourceBarrier(1, &barrier);

// 执行指令并等待执行完成
command_list_->Close();
ID3D12CommandList* cmd_lists[] = {command_list_.Get()};
command_queue_->ExecuteCommandLists(1, cmd_lists);

THROW_IF_FAILED(swap_chain_->Present(1, 0));
current_back_buffer_index_ = 1 - current_back_buffer_index_;

FlushCommandQueue();

完成上述工作后,理论上我们就能够将内存中的纹理绘制到屏幕上了,搭配上定时绘制和视频解码,我们就能够实现一个简单的视频播放器了。

参考

  1. 《DirectX 12 3D 游戏开发实战》
  2. 《深入理解 FFmpeg》
  3. 一篇文章带你彻底理解 Descriptors
  4. DX12 基础篇 (上)
  5. (译)Introduction To Direct3D 12
  6. DirectX12 - CPU&GPU Sync(Fence 机制)