Canvas 在 Unity UI 中,修改單一元素可能會觸發整個 Canvas 的重新整理。這種重新評估與網格生成的過程會對效能造成嚴重影響,特別是在複雜的 UI 設計中。主要原因如下:
網格生成成本高:Unity 的 UI 系統會將元素分組為繪製批次(Draw Calls),但每次小變動都需要重新生成批次,導致高資源消耗。
過度使用單一 Canvas:將大量 UI 元素集中於一個 Canvas 內,當有微小更新時,會引發顯著的效能尖峰。
解決方式:將 UI 分割到多個 Canvas 是減少效能問題的有效方法。
將單一個 Canvas 分割為 巢狀 Canvas(Nested Canvases),這麼做的好處有
子 Canvas 與父 Canvas 和兄弟 Canvas 互相隔離,具有獨立的幾何圖形和批次處理功能。
有助於組織層次結構化的 UI。
單一 Canvas 的變動不會影響其他 Canvas,縮小了網格重新生成的範圍。
分割 Canvas 的最佳實踐
依刷新頻率分組:
靜態元素:將不常變動的 UI 元素(如背景圖片、標題)放在單獨的 Canvas 上。
動態元素:將頻繁更新的 UI 元素(如血量條、分數、計時器)分配到不同的 Canvas。
保持一致性:
確保 Canvas 內的元素共享相同的 Z 值、材質和貼圖,以提高批次處理的效率。
避免過度巢狀化:
雖然巢狀 Canvas 功能強大,但過度巢狀化會增加維護難度。應針對邏輯分組策略性使用。
範例結構:
主 Canvas
:作為 UI 的總容器。
HUD Canvas
:顯示血量、分數和小地圖(頻繁更新)。
暫停菜單 Canvas
:包含按鈕和靜態菜單元素(很少更新)。
背包 Canvas
:用於展示背包內容(僅在打開時更新)。
這樣的結構下,當玩家血量在遊戲中變動時,僅 HUD Canvas
會更新,暫停菜單 Canvas
和背包 Canvas
不受影響。這樣的分割方式有效避免了不必要的效能開銷。
Graphic Raycasters Graphic Raycaster
是 Unity UI 系統的一部分,負責將玩家的觸控或點擊行為轉換為遊戲可理解的事件。它的主要作用是:
事件檢測:確認玩家觸碰的屏幕區域是否對應到某個 UI 元素。
信息傳遞:將觸控位置與設定為可互動的 UI 元素匹配,並將事件傳遞給正確的遊戲部分。
運作方式:Graphic Raycaster 僅關注 UI 圖形元素(例如按鈕、圖片)。檢查所有設置為可響應觸控的 UI 部件,並確認玩家的觸碰是否在其範圍內。
Graphic Raycaster
產生的問題:
效能消耗高:
每次觸控時,Graphic Raycaster 都需要遍歷屏幕上的所有可交互 UI 元素。
過多的 Graphic Raycaster 或不必要的檢測可能導致資源浪費,尤其是在大型 UI 設計中。
非交互元素:
並非所有 UI 元素都需要響應觸控事件,例如裝飾性的圖片或靜態文本。對這些元素進行射線檢測會降低效能。
解決方式:
移除不必要的 Graphic Raycasters
確保僅在需要檢測觸控事件的 UI Canvas 或元素上添加 Graphic Raycaster。
非交互式 Canvas(例如背景圖像)則移除 Graphic Raycaster,避免不必要的檢測。
關閉非交互元素的 Raycast Target
對於不需要觸控的 UI 元素(如純裝飾性圖片),關閉其 Raycast Target 設定。
在 Image 組件中,取消勾選 Raycast Target。
這樣可以防止該元素被包含在射線檢測的範圍內,減少計算負擔。
使用阻擋遮罩(Blocking Mask)
當 Canvas 的 Render Mode 設置為 Worldspace Camera 或 Screen Space Camera 時,可以使用 Blocking Mask:
該遮罩決定 Raycaster 是否使用 2D 或 3D 物理來檢測阻擋物。
如果物理檢測對 UI 無直接影響,避免啟用該功能以節省資源。
分割 Canvas
將靜態和動態 UI 元素分離至不同的 Canvas。
靜態 Canvas 不需要頻繁更新,也不需要 Graphic Raycaster。
例子: 假設遊戲的 UI 包含以下部分
主菜單:大部分按鈕需要觸控響應,因此保留 Graphic Raycaster,並確保按鈕的 Raycast Target 為啟用狀態。
背景圖片:純裝飾性元素,關閉 Raycast Target 並移除其 Canvas 的 Graphic Raycaster。
玩家 HUD(血量條、得分顯示):分配到單獨的 Canvas,僅需要檢測少數互動(例如按鈕點擊)。
管理 UI 對象池 問題:在常見的 UI 對象池使用方式中,開發者通常先更改對象的父節點(parent),再禁用對象(disable)。然而,這樣的操作會導致:
多次改變層級結構:反復更新對象的層級關係(hierarchy),使得整體性能受到影響。
不必要的開銷:每次改變父節點或激活/禁用對象時,Unity 會標記整個層級結構為“髒”,從而增加開銷。
解決方案:優化對象激活與重設順序
為提高效率,建議按照以下順序管理 UI 對象池:
禁用對象再更改父節點:
禁用對象(SetActive(false))後再將其重新分配到對象池的父節點。
這樣可以確保原始層級結構僅被標記一次為“髒”,避免重複更新。
從對象池提取時的順序:
先更改父節點:將對象移動到新父節點中。
更新對象數據:重設對象的數據(如 UI 文本或圖片)。
最後激活對象:使用 SetActive(true) 啟用對象。
這樣的流程可將每個對象的層級結構變化降至最少,從而減少不必要的性能開銷。
隱藏 Canvas 的方法 在開發過程中,可能需要暫時隱藏某些 UI 元素或整個 Canvas。常見的方式有
使用 SetActive(false) 禁用整個 GameObject
可能會導致 Canvas 層級結構中的回調函數執行(如 OnDisable 和 OnEnable),增加不必要的性能開銷。
移動 Canvas 的位置,讓使用者看不見
改變透明度
,
解決方案:停用 Canvas 組件,透過停用 Canvas 可以
停止繪製操作,當停用 Canvas 組件會立即停止向 GPU 發送繪製請求,使畫布變得不可見。
並保留頂點緩衝區,Canvas 的頂點資料(meshes 和 vertices)會被保留,因此在重新啟用時無需重建(rebulid),僅需恢復繪製操作。
避免不必要的回調,停用 Canvas 組件不會觸發整個層級結構的 OnDisable/OnEnable 回調,從而減少性能損耗。
UI 元素動畫 當在 UI 元素上應用 Animator 時,即使動畫的值保持不變,也會在每一幀對 UI 元素產生影響。這會導致
不必要的性能消耗,特別是在多個靜態 UI 元素上。
解決方案:針對靜態或偶爾改變的 UI 動畫需求,避免使用 Animator。可以透過以下方式實現高效的 UI 動畫。使用程式碼或 Tweening 系統進行動畫處理
手寫動畫程式碼:針對簡單的動畫需求,直接使用 C# 程式碼逐步更改 UI 屬性(如位置、透明度等)。
Tweening 系統:使用輕量級的 Tweening 庫來簡化動畫實現。
Tweening 系統通過插值逐步更改屬性,對於臨時或事件驅動的動畫需求非常高效。
使用專業的 Tweening 資產(如 DoTween)可以快速實現高效動畫。
開發 UI 建議
為最常用的 UI 元素建立 Prefabs
對於經常使用的 UI 元素(如標題文字),可以將其創建為 Prefab,並將所需的組件附加到這些 Prefab 上。這樣一來,當你需要修改某些元素時,所有使用該 Prefab 的地方都會自動更新,讓你更輕鬆地管理與修改 UI 元素。
例子:假設你有多個地方需要顯示標題文字,將標題文字做成 Prefab,並在遊戲中重複使用。未來如果需要調整字體或大小,只需更改 Prefab,就能同步更新所有相關元素。
使用 Sprite Atlas
在 Unity 中使用 Sprite Atlas 來將多個 Sprite 紋理打包到一個單一的紋理資源中。這對於優化遊戲效能非常有幫助,因為它減少了渲染過程中的 draw calls,從而提升遊戲性能,特別是在移動設備上。
Sprite Atlas 的優點:
Draw call 優化:使用 Sprite Atlas,可以減少 draw calls,讓 Unity 在渲染多個 Sprite 時只用一個 draw call,大大提高性能。
紋理分組:Sprite Atlas 讓你能夠將多個 Sprite 或紋理打包在一起,便於管理和組織資源。
自動打包:Unity 會自動進行紋理打包,將個別的 Sprite 儘可能地安排在 Atlas 中,減少空白區域的浪費,並優化紋理使用。
Mipmapping 支援:Sprite Atlas 支援 Mipmapping,這能夠提高在遠距離查看紋理時的渲染質量。
平台適配:可以為不同的裝置平台或螢幕解析度創建不同的 Sprite Atlas 變體,保證在各種設備上的最佳效能。
Unity 編輯器整合:你可以直接在 Unity 編輯器中創建與管理 Sprite Atlas,方便遊戲開發者進行資源的視覺化與調整。
使用透明圖片疊加進行設計對齊
將設計圖層疊加一個稍微透明的圖片,這樣可以幫助你更準確地對齊並組織 UI 視圖,使其符合最終設計。
MVVM 在 Unity 中實作 MVVM
1 2 3 4 5 6 public class GameData { public int level; public int starts; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class GameViewModel : MonoBehaviour { private GameData gameData; public int level => gameData.level; public int starts => gameData.starts; private void Start () { gameData = new GameData(); } public void UpdateGameData (int level, int score ) { gameData.level = level; gameData.starts = score; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class GameView : MonoBehaviour { [SerializeField ] private GameViewModel gameViewModel; private void Start () { gameViewModel.UpdateGameData(1 , 100 ); } private void Update () { Debug.Log("Level: " + gameViewModel.level); Debug.Log("Starts: " + gameViewModel.starts); } }