關於底層渲染API設計

關於底層渲染API設計

來自專欄 korok引擎開發

我們的渲染API設計大概用了4-5個月的時間,實際的成果是僅僅寫了幾千行代碼而已。算是我們耗時最久的一個系統。最終整個渲染系統的構圖如下,本文涉及的底層渲染API處在圖形API封裝部分:

最早的上層渲染系統和底層圖形API封裝是一個系統,經過幾次重構之後逐漸得到了現有的結構。在過去我們設計了3個版本的渲染API:

  1. 面向對象的 update/draw 系統
  2. 受管理的大 Context 系統
  3. 基於排序的無狀態渲染API

對於有經驗的引擎工程師可能很快會鎖定比如 bgfx 之類的渲染庫, 比如雲風的 3D引擎項目,我們當時一是缺少經驗,二是希望在完全不做第三方參考的情況下設計出自己的獨立渲染API ,這是我們的一次嘗試,很好奇最終會造出什麼樣的輪子。

面向對象的 update/draw 系統

在 Game Programming Patterns 一書中把這種做法歸納為 Update Method 模式,大概如下:

Node () { Update(dt) { // do something... } Draw(render r) { // draw something... }}

思路很簡單 - 給每個遊戲對象/實體提供一個 Update(dt) 方法,並在每幀調用。

這是我們第一版API的雛形,早期的Cocos/Love2D/Orge3D... 還有很多遊戲引擎都採用了這種設計,這種設計實現簡單也非常靈活。但是我們很快就發現了這種設計的問題:容易出錯且對GPU狀態無法管控。如果只渲染一種類型的數據(比如 2D 精靈),那麼這種設計是沒有問題的,但是如果要支持多種數據,比如粒子系統,文字,骨骼動畫.. 那麼各種對象導致的 GPU 上下文切換就變得無法管控,API難以調試。我們當時遇到的最常見的問題是:Render 修改了當前 GPU 狀態或者切換 Render 導致屏幕上什麼也不顯示,也不知道究竟發生了什麼...

更進一步,在這種系統中我們可以直接調用各種gl方法,這種隨意性最終也會暴露給用戶,這導致了下面的設計。

受管理的大 Context 系統

這個設計主要為了解決上面的 GPU 上下文切換問題,我們把所有的 GPU 狀態維護在一個大的 Context 裡面,所有的可以改變 GPU 狀態的方法都代理到這裡, 這樣可以確切的列印出當前GPU的狀態。這個設計解決了上面提到的 GPU 狀態錯亂問題,

Context () { state { function texture vb/ib.... } setState(..) { .. }}

只要把上面的各種 Render 基於 Context 重寫即可。而且此時還能對系統狀態做出優化,比如如果兩次 DrawCall 需要相同的狀態,那麼下次 DrawCall 就可以省去重新設置狀態,只要做一些簡單的 if-else 判斷即可。

這個系統已經可以很好的工作了,但是有幾個問題依然讓我覺得不爽 -- API太過難用,本質上這是由於 OpenGL 的大狀態機設計導致的。我們的大 Context 只是 OpenGL大狀態機 的一個翻版,這種API在使用上帶來了很多煩惱,你需要小心翼翼的維護這個狀態機的當前狀態以免發生任何意想不到的變化。

於是我們放棄了最初那個愚蠢的想法,開始尋求現代的主流的API設計,而 這篇文章 給予了很大的啟發,於是便有了下面最後的設計。

基於排序的無狀態API

重新審視之前的設計,我們一直把對精靈/字體的渲染和底層圖形API的調用混合在一起的,這也間接導致了每次添加新功能都會狀態錯亂。精靈/字體的渲染是比較上層的功能,而底層的渲染API應該只做一些簡單的確定的事情,比如提交DrawCall,修GPU狀態。所以我們的第一要務是分離出底層純渲染API, 這就是我們現在的 bk-api,只做最基本的GPU資源管理,提交 DrawCall 等簡單操作(bk = backend)。

它也應該具備這樣的特性:

  1. 無狀態 - 直接修改GPU狀態是容易出錯的,每次API調用都會影響下一次的調用。無狀態的API設計可以讓每次調用時當前的GPU狀態都是統一的,這是一個非常美好的想法。一旦有了這樣的API,那麼每次調用都是一次全新的調用而不必擔心API間的相互影響,棒極了!
  2. 基於排序 - 這種想法可以追溯到很久之前,大概是給每次DrawCall分配一個32/64位的sortkkey,提交drawCall的時候並不會立刻改變GPU狀態,僅僅是把 DrawCall 提交到渲染隊列,DrawCall 提交完畢之後根據sortkey執行一次排序,得到正確的/最佳的渲染順序,這樣我們不必擔心DrawCall的提交次序(比如畫布系統中需要按提交順序來渲染),甚至可以多線程生成DrawCall.
  3. 多線程 - 採用上面的設計理念可以很容易的實現多線程渲染(注意OpenGL本身是單線程的,雖然當前我們並沒有實現)

這套 API 同時也隔離了具體的圖形驅動,比如OpenGL/D3D/Vulkan/Metal等,以後有時間可以追加上對其它驅動的支持(目前僅有OpenGL)。

以下是大概實現:

渲染API設計

func draw(x, y float32, order int32) { // x, y transform m := f32.Translate3D(x, y, 0) bk.SetUniform(muh, unsafe.Pointer(&m[0])) // Set Model matrix for rendering id4 := f32.Ident4() bk.SetTransform(&id4) // Set Vertex and index buffer bk.SetVertexBuffer(0, vbh, 0, 4) bk.SetIndexBuffer(ibh, 0, 6) // Set render state bk.SetState(bk.ST_BLEND.ALPHA_PREMULTIPLIED, 0) bk.SetTexture(0, 0, txh, 0) // Submit primitive for rendering to view 0 bk.Submit(0, shh, order)}

注意,這套代碼沒有直接的 gl 調用,所有的api都是通過 bk.xxx 來調用的,調用 Submit 時會提交一次 DrawCall,並重置當前的狀態,所以每次 DrawCall 都是全新的,它也不會受到/影響之前或之後的 DrawCall。

資源 API 設計

// allocid, tex := bk.R.AllocTexture(img) // queryok, tex := bk.R.Texture(id) // freebk.R.Free(id)

所有的資源操作都是通過 bk.R. 來執行(R = ResManager),所有資源的管理都是id化的,同類的資源集中在一個大數據塊管理,在外面通過一個 id 來索引。這種設計是從 Bitsquid 偷師的,這是一種面向數據的設計方式。由於我們使用 id 而不是指針在 Golang 中還能額外的提高 gc 效率。

SortKey設計

簡單的說,我們的 sortkey 是這樣的

// SortKey FORMAT// 64bit:// 0000 - 0000000000 - 00000 - 000 - 0000000000// ^ ^ ^ ^ ^// | | | | |// | z-order() shader(2^5) | texture(2^10)// Layer(2^4) blend(2^3)

最高位是Layer在當前的2D系統中,Layer並沒有使用。之後是z-order,接下來是 shader/blend/texture, 基本上是按照可能的切換頻次排列的。關於 sortkey 可以找出很多古董一樣的文章,實現上也沒有什麼新意。Cocos2D-X 3.0之後實現的 Command 系統也是類似的系統。

渲染隊列設計

渲染隊列是我們整個系統的核心,發起一個 DrawCall 其實就是往隊列中插入一條數據。在渲染的時候,會先根據sortkey進行一次排序得到正確的渲染順序,然後遍歷渲染隊列挨個的執行每個DrawCall 就可以了。

type RenderQueue struct { // render list sortKey [MAX_QUEUE_SIZE]uint64 sortValues [MAX_QUEUE_SIZE]uint16 drawCallList [MAX_QUEUE_SIZE]RenderDraw drawCallNum uint16 ...}// 提交一個DrawCallfunc (rq *RenderQueue) Submit(id uint8, program uint16, depth int32) uint32 { // uniform range rq.uniformEnd = uint16(rq.ub.GetPos()) // encode sort-key sk := &rq.sk sk.Layer = uint16(id) sk.Order = uint16(depth+0xFFFF>>1) sk.Shader = program & IdMask // trip type sk.Blend = 0 sk.Texture = rq.drawCall.textures[0] rq.sortKey[rq.drawCallNum] = rq.sk.Encode() rq.sortValues[rq.drawCallNum] = rq.drawCallNum // copy data rq.drawCall.uniformBegin = rq.uniformBegin rq.drawCall.uniformEnd = rq.uniformEnd rq.drawCallList[rq.drawCallNum] = rq.drawCall rq.drawCallNum++ // reset state rq.drawCall.reset() rq.uniformBegin = uint16(rq.ub.GetPos()) // return frame Num return 0}

渲染隊列提前申請了一個大數組,每次提交只是把渲染隊列的指針向後移動一位,所以基本上沒有任何成本。這個數組的長度為 (8 << 10),也就是最大的 DrawCall 數量,這在2D遊戲中已經綽綽有餘了。事實上經過上層的Batch系統之後到達底層的 DrawCall 數量最多幾百個左右。

Shader 中的 Uniform 參數是比較特殊的一類參數,因為每個 Shader 的實現不同 Uniform 的長度會發生變化所以 Uniform 並不適合打包在 DrawCall 裡面,所以使用了一個 Buffer 來寫入所有的 Uniform,這樣的 Uniform 的讀取可以保持內存的連續性。

其實,隨著對這些概念的關注,自然就發現了 bgfx 這個優秀的渲染庫。我用bgfx寫了幾個demo,它的 API 設計之好用驚為天人,所以你會發現我們的API介面名稱和bgfx幾乎是一模一樣的。但是最終我們沒有使用 bgfx 來做渲染,我們希望把引擎做的盡量輕量。bgfx 可以完納2D引擎的功能,但是或許只能用到它30%的特性,如果用 bgfx 還需要讓用戶關心底層的跨平台 Shader 編譯系統,也需要了解底層的資源管理模式而且這是在 C/C++ 層。

在處理資源的時候我們和 bgfx 有了分歧,在 bgfx 中資源的創建和銷毀是和DrawCall一起執行的,所以你會看到 bgfx 會把資源的創建方法序列化後編碼到一個 ResourceCommandBuffer 裡面 (這也是一個很有趣的設計,你會在其它引擎中也發現類似的實現),這樣的好處是資源和普通DrawCall一樣是延後執行的,方便做多線程的事情。但是這極大的增加了代碼量,所以在我們的實現中資源創建是獨立的系統 -- ResMamanger,需要有更上層的系統來保證資源的及時創建和銷毀,這樣我們節省了大約40%左右的代碼(bgfx 中有大量代碼是用來序列化資源的)。

總的來說我們很大程度上參考了bgfx, 算是一個簡版的 bgfx。最終真誠的希望您能夠在 Github 上給我們的項目 Github - KorokEngine 來一套 watch/star/fork 三連擊。 關注一下 twitter 也是歡迎的(bgfx 的作者也在關注哦)。

以上(配圖是第一次運行bk-api時候的截圖)。


推薦閱讀:

Texture Processor Tool
經典CG渲染教程推薦
大型鳥瞰圖的渲染需要怎麼做?
計算機圖形學,下一步如何提高?
V-Ray 3.6 for Maya問世 帶來混合渲染技術

TAG:渲染 | 設計 | 遊戲引擎 |