Unity手游開發札記——使用Fast Shadow Receiver優化渲染效率

0. 例行的啰嗦

上篇關於技術的文章已經是10月底的事情了,上個月寫了半篇關於創業的文章,寫到後來覺得無趣,就停下了——誰會願意看一個無名小卒的無聊感慨呢?又是臨近月底了,其實可寫的內容不少,只是時間不多,就選最近花了大約半個星期時間搞的這個優化來聊聊Fast Shadow Receiver這個插件的使用吧。

1. 起因

關於Unity中的動態陰影,已經有挺多帖子聊過這個話題了,比如這篇《Unity移動端動態陰影總結》,還有錢康來博客里的這篇《利用Projector實現動態陰影》和《Planar Shadow》等等。

無論是最簡單的基於Planar投影的方案還是稍微「老式」一些的Projector的方案,乃至目前比較主流的ShadowMap的方案其實都各有優劣和對應的應用場景,它們之間的原理和差異不是本文的重點,有興趣的同學也可以很容易地找到相關的論文或者博客來看。

我們項目本著不要重複造輪子的想法,一直堅持使用Unity原生的ShadowMap的方案來做動態陰影。而且UWA也做過一些動態陰影方案效率的對比,自己的輪子能做得比有源碼的官方好的並不多,更何況我們這種地表有起伏,高配需要支持多角色動態陰影的「大型」MMORPG遊戲,ShadowMap已經是最適合的方案了。

然而!人生總會有然而,否則就太平淡無味了不是?……

大約1個多月前,我發現了這個問題——《Unity中靜態合批與Shadowmap的宏設置衝突問題》,簡單來說,靜態合批首先對場景物體進行了排序,保證結果正確,但是當引入了動態陰影之後,會去修改物體接受陰影的宏(這也是一種優化,因為有採樣和陰影計算的消耗,所以關閉掉宏著色器的效率更高),導致原本排序好的物體無法正常進行合批,因為著色器的宏不一樣了,從而導致之前靜態合批之後理論上可以做到很低的Batch數值增加了很多,使得場景渲染的效率大幅下降。

這個問題在想清楚原因之後,在依然想要使用Unity的ShadowMap的前提下感覺是沒有什麼特別簡單的優化方案的,於是就暫時擱置下來,直到上周的時候對遊戲各個效果對於幀率的影響在真機上做了一個定量的測試之後,才發現問題遠比想像中的嚴重……

各個效果對於幀率影響的定量測試結果

上面的測試是在中配機型小米Max2上進行的,可以看出陰影的開關與否導致一幀的時間消耗有9.5ms左右的差異,是所有效果中影響最大的!而ShadowMap自身渲染消耗不應該有這麼大的差異才對,觀察了下Batch數量的差異,單純場景的Batch數量大約會從25增加到150左右,這有點超出我們之前制定的美術規範了。

在中配效果下,我們只有主角自己開啟了動態陰影,因此最初的一個想法就是引入另外一套陰影繪製方案,比如Dynamic Shadow Projector,來專門針對主角進行陰影的繪製。雖然我個人很不喜歡同時使用兩套技術方案,但目前看起來這似乎是在不降低效果的前提下唯一的選擇了。

2. Dynamic Shadow Projector插件

This simple Unity asset provides a few components to render a shadow onto a render texture so that the render texture can be used with Blob Shadow Projector. Blob Shadow Projector is usually used for dropping a round blurry shadow which is not suitable for a skinned mesh object. This asset enables a projector to drop a dynamic shadow which is perfect for skinned mesh objects.

Dynamic Shadow Projector插件的原理比較簡單,將角色的陰影繪製到一張rt上,然後使用Unity的Projector組件將這張rt作為繪製輸入,再繪製一遍接受陰影的物體。陰影的rt是每幀更新的,也就做到了可以讓帶有動畫的角色陰影是實時變化的。

試用了一下,還是比較簡單易上手的,幾個組件正確設置之後就可以看到效果了,由於是針對單個角色的,因此使用比較小的rt就可以做到比shadowmap更加精細的效果,但是如果想讓一個projector處理多個角色,一旦擴大projector的範圍,陰影效果質量的下降就比shadowmap的方法還要厲害。

128*128的rt只投影一個Cube的情況下rt的使用率和陰影質量

512*512的rt投影三個Cube的情況下rt的使用率和陰影質量

上面兩張圖分別給出了模擬使用一個Projector針對單個角色進行投影和多個角色進行投影的效果對比圖,在下面的那張圖中,三個Cube的距離相隔並不遠,但是即使使用了512*512的rt,明顯可以看到其陰影已經有了鋸齒感,距離更大的時候鋸齒更加嚴重。

那我為什麼糾結於一定想要使用一個Projector來進行多個角色的動態陰影繪製呢?因為對於每一個Projector來說,繪製陰影的時候都需要把接受陰影的模型完整重回一遍,從下面抓幀的截圖可以看出,三個Cube分別使用三個不同的Projector,地表平面需要繪製三遍。這其實就是Projector的方法不太適合移動設備上多個物體都需要進行動態陰影繪製的原因。

多個Projector的時候接收陰影的地表繪製抓幀截圖

我們的地表使用了Terrain製作,轉為Mesh之後的三角形數量一般在大幾千的水平,多遍繪製對於整體面數的增加還是很可觀的,雖然在我們的中配下只有主角接受動態陰影,只需要多一遍地表模型的繪製,拿一次Draw Call和幾千面的消耗換取100+次Batch的減少,理論上已經夠划算了,但是我還有些不太甘心,於是想嘗試下Dynamic Shadow Projector推薦配合「服用」的Fast Shadow Receiver插件。

3. Fast Shadow Receiver的試用

Fast Shadow Receiver插件是很久前我就關注過的一個插件,錢康來在他的博客里也有提到。我一直保持一個敬而遠之的心態,一是因為從經驗上來說ShadowMap沒有接受陰影方需要重繪的問題,只是宏的改變,效率應該挺高的(沒想到影響了Static Batching);二是對於運行時對mesh進行暴力重建一直心存懷疑,擔心其對於CPU和內存的額外壓力。

購買了插件,將其引入我自己本地的項目工程,玩了玩Demo之後,嘗試將其和Dynamic Shadow Projector結合一起使用。和AssetStore上對於這個插件的評論一樣,這個插件的文檔的確有些晦澀,大約玩了三四個小時的時間才正式在遊戲中跑通整個流程,過程不詳述了,幾個小坑記錄一下:

  1. 可能是官方被吐槽文檔太難讀,所以做了一套Wizard,一步步走教你怎麼配置,然而我按照步驟做完之後並沒有得到正確的結果,反而因為Wizard隱藏了背後的部分設置步驟導致我無法正確理解過程,從而難以排查原因。而且Wizard是針對特定的需求,未必是我自己想要的效果。最終我還是按照Demo工程里的組件逐個對照配置實現的效果。
  2. LayerMask設定需要注意,為了優化效率,Projector組件上有Igore Layers的設定,在Draw Target Object上,也有Layer Mask的設定用於標識要繪製的節點下哪些Layer會被繪製,最終的ShadowReceiver組件也會屬於某一個Layer,比如默認的Default。這幾個Layer如果設定有問題,會導致最終沒有影子被繪製出來。我因為這裡的失誤多花了1個小時的時間調試各種參數,如果你在使用中遇到了奇怪的問題,可以把自己設置的各種Layer梳理一遍,保證邏輯上的正確性。我當時的問題之一是把ShadowReceiver所在的GameObject歸入到了Default Layer,而Projector又Igore掉了Default Layer,導致結果不正確。
  3. Fast Shadow Receiver的插件製作者估計沒有經受過中國美術的洗禮,除了文檔晦澀之外,代碼中對於容錯的兼容考慮得也不周全……我們場景中有幾千個物體,在最初測試的時候沒有花心思標記所有的地表接受陰影的物體,索性將所有物體都進行標註,結果MeshTree的生成一直存在問題,查了下是因為我們場景中存在一個Mesh對象為miss狀態的GameObject導致的,做一下兼容就好了,當然根本上也要美術去修復掉mesh miss的問題。

總之,經過一系列的嘗試,最終在我們自己的工程內使用正式的美術資源跑通了整個流程,也對於Fast Shadow Receiver的原理有了更深的理解:它使用Mesh Tree這樣一個繼承自Scriptable的類在離線階段來預計算並存儲需要接受陰影的地表網格信息,並且提供BinaryMeshTree、OctMeshTree和TerrainMeshTree三種類型來應對不同的場景。運行時,它提供MeshShadowReceiver這樣的組件,根據Projector的設定實時計算出來接受陰影的地方需要覆蓋的那些面片,生成一個新的網格作為陰影接收者的網格對象進行渲染,從而做到可以將原本幾千面的模型只需要幾十個面就可以繪製出來,因為畢竟需要繪製動態陰影的只有鏡頭前的部分區域。

Fast Shadow Receiver的Demo中的示例截圖

4. 和ShadowMap的結合以及集成

在最初的設想中是針對單獨的主角使用Projector方式的動態陰影,然後用Fast Shadow Receiver進行優化,在Demo中看到Fast Shadow Receiver支持ShadowMap的方案時也沒有多想。後來在和同事討論這個問題的時候聊到Projector的動態陰影方案和ShadowMap的動態陰影方案的優劣,被問到兩種方案是不是有可能做一個結合,然後想起了在Demo中看到了使用Fast Shadow Receiver來優化ShadowMap的例子。正好也在糾結我們抽離式的戰鬥中在中等配置下的效果,如果使用Projector,需要多幾張rt的繪製是否合算,那如果可以用Fast Shadow Receiver結合之前的Shadow Map方案,對於目前結構的改動是最小的,也不必引入第二套動態陰影的產生方案,只相當於用新的插件在中配下解決場景靜態合批的問題,這似乎是非常理想的一個方案。

沿著這個思路,學習了一下Fast Shadow Receiver中關於ShadowMap的例子,看上去也非常簡單。在理解了原理的情況下,只是讓場景內的其他Render組件的Receive Shadow屬性都更改為false,然後只讓Fast Shadow Receiver生成的那樣一個面片讀取生成的ShadowMap進行陰影的繪製即可,這樣額外增加1個Draw Call和幾十個面的渲染消耗,就可以做到和之前相似的效果,中高配置的切換邏輯也更加簡潔。

我們先來看一下最後經過修改敲定下來的製作步驟,然後再聊一些其中的設計細節。

1. 統一將場景中的Mesh相關的組件放置到同一個GameObject下。這一條原本沒有一條硬性的規定,完全看場編同學自覺,其實整理之後Unity中的Hierarchy面板也會更加乾淨整潔;

場景Mesh統一放入ArtRoot根節點下

2. 標記接受陰影的物體。這一步是一個有點瑣碎的工作,需要美術標記出來哪些物體是接收陰影的,BinaryMeshTree是根據這些標記出來的物體來進行網格的預處理的。標記的物體過少會出現應當接受陰影的物體沒有陰影效果,而過多會導致BinaryMeshTree的數據內容過多,載入變慢、檢索速度降低,內存佔用也會很多。由於我們目前只在中配下使用,所以對於這部分只要求地表和表現明顯的物體加入到標記中。Fast Shadow Receiver只支持Layer和RenderType的過濾方式,在我們場景中有些物體已經被標記過了其他有邏輯意義的Layer,因此我針對這點進行了改造,增加了Tag的過濾,和Mask Layer取或的方式來進行處理,並且為美術提供了方便的快捷鍵進行快速標註。我自己測試,我們遊戲內的場景,標註加上驗證需要的耗時大約也就半個小時到2個小時不等。

提供FastReceiver的Tag進行標註

3.創建BinaryMeshTree。我們最終選擇使用BinaryMeshTree這種結構,它和OctMeshTree的區別見下圖。其實這個步驟還需要更多的測試來做對比,因為官方也明說small和large的界限具體是什麼。

兩種不同的MeshTree對比

創建BinaryMeshTree的過程也很簡單,插件提供了右鍵Create菜單的支持:

創建BinaryMeshTree

4. 生成Mesh Tree。在標註完接收陰影的物體之後,就可以選中創建好的BinaryMeshTree,填寫其Root Object為場景的根節點,設置好Layer進行build。我們建議美術檢查最後創建完畢之後給出的build信息中對於內存的佔用要小於2M,這是一個編輯幾個場景之後的經驗值而已,還需要更多驗證。

Mesh Tree生成時Layer的配置

Build之後的Mesh Tree信息統計

5. 配置Projector和Mesh Tree信息。這部分為了簡化美術的配置工作,大部分的配置邏輯都寫在了代碼中,只需要美術複製一份prefab出來,將新創建的Mesh Tree信息設置正確即可。需要注意這份prefab是不保留在場景內的,編輯完畢Apply後會從場景中刪除掉。

創建BinaryMeshTree

這裡一共只使用了兩個組件,一個是圖中LightProjector對象上的LightProjector組件,用於設置陰影使用的方向光對象以及一些Projector的參數,比如跟隨的Target對象,擴展的Bound範圍等;另外一個是MeshShadowReceiver組件,關聯Mesh Tree數據,場景渲染物體的根節點和Projecter對象,一些Fast Shadow Receiver的裁剪、更新方式等屬性也可以在這裡進行設置。

6. 在資源根節點上添加Shadow Receiver Controller組件,並進行配置。這一組件是我們自己實現的,用於控制Fast Shadow Receiver的開關,它會根據遊戲配置在場景載入、遊戲配置切換等邏輯中對Fast Shadow Receiver進行設置。並且基於這一組件實現對於Mesh Tree的懶載入功能。

Shadow Receiver Controller組件配置

7. 在遊戲運行狀態下進行測試。上述配置完畢之後,就可以在遊戲邏輯的中等配置下看到優化後的陰影效果了,可以跑跑遊戲進行測試。

大部分細節已經在上述步驟中描述了,這裡再說明以下幾個地方:

a) Projector和MeshShadowReceiver組件是不默認放在場景里的。這是由於當地表物體較多的時候,Mesh Tree的載入是有時間消耗的(遇到過一個測試例子,Mesh Tree的大小有18M左右,在PC上需要5s以上的情況,具體原因沒有細查),也會有額外的內存消耗,因此這裡一方面建議美術確保這個文件不會特別大,另一方面通過Lazy Load的方式,在需要的時候才載入,來保證在高配和低配的情況下,不需要任何額外的CPU和內存開銷。

b) 為美術提供更多便利的工具來標記信息。由於標記地表是一個相對瑣碎的工作,驗證標記是否合理也是一個件需要花費很多時間和精力的事情,除了前面提到的快捷鍵可以一鍵標註,還推薦通過Layer的顯隱功能,以及我們自己開發的Tag顯隱功能進行快速檢查和問題定位。

Unity原生的Layer過濾功能

5. 優化結果和代價

使用同樣的測試方式,對比優化前後的遊戲運行幀率和時間消耗:

優化前後的性能消耗對比

可以看到,使用Fast Shadow Receiver在小米 Max2上有大約7.2ms的性能提升,幀率從26上升到33,這其中有Batch數量降低的功勞,應該也有場景物體不需要採樣ShadowMap貼圖帶來的渲染性能提升,更加具體的數據就沒有去測試了。剩餘的1.5ms的時間消耗包括了ShadowMap的繪製以及Fast Shadow Receiver的更新消耗,這是後續的優化對象,但這次優化已經有很大的提升了,中配下整體效率提升了20%,已經是難得的「神級優化」了。當然,這建立在場景通過關閉Shadow接收的宏能夠降低較大Batch數量的前提下。

這次優化的收益是很大的,但它也不全是一種無損優化,需要付出的代價有這麼幾點:

  1. 美術工作量。需要美術同學針對場景進行地表接收陰影物體的標註,雖然提供了快捷的工具,但是依然需要花費一些時間成本。
  2. 部分物體不再會受到動態陰影的影響。在之前基於ShadowMap的方案中,幾乎所有的物體都可以標記為接收陰影,而且可以保證效果的正確性,但是目前這種方案如果要做到這點會導致Mesh Tree對於內存的佔用較多,對於外部的大世界場景也不適應,因此會有出現一些小石頭等物體不會接收角色陰影的問題,這是一些效果的降低,但目前看是可以接受的範圍內。
  3. 和靜態陰影的融合與ShadowMap的方案不同。ShadowMap的方案是在場景繪製的時候進行處理的,一次像素著色的過程中會採樣lightmap和shadowmap兩張貼圖,這就可以判斷出該像素點是否在靜態陰影之中,這樣可以做到比如在屋檐下或者樹蔭下這樣的靜態陰影中,角色的實時陰影可以和靜態陰影做一個較好的融合,如下圖所示。

基於ShadowMap的方案動態陰影和靜態陰影的融合效果

而使用Fast Shadow Receiver方案之後,就比較難做融合的效果,除非在新生成的mesh中保存之前mesh的uv2信息以及使用的lightmap貼圖信息,再做一次lightmap的採樣。但這比較麻煩,性價比也不高,於是在靜態隱形中的角色動態陰影的效果就變成了如下圖所示的樣子。

使用Fast Shadow Receiver方案的效果

除了這些之外的代價就是程序這邊花費了大約半個多星期的時間來學習和集成這套方案,但是從優化結果上看,還是收穫很大,非常值得的~

6. 一個Projector同時處理多個角色的動態陰影

由於我們是類似回合制的抽離式戰鬥方式,即玩家進入戰鬥後整場戰鬥都會發生在一小塊固定區域內,這裡其實對於ShadowMap結合Fast Shadow Receiver的方案是一個非常合適的應用場景——只需要在進入戰鬥前生成一次陰影接收的面片,整場戰鬥中都不需要對其進行修改和變動

我們將LightProjector的Target鎖定為戰鬥的中心區域點,然後通過修改Bound的方式擴大其投射範圍到整個戰場。前面已經討論過基於Projector的動態陰影方案的一個問題是當projector較大的時候rt的使用率較低,導致陰影質量驟降的問題,但因為我們使用的是ShadowMap的陰影方案,因此擴大Projector的範圍並不會影響陰影精度,也不需要處理多個Projector帶來rt數量、draw call增加等問題。

戰場中多個角色共用一個Light Projector的方案

7. 總結和展望

Fast Shadow Receiver這種通過CPU的實時計算來換取GPU的渲染性能的方案,正好解決了我們場景靜態合批被動態陰影打斷的問題,大大提升了我們遊戲在中配下的幀率,是近期所做的優化中效果最為顯著的一個了,因此也記錄一下詳細的過程在這裡分享出來。

對於這個插件的感覺,在這一周的逐漸熟悉、應用、修改的過程中,也從心存懷疑到由衷讚歎。目前針對這個插件的魔改還不多,除了前面提到的增加Tag的支持、建立Mesh Tree的時候缺少一些對於資源的錯誤兼容之外,只修改了部分Component的默認參數,更加適合我們項目的設定,讓美術和程序可以更加方便地使用。它在運行時對於內存的分配和CPU的性能消耗也讓我們滿意,因此在這裡也幫這個插件做一下廣告——別被它的文檔和使用過程嚇到,用好之後,你的遊戲效率可以獲得很大的提升~

至於未來,當中配下的效果和效率都被驗證可以接受之後,可能考慮優化一些它的效果,將它也應用到高配下,當然,對於貼花等需要處理高低不平地面效果的地方,也可以考慮使用這個插件進行效率的優化。

PS:從Fast Shadow Receiver的啟發來思考場景靜態合批被打斷的問題,其實另外一個思路是自己來做哪些物體需要被接受陰影的判斷。Unity內部肯定也是有這樣的判定邏輯來設置各個場景Render的宏,由於Shadow的距離設定較大,Unity的判定範圍也過廣,導致了雖然我們在中配下只有角色渲染陰影,但是接收陰影的物體數量過多,從而導致Batch被頻繁打斷的問題。仿照Fast Shadow Receiver,使用一個跟隨角色的投影,和場景物體相交來判斷有哪些物體需要被設置為接收陰影,由於角色腳下的物體可能只會有幾個,因此Batch的數量也只會增加幾個。目前沒有沿著這個思路來做的原因之一也是地表物體的面數實在是有點多,Fast Shadow Receiver對於面數的降低也是我們想要的優化之一。

2017年12月24日於杭州家中(聖誕快樂~ _)

推薦閱讀:

從零開始學基於ARKit的Unity3d遊戲開發系列18
Unity3D程序腳本反編譯分析與加密
Unity3D UGUI優化:製作鏡像圖片(2)
基於物理的渲染—基於Haar小波基的實時全局光照明
大萌喵的著色器特效第二發---相交高亮(掃描效果)

TAG:Unity游戏引擎 |