從零開始手敲次世代遊戲引擎(DX12特別篇)

自從我們在從零開始手敲次世代遊戲引擎(十六)進行了DX11到DX12的升級之後,我們已經很久沒有進行DX12相關的更新了。本篇我們將DX12相關的實現統合進我們的代碼樹,使其輸出與OpenGL相同的畫面。

下面是實際執行的結果:

https://www.zhihu.com/video/952143729537114112

下面是具體的實現步驟和注意點。

首先,我們根據OpenGLGraphicsManager,提取出通用的介面,加入到基類GraphicsManager當中。這個介面不能含有任何平台相關或者RHI依存的類型或者結構:

#pragma once#include "IRuntimeModule.hpp"#include "geommath.hpp"#include "Image.hpp"#include "Scene.hpp"namespace My { class GraphicsManager : implements IRuntimeModule { public: virtual ~GraphicsManager() {} virtual int Initialize(); virtual void Finalize(); virtual void Tick(); virtual void Clear(); virtual void Draw();#ifdef DEBUG virtual void DrawLine(const Vector3f &from, const Vector3f &to, const Vector3f &color); virtual void DrawBox(const Vector3f &bbMin, const Vector3f &bbMax, const Vector3f &color); virtual void ClearDebugBuffers();#endif protected: virtual bool InitializeShaders(); virtual void ClearShaders(); virtual void InitializeBuffers(const Scene& scene); virtual void ClearBuffers(); virtual void InitConstants(); virtual void CalculateCameraMatrix(); virtual void CalculateLights(); virtual void UpdateConstants(); virtual void RenderBuffers(); protected: struct DrawFrameContext { Matrix4X4f m_worldMatrix; Matrix4X4f m_viewMatrix; Matrix4X4f m_projectionMatrix; Vector3f m_lightPosition; Vector4f m_lightColor; }; DrawFrameContext m_DrawFrameContext; }; extern GraphicsManager* g_pGraphicsManager;}

接下來,在提取出的這些通用流程(步驟)當中,有些處理過程也是不因平台或者RHI而改變的,這部分我們可以在基類當中直接實現。比如MVP矩陣的初始化,標準繪製流程(Update-Clear-Draw):

int GraphicsManager::Initialize(){ int result = 0; InitConstants(); return result;}void GraphicsManager::Finalize(){ ClearDebugBuffers(); ClearBuffers(); ClearShaders();}void GraphicsManager::Tick(){ if (g_pSceneManager->IsSceneChanged()) { cout << "[GraphicsManager] Detected Scene Change, reinitialize buffers ..." << endl; ClearBuffers(); ClearShaders(); const Scene& scene = g_pSceneManager->GetSceneForRendering(); InitializeShaders(); InitializeBuffers(scene); g_pSceneManager->NotifySceneIsRenderingQueued(); } UpdateConstants(); Clear(); Draw();}void GraphicsManager::UpdateConstants(){ // Generate the view matrix based on the cameras position. CalculateCameraMatrix(); CalculateLights();}void GraphicsManager::Clear(){}void GraphicsManager::Draw(){ UpdateConstants(); RenderBuffers();}void GraphicsManager::InitConstants(){ // Initialize the world/model matrix to the identity matrix. BuildIdentityMatrix(m_DrawFrameContext.m_worldMatrix);}void GraphicsManager::CalculateCameraMatrix(){ auto& scene = g_pSceneManager->GetSceneForRendering(); auto pCameraNode = scene.GetFirstCameraNode(); if (pCameraNode) { m_DrawFrameContext.m_viewMatrix = *pCameraNode->GetCalculatedTransform(); InverseMatrix4X4f(m_DrawFrameContext.m_viewMatrix); } else { // use default build-in camera Vector3f position = { 0, -5, 0 }, lookAt = { 0, 0, 0 }, up = { 0, 0, 1 }; BuildViewMatrix(m_DrawFrameContext.m_viewMatrix, position, lookAt, up); } float fieldOfView = PI / 2.0f; float nearClipDistance = 1.0f; float farClipDistance = 100.0f; if (pCameraNode) { auto pCamera = scene.GetCamera(pCameraNode->GetSceneObjectRef()); // Set the field of view and screen aspect ratio. fieldOfView = dynamic_pointer_cast<SceneObjectPerspectiveCamera>(pCamera)->GetFov(); nearClipDistance = pCamera->GetNearClipDistance(); farClipDistance = pCamera->GetFarClipDistance(); } const GfxConfiguration& conf = g_pApp->GetConfiguration(); float screenAspect = (float)conf.screenWidth / (float)conf.screenHeight; // Build the perspective projection matrix. BuildPerspectiveFovRHMatrix(m_DrawFrameContext.m_projectionMatrix, fieldOfView, screenAspect, nearClipDistance, farClipDistance);}void GraphicsManager::CalculateLights(){ auto& scene = g_pSceneManager->GetSceneForRendering(); auto pLightNode = scene.GetFirstLightNode(); if (pLightNode) { m_DrawFrameContext.m_lightPosition = { 0.0f, 0.0f, 0.0f }; TransformCoord(m_DrawFrameContext.m_lightPosition, *pLightNode->GetCalculatedTransform()); auto pLight = scene.GetLight(pLightNode->GetSceneObjectRef()); if (pLight) { m_DrawFrameContext.m_lightColor = pLight->GetColor().Value; } } else { // use default build-in light m_DrawFrameContext.m_lightPosition = { -1.0f, -5.0f, 0.0f}; m_DrawFrameContext.m_lightColor = { 1.0f, 1.0f, 1.0f, 1.0f }; }}

接下來就是從這個GraphicsManager派生出D3d12GraphicsManager,重載諸如Initialize、Finalize、Clear、Draw、InitializeShaders、InitializeBuffers、RenderBuffers、ClearShaders、ClearBuffers這些方法,並在其中調用我們在從零開始手敲次世代遊戲引擎(十六)並在當中所寫的那些創建過程,來實現DX12繪圖上下文的創建。

在導入從零開始手敲次世代遊戲引擎(十六)所寫的代碼的時候,大部分可以直接拷貝粘貼。主要遇到的問題有以下幾個方面:

1.

頂點緩衝區非交錯式(non-interlaced)存儲。在從零開始手敲次世代遊戲引擎(十六)當中,我們的頂點數據(position,normal,uv)是交叉存儲在Buffer上面的。也就是說,我們是採用的AOS(Arrary Of Structure)的方式,Buffer上面的數據結構是諸如:

頂點1.position,頂點1.normal,頂點1.uv,頂點2.position, 頂點2.normal,頂點2.uv ...

這樣的形式。然而,我們的SceneObject結構當中,為了能夠方便地支持不同的頂點數據結構(任意個頂點屬性),我們採用的是SOA(Structure Of Array)的形式。也就是說我們的頂點數據在內存上的結構是下面這個樣子的:

頂點1.position,頂點2.position,頂點3.position,...

頂點1.normal,頂點2.normal,頂點3.normal,...

頂點1.uv,頂點2.uv,頂點3.uv,...

對於這種情況,我們實際上輸入給Shader的是3個Buffer,每個Buffer當中的偏移量都是零。需要如下聲明頂點數據輸入格式:

注意比較與從零開始手敲次世代遊戲引擎(十六)當中的不同。

2.

為了實現材質,我們需要通過Constant Buffer傳遞物體的材質參數給Shader。這個參數是逐物體(DrawBatch)的。也就是說,雖然同樣是Constant Buffer,它的性質與MVP矩陣有很大不同(MVP矩陣對於每幀來說是固定的)

由於DX12是採用的錄製繪圖命令-->提交給GPU繪製這樣的(非立即)執行形式,我們不能在繪製過程當中去改變Constant Buffer的內容(這裡指常規情況。確實存在一些高級技巧可以在GPU執行繪圖命令的過程當中改變Constant Buffer的內容,但是不屬於我們這裡需要討論的範圍),所以我們只能事先將所有的材質參數都上傳到Constant Buffer當中。

但是如果這樣做,Shader如何知道現在繪製的是哪個物體,應該使用哪個參數呢?

我們知道,在DX12當中,Shader是通過一個叫RootSignature的結構來獲取輸入數據的結構和地址的。在RootSignature當中,通常記錄了4種GPU可以訪問的內存空間(Buffer)的信息(成為描述子(Descriptor)),它們分別是:

  1. CBV (Constant Buffer View):描述了Constant Buffer的地址和屬性
  2. SRV:描述了諸如貼圖等,由Shader進行只讀訪問的內存空間
  3. UAV:描述了可以由Shader進行隨機讀寫的內存空間
  4. Sampler :描述了存放紋理採樣過濾器的Buffer的地址和屬性

描述子示意圖。圖片來源:參考引用1

這4種描述子都可以以下面這3種形式當中的1種,在RootSignature當中進行指定:

  1. 立即數(Root Constants)。我們可以將Shader所需的參數作為立即數放在RootSignature當中,由Shader進行使用;
  2. 根描述子(Root Descriptor)。我們可以將參數先放在一個Heap當中,然後將這個Heap的描述子直接放在RootSignature當中;
  3. 描述子表(Descriptor Table)。我們可以將參數先放在一個Heap當中,然後將這個Heap的描述子放在另外一個Heap(描述子表)當中,然後將這個描述子在描述子表當中的位置放入到RootSignature當中

這裡需要注意的是,目前DX12的RootSignature對於每種描述子類型都只能創建至多一個Slot。也就是說,我們無法在一個RootSignature當中,混用立即數類型的CBV和根描述子類型的CBV,或者其他組合。

DX12的描述子表。不同的Shader使用不同的表來訪問顯存上的資源。圖片來源:參考引用1

對於我們這裡的情況,由於我們需要提供如下兩組Constant給Shader:

cbuffer PerFrameConstants : register(b0){ float4x4 m_worldMatrix; float4x4 m_viewMatrix; float4x4 m_projectionMatrix; float4 m_lightPosition; float4 m_lightColor;};cbuffer PerBatchConstants : register(b1){ float4x4 objectMatrix; float4 ambientColor; float4 diffuseColor; float4 specularColor; float specularPower;};

而其中PerBatchConstants對於每個被繪製的物體都有一組取值,所以實際上我們上傳到GPU內存(顯存)的內容是這樣的:

(PerFrameConstants),(PerBatchConstants),(PerBatchConstants),(PerBatchConstants),(PerBatchConstants),(PerBatchConstants), ...

但是我們在Shader裡面只能(只需要)看到一個PerBatchConstants,這個綁定就是通過在錄製繪圖命令的時候指定當前參照的描述子表當中的位置來實現的:

簡單來說,我們創建了一個2 * 最大繪製物體數的描述子表。這個表當中每兩行為一組,第一行包括一個指向(PerFrameConstants)的描述子,第二行則包括一個指向對應場景物體的(PerBatchConstants)的描述子。在錄製繪圖指令的時候,我們每完成了一個物體,就將RootSignature當中的指針往下移動兩行,這樣就可以實現將物體的材質參數依次傳遞給Shader的目的。

原理不複雜,但是因為這裡是一個3層間接引用關係,很容易出錯。事實上,我也是斷斷續續花了好幾個月的時間,才終於把這部分調通。所以大家編寫這部分代碼的時候一定要仔細,最好畫個圖安排清楚再編碼。這部分也是與之前的DX版本最為顯著不同的部分。

那麼這麼做是否值得呢?答案是肯定的。雖然複雜,但是我們獲得了直接控制顯存當中內容擺放方式的手段,如果妥善運用可以極大地改善CPU與GPU之間的帶寬利用,以及GPU顯存的利用率,提高整體性能和幀率。當然,相應的,如果運用不當,那麼可能獲得遠低於預期的效果,甚至是錯誤的結果。

本篇所對應代碼在netwarm007/GameEngineFromScratch當中。

參考引用

Introduction to Resource Binding in Microsoft DirectX* 12software.intel.com圖標
推薦閱讀:

[CppCon14] How Ubisoft Montreal develops games formulticore – before and after C++11
從零開始手敲次世代遊戲引擎(四十二)
[翻譯]DOOM(2016) - Graphic Study
Dirty Game Engine
[GDC16] Optimizing the Graphics Pipeline with Compute

TAG:遊戲引擎 | DirectX12 | 編程 |