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接下來我會嘗試將整個流程進行封裝,以免除每次都得寫一串冗長的初始化代碼,並開始學習渲染流水線部分;

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