創作內容

20 GP

【程式】Direct3D與OpenGL的繪圖管線(上)

作者:Shark│2020-09-05 03:26:29│巴幣:1,234│人氣:1640
本篇介紹寫Direct3D和OpenGL程式時,最先要知道的知識:繪圖管線(Graphics Pipeline),了解顯示晶片是怎麼工作的才能正確駕馭它。
全文很長,分成兩篇。

直接用Direct3D和OpenGL寫程式不用說,使用別人做好的3D引擎也最好了解一下繪圖管線,有時候碰到莫名其妙的問題才知道怎麼解。
現在不能全部看懂沒關係,先有個印象,建議的學習方法是寫D3D和OpenGL程式的時候跟本篇對照著看。

本篇再次讓哭哭喵的顯示卡擬人化作品《顯卡少女》客串。 《顯卡少女》介紹
電子妖精讓另一個角色出場,每次都是艾莉兒出場沒意思。
不過這次以介紹程式知識為主,角色的戲份很少。

2020/9/7更新:補上framebuffer的說明



這個例子先繪製一個簡單的東西:邊長為1的正方體,但一個簡單的物體就有很多要注意的地方。


標出三個三角形做例子:a,b,c,可以跟下面的陣列對照。

數學裡正方體是由六個正方形組成,但因為D3D和OpenGL只能繪製三角形,必須把一個正方形分成兩個三角形,變成12個面。
(註:舊版D3D和OpenGL有支援四邊形,早期也有些GPU是以四邊形為主,但現在的D3D和OpenGL只支援三角形,想繪製四以上的多邊形得靠建模軟體和程式事先分割成三角形,再將資料送給GPU)
polygon(多邊形)這個詞指GPU能直接畫的三角形和四邊形,以下有些地方就直接稱三角形為polygon。

電子妖精先把頂點放入一個陣列。
實際應用通常是把頂點資料存在檔案裡(即模型檔),從檔案讀取,不會寫在程式裡。
const float CUBE[]={
0,0,0, 1,1,0, 1,0,0, //三角形a
0,0,0, 0,1,0, 1,1,0,
0,0,0, 0,1,1, 0,1,0,
0,0,0, 0,0,1, 0,1,1,
0,0,0, 1,0,1, 0,0,1, //三角形b
0,0,0, 1,0,0, 1,0,1,
1,1,1, 1,1,0, 0,1,0,
1,1,1, 0,1,0, 0,1,1, //三角形c
1,1,1, 0,1,1, 0,0,1,
1,1,1, 0,0,1, 1,0,1,
1,1,1, 1,0,1, 1,0,0,
1,1,1, 1,0,0, 1,1,0,
};
這個陣列實際上是108個float連在一起,上面把程式碼加上空白和換行,讓人類看得出哪裡到哪裡是一個三角形。
由於一個頂點被好幾個面共用,同一個坐標會重覆出現好幾次,有個index的方法可以減少重覆的資料,這裡先不介紹。





這張圖是簡易版的繪圖管線,步驟名稱使用Direct3D的,OpenGL的教學可能會用不同的名稱。


坐標轉換和計算顏色這兩格是不同顏色。在「【程式】電子妖精與顯卡少女的合作——何謂GPU、Direct3D與OpenGL?」有提到,由於這兩項的需求特別變化多端,呼叫函式填參數的方法不夠用,於是發明了shader,由人類寫程式給GPU執行。
其他步驟仍然是填參數的方法控制,並沒有可程式化。

1. Input Assembler
我們人類從前面的程式碼可以看出頂點格式是怎麼樣:
  • 12個面,每個面3個頂點,共36個頂點。
  • 每個頂點坐標由三個浮點數(float)組成。
  • 一個float是4 byte。
不過寫程式有個重要的觀念是「程式編譯成binary之後就沒有變數名稱和資料型態了,所有資料都是一堆byte」,把頂點資料傳到顯示記憶體後,顯卡少女看到的是這樣:
0000803f0000803f0000803f0000803f0000803f00000000000000000000803f……

必須呼叫一些函式告訴顯卡少女頂點是什麼格式,她才能正確解讀這些binary資料。
--Direct3D 11
D3D11_INPUT_ELEMENT_DESC layoutDesc[] = {
  {"P",0, DXGI_FORMAT_R32G32B32_FLOAT, 0,0,
    D3D11_INPUT_PER_VERTEX_DATA,0},
};
device->CreateInputLayout(layoutDesc, ...);

--OpenGL
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, 0, sizeof(float)*3, 0);

此外要設定幾何形狀(primitive),D3D和OpenGL支援點、線段、三角形,三角形也有獨立三角形、strip(長條)和fan(扇形)好幾種,要告訴GPU是哪一種。
--Direct3D 11
context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
但是「36個頂點」的資訊不是在這一步告訴GPU,OpenGL的幾何形狀也不是在這一步設定,在下篇會說明。

2. 坐標轉換(Vertex Shader)
繪圖管線最終要把立體物畫在平面的畫面上,要計算各頂點應該放在畫面的哪裡,才能正確呈現立體感。
想像一下正方體畫好後的樣子,量一下頂點在畫面上的坐標(以其中四個頂點為例):

D3D和OpenGL的規格裡畫面坐標不是以像素為單位,而是-1~1的浮點數,(0,0)是畫面正中間。
模型裡的資料 畫面坐標
0,0,0 -0.119, -0.783
1,0,0 0.567, -0.518
1,1,0 0.598, 0.365
0,1,0 -0.126, 0.173
這一步要根據物體位置和鏡頭位置,從左邊的值算出右邊的值。如流程圖所述這一步是shader,要自己寫程式計算,如何算就得運用數學知識了。會用到3D圖學常提到的「矩陣」,詳細算法很長,必須另寫一篇介紹。

除了Input Assembler傳來的頂點以外,如果顯卡少女計算時有需要其他資料,放在一個叫constant buffer的地方傳過去,物體位置和鏡頭位置要用這個方法告訴顯卡少女。
(OpenGL裡稱為uniform buffer)

每個頂點用shader處理後會輸出四個分量:X,Y,Z,W,很明顯X和Y是畫面上的坐標,Z與W跟頂點與鏡頭的距離有關但兩者用途不一樣,Z用在後述的depth test,W用來產生近大遠小的效果。

3. Rasterizer
字面意思是「點陣化」。把頂點連接畫出三角形,並找出三角形覆蓋畫面上哪些pixel。下圖以兩個三角形為例,事實上12個三角形都會做相同處理。

此時會套用一個設定:viewport,可以在視窗或螢幕上切一塊矩形區域,只畫在這個範圍。像二分割畫面是將viewport設成畫面的一半畫出場景,然後把viewport設成另一半,修改鏡頭位置把整個場景再畫一次,畫出從另一個角度看到的場景。

此外會做兩個工作,第一個是back-face culling(消除背面),實際應用裡大部分模型都是封閉無開口的,背對鏡頭的面一定會被面對鏡頭的面擋住,如果畫出物體背面之後也會被正面蓋掉。雖然背面也可以用之後的depth test檢查出來,但早一點把不必畫的部分排除可減少之後的工作。

從一開始的陣列取出兩個三角形來看。
0,0,0, 1,1,0, 1,0,0, //三角形a
1,1,1, 1,1,0, 0,1,0, //三角形g
判斷正背面的方法是三角形三個頂點的順序。看下面圖1,將三角形a三個頂點依序繞一圈:(0,0,0)、(1,1,0)、(1,0,0)、再回到(0,0,0),是順時針。
再繞三角形g:(1,1,1)、(1,1,0)、(0,1,0)、回到(1,1,1),也是順時針,這兩個三角形都會被畫出來。

如果從N的位置看正方體則會變成圖2,同樣依序繞三角形a一圈,變成逆時針,就不會被畫出來。從這個角度看三角形g仍然是順時針,會被畫出來。
因此送給GPU的頂點資料要有一定順序,不能任意把三角形的兩個點互換。

順時針和逆時針哪個代表背面可以用程式控制,也可以設成不消除背面。每個3D軟體、遊戲引擎、檔案格式可能會用不同設定,讀寫模型檔的時候要注意。
因為有culling的存在,一般3D遊戲裡的polygon都是單方向透明的,如果鏡頭移到物體內部可以從內部看到外面,但從外面看不到內部。

第二個是內插。vertex shader求出的Z值需要內插,三角形三個頂點作為端點,算出內部各個像素的Z值是多少。

圖中的Z值筆者沒有精確計算,是用Blender大概建個模型求出來的,只是用來介紹內插的概念。
有需要也可以叫GPU內插其他數值,如下面介紹的貼圖坐標。

這個步驟的計算顯卡少女會自己做,人類只要呼叫函式把設定值告訴顯卡少女就行了,不用自己寫算式。

4. 計算顏色(Pixel Shader)
(D3D把這一步稱為pixel shader,OpenGL稱為fragment shader,本篇稱為pixel shader)
每個被覆蓋的像素執行一次pixel shader算出顏色,要設法模擬現實中的各種現象,例如:
  • 物體面對光的部分亮,背光的部分暗
  • 光被其他物體擋住形成影子
  • 貼圖
  • 物體表面凹凸
  • 其他物體反光,讓光線照在這個物體上
這一步也是shader,必須運用數學理論自己寫程式計算。上面列的現象可以自己決定要做到多少,實作越多會越有真實感,但程式也越難寫,也消耗越多效能。

跟Vertex Shader一樣,如果有什麼額外資料要告訴顯卡少女,放在constant buffer。

pixel shader必須執行成千上萬次(例如畫一張640×480的圖,需要執行640×480=307200次),而且現在的pixel shader常會寫得很複雜,因此pixel shader是效能的重點,減少pixel shader的複雜度和執行次數是tune效能的主要方法。

5. 深度測試(Depth Test)
如下圖從M的位置看,按照常理B的一部分會被A擋住,電腦裡要如何實作這個現像?

也許有人想到只要先畫B再畫A,後畫的自然會把先畫的蓋掉。但在一個主角可任意走動的遊戲,如果鏡頭移到N的位置那前後順序就反過來了。場景裡有眾多物體且有可能一個嵌入另一個的時候,一個一個物體檢查遠近順序難以做到。

目前標準做法是:另外建立一個點陣圖記錄各個像素與鏡頭的距離(不一定要真實距離,只要與距離是正相關或負相關就行了),每個像素有一個分量,數值範圍是0.0~1.0的浮點數,稱為depth buffer或Z buffer。
畫場景之前先把所有像素都設為1.0,代表能看到的最遠距離。
Z值在這裡派上用場了,畫polygon時除了更新color buffer,把各像素的Z值也寫進Z buffer。

更新各個像素時把Z值跟目前Z buffer裡的值比對,如果新的Z值較大就代表被較近的物體擋住,這個像素不需要畫出。
也可以初值設為0.0,數字越大代表越近,寫程式時可以設定要用哪一種。

每個像素都要記一個值,消耗一大塊記憶體的方法看起來很原始,但是有用。
但Z buffer的做法只能用在完全不透明的物體,半透明物體背後的物體也必須畫出來,且要先畫遠後畫近才會正確,這時還是得用手動排遠近的方法。

流程圖裡還有一個Early Depth Test,這又是什麼?D3D和OpenGL規格允許在pixel shader裡修改Z值,必須算完pixel shader才能確定Z值是什麼,所以depth test得排在pixel shader後面。但如果pixel shader並沒有修改Z值,那在pixel shader之前和之後做depth test結果會一樣。因此大部分GPU有做一個優化:如果你寫的pixel shader沒有修改Z值就把depth test移到pixel shader之前做,提早把不用畫的像素排除。

由於有depth test的存在,3D繪圖先畫近再畫遠會比較有效率,因為可以及早排除被擋住的像素,減少pixel shader執行次數。

題外話:PS1並沒有Z buffer的功能,聽說做PS1的3D遊戲時polygon排序得自己做,如果遇到兩個面交差的情況就很麻煩,N64才開始有Z buffer的功能。但N64的弱點是卡帶的容量比光碟小很多,放不下大檔案,因此模型和貼圖品質很受限。

6. 混色(Blending)
pixel shader算出的BGRA值和畫面上目前的BGRA值,要用什麼算式計算、混合。
類似繪圖軟體的圖層模式:物體完全不透明,直接覆蓋舊的值;物體是半透明,要看得到背後的物體;或是火焰特效,要用加法混色做出發光的感覺,這些效果是在這一步設定。

公式怎麼設定可參考繪圖軟體的演算法。這一步不能寫shader,能用的算式很有限,不是所有圖層模式都能做到。

7. Output
繪圖管線最終目的是把3D場景畫在點陣圖上,一般要準備兩張圖作為輸出,其中color buffer記錄顏色,Z buffer的用途如上面depth test所述。OpenGL把兩張圖合稱為framebuffer object,D3D則沒有取特別的名稱。
這兩張圖是由顯卡少女建立的,電子妖精要做的是把顏色格式、尺寸等資訊告訴她,叫她建立物件。

D3D和OpenGL初始化完成後,就會有一張framebuffer代表人類看到的畫面,有需要也可以自行建立其他framebuffer,將圖畫在記憶體裡而不直接畫在畫面上。
同樣是點陣圖,把它當作輸出時稱為framebuffer,當作輸入時就稱為貼圖。可以用兩階段繪圖的技巧:先把場景畫在一張framebuffer,再把它作為貼圖畫到另一張framebuffer上。

8. 貼圖(texture)
(有的文章翻譯成紋理。)
照以上的方法只能畫出素色物體,但現在的3D畫面幾乎沒有不用貼圖的,使用貼圖要多做哪些工作?
首先當然要把一張點陣圖放在顯示記憶體。
然後頂點資料要包含「貼圖坐標」。

上圖標出8個頂點和其中兩個三角形。自己想像一下把左邊正方體和右邊展開圖的坐標對應起來,折疊後B與B'會重合,其餘C'、D'、F'、G'、H'依此類推。

下列紅色字是貼圖坐標。
const float CUBE[]={
0,0,0,0.25,0.5, 1,1,0,0.5,0.25,  1,0,0,0.5,0.5,
//↑頂點A         ↑頂點F           ↑頂點B
0,0,0,0.25,0.5, 0,1,0,0.25,0.25, 1,1,0,0.5,0.5,
0,0,0,0.25,0.5, 0,1,1,0,0.25,    0,1,0,0.25,0.25,
0,0,0,0.25,0.5, 0,0,1,0,0.5,     0,1,1,0,0.25,
0,0,0,0.25,0.5, 1,0,1,0,0.75,    0,0,1,0,0.5,
0,0,0,0.25,0.5, 1,0,0,0.25,0.75, 1,0,1,0,0.75,
1,1,1,0,0,      1,1,0,0.25,0,    0,1,0,0.25,0.25,
1,1,1,0,0,      0,1,0,0.25,0.25, 0,1,1,0,0.25,
//↑頂點G'        ↑頂點E           ↑頂點H'
//以下四個面在背後

1,1,1,0.75,0.25, 0,1,1,1,0.25,   0,0,1,1,0.5,
1,1,1,0.75,0.25, 0,0,1,1,0.5,    1,0,1,0.75,0.5,
1,1,1,0.75,0.25, 1,0,1,0.75,0.5, 1,0,0,0.5,0.5,
1,1,1,0.75,0.25, 1,0,0,0.5,0.5,  1,1,0,0.5,0.25,
};
貼圖坐標不是以像素為單位,而是0~1的浮點數,D3D跟多數繪圖軟體一樣以左上角為(0,0)。

OpenGL比較特別,以左下角為(0,0),但是將貼圖傳到顯示記憶體的函式glTexImage2D()也假設給它的指標以左下角為原點,所以讀取圖檔時不用刻意把圖上下顛倒。但是把framebuffer當成貼圖使用時就會出問題,以前有寫過一篇介紹:【程式】倒立的OpenGL貼圖坐標

Input Assembler設定頂點格式時,要告訴顯卡少女有貼圖坐標的存在。
Rasterizer除了內插Z值以外也要內插貼圖坐標,計算出每個像素的貼圖坐標。
讀取貼圖會用到一個叫取樣器(sampler)的物件,要填好設定值告訴顯卡少女。
最後在pixel shader裡下一行指令讀取貼圖,取得貼圖裡某一點的BGRA值,用它計算最終的顏色。
--HLSL
float4 texColor=texture1.Sample(sampler, texCoord);
--GLSL
vec4 texColor=texture(sampler, texCoord);



(待續)

題外話:文中用到優化這個詞,這個詞雖然是對岸傳來的,但我覺得把optimize翻譯成優化比最佳化要好。

繪圖管線的教學網路上能找到不少,我看了很多前人的作品後想改良一個地方:圖解,於是寫這篇時花了很多時間畫圖。
引用網址:https://home.gamer.com.tw/TrackBack.php?sn=4906070
All rights reserved. 版權所有,保留一切權利

相關創作

同標籤作品搜尋:程式|遊戲製作|Direct3D|DirectX|OpenGL

留言共 5 篇留言

stormcorn
厲害,辛苦啦

09-05 07:23

樂小呈
太棒啦!
推~

09-05 09:53

羅伊
講得很詳細,感謝分享~

09-05 19:40

ays.
辛苦了, 圖片製作的很用心XD

09-13 21:03

厚道᨟HappyDay
那譯成改良、改善、改進呢?或加速?

09-15 16:59

我要留言提醒:您尚未登入,請先登入再留言

20喜歡★shark0r 可決定是否刪除您的留言,請勿發表違反站規文字。

前一篇:FF36遊戲攤位一覽... 後一篇:【程式】Visual C...

追蹤私訊切換新版閱覽

作品資料夾

a86189642祝福
祝大家幸福。看更多我要大聲說1小時前


face基於日前微軟官方表示 Internet Explorer 不再支援新的網路標準,可能無法使用新的應用程式來呈現網站內容,在瀏覽器支援度及網站安全性的雙重考量下,為了讓巴友們有更好的使用體驗,巴哈姆特即將於 2019年9月2日 停止支援 Internet Explorer 瀏覽器的頁面呈現和功能。
屆時建議您使用下述瀏覽器來瀏覽巴哈姆特:
。Google Chrome(推薦)
。Mozilla Firefox
。Microsoft Edge(Windows10以上的作業系統版本才可使用)

face我們了解您不想看到廣告的心情⋯ 若您願意支持巴哈姆特永續經營,請將 gamer.com.tw 加入廣告阻擋工具的白名單中,謝謝 !【教學】