CanvasUpdateRegistryとICanvasElement
UnityEngine.UI.dllの中身はBitbucketにあります。誰でも読めて素晴らしみあります。
uGUIを理解するのにあたって個人的にとても大事だと思っているのは、UnityEngine.UI.CanvasUpdateRegistryクラスです。
こいつは何者なのかというと、ざっくり言ってしまうと前回の記事に書いたCanvas.willRenderCanvases
イベントを、レイアウトとレンダリングの二つに分割するのが役割です。
uGUIにはVerticalLayoutGroupのようなレイアウトコンポーネントが存在します。
もし、描画コンポーネントがCanvasRendererにMeshを送り込んだあとでVerticalLayoutGroupがレイアウトを調整してしまった場合、1フレーム描画がおかしくなります。
したがってレイアウトは必ず描画より先に行われる必要があります。Canvas.willRenderCanvasesイベントより先に発火することが保証された別のレイアウト用イベントを用意するなど実現方法はいろいろあるでしょうが、uGUIはCanvas.willRenderCanvasesを二つにカチ割ることを選びました。
- 再レイアウトが必要なuGUIのコンポーネントは、CanvasUpdateRegistry.RegisterCanvasElementForLayoutRebuildで自身をCanvasUpdateRegistryに登録します
- 再描画が必要なuGUIのコンポーネントは、CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuildで自身をCanvasUpdateRegistryに登録します
- CanvasUpdateRegistryはCanvas.willRenderCanvasesのタイミングでレイアウト用に登録されたコンポーネント達にレイアウト指示を送ります。レイアウトが終わったら、再描画用に登録されたコンポーネント達に再描画指示を送ります
- レイアウトや再描画の登録はCanvasUpdateRegistry側で勝手に解除されるので、登録したフレームで一度実行されておしまいです
ICanvasElement
CanvasUpdateRegistryに登録できるのは、UnityEngine.UI.ICanvasElementインターフェースを実装したクラスならなんでもOKです。MonoBehaviourですらない、プレーンなC#クラスでOK。
(※ただしプレーンなクラスを用いる場合はtransformやIsDestroyedの実装で困らないようにMonoBehaviourをラップする形になるのがほとんどだと思われますが)
uGUIのコンポーネントは基底クラスのUnityEngine.UI.GraphicクラスがICanvasElementを実装していますし、レイアウトコンポーネントもUnityEngine.UI.LayoutRebuilderクラスに勝手にラップされるので、uGUIを普段使用するうえでICanvasElementインターフェースの実装を意識することはまずありません。
ですが、「シーンのヒエラルキー上は親子関係にはないがuGUIのコンポーネントと位置を同期しなくてはいけない奴がいてしかもuGUI側が自動レイアウトされる」みたいな面倒ケースでは、ICanvasElementの実装もやむなし!となることもあります。僕は最近遭遇しましたよ、ええ…
ICanvasElementの実装で一番ポイントとなるのはICanvasElement.Rebuildメソッドです。これがレイアウトや再描画の本体になります。現在のRebuildがレイアウト用に呼ばれているかは、引数で渡ってくるUnityEngine.UI.CanvasUpdate列挙型を見ればわかります。わかるのですが、この列挙型はレイアウト用に三つ、描画用に二つのメンバーがいます。
わかりやすくはないのですが、RegisterCanvasElementForLayoutRebuildでコンポーネントを登録すると、1フレ中にPreLayout, Layout, PostLayoutで3回Rebuildが呼ばれ、RegisterCanvasElementForGraphicRebuildでコンポーネントを登録すると、PreRenderとLatePreRenderの2回Rebuildが呼ばれます。(PostRenderじゃないのかよという気もしますがまあMeshをCanvasRendererに突っ込む行為は厳密にはRenderingではないので。この記事内で「再描画」と書いているのも本当は正しくないですね)
実装
UnityEngine.UIを使わずにCanvasに描画するで書いたRawImageModokiを、ICanvasElement対応してみます。
using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(CanvasRenderer))] public class RawImageModoki : MonoBehaviour, ICanvasElement { [SerializeField] private Texture2D _texture = null; public Texture2D Texture { get { return _texture; } set { if(_texture != value) { _texture = value; SetDirty(); } } } private CanvasRenderer _canvasRenderer = null; public CanvasRenderer CanvasRenderer { get { return _canvasRenderer != null ? _canvasRenderer : (_canvasRenderer = GetComponent<CanvasRenderer>()); } } void OnEnable() { SetDirty(); } private void Start() { StartCoroutine(DelayRegister()); } //なぜかIsDestroyedがtrueを返すタイミングがあるので少し遅らせて登録ナンデェ… private System.Collections.IEnumerator DelayRegister() { yield return null; SetDirty(); } private void SetDirty() { CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this); Debug.Log("SetDirty"); } private void Canvas_willRenderCanvases() { const float size = 200; var mesh = new Mesh(); mesh.vertices = new Vector3[] { new Vector3(-size, -size), new Vector3(-size, size), new Vector3( size, size), new Vector3( size, -size), }; mesh.uv = new Vector2[] { new Vector2(0, 0), new Vector2(0, 1), new Vector2(1, 1), new Vector2(1, 0), }; mesh.triangles = new int[] { 0, 1, 2, 2, 3, 0 }; var renderer = this.CanvasRenderer; renderer.SetMesh(mesh); renderer.materialCount = 1; //これ忘れると描画されないので注意 renderer.SetMaterial(Canvas.GetDefaultCanvasMaterial(), 0); renderer.SetTexture(this.Texture); renderer.SetColor(Color.white); } #region ICanvasElement void ICanvasElement.Rebuild(CanvasUpdate executing) { Debug.LogFormat("Rebuild {0}", executing); if (executing == CanvasUpdate.PreRender) Canvas_willRenderCanvases(); } public void LayoutComplete() { } public void GraphicUpdateComplete() { } public bool IsDestroyed() { var result = this == null; Debug.LogFormat("IsDestroyed {0}", result); return result; } #endregion #if UNITY_EDITOR private void OnValidate() { SetDirty(); if (!Application.isPlaying) { Canvas_willRenderCanvases(); } } #endif }
Canvas.willRenderCanvasesを直接購読しなくてもよくなったので、_renderRequiredフラグで毎フレームの処理をガードする必要はなくなりました。 Start付近に悲しい小細工がありますが…エディタのみの挙動ですかね…ちょっとよくわからない。
まとめ
- uGUIのコンポーネントはCanvas.willRenderCanvasesイベントを直接購読するのではなく、CanvasUpdateRegistryを経由することでレイアウトとレンダリングのタイミングを分離している。これによって柔軟なレンダリングが可能になる
- ICanvaElementを実装すればCanvasUpdateRegistryに登録できて、uGUIのコンポーネントとイベントの足並みをそろえたり割り込んだりできる
@UTJ/UCL