關於 web service, unity, blogger 等軟體工程筆記

Unity5 Graphic raycastTarget default values

Edit icon 沒有留言
Unity

What is this

從社團上看到有人在討論,Unity GUI 大量的 UI 物件會使得 Graphic Raycaster 效能低落,因為要判斷該 Canvas 上許多的 Graphic 物件有沒有被 Ray 打到。主要的解決方案是減少判斷的 Graphic 物件,設定 Blocking Mask,或是該 Canvas 僅留下所有需要判斷有沒有到 Ray 打中的 Graphics。

還有一種方式是調整 raycastTarget 參數,用來標示需要判斷有沒有被 Ray 打中的屬性,可以將此屬性設定成 false,減少一個 Raycast 判斷。看到 douduck08 所提出的方案 [1],為特定 Graphic(e.g. Button, Text 等)新增特別的菜單選項(Menu Item),建立 raycastTarget 預設值為 false 的 Graphic。

這樣有一個小問題是,會打亂原先使用者的操作習慣,所以思考另一種解決方案,有沒有辦法在 Unity 新增新的 Graphic 事件發生後,自動設定 raycastTarget 屬性呢,使得使用者不用改變原先的操作習慣。

求助 Google 大神沒有得到有用的回應,Unity Editor 本身並沒有提供這樣的事件。換個想法,有沒有可以設定 Unity Component 預設值這件事?改用別的關鍵字,unity component default value,在 Unity Answers 找到可能的做法,使用 hierarchyWindowChanged callback 來判斷是否有加入新的 Graphics。

Implementing solution

因此完成以下的編輯器程式碼,功能是,當任意 Graphics 被加入到場景中時,若該 Graphic's GameObject 沒有包含任何的 IEventSystemHandler 時,設定其 raycastTarget 為 false。例如 Image 被加入場景中時,其 raycastTarget 會設定為 false,反之像是 Button Image 就不會設定為 false。

hierarchyWindowChanged 事件,不管是載入新場景,移除 GameObjects,GameObjects Inactive 或是有新 Components 被加入時,只要場景有任何修改便會觸發該事件,因此是處理目標功能最好的進入點。

場景任何更動都會觸發 hierarchyWindowChanged,如何判斷該次場景更變中,有新增哪些 Graphics 便是主要的思考脈絡。最簡單的想法是,標示所有已存在場景中的 Graphics,當 hierarchyWindowChanged 事件發生後,抓取當下場景的 Graphics,一個一個問這個 Graphic 有沒有被標示過,沒有標示過便是新增的 Graphics 了。

以上想法衍生出兩個議題,如何得知場景有哪些 Graphics?要如何標示 Graphics?

場景中有哪些 Graphics,最簡單的方式莫過於 GameObject.FindObjectsOfType() 了,該函數會回傳場景中 Graphics 的陣列。但產生該陣列需要額外分配(Allocate)新的記憶體區塊,而 hierarchyWindowChanged 在使用者編輯過程中又是常常會發生,若場景中存在數千個 Graphics,每次都需要上百 kb 的記憶體區塊,是很沒有效率且恐造成記憶體破碎(Memory Fragmentation)。且 GameObject.FindObjectsOfType 不像 GameObject.GetComponentsInChildren 有提供 Temp List 的多載,可以重複利用已分配的記憶體區塊。

因此改用別的方式來取得場景中所有的 Graphics,先使用 SceneManager 取得所有已載入的場景,接著取得該些場景中 Root GameObjects,使用  GetComponentsInChildren(TempList) 方式來取得場景中的 Graphics,最後使用 C# Enumerable 模式回傳。

至於標示 Graphics 的方法,在考慮不能設定 Graphic 額外屬性的情況下,需要一個資料結構來紀錄已標示的 Graphics。可以考慮常用 List,但若場景中 Graphics 存在數千個時,判斷 Graphic 有沒有標示將會非常的慢,List.Contains(Object),O(n)。因此改用 HashSet 來儲存已標示的 Graphics 應是較好的方法,其搜尋方法,HashSet.Contains(Object),O(1),比 List 還要快上許多。

最後,不希望載入新場景時,此功能修改新場景中 Graphics 的已編輯屬性。需要 hierarchyWindowChanged 發生時,是否有載入場景,一旦載入場景,便需要重新標示場景中的 Graphics。

那麼如何判斷場景載入,簡單的方式紀錄所有已載入場景的路徑,用路徑字串陣列比較是否有所不同。實做上,紀錄字串陣列比對總是比較麻煩,因此使用雜湊演算法(Hash Algorithm)對於已載入的場景路徑,建立一組雜湊值來判斷。當這雜湊值更改時,表示場景所有更動,重新標示 Graphics。至於用哪一種的雜湊,最後使用 SHA-512。

using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public static class GraphicDefaultValueWriter
{
private static HashSet<Graphic> sceneGraphics = new HashSet<Graphic>();
private static string scenesHash = string.Empty;

private static List<GameObject> tempRoots = new List<GameObject>();
private static List<Graphic> tempGraphics = new List<Graphic>();

private static HashAlgorithm hasher = new SHA512Managed();

[InitializeOnLoadMethod]
private static void Initialize()
{
EditorApplication.hierarchyWindowChanged += OnHierarchyChanged;
BuildInitialSceneGraphics(ComputeScenesHash());
}

private static void OnHierarchyChanged()
{
if (Application.isPlaying)
{
scenesHash = string.Empty;
return;
}

var hash = ComputeScenesHash();
if (hash != scenesHash)
{
// New scene, rebuild
BuildInitialSceneGraphics(hash);
}
else
{
// Each new graphics, set default value
foreach (var graphic in GetNewGraphics())
{
graphic.raycastTarget = graphic.GetComponent<IEventSystemHandler>() != null;
}
}
}

private static void BuildInitialSceneGraphics(string hash)
{
sceneGraphics.Clear();
scenesHash = hash;

foreach (var graphic in GetSceneGraphics())
{
sceneGraphics.Add(graphic);
}
}

private static string ComputeScenesHash()
{
var sb = new StringBuilder();
for (var ss = 0; ss < SceneManager.sceneCount; ss++)
{
var scene = SceneManager.GetSceneAt(ss);
if (scene.isLoaded)
{
sb.Append(scene.path);
}
}

var hash = hasher.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()));
var hashSb = new StringBuilder(hash.Length * 2);
foreach (var b in hash)
{
hashSb.AppendFormat("{0:X2}", b);
}

return hashSb.ToString();
}

private static IEnumerable<Scene> GetLoadedScenes()
{
for (var ss = 0; ss < SceneManager.sceneCount; ss++)
{
var scene = SceneManager.GetSceneAt(ss);
if (scene.isLoaded)
{
yield return scene;
}
}
}

private static IEnumerable<Graphic> GetSceneGraphics()
{
foreach (var scene in GetLoadedScenes())
{
tempRoots.Clear();
scene.GetRootGameObjects(tempRoots);
foreach (var root in tempRoots)
{
tempGraphics.Clear();
root.GetComponentsInChildren<Graphic>(tempGraphics);
foreach (var graphic in tempGraphics)
{
yield return graphic;
}
}
}
}

private static IEnumerable<Graphic> GetNewGraphics()
{
foreach (var graphic in GetSceneGraphics())
{
if (!sceneGraphics.Contains(graphic))
{
sceneGraphics.Add(graphic);
yield return graphic;
}
}
}
}
  • Ln20:InitializeOnLoadMethod,當 Editor 載入時就開始運作
  • Ln29-33:Play Mode時,不做任何處理,且清空 sceneHash,待回到編輯模式重新標示 Graphics
  • Ln62-82:建立 sceneHash,原始資料使用已載入的場景路徑
  • Ln96-112:取得目前場景中的 Graphics,使用 TempPool 來取得物件,降低記憶體重複 Allocate/ Deallocate 的次數

How about old projects

至於已在開發的專案,直接執行以下程式碼,重新設定一次 raycastTarget 吧。

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using UnityEngine.SceneManagement;
using UnityEditor;
using UnityEditor.SceneManagement;

public static class ResetRaycastTargetMenuEditor
{
[MenuItem("Siyuan/ResetRaycastTarget")]
public static void ResetRaycastTarget()
{
AssetDatabase.StartAssetEditing();

foreach (var guid in AssetDatabase.FindAssets("t:GameObject"))
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
var go = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
foreach (var graphic in go.GetComponentsInChildren<Graphic>())
{
if (graphic != null)
{
graphic.raycastTarget = graphic.GetComponent<IEventSystemHandler>() != null;
}
}

PrefabUtility.ReplacePrefab(go, prefab, ReplacePrefabOptions.Default);
GameObject.DestroyImmediate(go);
}

AssetDatabase.SaveAssets();
AssetDatabase.StopAssetEditing();

// Load all build scenes
foreach (var scene in EditorBuildSettings.scenes)
{
EditorSceneManager.OpenScene(scene.path, OpenSceneMode.Additive);
}

// Set property
foreach (var graphic in GameObject.FindObjectsOfType<Graphic>())
{
if (graphic != null)
{
var newValue = graphic.GetComponent<IEventSystemHandler>() != null;
if (graphic.raycastTarget != newValue)
{
graphic.raycastTarget = newValue;
}
}
}

// Save
for (var ss = 0; ss < SceneManager.sceneCount; ss++)
{
var scene = SceneManager.GetSceneAt(ss);
if (scene.isLoaded)
{
EditorSceneManager.SaveScene(scene);
}
}
}
}
  • Ln13-29:將專案所有的 Prefabs 全部一個一個讀入,一個一個檢查設定 Graphic raycastTarget,最後 Apply prefabs
  • Ln35-39:載入所有的 build scenes(File > Build Settings > Scenes In Build)
  • Ln46-50:檢查有沒有要改變值,有改變才設定。這是避免對於 scene prefab 寫入場景值。
  • Ln50-62:所有開啟的場景存檔

Reference

沒有留言: