前往
大廳
主題

【程式】Direct3D 11初始化

Shark | 2021-03-22 02:10:00 | 巴幣 536 | 人氣 1446

之前寫的都是概念介紹,這篇開始實際指揮顯卡少女做事了。
可以讓人物在3D世界裡自由行動了。……想得美,必須從如何初始化開始,D3D和OpenGL初始化很繁瑣,做到單純畫面上有東西,還沒有其他功能就有很多觀念要學。

雖然介紹的是D3D11,即使顯示晶片只支援到D3D 9~10也可以執行本篇的程式,原因見最下面「Direct3D的版本號」。
但作業系統就有要求了,需要Windows 7以上。

筆者的程式教學一覽



SDK安裝方法:
VC安裝後會同時裝Windows SDK,其中就有D3D11的header和library。
TDM-GCC很久沒用了,我記得它的include資料夾有d3d11.h,也是安裝好就可以用。

D3D初始化簡單來說是:準備一個HWND,把這個HWND設成D3D的畫布。
建立視窗的方法以前寫過一篇,以那篇為基礎來製作。
如何建一個視窗—Windows API篇

#define UNICODE
#include<windows.h>
#include<d3d11.h>
#include<stdio.h>


const int WINDOW_W=200, WINDOW_H=200;
//D3D11必要物件
ID3D11Device* device;
ID3D11DeviceContext* context;
IDXGISwapChain* swapChain;
ID3D11RenderTargetView* screenRenderTarget;
//畫面顏色
float color[]={0,0,0,1};

//這三個函式在下面說明
static int initD3D(){
  ……
}
static void nextFrame(){
  ……
}
static void deinitD3D(){
  ……
}

LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam){
  switch(message){
  case WM_DESTROY:
    PostQuitMessage(0);
    return 0;
  }
  return DefWindowProc(hwnd,message,wparam,lparam);
}

int main(){
  WNDCLASS wndclass;
  ZeroMemory(&wndclass, sizeof(WNDCLASS));
  wndclass.lpfnWndProc=WndProc;
  wndclass.hCursor=LoadCursor(NULL, IDC_ARROW);
  wndclass.lpszClassName=L"window";
  RegisterClass(&wndclass);

  RECT rect={0,0,WINDOW_W,WINDOW_H};
  AdjustWindowRect(&rect,WS_CAPTION|WS_SYSMENU|WS_VISIBLE,0);

  HWND window=CreateWindow(L"window", L"title",
    WS_OVERLAPPED|WS_SYSMENU|WS_VISIBLE,CW_USEDEFAULT,CW_USEDEFAULT,
    rect.right-rect.left, rect.bottom-rect.top,
    NULL,NULL,NULL,NULL);

  if(initD3D(window)){
    printf("Can not initialize D3D11\n");
    return 0;
  }

  //開始訊息迴圈
  timeBeginPeriod(1);
  MSG msg;
  int isEnd=0;
  while(!isEnd){
    while(PeekMessage(&msg,NULL,0,0,PM_REMOVE)){
      if(msg.message==WM_QUIT){ isEnd=1; }
      DispatchMessage(&msg);
    }

    //正式寫遊戲時,遊戲邏輯放在此處

    nextFrame(); //更新畫面
    Sleep(16);
  }
  timeEndPeriod(1);

  deinitD3D();
  return 0;
}

本程式要用printf()顯示一些東西,所以用main()而不是WinMain()
跟上次的視窗程式不同的地方:
● 一開始宣告了ID3D11Device等物件,這些是D3D運作必須的。
● 填wndclass時沒有填style和hbrBackground,因為本程式整個視窗都是我們自己繪製,不需要Windows內建的方式。

timeBeginPeriod()和timeEndPeriod()之間的部分要說明一下。

這裡用PeekMessage()而不是GetMessage()取得訊息。兩者差別是GetMessage()會讓程式暫停,等收到訊息才繼續,PeekMessage()是沒有訊息也會繼續執行。「遊戲程式基本架構」有提到,遊戲程式即使玩家沒在操作時也有東西在跑,不能在這裡停下。
PeekMessage()傳回值是有訊息時傳回TRUE,沒訊息時傳回FALSE,要檢查msg.message得知是否呼叫過PostQuitMessage()。由於break不能一次跳出兩層while,用一個做記號的變數isEnd。

Sleep()是讓程式暫停一段時間,單位為毫秒(millisecond,千分之一秒)。這裡16大約是1000的1/60,即60FPS。
真正寫遊戲不會直接填16,會先檢查遊戲邏輯和繪製畫面花掉多少時間,然後算出該Sleep多久。本篇是入門教學,先不做這個處理。

至於timeBeginPeriod()和timeEndPeriod(),必須呼叫這兩個函式Sleep()才能正常運作,否則Sleep()的精度很差,即使填16也會停50ms。
個人認為這是為了相容舊版的Windows。Windows 95、98的時代因為當時的技術限制,作業系統內建計時器精度不高,必須切換成另一個模式才能以1ms的精度計時,但代價是消耗很多效能(我沒親自試過,聽說如果不小心會讓電腦當掉)。
現在的軟硬體已經沒有這個限制,但有些舊程式可能利用這個特性工作,為了讓舊程式在現在的Windows有相同行為,保留了這個特性。

接下來是本篇的重點:initD3D()、nextFrame()、deinitD3D()這三個函式。
//傳回0代表成功,非0代表失敗
static int initD3D(HWND window){
  HRESULT hr;
  DXGI_SWAP_CHAIN_DESC scd;
  ZeroMemory(&scd, sizeof(DXGI_SWAP_CHAIN_DESC));
  scd.BufferDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; //色彩格式
  scd.SampleDesc.Count = 1; //multisample
  scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
  scd.BufferCount = 1;
  scd.OutputWindow = window; //把這個視窗作為D3D的畫布
  scd.Windowed = TRUE; //視窗還是全螢幕模式

  D3D_FEATURE_LEVEL usedFeatureLevel;
  //建立D3D系統物件
  hr=D3D11CreateDeviceAndSwapChain(NULL, D3D_DRIVER_TYPE_HARDWARE,
    NULL, D3D11_CREATE_DEVICE_SINGLETHREADED,
    NULL, 0, D3D11_SDK_VERSION, &scd,
    &swapChain, &device, &usedFeatureLevel, &context);
  
  printf("result %x usedFeatureLevel %x\n",hr,usedFeatureLevel);
  if(hr!=S_OK){ return 1; }

  //取得畫面的framebuffer物件
  ID3D11Texture2D* screenTexture;
  swapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&screenTexture);
  device->CreateRenderTargetView(screenTexture, NULL, &screenRenderTarget);
  screenTexture->Release();
  return 0;
}

把資訊填好,傳給D3D11CreateDeviceAndSwapChain()讓它建立物件。
程式裡可以看到好幾次buffer這個詞,這個詞在寫程式很常用,代表記憶體裡的一塊區域,寫D3D程式時當然代表的是顯示記憶體。

3D繪圖裡作為畫布的點陣圖稱為framebuffer,畫面本身也是一張framebuffer,將我們要的framebuffer格式填在struct DXGI_SWAP_CHAIN_DESC裡,叫GPU配置一塊記憶體空間。
除了一般點陣圖有的色彩格式,還要設定multisample等等3D繪圖專用的屬性。
struct DXGI_SWAP_CHAIN_DESC說明
翻一下微軟的說明文件,可看到struct裡有別的struct,有的值是enum放在另一頁說明,總合起來內容很多,而且仔細查enum的說明會看到有些值在Windows 8以上才能用,本程式預定最低支援Windows 7,只能填Windows 7能用的值。

經筆者測試,至少需要填的資料有這些。
● scd.BufferDesc.Format:色彩格式,RGB每個顏色幾bit、有沒有alpha等等。這裡設成低位元組到高位元組是BGRA,每個分量8 bit。
全部能填的值在這裡有說明,相當多,除了建立framebuffer以外,建立貼圖、Z buffer,甚至input assembler裡設定頂點資料也會用到它。
DXGI_FORMAT enumeration
scd.BufferDesc有其他欄位是設定framebuffer寬高、螢幕更新率等等的,這些是全螢幕模式才會用到。

● scd.SampleDesc:multisample設定,目前沒有用到multisample,scd.SampleDesc.Count填1,scd.SampleDesc.Quality填0代表不使用。
● scd.BufferUsage:要把buffer用在什麼地方。這個buffer只要寫入而不用被shader讀取,填DXGI_USAGE_RENDER_TARGET_OUTPUT。
● scd.OutputWindow和scd.Windowed比較簡單,見上面註解。
● 至於scd.BufferCount,字面意思是buffer數量。更新畫面有double buffer、triple buffer、flip等方法,有些方法需要手動設定buffer數量,但這裡scd另一個欄位:scd.SwapEffect填0代表自動選擇最適合目前環境的方法,scd.BufferCount只要填>0的數即可而沒有實際影響。

再來這個函式的參數
D3D11CreateDeviceAndSwapChain()說明
1:如果電腦有兩個以上的GPU,可以用這個參數選擇,此處填NULL讓系統自動選一個。
2:如果是自動選擇driver要選哪一種,是利用GPU還是由軟體模擬。
  用D3D一般都是想利用GPU的運算能力,所以一般都填D3D_DRIVER_TYPE_HARDWARE。
  如果第一參數不是NULL,這裡要填D3D_DRIVER_TYPE_UNKNOWN。
3:如果driver是軟體模擬需要這個值,此處不需要,填NULL。
4:其他flag。
5,6:要求的feature level,可傳入一個陣列,如果第一個失敗會嘗試第二個。此處填NULL和0使用這個GPU能支援的最新版。
7:你的電腦上安裝的D3D sdk版本,填D3D11_SDK_VERSION,編譯時會自動代入數值。
8:上面宣告的struct DXGI_SWAP_CHAIN_DESC。

剩下四個參數是傳回值,D3D很多函式是本身傳回HRESULT(一個整數)代表是否成功,其他傳回值用指標傳回。
9,10,12就是程式開頭宣告的變數。三者各是什麼現在講各位大概也記不住,實際用過後自行體會比較好。
11:實際使用的feature level,可以填NULL,此處用printf()看一下系統自動選擇的是哪個。
用16進位表示,總共有哪些值可以看Windows SDK安裝資料夾裡的D3Dcommon.h。
typedef enum D3D_FEATURE_LEVEL
{
  D3D_FEATURE_LEVEL_9_1  = 0x9100,
  D3D_FEATURE_LEVEL_9_2  = 0x9200,
  D3D_FEATURE_LEVEL_9_3  = 0x9300,
  D3D_FEATURE_LEVEL_10_0 = 0xa000,
  D3D_FEATURE_LEVEL_10_1 = 0xa100,
  D3D_FEATURE_LEVEL_11_0 = 0xb000,
  D3D_FEATURE_LEVEL_11_1 = 0xb100,
  D3D_FEATURE_LEVEL_12_0 = 0xc000,
  D3D_FEATURE_LEVEL_12_1 = 0xc100
} D3D_FEATURE_LEVEL;

傳回的hr=S_OK (=0)代表成功建立物件,不等於0則用不同的值代表失敗原因。
錯誤碼的意義可以用這個工具查,用法請自己研究。
The Microsoft Error Lookup Tool

建立系統物件到這裡就完成了,但還要把它建立的framebuffer物件取出來,繪圖時會用到。使用swapChain->GetBuffer()取得它建立的點陣圖(Texture2D物件),再用它建立一個ID3D11RenderTargetView物件。建完screenRenderTarget後就不會再用到screenTexture這個變數,呼叫Release()減少reference count。

以前在另一篇解釋過reference count是什麼,這裡再講一次:D3D刪除物件的方法不是直接呼叫刪除的函式,而是物件內部有個計數器記錄有幾個物件參考到它,呼叫object->Release()會把數字減一,減到0才刪除。
這裡swapChain->GetBuffer()把screenTexture的reference count加1,呼叫Release()扣回來。

至於ID3D11Texture2D和ID3D11RenderTargetView具體是什麼現在先不介紹,等以後教到貼圖物件的時候再介紹。(但不確定我何時會寫到那裡)

(註:經過筆者測試,在這裡device->CreateRenderTargetView()不會增加screenTexture的reference count,呼叫一次Release()就把reference count減到0了,但screenTexture也不會被刪除,可能這個物件有特別處理。不過這是少數特例,大部分物件還是reference count減到0就刪除)

static void nextFrame(){
  color[0]+=1.0/60;  //修改顏色的R分量
  if(color[0]>=1){
    color[0]=0;
  }
  context->ClearRenderTargetView(screenRenderTarget, color);
  swapChain->Present(0, 0);
}
每個frame執行一次這個函式。要是畫面一片黑我們也看不出是否成功初始化D3D11,所以在這裡做一點事,讓畫面從黑漸變到紅。
context->ClearRenderTargetView()是把畫面塗上單色,用ID3D11RenderTargetView物件指定要塗上的framebuffer。
第二參數是顏色,型態是float[4],[0][1][2][3]分別是R,G,B,A分量,各分量的範圍為0~1。此處每個frame把顏色改變1/60,也就是一秒循環一次。

swapChain->Present()是「遊戲程式基本架構」提到的double buffer,繪圖指令都是先畫在back buffer,再呼叫這個函式把back buffer複製到畫面上。

static void deinitD3D(){
  device->Release();
  context->Release();
  swapChain->Release();
  screenRenderTarget->Release();
}
程式結束時把物件刪除。由於D3D是用reference count管理物件而不是呼叫函式就立刻刪除,先呼叫上層物件的Release()再呼叫下層的也沒關係,不會有「先刪除上層 → 刪除下層時需要存取上層物件,但上層物件已被刪除 → 發生錯誤」的問題。

現在的作業系統資源管理很完善,即使你偷懶沒刪除物件,程式結束時作業系統也會把程式使用的資源釋放,不會殘留在系統裡。但順便介紹一下刪除D3D物件的方法。

程式寫好,假設檔名是simpled3d.cpp,VC用這個指令build
cl simpled3d.cpp /Fesimpled3d.exe /O2 /MD /link user32.lib d3d11.lib winmm.lib
winmm.lib是為了使用timeBeginPeriod()和timeEndPeriod()。
VC命令列工具的用法見這一篇:Visual C++的命令列工具
IDE的話可能要設定library。

執行的樣子

這個例子feature level為0xa100,跟上面列的D3D_FEATURE_LEVEL比對,可看出這個晶片最高支援D3D 10.1。



- Vsync -

遊戲程式基本架構」講到的Vsync,現在很初學的階段就可以玩玩看這個東西了。

把本篇的程式修改兩個地方
1.把「Sleep(16);」註解掉
2.「swapChain->Present()」第一參數改成1。這個參數是讓程式在這裡暫停,經過幾次畫面更新後才繼續。
再編譯、執行,這次沒有用Sleep()暫停,但仍然是穩定速率執行,跑得快慢依照你螢幕的更新率。


GPU廠商可能有提供一個程式用來設定GPU,裡面有「垂直同步」或「等待垂直重新整理」之類的項目,如下圖是筆者的其中一台電腦:
  
看起來是強制開啟或關閉vsync,但測試發現這個設定不會影響vsync,vsync完全由程式呼叫swapChain->Present()決定。

nVidia的程式裡有這一段說明
  
AMD的網站也有說明,說GPU的設定只會影響OpenGL,D3D完全由程式決定。
How to Configure Radeon™ Software to Get an Optimal Gaming Experience

後來試了一下OpenGL程式,確實就會受強制開關影響。看來這個選項只影響OpenGL,D3D11完全由程式決定,D3D9沒試過,但我也沒打算學D3D9了。



- Direct3D的版本號 -

現在來解答雖然程式裡一堆型態和常數名稱有D3D11,為何D3D9、10的晶片也可以執行本程式。

現在的Direct3D有兩種版本號:feature level和DLL的版本號
介紹繪圖管線時貼過這張圖,電子妖精傳指令給顯卡少女實際要經過以下途徑。

我們平常說「某顯示晶片支援D3D12」、「某遊戲需要D3D11的顯卡」指的是feature level,是上圖驅動程式和GPU的部分,指GPU實際有的功能。

各個廠商與世代的GPU支援的功能不一樣,D3D9以前的做法是,程式必須執行時查詢晶片有哪些功能,再根據功能用不同流程繪圖。
呼叫IDirect3DDevice9::GetDeviceCaps()會傳回一大堆GPU性能的資料:
 D3DDEVCAPS_HWTRANSFORMANDLIGHT:可用GPU計算坐標和打光,不支援的話只能用CPU計算。
 D3DPRASTERCAPS_FOGRANGE, D3DPRASTERCAPS_FOGTABLE, ……:霧效果的性能。
 D3DPTEXTURECAPS_MIPMAP:支援mipmap。
   …………

詳細說明看微軟官網的這兩篇
IDirect3DDevice9::GetDeviceCaps說明
struct D3DCAPS9說明

舉個例子,東方project花映塚以前的作品執行後會產生log.txt,有如下的內容,就是在檢查功能支援度。FAQ裡也有些項目是處理GPU的問題。
HAL で動作します
現在のビデオカード、及びドライバの能力詳細
  …………
 -- デバイス能力 ------------------------------
 System -> 非ローカルVRAMブリット : 不可
 ハードウェア T&L : 不可
 非ローカルVRAMからテクスチャ取得 : 不可
 システムメモリからテクスチャ取得 : 不可
  …………
風神錄以後沒打算再支援較舊的晶片,log.txt就沒這些內容了。

這樣程式寫起來繁瑣,一般玩家也難以知道哪個晶片可以執行哪些程式。D3D10以後改用另一個做法:用feature level表示,先制定規格,規定各版本至少要有哪些功能,晶片達到條件才能算是支援某某版本。

例如某個晶片支援geometry shader、不支援compute shader、貼圖大小最大8192×8192,廠商可以在產品標上「支援Direct3D 10.0」,程式作者可以在系統需求寫「需要Direct3D 11.0」,這樣就能清楚看出晶片不能執行這個程式。
呼叫D3D11CreateDeviceAndSwapChain()時用第5,6參數指定feature level會限制你能用的功能,如果指定D3D10,即使你的硬體支援D3D11,之後使用D3D11的功能也會跳error。這可以防止一個問題:假如作者設定程式最低支援D3D10,但作者的電腦支援D3D11,寫程式時不小心用了D3D11才有的功能而沒有發現,程式拿到D3D10的電腦就無法執行。

程式裡很多型態和常數名稱帶有D3D11,且執行時需要d3d11.dll,這是DLL的版本號。電腦裡有哪個DLL是綁Windows版本,Vista以上才有d3d10.dll,7以上才有d3d11.dll。
DLL實際放的位置
32bit版:C:\Windows\SysWOW64\
64bit版:C:\Windows\System32\

兩樣東西的版本號是分開的,即使晶片的feature level只到10.0,只要安裝了Windows 7就有D3D11的DLL,所以D3D10的晶片也能執行本篇的程式。

創作回應

Gawin
專業
2021-03-22 02:14:41
KK
這系列真的超棒的![e19]
2021-03-22 11:03:40

更多創作