前往
大廳
主題

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

Shark | 2021-03-14 15:08:31 | 巴幣 3656 | 人氣 1284

隔幾個月才寫好下篇。繼續介紹電子妖精和顯卡少女如何聯手工作。
上篇在此

新版小屋介面有一個改變:舊版寬度是627px,新版是756px,可以放比較寬的圖和比較長的程式碼了。
因為超過寬度的圖會被縮小,用滑鼠點一下才能看到原尺寸,我製作圖會儘量不超過小屋寬度

另外我寫過的程式教學散落各處,可能不好找,寫了一篇目錄出來。
Shark流程式教學一覽



上篇說的那些步驟並不是呼叫一個函式就做一步,而是像這樣:


繪圖管線像顯卡少女的辦公室有一組工作檯,先呼叫一堆函式指示顯卡少女把設定值和物件放在工作檯上,然後呼叫draw call才一口氣執行那些步驟。draw call不止一種,依不同情況使用。
上篇Input Assembler提到的頂點數量,以及OpenGL的幾何形狀是在draw call裡指定。

(註:雖然上圖是那樣畫,實際上套用設定值和物件的指令也是先儲存在驅動程式,draw call時才一口氣傳給GPU。)

GPU讀取顯示記憶體很迅速,將資料從主記憶體傳到顯示記憶體比較慢,所以先找時間把模型、貼圖這些量大的資料傳到顯示記憶體(可以在Now Loading時做,或是遊戲進行時在背景處理),之後每個frame只要傳送少量的資料,如「套用3號模型,5號貼圖」的指令,或是更新constant buffer。

Direct3D 11
//設定input assembler
context->IASetInputLayout(...); //頂點格式
context->IASetPrimitiveTopology(...); //幾何形狀
context->IASetVertexBuffers(...); //頂點資料
//設定vertex shader

context->VSSetShader(...);
context->Map(...); //將矩陣放入constant buffer
context->Unmap(...);
context->VSSetConstantBuffers(...); //設定要使用的constant buffer
//設定rasterizer

context->RSSetViewports(...);
context->RSSetState(...); //culling在此設定
//設定pixel shader

context->PSSetShader(...);
context->PSSetShaderResources(...); //設定貼圖
context->PSSetSamplers(...); //設定取樣器(sampler),讀取貼圖要用到這個東西
//設定depth test

context->OMSetDepthStencilState(...);
//設定blend
context->OMSetBlendState(...);
//設定畫布和depth buffer
context->OMSetRenderTargets(...);

//draw call,到這裡才開始畫
context->Draw(vertexNumber, 0); //頂點數量在此才設定

//填入新矩陣

context->Map(...);
context->Unmap(...);
//切換模型和貼圖
context->IASetVertexBuffers(...);
context->PSSetShaderResources(...);
//畫第二個物體
context->Draw(vertexNumber2, 0);

OpenGL
//設定input assembler
glBindVertexArray(...); //頂點格式和頂點資料
//設定shader

glUseProgram(...); //一個函式同時設定vertex shader和fragment shader
glBindTexture(...); //設定貼圖
glBindSampler(...); //設定取樣器
glBindBuffer(GL_UNIFORM_BUFFER, ...); //設定要使用的uniform buffer
glBufferSubData(GL_UNIFORM_BUFFER, ...); //將矩陣放入uniform buffer
//設定rasterizer

glViewport(...);
glEnable(GL_CULL_FACE);
glFrontFace(GL_CW);
//設定depth test
glEnable(GL_DEPTH_TEST);
//設定blend
glEnable(GL_BLEND);
glBlendFunc(GL_ONE,GL_ZERO);
//設定畫布和depth buffer
glBindFramebuffer(...);
glFramebufferTexture2D(...);
glFramebufferRenderbuffer(...);

//draw call,到這裡才開始畫
glDrawArrays(GL_TRIANGLES,0, vertexNumber); //頂點數量和幾何形狀在此才設定

//填入新矩陣

glBufferSubData(GL_UNIFORM_BUFFER, ...);
//切換模型和貼圖
glBindVertexArray(...);
glBindTexture(...);
//畫第二個物體
glDrawArrays(GL_TRIANGLES,0, vertexNumber);

這些函式實際的參數都落落長,本篇先略過,之後如果我有寫到那部分,實際用到這些函式再介紹。

draw call後設定值和物件仍然擺在工作檯上,下一次叫顯卡少女更換才會改變,所以如果第二個物體只要更換一部分資料,其他設定跟前一個物體相同,只要呼叫一部分函式即可。



前篇放的流程圖是簡易版,那完整版又是怎麼樣呢?

(這是筆者認為比較好講解的版本,網路上其他教學可能畫得不一樣,可能把某個步驟再細分,或把好幾個步驟合成一個。
步驟名稱一樣用Direct3D的,有的步驟OpenGL用不一樣的名稱。)

多了很多步驟,shader除了vertex和pixel以外還有geometry、hull、domain三種,而且多了個Stream output,可以把前面那些shader算出的頂點坐標存起來,供下次draw call使用。
很多步驟不需要時可以省略,簡易版大概就是畫一個3D物體最低限度必要的工作。
有一堆用語很難懂吧。D3D和OpenGL早期版本功能很簡單,大概像簡易版的圖再把shader拿掉。隨著軟體對畫面的需求越來越多,D3D、OpenGL、和顯卡少女不斷追加新功能,可以做到越來越多的事,但因此變得很複雜,提高初學門檻。

上圖是D3D11和OpenGL 4版的pipeline,兩者的後繼產品:D3D12和Vulkan有變更一部分。然後D3D12在2018年,Vulkan在2020年新增一種技術:光線追蹤(ray tracing),這是走完全不同的pipeline。



主題講到這裡,接下來講幾個有關的項目。

即時運算與預運算

繪製3D場景其實不只一種方法。像是描述物體形狀的方法除了本篇介紹的三角形面,還有利用曲線稱為NURBS的方法,或是用橢球組合出物體。

在「【程式】電子妖精與顯卡少女的合作——何謂GPU、Direct3D與OpenGL?」有提過標準規格很重要,如果每家廠商各自開規格,那在每個硬體和作業系統上程式都要重寫一次。
規格不能一味追求功能強大。在即時運算的場合(如遊戲需要在1/60或1/30秒內畫完整個畫面),不能採用計算耗時的演算法,而且功能複雜會讓軟硬體難以製造,PC、遊戲機和手機成本要低,一般使用者才負擔得起售價。在性能和成本之間取捨之後,廠商們定出的標準規格就是現在的繪圖管線。

相對地在動畫、電影等應用,工作室可以買幾台高性能電腦讓它們計算好幾天、輸出影片檔,發佈只要發佈影片檔就行了。這時可以採用高畫質但計算複雜的技術,可能也不用D3D和OpenGL,而是開發一套專用API。

以上兩種分別是「即時運算」和「預運算」,即時運算容易看情況更改內容,但不能產生很精緻或擬真的畫面,預運算則相反。可以觀察遊戲裡的過場動畫,奴果精緻度比遊戲其他部分高很多那應該是預運算,如果角色變更裝備會反映在動畫裡那大概是即時運算。
(如果精緻度跟其他部分一樣,且角色變更裝備不會反映在動畫裡,……那就無法判斷。)

還有3D軟體像3ds Max和Blender為了讓使用者操作後可以即時在畫面上反映,編輯時是用D3D和OpenGL畫出比較簡單的畫面,render時才用精確的演算法慢慢算。
下圖是Blender說明書裡的範例,左邊的沒有表面紋路、光柱半透明、影子,右邊才有畫出來。




如何用D3D和OpenGL畫2D畫面?

從名稱看起來D3D和OpenGL是專門畫3D的,但現在2D也是用D3D和OpenGL繪製了。
如果要畫一張矩形點陣圖,把傳給顯卡少女的資料改一下:

  1. CPU將矩形四個頂點傳給GPU,位置坐標只有XY,沒有Z分量。
  2. CPU和vertex shader裡使用2D數學來計算坐標。
    只有4個頂點,移動和變形可以乾脆完全由CPU算,不靠vertex shader。
  3. vertex shader輸出的W值設為1,讓這個值不起作用。
  4. 關閉depth test改用手動排序,或者乾脆不建立Z buffer,因為2D很常用到半透明。

就可以畫2D畫面。2D、3D統一使用D3D和OpenGL繪製可以簡化規格和軟硬體,廠商只要做一套系統就可以兩者通用。
DirectDraw本來是在顯示晶片沒有3D的時代使用,現在已停止增加新功能和改進效能,只是為了讓舊程式能繼續執行而保留。



D3D和OpenGL哪個比較好?

當然兩者最大差異是哪個作業系統能用,大概只有Windows桌機版可以同時用兩者,這裡根據筆者經驗講一下性能方面。

以Direct3D 10和OpenGL 3.2以後來說,最底層建立物件的步驟差很多,但從高階一點的需求來看兩者能做的事就差不多,大部分功能都能找到對應。例如都可以建立vertex buffer和framebuffer物件,但呼叫的函式和傳入的參數差很多。
以前的版本就不一定,有一段時間是OpenGL落後於D3D,D3D持續推出新版但OpenGL遲遲沒有跟進。

API設計方面,OpenGL的風格比較古老,寫程式比較容易出錯。
舉個例子,操作物件的時候,D3D11是呼叫物件的method,或把物件作為參數傳入函式,每一行都明確指出操作的物件。
//貼圖物件為ID3D11Texture2D* textureObj1;
context->UpdateSubresource(textureObj1, 0, NULL, ……);

ID3D11ShaderResourceView* shaderResourceView;
device->CreateShaderResourceView(textureObj1, NULL, &shaderResourceView);
textureObj1->Release();

OpenGL通常是在內部設定一個全域變數,之後的函式讀取這個全域變數。
//設定「GL_TEXTURE_2D」這個全域變數
glBindTexture(GL_TEXTURE_2D, textureObj1);

  ……

//這些函式讀取GL_TEXTURE_2D得知要操作的貼圖是textureObj1
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, ……);
如果glBindTexture()和下面函式距離有些遠,看程式碼有時會看不懂目前在操作哪個物件。

效能的話根據筆者經驗,關鍵是「晶片廠商有沒有認真寫驅動程式」,跟API本身關係不大。
電子妖精送指令給顯卡少女實際要經過以下途徑。

一個功能即使晶片做得到,作業系統和驅動程式沒有配合製作的話,也不能使用。


以下是筆者用2012左右的硬體測試,有的問題在新版本可能已經解決。

筆者有一台電腦是Intel Sandy Bridge微架構,在Windows上如果把相同功能用D3D和OpenGL寫,它的內顯在D3D的效能比較好,而且有些功能只有D3D才能用。
可能因為Windows的遊戲幾乎都用Direct3D,廠商就花比較多心力在D3D驅動程式,OpenGL的只求有,沒認真debug或改進效能。
AMD和nVidia的Windows OpenGL驅動程式比較沒這個問題。

Linux驅動程式的情況是這樣:AMD和nVidia是廠商自行開發,closed-source。這兩家另外有非官方的driver,不過是逆向工程做出來的,性能遠不及官方版本。
Intel則是把規格公開,讓網路上的開發者以open-source的方式開發。
同一台電腦裝Windows和Linux來比較的話,AMD和nVidia晶片在Linux表現比較差,可能因為比較少人做Linux的遊戲,Linux版驅動程式就沒有認真寫。有時候裝了原廠驅動程式還會沒辦法開GUI,只剩黑底白字的命令列可以用。
Intel的由於有很多Linux使用者改良,驅動程式品質比較好,Intel晶片在Linux的表現比在Windows好。
2015年AMD把驅動程式改用open-source的方式開發,品質可能有改善,但筆者手上沒有夠新的硬體可試用。

最近一個經驗也跟這有關。筆者的Linux開發機有Intel內顯和nVidia獨顯,上個月在這台安裝Mint 19.3,剛灌好只有open source版驅動程式時還可以開GUI,安裝nVidia官方driver以後就無法開GUI了,照網路上的說明用命令列模式移除driver也不能復原,只好重灌。
但較早的18.2版沒這個問題。

查了一下資料,一般桌機裝兩個GPU是這樣,顯卡和主機板各有一組插座,要使用哪個GPU就把螢幕線插到哪裡。


筆電的Optimus技術是這樣,只有內顯輸出到螢幕,用獨顯繪製時會先在獨顯畫出整個畫面,把畫面複製到內顯記憶體,再傳給螢幕。

驅動程式要特別寫才做得到這個功能,可能nVidia的Linux版驅動程式沒有好好寫,不是每個發行版都能正常運作。
如果使用非官方驅動程式,不但不能用獨顯,而且獨顯閒置時也不能節省電力,電腦會變得很燙。所以我寧願放棄獨顯了,修改BIOS設定調成只用內顯。

有聽說nVidia本來就對Linux社群比較不友善。目前不急著買新電腦,這台先將就著用,但以後Linux開發機我要用Intel或AMD的晶片。



偽3D是什麼?

這個詞沒有明確的定義,只是玩家間的俗稱,所以如果看到兩個人爭論某個東西是不是偽3D,吵半天得不出共識,這很正常。

如果真要問筆者的看法,按照上篇「polygon或曲線 → 坐標轉換 → rasterizer → 計算顏色 → 輸出」的流程繪製的是真3D,用其他方法產生遠近感的是偽3D。

很多SFC和Mega Drive遊戲可見到一種效果:讓背景用較慢速率捲動產生背景比較遠的感覺。由於背景只是單張圖片,並沒有建模型用3D數學計算,這是一種偽3D。

紅白機的兩個遊戲:Mach Rider和Rad Racer,紅白機並沒有處理polygon的功能,也是用偽3D做出遠近感。



當時繪製畫面的方法是畫面由很多橫向掃瞄線組成,逐條畫出,每畫一條掃瞄線之後把下一條橫向偏移,就能讓圖彎曲,紅白機有這個功能。

這個技巧在日本叫raster scroll(ラスタースクロール),英文好像叫line scrolling。
因為是這種方法,遊戲畫面表現不出交差或距離很近,照理說可以看到另一條路的情況。也只能把圖橫向變形,紅白機看不到用這個技巧把圖縱向變形的遊戲。
找到這篇舉了幾個Mega Drive的例子:Line Scrolling - Raster Scroll Books

鐵桶之類的障礙物也不是建個圓柱形的模型,而是準備數張大小不同的圖片看時機切換(紅白機是沒有縮放功能的)。



關於插圖:

很多教學裡畫暗處的常用方法是用乘法(multiply)疊上顏色,這張圖改用線性加深(linear burn)試試看。

乘法的算式:A×B。
A、B代表RGB分量,範圍是0~1。
假如底色是(1, 0.75, 0.5),max-min=0.5
疊上灰色(0.6, 0.6, 0.6)
會變成(0.6, 0.45, 0.3),max-min=0.3
RGB差異減少代表彩度下降,所以乘法模式容易讓整張圖變得灰暗,避免的方法是針對每個底色的特性各別選擇重疊的顏色。

線性加深的算式:A+B-1,如果<0就設為0。
底色(1, 0.75, 0.5),max-min=0.5
疊上灰色(0.6, 0.6, 0.6)
變成(0.6, 0.35, 0.1),max-min=0.5
彩度比較不易下降,所以試試看這個模式,看能不能畫暗處一個顏色用到底,只有少數部位各別選色。
但這個模式可能讓分量變成0,容易讓整張圖一片黑,還是免不了要看底色各別選色。試一試覺得還是乘法模式比較易用。

另外試用一下GIMP 2.99。這版把依存的GTK 2和Python 2換成GTK 3和Python 3,Inkscape之前就已經把這兩個函式庫換新,GIMP也更新之後,我的電腦上就不再需要GTK 2和Python 2了。
不過發現2.99版的Python API改變,script必須重寫,所以暫時還是用2.10,等這張圖畫完再改用2.99。

右下的遊戲畫面放大

因為第四格的遊戲邏輯寫的是RPG的戰鬥,這一格就這樣畫了。
角色是把目前設計出的顯卡少女挑三個換服裝,但角色在圖中很小,不容易看出誰是誰。

電子妖精和顯卡少女的工作室長怎麼樣、工作時用什麼工具等等的,這張圖畫得還比較隨便,但如果之後要創作一系列作品,要把這些背景設定定個規格,以免不同作品之間有矛盾。

創作回應

樂小呈
感謝解說!
2021-03-14 15:22:07
阿修
讚讚 好文章[e22]
2021-03-14 19:11:34
Sunwen
好棒的文章,長知識了
2021-03-14 19:48:38

更多創作