シーン上のCanvasを描画順で表示するエディタ拡張を作る

はじめに

こんにちは。サイバーエージェントのゲーム事業部でUnityエンジニアをしている田村です。

Unityには、通称uGUIと呼ばれるUIシステムが備わっています。uGUIはUnity4.6から提供されている機能で、uGUIを利用することで外部ライブラリ等を使わなくても簡単に様々なUIを構築することができます。そのため、現在Unityで開発を行っているほとんど全ての方がuGUIを使ったことがあるかと思います。

uGUIによるUI開発の際、必ず必要となるコンポーネントがCanvasです。Canvasがアタッチされたゲームオブジェクトの配下にUIを配置していくことで、それらのUIが機能を発揮することになります。逆に、Canvasの配下ではない場所にUIを配置しても、描画もされないしユーザーの入力も受け付けません。

Canvasの役割の1つとして、描画順の制御があります。当然ながら、UI同士には前後関係があります。すなわち、AというUIとBというUIがあったときに、AとBのどちらが前に配置されるかということです。Aを先に描画した場合は、Bが後から描画されるため、Aの上にBが「上書き」されて、Bが前に配置されたように見えます(図1左)。逆に、Bを先に描画した場合は、Aが後から描画されるため、AがBの前に配置されたように見えます(図1右)。

図1: UIの描画順イメージ

図1: UIの描画順イメージ

Canvasにはいくつかのパラメータがあり、開発者はそれをうまく指定することによってUIの描画順を制御することができます。大規模なゲームになると、様々な理由から1つのシーンにCanvasがいくつも配置されることがあります。例えば、複雑な描画順の制御をするためだったり、設計の都合上Canvasを分散させる必要があったりといった理由が考えられます。また、パフォーマンスの観点から、大量のUIを1つのCanvasの配下に置くのではなく複数のCanvasの配下に分散させるということもあります。しかし、1つのシーンにCanvasがいくつも配置される場合、どのような順番でどのUIが描画されるかを把握することが難しくなります。

そこで今回、シーン上のCanvasを描画順で並べて表示するエディタ拡張を開発しました。このエディタ拡張には、CanvasCheckerという名前を付けました。CanvasCheckerを使うことで、色々な設定のCanvasが多数存在していてもそれらがどのような順番で描画されるかが一目瞭然で分かるようになり、シーン全体のUIの構造の把握に役立たせる事ができます。

この記事では、まずはじめにCanvasについてのおさらいを行います。その中で、どのようなパラメータによってどのように描画順が決定されるかを解説します。続いてCanvasCheckerの機能を紹介した後、CanvasCheckerの実装に必要な要素技術をいくつかピックアップし、それらについて解説します。具体的には、シーン上のコンポーネントを全て検索する方法、プロジェクトで定義されているSorting Layerを取得する方法、複数の列を持ったツリー状のビューをエディタ拡張で実装する方法を紹介します。

※本記事は2019年4月14日に開催された技術書典6にて販売した「UniTips Vol.3」からの転載となります。

Canvasについて

Canvasの内部実装

uGUIに関するクラスのほとんどは、UnityEngine.UIネームスペースに含まれます。UnityEngine.UIネームスペース内のクラスはソースコードが公開されており、内部実装を見ることができます。uGUIはC#で実装されている部分も多く、ソースコードを読むことで、どのように各機能が実現されているかをある程度把握できます。

一方で、CanvasクラスのネームスペースはUnityEngineであり、このUI用リポジトリには含まれていません。UnityのC#実装は2018年3月頃からGitHubで公開されており、Canvasクラスの実装もここで確認できます。しかし残念ながら、Canvasの内部実装はC++によるネイティブコードになっており、具体的に内部でどのような処理が行われているかの確認はできません。したがって、Canvasの機能を確認するためにはドキュメントを読むか実験してみるしかありません。

CanvasのRender Mode

Render Modeは、Canvasにおいて最も重要な設定であり、インスペクターのドロップダウンメニューから選択することができます。Render Modeによって、Canvas内のUIをどのように描画するかが決まります。後に説明する描画順の話とも絡んでくるので、ここで少し詳しく説明しておきます。

Screen Space – Overlay

図2: 「Screen Space - Overlay」の設定

図2: 「Screen Space – Overlay」の設定

1つ目のRender Modeは、「Screen Space – Overlay」です(図2)。Unityにおいて何らかのオブジェクトを画面に描画するためには、基本的にカメラが必要になります。しかし、このRender Modeでは、描画にカメラを必要としません。極端な話では、シーン上にカメラが1つも配置されていなくても、この設定のCanvas内のUIは描画されます。カメラとは独立した存在で問答無用で画面にUIを描画するモードといえます。

Screen Space – Camera

図3: 「Screen Space - Camera」の設定

図3: 「Screen Space – Camera」の設定

2つ目のRender Modeは、「Screen Space – Camera」です(図3)。このモードでは、描画するカメラと、そのカメラからCanvasまでの距離を指定する必要があります。指定されたカメラから指定された距離だけ離れたところで、画面いっぱいに広がった平面になるようにCanvasの領域が調整されます。シーンビュー上でカメラを移動させたり回転させたりすると、Canvasの領域がカメラに追従している様子がよく分かります。このモードではUIが指定したカメラによって描画されるため、カメラの設定がUIの描画にも効いてきます。例えば、カメラに対してポストエフェクトを有効にしたときは、UIに対してもポストエフェクトを効かせることができます。カメラの設定をUIの描画に対しても適用することができるため、実際の開発現場ではこの設定が使われることが最も多いように感じます。

World Space – Camera

図4: 「World Space - Camera」の設定

図4: 「World Space – Camera」の設定

最後のRender Modeは、「World Space – Camera」です(図4)。これまでの2つのモードでは、Screen Spaceという名前にもあるように、画面空間全体にCanvas領域が広がるものでした。一方で「World Space – Camera」では、ワールド空間にCanvasの領域が配置されます。カメラの位置や回転とは独立して、ワールド内の空間に自由にUI用の領域を配置できます。他の3Dオブジェクトと同じようにパースを効かせた状態でUIを配置したい場合などに用いることができます。ただし、視認性や操作性の観点から、このモードの使い所は限られるのではないかと考えられます。このモードに関しても、「Screen Space – Camera」と同様に、最終的にUIを描画するのはカメラなので、カメラの設定がUIの描画にも適用されるということになります。このことから、「Screen Space – Camera」と「World Space – Camera」をまとめて、「カメラ系のCanvas」と呼ぶことにします。ちなみに、このモードの場合はインスペクターでのカメラの設定がEvent Cameraという名前になります。ここで指定されたカメラは描画とはあまり関係なく、UI関連のイベントを発火するためのカメラになります。ただし、実際にはここで指定されたカメラによってCanvas内のUIを描画することがほとんどだと思うので、今回作成したCanvasCheckerではそのことを前提にしています。

Canvasの描画順の決まり方

続いて、CanvasCheckerを実装するにあたり最も重要となる、Canvasの描画順の決まり方について説明します。Canavsの描画順に最も影響を与えるのがRender Modeです。次に、カメラのDepthやSorting Order(Order in Layer)など色々なパラメータに応じて描画順が決定されます。以降、Canvasの描画順を決めるパラメータについてそれぞれ解説していきます。

Render Mode

まず、Cameraの指定を行わない「Screen Space – Overlay」ですが、これはあらゆるカメラよりも手前に描画されます。そのため、Render Modeが「Screen Space – Overlay」であるかそうでないかによって描画順に関してまず大きな差が生まれます。すなわち、Render Modeが「Screen Space – Overlay」であるCanvasが必ず手前、そうでないCanvasが必ず奥に描画されるということになります。

Sort Order

Render Modeを「Screen Space – Overlay」にすると、インスペクターにSort Orderという項目が表示されます。これは、「Screen Space – Overlay」のCanvasが複数あった場合に、その中での描画順の制御に使われるパラメータです。Sort Orderが大きいほど手前に描画されます。

CameraのDepth

カメラ系のCanvasの場合、Canvasを描画するカメラを指定することになります。このカメラのDepthが大きいほど手前に描画されます。カメラのDepthは図5のようにインスペクターから指定することができます。複数のCanvasが同じカメラを参照している場合、その中での描画順はまた別のパラメータによって決まることになります。同じDepthのカメラが複数あり、複数のCanvasがそれらのカメラをそれぞれ参照している場合は、どちらかのカメラのCanvasが必ず先に描画されます。同じDepthのカメラが複数あった場合、どちらが先に描画されるかはおそらく制御できないため、通常複数のカメラに同じDepthを割り当てるべきではありません。

図5: カメラのdepth

図5: カメラのDepth

Sorting Layer

カメラ系のCanvasにおいて、同じカメラの中での描画順を決める最初のパラメータがSorting Layerです。CanvasのインスペクターにおいてプルダウンメニューからSorting Layerを選択することができます。Sorting LayerはuGUIだけの機能ではなく、Unityでの描画において統一的に使われるものです。パーティクルやSprite RendererなどのインスペクターでもSorting Layerを選択できるようになっています。Mesh RendererのインスペクターにはSorting Layerは表示されませんが、実はRendererクラスにsortingLayerIDやsortingLayerNameというプロパティが定義されているので、Mesh Renderer等に対してもコードからSorting Layerを設定することができます。ただし、Sorting Layerが有効になるかどうかはシェーダーの設定に依存するので注意が必要です。

Sorting Layerは初期状態ではDefaultという名前のものしかありません。Sorting Layerを増やすためには、Project Settingsの中のTags and Layersを開きます。ここのSorting Layersの項目の中でSorting Layerの追加や削除、並べ替えができます(図6)。このリストで下にあるSorting Layerほど手前に描画されます。

図6: 「Sorting Layer」の設定

図6: Sorting Layer

Order in Layer

同じカメラ、同じSorting Layerの場合、Order in Layerで描画順を制御することができます。Order in Layerには整数値を指定することができ、値が大きいほど手前に描画されます。これも前述のSorting Layerと同様、Unityで統一的に使われるパラメータです。Canvasだけでなく、Renderer全般にSorting Layerとセットでプロパティが用意されています。

カメラからの距離

カメラ系のCanvasにおいて、Order in Layerまでの全てのパラメータが等しいとき、カメラからのUIの距離によってCanvasの描画順が決まります。これに関しては実験によって試した結果でしかないのですが、Canvasの配下のUIとカメラの距離の最小と最大の中点を代表のz値としたとき、z値が小さい方が手前に描画されるようです。一方で、OverlayのCanvasにおいては、Sort Orderが同じ場合、UIのz座標がどうであれ描画順が変わることはないようでした。

ヒエラルキーにおける順番

1つのCanvasの中のUIに関しては、ヒエラルキーにおける順番で描画順が決まります。ヒエラルキーにおいて下に配置されているUIほど手前に描画されます。Canvas自体の描画順に関してはヒエラルキーには影響を受けず、今までに説明したパラメータによってのみ描画順が決まります。

Canvasの描画順のまとめ

これまでの描画順の説明をまとめると図7のようになります。このように、描画順には色々なパラメータが影響しており、かなり複雑な構造となっています。「ヒエラルキーにおける順番」に関しては、Canvas自体の描画順には関係ないので括弧で囲っています。カメラ系のCanvasには「Screen Space – Camera」と「World Space – Camera」の2種類がありますが、この2つのRender Modeの違いで前後関係が変わるということはないようでした。

図7: Canvasの描画順まとめ

図7: Canvasの描画順まとめ

入れ子になったCanvas

Canvasの配下には基本的にはUIを配置することになりますが、Canvasを入れ子に配置することもできます。入れ子に配置されたCanvasをここではSub-canvasと呼ぶことにします。ちなみに、入れ子になったCanvasを指す標準的な名称はおそらくないようで、Sub-canvasだけでなくNested CanvasやNon-root Canvasなどとも呼ばれるようです。Sub-canvasの配下にさらにSub-canvasを配置することもできます。Sub-canvasに対して、最もルート側に位置するCanvasをRoot Canvasと呼びます。

Sub-canvasでは、多くのパラメータはRoot Canvasのものが引き継がれます。ただし、描画順に関わるパラメータの中で、Sorting LayerとOrder in Layerに関しては、Root Canvasのものを上書きすることも可能です。Sub-canvasのインスペクターでOvedrride Sortingにチェックを入れると、Sorting LayerとOrder in Layerの入力ができるようになります(図8)。

図8: Sub-canvasにおけるインスペクター

図8: Sub-canvasにおけるインスペクター

設計の都合などでUIを特定のCanvasの配下に配置したい一方で、ヒエラルキーにおける順番以外で描画順を制御したいという場合に、Sub-canvasが役に立ちます。

CanvasCheckerの概要

前節で説明したように、Canvasの描画順は複雑に決定されるため、シーン中のCanvasの構造が複雑になると、どのようなCanvasが存在し、どのような順で描画されているかの把握が難しくなるという問題があります。そこで今回、シーン上のCanvasを描画順で表示するエディタ拡張としてCanvasCheckerを開発しました。

本節では、CanvasCheckerの導入方法や機能について説明します。そして、以降の節で、ツールの実装で使った技術を紹介します。特に、シーン上のCanvasを全て検索する方法、Sorting Layerの取得方法、複数の列を持ったツリー状のビューの実装方法を説明します。

CanvasCheckerの導入

CanvasCheckerはGitHubで公開しています。ただし、Unity2018でのみ動作確認をしており、.NET 4.xを有効にする必要があるので注意してください。

GitHubからリポジトリをクローンもしくはZIPファイルとしてダウンロードし、CanvasCheckerフォルダを自分のUnityプロジェクトに追加します。すると、UnityのメニューのToolsにCanvasCheckerという項目が追加されるので、そこをクリックすることでCanvasCheckerのウィンドウが開きます。

図9: CanvasCheckerのウィンドウ

図9: CanvasCheckerのウィンドウ

機能の説明

CnavasCheckerのウィンドウにおいて、「Canvasを検索」というボタンをクリックすると、現在シーンに開いているCanvasが全て検索され、描画順にウィンドウに表示されます(図10)。結果は3つの列で構成されたツリー状のリストで表されます。手前に描画されるCanvasほどリストの下に表示されます。ただし、前節で説明した描画順を決める要素の1つである「カメラからの距離」に関しては、仕様がはっきり分からないということと、需要がそこまでないだろうということでCanvasCheckerでは考慮に入れていません。

図10: Canvasを検索した後のウィンドウ

図10: Canvasを検索した後のウィンドウ

1列目の「Name」では、Canvasの描画順を決定する要素がツリー状で並べられ、ツリーの末端が1つのCanvasに対応するようになっています。ツリー状で表現することで、同じカメラを参照しているCanvasや、同じSorting LayerのCanvasなどが階層的にまとまるようになっています。ツリー上の各項目の左の三角形をクリックすることで、ツリーをまとめたり展開したりできます。

2列目の「Object」では、カメラやCanvasのオブジェクトが描画されています。それらをクリックすると、ヒエラルキービューにおいて対応するオブジェクトにフォーカスが当てられます。カメラやCanvasがヒエラルキーのどこに配置されているかを確認するのに便利です。

3列目の「Note」では、補足情報を表示します。先述のとおり、Canvasがヒエラルキー上で他のCanvasの配下に配置されている、つまりSub-canvasの場合、親のCanvasの設定が引き継がれます。しかし、Sub-canvasが非アクティブのときは、この設定がうまく引き継がれません。設定がうまく引き継がれないということは、描画順も正しく求められないことになってしまうため、それを知らせるためにこの3列目にそのことを記述します。ついでに、Sub-canvasではないが非アクティブ、というCanvasに関しても、そのことを情報として表示するようにしています。

シーン上のコンポーネントを全て検索する

CanvasCheckerの要素技術の1つとして、シーン上のコンポーネント(Canvas)を全て検索するというものがあります。Unity経験者の方であれば、ほとんどの方がUnityEngine.ObjectクラスのstaticメソッドであるFindObjectsOfType()を使えば良いと考えると思います。実際このメソッドは、シーン上のObjectを検索するためのメソッドです。しかし実は、FindObjectsOfTypeメソッドでは非アクティブなGameObjectを検索対象に含めることができません。CanvasCheckerでは、非アクティブなCanvasも含めて全てのCanvasを検索したいと考えたため、このメソッドは使うことができませんでした。そこで、この問題にどのように対処したかを説明します。

あるGameObjectの参照を持っているとき、その配下のGameObjectにアタッチされたコンポーネントを全て取得するためのメソッドとして、GetComponentsInChildren()があります。実はこのメソッドも、そのままでは非アクティブなGameObjectは検索対象に入りません。しかしこのメソッドには、bool型の引数を取るパターンがオーバーロードされています。この引数の名前はincludeInactiveとなっており、これをtrueにすると非アクティブなものも含めて全てを検索対象にしてくれます。このようなオプションがFindObjectsOfTypeにもあれば話は簡単だったのですが、残念ながらそちらにはありません。そこで、GetComponentsInChildrenを使ってどうにか今回の目的を果たすことにします。

GetComponentsInChildrenを使ってシーン上の全てのコンポーネントを検索するためには、まずシーンに存在するルートのGameObjectを全て取得する必要があります。そのような目的のためには、SceneクラスのGetRootGameObjects()メソッドを使うことができます。Sceneクラスのインスタンスは、SceneManager.GetSceneAt(i)で取得できます。Additiveロードによって一度に複数のシーンを開いている場合があるので、引数で何番目のシーンの情報を取得するかを指定する必要があります。また、現在開いているシーンの数はSceneManager.sceneCountで取得できます。

以上から、現在開いているシーンに存在するCanvasを、非アクティブなものも含めて全て取得する処理は次のような実装になります。

var canvases = new List<Canvas>();
for (int i = 0; i < SceneManager.sceneCount; i++)
{
    var scene = SceneManager.GetSceneAt(i);
    canvases.AddRange(
        scene.GetRootGameObjects()
            .SelectMany(root => root.GetComponentsInChildren<Canvas>(true))
    );
}

現在開いている全てのシーンに対してルートのGameObjectを取得し、それぞれに対してGetComponentsInChildrenした結果をリストに集約しています。

DontDestroyOnLoadへの対応

残念ながら、実はまだ終わりではありません。先程の実装では、DontDestroyOnLoadによってシーン遷移時に破棄されないようになったGameObjectが検索対象になりません。GameObjectに対してDontDestroyOnLoadを実行すると、そのGameObjectは専用の特別なシーンに配置された状態になります。その特別なシーンは、先程使ったSceneManager.GetSceneAt(i)では取得することができません。そして残念ながら、DontDestroyOnLoad用のシーンを取得する簡単な方法は用意されていません。しかし、それを実現する少々トリッキーな方法があるので紹介します。

GameObjectにはそれが配置されているシーンへの参照としてsceneというプロパティが用意されています。そこで、適当なGameObjectをDontDestroyOnLoadの対象にし、そのGameObjectのsceneプロパティを使うことで、DontDestroyOnLoad用のシーンの参照を手に入れることができます。あとは先程と同様の方法で、DontDestroyOnLoad用のシーンの中から目的のコンポーネントを検索します。

以上から、DontDestroyOnLoadへの対応は次のようになります。

// DontDestroyOnLoadはアプリケーション実行中のみの存在なので、実行中のみ対応する
if (Application.isPlaying)
{
    var go = new GameObject("TempDontDestroyObject");
    DontDestroyOnLoad(go);

    canvases.AddRange(go.scene.GetRootGameObjects()
        .SelectMany(root => root.GetComponentsInChildren<Canvas>(true))
    );

    DestroyImmediate(go);
}

検索するときに、TempDontDestroyObjectという名前のGameObjectを新たに作り、DontDestroyOnLoadの対象にします。そして、検索し終わったらDestroyImmediate(go)を実行することでGameObjectをすぐに削除します。これで、特にシーン上にゴミも残さずDontDestroyOnLoad対象のGameObjectの検索も行うことができます。

SortingLayerの取得

Canvasの描画順に影響を与えるプロパティにSorting Layerがありますが、あるSorting Layerが何番目に描画されるものかを知る術は標準では提供されていません。しかし、Canvasを描画順で並べるためには、どうしてもSorting Layerの順番を知る必要があります。

実は、InternalEditorUtility というクラスにsortingLayerUniqueIDsというinternalなプロパティが存在し、このプロパティを使うことでSorting LayerのIDを描画順で取得することができます。internalなプロパティなので通常の方法でアクセスすることはできず、リフレクションを使う必要があります。

リフレクションにより、sortingLayerUniqueIDsの情報を次のように使うことができます。

// リフレクションを使ってsortingLayerUniqueIDsにアクセス
var sortingLayerUniqueIDsProperty =
    typeof(InternalEditorUtility).GetProperty("sortingLayerUniqueIDs",
        BindingFlags.Static | BindingFlags.NonPublic);
var layerIds = (int[]) sortingLayerUniqueIDsProperty.GetValue(
    null, new object[0]);
// SortingLayerのIDからdepthへの対応付け
var sortingLayerDepth = new Dictionary<int, int>();
for (var i = 0; i < layerIds.Length; i++)
{
    var layerId = layerIds[i];
    sortingLayerDepth[layerId] = i;
}

sortingLayerDepthというDictionary型の変数が、Sorting LayerのIDからdepth(大きいほど手前に描画される値)への対応付けとなっています。

複数の列を持つツリー状のビューの実現方法

CanvasCheckerのメインのUIである複数の列を持ったツリー状のビューは、TreeViewというUnity標準の機能を使って実装しています。やりたいことがマッチすれば、かなりリッチなGUIを比較的簡単に作ることができるため、知っておいて損はない機能だと思います。

TreeViewはUnityの公式ドキュメントでも詳しく紹介されています。公式ドキュメントでは、サンプルプログラムがダウンロード可能になっており、サンプルプログラムとドキュメントを合わせて見ることで、どのようにしてTreeViewを実装すれば良いかを知ることができます。

まず、複数の列を持たない、単純なツリーを実装する方法を簡単に説明します。その後、複数の列のTreeViewの実装方法を重点的に紹介します。本節では、エディタ拡張を使って何らかの独自Windowを実装できる程度の知識を前提としています。

複数の列を持たない場合のTreeViewの使い方

それでは早速、TreeViewクラスを使ってツリー状のビューを実装する基本的な方法を説明します。

TreeViewクラスはabstractであるため、実際にTreeViewを使うときは、TreeViewクラスを継承したクラスを作り、そこで独自のカスタマイズを施していきます。

TreeViewクラスにあるBuildRoot()メソッドはabstractメソッドであるため、必ずoverrideする必要があります。返り値の型はTreeViewItemというクラスになっています。TreeViewItemは、ツリー構造のノードにあたるクラスであり、IDや子供などの情報を持ちます。BuildRootメソッドでは、TreeViewItemを用いてツリー構造を構築し、そのルートノードを返します。

TreeViewクラスを継承したクラスの実装は例えば次のようになります。

public class SimpleTreeView : TreeView
{
    /// <summary>
    /// コンストラクタ
    /// TreeViewStateはノードの開閉状態などを記録するためのクラス
    /// </summary>
    public SimpleTreeView(TreeViewState state) : base(state)
    {
        // Reloadを呼ぶことでBuildRootが呼ばれ、ツリーを描画する準備が整う
        Reload();
        // ExpandAllによりツリーが全て展開された状態にする
        ExpandAll();
    }

    /// <summary>
    /// ツリーを構築する
    /// Reloadが呼ばれるとこのメソッドが呼ばれる
    /// </summary>
    protected override TreeViewItem BuildRoot()
    {
        // ルートはdepthを-1にする
        var root = new TreeViewItem {id = 0, depth = -1};
        // 他のノードを作る
        // idは一意になるようにする
        // ここでdepthを設定する必要はない
        var node1 = new TreeViewItem {id = 1, displayName = "node1"};
        var node2 = new TreeViewItem {id = 2, displayName = "node2"};
        var node3 = new TreeViewItem {id = 3, displayName = "node3"};
        var node4 = new TreeViewItem {id = 4, displayName = "node4"};
        // rootの子供としてnode1を追加する
        root.AddChild(node1);
        // node1の子供としてnode2とnode3を追加する
        node1.AddChild(node2);
        node1.AddChild(node3);
        // node2の子供としてnode4を追加する
        node2.AddChild(node4);
        // 親子関係からdepthを自動的に計算する
        SetupDepthsFromParentsAndChildren(root);
        // ルートを返す
        return root;
    }
}

コードの中に丁寧にコメントを書いたので、それぞれの行で何をしているかは理解できると思います。TreeViewを継承したクラスにコンストラクタとBuildRootメソッドを定義しているだけです。ここで実装したSimpleTreeViewを表示するエディタWindowの実装は次のようになります。

public class SimpleTreeViewWindow : EditorWindow
{
    /// <summary>
    /// TreeViewのフィールド
    /// </summary>
    private SimpleTreeView _treeView;

    /// <summary>
    /// TreeViewの状態
    /// シリアライズすることでコンパイルが走っても状態が保持されるようにする
    /// </summary>
    [SerializeField]
    private TreeViewState _treeViewState;

    private void OnEnable()
    {
        if (_treeViewState == null)
        {
            _treeViewState = new TreeViewState();
        }

        _treeView = new SimpleTreeView(_treeViewState);
    }

    private void OnGUI()
    {
        // TreeViewのOnGUIは描画領域のRectが必要なので、何らかの方法で計算する
        var rect = EditorGUILayout.GetControlRect(false, position.height);
        _treeView.OnGUI(rect);
    }
}

このエディタWindowを開くと図11のようにツリー状のUIが表示されます。_treeView.OnGUI(rect)を呼ぶことで、TreeViewの描画が行われます。

図11: SimpleTreeViewを表示したWindow

図11: SimpleTreeViewを表示したWindow

以上が、単純なツリー状のビューの実装方法の説明でした。このように、TreeViewクラスを使えば簡単にツリー構造を描画することができます。

複数の列を持つ場合のTreeViewの使い方

それでは、複数の列を持つツリー状のビューをTreeViewクラスを使ってどのように実装するかについて説明します。実は、複数の列のヘッダーを表示するだけであれば、それほど難しいことをする必要はありません。TreeViewのコンストラクタにヘッダーの情報を与えるだけで、複数の列を持つTreeViewが実現できます。

まず、TreeViewクラスを継承したクラスをMultiColumnTreeViewとして次のように実装します。

public class MultiColumnTreeView : TreeView
{
    /// <summary>
    /// コンストラクタ
    /// MultiColumnHeaderという型のヘッダー情報を受け取り、
    /// ベースクラスのコンストラクタに渡す
    /// </summary>
    public MultiColumnTreeView(
        TreeViewState state, MultiColumnHeader header
    ) : base(state, header)
    {
        Reload();
        ExpandAll();
    }

    /// <summary>
    /// MultiColumnHeaderStateヘッダーの状態を表すクラスのインスンスを作る
    /// </summary>
    public static MultiColumnHeaderState CreateHeaderState()
    {
        var columns = new[]
        {
            new MultiColumnHeaderState.Column
            {
                headerContent = new GUIContent("Header1")
            },
            new MultiColumnHeaderState.Column
            {
                headerContent = new GUIContent("Header2")
            },
            new MultiColumnHeaderState.Column
            {
                headerContent = new GUIContent("Header3"),
            },
        };

        return new MultiColumnHeaderState(columns);
    }

    protected override TreeViewItem BuildRoot()
    {
        // SimpleTreeViewのときと完全に同じなので略
    }
}

CreateHeaderState()というstaticメソッドを定義して、このTreeViewのためのヘッダー(の状態を表すもの)を構築します。ここでは、3つの列を用意し、各列に表示するラベルの文字列を与えています。このMultiColumnHeaderState.Columnクラスでは様々なプロパティを指定することができますが、それについては後ほど説明します。このMultiColumnTreeViewを表示するエディタWindowの実装は次のようになります。

public class MultiColumnTreeWindow : EditorWindow
{
    private MultiColumnTreeView _treeView;

    [SerializeField]
    private TreeViewState _treeViewState;

    private void OnEnable()
    {
        if (_treeViewState == null)
        {
            _treeViewState = new TreeViewState();
        }

        // MultiColumnHeaderStateの生成
        var headerState = MultiColumnTreeView.CreateHeaderState();
        // MultiColumnHeaderの生成
        var header = new MultiColumnHeader(headerState);

        // ヘッダーの列のサイズを描画領域に合わして調整する
        header.ResizeToFit();

        _treeView = new MultiColumnTreeView(_treeViewState, header);
    }

    private void OnGUI()
    {
        // SimpleTreeViewEditorWindowのときと同じなので略
    }
}

MultiColumnHeaderStateを生成したあとに、それを使ってMultiColumnHeaderを生成します。そして、そのMultiColumnHeaderをTreeViewのコンストラクタに渡しています。ResizeToFitは、ヘッダーの列のサイズを描画領域に合わして調整してくれるメソッドです。

これにより、図12のように複数の列からなるヘッダーが描画されるようになります。ここで、ヘッダーの列の間の縦棒をドラッグすることで列のサイズを調整したり、ヘッダーを右クリックして特定の列を非表示にしたりできます。

図12: MultiColumnTreeViewを表示したWindow

図12: MultiColumnTreeViewを表示したWindow

実は、今の実装には少し問題があります。それは、何らかのスクリプトを編集して再コンパイルが走ると、ヘッダーの状態がリセットされてしまうというものです。これを回避するために、MultiColumnHeaderStateをシリアライズするようにします。ちょうど、TreeViewStateをシリアライズしているのと同じような処理をするということです。

MultiColumnHeaderStateのシリアライズに対応した実装は次のようになります。

public class MultiColumnTreeWindow : EditorWindow
{
    // 略

    /// <summary>
    /// ヘッダーの状態(シリアライズする)
    /// </summary>
    [SerializeField]
    private MultiColumnHeaderState _headerState;

    private void OnEnable()
    {
        if (_treeViewState == null)
        {
            _treeViewState = new TreeViewState();
        }

        // 後で使いたいため、ヘッダーを初めて初期化したかどうかを覚えておく
        bool isFirstInit = _headerState == null;

        // とりあえず新しいMultiColumnHeaderStateを生成する
        var headerState = MultiColumnTreeView.CreateHeaderState();

        // 新しいHeaderStateを古いHeaderStateで上書きできるならする
        if (MultiColumnHeaderState.CanOverwriteSerializedFields(
            _headerState, headerState))
        {
            MultiColumnHeaderState.OverwriteSerializedFields(
                _headerState, headerState);
        }

        // 新しいHeaderStateをシリアライズ対象とする
        _headerState = headerState;

        // 新しいHeaderStateを使ってヘッダーを作る
        var header = new MultiColumnHeader(_headerState);

        // ヘッダーを初めて初期化したときはResizeToFitを呼ぶ
        if (isFirstInit)
        {
            header.ResizeToFit();
        }

        _treeView = new MultiColumnTreeView(_treeViewState, header);
    }

    // 略
}

このような実装をヘッダー生成部分に記述することで、ヘッダーの状態をコンパイル後も引き継げるようになります。このように、MultiColumnHeaderStateのシリアライズは、TreeViewStateのシリアライズのときよりもクセのある記述をする必要があります。この実装は、公式ドキュメントからダウンロード可能なサンプルを参考にしています。

列に対応する要素を描画する方法

ここまで複数の列を持つヘッダーを表示する説明をしてきましたが、列に対応するものが何も描画されていないため、このヘッダーは何も意味をなしていません。続いて、列に対応する要素を描画する方法を説明します。

TreeViewでは、RowGUI(RowGUIArgs args)メソッドをoverrideすることで、ツリーの各行のGUIをカスタマイズすることができます。このメソッドの引数であるargsには、対応するTreeViewItemの情報や今何行目であるかといった情報が含まれています。

複数の列を持つTreeViewを実装するときは、RowGUIArgsクラスのインスタンスメソッドであるGetCellRectとGetColumnを使うことになります。GetCellRectメソッドでは、今表示中の列の中で、引数で与えられた整数番目に表示されている列の描画領域をRectとして取得できます。GetColumnメソッドは、引数で与えられた整数番目に表示されている列が、非表示の列も含めたときに何番目の列なのかを返します。また、現在表示中の列の数はGetNumVisibleColumns()メソッドで知ることができます。

以上を踏まえて、列に対応する要素を描画する実装は次のようになります。

public class MultiColumnTreeView : TreeView
{
    /// <summary>
    /// 列のインデックスと意味の対応を分かりやすくするためのenum
    /// </summary>
    private enum ColumnType
    {
        Header1,
        Header2,
        Header3,
    }

    // 略

    /// <summary>
    /// 各行のGUIを描画する
    /// </summary>
    protected override void RowGUI(RowGUIArgs args)
    {
        for (int i = 0; i < args.GetNumVisibleColumns(); i++)
        {
            var columnType = (ColumnType) args.GetColumn(i);
            CellGUI(args.GetCellRect(i), columnType, args);
        }
    }

    /// <summary>
    /// 各セルのGUIを描画する
    /// </summary>
    private void CellGUI(Rect rect, ColumnType columnType, RowGUIArgs args)
    {
        switch (columnType)
        {
            case ColumnType.Header1:
                // ツリーを描画する
                args.rowRect = rect;
                base.RowGUI(args);
                break;
            case ColumnType.Header2:
                // args.itemでTreeViewItemを参照可能
                EditorGUI.LabelField(rect, $"id = {args.item.id}");
                break;
            case ColumnType.Header3:
                // ボタン等も描画可能
                // args.rowは今見えている行の中で何行目か
                GUI.Button(rect, args.row);
                break;
        }
    }
}

各列に対するenumを定義することで、switchで各列の処理を記述できるようにし、コードを見やすくしています。

これで、図13のように各列にUI要素を描画できました。列の大きさを変えるとそれに応じて各列の要素の位置も変わることや、列を削除するとその列の要素も消えることが確認できます。

図13: 各列に対してGUIを描画する

図13: 各列に対してGUIを描画する

ヘッダーの列に関するプロパティ

MultiColumnTreeViewのCreaeteHeaderStateメソッドでヘッダーの列の状態(MultiColumnHeaderStateクラス)を初期化するときに、MultiColumnHeaderState.Columnクラスの配列を与えていました。MultiColumnHeaderStateクラスには、ヘッダーの列に関する様々なプロパティが含まれており、初期化時に指定できるようになっています。MultiColumnHeaderState.Columnのプロパティ全てを説明とともに列挙したものが次の表です。

プロパティ名 集合場所
width 初期状態における列の幅
minWidth 列の幅の最小。これ以上列が小さくならない
maxWidth 列の幅の最大。これ以上列が大きくならない
autoResize 全体のサイズが変わったときに列の幅を自動で変えるかどうか
headerContent ヘッダーのメインのGUIとなるGUIContent
headerTextAlignment ヘッダーのテキストの配置
sortingArrowAlignment ソートを表す矢印の配置
canSort ソート可能かどうか
sortedAscending (ソートがONのとき)ソートが昇順かどうか
contextMenuText 列の表示非表示を切り替えるメニューに表示するテキスト

このように、各種プロパティを指定することよって色々な挙動を変えることができます。ただし、sortedAscendingプロパティだけは、初期化時に指定しても意味をなさないので注意が必要です。

この表からも分かる通り、TreeViewにはソート機能が搭載されています。MultiColumnHeaderにはsortingChangedというイベントが定義されており、このイベントの発火時に自分でツリーをソートする必要があります。詳細に関しては公式のサンプルを参考にしてください。

おわりに

本記事では、シーン上に存在するCanvasを検索して描画順に表示するCanvasCheckerというツールを軸として、それにまつわる技術的トピックスをいくつか説明しました。

Canvasの描画順の決まり方に関しては、Unityエンジニアであっても曖昧にしか把握していない方も多かったのではないでしょうか。私もCanvasCheckerの実装にあたり改めて調べたことで、描画順に関する細かいルールまで知ることができました。実際のゲーム開発においても、描画順に関する細かいルールまで知った上で適切にCanvasのパラメータを決められるようにしたいですね。

CanvasCheckerの実装のための要素技術に関しては、あまり世の中に知られていない部分に関しては全て説明できたかと思います。何か似たようなことをしたい際には参考にして頂ければ幸いです。

本文でも紹介しましたがCanvasCheckerはGitHubで公開しておりますので、興味があれば是非使ってみてください。何か意見やバグ等があればGitHubのissueなどに投稿していただけると嬉しいです。

【そのほかの記事をみる】

おすすめ記事