前往
大廳
主題

【程式】Direct3D 11 架設基本繪圖管線

Shark | 2021-05-15 02:35:52 | 巴幣 1222 | 人氣 1274

把「Direct3D與OpenGL的繪圖管線(上)」的圖拿出來看一下。

這次要做的是把左邊列的東西一樣一樣建出來,除了constant buffer、貼圖和sampler以外,本篇先不用到貼圖。

本篇重點是將頂點資料和shader傳給GPU。D3D9不一定要用到shader,而且有一個函式DrawPrimitiveUP()可以「上傳頂點資料→繪製」兩個工作同時做。但D3D10以後就一定要寫vertex shader和pixel shader,且「要求顯卡少女配置記憶體空間→上傳頂點資料→繪製」必須手動一步一步做,所以初學階段就得學習怎麼做這兩件事。

因為本篇新介紹的東西很多,在此把vertex buffer和input layout的一些細節略過,先寫個能用的程式再說,之後教到貼圖和shader輸入輸出時再介紹。

之前的教學可以看這篇目錄,「Direct3D與OpenGL」的部分。
Shark流程式教學一覽



#define UNICODE
#include<windows.h>
#include<d3d11.h>
#include<d3dcompiler.h> //使用D3DCompile()
#include<stdio.h>
#include<stdint.h>


const int WINDOW_W=200, WINDOW_H=200;
//D3D11 global狀態
ID3D11Device* device;
ID3D11DeviceContext* context;
IDXGISwapChain* swapChain;
ID3D11RenderTargetView* screenRenderTarget;
//D3D11資料和設定值
ID3D11VertexShader* vertexShader;
ID3D11PixelShader* pixelShader;
ID3D11Buffer* vertexData;
ID3D11InputLayout* inputLayout;
ID3D11RasterizerState* rsState;
ID3D11DepthStencilState* dsState;
ID3D11BlendState* blendState;

//要傳給GPU的資料
const float VERTICES[]={
0, 0.8, 0.8, 0, -0.5, -0.5,
};

const char SHADER[]=" \
float4 vsMain(in float2 pos:P):SV_POSITION { \
  return float4(pos, 0, 1); \
} \
\
float4 psMain():SV_TARGET { \
  return float4(1,1,1,1); \
} \
"
;

//這五個函式在下面說明
static int initD3D(HWND window){
  ……
}
static int initSettings(){
  ……
}
static void nextFrame(){
  ……
}
static void deinitSettings(){
  ……
}
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"simplepipeline",
    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;
  }
  if(initSettings()){
    return 0;
  }

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

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

    nextFrame(); //更新畫面

    QueryUnbiasedInterruptTime(&nextTime); //單位為100ns (10^-7 sec)
    ULONGLONG elapsedTime=(nextTime-prevTime)/10000; //除以10000換算成毫秒 (10^-3 sec)
    int32_t sleepTime=16-elapsedTime;
    if(sleepTime>0){ Sleep(sleepTime); }
  }
  timeEndPeriod(1);

  deleteObjects();
  deinitD3D();
  return 0;
}
大部分和「Direct3D 11初始化」相同,不同的地方如下。
  • 多了一行#include<d3dcompiler.h>,用來編譯shader。
  • 多了一些變數儲存pipeline裡的設定值。
    本篇把這些物件宣告成全域變數,程式裡有一大堆全域變數其實不是好的寫法,本篇儘量把主題以外的東西省略,寫正式專案時可以思考一下怎樣組織資料。
  • 計時器這次採用正式的做法了
    1.Sleep()後立刻呼叫QueryUnbiasedInterruptTime()取得目前時間。
    2.做完處埋輸入、遊戲邏輯、繪圖後再取得一次目前時間,將兩者相減算出經過的時間。
    3.再算出應該暫停多久,如果經過時間>=16ms就不呼叫Sleep()了。
MSDN的QueryUnbiasedInterruptTime()說明
QueryUnbiasedInterruptTime()傳回的是電腦開機後到現在的時間,這個函式要Windows 7以上才有,之前的Windows有另一個函式「DWORD timeGetTime()」,傳回的單位是毫秒,但筆者有預留以後做Windows app的空間,timeGetTime()在Windows app不能用,所以改用QueryUnbiasedInterruptTime()。

Windows有其他時間函式可以量到小於毫秒,如QueryPerformanceCounter(),但sleep函式最細只能計時到毫秒,測量到比毫秒細也沒用。

題外話:時間超過整數能儲存的最大值會溢位(從0重新開始增加),DWORD型態毫秒可以計時到49.7天,Windows 95、98因為計時器溢位的問題開49.7天不關機就會當掉,之後的Windows有修正這個問題。
QueryUnbiasedInterruptTime()單位為100奈秒,用64位元整數儲存,溢位需要58000年以上。
那如果照本篇的寫法溢位了會怎樣?電腦計算整數加減法有環繞的現象,如果是32位元整數,0xffffffff過16ms會變成15,計算15-0xffffffff仍然可以得知經過16ms,不會出問題。

還多了這部分。
//要傳給GPU的資料
const float VERTICES[]={
0, 0.8, 0.8, 0, -0.5, -0.5,
};

const char SHADER[]=" \
float4 vsMain(in float2 pos:P):SV_POSITION { \
  return float4(pos, 0, 1); \
} \
\
float4 psMain():SV_TARGET { \
  return float4(1,1,1,1); \
} \
"
;
寫一組最簡單的shader,vertex shader把坐標原封不動傳給rasterizer,pixel shader把像素塗成白色,shader的輸出入、語法等等的之後另寫一篇介紹。
這裡把vertex shader和pixel shader寫在同一個字串裡,D3D有能力從裡面區分兩者,下面會看到方法。

三個頂點坐標是(0, 0.8)、(0.8, 0)、(-0.5, -0.5),以前有講過D3D和OpenGL的畫面坐標是-1 ~ 1,可以預想畫出的三角形是這樣。


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;
  scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
  scd.BufferCount = 1;
  scd.OutputWindow = window; //把這個視窗作為D3D的畫布
  scd.Windowed = TRUE; //視窗還是全螢幕模式

  //建立D3D系統物件,feature level=10.0

  D3D_FEATURE_LEVEL featureLevel=D3D_FEATURE_LEVEL_10_0;
  hr=D3D11CreateDeviceAndSwapChain(NULL, D3D_DRIVER_TYPE_HARDWARE,
    NULL, D3D11_CREATE_DEVICE_SINGLETHREADED,
    &featureLevel, 1, D3D11_SDK_VERSION,&scd,
    &swapChain,&device,NULL,&context);
  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;
}
initD3D()和之前大同小異。



之後的initSettings()是重點,要一步一步看

-Vertex & Pixel Shader-

//傳回0成功,傳回非0失敗
static int initSettings(){
  HRESULT hr;
  //vertex shader
  ID3DBlob* vsBlob=0,*psBlob=0, *errorMessageBlob;
  hr=D3DCompile(SHADER, sizeof(SHADER), NULL, NULL,
    NULL, "vsMain", "vs_4_0",0, 0,&vsBlob, &errorMessageBlob);
  if(hr!=S_OK){
    printf("vertex shader:\n%s", errorMessageBlob->GetBufferPointer());
    errorMessageBlob->Release();
  }
  //pixel shader
  hr=D3DCompile(SHADER, sizeof(SHADER), NULL, NULL,
    NULL, "psMain", "ps_4_0",0, 0,&psBlob, &errorMessageBlob);
  if(hr!=S_OK){
    printf("pixel shader:\n%s", errorMessageBlob->GetBufferPointer());
    errorMessageBlob->Release();
  }
  //如果shader有錯誤,刪除已建立的物件並return
  if(vsBlob==0 || psBlob==0){
    if(vsBlob){ vsBlob->Release(); }
    if(psBlob){ psBlob->Release(); }
    return 1;
  }
先從shader開始,如果shader有錯就直接結束程式。

shader是給GPU執行的程式,跟C/C++一樣要先把程式碼編譯成binary才能給GPU使用,D3D shader的程式語言稱為HLSL,要先把code編譯成一種中間碼(byte code)再傳給驅動程式。
編譯方式有兩種:
1. 把原始碼附在程式裡,執行時使用D3DCompile()編譯。
2. 用一個工具:fxc.exe事先編譯,發佈程式時只要附byte code。
本篇用第一種,第二種以後再介紹。

MSDN的D3DCompile()說明
本篇用到的參數如下:
1:程式碼。
2:程式碼長度(byte數)。
3:shader名稱,程式執行不會用到,只用在顯示錯誤訊息,不需要可填NULL。
6:程式進入點。C/C++規定程式從main()或WinMain()開始執行,HLSL沒規定程式起點,而是用這個參數指定。
因此可以把vertex和pixel shader寫在同一個字串,用這個參數告訴compiler哪個函式是哪個shader。
7:target,指定shader版本號和種類。
版本號要跟「Direct3D 11初始化」提到的feature level對應,如果程式預定最低支援D3D10的晶片就填vs_4_0或ps_4_0,這樣如果shader裡用到D3D11以後的東西會視為error。
所有能填的值看這篇 MSDN : Specifying Compiler Targets
第10、11參數是傳回值,把byte code和錯誤訊息存在兩個ID3DBlob物件,可以如上印出錯誤訊息得知shader哪裡寫錯。

  //create shader objects
  hr=device->CreateVertexShader(vsBlob->GetBufferPointer(),
    vsBlob->GetBufferSize(),NULL, &vertexShader);
  hr=device->CreatePixelShader(psBlob->GetBufferPointer(),
    psBlob->GetBufferSize(),NULL, &pixelShader);
  psBlob->Release();
  context->VSSetShader(vertexShader,NULL,0);
  context->PSSetShader(pixelShader, NULL,0);
編譯出byte code之後用device->CreateVertexShader()和device->CreatePixelShader(),驅動程式會將byte code轉換成晶片固有的指令,並且用最後一個參數傳回代表這個shader的物件。
psBlob不會再用到,呼叫Release()將它刪除,vsBlob之後還會用到,先不刪除。
最後還要用context->VSSetShader()和context->PSSetShader()套用物件,如「Direct3D與OpenGL的繪圖管線(下)」所說,可以想成是叫顯卡少女把物件放在工作台上,之後呼叫Draw()就會套用這兩個shader。

-Vertex buffer-

  //vertex buffer
  D3D11_BUFFER_DESC buDesc;
  ZeroMemory(&buDesc, sizeof(D3D11_BUFFER_DESC));
  buDesc.ByteWidth = sizeof(VERTICES);
  buDesc.Usage = D3D11_USAGE_IMMUTABLE;
  buDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER;
  D3D11_SUBRESOURCE_DATA data;
  data.pSysMem = VERTICES;
  hr=device->CreateBuffer(&buDesc, &data, &vertexData);
  const UINT stride=sizeof(float)*2;
  const UINT offset=0;
  context->IASetVertexBuffers(0,1,&vertexData,&stride,&offset);
再來把頂點坐標從主記憶體傳給顯卡少女,先告訴顯卡少女要配置幾byte的空間,再將資料傳給她。
D3D11_BUFFER_DESC說明
ID3D11Device::CreateBuffer()說明
ID3D11DeviceContext::IASetVertexBuffers()說明
D3D11裡只要配置一塊記憶體(建立buffer或texture物件)都要填以下欄位:
ByteWidth:byte數。
Usage與BindFlags:這裡D3D11_USAGE_IMMUTABLE代表資料會在建立物件時一併上傳,之後不可修改,D3D11_BIND_VERTEX_BUFFER代表這一塊空間要儲存頂點資料,其他還能填什麼值之後再介紹。
D3D11_SUBRESOURCE_DATA:如果想在建立物件同時把資料上傳,需要填這個struct。

IASetVertexBuffers()第二參數是陣列長度,第三~第五參數可以給ID3D11Buffer*或UINT的陣列,頂點資料可以來自兩個以上的buffer物件,以後講到貼圖坐標會示範這些參數的用法。

-Input assembler-

之後都是設定值,不用把資料從CPU傳到顯示記憶體就比較簡單了。D3D11是把同一個步驟的設定值都包在一個物件裡,之後想調整設定就是套用物件。
建立物件大多是這樣的步驟:
1. 將設定值填入一個struct。
2. 把struct傳入device->CreateXXXX()產生物件,函式本身傳回錯誤碼代表是否成功,物件用指標傳回。
3. 呼叫context的成員函式套用物件。
物件建好之後就不可修改,如果畫不同物體想用不同設定值,要事先建立好幾個物件看情況套用。
不過struct裡有些目前沒用到的功能也必須填,所以以下會看到一些目前還沒教到的東西。

  //input assembler
  D3D11_INPUT_ELEMENT_DESC layoutDesc;
  ZeroMemory(&layoutDesc, sizeof(D3D11_INPUT_ELEMENT_DESC));
  layoutDesc.SemanticName = "P";
  layoutDesc.Format = DXGI_FORMAT_R32G32_FLOAT;
  hr=device->CreateInputLayout(&layoutDesc, 1, vsBlob->GetBufferPointer(),
    vsBlob->GetBufferSize(),&inputLayout);
  vsBlob->Release();
  context->IASetInputLayout(inputLayout);
  context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
Direct3D與OpenGL的繪圖管線(上)」講過,把頂點資料傳給顯卡少女時,她看到的只是一堆byte,還要告訴她資料是什麼格式。D3D的方法是將資訊填入struct D3D11_INPUT_ELEMENT_DESC再建立InputLayout物件,有幾項資料就要填幾個struct,本篇每個頂點只有位置一項資料,只要填一個struct。
D3D11_INPUT_ELEMENT_DESC說明
ID3D11Device::CreateInputLayout()說明

本篇重要的欄位只有Format一個,表示向量有幾個分量、每個分量幾byte等等,所有能填的值跟initD3D()裡framebuffer的顏色格式一樣,看這篇。
DXGI_FORMAT enumeration說明
這個例子位置有x,y二個分量,分量是32 bit float型態,填DXGI_FORMAT_R32G32_FLOAT。
有沒有覺得奇怪,這個值是明明是坐標,為何常數名稱是RGBA?以前fixed-function pipeline的時代坐標和顏色要區分開,但有了shader之後很多事都是人寫程式控制了。顏色RGBA和坐標XYZW都可視為四維向量,GPU只需要知道傳過來的向量是幾維,它實際的作用由人寫的程式決定。

SemanticName此時不方便解釋,之後教到貼圖,有兩項以上的資料比較能看出效果。
建立物件的CreateInputLayout()其中兩個參數是vertex shader的byte code,這個函式需要把SemanticName跟shader程式碼比對,做完這一步才能將vsBlob刪除。

除了套用input layout物件以外,還要呼叫IASetPrimitiveTopology()指定幾何形狀。
頂點數量不是在這裡告訴顯卡少女,而是之後呼叫Draw()時指定。

-Rasterizer-

  //rasterizer
  D3D11_RASTERIZER_DESC rsDesc;
  ZeroMemory(&rsDesc, sizeof(D3D11_RASTERIZER_DESC));
  rsDesc.FillMode = D3D11_FILL_SOLID;
  rsDesc.CullMode = D3D11_CULL_NONE;
  hr=device->CreateRasterizerState(&rsDesc, &rsState);
  context->RSSetState(rsState);

  D3D11_VIEWPORT viewport;
  viewport.TopLeftX = 0;
  viewport.TopLeftY = 0;
  viewport.Width = WINDOW_W;
  viewport.Height = WINDOW_H;
  viewport.MinDepth = 0;
  viewport.MaxDepth = 1;
  context->RSSetViewports(1, &viewport);
D3D11_RASTERIZER_DESC說明
這一步比較常用的設定值是culling和multisample,這個例子要關閉這兩個功能。
除了建立RasterizerState物件以外還要設定viewport,X、Y的單位是像素而不是-1~1,Z的範圍是0~1。
忘記culling和viewport是什麼的話可以看這篇複習:Direct3D與OpenGL的繪圖管線(上)

-Depth and stencil-

  //depth stencil
  D3D11_DEPTH_STENCIL_DESC dsDesc;
  ZeroMemory(&dsDesc, sizeof(D3D11_DEPTH_STENCIL_DESC));
  hr=device->CreateDepthStencilState(&dsDesc, &dsState);
  context->OMSetDepthStencilState(dsState,0);
D3D11_DEPTH_STENCIL_DESC說明
本篇不做depth test和stencil test,也根本沒配置depth buffer,直接用ZeroMemory()把其中兩個欄位:DepthEnable和StencilEnable填0。

-Blend-

  //blend
  D3D11_BLEND_DESC blendDesc;
  ZeroMemory(&blendDesc,sizeof(D3D11_BLEND_DESC));
  D3D11_RENDER_TARGET_BLEND_DESC* blendDesc2=blendDesc.RenderTarget;
    //把blendDesc.RenderTarget[0]取出來讓下面的程式簡短一些,
    //否則以下都要寫成blendDesc.RenderTarget[0].???

  blendDesc2->BlendEnable = 1;
  blendDesc2->RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
  blendDesc2->BlendOp = D3D11_BLEND_OP_ADD;
  blendDesc2->SrcBlend = D3D11_BLEND_ONE;
  blendDesc2->DestBlend = D3D11_BLEND_ZERO;
  blendDesc2->BlendOpAlpha = D3D11_BLEND_OP_ADD;
  blendDesc2->SrcBlendAlpha = D3D11_BLEND_ONE;
  blendDesc2->DestBlendAlpha = D3D11_BLEND_ZERO;
  hr=device->CreateBlendState(&blendDesc, &blendState);
  context->OMSetBlendState(blendState, 0, 0xffffffff);
D3D11_BLEND_DESC說明
其中一個成員「D3D11_RENDER_TARGET_BLEND_DESC RenderTarget[8]」是8個元素的陣列,D3D可以一次draw call同時畫在多個framebuffer上,每個framebuffer可以套用不同blend設定,這個例子只輸出到一個framebuffer,所以只填[0]。

Blend是指pixel的輸出和現在畫面上的像素要用什麼算式計算,本篇用pixel shader的輸出直接取代掉舊的值。

  context->OMSetRenderTargets(1, &screenRenderTarget, 0);
  return 0;
}
最後一步,還要告訴顯卡少女要畫在哪個framebuffer,到這裡才把所有物件都準備完成。
因為可以一次draw call同時畫在多個framebuffer,OMSetRenderTargets()第二參數是個陣列,第一參數是陣列長度。

本篇把錯誤檢查的code省略,以下函式有用一個變數hr記錄傳回值,但沒有檢查它的值。
hr=device->CreateVertexShader(……);
hr=device->CreatePixelShader(……);
hr=device->CreateBuffer(……);
hr=device->CreateInputLayout(……);
hr=device->CreateRasterizerState(……);
hr=device->CreateDepthStencilState(……);
hr=device->CreateBlendState(……);
如果struct成員或函式參數填了一個無效的值,hr會不等於S_OK(=0)。例如buDesc.ByteWidth必須填一個正數(因為配置0 byte的buffer無意義),填0會傳回錯誤碼。
但傳回值不能幫人檢查邏輯錯誤,如culling把順逆時針設錯,這時會看到傳回值沒問題,物件都有建出來,但畫面上顯示的不是我們想要的。
還有套用物件的函式「context->Set什麼的」傳回值是void,如果填錯參數或物件它不會告訴你,這時通常會看到物體沒被畫出來,但函式傳回值都沒問題。
所以D3D的程式有時候很難debug,有時候用一些debug工具會比較方便。



static void nextFrame(){
  const float color[]={0,0,0,0};
  context->ClearRenderTargetView(screenRenderTarget, color);
  context->Draw(3, 0);
  swapChain->Present(0, 0);
}
實際畫出畫面。用ClearRenderTargetView()把畫面塗成全黑,再用Draw()畫出三角形,第一參數是頂點數量,「頂點有3個」的資訊在這裡才告訴顯卡少女。
本篇只畫一個物體,所以各種設定值和vertex buffer、shader只在初始化時套用一次,之後就不更換,如果要畫多個物體,每次繪製之前要呼叫函式更換物件。
另外「Direct3D與OpenGL的繪圖管線(下)」有講到,呼叫Draw()的時候才真正使用這些物件,因此initSettings()裡套用物件的順序可以互換,不用照繪圖管線的步驟。

static void deinitSettings(){
  vertexShader->Release();
  pixelShader->Release();
  inputLayout->Release();
  vertexData->Release();
  rsState->Release();
  dsState->Release();
  blendState->Release();
}
跟刪除device和context相同,刪除物件的方法是呼叫Release()。

static void deinitD3D(){
  device->Release();
  context->Release();
  swapChain->Release();
  screenRenderTarget->Release();
}
deinitD3D()和之前一樣。

假設檔名是simplepipeline.cpp,用這個指令build。
cl simplepipeline.cpp /Fesimplepipeline.exe /O2 /MD
  /link user32.lib d3d11.lib winmm.lib d3dcompiler.lib
多了一個d3dcompiler.lib是因為用到D3DCompile()。

執行的樣子




這次改良程式的syntax highlighting。
本篇的color theme是Monokai,原本是Sublime Text使用的,後來有移植到很多軟體上,Visual Studio Code安裝好後就有內建,Code::Blocks也有人做出來,但我在本篇用的配色沒完全按照VS code的。

VS code的例子。

創作回應

md9830415
最近在參考github上的tinyrender+games101自學軟體渲染器,你的文章都對我非常有幫助
2021-05-15 07:30:32

更多創作