Direct3D 12入门教程之 ---- Direct3D 12初始化流程

注:以下内容参考自
书籍:《DirectX 12 3D》游戏开发实战,
微软官方的 DirectX样例程序;DirectX-Graphics-Samples, 参见github链接:https://github.com/Microsoft/DirectX-Graphics-Samples

Direct3D 12对于开发者来说,就是一个SDK, 这篇文章就来讲一讲这个SDK的初始化流程,以及我在实践的过程中遇到的一些问题;

这是我实践时写的代码的GitHub链接:https://github.com/blowingBreeze/D3D12Guide,持续更新

1. 创建Direct3D设备,ICreateD3D12Device

  • Direct3D是我们操控显卡的一个抽象层,学习过面向对象的同学应该很熟悉,在将一个现实中的对象(也就是这里的显卡),往往会将该对象分解为代码中的多个对象,由这些对象对外部系统提供接口;

  • D3D12Device就是Direct3D中用于提供显卡控制接口的对象,它代表着当前系统中的显示适配器,一般来说,它是一个3D图形硬件(如显卡), 但是,操作系统在没有显卡的时候也能正常的显示图像,这时候使用的就是软件显示适配器,如(WARP适配器),

可以在不急着使用电脑的时候折腾一下,将操作系统的显卡设备全部卸载,观察一下电脑的情况

通过这个函数即可创建一个D3D12的设备对象

HRESULT D3D12CreateDevice(
  IUnknown          *pAdapter,  //想为哪个显示适配器创建一个设备对象,传递nullptr则使用系统中的默认适配器
  D3D_FEATURE_LEVEL MinimumFeatureLevel,  //指定支持的最低版本的Direct3D版本
  REFIID            riid,  //GUID
  void              **ppDevice  //用于接收设备对象所在的内存的指针
);

为了简单起见,这里使用系统默认的显示适配器

hResult = D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&mD3DDevice)); 

其中IID_PPV_ARGS是Direct3D为我们提供的一个工具宏,IID_PPV_macro,它为我们生成了后面接口中的后两个参数

1.1 获取显示适配器

显示适配器是真正实现了图形处理能力的对象,上面的D3D12Device是对显示适配器的进一步封装.
一个系统中可能会有多个显示适配器,比如我就有两个显示适配器;
在这里插入图片描述
那么在程序中我们怎么才能知道使用的是哪个适配器呢,毕竟游戏的使用性能较高的适配较好。

下面简单提一下DXGI的概念,现在仅知道有这么个东西就行了,以后慢慢就理解了

DXGI是一种与Direct3D配合使用的API,设计DXGI的基本理念是使得多种图形API中的底层任务能够使用通用的API,比如3D和2D的图形API在底层都可以使用相同的,比如Direct3D和Direct2D内部实现交换链时可以使用同一套接口

我们在获取系统的可用显示适配器时,会使用到 IDXGIFactory,主要用于创建SwapChain以及枚举显示适配器
我们可以使用下面的代码来枚举系统中的显示适配器

ComPtr<IDXGIFactory4> factory;    
UINT dxgiFactoryFlags = 0;
#if defined(_DEBUG)    
// Enable the debug layer (requires the Graphics Tools "optional feature").    
// NOTE: Enabling the debug layer after device creation will invalidate the active device.    
{        
	ComPtr<ID3D12Debug> debugController;        
	if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(&debugController)))        
	{            
		debugController->EnableDebugLayer();
	        // Enable additional debug layers.            
	        dxgiFactoryFlags |= DXGI_CREATE_FACTORY_DEBUG;       
	}    
}
#endif    
 CreateDXGIFactory2(dxgiFactoryFlags, IID_PPV_ARGS(&factory));    
 UINT i = 0;   
 ComPtr<IDXGIAdapter> adapter = nullptr;    
 while (factory->EnumAdapters(i, &adapter) != DXGI_ERROR_NOT_FOUND)   
  {        
	DXGI_ADAPTER_DESC desc;        
	adapter->GetDesc(&desc);
	std::wcout << desc.Description<<std::endl;        
	++i;   
}

系统不单单可以有多个显示适配器,每个显示适配器也可以连接多个显示输出(显示屏),我们可以通过获取到的adapter对象进一步获取更详细的显示信息,这里就不进行介绍了

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

  • 在《DirectX 12 3D游戏开发实战》中,第二步是创建 ID3D12Fence对象,并查询描述符大小
  • 我这里不这么做,是因为我觉得,Fence是一个用来同步CPU,GPU的,但是目前为止还没有提 到CPU与GPU的交互,在第二步创建会显得很奇怪;当然,对新手来说(比如我)是这样的,熟悉以后可以依据实际情况调换初始化顺序;
  • 这里你也可以直接先跳到创建 ID3D12Fence对象 的部分进行阅读

2.1 命令队列和命令列表

  • 进行图形编程的时候,是有两种处理器在进行工作的,CPU和GPU,他们之间没有绝对的从属关系,并行工作,但GPU需要CPU告诉它,该画什么东西;
  • CPU和GPU的执行命令的速度是不一样的,如果使用同步的方式执行,那么CPU势必需要等待 GPU执行完命令才能给GPU下达下一个绘制指令,而GPU做完绘制工作后在CPU没有下达指令前也必须 等待 CPU下达指令,这样就会导致处理器有一定的空转状态,不利于最大程度的发挥出处理器的性能;
  • 那么我们可以参考异步事件和缓冲池的方式进行处理,每个CPU命令看作一个一条指令,放入指令池中,而GPU不停的从这个指令池中读取CPU下达的指令,进行绘制工作;这样就能将两个处理器进行分离,互不相干(当然,不管怎么样,这两个处理器都是需要做一些同步操作的,这个会在讲Fence的时候说明),GPU可以最大限度的执行绘制任务直到没有指令需要执行,而CPU也不需要等待GPU绘制完成就可以继续下发任务

这里面有一点很重要,指令的执行是异步的,CPU下发的指令不会立即执行,直到GPU执行到了指令池中的对应指令

在这里插入图片描述

  • 在《DirectX 12 3D游戏开发实战》中有提到,指令池满了或者空了之后,CPU和GPU必然有一个处于空闲状态,但是我并未在书中看到相应的解决方案,
  • 我的一个想法是,指令池满了或者空了之后,可以将一部分GPU或CPU中的任务移交到CPU或GPU中,当然,这个在具体实现时难度是很大的
  • 在Direct3D 中,使用的是命令队列和命令列表的方式对CPU和GPU的交互进行缓冲

《DirectX 12 3D游戏开发实战》 4.2.1节中:

  • 每个GPU都至少维护着一个命令队列(command queue, 本质上是环形缓冲区,即ring buffer)
  • 借助Direct3D API,CPU可以利用命令列表(command list)将命令提交到这个队列中去
  • 在Direct3D 11中,有立即渲染(immediate rendering)延迟渲染(deferred rendering),前者是将缓冲区的命令之间借驱动层发往GPU执行,后者则与Direct3D 12中的命令列表模型类似,而在Direct 3D 12中则完全采取了 "命令列表->命令队列的方式"是多个命令列表同时记录命令,借此充分发挥多核心处理器的性能

2.2 命令队列和命令列表代码示例

在Direct3D 12中,命令队列使用 ID3D12CommandQueue接口进行表示,通过ID3D12Device::CreateCommandQueue方法创建队列(还记得1.1中的D3D12Device吗?)
创建命令队列时,需要通过填写D3D12_COMMAND_QUEUE_DESC queueDesc结构体来描述队列
MSDN上的 ID3D12Device::CreateCommandQueue method

D3D12_COMMAND_QUEUE_DESC queueDesc = {};
queueDesc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
ThrowIfFailed(mD3DDevice->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&mCommandQueue)));
ThrowIfFailed(mD3DDevice->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, 
                   IID_PPV_ARGS(mDirectCmdListAlloc.GetAddressOf())));
ThrowIfFailed(mD3DDevice->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, 
                    mDirectCmdListAlloc.Get(), nullptr, IID_PPV_ARGS(mCommandList.GetAddressOf())));

//下面是ThrowIfFailed的定义
inline void ThrowIfFailed(HRESULT hr)
{
    if (FAILED(hr))
    {
        throw HrException(hr);
    }
}

这里我们提到的三个函数

  • CreateCommandQueue:这个用于创建命令队列,很好理解
  • CreateCommandAllocator:用于创建命令分配器(command allocator),这个用于记录在命令列表中的命令,在执行命令列表时,命令队列会引用命令分配器中的命令; 我目前对这个对象的理解是,用于保存命令队列中指令的内存地址的,方便命令队列在执行命令列表时进行引用
  • CreateCommandList:用于创建命令队列,这个很好理解了,真实的管理命令的添加删除的对象

CommandList有一系列的方法用于向队列中添加命令,MSDN上的 ID3D12GraphicsCommandList interface

在添加完命令后一定要调用 ID3D12GraphicsCommandList::Close方法结束命令的记录,命令列表添加完成后,需要使用ID3D12CommandQueu::ExecuteCommandLists方法将命令列表送入命令队列中,还记得之前提到的命令缓冲吗,这里的执行其实对于CPU来说是以及执行了,但实际上GPU并不一定马上执行指令

  • 我们可以创建多个关联与同一个命令分配器的命令列表,但是不能同时用他们记录命令,即必须保证其中一个命令列表在记录命令时,必须关闭同一个命令分配器的其他命令列表,
  • 换句话说,必须保证命令列表中的所有命令都会按顺序地添加到命令分配器中
  • 当创建或重置一个命令列表的时候,它会处于一种“打开“的状态,所以当尝试为同一个命令分配器连续创建两个命令列表时会报错
  • 在调用ID3D12CommandQueue::ExcuteCommandList方法后,就可以通过ID3D12GraphicsCommandList::Reset方法,安全地服用命令列表占用的底层内存来记录新的命令集,Reset命令列表并不会英雄命令队列中的命令,因为相关的命令分配器依然维护者其内存中被命令队列引用的系列命令
  • 在向GPU提交了一帧的渲染命令后,我们可能需要为了绘制下一帧而复用命令分配器中的内存,可以使用ID3D12CommandAllocator::Reset方法,这种方法的功能类似与std::vector::clear方法,使得命命令分配器种的命令清空,但保存内存不释放,**注意,在不确定GPU执行完命令分配器中所有的命令之前,不要Reset命令分配器,因为命令队列可能还引用着命令分配器中的数据**

3.创建Fence(围栏)

前面有提到,CPU和GPU的指令执行是异步的,并且他们可能会同时访问同一块内存(指令分配器),也就有可能发生访问冲突,考虑以下情况,

  • CPU向GPU发送了A,B,C三条指令,其中B引用了dataB对象,而在CPU中,发送ABC指令的同时也在执行D指令,D指令可能会修改dataB对象;

这种情况下,GPU在执行B指令时,获取的dataB有可能不是CPU发送B指令时的dataB,可能导致很奇怪的程序异常,这种由于访问冲突导致的异常很难进行排查;

这时候我们需要做的,就是让CPU在执行B指令前,不执行D指令,也就是CPU和GPU需要进行状态同步;

  • 在进程和线程的同步方式中,可以选择锁,信号量,互斥量等方式进行同步,在这里,也可以参考这种方式进行实现,

Drect3D 12中,提供了一种 Fence对象,可以在命令队列中,设置一条围栏指令,当GPU执行到围栏指令时,触发某个事件,而在GPU中则等待事件的发生,这样就达到了同步的目的,这种方法也称作刷新命令队列(flushing the command queue)

ThrowIfFailed(mD3DDevice->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&mD3DFence)));

const UINT64 fence = mFenceValue;     
//向命令队列中添加一条用于设置新的围栏的命令
ThrowIfFailed(mCommandQueue->Signal(mD3DFence.Get(), fence));     
mFenceValue++;     
// Wait until the previous frame is finished.  
if (mD3DFence->GetCompletedValue() < fence)   
{       
	ThrowIfFailed(mD3DFence->SetEventOnCompletion(fence, mFenceEvent)); 
	WaitForSingleObject(mFenceEvent, INFINITE);   
}

4.创建交换链

4.1 什么是交换链?

  • 最终展现在屏幕上的图像数据,必定是要保存在某块内存中的,也就是缓冲区中。
  • 想象一下,若我们只创建一个缓冲区,那么每次画面的更新和屏幕图像的更新便是混在一起的,帧率不高(也就是绘制速度不够)时,能看出画面的撕裂(旧的图像和新绘制的图像混在了一起),
  • 为了解决这个问题,Direct3D中采用了双缓冲区的做法:前台缓冲区和后台缓冲区,前台缓冲区存储屏幕上展示的图像数据,而后台缓冲区存储绘制中的数据,用于下一次展示,当后台缓冲区的图像绘制完成时,前后台缓冲区角色互换,这种互换操作称为呈现(presenting),前后台缓冲区构成的交换链(swap chain),他们每帧都需要进行互换;

在这里插入图片描述

4.2 创建

mSwapChain.Reset();
mSwapChainDesc.BufferDesc.Width = 1366;
mSwapChainDesc.BufferDesc.Height = 768;
mSwapChainDesc.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
mSwapChainDesc.BufferDesc.RefreshRate.Numerator = 60;
mSwapChainDesc.BufferDesc.RefreshRate.Denominator = 1;
mSwapChainDesc.BufferDesc.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER::DXGI_MODE_SCANLINE_ORDER_LOWER_FIELD_FIRST;
mSwapChainDesc.BufferDesc.Scaling = DXGI_MODE_SCALING::DXGI_MODE_SCALING_CENTERED;
mSwapChainDesc.Windowed = true;
mSwapChainDesc.OutputWindow = mhMainWind;
mSwapChainDesc.BufferCount = BUFFER_COUNT;
mSwapChainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
mSwapChainDesc.SwapEffect = DXGI_SWAP_EFFECT::DXGI_SWAP_EFFECT_FLIP_DISCARD;
mSwapChainDesc.SampleDesc.Count = 1;
//这里要填0,不然会报错,原因是不支持该功能,具体的还不清楚
mSwapChainDesc.SampleDesc.Quality = 0;

ComPtr<IDXGISwapChain> swapChain;
ThrowIfFailed(mD3DFactory->CreateSwapChain(
	mCommandQueue.Get(), // Swap chain needs the queue so that it can force a flush on it.
	&mSwapChainDesc,
 	swapChain.GetAddressOf()
));
ThrowIfFailed(swapChain.As(&mSwapChain));

和以前一样,你需要先填写一个描述交换链的结构体,然后进行创建,具体可以参考:MSDN , IDXGIFactory::CreateSwapChain method

5. 创建描述符堆

5.1 什么是描述符?

  • 在渲染的过程中,GPU需要对资源进行读写操作,我们需要将与本次绘制调用(draw call)相关的绑定(bind,或称链接,link)到流水线上,而部分资源可能在每次绘制调用时都有所变化,因此我们需要每次按需更新绑定资源到渲染流水线中。
  • 但是GPU资源并非直接和渲染流水线绑定的,而是需要通过一种名为描述符(descriptor)的对象来对它进行间接引用,可以把描述符看作时一种对GPU资源的内容声明,告诉GPU,这个资源是什么东西,什么格式,什么类型;
  • 每个描述符都有一种具体的类型,这个类型指定了资源的具体作用,常见的有:
    • CBV:常量缓冲区视图(constant buffer view),
    • SRV:着色资源视图(shader resource view)
    • UAV:无序访问视图(unordered access view),
    • sampler:采样器资源
    • RTV:渲染目标视图(render targe view),
    • DSV:深度/模板视图(depth/stencil view)
      这里面每种视图对应的都是一种资源;

5.2 什么是描述符堆?

  • 描述符堆(descriptor heap)中存有一系列描述符(可以看作是描述符数组),本质上是存放某种特定类型描述符的一块内存,我们需要为每一种类型的描述符都创建出单独的描述符堆,也可以为同一种描述符类型创建多个描述符堆;

5.3创建

D3D12_DESCRIPTOR_HEAP_DESC rtvHeapDesc;
rtvHeapDesc.NumDescriptors = mSwapChainDesc.BufferCount;
rtvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;
rtvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
rtvHeapDesc.NodeMask = 0;
ThrowIfFailed(mD3DDevice->CreateDescriptorHeap(&rtvHeapDesc, 
	IID_PPV_ARGS(mRtvHeap.GetAddressOf())));

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

可参考:MSDN,ID3D12Device::CreateDescriptorHeap method


6.创建渲染目标视图(Render Target View,RTV)

前面我们已经创建好了描述符堆,接下来应该为后台缓冲区创建一个渲染目标视图,这样才能将缓冲区绑定到渲染流水线中,使得Direct3D向缓冲区中渲染图像,可以理解为,本来内存中有一块缓冲区,但是GPU看不到它,我们创建一个视图,绑定到渲染流水线中,这样GPU就能看到这个缓冲区并往里面写东西了。

//获取描述符堆的首地址(句柄)
D3D12_CPU_DESCRIPTOR_HANDLE rtvHandle(mRtvHeap->GetCPUDescriptorHandleForHeapStart());    
// Create a RTV for each frame.     
for (UINT n = 0; n < BUFFER_COUNT; n++) 
 {        
 	ThrowIfFailed(mSwapChain->GetBuffer(n, IID_PPV_ARGS(&mRenderTargets[n])));        
 	mD3DDevice->CreateRenderTargetView(mRenderTargets[n].Get(), nullptr, rtvHandle);        
 	rtvHandle.ptr += mRtvDescriptorSize;    //每次偏移每个描述符的大小,
 }

7.设置视口

这个比较简单,

D3D12_VIEWPORT mViewport; //视口信息描述
mViewport.TopLeftX = 0;
mViewport.TopLeftY = 0;
mViewport.Width = 1366;
mViewport.Height = 768;
mViewport.MinDepth = D3D12_MIN_DEPTH;
mViewport.MaxDepth = D3D12_MAX_DEPTH;

mCommandList->RSSetViewports(1, &mViewport); //向命令列表添加命令

8.尾声

到这里整个Direct3D 12的初始化基本就完成了,当然,这里只是简单的介绍了初始化过程中的一些关键步骤,如果希望完整的学习整个流程,可以去我的GitHub上看完整的代码:https://github.com/blowingBreeze/D3D12Guide接下来我会尝试将整个流程进行封装,以免除每次都得写一串冗长的初始化代码,并开始学习渲染流水线部分;

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章