BGFX多執行緒渲染

SkySnow(天雪)發表於2022-03-01

 

BGFX多執行緒渲染

1. 多執行緒基礎

1. 併發概念

1. 併發任務簡介

在多年以前,在手機、pc端、遊戲機等,都是一個單核的CPU。這樣,在硬體層面上,處理多個任務的時候,也是把一些任務切分成一些小任務。在某些時刻進行任務的切換,從A任務切換到B任務,在這個過程中,系統每一次切換任務,都是需要切換上下文的,這也就從側面說明了一個問題,切換任務也是有時間開銷。

有人會說為什麼把大任務切割成小任務,在一個一個小任務進行切換那?其實這是一個客觀存在的需求。舉一個例子,如果大任務都是排著隊執行,也不進行切割,如果開啟一個app是一個任務,開啟另外一個app也算是一個任務,如果點選A並在A中進行一些操作時,這個時候想開啟B,很明顯,如果這個不進行任務切割,那麼我需要關閉A,在開啟B,這樣才可以。但是我又想回到A的操作的時刻,那對不起,只能重新開啟。可能我舉得的這個例子不是非常恰當,但是隻是為了描述一個問題:即使是單核處理器,作業系統也是會把一些大任務切分成小任務,以滿足剛才所說的這個實際需求,從一定程度上來達到併發的需求。

在當下這個時代,2000年之後,多核逐漸取代了單核處理器。傳統的單核處理器在通過增加頻率來提升處理器的效能明顯已經走到了瓶頸,也就是摩爾定律。但是,對於當下的一些開發人員來說,所開發的軟體工程還是大部分集中在一個任務中,雖然上文說到,單核CPU其實也是多工的形式。這就讓多核的硬體特性非常尷尬,常常是這種情況,主核CPU忙的要死,其它幾個核心倒是在睡大覺。

2. 併發實現途徑

上節說到了其實無論是單核的硬體,或者是雙核的硬體。我們的系統是把一些任務切分成了一個一個的小任務,在不停的去切換,並保護分割切換區域,把不同的小任務分門別類的封裝一下。但是並沒有介紹,是如何實現。

本節簡單介紹一下實現併發的途徑。簡單來說,一種是基於執行緒的併發,一種是基於程式的併發;

備註: 部分c++程式設計指南中,並未區分併發與並行,只是統稱為併發。在UE原始碼中是區分了兩者的區別;部分 c++相關書籍指出,多核物理裝置中,兩個執行緒以上同時進行,不會進行協同稱之為並行,如果有切換等待或者交換同步則稱之為併發;具體區別,可自行查閱相關資料與書籍進行驗證;本文將不區分併發與並行;可能對併發與並行描述並非科學嚴謹,歡迎有識之人講解分析

  • 執行緒與程式區別

    • 程式

      程式是併發執行過程中資源分配和管理的基本單位。程式可以理解為一個應用程式的執行過程,應用程式一旦執行,就是一個程式。每個程式都有自己獨立的地址空間,每啟動一個程式,系統就會為它分配地址空間,建立資料表來維護程式碼段、堆疊段和資料段

    • 執行緒

      執行緒是程式執行的最小單元,是CPU執行任務與切換的基本的單元,是依賴於程式,每個執行緒獨佔一個虛擬處理器,暫存器組,指令計數器,核處理器狀態,多個執行緒共享當前程式的地址空間

    • 區別聯絡

      1. 一個程式包含多個執行緒,當前程式中的執行緒共享本程式地址空間,不同程式之間的地址空間是獨立的

      2. 獨立的程式有自已的執行入口,執行順序,執行緒不能獨立執行,依託於程式由多執行緒控制機制控制

      3. 切換程式的開銷大於切換執行緒的開銷,程式建立銷燬開銷大,但是可靠性高,執行緒開銷小,切換速度快,但是執行緒崩潰會導致程式崩潰,但是不會影響其它程式

    • 基於程式併發

      建立多個程式,然後每個程式分配任務,如果多個程式之間有通訊,則使用管道,系統ipc(訊息佇列、訊號量、訊號、共享記憶體)以及套接字進行。安全可靠,程式碼健壯性高,但是開銷比較大。如果用在遠端連結上,不同的機器上獨立執行的程式,在設計精良的系統上,又可能是一個提高並行與效能的低成本方式

    • 基於執行緒併發

      在c++11之前,大家實現多執行緒程式設計,可謂是八仙過海各顯神通;有使用pthread的,有使用boost::thread的,還有使用根據各個系統平臺提供的多執行緒API。在C++11之後,c++組織將多執行緒納入了自己的元件庫,這為多執行緒開發帶來很大的便捷。依賴少了,移植性高了。

      建立多個執行緒,對每個執行緒進行任務分配,如果多個執行緒有通訊,則使用訊號量,條件變數,互斥鎖等手段進行。這只是簡單的介紹一下,真正的多執行緒實現,又有各種需要考慮的問題,資源安全,任務分配合理性,以及減少切換效能開銷等。對應而生的一些新的技術,比如說執行緒池,任務系統,執行緒安全智慧指標等等。

2. 多執行緒併發

1. 使用併發的原因

當然多核處理系統已經誕生了很長一段時間了,但是對於程式開發人員來說,有部分人員還是忽略了這些,現在這個時間,很有必要將之納入自己的專業技能中了。

使用併發的原因:一是關注點分離(SOC)和提高效能

  • 關注點分離(SOC)

    關注點進行分離;簡單來說,通過將實現邏輯或者計算的一些程式碼進行分類,把一些不粘連的程式碼進行分離,這樣就可以程式更加的容易理解與健壯,而且我們在處理併發的時候,更容易處理臨界區域問題。

  • 提高效能

    利用併發提高效能有兩種方式

    • 任務併發

      將一個任務拆分為幾個部分,各個部分併發執行,從而降低總執行時間;

      雖然說起來很簡單,但是這個需要處理各個分任務之間可能存在的依賴問題,有時候需要付出很大的精力去處理。

    • 資料併發

      不同的資料部分上執行相同的指令

    任務併發是,一個執行緒執行演算法的一部分,另一個執行緒執行演算法的另外一部分;但是資料併發是,兩個執行緒執行的指令是相同的,但是執行的資料是不一樣的

2. 什麼時候不用併發

自從2000年以來,多核成為主流形式以後;經常會看到部分人在講,如果效能不行,效能低下,那就多開幾個執行緒,就會提高效能。從一定意義來講,這句話有道理,但是也不是完全有道理。

併發何時不能用與何時能用是同等重要的事情。核心一個原因收益比不上成本的幾種情況,如下所示

  • 收益比不上成本

    • 效能提升小於維護成本

      大多數情況下,使用併發是增加了程式的複雜度,使得程式碼不好理解。有可能當事人寫的清楚,但是後來維護的不清楚,即使在有文件的情況下也會看的一頭霧水,還極易可能引起更多的問題。在這種情況下,如果提升的效能很小,那麼就沒有必要去做併發了。除非潛在的效能提升以及關注點分離的非常清晰,那就請不要用併發

    • 效能提升小於預期

      我們常說,殺雞焉用宰牛刀。在這裡這句話也非常實用,作業系統會為每一個執行緒分配:虛擬處理器、暫存器組、指令計數器、核處理器狀態等作業系統資源,那麼在每次執行緒啟動時,也會有固定的開銷,然後才會把線執行緒加入到排程器中。那麼比如說,執行緒中的任務完成所耗費時間小於執行緒啟動的時間,那這個就非常得不償失,可以用殺雞焉用宰牛刀來描述一點都不過分

    • 執行緒是有限資源

      我們舉一個簡單的例子;大家都很清楚執行緒是需要系統資源的,假設每個執行緒都有1MB的堆疊空間,對於一個可用地址空間為4GB(32bit)的平坦架構的程式來說,4096個執行緒將會耗費光所有的地址空間。那麼留給程式碼、靜態資料或者堆資料的空間是0,那麼效率怎麼能高那?儘管我們使用執行緒池來優化執行緒所佔用資源,但是也並不是靈丹妙藥,線上程很多的情況下,消耗很多系統資源,也是會導致整個系統執行更加緩慢。從這一點來講,一定得平衡好這個節點,換句話說,我們開啟併發模式,一定是為了更加有價值的事情才會忽略一些限制,比如說併發可以使設計更加清晰,關注點分離的更加徹底,使當前的負載更加均衡,提高系統執行的效能,那麼併發是值得去做的。

備註:其實寫這兩個小節,我是非常糾結的,如果不寫,那麼一些併發的背景與基礎概念將介紹的含糊其辭,對於看這個文章的人來說,可能不清楚併發到底是為了解決什麼樣的問題,或者說接下來的多執行緒渲染是否是行業主流趨勢,沒有一個清晰的瞭解。但是寫了這一段,又覺得少了很多東西,比如說一些實際存在的profile資料證明;另外是我讀了一些書籍以及部落格,整理所寫的這些東西,總覺得哪裡少了點東西,哪裡說得不準確。如有有識之人指正或者找我討論,我歡迎至極。

3. 生產者消費者模型

為什麼要單獨拎出來這個基礎的多執行緒模型那。其實主要是為了後面多執行緒渲染的方案做一個基礎預熱,其實下面第二節所講的多執行緒渲染方案,主要是圍繞生產者消費者展開。如果是否要考慮現代api的一些特性,將圍繞分攤編碼器、編碼器分攤是否值得去介紹,具體的一些思考以及理解,將會在第三節簡單展開來說,受自己新渲染api掌握的深度侷限,可能思考的方向並非完全正確,有指正之人,將感激不盡。

本節只介紹單生產者單消費者模型,至於更復雜一些的單生產者多消費者、多生產者單消費者、多生產者多消費者,在這裡不贅述了,有興趣的可以自行查閱相關的資料與文件。

3.1 執行緒基礎

本文圍繞pthread的api對執行緒的一些基礎進行討論,c++11的相關的api將不在本文的討論範圍內。

pthread中的p代表的是POSIX,pthread是IEEE(電子和電氣工程師協會)委員會開發的一組執行緒介面,負責指定行動式作業系統介面(POSIX)

1. 執行緒管理
  • 執行緒建立

    int pthread_create (pthread_t *thread,pthread_attr_t *attr,void *(*start_routine)(void *),void *arg)
    • pthread_t

      該資料型別表示執行緒在程式中的唯一的表示符,是一個無符號長整型資料(32位系統為4位元組,在vc以及MinGW64中為4位元組,GCC(POSIX系統以及Cygwin)為8位元組)。該值不是由使用者指定的,是在函式建立新的執行緒的識別符號賦值該變數。

    • pthread_attr_t

      指定執行緒的屬性,可以使用NULL表示預設屬性;預設狀態下,執行緒在被建立時要被賦予一些屬性,這個屬性儲存在pthread_attr_t變數中,包含執行緒排程策略,堆疊相關資訊,join或者detach的狀態等

    • start_routine與arg

      指定執行緒開始執行的函式,而arg是start_routine所需要的引數,是一個無型別指標

    • 執行緒屬性

      執行緒建立,在預設情況下是帶有一些屬性的,開發人員可以通過執行緒屬性物件更改其中的一些屬性。其中pthread_attr_init與pthread_attr_destroy用於初始化/銷燬執行緒屬性物件

      然後使用屬性的api來查詢或者設定執行緒屬性物件中的特定屬性,如下:

      • 分離或可連結狀態

      • 排程繼承

      • 排程策略

      • 排程引數

      • 排程範圍

      • 堆疊大小

      • 堆疊位置

      • 堆疊保護大小

    建立完執行緒後,如何知道作業系統何時安排它執行,它將在那個處理器或者核心上執行?

    執行緒一旦建立,執行緒就是對等的,並且可以建立其他執行緒,而且執行緒之間是沒有隱含的層次結構或者依賴關係

    pthreads提供了一些api來進行排程執行,可以安排執行緒執行為FIFO(先進先出)、RR(迴圈)或者OTHER(作業系統確定),並提供了設定執行緒排程優先順序的能力,可以具體在sched_setscheduler手冊中檢視

    pthreads API不會提供將執行緒繫結到特定CPU/核心的一些例子。pthread_setaffinity_np是非標準的,可以處理將執行緒繫結到某個cpu,具體請檢視相關的api(本地作業系統會提供一些api來處理這些問題)。

  • 執行緒結束

    void pthread_exit (void *retval);
    int pthread_cancel (pthread_t thread);

    執行緒會在下面的幾種情況發生時,會終止執行緒

    • start_routine函式return了,執行緒所做的工作已經完成了

    • 呼叫了pthread_exit函式,會將所有的執行緒停止

    • 當前執行緒被另外一個執行緒通過呼叫pthread_cancel取消

    • 通過呼叫exec()或者exit()來終止執行緒

    • main()函式完成,但是並沒有顯示呼叫pthread_exit函式,執行緒會終止。但是如果main()顯示呼叫了pthread_exit,main()函式會被堵塞等待執行緒執行完畢後在退出

    pthread_exit函式,retval由開發人員指定引數,這個引數可以在pthread_exit執行完後獲的執行緒退出的一些狀態

    pthread_cancel函式,當前執行緒通過pthread_t引數指定另外一個執行緒的id來取消另外一個執行緒;當然這個只能取消同一個程式下的另外一個執行緒,成功返回0,失敗返回對應的錯誤碼

  • 執行緒阻塞

    執行緒建立後有兩種型別,一種是分離的,一種的可連結的

    顯示的建立可連結或可分離的執行緒,使用執行緒屬性物件,其典型的四個步驟如下:

    1. 宣告pthread_attr_t資料型別的屬性變數

    2. 初始化屬性變數pthread_attr_init()

    3. 設定屬性分離狀態pthread_attr_setdetachstate()

    4. 完成後,釋放屬性使用的資源pthread_attr_destory()

    • pthread_join函式是執行緒間同步的一種方式

      • 其它執行緒可以通過指定的threadid來等待執行緒完成

      • 同一個執行緒不可以被多個執行緒進行join,否則會出現不可意料的問題

    • pthread_detach可用於顯示的指定執行緒為可分離的,但是已經是可分離的不可以指定為可連結的

    如果執行緒是需要可連結的,那就考慮在建立執行緒時設定為可連結的。如果在建立執行緒之前,就明確知道該執行緒不需要連結,執行完畢後,就結束了,那就在建立執行緒時設定為可分離的,這樣線上程執行完畢後,一些系統資源也會一起被回收。因為系統資源有限,如果建立的可連結的執行緒很多的話,有可能新建立一根執行緒會有堆疊資源不夠用的錯誤。

  • 執行緒堆疊

    開發者往往會忽略這個問題,但是這個也總是會引起一些問題,POSIX標準並沒有規定執行緒的堆疊大小,而開發者總是喜歡使用預設的堆疊,當使用的系統資源超出了預設的堆疊大小,通常會產生程式終止或者資料損壞,然後花很大力氣與時間查詢這個問題。

    堆疊管理的API如下:

    pthread_attr_getstacksize (attr, stacksize);
    pthread_attr_setstacksize (attr, stacksize);
    pthread_attr_getstackaddr (attr, stackaddr);
    pthread_attr_setstackaddr (attr, stackaddr);

    從api的名字就可以大概瞭解這些函式的作用,這裡不在贅述

    提醒:POSIX標準沒有規定一個執行緒的堆疊大小(又一次強調了這個問題),因此想要編寫一個高質量的安全可靠的移植性強的程式,不要去依賴預設的堆疊設定,而是自己去呼叫pthread_attr_setstacksize來分配足夠的堆疊空間(重點).

2. 互斥量
  • 概述

    • 互斥量API

    pthread_mutex_init (mutex,attr)
    pthread_mutex_destroy (pthread_mutex_t *mutex)
    pthread_mutexattr_init (attr)
    pthread_mutexattr_destroy (attr)
    phtread_mutex_lock(pthread_mutex_t *mutex)
    phtread_mutex_trylock(pthread_mutex_t *mutex)
    phtread_mutex_unlock(pthread_mutex_t *mutex)

    具體的api作用,這裡不在贅述了,需要查詢具體使用的可以去查pthread的開發手冊

    • 互斥量使用順序

      • 建立和初始化互斥量

      • 多個執行緒試圖鎖定互斥鎖

      • 只有一個成功並且該執行緒擁有互斥鎖

      • 所有者執行緒執行一些操作

      • 所有者執行緒釋放互斥鎖

      • 另一個執行緒獲取互斥鎖重複4、5操作

      • 互斥鎖被銷燬

      pthread中互斥鎖的基本概念是,在任何時間段只有一個執行緒可以擁有互斥鎖,即使多個執行緒嘗試去獲取互斥鎖,也只有一個執行緒會成功獲取。

  • 建立/銷燬

    • 建立

      互斥鎖宣告是用pthread_mutex_t,並且初始化後是解鎖狀態,使用前需要初始化,兩種初始化方式如下:

      • 靜態: pthread_mutex_t mymutex = PTHREAD_MUTEX_INITIALIZE

      • 動態: 常規方式初始化,可以設定互斥鎖的屬性

    • 銷燬

      pthread_mutex_destroy()釋放不需要的互斥物件

    • 屬性

      attr是可以設定互斥鎖的屬性,也可以指定為NULL來設定為預設值。pthread標準中關於互斥鎖屬性有三個如下:

      • protocol: 指定用於防止互斥鎖優先順序反轉的協議

      • prioceiling: 指定互斥鎖的優先順序上限

      • process-shared: 指定互斥鎖在程式共享

      備註: 並非所有的系統都提供了三個可選的互斥鎖屬性

  • 鎖定/解鎖

    • 鎖定

      • pthread_mutex_lock函式用來獲取互斥鎖的鎖定,如果當前互斥鎖已經被另外一個執行緒鎖定了,那麼將被堵塞,直到互斥鎖解鎖;這裡就會涉及到一個問題,一個執行緒獲取到了互斥鎖,另外的執行緒則會一直輪詢這個互斥鎖,這將是會消耗系統資源的,故開發者慎用此種方式。

      • pthread_mutex_trylock()嘗試鎖定互斥鎖,如果互斥鎖被鎖定,那麼會返回"busy"錯誤碼。可以防止死鎖情況,比如說優先順序反轉

    • 解鎖

      • pthread_mutex_unlock()函式用來解鎖互斥鎖,解鎖互斥鎖後,其他執行緒才可以拿到該鎖的所有權。但是有兩種情況是錯誤的使用方式,如下:

        • 互斥鎖已經解鎖了,再次解鎖

        • 互斥鎖由另外一個執行緒鎖擁有,當前執行緒進行解鎖

3. 條件變數
  • 概述

    如果是互斥量的話,那麼就是兩種狀態,這樣如果有的執行緒沒有獲取到互斥鎖的所有權,那麼就會一直在輪詢等待該互斥鎖被釋放,然後獲取所有權,在去做一些事情。這樣會浪費一些系統效能,pthread標準提供了條件變數,可以在一個執行緒被阻塞的時刻,被另一個執行緒傳送訊號,當收到訊號時,阻塞的執行緒被喚醒然後獲取與之相關的互斥鎖。

    pthread標準中條件變數的使用要搭配互斥鎖。條件變數在某個執行緒在滿足某種條件下才會被喚醒去操作臨界區,避免了不斷的輪詢檢查條件是否成立而浪費系統資源的問題。互斥量的使用,要保證同一時刻,不會有多個執行緒去進行pthread_cond_wait以及pthread_cond_signal,造成不可預料的問題。

    pthread_cond_init (condition,attr);
    pthread_cond_destroy (condition);
    pthread_condattr_init (attr);
    pthread_condattr_destroy (attr);
    pthread_cond_wait(condition,mutex);
    pthread_cond_signal(condition);
    pthread_cond_broadcast(condition);
  • 建立/銷燬

    • 建立

      條件變數的宣告型別是pthread_cond_t,在使用之前需要進行初始化,初始化有如下兩種:

      • 靜態: pthread_cond_t mycond = PTHREAD_COND_INITIALIZER

      • 動態: 使用pthread_cond_init函式進行建立,條件變數的id通過函式的引數進行返回;attr可以設定條件變數的屬性,attr可以設定為process-shared(並非所有的系統可以設定程式共享屬性),建立和銷燬條件變數屬性物件是使用pthread_condattr_init()與pthread_condattr_destory()函式

    • 銷燬

      釋放不需要的條件變數使用pthread_cond_destory()函式

  • 等待/釋放

    • 等待

      pthread_cond_wait函式,該函式是阻塞當前執行緒。阻塞時,先鎖定互斥鎖,在等待條件變數時自動釋放互斥鎖。收到訊號時喚醒執行緒,然後自動鎖定互斥鎖,解鎖是需要開發人員進行解鎖。

    • 釋放

      pthread_cond_signal/pthread_cond_broadcast函式,線上程操作完臨界區時,需要解鎖互斥鎖,這樣在等待訊號量的執行緒會獲取互斥鎖並鎖定。當不止一個執行緒在在等待訊號時,用pthread_cond_broadcast來代替pthread_cond_signal

    • 注意事項

      在等待訊號量pthread_cond_wait之前要鎖定互斥鎖,否則執行緒將不會被堵塞

      在釋放訊號量pthread_cond_signal之後要解鎖互斥鎖,否則其它執行緒將獲取不了互斥鎖,不被允許匹配pthread_cond_wait

4. 附加: 訊號量
  • 簡單說一下

    嚴格來講,semaphores並不是pthread標準的一部分,是由POSIX標準定義的,是可以和pthread一起使用,但是並沒有帶pthread_字首就可見一斑了。

    訊號量的建立分為有名訊號量與無名訊號量,開發者常用的是一些無名訊號量,由於他允許多個執行緒對臨界區資源進行操作,這也是它並沒有條件變數以及事件等安全的原因。

    另外對於訊號量,在window平臺可以使用pthread_win32這個pthread的庫,來使用訊號量。在unix、以及linux、macos可以使用pthread,但是並非就代表,這些平臺都可以使用訊號量,訊號量並非是pthread的標準,是POSIX的標準。在最新的macos以及ios系統中,訊號量就已經不能使用了(macos以及ios中可以使用dispatch_semaphore_t,bgfx中對於macos以及ios也是用此api),已經是undefined了,所以想要使用訊號量,就需要查閱相關的資料,自己對訊號量在做一層封裝,以方便使用與移植性。具體的api以及一些注意事項,這裡就不再展開贅述了,想要了解的可以專門去檢視一些資料去學習。

    總結: 這個就是屬於題外話了,對於引擎來說,引擎的可移植性是一個非常重要的指標,那麼當開發多執行緒的時候,就不能單單依賴於某一個平臺的api了,就需要對各個平臺中功能相同介面不一樣api,封裝成統一的介面以供引擎開發使用。這是一個麻煩的事情,但是這個過程可以瞭解各個平臺差異性,熟悉一些api的具體能力以及缺陷,從中甄選或者封裝出來高效的統一介面,這就是在考察引擎開發人員的基本功了。所以引擎在對於一些基礎性的東西來說,它不是想象中的那麼困難,它只是一個組合體,讓藝術人員更好的展現一些東西,而不用在擔心效果以外的其他問題。所以請不要對引擎開發抱有畏懼心態,萬丈高樓平地起,都是簡單的東西湊成了不簡單而已。

3.2 生產者消費者

  • 概述

    先回歸最原始的問題,多執行緒是為了關注分離點以及提高效能;那麼使用生產者消費者模型,拆解任務,把任務拆解為提交任務與執行任務,程式碼框架更加清晰,更加容易擴充。另外,生產者消費者模型的臨界區資源保護處理相對清晰,多工處理,提高資料併發處理的效率;並且在現下這種硬體,更容易的提高程式的效能。也就是說生產者消費者模型也是遵循這個規則。

  • 具體思路

    利用訊號量,將生產者消費者任務進行分割,生產者作為任務提交者,消費者作為任務執行者,這樣邏輯程式碼將分割處理。使用雙佇列隊以及訊號量臨界區資源進行保護以及同步。簡單的提供一個生產者消費者例子來說明一些問題。

    例子中的程式碼是對訊號量以及執行緒進行了封裝的介面,感興趣的可以查閱一些開源的引擎的原始碼,推薦ue。一般多執行緒開發,不會去直接呼叫一些執行緒庫或者平臺提供的執行緒的api,而是對其進行一定意義上的封裝,對外提供統一介面。這樣介面易用,並且如果平臺對執行緒api進行了修改,也可更好的維護,簡單的說就是健壯性以及擴充性都會提高。

    說明:該程式碼是筆者自己學習驗證一些多執行緒方案的一個demo案例,雖說比較簡陋,但是大體框架以及思路是沒有問題的
    #pragma once
    #include "ThreadSemaphore.h"
    #include "ThreadMutex.h"
    #include "ThreadQueue.h"
    #include <unistd.h>
    namespace ThreadMultiRender
    {
        class ThreadDoubleQueue :public ThreadQueue
        {
        public:
            //區分一下初始化與賦值的區別,c++規定中,物件的成員變數的初始化動作是發生在進入建構函式本體之前
            //而賦值,是首先呼叫預設建構函式對成員變數進行設初值,然後再賦予新值,對比來看效能顯而易見,如果物件多了就明顯了
            //比如說成員變數的記憶體對齊,簡單的手動對齊,還有更加複雜的一些方式,當然都是一些簡單的事情組裝出來的
            //當然這個例子只是一個案例,更多的一些規則以及一些編碼細節都沒有遵循,但這些是應該去做的
            //比如說方法的讀寫屬性,如果確認方法百分百類內自己呼叫,那麼就要宣告為私有的,防止使用起來歧義以及錯誤
            //還有註釋,不求複雜標準,但求最基本的註釋一定要寫出來,方正羅裡吧嗦的一堆,儘量讓自己寫出來的程式碼優雅
            //還有一些其他的基礎的規則,還是建議大家儘量去做,如果不清楚,可以多讀讀EffectiveC++系列
            ThreadDoubleQueue()
                : m_EncoderList(0)
                , m_RenderList(0)
            {
            }
            virtual ~ThreadDoubleQueue()
            {
            }
            //MainThread call this function
            virtual void EngineUpdate()
            {
                BeginRender();
                //Submit Render CMD
                m_PrintMutex.Lock();
                m_EncoderList += 1;
                LOGI("MainThread=================================:%f", m_EncoderList);
                m_PrintMutex.UnLock();
                Present();
            }
            //RenderThread Call this function
            virtual void RenderOneFrame()
            {
                m_RenderSem.WaitForSignal();
                //m_RenderList = 2;
                m_PrintMutex.Lock();
                LOGI("RenderThread===:%f", m_RenderList);
                m_PrintMutex.UnLock();
                SimulationBusy();
                m_MainSem.Signal();
            }
        private:
            void Swap(float lhs,float rhs)
            {
                float temp = lhs;
                lhs = rhs;
                rhs = lhs;
            }
            void SimulationBusy()
            {
                sleep(3000);
                for (int i = 0; i < 10000000; i++)
                {
                    float value = 10 * 20 * 4.234 * 2341;
                }
            }
            //喚醒渲染執行緒
            void BeginRender()
            {
                m_RenderSem.Signal();
            }
            //等待渲染執行緒渲染完畢,交換緩衝佇列
            void Present()
            {
                m_MainSem.WaitForSignal();
                //Swap(m_EncoderList,m_RenderList);
                float temp = m_EncoderList;
                //m_EncoderList = m_RenderList;
                m_RenderList = temp;
                m_PrintMutex.Lock();
                LOGI("Swap CMD  m_EncoderList  ===:%f", m_EncoderList);
                LOGI("Swap CMD  m_RenderList   ===:%f", m_RenderList);
                m_PrintMutex.UnLock();
            }
        private:
            float           m_EncoderList;
            float           m_RenderList;
            ThreadSemaphore m_MainSem;
            ThreadSemaphore m_RenderSem;
            ThreadMutex     m_PrintMutex;
        };
    }

    該程式碼並不能執行,只是一個框架學習demo,其中有一些類是筆者自己寫的庫中的封裝。

    其它的程式碼這裡就不再提供了,感興趣的可以自己去封裝一套簡單的跨平臺執行緒庫。這裡只提供核心生產者消費的任務拆分,臨界區資料保護,同步策略。

    • 任務拆分

      主執行緒喚醒渲染執行緒,生產渲染指令

      渲染執行緒被喚醒後,執行渲染指令

    • 資料保護

      渲染執行緒執行渲染佇列(渲染buffer)的命令,或者說讀取渲染佇列的渲染命令;主執行緒往編碼佇列(編碼buffer)提交命令,或者說是往編碼佇列寫渲染命令。在滿足一定條件下,交換渲染佇列與編碼佇列的命令,這樣讀寫是分開的,不會同時讀寫一個buffer或者是佇列,保證資料是安全的,邏輯處理也更加清晰。

    • 同步策略

      主執行緒生產完渲染指令,申請編碼佇列與渲染佇列交換,如果渲染執行緒沒有執行完上一次渲染佇列指令,那麼主執行緒是被阻塞;

      渲染執行緒執行完上一次渲染佇列指令,主執行緒結束阻塞,編碼佇列與渲染佇列互換。渲染執行緒執行交換後的渲染佇列指令,主執行緒編碼下一次編碼佇列指令,如此迴圈。

  • 總結

    這是典型的雙佇列同步解決方案,是生產者消費者的一種解決方案。當然還有其他的方案,比如說環形無鎖佇列等,當然這裡不再討論這種方案,感興趣的可自行查閱環形無鎖佇列這種經典模式。有人為問為啥要介紹雙佇列同步方案,那是因為接下來的bgfx多執行緒方案是基於剛才所介紹這種框架之上的。

    雙佇列同步方案,一個特點是,主執行緒是比渲染執行緒快一次編碼指令的,或者說渲染執行緒執行的渲染指令是滯後的,這在遊戲引擎中的被稱為差幀渲染。這種方式的好處是,將算力分攤,不在讓渲染執行緒執行一些與邏輯相關的操作,只關注於自己渲染指令的執行,這樣GPU的執行耗時會更加平穩,不會再出現極端的波峰與波谷現象,導致幀率波動過大。而主執行緒也不需要在去關心渲染指令的提交,只需要去關心相關邏輯以及一些CPU運算元的處理。

2. bgfx多執行緒方案

概述

Bgfx多執行緒渲染是以生產者消費者為基礎,將邏輯拆分到生產執行緒,將執行拆分拆到渲染執行緒,使用雙佇列(雙緩衝)來保護臨界區資源。但是這並非是真正的多執行緒渲染,真正的多執行緒渲染是沒有渲染執行緒的概念,如Vulkan、Metal以及Dx12,可以多個執行緒同時訪問圖形API。目前的圖形API,如OpenGL、OpenGLES、DX9及DX10不允許多個執行緒同時訪問圖形API(多執行緒同時訪問圖形API有諸多限制)。

Bgfx中並不支援立即模式的渲染模式,它的整體框架設計,沒有有區分移動端以及PC端,是主執行緒比渲染執行緒快至多一幀的延遲模式。UE4的多執行緒渲染框架中,提供了立即模式與延遲模式兩種方式,個人認為這種框架設計能更好的釋放不同平臺的特性。畢竟移動端的GPU架構與PC端的GPU架構是不一樣的(移動端是TBR的架構,而PC端是IBR架構)。

Bgfx雖然對於多執行緒的封裝並不如一些商業引擎做的靈活與複雜,但是Bgfx勝在輕量。倘若讓開發者將UE4的渲染系統摘抄出來,不如直接拿這個開發者祭天,這樣開發者能更痛快一些。還有就是Bgfx中雖然多執行緒架構不是非常靈活好用,但是它在驅動層的封裝,OpenGL、Metal、Vulkan以及DX系列,將絕大多數渲染所需的介面囊括進來了,因此這也是眾多人員樂意使用它的原因之一。

筆者雖然對於Bgfx的瞭解不足Bgfx設計者的十之一二,但是筆者在學習Bgfx的過程中也會有一些自己的看法以及想法在,如下:

  • 記憶體管理不夠靈活

    Bgfx中是從初始化開始,就定義了最大的Drawcall數量,併為之申請了一個非常大的記憶體池出來給其使用。然後記憶體回收起來並非是用執行緒安全的智慧指標進行管理,而是手動去管理那個資源該回收了。如果一幀內需要繪製的Drawcall數量超過了最大值,就會崩潰,如果改變其最大Drawcall數量,那就導致整體記憶體又翻倍。而超過Drawcall最大數量的幀少數存在的。因此它對於一些重型的遊戲引擎來說,bgfx的記憶體管理的方式是需要進行擴充開發。

  • 程式碼堆積&巨集滿天

    Bgfx中的程式碼邏輯,從開發者的角度來講,確實是比較清晰的。但是如果從閱讀者來講,Bgfx的程式碼完全堆積在一個檔案中,這就導致Bgfx的入門異常艱難,常常為了理清一個邏輯,要看好長時間的程式碼才能理清楚。另外一個問題是,Bgfx中的巨集定義真的是滿天都是,有的時候,看著看著就完全懵掉了。這就導致Bgfx的程式碼可讀性極其差。

  • Bgfx的Encoder與新圖形API的區別

    Bgfx中是有編碼器池的概念,即EncoderPool,Encoder從EncoderPool中分配而出。渲染執行緒持有EncoderPool池,每個執行緒可以從EncoderPool中申請至多一個Encoder。但是每個持有Encoder的執行緒編碼渲染命令提交到m_submit(雙緩衝佇列之一)時,Bgfx並未有時序處理。而新的圖形API中,例如Vulkan中,不同執行緒持有CommandBuffer(可與Bgfx的Encoder類比)是來自於不同的CommandBufferPool中,並且CommandBuffer之間可以使用事件與屏障做同步處理,這樣不同的CommandBuffer之間也可以人為定義時序了,而不是像Bgfx一樣不可控制,想要控制還需要開發者自己擴充。因此Bgfx多執行緒框架對Vulkan、Metal以及DX12來說是極其不友好的。而這塊做的比較好的引擎就是U3D了,針對新的圖形API提供了GraphicsJobs的多執行緒渲染模式,能更好的利用圖形API特性。

雖然Bgfx有一些缺點,但是整體來說它是非常輕量的,並且在渲染的各個能力支援是比較全的,相較於一些成熟商業級引擎有一些不足,但是它的輕量以及完備足以讓它作為一些小型引擎的渲染底層了。

1. Bgfx框架

Bgfx雖然程式碼堆積在一個檔案內,巨集定義滿天飛,但是它整體的框架層次邊界還是很清晰的。

  • 介面層

    主要是在Bgfx.h這個標頭檔案,主要包含一些渲染所使用的介面、對外所使用的資料結構

  • 編碼層

    這一層稍微複雜些,主要涉及Context、Encoder以及CommandBuffer三個類。其主要是一些執行緒同步、臨界區資源安全保護、Encoder編碼資料如何安全提交到m_submit佇列。

  • 驅動層

    這一層就比較簡單直觀,由一個基類RenderContextI,其下有對應圖形API的子類,幷包含一些各個圖形API對應的資源封裝類。而其與編碼層的互動,分為兩個方面

    • 渲染資源的建立(例如CreateXXX、SetXXXX等等由渲染執行緒遍歷CommandBuffer進行呼叫)

    • RenderQueue(包含一幀中所有的drawcall資料)中一幀渲染命令資料的執行(在Submit函式中for迴圈執行)

圖一

1. 介面層

bgfx的對外介面是集中在bgfx.h這個標頭檔案內。其主要介面提供分為兩個方式,如下

  • C語言型別的介面

  • Encoder類提供的介面

這兩種介面是有所關聯的,C語言的介面,一部分是把命令編碼進了CommandBuffer(m_submit佇列成員變數),一部分是呼叫到了Encoder類的介面。故C語言型別的介面是包含Encoder類所提供的介面的。

1.1 對外介面
  • Encoder介面

    void setMarker(const char* _marker);
    void setState(uint64_t _state, uint32_t _rgba=0);
    void setCondition(OcclusionQueryHandle _handle,bool _visible);
    void setStencil(uint32_t _fstencil,uint32_t _bstencil=BGFX_STENCIL_NONE);
    uint16_t setScissor(uint16_t _x, uint16_t _y, uint16_t _width, uint16_t _height);
    void setScissor(uint16_t _cache = UINT16_MAX);
    uint32_t setTransform(const void* _mtx,uint16_t _num=1);
    uint32_t allocTransform(Transform* _transform,uint16_t _num);
    void setTransform(uint32_t _cache,uint16_t _num = 1);
    void setUniform(UniformHandle _handle,const void* _value,uint16_t _num=1);
    void setIndexBuffer(IndexBufferHandle _handle);
    void setIndexBuffer(IndexBufferHandle _handle,uint32_t _firstIndex,uint32_t_numIndices);
    void setIndexBuffer(DynamicIndexBufferHandle _handle);
    void setIndexBuffer(DynamicIndexBufferHandle _handle,uint32_t _firstIndex,uint32_t _numIndices);
    void setIndexBuffer(const TransientIndexBuffer* _tib);
    void setIndexBuffer(const TransientIndexBuffer* _tib,uint32_t _firstIndex,uint32_t _numIndices);
    void setVertexBuffer(uint8_t _stream,VertexBufferHandle _handle);
    void setVertexBuffer(uint8_t _stream,VertexBufferHandle _handle,uint32_t _startVertex
                         ,uint32_t _numVertices,VertexLayoutHandle _layoutHandle = BGFX_INVALID_HANDLE);
    void setVertexBuffer(uint8_t _stream,DynamicVertexBufferHandle _handle);
    void setVertexBuffer(uint8_t _stream,DynamicVertexBufferHandle _handle,uint32_t _startVertex
                        , uint32_t _numVertices, VertexLayoutHandle _layoutHandle = BGFX_INVALID_HANDLE);
    void setVertexBuffer(uint8_t _stream,const TransientVertexBuffer* _tvb);
    void setVertexBuffer(uint8_t _stream,const TransientVertexBuffer* _tvb,uint32_t _startVertex
                        , uint32_t _numVertices,VertexLayoutHandle _layoutHandle=BGFX_INVALID_HANDLE);
    void setVertexCount(uint32_t _numVertices);
    void setInstanceDataBuffer(const InstanceDataBuffer* _idb);
    void setInstanceDataBuffer(const InstanceDataBuffer* _idb,uint32_t _start,uint32_t _num);
    void setInstanceDataBuffer(VertexBufferHandle _handle,uint32_t _start,uint32_t _num);
    void setInstanceDataBuffer(DynamicVertexBufferHandle _handle,uint32_t _start,uint32_t _num);
    void setInstanceCount(uint32_t _numInstances);
    void setTexture(uint8_t _stage,UniformHandle _sampler
                    ,TextureHandle _handle,uint32_t _flags=UINT32_MAX);
    void touch(ViewId _id);
    void submit(ViewId _id,ProgramHandle _program,uint32_t _depth=0,uint8_t _flags= BGFX_DISCARD_ALL);
    void submit(ViewId _id,ProgramHandle _program,OcclusionQueryHandle _occlusionQuery
                ,uint32_t _depth=0,uint8_t _flags=BGFX_DISCARD_ALL);
    void submit(ViewId _id,ProgramHandle _program,IndirectBufferHandle _indirectHandle
                ,uint16_t _start=0,uint16_t _num=1,uint32_t _depth=0,uint8_t _flags=BGFX_DISCARD_ALL);
    void setBuffer(uint8_t _stage,IndexBufferHandle _handle,Access::Enum _access);
    void setBuffer(uint8_t _stage,VertexBufferHandle _handle,Access::Enum _access);
    void setBuffer(uint8_t _stage,DynamicIndexBufferHandle _handle,Access::Enum _access);
    void setBuffer(uint8_t _stage,DynamicVertexBufferHandle _handle,Access::Enum _access);
    void setBuffer(uint8_t _stage,IndirectBufferHandle _handle,Access::Enum _access);
    void setImage(uint8_t _stage,TextureHandle _handle,uint8_t _mip
                ,Access::Enum _access,TextureFormat::Enum _format=TextureFormat::Count);
    void dispatch(ViewId _id,ProgramHandle _handle,uint32_t _numX=1
                ,uint32_t _numY=1,uint32_t _numZ=1,uint8_t _flags=BGFX_DISCARD_ALL);
    void dispatch(ViewId _id,ProgramHandle _handle,IndirectBufferHandle _indirectHandle
                ,uint16_t _start=0,uint16_t _num=1,uint8_t _flags=BGFX_DISCARD_ALL);
    void discard(uint8_t _flags = BGFX_DISCARD_ALL);
    void blit(ViewId _id,TextureHandle _dst,uint16_t _dstX,uint16_t _dstY,TextureHandle _src
              ,uint16_t _srcX=0,uint16_t _srcY=0,uint16_t _width=UINT16_MAX,uint16_t _height=UINT16_MAX);
    void blit(ViewId _id,TextureHandle _dst,uint8_t _dstMip,uint16_t _dstX,uint16_t _dstY
        ,uint16_t _dstZ,TextureHandle _src,uint8_t _srcMip=0,uint16_t _srcX=0
        ,uint16_t _srcY=0,uint16_t _srcZ=0,uint16_t _width=UINT16_MAX
        ,uint16_t _height=UINT16_MAX,uint16_t _depth=UINT16_MAX);

    從介面來看,包含了設定渲染狀態(混合、模板、剪裁、深度、卷繞等)、設定矩陣、設定頂點、索引、紋理、圖片、幾何例項化資料等一次Drawcall所需要的渲染資源以及狀態的介面。開發者通過這些介面將渲染資源以及狀態設定給Encoder中,並儲存到自身的快取中,在submit函式被呼叫時,提交到m_submit佇列中。

  • C語言型別介面

    //建立GPU資源,並有相關Enum列舉標記
    IndexBufferHandle createIndexBuffer(const Memory* _mem, uint16_t _flags);
    void setName(IndexBufferHandle _handle, const bx::StringView& _name);
    void destroyIndexBuffer(IndexBufferHandle _handle);
    VertexLayoutHandle createVertexLayout(const VertexLayout& _layout);
    void destroyVertexLayout(VertexLayoutHandle _handle);
    VertexBufferHandle createVertexBuffer(const Memory* _mem, const VertexLayout& _layout, uint16_t _flags);
    void destroyVertexBuffer(VertexBufferHandle _handle);
    DynamicIndexBufferHandle createDynamicIndexBuffer(uint32_t _num, uint16_t _flags);
    DynamicIndexBufferHandle createDynamicIndexBuffer(const Memory* _mem, uint16_t _flags);
    void update(DynamicIndexBufferHandle _handle, uint32_t _startIndex, const Memory* _mem);
    DynamicVertexBufferHandle createDynamicVertexBuffer(uint32_t _num, const VertexLayout& _layout, uint16_t _flags);
    DynamicVertexBufferHandle createDynamicVertexBuffer(const Memory* _mem, const VertexLayout& _layout, uint16_t _flags);
    void update(DynamicVertexBufferHandle _handle, uint32_t _startVertex, const Memory* _mem);
    uint32_t getAvailTransientIndexBuffer(uint32_t _num);
    uint32_t getAvailTransientVertexBuffer(uint32_t _num, uint16_t _stride);
    void allocTransientIndexBuffer(TransientIndexBuffer* _tib, uint32_t _num);
    void allocTransientVertexBuffer(TransientVertexBuffer* _tvb, uint32_t _num, const VertexLayout& _layout);
    void allocInstanceDataBuffer(InstanceDataBuffer* _idb, uint32_t _num, uint16_t _stride);
    IndirectBufferHandle createIndirectBuffer(uint32_t _num);
    ShaderHandle createShader(const Memory* _mem);
    uint16_t getShaderUniforms(ShaderHandle _handle, UniformHandle* _uniforms, uint16_t _max);
    void destroy(ShaderHandle _handle);
    ProgramHandle createProgram(ShaderHandle _vsh, ShaderHandle _fsh, bool _destroyShaders);
    ProgramHandle createProgram(ShaderHandle _vsh, bool _destroyShader);
    void destroyProgram(ProgramHandle _handle);
    TextureHandle createTexture(const Memory* _mem);
    void* getDirectAccessPtr(TextureHandle _handle);
    void destroyTexture(TextureHandle _handle);
    uint32_t readTexture(TextureHandle _handle, void* _data, uint8_t _mip);
    TextureHandle createTexture(...);
    TextureHandle createTexture2D(...);
    TextureHandle createTexture3D(...);
    TextureHandle createTextureCube(...);
    void updateTexture(TextureHandle _handle,...);
    void updateTextureCube(TextureHandle _handle,...);
    void updateTexture2D(TextureHandle _handle,...);
    void updateTexture3D(TextureHandle _handle,...);
    FrameBufferHandle createFrameBuffer(...);
    void destroy(FrameBufferHandle _handle);
    TextureHandle getTexture(FrameBufferHandle _handle, uint8_t _attachment);
    UniformHandle createUniform(const char* _name, UniformType::Enum _type, uint16_t _num);
    void getUniformInfo(UniformHandle _handle, UniformInfo& _info);
    void destroyUniform(UniformHandle _handle);
    OcclusionQueryHandle createOcclusionQuery();
    OcclusionQueryResult::Enum getResult(OcclusionQueryHandle _handle, int32_t* _result);
    void destroy(OcclusionQueryHandle _handle);
    void setPaletteColor(...);
    void setViewXXXX(ViewId _id, const char* _name);
    //顯示建立編碼器介面、以及同步介面
    Encoder* begin(bool _forThread = false);
    void end(Encoder* _encoder);
    //主執行緒申請交換佇列介面
    uint32_t frame(bool _capture = false);

    這塊主要是有幾塊重要介面(另外一小部分其它功能介面就不做介紹了)

    • bgfx對外類c介面中關於GPU資源的建立,這部分介面會返回XXHandle的資料結構。XXHandle用於索引圖形API返回的控制程式碼ID(注意出現概率很高的資料結構Memory)

    • 一部分是呼叫到了Encoder中的介面(不在展示)。

    • 關於編碼器建立,編碼器同步通訊介面。

    • 主執行緒與渲染執行緒同步通訊的介面。

1.2 對外資料結構

對外的資料結構,是為了更好的與主執行緒與渲染執行緒進行資料的同步交換。其涉及如下幾個部分

  • bgfx初始化設定相關的據結構封裝;平臺屬性,初始化設定,以及圖形API能力支援

    struct PlatformData
    {};
    struct Init
    {};
    struct Caps
    {};
    const Caps* getCaps();
    const Stats* getStats();
  • 對帶有GPU控制程式碼返回值的渲染資源的抽象封裝

    #define BGFX_HANDLE(_name)                                                           \
        struct _name { uint16_t idx; };                                                  \
        inline bool isValid(_name _handle) { return bgfx::kInvalidHandle != _handle.idx; }
    BGFX_HANDLE(DynamicIndexBufferHandle)
    BGFX_HANDLE(DynamicVertexBufferHandle)
    BGFX_HANDLE(FrameBufferHandle)
    BGFX_HANDLE(IndexBufferHandle)
    BGFX_HANDLE(IndirectBufferHandle)
    BGFX_HANDLE(OcclusionQueryHandle)
    BGFX_HANDLE(ProgramHandle)
    BGFX_HANDLE(ShaderHandle)
    BGFX_HANDLE(TextureHandle)
    BGFX_HANDLE(UniformHandle)
    BGFX_HANDLE(VertexBufferHandle)
    BGFX_HANDLE(VertexLayoutHandle)

    從資料結構封裝來看,不難看出都是一些帶有GPU控制程式碼返回值的一些資源,針對這些資源,bgfx對外提供其對應的資料結構封裝,更方便主執行緒(編碼執行緒)與渲染執行緒的互動,同時也防止誤傳值(EffectiveC++有介紹這個思路)

  • 一次Drawcall所需要的渲染資源的抽象封裝,以及一幀所有資料統計封裝

    struct TransientIndexBuffer
    {};
    struct TransientVertexBuffer
    {};
    struct TextureInfo
    {};
    struct UniformInfo
    {};
    struct Attachment
    {};
    struct Attrib
    {};
    struct AttribType
    {};
    struct TextureFormat
    {};
    struct UniformType
    {};
    struct BackbufferRatio
    {};
    struct OcclusionQueryResult
    {};
    struct ViewMode
    {};
    struct Resolution
    {};
    struct Stats//一幀中相關狀態的封裝
    {};
  • 記憶體相關;

    這個資料結構是非常重要的,主執行緒(編碼器執行緒)生產渲染指令,但是有些指令是需要cpu端的資料的,比如說頂點資料。那麼就會存在主執行緒以及渲染執行緒都會有擁有該份資料所有權的時刻,那麼如果不對其加以讀防寫,那麼是會產生讀寫衝突等問題。bgfx中的方案是,利用Memory這個對外資料結構,將主執行緒的CPU資料拷貝一份到渲染執行緒中,這樣主執行緒與渲染執行緒各持有一份自己的資料,就不會擔心臨界區資源的問題了。(bgfx還提供了一種方式,主執行緒提供一個資料釋放的函式,然後以函式指標的形式交給渲染執行緒保管,但是該段記憶體的釋放還是歸於渲染執行緒管理)

    struct Memory
    {};
    struct Access
    {};
    const Memory* alloc(uint32_t _size);
    const Memory* copy(const void* _data,uint32_t _size);
    const Memory* makeRef(const void* _data,uint32_t _size
            ,ReleaseFn _releaseFn=NULL,void* _userData=NULL);
  • 排序優化相關

    筆者目前暫未涉略到,不過按照筆者的理解是優化處理相關的一些;還有一部分是頂點打包,頂點佈局也是優化相關的一些處理(頂點佈局相關的處理,在ue以及u3d中都有對應的方案來優化處理,感興趣的可以自行檢視)

    struct Topology
    {};
    struct TopologyConvert
    {};
    struct TopologySort
    {};
    void vertexPack(...);
    void vertexUnpack(...);
    uint32_t topologyConvert(...);
    void topologySortTriList(...);
    VertexLayoutHandle createVertexLayout(const VertexLayout& _layout);
    void destroy(VertexLayoutHandle _handle);

2. 編碼層

編碼層,主要的類為Context、EncoderI以及ComandBuffer。對介面層的具體實現,對多執行緒同步通訊的處理,Encoder與CommandBuffer區別與聯絡,對臨界區資源安全保護。

  • 主執行緒與渲染執行緒同步的封裝

    基於生產者消費者模型,利用訊號量機制,對渲染執行緒與主執行緒之間的同步進行封裝處理。主執行緒申請交換雙佇列函式為Frame(),渲染執行緒渲染函式為RenderFrame()函式。

  • 編碼執行緒與主執行緒同步的封裝

    基於生產者消費者模型,利用訊號量機制,對編碼執行緒與主執行緒之間的同步進行封裝處理。

  • Encoder與CommandBuffer區別與聯絡

    Encoder是由EncoderPool分配,其至多可以分配八個編碼器(如想新增可改動程式碼),並在申請Encoder時,由一把鎖保護,防止多個執行緒同時申請編碼器,其本身擁有RenderDraw、RenderBind以及RenderCompute等資料結構及變數,可以快取編碼執行緒編碼的一次Drawcall資料,該資料是要提交到m_submit佇列中的。

    CommandBuffer是m_submit的成員變數,主執行緒(編碼執行緒)建立帶有GPU控制程式碼的渲染資源,以Key-value的格式編碼到CommandBuffer中(Key為XXHandle,Value為記憶體資料或XXHandle)。

    在渲染執行緒啟動時,先執行完m_render佇列中CommandBuffer的渲染命令(呼叫驅動層封裝的介面),然後在將m_render佇列中N個Drawcall全部執行一遍(呼叫驅動層的Submit()函式)。

  • 臨界區資源安全保護

    CommandBuffer面臨多個編碼執行緒同時進行編碼,這裡使用互斥鎖保護

    m_submit佇列面臨多個編碼執行緒同時進行編碼,使用一把自旋鎖對m_submit進行保護

    CommandBuffer中編碼的渲染命令如果帶有圖形API返回的控制程式碼,bgfx是返回一個XXHandle的物件給編碼執行緒使用(這一塊是有一個記憶體池)。其一是防止有返回GPU控制程式碼的值,外部使用的時候傳值錯誤(EffectiveC++有介紹);其二使用記憶體池,小記憶體從池分配,防止碎片,提高建立物件效能;其三,主執行緒其實也並不關心真的圖形API返回控制程式碼的值,bgfx根據XXHandle索引到對應的真正的圖形API控制程式碼再使用即可。

3. 驅動層

驅動層主要是幾個方面,建立渲染資源的統一介面,執行m_render渲染命令佇列的命令,渲染資源的資料結構封裝。

  • GPU資源建立

    渲染API統一介面封裝,其基類RenderContextI,例如建立FBO、建立頂點、建立著色器物件等純虛擬函式介面,每一個子類都要去實現基類中的介面。

  • RenderQueue執行

    編碼層優先將m_render佇列中的CommandBuffer進行遍歷執行,CommandBuffer記錄的渲染命令是建立一些帶有GPU控制程式碼的渲染資源,然後呼叫對應的函式進行建立。

  • 渲染資源的封裝

    將每一個圖形api對應的渲染資源抽象為資料結構,以方便進行使用。

  • 個人看法:

    這裡著重說一下Bgfx中關於OpenGL的擴充,Bgfx對於OpenGL做了大量的API擴充,這也是對硬體平臺效能的極致壓榨,也是對引擎效果跟效能的一種極致提升了。感興趣的同學可以自行檢視相關(擴充)程式碼。針對於驅動層,筆者有自己的一些看法與理解。如下

    • OpenGL渲染狀態每次Drawcall都要重置一遍

      bfx中對於OpenGL&ES渲染命令的執行(submit)函式,其中對於渲染狀態,bgfx是每一次drawcall都會開啟關閉大量的渲染狀態。而OpenGL是狀態機,一個狀態的改變,如果開發人員不去改變,OpenGL會一直保持這個狀態,如果開發人員頻繁的去改變一些狀態,這對效能來說,也是有所消耗的。UE4中是對其做了快取處理的,每一次Drawcall都與上一次對比,這樣可減少部分渲染狀態反覆重置的問題。

    • 渲染資源的封裝不夠靈活

      關於渲染資源的封裝,bgfx更像是對渲染資料做一些簡單封裝以便於使用而已,並沒有對其進行抽象,然後opengl、dx、vulkan以及metal都有自己的一套資源資料結構。這也不能說這種模式不好,但是如果想依靠智慧指標(執行緒安全)管理資源的生命週期,就得需要在進行擴充了;或者是做常見的LRU快取處理,也是一件比較頭疼的事情,要寫好幾份程式碼。感興趣的同學可以檢視這塊的封裝,也可以去檢視UE4中關於這塊的處理方案,同時思考一下UE4為什麼這麼做?

圖二

2. 雙佇列同步

上一節從廣義的框架或者層次結構上結合一些介面或者細節來介紹了一下Bgfx,由此也會對Bgfx有一個初步的認知。本節將主要分析Bgfx中多執行緒渲染的一些方案跟細節。主要從如下幾個方面介紹,主執行緒與渲染執行緒同步通訊,編碼執行緒與主執行緒同步通訊,GPUHandle機制的設計。

2.1 渲染執行緒與主執行緒同步

圖三

如上圖三,主執行緒第一幀先喚醒渲染執行緒,然後執行Init操作,然後主執行緒進行編碼第一幀的渲染命令,並將命令快取到m_submit佇列中。當第一幀的Update走完,標誌著主執行緒第一幀編碼命令已經結束,這個時候呼叫Frame函式申請與渲染執行緒進行命令佇列的交換,如果這個時候渲染執行緒的Init函式執行完畢了,那麼就會交換佇列。主執行緒繼續Update更新編碼渲染命令到m_submit佇列中,渲染執行緒並行執行第一幀的渲染命令(將渲染命令提交到GPU中),如此往復。如果主執行緒跑的比渲染執行緒快,主執行緒第N+1幀已經編碼完畢,渲染執行緒第N幀還未執行完畢,主執行緒阻塞;如果渲染執行緒比主執行緒快,渲染執行緒第N幀已經執行結束,主執行緒第N+1幀未編碼結束,渲染執行緒阻塞。這樣就能保證朱祥成至多比渲染執行緒多跑一幀,除了在交換佇列是序列外,其它時間是並行。

為了方便理解,這裡筆者將bgfx的雙佇列方案用簡單的虛擬碼實現出來,這樣更方便大家去理解上段介紹的知識。

class Context
{
public:
    Context()
    {
    }
    void Init()
    {
        //主執行緒第一幀起始時呼叫一次
        m_ApiSem.Post();
    }
    //Each Frame is called by the MainThread
    void Frame()
    {
        m_RenderSem.Wait();
        Swap();
        m_ApiSem.Post();
    }
    //Each Frame is called by the RenderThread
    void RenderFrame()
    {
        m_ApiSem.Wait();
        //編碼標記的渲染命令被驅動層執行建立真正渲染資源
        CommandBuffer.Render();
        Render();
        m_RenderSem.Post();
    }
    private:
        Render()
        {
            //m_Render渲染佇列執行
        }
        void Swap()
        {
            Frame temp;
            temp = m_Submit;
            m_Submit = m_Render;
            m_Render = temp;
        }
    private:
        RenderSem   m_RenderSem;
        APISem      m_ApiSem;
        Frame       m_Submit;
        Frame       m_Render;
};

這段是虛擬碼,並不太嚴謹或者說可以執行,但它可以清晰的描述雙佇列幀同步的實現方式,這樣更容易理解。細心的人可能已經發現了,這段虛擬碼與前面章節的生產者消費者模型非常像(嚴格意義上來講是一模一樣)。這就說明一個道理,往往很複雜的方案或者技術都是從一些很基礎的知識上延伸出來,將一些簡單的知識疊加到一起就會變得不簡單。

2.2 編碼執行緒與主執行緒同步

本節的部分內容在第一節的第二個小部分已經簡略的介紹了,本節在以圖的方式具體介紹一下。

1. 同步策略
圖四

如上圖四,在bgfx中,如果不申請編碼器,bgfx是預設有一個編碼器給主執行緒使用。編碼執行緒,或者稱之為工作執行緒,進行一些渲染命令的編碼,編碼執行緒編碼完畢(如果是多個編碼執行緒,主執行緒在申請交換佇列時等待所有的編碼執行緒編碼完畢),然後主執行緒在與渲染執行緒交換m_submit與m_render佇列。

2. Encoder與CommandBuffer
圖五

如上圖五所示,其中Encoder是可以有多個,CommandBuffer在m_submit佇列只有一個(具體為CMDPre與CMDPos)。CommandBuffer編碼帶GPU返回控制程式碼的渲染命令,會返回Bgfx封裝的XXHandle。Encoder設定一次Drawcall相關的渲染命令時,XXHandle也是引數之一。這樣Drawcall建立Shader、建立FBO、建立VBO、設定渲染狀態(混合、深度、模板、剪裁等)等是一個完整的Pipeline了,然後在合適的時機將這次Drawcall提交到m_submit佇列中。

3. SubmitQueue安全保護
圖六

如上圖六,無論是編碼執行緒,亦或者是主執行緒,嚴格意義上來講,都可以統一稱為編碼執行緒。區別是在與主執行緒有與渲染執行緒同步操作,而編碼執行緒是與主執行緒進行同步。主執行緒(編碼執行緒)在往CommandBuffer中寫渲染命令時需要拿到資源鎖才可以進行編碼,防止多個執行緒同時往CommandBuffer中寫入資料。編碼器中一次Drawcall的快取渲染資源(呼叫submit(...)函式啟動提交)向m_submit中提交時,是需要拿一把鎖(自旋鎖),確認當前m_submit中安全的可以寫入一次Drawcall渲染資料的位置,然後寫入。

備註:本文不在對bgfx的view的機制進行詳細介紹(由於篇幅有限,字數嚴重超標),感興趣的同學可自行檢視原始碼。

3. GPUHandle封裝

主執行緒或者是編碼執行緒,使用CommandBuffer編碼渲染命令時,圖形APi是會返回GPU的控制程式碼的(其實也就是一個int值),但是圖形API的控制程式碼ID並不會返回給主執行緒使用,而是返回了一個XXHandle物件回來。

這個XXHandle具體的作用是,在bgfx中作為陣列的ID去找到真正的圖形API的值,並對其進行操作。換句話說它更像是一箇中間代理值,XXHandle代表了什麼圖形API的返回值。

bgfx對於XXHandle的作用如下

  • 大量渲染命令的建立,都帶有圖形API返回值(XXHandle物件),這些值也就是一個整數而已,所以小記憶體會頻繁申請釋放;bgfx使用了記憶體池進行管理,避免記憶體碎片化,提升效能(關於這塊,bgfx使用了記憶體換時間的一種思路,感興趣的可以自行查閱)

  • 如果真的返回給主執行緒一個圖形API的整型值,很有可能使用時會傳錯,如果是結構體型別的形參,那編譯期也是過不了的,執行期的時候也比較好查問題。畢竟傳一個數字,屬於魔法系列了,關於這塊EffectiveC++系列有介紹。而且主執行緒或者是編碼執行緒並不太關心真的圖形API的返回值是什麼,只要是可以通過一個物件或者是指標找到對應的圖形API的返回值,並進行使用就可以了。

  • 另外就是安全性問題了,這個XXHandle什麼時機建立,什麼時機釋放。而bgfx採用的是誰建立,誰釋放;主執行緒或者編碼執行緒建立XXHandle,引用計數加一,銷燬XXHandle時,其引用計數減一,等到為0時,編碼DestroyXX命令銷燬真正的圖形API資源,在主執行緒下一幀編碼結束後,申請交換佇列時,在釋放XXHandle,避免渲染執行緒使用了主執行緒建立的已經在當前幀刪除掉的XXHandle物件(延遲一幀刪XXHandle)。

圖七

如上圖七所示,主執行緒傳送建立渲染命令的訊號,CommandBuffer對其進行編碼,然後返回XXHandle物件,主執行緒拿到XXHandle物件在通過Encoder設定給Drawcall的渲染資源中。當銷燬渲染資源時,CommandBuffer也對其進行編碼,然後在渲染執行緒真正的銷燬圖形API的資源。主執行緒在下一幀交換佇列時,刪除上一幀在記憶體池的XXHandle物件的資料,其記憶體給新申請的XXHandle使用。

關於返回Handle的方式,UE4中是採用了執行緒安全的智慧指標進行管理返回一個XXRef,這樣統一了驅動層與編碼層資源的邏輯關係,這樣更容易管理一些。但如果說效能問題,這塊筆者沒有親自測試過這兩種方式的效能。UE4對於執行緒安全的引用計數處理(自旋鎖),而bgfx中編碼渲染命令也是拿一把鎖後,在進行XXHandle的相關處理,其實也算是不相上下。因此筆者更傾向於UE4的設計方式,UE4的設計方式統一了驅動層編碼層的渲染資源的封裝。

3. Vulkan編碼器設計

作為Khronos組織的新的一代跨平臺圖形API,與其兄弟OpenGL或者是GLES是完全不同的,並不向GL相容。完全摒棄了GL的一些缺點,更加面向多核程式設計開發。其核心概念對於多執行緒渲染開發更加友好。

新一代Vulkan是一個完全脫離了OpenGL限制的圖形API,不在有渲染執行緒的概念,也不在有渲染上下文的概念。可以將一幀內所有的渲染的建立與渲染狀態的設定設定到一個CommandBuffer(也可以多個),一幀編碼結束後,進行一次submit提交到VKQueue中,如下圖八所示

圖八

那如果是多個執行緒,每個編碼執行緒可以分配一個CommandBufferPool(bgfx中只有一個EncoderPool),然後拿到CommandBuffer,進行編碼。在這裡提示一下,不同執行緒的CommandBuffer不能來自於同一個CommandBuffer中,如果是來自同一個,那麼就需要外部自己同步,這樣是很不划算的,圖形API層級的同步肯定是比外部同步要快的多的。並且考慮到了,如果一個執行緒想擁有多個CommandBuffer進行編碼的話,那麼該執行緒自己獨自擁有一個CommandBufferPool就好。Vulkan中編碼執行緒也是需要與主執行緒進行同步,然後將渲染命令佇列提交到GPU進行執行,這與bgfx是非常類似的,不同的是bgfx是主執行緒將渲染命令提交到渲染執行緒。如下圖九、圖十

圖九
圖十

既然有多個執行緒進行編碼,那就必然要有相關同步的處理在裡面,vulkan也對此提供了全面的支援。semaphore(訊號)用於同步Queue(VKQueue是可以有多個的);Fence(柵欄)用於同步GPU和CPU;Event(事件)和Barrier(屏障)用於同步Command Buffer。如下圖十一

​圖十一

關於Vulkan的介紹,還是比較淺顯,因此對於其多執行緒渲染的介紹可能一些細節並未介紹清楚(其實筆者瞭解的也算比較淺顯,還未掌握精髓),如果精通Vulkan的大佬,還望不吝賜教,筆者在此感激不盡。

附加:如下圖,使用Vulkan繪製一個三角形所需要的流程,感興趣的可自行查閱Vulkan的文件。

4. 結束語

當代多核心平行計算架構已經是現代硬體的標配,無論是商業級遊戲引擎UE4以及U3D都已經對多核平行計算的硬體架構做了多執行緒渲染架構的調整,還是開源的bgfx渲染引擎都對多執行緒渲染做了支援。因此未來的時代,隨著技術與圖形API的進步,多執行緒渲染會做的越來越好,多執行緒渲染將會變成基礎的技術學科。

本來是想多寫一些東西的,但是寫著寫著發現,章節字數變得異常多,因此刪掉了一些章節及細節,導致某些部分只能介紹個大體的框架出來,有非常多的細節都沒介紹,希望未來有時間補充出來。

Vulkan中的介紹,部分圖片來源於網路,如有侵權,請聯絡,會刪除。

在此感謝參考文獻中的作者。

最後希望這篇文章能給想學習bgfx的讀者帶來些許幫助,如果有錯誤,請留言,感謝關注和收藏。

參考文獻

  1. https://www.bookstack.cn/read/Cpp_Concurrency_In_Action/content-chapter1-1.2-chinese.md

  2. https://www.runoob.com/cplusplus/cpp-multithreading.html

  3. https://hpc-tutorials.llnl.gov/posix/

  4. https://hpc.llnl.gov/documentation/tutorials/introduction-parallel-computing-tutorial

  5. https://bkaradzic.github.io/bgfx/overview.html

  6. https://developer.nvidia.com/sites/default/files/akamai/gameworks/blog/munich/mschott_vulkan_multi_threading.pdf

  7. https://github.com/bkaradzic/bgfx

  8. https://www.cnblogs.com/timlly/p/14327537.html#2533-rhi%E7%BA%BF%E7%A8%8B%E7%9A%84%E5%AE%9E%E7%8E%B0

  9. https://zhuanlan.zhihu.com/p/44116722

  10. https://dev.gameres.com/program/visual/3d/renderstate.htm

相關文章