【轉載】
地址:https://www.gameres.com/877050.html
視頻:https://www.bilibili.com/video/BV1ca4y1W7wN
從Unity2018開始,Unity就引入了不少的新技術,不過一直以來相關技術分享仍以教程介紹為主,所以今天我們來看一下DOTS和URP這兩項技術的實操,以及我們的一些心得和體驗。
首先簡單介紹一下《黑暗之潮》,這是一款頂視角的次世代手游,目前處於內測階段,雖然它鎖定了視角,但實際對畫質和戰斗細節的要求很高。大家可以從截圖上看出,游戲采用了PBR的渲染,場景當中有不少的動態光影效果,場景的細節也相當豐富。

今天主要分享幾塊內容:首先是制作《黑暗之潮》這款項目的挑戰,其次是我們在渲染管線的選擇和定制方面的經驗,包括當中可能遇到的問題,以及應用的地方;接下來我會就DOTS技術棧在我們項目當中的運用,聊一聊有關DOTS的常見誤解;最后,我會分享一些項目工作流的簡化和改善經驗。

正題開始之前,先給ILRuntime打個小廣告,ILRuntime是我制作的C#熱更解決方案,目前已經在大量的商業項目當中得到了驗證,比如掌趣旗下四款上線很長時間的游戲,都是采用ILRuntime進行的熱更,大家如果對C#語言的熱更方案感興趣,可以在Github上進一步了解。

現在開始正題,先看當時《黑暗之潮》中遇到的挑戰。
首先顯而易見的,我們的游戲采用了PBR的次世代渲染技術,在畫面表現上面有不少挑戰。第二,我們希望適配盡可能廣的機型,接觸更多的玩家。第三,如下圖,這款游戲的戰斗強度非常高,會有大量的怪以及技能特效。
除此之外,這款游戲的戰斗機制也有非常多特殊的定制,單一個職業來說,可能有上百個技能供玩家選擇和搭配,在這些技能的實現過程中,對性能的要求會非常高。最后,由於采用了PBR的模型制作流程,在工作流方面也有繁多、復雜、出錯的環節,需要針對這些環節進行簡化。

下面第一個主題,是關於渲染管線的選擇和定制。
《黑暗之潮》選用了URP技術,它是一個比較適合移動平台開發的PBR渲染管線,雖然說它是PBR渲染管線,但實際上非PBR的東西也可以用它來渲染。
我們當初非常看重的一點是,URP擁有非侵入式修改的能力,我們在不修改URP源碼的情況下,可以對它進行比較多的定制。此外,URP有全部的C#源碼,整個渲染過程基本全部能掌控在我們自己手里,當出現問題或者遇到bug的時候,比較容易定位。源碼的結構清晰,組織也非常合理,所以我們擴展和自定義起來也會相對方便。
還有最關鍵的是,URP的性能比Builtin內置管線更好。可能有人會問,為什么我們要對渲染管線進行自定義,是不是因為URP有坑,或者不能實現什么效果,所以必須去自定義?實際上不是的,因為每個項目都有各自獨特的需求,在更好地滿足這些需求的情況下,就需要對渲染管線去進行定制。

舉一個例子,這個角色釋放了一個火焰效果的技能,但是這個火焰效果的特效被渲染在了地面的裂紋之上,這個其實是一個錯誤的表現。正確的表現是火焰的特效能夠蓋在這些地表裂痕的上方。

為了解決這個問題,以前我們在Builtin管線當中只能通過修改不穩定的Renderqueue,或者通過代碼去修改這些物體的Renderqueue來規避問題。這么做有一個比較大的弊端,這時可能需要對這些物體新建一個Shader,或者要寫比較復雜的邏輯來規避。
一旦引入了新的Shader,就有可能要重新去做一遍剛才做的這些效果,非常麻煩,而且容易反復出問題。在Builtin當中,一些效果其實只能通過Shader Pass去實現。比如要給這個物體增加一個描邊,就需要在角色的Shader里額外增加一個Pass去實現。弊端是,在渲染過程中,勢必會被多Pass給打斷合批。

大家可以看到下面這幅圖,我們如果在渲染object1的時候,如果它的Shader有多個Pass,我們需要首先渲染Pass1,然后通過一個Set Pass call渲染Pass2,然后再Set Pass call渲染Pass3,這個時候渲染完object1再渲染第二個物體時,又會把剛才的操作重新重復一遍,Pass1、Pass2、Pass3……渲染這兩個物體的時候,就會有非常多的DrawCall,而且每次DrawCall切換開銷都比較大。

實際上有更好的方式,我們可以用這種流水線一樣的方式渲染這兩個Pass。我們在渲染Pass1的時候,我們會以一口氣把所有的object1、2、3,一次性全都渲染了,渲染完畢之后通過一次Set Pass Call去渲染這個Pass2,實際上我們這三個物體總共需要兩個Pass就可以渲染完畢,自然而然它的效率會高不少。

還有一個問題是,Unity是一個通用引擎,會考慮各種項目的情況,為了兼容性,它可能會在渲染的過程當中,在一些情況下加入Blit操作,相當於是把全屏的結果進行一次復制。

這個復制開銷對於移動平台來說非常大,因為移動平台的帶寬很有限。實際上,我們在自己的項目當中,因為對整個渲染流程比較清楚,知道哪些情況下可以使用Blit,或者不需要Blit,就可以看情況把它去掉。如果它能夠去掉,對整個游戲性能會有比較大的改進,也能降低很多帶寬開銷。

另外,每個項目都會有一些特有的效果,比如下面截圖里,對於URP本身,在默認情況下,像扭曲,空氣擾動效果只對不透明物體生效,火焰效果在這里就會顯得比較突兀,因為它不受擾動效果的影響。對美術而言,這樣的效果就不是特別理想,所以我們可以對它進行定制,最后實現空氣擾動同樣能對火焰產生影響的效果。

接下來介紹一下URP在默認情況下的渲染管線的流程。
在開啟了動態光影的情況下,URP它會首先去渲染主光源的Shadowmap,然后再去渲染附加光源的為Shadowmap,主光源在URP里面其實主要是指的充當太陽光的那么一棧方向光,附加光源除了那棧方向光以外的,比如點光源,射燈之類的動態光源,在渲染完這兩張shadowmap之后,URP會進行一個叫做Depth Prepass的操作。
稍微岔開一些話題,Depth Prepass這個名字可能會有一些誤導。通常來說,Depth Prepass的最主要的作用就是預先把整個場景所有物體的深度渲染一次,后面再進行不透明物體渲染的時候直接使用深度的結果進行深度測試,從而盡可能去利用Early Z把一些不必要的片源去掉。在AlphaTest的時候,像素的深度實際上要在比較后期才能夠決定的,如果沒有Depth Prepass的話,有可能Early Z會在這些地方失效。
但在URP當中,Depth Prepass並沒有上述這個作用,實際上只是把場景里面所有的物體深度渲染到一張單獨的RT當中,給后面的效果進行使用。
回到管線流程,做完Depth Prepass之后,會進行所有不透明物體的繪制,繪制完畢之后會進行天空盒的繪制,繪制完天空盒之后,會進行Copy Color的操作,如果用戶在渲染管線的設置當中,開啟了Color Pictures這個功能,它就會進行這個操作,把當前的渲染結果復制到一張獨立的RT上面,供后期的效果使用。
接下來會進行所有透明物體的繪制,繪制完透明物體,會對全屏進行后效處理。如果大家還有UI,會在這個時候去繪制,繪制完UI之后,會把當前所有的渲染結果進行最后的一次Blit操作,把它給復制到屏幕緩沖區當中。

那么我們怎么去對URP內置進行定制?
第一,URP默認的情況下提供了一個比較簡單的東西,叫做RenderObject,它是URP已經實現好的RenderFeature和RenderPass,關於RenderFeature我后面詳細再說。通過它,我們可以在不寫一行代碼的情況下,對渲染管線進行定制。我們可以明確設定一個layer,以及這個layer需要在哪一個具體的時間點進行渲染。此外,我們還可以在選擇透明物體渲染之前,去做RenderFeature,並且做一些額外的設置。比如繪制塗層的時候,選擇需要使用哪個彩色球,也可以選擇不進行重載等。
我們還可以對一些渲染狀態進行重載 ,對深度進行重載,來決定這個東西是否寫深度或者做深度測試,以及對模板緩存的方式進行具體設置。對於攝像機的參數,我們可以去設置,包括單獨對某一層的物體使用不一樣的FOV,這在FPS類游戲應用比較。甚至我們可以對camera變換矩陣進行重載,拍一個跟主相機完全不一樣的區域,這也是可以實現的。

在《黑暗之潮》中,我們利用RenderObject主要是進行了這些的操作。
第一,解決我們最開始提到的例子,地面的裂紋這些透明物體的渲染,解決它的渲染不確定性。我們單獨使用了一個RenderObject,選中了剛才地表的那一層layer,讓它在透明物體之前去渲染這一整層,就能保證會在所有技能特效之前去渲染地面的裂紋,這樣就不會出現剛才例子里面提到的錯誤情況。
第二,RenderObject也可以輔助其他的自定義RenderPass,我們在后面講RenderFeature和RenderPass的時候會具體說這樣的用法。
第三,剛才提到我們想要對透明物體也能夠實現扭曲的效果,需要把復制那張ColorTexture的時機往后挪,挪到透明物體之后,用單獨的Pass額外的去渲染這些需要扭曲的效果的特效,才能完成正確的渲染。這就是通過RenderObject去實現的。

接下來介紹一下RenderFeature和RenderPass的自定義。
這是URP提供的比RenderObject更高一層級的自定義,通過RenderFeature基本上可以做到在任意一個時間點插入自己想要的渲染操作,我們就會擁有更強的控制能力。因為在RenderFeature里面可以通過手動調用CommandBuffer底層渲染接口,這能實現非常多的效果。

此外,在使用RenderPass的時候,可以在切換RT的時候,通過RenderBuffer的LoadStore操作來進行性能優化。我需要提一下,在切換RT時的RenderBuffer的LoadStore操作具體是什么含義?
現在移動GPU基本上都采用了tile base的架構,渲染的時候GPU會有一個叫片上內存的東西,它所有的渲染結果實際上是直接對片上內存進行操作,而不是直接對顯存進行操作,就能夠減少頻繁讀顯存所帶來的帶寬開銷。
我們在渲染的時候需要提前告訴GPU,現在切換了一個RT,告訴GPU我們是否需要把RT本來保存的顏色系統首先加載到片上內存,然后再進行接下來的渲染操作。
實際上在很多時候,我們能知道這個操作是不必要的,每次渲染新的一幀時,肯定要對屏幕上所有的像素進行重繪,或者類似做后效的時候,肯定需要對所有的像素進行重新繪制,RT之前本來保存什么樣的信息,完全就沒有任何意義。
這時候我們可以告訴GPU,你不需要幫我們把RT上面的內存加載到片上內存,自然而然這個加載就不需要有任何的帶寬開銷,我們就可以省掉一大部分的帶寬開銷。
同樣,對於寫操作也是一樣,如果說一個深度圖,這個深度只是拿來做深度測試,深度的結果不需要寫回RT里面,那我們就可以在切換RT的時候告訴GPU,渲染結果不需要寫回RT內容。

接下來介紹一下我們《黑暗之潮》項目當中利用RenderFeature做了什么效果?
第一,平面陰影,這是一種作假的陰影渲染方式,它只適用於游戲大部分都是平地的情況,正好《黑暗之潮》就是這樣一款游戲。
平面陰影有一個優點,大家可以看到下面的截圖,陰影是非常銳利、非常清晰的,它的整個的渲染質量很高,不會出現任何的鋸齒。還有一個比較大的好處是,因為它不需要去額外渲染shadowmap,在渲染地表的時候也不會需要對shadowmap進行采樣,這樣的話,這個渲染的整體開銷要比使用shadowmap省非常非常多的。
這個效果用RenderFeature就可以非常容易的實現,我們直接添加一個Shadow RenderFeature,把需要有陰影的角色用一個特殊的shadow繪制一遍就可以了。

第二,我們用RenderFeature實現了沙盤地圖地塊描邊的效果,大家可以看到這個截圖,描邊需要嚴絲合縫地對應這個區塊范圍。同時,區塊下半部分,牆、山體不能有描邊。所以我們在做這個效果的時候,沒有辦法運用到傳統的描邊方式,即利用法線往外擴的方式去渲染這個描邊。

我們采用的流程是這樣的。首先我們用一個純色去渲染這個地塊,渲染出來了之后我們對這個渲染結果進行降采樣,縮分辨率,在比較低分辨率的情況下,再利用BoxFilter進行模糊操作,這樣做的好處是,可以利用盡可能小的帶寬開銷來對這個結果進行模糊操作。
然后再將模糊完畢的結果進行升采樣提高分辨率,最后再用透明的顏色繪制一次地塊,就把中間這個區域扣除了,只剩下外面的描邊,這樣就可以實現剛才描邊的效果,並且還能實現從描邊從靠近物體的部分往外慢慢漸變漸影的柔和的過渡效果。

接下來還有更深一層次的自定義,有一些效果或者需求我們必須需要更深層次的自定義才能夠實現的。在URP當中,提供了一個叫做Renderre的機制,它是一個抽象層,URP里面內置了兩個渲染器,一個是Forward,也就是我們常說前向渲染器,另外一個就是2DRenderer,主要是用來渲染2D物體的,一些2D游戲可能會選擇這個渲染器。
在最新版的URP當中,還會集成了一個叫做defer Renderer延遲渲染器,在《黑暗之潮》當中我們可以對Renderer進行寄存,通過它去實現一些通過RenderFeature做不到的事情。
URP有一個好處,雖然說我們想要自定義Renderer,但並不意味着我們所有東西必須要從頭開始做,因為URP里面已經實現了各種各樣的Pass,我們是可以直接使用的,所以我們只需要對這些Pass進行重新編排就能完成我們對這個Renderer的自定義。

《黑暗之潮》當中對ForwardRenderer基礎上面進行了一些修改做到了自定義。能做到,比如之前提到的全屏Blit操作,是否把它給避免掉。
我們觀察到,比如做后效時,這個后效不可避免對全屏所有的像素進行操作,正常情況下,如果說我們后面還需要渲染UI,會在這個后效計算完畢之后渲染UI,最后通過Frame Blit去復制到FramBuffer里面。
所以我們就在想,這兩個過程能否合並,答案肯定是可以的,在做后效的時候,在計算完畢后直接將結果寫入FrameBuffer里,實際上我們就能夠省掉Final Blit。最后在渲染UI的時候,我們就把這個UI直接在FrameBuffer上面去進行繪制。這個樣子就可以省掉最后這個Blit的操作。
這么做還有一個好處,我們可以把3D場景的渲染分辨率和UI的渲染分辨率分開。以前如果我們因為受制高低配 ,對整個渲染結果的分辨率進行降分辨率操作,那么UI也會跟着一起被降分辨率,但是UI對分辨率很敏感,只要一降分辨率就能夠肉眼可見,而且對整個游戲的品質影響很大。
所以如果我們能夠把3D場景的渲染分辨率和UI分辨率分開,就能在降低渲染開銷的情況下又不對整個游戲的品質產生比較大的影響。經過剛才的介紹方式,就能夠實現這兩個分辨率的分開,因為我們3D場景在RT上面渲染,渲染完畢之后,通過后效復制到FrameBuffer上面,UI是直接在FrameBuffer上面繪制的,所以說UI的分辨率是不受降分辨率的影響的。

總結一下,《黑暗之潮》最終渲染管線由流程圖展示如下,前半部分跟默認的URP沒有太大的區別,主要是在渲染不透明物體之后,我們加入ECS模型渲染。我們這個地方還有一個Copy Depth ,把不透明物體的深度給復制到一張單獨的RT上面。
這個Pass不是每次渲染都會有,而是只有開啟沙盤地圖的時候才會用,因為沙盤地圖在渲染水體的時候會需要那張深度圖。接下來我們就會去渲染地表的這些不透明物體,渲染所有的平面陰影以及ECS物體的平面陰影,繪制沙盤地圖的描邊。
最后再去渲染我們的透明物體,也就是特效這些東西,渲染完特效我們會在進行這個copy color,把整個渲染結果復制到一張單獨的RT上面,而且這個RT是進行了降分辨率操作,實際上抓取的並不是全屏,大概只有1/4屏幕的分辨率的顏色信息。
這個顏色信息給類似於扭曲這些效果去使用,因為這些效果對分辨率的要求並不是特別高,因為本身已經扭曲了,之前采用1/4分辨率的貼圖是沒有任何問題的。渲染完扭曲之后,我們會對整個屏幕進行后效處理,后效處理完畢之后,結果可以直接寫在FrameBuffer屏幕緩沖區里面,最后再去對UI直接進行繪制,完成了整個渲染流程。

說完URP的功能,下面說一下關於URP性能方面的優勢。
首先第一點,URP的特點它是一個單Pass的前向渲染管線,單Pass也就是說所有的動態光照是在一個Pass里面完成計算的。單Pass最好的好處是,我們在添加動態光源的時候,不需要把場景里面所有的物體再去渲染一遍。以前在內置管線的時候,這個問題會比較嚴重的,如果我們添加一盞動態的點光,場景的DrawCall直接翻倍了,這個渲染開銷根本沒有辦法忍受,所以之前在移動游戲基本上不會使用動態的點光源。
大家可以看到下面的截圖,我們這個場景實際上已經有好幾盞動態光源了,通過單Pass的方式渲染的話,只要我們同場景里面同時能看見的這些光源的數量能夠有一個比較好的控制,實際上是能夠實現很好的一個渲染效果的,而且這個渲染開銷相對來說也比之前多Pass光照渲染就會是有非常大的優勢的。經過我們測試,在目前主流的終端機以上,中高端機都是沒有任何問題的。
第二點,URP它采用了單Pass的Color Texture去替代GrabPass,之前我們在Builtin管線里面做類似於空氣擾動之類的效果,必須要使用GrabPass無這個功能的。這個功能雖然非常方便也比較簡單,但是它有一個非常嚴重的問題,我們使用GrabPass之后,我們完全沒有辦法預知當前渲染屏幕會被全屏抓屏幾次,而且這個抓取是不會降分辨率的,真的就是全屏抓取,全屏抓取操作是非常非常廢資源的,尤其是在移動平台上面,基本不大能忍受。
通過這個單Pass的ColorTexture就可以通過一次抓取來完成所有需要扭曲操作的渲染,這個無疑性能就會高的非常多。也就是剛才介紹到的通過RT可以去自定義LoadStore這些操作,也能進一步減少帶寬。剛才也說了,我們可以根據實際情況去掉一些不必要的Blit操作。
最后還有一點非常重要,SRP Batcher,單這一條就已經不能拒絕URP的使用。

我們看一下SRP Batcher對項目有什么具體的影響?
首先在內置管線當中有三種方式去進行合批,第一是Dynamic Batching,它實際上對合批有比較嚴格的要求,對於三角面數要求比較高的。它還有一個問題,它是通過CPU降低DrawCall我們降低DrawCall的目的也是為了降低CPU開銷,相互意義已消,只有在一些特定的情況下,Dynamic Batching才能夠有性能提升,絕大多數情況下是沒有太大作用的。
第二,靜態合批Static Batching,這個東西確實是非常有效,對降低DrawCall和提升性能都很有效,但是它最大的問題是它只對靜態物體生效。對於動態物體完全沒有任何效果的,而且進行靜態合批之后,整個場景的內存占用會提高非常多。還有一點,隨着現在場景復雜度的提升,現在次世代的游戲都已經場景都已經非常復雜了,LOD就是一個非常不可或缺的功能了。Static Batching對LOD是非常不友好的。
最后還有一種方式就是GPU Instancing,這種方式只對網格Mesh以及Materia均一致的情況下才能生效,這個應用的范圍比較窄了,一大片的草,一片大的石頭,對於普通的物件,比如房子,場景物件,沒有辦法對它進行合批。
綜上所述,上面三種合批方式如果用於次世代游戲是有些捉襟見肘,很多都沒有辦法合批,就會造成對於做這樣的游戲,性能方面的優化就會非常的困難。
而SRP Batcher就能很好的解決這個問題,因為我們觀察之后能得出一個結論,實際上DrawCall里面,開銷最大的就是SetPassCall,SRP Batcher它的原理就是通過降低SetPassCall的數量來去打造性能提升,它降低的並不是DrawCall的數量。
通過把所有的渲染當中所需要用到的參數變量拆分成幾個若干個Constant為Buffer分別保存,比如保存的是全局的靜態參數,有一些可能保存的是當前這一幀數據,剩下的一個Buffer保存的是當前這個材質球特有的參數,這樣做好處比較明顯了。
如果說我們同一個Shader物體,它實際上變化的就只有它的模型以及材質球上的參數。至於像它的Shader的program,以及它的渲染狀態,這些都是不需要改變的。所以說我們一次DrawCall基本只需要傳一些參數,ConstantBuffer的內容,再去綁定一個Mesh的指針就可以完成了,這樣整個DrawCall的開銷就會非常低。

大家可以看這兩張圖的對比,左邊是開起來SRP Batcher,右邊沒有開啟,這個圖是通過RenderDoc抓取的一次DrawCall的渲染流程,左邊大家可以看到綁定了一個貼圖,傳了一些頂點的指針,最后通過一個BannerBuffer把ConstantBuffer數據更新一下,最后可以直接去繪制了。
但是在不開啟SRP Batcher的情況下,大家可以看到整個渲染流程非常的長,會進行非常多的設置,還會去更改Shader program,還要更改非常多的渲染狀態,這個截圖還不全,這個列表下面還有很長一段,大家通過對比直接列表的長度就能說明這兩個DrawCall之間的性能開銷差別有多大。

對此我們也進行了一個測試,我們拿了一個測試場景,這個場景有三棧動態光源,這個場景在頂配的情況下,大概有40W三角面,以及500dc;中配進行簡化過有32W三角面和400dc,低配是25W三角面和280dc。三檔機型上面實際測試都有比較大幅度的提升,這個地方我想拿低端機舉個例子,低配大家可以看到25W三角面和280DrawCall,實際上之前在Builtin的項目里面已經是一個高配才能比較流暢運行的標准了。

大家可以看一下這個Profiler的結果,我們是在一個驍龍450SoC上進行的測試,這是一個非常低端的處理器。大家可以看到我們的主線程Render Camera是4.3毫秒,在下面渲染線程Camera的開銷是14毫秒。

我們再把SRP Batcher關了之后再看一下,相同的場景一模一樣的東西同樣的視角,主線程的Render Camera的開銷已經直接漲到7.8毫秒,渲染線程實際提交的過程中我們整個渲染開銷就已經達到了22毫秒。22毫秒已經相當於說,我們場景里面,只有場景,沒有任何的技能特效,沒有其他的角色,也沒有任何的業務邏輯,就已經不大能跑30幀了,這個渲染就不大能夠接受了。

講完了剛才關於URP的東西,功能性以及性能上面的優勢,接下來我想跟大家分享一些關於DOTS技術棧在商業上的運用。
我跟其他開發者進行交流的時候,會發現有一個問題,大多數的開發者都會有一些常見的誤解,對於DOTS技術棧,第一個非常典型的,會聽到非常多的人說,我們在項目里面沒有用到多線程,所以也不需要用DOTS。
還有一個,可能大家會覺得用DOTS必須要用於大規模的集群模擬才能帶動比較大的提升,因為之前大多數的分享,都是去演示的大規模集群模擬的性能的提升,可能會給大家帶來這樣的一個錯覺。
大家會覺得使用ECS的代價會非常高,因為首先ECS是一個全新的東西需要重新學習,把現在的項目轉換成ECS,代價也非常的高。可能也就用不上DOTS,這三個都是多多少少是一些誤解,我在后面會給大家介紹,DOTS應該怎么樣使用。
首先我們需要了解一下DOTS具體是什么?它實際上叫Data-Oriented Tech Stack,它的意思實際上就是面向數據的開發棧。它主要是由三個組建組成的,ECS、JobSystem、Burst。這三個組建是可以相互獨立使用,並不是說使用一個這三個必須同時用,你可以任意選擇其中一個來進行使用,用於不同的應用場景。
如果說我們需要使用JobSystem,其實它跟ECS沒有太大的關系,你可以在ECS里面用,也可以不在ECS里面用,只要是需要並行計算的地方都可以使用。
Burst也一樣,它也不需要配合ECS使用,不需要跟並行計算捆綁使用,它的作用僅僅是對於一些復雜的計算密集的東西去進行編譯器優化,來達到性能提升。
只要是計算密集型的東西,都可以使用Burst,同步方法也是可以的。
最后關於ECS,一個比較大的誤解,可能大家會覺得用ECS之后,所有東西都可以用ECS來寫,就會想UI的業務邏輯怎么用ECS實現。大可不必,並不是說用ECS,所有東西全部都要用ECS來做,而是大家可以根據項目需求選擇其中適合那部分來用ECS去寫,剩下的部分還是使用傳統的面向對象的方式去寫,沒有任何問題,只要用代碼稍微結合一下就可以了。

第一個我想給大家看一下,我們在《黑暗之潮》當中利用ECS的例子,我們通過ECS渲染了大量的怪物。我們游戲里面怪物通常有一個特點,一組怪由幾名精英配合一兩種大量的存在的爪牙組成的,大家可以看到右面的圖只有三種怪,如果說用默認的SkinMeshRenderer的話,就有一個非常嚴重的問題,沒有辦法合批了,畫面上面有多少個怪,有多少個DrawCall而且Animator開銷也不小,還有一個問題,GameObject為.Instantiate開銷也是比較大的,如果說我要同時刷出來三四十只怪的話,肯定會卡頓,用ECS就能比較好的解決這三個問題。

使用ECS先把整個動畫信息去烘焙到這么一張動畫貼圖上面,在GPU當中進行蒙皮操作,我們再通過JobSystem和Burst實現視錐剔除和動畫系統的更新,最后我們再在面向對象那塊業務邏輯那塊控制ECS Enity就可以了。也就是說ECS的部分,我們只是提供渲染的和動作的結構,其他部分業務邏輯還是完全用面向對象去實現的,相當於各取所長。

用ECS最大的好處就是性能。
首先第一個,因為我們采用了GPU蒙皮,整個DrawCall的數量下降到有幾種怪就是幾個DrawCall,這個就非常好了。實例化也是非常快,ECS基本上就是無感的,在極端機上消耗,即便同時刷一千只怪也不足1毫秒,借助Burst力量類似於視錐剔除這些計算量比較大的操作,在低端機上也是可以忽略不計的。
大家可以看到下面的截圖,演示我們整個動畫更新階段,也是同樣在驍龍450 SoC上測的,100只怪左右的情況,動畫整個更新過程只用了0.008毫秒,這就是忽略不計,根本不需要考慮的一個量級。通過ECS,我們畫面上怪物的渲染完全取決於GPU本身的渲染性能,CPU的開銷完全不需要去考慮了,所以也不會出現卡頓。

第二個,我們通過Jobsystem去實現了怪物擊飛的效果,大家可以看到這個怪物被打下懸崖,它如果說碰到牆壁必須要被牆壁擋下來,需要進行一些物理運算,如果直接使用Unity的Ragdoll也就是布娃娃系統,它的物理計算非常復雜,對於低端機會造成比較大的性能負擔。我們把這個過程稍微簡化了一下,所有的這些怪物在被擊飛的時候,使用的是預先制作好的動畫,我們只需要計算它的運行軌跡就行了。
我們首先用Job去並行計算這些怪物的分析軌跡,再通過Unity提供的多線程Raycast方法去進行射線檢測來判斷它是否撞到牆或者碰到地面了。最后如果說我們還有一些非ECS了對象,我們可以在計算完畢之后再通過一個單獨的Job把這個所有GameObject的位置給同步一下就可以了。

第三個,我們通過Burst實現的就是射線技能,這個東西看上去很簡單,實際上需要對整個場景以及所有的怪物和其他對象產生交互。射線打到牆上能夠實時產生反映,我們這個東西需要每幀對整個場景進行射線檢測,整個計算過程實際上是開銷比較大的。通過Burst我們相當於把這個東西做成了一個Job,通過Job.Run的方法去直接進行調用,就是在當前這個線程進行的操作。
使用起來跟一個靜態方法沒有太大的差別,還有像大家看到的這個技能,會有大量的子彈,對這些子彈我們同樣需要進行運行軌跡的計算。通過Burst非常有效的把這兩個計算開銷降的非常低,Burst開啟之后,它的性能提升基本能上百倍,通過剛才也提到Job.Run的方式實現同步調用,我們在整個計算流程當中不需要開額外的線程,直接在當前線程,單個靜態方法直接調用就可以了,也是非常方便的。

大家可以看一下開啟和不開啟Burst效果的差別,左邊是開啟,右邊是不開啟,我們在一個計算體系化模型工具中測試,左邊只用241毫秒,右邊用了20毫,真是一百倍的差別。而不是說它用了多線程所以更快,大家可以看到每個線程都快了100倍,如果算總耗時,這邊用了143秒,這邊只用了1秒鍾,如果把所有線程的時間加起來,就是100倍的差別,效果非常明顯。

最后跟大家介紹一下我們在工作流方面的簡化和改善。
這部分由於時間的關系可能就只能講的比較粗略了,大家稍微理解一下。首先隨着我們采用PBR流程,Prefab的制作就會比較麻煩,而且以往這個Prefab的制作都是交給美術同學,美術需要把模型導入Unity,再規范創建材質和Prefab。

在采用PBR流程之后,這個創建過程就會麻煩了非常多,首先貼圖多了很多張,跟各式各樣的PBR的設置,是非常繁雜的。尤其是ECS的單位,我們還需要對這個動畫進行烘焙。這是一個非常復雜而且操作量非常大的操作,非常的耗時,而且容易出錯。
為了解決這個問題,我們引入了AssetGraph這個工具,這個工具是Unity開發的一個節點式的自動化資源導入流程的工具,非常好用。通過自定義節點,我們可以完全根據項目的需求對資源的導入進行自定義,通過這個工具,這個節點自定義完成之后,我們就可以實現一鍵就能夠創建所有的角色的Prefab,所以說美術也就能從工作當中解放出來了,美術也只需要做完了之后把FBX和貼圖文件按照我們定好的規定就放到指定的目錄下就可以,它連Unity我不需要開,美術非常喜歡這個功能。

我們通過這個工具對這些模型進行批量的一次性Prefab生成,能夠直接穩定的生成一個符合規范的Prefab文件了。這個過程當中大家肯定也知道我們如果用Animator都需要建立動作狀態機這個東西,這個東西手動建立非常麻煩,所以我們也可以用剛才那個工具就能夠實現在美術把這些動作文件、模型文件上傳之后可以一鍵把整套東西自動生成了。

我們在導出場景的時候有些時候需要對渲染物件進行渲染設置,來達到最佳的渲染性能,具體的設置方式實際上是技術團隊根據Profiling的結果進行不斷的迭代和調整才能形成一個調整的方案。每一次調整,都需要去修改美術資源,如果說這個都需要美術去進行操作,整個工作量會非常的大。這是美術那邊沒辦法接受的,所以說我們需要把這個過程稍微自動化一下。
為了提升切換場景的加載速度,我們需要對場景進行切塊和分簇,大家可以從下面的截圖看到,這些藍綠色的這些盒子就是我們分簇切塊之后的結果,它所展示的分塊Bounding Volume。


結合剛才所講的我們整個場景的導出流程就會按照這個流程圖的方式進行一步一步做。
第一步,我們會檢查美術設置的LOD的選項是否正確,會把美術那些臨時物件給剔除,有一些碰撞Fix Mesh Collider ReadWrite這些設置是否正確,還會把LOD的點面工具的臨時腳本給刪掉,最后還會對ShadowMask去進行一些設置,因為URP里面沒有shadowMask,這是我們自己實現的,所以會需要一些額外的設置。然后會根據我們Prefab的結果去進行一些詳細的設置,比如Instancing的設置該怎么設?哪些物體適合Instancing,那些適合,我們都會去進行設置。我們會對整個場景進行分簇會看哪些物體適合進行Static Batch,Static Batch不是所有物體都會適合,我們會進行一些選擇。
剩下一些物體適合轉換成ECS hybrid方式渲染,我們會轉換成hybrid,最后我們再把每一個簇進行Bounding Volume的計算就完成整個場景流程的導出。我們在場景導出完畢之后,整個場景就是這樣一個空場景的狀態, 里面只剩下錯的節點,就會對進入這個范圍之后再進行動態的加載,這就是我們生成的每一簇的Prefab以及靜態合並的Mesh。

以上就是本次分享的全部內容,謝謝大家!