轉自:https://mp.weixin.qq.com/s/bybEHM9tF-jBPxxqXfrPOQ##
Unreal Open Day 2017 活動上 Epic Games 開發者支持工程師郭春飈先生為到場的開發者介紹了在 Unreal Engine 4 中 UI 的優化技巧,以下是演講實錄。
1. UI的基本概念
1.1 名詞解釋

User Widget:對應一個用戶界面。
Widget Tree:每一個 User Widget 都是存儲成樹狀結構。
Panel Widget:不會渲染出來,用於對 Child Widget 進行布局,如 Canva Panel, Grid Panel, Horizontal Box 等。
Common Widget:用於渲染,會生成到最后的 Draw Elements 中,如 Button, Image, Text 等。
1.2 渲染流程
基本渲染流程示意圖:

在游戲線程 (Game Thread),Slate Tick 每一幀會遍歷兩次 Widget Tree。
Prepass:從下到上遍歷樹,計算每一個Widget的理想尺寸 (Desired Size)。
OnPaint:從上到下遍歷樹,計算渲染所需的 Draw Elements 。這個過程中,會根據 Common Widget 的類型和參數生成相應的 Vertex Buffer,將 Common Widget 的 Render Transform 計算到 Vertex Buffer 中,並根據 Layer ID 和 Material 等信息進行批次合並。最后一個 User Widget 會生成1個或多個 Draw Element,並將 Draw Elements 傳遞給渲染線程進行渲染,其中每個 Draw Element 對應一個 Draw Call。
在渲染線程 (Render Thread),Slate 渲染分為兩步:
Widget Render:執行 UI 的 RTT,如果使用了 Retainer Box,這里會將 Draw Elements 渲染到 Retainer Box 的 Render Target。
Slate Render:將 Draw Elements 渲染到 Back Buffer,如果使用了 Retainer Box,會將 Retainer Box 對應的 Texture Resource 渲染到 Back Buffer。
1.3 性能指標
Stat.Slate命令列舉了一些主要的Slate性能參數:

Num Painted Widgets:在游戲線程執行 OnPaint 的 Widget 數量。
Num Batches:Draw Element(也即 Draw Call)數量。
Stat.Slate 會創建一個未優化的 UI,並且統計線程會將這個 UI 的性能數據算入 Slate 開銷,因此表格中的時間數據和真實數據相差很大。建議通過如下命令查看統計線程變量的時間開銷:
stat dumpave –num=120 –ms=0.5

三個關鍵指標的統計數據分別是:
Slate Tick:統計線程變量 STAT_SlateTickTime。
Slate Render:統計線程變量 STAT_SlateRenderingRTTime。
Widget Render:統計線程變量 FWidgetRenderer_DrawWindow。
如果希望在項目中實時調試性能,可以從統計線程直接獲取數據,並做一個簡單的調試面板進行查看。
游戲線程代碼:

統計線程代碼:

調試面板效果:

2 優化方案
2.1 游戲線程優化
2.1.1 Invalidation Box
使用 Invalidation Box 封裝 User Widget,從而緩存 Slate Tick 數據,不需要每幀都進行計算。操作方式如下所示:

在 Invalidation Box 下的所有 Prepass 和 OnPaint 計算結果都會被緩存下來。如果某個 Child Widget 的渲染信息發生變化,就會通知 Invalidation Box 重新計算一次 Prepass 和 OnPaint 更新緩存信息。
下圖演示了一種特殊情況,英雄圖標是一個重復使用的 User Widget,每個都被封裝進了 Invalidation Box。整個英雄列表是一個 Scroll Box,當 Scroll Box 上下滑動時,英雄圖標對應 User Widget 的 Transform 信息也會發生變化。

此時可以勾選 Invalidation Box 對應的 Cache Relative Transforms,如下所示:

那么當 User Widget 的位置變化時,引擎不會去更新所有的 Draw Element(即 Vertex Buffer ),而會通過修改 Shader 參數(View * Projection Matrix)來反應位置變化。這種方式僅適用於位置變化,如果縮放發生變化,仍然需要重新計算 Draw Element。Cache Relative Transforms 會在 Game Thread 增加少量額外的計算,確保需要使用時才勾選。
當某個 Widget 的渲染信息變化時,會通知所在的 Invalidation Box 重新緩存 Vertex Buffer。在一個復雜的 User Widget 中,Invalidation Box 頻繁緩存整個 Widget Tree 會帶來很高的性能開銷,有兩種方式可以解決這個問題。
第一種方式是拆分 Invalidation Box,根據 Widget 變化是否頻繁將它們拆分到不同的 Invalidation Box 中。
有時由於布局的原因,不是很方便的划分不同的 Invalidation Box,那么可以使用第二種方式,將 Widget 設定成 Is Volatile,這樣上層的 Invalidation Box 在緩存時就會排除這個 Widget,該 Widget 每幀都會 Tick 並計算 Prepass 和 OnPaint,但整體 Widget Tree 的緩存不會受到影響。

上圖中的 LevelUpIcon,平時處於隱藏狀態,當角色升級時會顯示出來, LevelUpAnim 通過改變 Widget 的位置實現動畫效果。當渲染這個 Image 時,由於位置一直在變化,會導致 Invalidation Box 每幀都在重新計算整個 Widget Tree 的 Cache,性能比較低。此時可以將這個 Widget 設定成 Is Volatile,從而提高性能。
編輯器中 Is Volatile 選項可以用於顯式地設置 Volatile,用於提高 Invalidation Box 的性能。有時 Widget Binding 會隱式地將 Widget 標記成 Volatile,導致這個 Widget 每幀都會 Tick,從而降低性能。
每個 Widget 在 ComputeVolatility 函數中詳細列舉了哪些屬性會導致影響 Draw Element(Vertex Buffer)。
文本 Widget 影響 Draw Element 的屬性:

進度條 Widget 影響 Draw Element 的屬性

如果在影響 Draw Element 的屬性上使用了 Widget Binding,會導致引擎每幀都要 Tick 查詢是否屬性發生變化,從而判斷是否需要更新 Draw Element,因此應該避免使用 Widget Binding。
可以通過 Slate.InvalidationDebugging 查看是否正確地設置了 Invalidation Box 和 Volatile。

綠線框:使用 Invalidation Box 緩存的 Widget。
藍線框:Invalidation Box 勾選了 Cache Relative Transforms。
虛線框: 標記為 Volatile 的 Widget。
紅線框:沒有使用 Invalidation Box 的 Widget。
Slate.AlwaysInvalidate 命令可以強制 Invalidation Box 每幀更新緩存,可以用於測試是否會造成突然的卡頓。如果一個 User Widget 過於復雜,可以拆分成多個 Invalidation Box,將 Widget 按照更新頻率的高低放入不同的 Invalidtion Box。
2.1.2 可見性(Widget Visibility)
Widget 可見性有 5 種:
Visible: 可見、可點擊
HitTestInvisible: 可見、當前 Widget 不可點擊、所有 Child Widget 不可點擊
SelfHitTestInvisible: 可見、當前 Widget 不可點擊、不影響 Child Widget
Hidden: 不可見、占用布局空間
Collapsed: 不可見、不占用布局空間
很多 Widget 默認屬性是 Visible,需要手動設置成 HitTestInvisible 和 SelfHitTestInvisible。如果大量 Widget 設置成 Visible,那么引擎在點擊響應時的效率就會大大下降,這也會增加游戲線程的開銷。
Collapsed 不占用布局空間(Layout Space),因此在隱藏后不會進行 Prepass 的計算,性能優於 Hidden。
可以使用 Widget Reflector 幫助檢查是否有錯誤設置的 Visibility 屬性。

2.1.3 Widget Binding
在分析 Volatile 時提到過 Widget Binding 會導致 Volatile 從而降低 UI 性能。另外 Widget Binding 是每幀 Tick 執行,性能比較低。不建議在項目中使用這個功能,建議通過 C++(或藍圖)調用函數的方式傳值。
RemoveFromViewport/AddToViewport 會銷毀以及重新構建 User Widget,使用 Collapsed/SelfHitTestInvisible 可以得到更好的性能。
另外,在移動平台上建議將藍圖 Tick 中復雜的運算邏輯移動到 C++ 中。
2.2 渲染線程優化
2.2.1 合並批次
隨着 GPU 的發展,Draw Call 的數量對於性能的影響也越來越小,很多情況下減少 Draw Call 並不能帶來 FPS 的提升。但減少 Draw Call 可以減少對 GPU 的 API 調用,在移動端有助於控制手機發熱。
A. Panel Widget
在 4.15 之前的引擎版本,Canvas Panel 不支持批次合並,建議不要使用 Canvas Panel,盡量使用 Grid Panel、Vertical Box、Horizontal Box 等支持合並批次的容器。
4.15 增加了對 Canvas Panel 合並批次的支持,開啟方式位於 Project Settings 中:"Engine->Slate Settings->Constraint Canvas->Explicit Canvas Child ZOrder"。接着可以通過設定 Canvas Panel 的 Child Widget 的 ZOrder 屬性,ZOrder 相同(渲染參數也相同)的會合並批次,比起 Grid Panel 和 Horizontal Box,Canvas Panel 沒有額外的布局計算,OnPaint 效率會稍微高一些(游戲線程)。
B. 合並貼圖
在 UE4 中的 Sprite 很方便地支持合並貼圖的編輯和使用。

如果需要在邏輯代碼中切換獨立貼圖和合並貼圖,在 Manager Class 中,初始化獨立貼圖 (UTexture2D) 和合並貼圖資源 (UPaperSprite),並創建 FSlateBrush,通過 SetResourceObject 將資源設置給 FSlateBrush。接着就可以通過開關變量控制傳入 UImage::SetBrush 的參數。
在項目后期,如果需要將 User Widget 中的貼圖全部替換成合並貼圖,是一項很繁瑣的工作。Epic Games 的 Dmitriy Dyomin 提供了一個思路方便快速地進行替換。
首先實現一個 Commandlet:


可以使用如下命令運行這個 Commandlet:

Commandlet 的具體功能:遍歷所有的 Widget Blueprint Asset,使用 AssetRegistry 加載 Asset,並檢查其中 UImage 和 UBorder 使用的 Texture,根據命名規則判斷是否有對應的 Sprite Asset 存在。使用 AssetRegistry 將 Texture 替換成 Sprite,最后保存 Widget Blueprint Asset。
2.2.2 Retainer Box
通過合並批次和合並貼圖的方式,UI 的 Draw Call 數量可能減少到比較低,但仍然會有很高的像素填充率。
在很多情況下,UI 不需要每幀都渲染,因此可以通過 Retainer Box 緩存渲染結果,每隔幾幀更新一次。Retainer Box 的原理就是將 UI 渲染緩存在 Render Target上,再將 Render Target 渲染到屏幕。
下圖中,我們將主界面的 UI 划分到 4 個 Retainer Box 中,通過間隔3幀更新一次的方式來渲染。


Retainer Box 區域應該盡量小,有助於提高渲染效率、降低顯存使用。通常 Retainer Box 都應該包含 User Widget 的背景圖,因為背景圖有很大的像素填充率。
Retainer Box 會為每個 User Widget 實例創建一個 Render Target, 因此在不改動代碼的情況下,重復使用的 User Widget 不要使用 Retainer Box。例如下圖中,我們應該為 Scroll Box 所在的 User Widget 創建 Retainer Box,而不應該為 Scroll Box Item 所在的 User Widget 創建 Retainer Box。

下圖演示了另外一種情況,B_HeroIcon 這個 User Widget 被重復用到了 HEROS 和 SOCIAL 等多個主界面中。Battle Breakers 是一個重 UI 的手機游戲,因此很難為所有的主界面分配 Retainer Box,這會占用大量的顯存,當然我們也不希望為每個 B_HeroIcon 創建一個 Retainer Box。

此時可以通過擴展代碼的方式實現更好的 Retainer Box 效果,假設我們知道該 B_HeroIcon 在畫面中同時出現的上限是 20,那么可以創建一個包含 20 個 Render Target 的 Render Target Pool,使得不同的 Retainer Box 可以共享同一個 Render Target。
Retainer Box 會占用額外的顯存,因此要控制使用量,將它優先分配給性能提升最大的 User Widget。一種情況是主界面的 User Widget,另一個種情況是使用共享 Render Target 后的大量頻繁使用的 User Widget。
使用 Retainer Box 不但能提高渲染線程的效率,游戲線程的 Tick 也會相應的隔幾幀執行一次。如果 Retainer Box 內部包含了可以點擊的 Widget,那么需要將 Retainer Box 設置成 Visible,這樣引擎會將點擊測試區域映射到 Retainer Box 上。
持續表示的效果(如3D 角色、材質特效)可以從 Retainer Box 中分離出來,但需要注意像素填充率,也可以從特效設計的方面解決。
Invalidation Box 放置在 Retainer Box 上方沒有意義,通常做法是在 Retainer Box 下層放一個 Invalidation Box。
在設定 Retainer Box 的 Phase Count 時需要全局考慮。例如下圖表示每隔3幀更新一次 Retainer Box,並在第 0 幀更新:

下圖表示每隔 5 幀更新一次,並在第 2 幀更新:

那么每隔15幀這兩個 Retainer Box 就會在一幀內同時更新,導致幀數下降。
2.2.3 事件驅動的 Retainer Box
目前 Retainer Box 需要指定每隔幾幀強制更新一次,但某些情況下 User Widget 不需要按照固定頻率更新,只會在用戶操作(且操作不頻繁)時才更新。這種情況下就可以通過擴展 Retainer Box 來支持事件驅動的方式。
實現思路是繼承 URetainerBox 和 SRetainerWidget,並在 PaintRetainedContent(在 4.16 之前的版本函數名是 OnTickRetainers)中判斷是否有事件觸發更新,如果需要更新則調用父類的 PaintRetainedContent,否則 return。
2.2.4 切換材質
UE4 提供了豐富的材質效果,在低端機上可以考慮關閉這些效果、或切換到低配材質以提升性能。
可以使用引擎提供的 DYNAMIC_MULTICAST 框架,將所有受影響的 Widget 綁定到一個開關變量上,實現整體切換。
2.3 其它優化
2.3.1 C++ 開發
除了 UI 動畫這塊存儲結構設計的原因不能使用 C++ 實現,其它 UI 功能都可以用 C++ 實現。
第一步,實現一個 C++ 類 UWExpHeroIcon 繼承自 UUserWidget

第二步,使用 Reparent Blueprint 修改父類為 UWExpHeroIcon

第三步,在編輯器中找到需要暴露的變量以及類型

第四步,在 C++ 中聲明 BindWidget 變量,引擎會自動關聯數據

2.3.2 Manager Class
建議在項目中創建一個 Manager Class,統一管理所有的 User Widget,並且統一管理所有的 UI 資源,比如 Brush、Font 等。Manager Class 可以是 C++ 或藍圖的形式。
2.3.3 釋放貼圖內存
釋放貼圖內存的一個前提是不要在編輯中設置貼圖(下圖中的 Image 項),而是通過程序進行手動的貼圖加載、貼圖設置、以及貼圖銷毀。不在編輯器中設置貼圖,可以避免在 CDO(Class Default Object)中引用這個貼圖對象。CDO 的引用會使得 SharedPtr 的引用計數至少為1,並且退出應用前不會銷毀。

如果在 Editor 中設置了 Image 屬性,同時又希望銷毀這個貼圖,Epic Games 的王彌提供了一個思路,可以在 Cook 階段解除 UImage 和 UTexture 的引用關系,從而這個 User Widget 的 CDO 不會引用到 UTexture。
解除 Cook 階段引用關系的代碼如下所示:

加載貼圖的代碼如下所示:

釋放貼圖的代碼如下所示:

2.3.4 3D RTT 優化
默認 SceneCaptureComponent2D 是每幀 Tick 的,通常情況下可以取消每幀更新圖像:

動畫的 Update 頻率在手機上每秒 30 次就夠了,因此可以通過藍圖設置 SceneCaptureComponent2D 的 Tick 間隔設置:

接着在藍圖里手動調用 Capture 即可:

另外 SceneCaptureComponent2D 的 Render Target 的尺寸不要太大,有助於提高性能。
2.3.5 新功能
我們在 Battle Breakers 中新增了兩個調試命令,可能會在 4.17 版本合並到主干上。游戲界面:

使用 Slate.ShowOverdraw 查看 Pixel Overdraw:

使用 Slate.ShowBatching 查看批次:

3 效果測試
我們做了一個測試工程用於測試優化效果,下圖中的 UI 有 800 多個 Widget:

測試機器是千元機,機器參數如下:

開啟 Invalidation Box 后,Slate Tick 時間大幅降低,由於應用程序開啟了 Mobile HDR,瓶頸在 GPU 上,因此 FPS 提升不大,如下所示:

下圖可以方便對比 Invalidation Box, Retainer Box, 事件驅動的 Retainer Box 開啟后性能參數的變化(可以看到渲染線程的提升對於 FPS 提升很大):



