多線程渲染
來自專欄高品質遊戲開發98 人贊了文章
一、引言
性能開銷歷來都是遊戲中關注的重點,隨著遊戲開發的不斷迭代,遊戲資源的不斷累加,遊戲的整體性能開銷也會變得越來越高;評價遊戲性能的主要指標之一是幀率,而影響幀最大的因素是圖形渲染;一般來說,圖形渲染是在主線程中調用圖形API來完成,而圖形API的操作開銷往往是CPU的瓶頸。近些年來,不管是在PC平台還是移動平台,CPU的處理能力變得越來越強大,核心變得越來越多,因此可以將圖形API的調用從主線程中抽離出來,單獨放到另一個線程中執行。
本文將講述多線程渲染的動機,實現方法及遇到的問題二、多線程渲染背景介紹
(圖一)展示的是單線程渲染的流程,一般情況下,在遊戲每一幀運行過程中,主線程(CPU1)先執行Update,在這裡做大量的邏輯更新,例如AI、碰撞檢測和動畫更新等,然後執行Render,在這裡做渲染相關的指令調用。在渲染時,主線程需要調用圖形API更新渲染狀態,例如設置shader、紋理、矩陣和alpha融合等,然後再執行DrawCall,所有的這些圖形API調用都是與驅動層交互的,而驅動層維護著所有的渲染狀態,這些API的調用有可能會觸發驅動層的渲染狀態的改變,從而發生卡頓。由於驅動層的狀態對於上層調用是透明的,因此卡頓是否會發生以及卡頓發生的時間長短對於API的調用者(CPU1)來說都是未知的。而此時其它CPU有可能處於空閑的狀態,因此可以將渲染部分抽離出來,放到其它的CPU中,以減少主線程卡頓。
關於圖形API的調用卡頓情況,我們在實際項目中做過一些統計,(圖二)展示的是我們項目在實際運行兩萬次的過程中,Unity渲染指令調用耗時峰值發生次數TOP5統計詳情:
表格的第一列對應的是Unity的渲染指令,第二列是這些指令發生峰值的次數,第三列是指令峰值的平均值,第四列是對應的OpenES的API。從表中數據可以得出兩個結論:首先、部分渲染指令的耗時峰值比較高,例如Clear的調用竟然高達0.13秒,也就是說在發生該次峰值時,遊戲的幀率會發生劇烈的抖動,瞬間會下降到10幀以下;其次、發生峰值頻率最高的是提交渲染,即OpenES的eglSwapbuffers。我們對提交渲染的Top5峰值的機型做了詳細的統計,如(圖三)
從(圖三)中我們可以看到到,個別機型提交渲染時,耗時峰值竟然高達0.21秒。那麼為什麼提交渲染的耗時會如此之高呢?原來,提交渲染(eglSwapBuffers)會導致驅動層中緩存的渲染指令立即執行,此時CPU被阻塞。如果在提交渲染時驅動層緩存了大量的指令,CPU就會被阻塞很長時間。
在引言中我們提到,現在CPU的運算能力變得越來越強,核心數變得越來越多,關於移動設備的CPU核心數量,我們也做了一些統計,(圖四)展示的是某遊戲用戶手機CPU核心數在不同國家的佔比詳情:
從(圖四)可以看到,大多數用戶的手機核心都在4核及以上,所以即使在在手機上,多線程渲染也是可行的。
三、多線程渲染框架介紹
在第二節,我們介紹了為什麼需要多線程渲染以及多線程渲染的可行性,接下來我們介紹多線程渲染的框架。(這裡的多線程渲染其實是指將渲染單獨放到一個線程,理論上可以開啟不止一個線程來執行渲染指令,但是Opengl並不支持多個線程同時操作同一個Context)
多線程渲染實現方式有很多種,但是大體框架圖如(圖五)所示:
- 在主線程中調用的圖形API被封裝成命令,提交到渲染隊列,這樣就可以節省在主線程中調用圖形API的開銷,從而提高幀率
- 渲染線程從渲染隊列獲取渲染指令並執行調用圖形API與驅動層交互,這部分交互耗時從主線程轉到渲染線程
四、多線程渲染通信模型
主線程與渲染線程數據傳遞類似於傳統的生產者與消費者模型,即主線程是產生者,不斷的生成渲染指令,提交到隊列;渲染線程是消費者,不斷的從隊列獲取渲染指令並執行。這種模式最簡單的實現方式就是使用一個隊列,然後對這個隊列的入隊和出隊加鎖保證線程安全即可,但是頻繁的加鎖會導致兩個線程相互阻塞,降低運行效率,因此這種方法在這裡就不做介紹。
4.1、雙隊列+幀同步方案
接下來先介紹第一種方案,雙隊列+幀同步方案, 其框架圖如(圖六)所示:
- 每一幀開始時,主線程調用BeginRender喚醒渲染線程,讓渲染線程執行上一幀的渲染指令,此時主線程執行更新邏輯,然後提交本幀的所有渲染指令到更新隊列
- 每一幀主線程在將所有渲染指令放入更新隊列後,執行提交命令,此時主線程會同步等待渲染線程執行完渲染隊列中上一幀的所有渲染指令直到掛起,然後交換更新隊列和渲染隊列
- 每一幀渲染線程執行完渲染隊列中的指令後掛起,等待主線程交換隊列。在下一幀開始時,會被主線程喚醒執行隊列中渲染指令
- 這種雙隊列+幀同步的好處主線程與渲染線程分別操作自己的隊列,不用考慮由於兩個線程同時訪問同一隊列數據引起的資源競爭問題
- 缺點是相對於1個隊列,2個隊列會多一些額外的存儲空間,並且由於每一幀結束時才交換隊列的內容,渲染相對於邏輯會延遲一幀
- 線程之間的數據傳遞使用智能指針,即主線程和渲染線程共享一份資源,因此智能指針必須是線程安全的;另外還有一個潛在的問題——如果資源在渲染線程中被析構,並且析構函數中調用了主線程的方法(比如將某個資源從主線程的隊列中移除),可能會發生一些不確定的線程安全問題。
- 對於上述出現的線程安全問題有兩種解決方案,第一種方案是禁止在資源析構函數中調用主線程的函數。第二種方法是使用資源GC,即資源的分配和釋放完全在主線程中完成,分配資源時,將資源放到主線程的資源管理器中,引用計數為1,在每一幀結束時,將資源管理器中引用計數為1的資源刪除
4.2、管道方案
第二種方案是管道,這也是Unity的多線程渲染採用的方案,其框架圖如(圖七):
- 管道可以採用環形buffer來實現,主線程將渲染指令系列化為二進位數據,不斷的往buffer里寫入,當buffer被填滿時,主線程被阻塞;渲染線程不斷的從buffer中讀取數據,反系列化為渲染指令,當buffer中無數據時,渲染線程被阻塞.
- 採用環形buffer的優點是讀寫buffer只需要移動讀寫點的位置,在只有一個生產者和消費者前提下,環形buffer的讀寫可以做到無鎖(讀寫點用int32表示,如果32位的int讀寫是原子操作,那麼只需要採用內存屏障來保證在讀寫點變更時,相應的內存是可用的即可)
- 採用管道的優點是,不用考慮資源分配與釋放的線程安全,因為同一份資源在主線程和渲染線程分別有自己不同的實例,兩個線程分別維護自己資源的生命周期
- 採用管道的缺點是面向數據,它要求所有渲染指令必須能被系列化,因此不能採用抽象度過高的實現方式。比如一個抽象度比較高的渲染指令是傳入一個相機、場景和renderTarget,這種級別的抽象使用管道來實現就會比較麻煩。因為它要求相機、場景和renderTarget必須是可系列化的。
- 採用管道的另一個缺點是為了能讀寫大容量的資源,需要預先分配一塊比較大的內存,而且相對於直接傳遞資源的指針來說,讀取耗時會相對高一些
4.3、環形隊列方案
第三種方案是環形隊列,這種方案結合和方案一和方案二的優點,其框架圖如(圖八)所示:
- 類似於管道方案,主線程不斷的將渲染指令加入到隊列(但是不用系列化),如果隊列被填滿,主線程被阻塞;渲染線程不斷的從隊列中取出數據執行,如果隊列為空,渲染線程被阻塞
- 不用像雙隊列一樣每一幀去同步主線程與渲染線程,渲染雖然會有延遲,但是延遲時長比雙隊列要短
- 和管道類似,環形隊列的讀寫操作也可以做到無鎖(由於每次讀或寫只移動一個位置)
- 與雙隊列類似,主線程與渲染線程採用智能指針共享資源,資源的線程安全可以採用方案一相同的處理方法
4.4、三種方案的幀率對比
關於多線程通信的三種方案,我都做了實現,並寫了一個Demo來測試幀率,該Demo每一幀從主線程傳遞一個3萬多個頂點大小約為1M的VBO數據到渲染線程,代碼地址在https://github.com/feefiliang/MTRender,測試機型為Sumsung Galaxy J7,幀率數據如下(圖十):
從(圖十)中可以看到,環形隊列的幀率最高,雙隊列+幀同步的幀率其次,管道的幀率最低,但是比單線程渲染還是要高出許多。
五、主線程與渲染線程同步策略
第四節主要描述了主線程與渲染線程的通信模型,三種模型在實現的細節上略有不同,但大體的框架上是一致的,即主線程提交渲染指令到緩衝區,渲染線程從緩衝區取出這些數據處理,這是經典的生產者與消費者模式。經典的生產者消費者模式在緩衝區被填滿時生產者進入休眠,同樣,在緩衝區空時消費者進入休眠,當然多線程渲染也可以採用這種休眠和喚醒的模式,但是這裡有一個小問題,當瓶頸在消費者(渲染線程,GPU)時,緩衝區會長時間處於填滿的狀態,生產者(主線程)可能頻繁被休眠和喚醒從而影響幀率;另外在緩衝區被填滿時,渲染線程的幀可能會落後主線程多幀,導致畫面出現比較高的延遲。
為了解決上術問題,我們需要採取相應的策略來保持主線程與渲染線程同步,對於方案一(雙隊列+幀同步),在第四節已經描述了同步方案,這裡在通過圖形來進一步的描述細節,在理想的狀態下,主線程與渲染線程的協同如(圖十一):
也就是說在每一幀率主線程將渲染指令放後隊列後,上一幀的渲染已經完成進入休眠狀態,這時候主線程將更新隊列與渲染隊列交換,在下一幀開始時再喚醒渲染線程執行渲染。如果渲染線程出現瓶頸時,主線程在每一幀結束時會同步的等待渲染線程的上一幀渲染結束,協同如(圖十二):
主線程在第2幀執行結束時,渲染線程第1幀渲染還未結束,些時主線程會等待第1幀的渲染執行完畢直到休眠,才會交換更新隊列與渲染隊列,然後開始第3幀的邏輯更新,同時喚醒渲染線程,執行第二幀的渲染。
對於方案二和方案三,我們可以採取相同的同步策略,第一種同步方案是在主線程在每一幀更新與往隊列中提交渲染指令之間同步,如(圖十三):
具體同步流程如(圖十四):
- 在開始每一幀的渲染時,主線程提交BeginRendering指令,在每一幀渲染結束時,主線程提交Present指令
- 在提交BeginRendering指令時,如果渲染線程上一幀的渲染指令未結束,主線程會阻塞等待渲染線程直執行完成才會開始這一幀的渲染指令提交
這是Unity多線程渲染採用的同步策略,其實這裡還有一些優化空間,假設遊戲的瓶頸不在GPU端,但是偶爾因為圖形驅動層的狀態變更導致某一幀的渲染耗時較高,這種等待策略可能會讓主線程在這一幀等待較長的時間,造成幀率的抖動(在這一幀幀率突然下降很多)。比如我們在某次性能的Profiler中就抓到了這樣一個熱點(如圖十五):
其實這時指令隊列可能有還大部分剩餘空間,如果讓主線程將渲染指令提交到隊列繼續下一幀的更新,會讓幀率變得更平滑,因此我們可以採用延時一幀的等待策略,具體等待策略如(圖十六):
只有在主線程第3幀Render時,如果發現渲染線程第1幀的渲染指令未處理完成時才會去等待,這樣做的好處是,主線程第二幀的渲染指令可以及時提交到隊列,然後執行第三幀的Update,不會影響第二幀的幀率。在GPU無瓶頸的情況下,如果第3幀渲染耗時過長只是因為圖形驅動層的狀態變更而引起的,那麼有可能在第四幀的Update過程中,渲染線程已經將第2、3幀的渲染指令處理完成了。
六、渲染API返回值封裝
在前面我們提到,多線程渲染是主線程將圖形API的調用直接封裝成渲染指令,提到到渲染隊列,對於調用沒有返回值的API來說,這是沒問題的,但是對於那些有返回值的圖形API,在主線程將渲染指令提交到隊列時,應該返回什麼值?比如,我們在主線程中創建Sahder(如圖十七):此時主線程的CreateShader應該返回什麼值?
有兩種方案可以解決這個問題,第一種方案是阻塞方案,即在調用有返回值的渲染API時,先阻塞主線程,等待渲染線程將隊列中指令全部指令完畢返回時,再喚醒主線程,流程如(圖十八):
這種方案的優點是實現比較簡單,而且保證返回值一定可用,但是由於阻塞了主線程,幀率也會降低。不過大多數有返回值的圖形API都是與資源載入相關的,我們可以預先載入好這些資源,避免在Update中頻繁的調用。
第二種方案是封裝返回值,在主線程調用有返回值的圖形API時,在主線程先創建一個被封裝的對象直接返回,再將將這個對象作為渲染指令的參數傳遞到隊列,等到渲染線程執行到該指令後創建好正真的對象,再將真正的對象賦值給封裝的對象,流程如(圖十九):
這種方案的優點是非阻塞,對主線程的幀率影響比阻塞方案低;缺點是實現相對複雜,首先,由於對象在主線程與渲染線程之間傳遞,生命周期維護起來也比較麻煩,比如在主線程中創建的封裝對象後,剛好提交到隊列時就在主線程中刪除了該對象,那麼渲染線程指令到這條指令時,有可能會引進崩潰;對於這種情況一種簡單的解決辦法是在主線程中不直接刪除對象,而是將刪除對象調用封裝成一條指令,提交到隊列,讓渲染線程來執行刪除。其次,由於返回的封裝的對象,主線程在調用這個對象具有返回值的函數時,這些函數返回的對象還要做進一步的封裝。
關於調用有返回值的渲染指令的兩種方案,我寫一個Demo來測試,測試的手機機型為Galaxy J7,代碼地址在https://github.com/feefiliang/MTRender,主要測試代碼如下:
void DemoBase::Render(){ _device->BeginRender(); _device->Clear(); auto program = _device->CreateGPUProgram(vStr, fStr); _device->UseGPUProgram(program); auto mvpParam = program->GetParam("MVP"); _device->SetGPUProgramParamAsMat4(mvpParam, _mvp); _device->DrawVBO(_vbo); _device->DeletGPUProgram(program); _device->Present();}
為了突出對比,每一幀的渲染開始時動態載入一個shader,然後使用這個shader渲染vbo,渲染完成後刪除這個shader(在每一幀的更新函數中,主線程做5000次矩陣乘法(耗時20毫秒左右)以模擬主線程的繁忙狀態),測試結果如(圖二十)
- 採用阻塞方案時,載入Shader主線程平均被阻塞時長為1.3毫秒,平均幀率為47.7幀
- 採用封裝方案時,載入shader主線程平均被阻塞時長為5微秒,基本可以忽略不計,平均幀最高達50.9幀
- 採用單線程方案時,載入shader主線程平均被阻塞時長雖然只有0.9毫秒,但是平均幀率最低只有43幀,原因每一次調用opengl的api時,主線程都會被阻塞
- 在採用預載入後(將創建Shader的代碼放到初始化中),阻塞和封裝的平均幀率基本上相同,單線程的幀率也有所提高
七、真正的多線程渲染
前面所提到的多線程渲染方案嚴格意義上來講並不是多線程渲染,因為我們並沒有開啟多個線程來執行渲染,主要原因是目前主流的OpenGL、DirectX9,DirecX10並不支持多線程同時訪問圖形API(或者說多個線程同時訪問圖形API時有很多限制),所以只能開一個渲染線程來與渲染指令交互。目前,微軟的DirectX11已經從架構上支持了真正的多線程渲, 其多線程渲染模型如(圖二一):
DirectX11支持兩種類型的渲染——立即渲染和延遲渲染(這個和傳統意義上的延遲渲染是不同的),這兩種渲染模式是基於兩種設備的Context的,即immediate context和deferred context,立即渲染的draw call是與immediate context交互,所有的Draw Call被立即提交到圖形驅動層,而延遲渲染的draw call調用會先緩存在deferred context的Command list中,在合適的時間通過immediate context提交到圖形驅動層。DirectX11支持在多線程中使用不同的deferred context,這樣我們可以將複雜的渲染劃分成到不同的deferred context中,從而實現多線程渲染。
微軟最新的Directx12、蘋果的Metal及Khronos的Vulkan對多線程渲染已經有了很好的支持,更弱化了驅動層,它們都有一個Command List的概念,Command List可以並行執行而並不會太多的依賴圖形驅動層的優化,將不同的渲染指令提交到不同的Command List,能更充分的利用多核CPU,提高渲染效率,在後續的學習過程中,我也會做進一步的分享。
推薦閱讀: