重建 是UGUI優化的關鍵 -- Unite2017嘉賓楊懷忠分享《UGUI深度優化》
關於Unite
Unite大會是由Unity舉辦的全球開發者大會,至今已有10年的歷史。Unite現已成為遊戲行業,VR/AR行業中最具有權威性和影響力的活動。
楊懷忠:前面幾位同事有點拖堂了,所以我可能今天講的速度會快一點。我今天跟大家分享的是UI方面的經驗。我先介紹一下UI的基礎知識,會稍微提一下UI常用的幾款優化工具,這個優化工具使用起來比較簡單,我會簡單說一下。重點介紹一下UI-Canvas使用原理的問題。中間會穿插一些UI的控制元件,其實用Unity的開發者,都比較瞭解了,比較多。因為時間的關係我這邊會篩選兩個來說。最後會介紹一下其他的經驗。
UI的基礎我這裡分了這樣幾個環節,首先我先介紹這個術語,這個術語在開發功能是不太常見的。然後會說到一些渲染的細節,平時在用UI渲染的時候經常會出現的一些誤區。然後比較重要的一點概念就是重新合批。接下來在合批的過程當中,我們有一步很重要的也是對效能消耗非常大的一步就是重建。具體的內容後面會詳細介紹一下。然後就是Layout,就是重建,它對效能的消耗都是非常非常大的。這些都是我今天介紹的重點。然後術語我們做Unity UI,最多接觸的就是UI-Canvas。我們平時的工作過程當中,會接觸到很多企業,包括很大型的企業他們在做UI的時候,可能一個遊戲下來裡面只有一個canvas。我們使用一個canvas還是使用多個canvas,這個是很有講究的。然後dirty,這是什麼意思呢?什麼樣的UI我們認為是dirty,如果是一個dirty的UI,我們會做什麼呢?接下來是合批,然後Sub-canvas,如果我們在一個遊戲裡面不使用一個canvas,使用多個canvas。我們會有怎麼樣的影響。接下來的Graphic跟圖形學那個單詞長的是一樣的,但是意思會有一點區別。我這裡的區別是Unity UI的基類,通過這個基類,我們在一款遊戲當中我們希望建立自己的UI,你必須要從這個Graphic裡面繼承出來。然後我們美術在設計的時候,我們本身都會去設定它的一個佈局,但是在我們引擎這一步也有組建。這個Layout的使用以及需要注意的事項後面會有詳細的介紹。最後介紹一下什麼叫rebuild,一定要區別開來,它與合批是兩個意思。它主要是針對UI mesh重新編譯的過程,所以它跟合批是有很大的區別。
這邊看一下渲染的細節,它是很關鍵的,也是我們經常忽略的。首先在Unity UI基礎裡面,所有渲染細節都是透明佇列的。我們可能碰到很多都是不透明的場景,但透明的效能和不透明的區別非常地大。底下都是針對透明佇列做了一個解釋,比如說我們在透明佇列裡面,我們是從後往前畫的。後面還有一個混合的問題,它的效能的影響是非常大的。我們的建議是什麼樣呢?我們在做UI的時候儘量保證每一個畫素不重合,當然這是很難做到的。就是關於Overdraw的話題,我們會發現UI的問題可能會有80%以上的情況都是Overdraw太高。我們希望每個畫素只畫一次,但是因為Overdraw的存在,我們可能畫了十次甚至更多。這對它的填充率就要求很高了。
再介紹一下合批,我這裡加了一個重合批,就是對於我們canvas來說,我們每個canvas在畫之前都要進行一個合批的過程。如果我們這個canvas底下所有的UI元素每一幀都始終保持不變,我們只需要合批一次。它的意思就是說第一次合批之後把這個結果儲存起來,如果下面再畫第二幀的時候,如果沒有變化,就繼續採用第一幀的。如果發生變化了,就重用Batching。如果有任何一個UI元件發生變化了,它發生dirty之後就會觸發重新合批。重新合批的過程是非常複雜的過程,我這邊簡單地列了一下,在重新合批的過程當中會做哪些事情。首先第一步要根據深度關係進行排序,如果一個canvas底下的層級關係非常複雜,它的排序的效能消耗呈非線性的增長。另外一個就是覆蓋關係,在UI裡面,其實像我剛才提到的,我們需要儘量避免UI的重疊。舉個簡單的例子,如果我們有兩個Button,它們重疊的話,它是多少的消耗呢?這就是一個覆蓋關係。然後就是一個材質,材質在一開始接觸Unity引擎的時候都會聽說過靜態合批和動態合批。其實原理是一樣的,我們能夠合到一個批次裡面,他們的材質必須是一樣的,就是UI元件。這就是rebatching大概的過程。我們是根據節點從上往下的順序。它要有一個排序的過程,如果UI元件佈局不合理,它的這個演算法的複雜度也會成倍地增長。什麼樣的排序是合理的呢?
關於rebatching的過程當中還有一點需要注意,它是多執行緒的。我們會發現,同樣的一個遊戲在不同的手機上測試出來的效能差距會很大。這個跟手機效能,還有CPU多核有關係。如果你單核、四核跑起來的效能在遊戲裡面用到了引擎的多執行緒的處理演算法,區別會非常明顯。所以重新合批就用到了多執行緒。所以在不同核的手機說,效能的差距會比較明顯。剛才介紹了一個合批,這裡介紹一個re-build,它有一定的聯絡,但是有一定的區別。在合批這樣的管線或者流程完成的時候,在這個過程當中要完成這樣一個re-build的過程。比如說它的位置發生了變化,我們都知道要觸發同一合批。那個re-build我這邊重新分了一下,主要是佈局的重新build還有圖形學的Graphic 的rebuild。底下的函式再重新提一下,我們在進行效能優化的時候會經常看到這個函式,這個函式效能消耗一般在UI裡面比重比較大。它乾的事情就是完成這樣rebuild的過程,如果佈局是dirty,它也會觸發。什麼情況下會產生Graphic rebuilds呢?這裡有一個demo,裡面會列出幾個屬性,比如說我們常用的屬性,有位置,顏色,都會影響到Graphic的重建。這邊就是對佈局rebuild進行一個重新說明,它就是一個位置、大小發生了改變。當它如果發生變化,我們的佈局在這個底層實現的時候,相當於一個函式模組。這個模組的函式都要重新計算一遍,顯示該UI的區域。我們在計算的過程當中也是先計算根節點,按照越靠上的結點會優先結算。這樣做的過程我們是佈局,如果根接點先發生變化,也會影響指節點。在這個演算法中,在Layout的rebuild當中,你執行這樣的一個過程,效能的消耗非常大。消耗在什麼地方,就是我列出來的這個位置。當你這個列表發生變化的時候,這個列表裡面的所有元素都要重新排序和顯示新的區域。
這裡就是提到Graphic rebuild,因為在UI裡面,頂點資料就是儲存一些位置、顏色。等Graphic rebuild的時候,我們要重建mesh。如果這個頂點數很多,它的效能消耗是非常非常嚴重的。還有一個非常容易被大家忽略的,當我們材質發生變化的時候,比如說在UI裡面,它可能想改變這個UI的材質,這材質的改變也會導致canvas Render的改變。上面介紹了一些UI優化當中經常用到的一些基礎概念和常用的一些術語,對我們理解UI底層優化工具會有幫助。我這裡推薦幾款UI優化領域的工具,比如說最常用的Profiler,只要做過優化的人都對這一塊非常熟悉。還有 Frame Debugger,這兩款都是在Unity上面的。底下兩款是Xcode裡面的。相對於前面兩款工具來說,它們的級別更高一點,相當於重量級的選手。一般在專案的某一些階段的時候,還是建議大家用下面的兩個看一下它的效能。前面兩個是比較方便,是在Unity的編輯器裡面,隨時可以用這兩款工具。然後在裡面會經常出現關於效能的函式,這些函式我們可能有的時候在手冊裡面是找不到的。所以我這邊簡單地介紹一下這些函式是做什麼用的。首先第一個,它主要是計算batch的過程,在這裡都要完成。所以我們會經常發現納佔用的效能消耗的比重會非常高。底下這一塊,canvas的Render是每一幀裡面都出現。剛才提到的是C++底層出現的,雖然消耗比較大,但是計算速度是有保障的。底下這個函式稍微有一點不太有把握的是它包含了一些指令碼呼叫的指令碼。
前面提到的dirty這個關鍵詞在這裡也提到了,這兩個函式的消耗也非常高。關於Graphic的rebuild,這個是我們Unity內建的一個小工具。一個用漢語來表示,一個叫描邊,一個叫字型陰影。很多遊戲廠商都用了這兩個其中的功能,字型描邊和陰影雖然經過開發團隊進行優化過,但是效能消耗還是非常高。在我們專案當中是否能夠接受這個損耗是要考慮的。當我們看到這兩個標題的時候,我們應該知道它在做什麼事情了。如果想把它去掉,應該要知道它應該怎麼來做。rebuild的過程是去修改mesh的過程。然後關於Frame Debugger,我也簡單提一下。它有三個模式,一個是screenspace overlay一個是screen space camera,一個是world space。會發現canvas的下面的效能消耗中找不到它們的位置了。用Frame Debugger的時候,怎麼去找到canvas的消耗。
下面介紹一下今天的著重點,就是canvas。在這個UI canvas裡面,效能消耗比較大的一塊也是canvas的重建。在我前面那個同事在介紹效能優化的時候,多少提到了一些。然後在UI重建canvas的過程當中,在canvas下面肯定會包含很多UI的元件。我們怎麼去排這個次序,這一點我相信大家都應該有這個經驗。這個經驗會打斷我們已經重建的批次。比如說我們下面有三個元件,第一個和第三個它們的材質是一樣的,可能會分到同一個批裡面,如果中間有一個材質或者其他的因素影響了這兩個合批,它們三個是單獨劃出來。如果做得好的話,材質是一樣的話,可能是同一個批。不要出現打斷這個合批的過程,因為如果不打斷合批,在最理想的情況下,每次合批的過程是可以重用的。而且一旦打斷了這個合批,所有的都不能重用的。這只是我們目前表面上看到的東西。包括VBO資料,包括頻寬都會受到這一塊的影響。然後就是多級canvas,對應於我們前面提到Graphic的時候,我們到底用一個canvas還是用很多個canvas。如果我們選擇用很多個canvas,我們是建平級的canvas還是用多級的canvas。他們是有一些區別的,後面會詳細地講一下。會介紹一下我們使用canvas的時候最基本的一些準則。
canvas重建的過程其實就是生成UI元件的過程。這邊插一句,在之前很多開發者不明白我們UI canvas是幹什麼用的。我這邊解釋一下,這個canvas其實在UGUI裡面重要的作用就是生成UI元件,然後生成command命令,然後傳遞到GPU,最後由GPU把它們畫出來,完成的是這麼一個過程。在生成UI元件的過程當中,也包括了佈局,就是哪些UI顯示在哪個位置,包括它們的大小。這邊有一個字型多邊形,我這邊簡單提一下,後面會有一個字型的簡單介紹,每一個字型都是一個單獨的多邊形,這一點很重要。每一個字型都是一個單獨的多邊形。然後canvas的重建過程當中也會包含我們前面說的一個合批。底下我這邊寫了一個重建會不會成為我們遊戲的瓶頸呢?有可能會。什麼情況下會呢?如果一個canvas下面,比如說一個遊戲就一個canvas,所有的UI元件都放在這個canvas下面,你遊戲足夠大,這個canvas就會有可能成為你遊戲的瓶頸。有些情況下canvas會分成很多個,但是它們分佈非常不均勻,有的canvas下面UI,比如說我一個canvas下面有10個canvas,雖然看起來不是很多,但是我每一幀都要dirty,都要重建,這樣就會形成瓶頸。它會影響我們合批的結果,既然影響我們合批的結果,我們怎麼避免出現這種情況呢?就是避免出現中間層,中間層的意思就是說我某一個UI元件和它周圍的UI元件都不在一個批次裡,我是單獨的UI元件,這會導致我們之前做的batch會被打斷。如何避免這種情況出現呢?我們要重新排序,把這種被稱為中間層的UI元件移開,移到哪裡去呢?我們可以把它移到最下面的位置,做所有操作的時候都是從根接點往上做的,這樣對我們效能的影響會減少。
前面也提到了這一點,我們在canvas底下放UI元件的時候,我們要保證不必要重疊的UI元件千萬不能出現重疊區域。前面這句話我說了兩遍,每一個字型都是單獨的多邊形,為什麼這句話說了很多呢?因為根據我們的經驗,很多遊戲會因為字型打斷UI的合批。就像我們看一個字型,看起來它的區域很小,但是因為每個字型都是一個多邊形,這個區域我們是看不見的。如果覆蓋了其他的UI,這個UI就會被打斷,每一幀都會被重新合批。
然後提一下多級canvas,我們需要考慮一下是使用多個同級的canvas還是就是sub-canvas。這裡需要有一個小的知識點,前面提到了這麼多的合批,這麼多的重建,都是針對單獨的canvas來講的。內嵌的canvas和前面覆類的canvas是沒有關係的在合批過程當中。我們在這邊提到的是效能優化,然後效能優化我們要找到的一個平衡點就是最後這一句話,就是最少的重建消耗和最少的drawCall消耗。如果都放在同一個canvas下面,也是不現實的。
然後這邊就是我們canvas使用的一般準則,其實這是比較簡單的準則,在真正應用當中要比這個複雜很多。一般的準則是至少會存在一個canvas。我這個UI元件我只需要遊戲啟動的時候合一次批,所有的結果都會儲存,直到我退出這個APP。底下這個canvas,我們放動態的UI元件,這個動態UI元件已經提到過很多次了,每一幀都要重新合批,這個效能消耗非常大。還有一點,我們把所有的UI元件都放在同一個canvas下面是不現實的。如果都放在動態canvas下面也是不現實的。我們在動態canvas下面可以繼續劃分,至於怎麼劃分,多少個canvas,這個跟我們具體專案有關係,不太好給出一個統一的標準答案。這邊提下canvas的一個元件處理輸入,我們只需要知道它是怎麼樣的輸入處理的,比如說canvas Raycaster。我們每一個新增UI元件的時候可能都會勾選了Raycster,這樣每一幀都要去檢測所有勾選的這個UI元件。這個效能消耗是非常非常嚴重的。還要注意一下,我這邊列出了一個版本資訊,Raycaster的檢測在5.4之前都會去做檢測的,不管是手機平臺還是其他平臺,不管有沒有滑鼠都會去檢測。但是5.4之後優化了這個問題,大家有興趣的可以回去檢測一下。開發者可以定義自己的InputManger類。如何定義呢?方法也比較簡單。我們可以從網上下載下來,繼承該類,然後新增功能。
這邊簡單說一個優化,如果所有的UI元件都包含了射線優化的話,效能消耗非常高。下面就說到了UI的效能消耗為什麼會這麼高。比如說我們因為會有一些組合的UI控制元件,比如說一個button下面有很多層,如果是掛在了button下面的最後一級,一級一級往下,去檢測。對於複雜的控制元件,如果你需要對它進行檢測,儘量把這個Raycast放在根節。因為碰撞檢測這一塊效能消耗非常高,我們有一個小技巧,overridesorting屬性會打斷射線,可以降低層級遍歷的成本。下面說一下優化UI控制元件,因為UI控制元件非常多,這邊講兩個。一個是字型,還有一個就是滾動條。字型會分成這幾個模組來講,一個是字型網格重建還有動態字型,字型集,還有關於效能方面的。
一開始提到了每一個字型都是獨立的四邊形,這一點很重要。你再複雜的一個字型都是一個四邊形。我們對一個字型要預留一定的空間,避免它們之間進行覆蓋。然後還有字型UI Text重建,如果這個字型掛在其他的UI上面也會導致它的重建。底下這個就是當我們需要隱藏UI的時候,最常用的就是Disabled和re-enabled,不僅僅是針對字型也包括其他的UI元件。你看底下這邊又說了一下,會導致我們掉幀。這裡僅僅簡單的是先把一個元件Disabled掉然後又re-enabled掉。後面再詳細地解釋一下。然後是動態字型和字型集。動態字型在開發的過程中,我們使用的字型大部分都是動態字型,但現在也有靜態字型。靜態字型我們會把它提前渲染到貼圖裡面,然後直接用,這樣比較簡單,不再提了。主要講動態字型和字型集。動態字型最常用的就是遊戲裡面都有聊天環節,我們不知道使用者會輸入什麼,我們會放很多字型。我們每一個字型都會維護自己的一個字型集。字型的大小比如說我一個A字,我有一個大寫的,有一個小寫的,有一個8號字型,有一個10號字型,在字型集裡面都會保留4份A。我申請一個字型集,它的空間是有限的。它裡面存的是我當前活動的字型。如果根據這個去寫,很快就會把字型集合撐滿。後面會講,這個後果很嚴重。如果這個時候還需要新加字型,空間就不夠了。就是要重建這張貼圖,如果重建了這張貼圖,會有一個很複雜的過程。對我們效能的影響也會非常大。如果當前這個字型集太小了,比如說256×256的,我已經滿了,但是我在當前的字型裡面找不到新加的字型,也會導致字型集的重建。
這裡說一下字型集如何重建,以及對我們效能的影響。第一步我們會使用當前的一個大小。比如說我當前用的字型集就是512×512的。如果這個介面上用了100個字型,我會把這100個字型全部加入到字型集裡面,如果OK就OK,如果不OK就繼續。那麼就擴充,擴充的過程,首先是512×512會撿最小解析度的一個進行擴,如果還不夠會繼續往下擴。這個擴充的過程,你想再回到過去,已經回不去了。底下會有一個函式,這個函式的功能會申請一些字型,把我們需要的字型申請這些字型加入到當前的字型集裡面,引出底下這句話,當我們發生重建的時候,即使我們用了Font.RequestCharacter,也沒用了。
再說一下備用字型。裡面會讓大家選一些備用字型。備用字型其實很好,它會讓我們避免一些字型找不到。但是它帶來的問題是什麼呢?為了讓我們找到我們需要的字型,它帶來的問題就是記憶體會爆掉。我們給出的應對方案是對字型庫進行裁減。我們要明確哪些字是我們需要用到的,如果不用到,可以裁減掉。然後就是敢於Best Fit,它會將當前字型自適應到適合當前文字框最適合的整數字體大小。比如我設定的是14號字型,但是因為文字框比較小,會往下面調整一點。它帶來的一個缺點是什麼呢?如果一個介面裡面所有的字型都勾選了這個選項,同一個字型它的大小會發生變化,可能變成12號,可能變成13號。後續的影響是,比如說字母A在螢幕上出現了五次,但是大小是不一樣的。它在我們出現的字型集裡面,每一個字號都會出現一次。這個字型集很快就會撐爆了。
接下來介紹一下滾動製圖。它也是會出現問題的控制元件。在滾動製圖裡面經常會出現問題。第一個是把所有需要顯示的東西都顯示出來,在滾動的時候就把它例項化。在滾動的過程當中,有些東西是看得到的,有些東西是看不到的。它會導致根節下面的所有東西都進行重建。這隻適用於滾動製圖比較小的環境下,有可能我們是可以接受的,但是在我們UI元件非常高的情況下,肯定是不可以接受的。第二種方式我們要用記憶體池,快取池我們可以把UI元件裡面用到的這些UI元件先快取起來,在使用到的時候通過改變UI的transform去顯示。transform對應的是佈局,佈局改變了,它也會導致一些東西重建。而且它的重建會隨著canvas Render的數量的增加而增加。
最後介紹一下我們用到的一些小經驗。首先是Layout元件,很多遊戲為了自適應解析度,它會把Layout元件掛在上面,這是我們最不推薦的,因為效能消耗非常大。我們推薦的是在不同的時間節點可以去手動地切換,切換不要讓它自動去選擇。有一些UI元件暫時不需要,就把它隱藏掉。隱藏的方式有很多種,最好的是哪種方式呢?就是Disabling G component,不會重新組建,不會重新合批,所有的效能消耗都不會觸發。唯一做的就是資料儲存在那裡,合批的結果也會儲存在那裡,只是不發出來。如果把它移到螢幕外邊,如果把它當前UI元件Disable掉,前面說過的雷你都會踩。第三條就是大家比較忽略的,裡面有一個camera的選項,很多開發者會把這一條忽略掉。這一條空的值可能對功能沒有影響,但是對效能是有影響的。如果不指定一個camera,到底會觸發哪一個camera傳遞過來的事件呢?它會傳遞所有的事件。
然後重要的話就避免覆蓋,在UI元件沒覆蓋的情況下,我們可以推測我這個UI的。最後一條,就是Mask,這個也是大家最容易忽略的。當我們使用滾動條的時候,要用Mask。它的功能是可以保證讓當前沒有顯示出來的UI元件不把它劃出來。我今天的分享就到這裡,謝謝大家!