見出し画像

UnityのEditor拡張【Advent Calendar 12/15】

はじめに

この記事は Colorful Palette アドベントカレンダー 12/15の記事です。

株式会社 Colorful Palette でクライアントエンジニアをしている中水流です。
普段はゲームシステム周りの実装、UIの実装等を行っています。

直近UnityのEditor拡張について触れる機会があったので、それについて記載しようと思います。
まず、Editor拡張と言っても様々なものがあります。
コンポーネントのプロパティを設定しやすくしたり、見えない数値を可視化したり、はたまた、Unityエンジンにある機能をGUIで操作できるようにしたりと、これらは開発の効率化だったりによく行われるでしょう。
ただ、それらを実装するにはUnityのどこを拡張するかで実装方法は異なります。
今回はサンプルと共にその拡張方法を紹介していきたいと思います。

環境

Unity 2021.3.15f1

Inspectorウィンドウの拡張

Inspectorウィンドウでは、ゲームオブジェクトやアセットについてのプロパティの編集をすることができます。
拡張としては、ゲームオブジェクトのコンポーネントやマテリアル、シェーダーといったプロパティの編集をより行いやすくしたりすることが可能です。
今回はゲームオブジェクトのコンポーネントを拡張する方法を紹介したいと思います。
拡張する手順としては以下。

  1. コンポーネントを作成

  2. コンポーネントのInspector表示を拡張するクラスを作成

  3. GUIを実装する

1. コンポーネントの作成

MonoBehaviourを継承したコンポーネントを用意します。
今回は以下のようなサンプル用のコンポーネントを作成しました。

SampleComponent.cs

using UnityEngine;

public class SampleComponent : MonoBehaviour
{
    [SerializeField]
    private int _value;

    [SerializeField]
    private string _text;
}

2. コンポーネントのInspector表示を拡張するクラスを作成

SampleComponentクラスのInspector上での表示を拡張するためのクラスを作成します。
作成するうえでは以下の点に注意してください。

  • Editorフォルダ以下にスクリプトを配置する *1

  • UnityEditor.Editorクラスを継承させる必要がある

  • CustomEditor属性を使用して拡張するコンポーネントを指定する

  • OnInspectorGUIをオーバーライドする


SampleComponentEditor.cs

using UnityEngine;
using UnityEditor;

// UnityEditor.Editorを継承する
// CustomEditor属性を使用してSampleComponentの拡張であることを指定する
[CustomEditor(typeof(SampleComponent))]
public class SampleComponentEditor : Editor
{
    // OnInspectorGUIをオーバーライドする
    public override void OnInspectorGUI()
    {

    }
}

*1 : Editorフォルダ以下に配置することでエディタ用のスクリプトとして扱うことができます。

3. GUIの実装を行う

作成した拡張用クラスに対して、Inspector上での表示を実装していきます。
実装はOnInspectorGUIに記述します。
先ほど作成したSampleComponentEditorでは、何も表示されない状態となっています。
これはオーバーライドしたことにより、EditorクラスのOnInspectorGUIが呼ばれなくなってしまったためです。
base.OnInspectorGUI(); を記述することで解消することができますが、今回はサンプルとしてこちらを使用せずにプロパティの表示を行いたいと思います。
GUI上でプロパティを表示するには、serializedObject.FindProperty から該当のSerializedPropertyを取得します。
それを EditorGUILayout.PropertyField に渡すことで表示させることができます。*2
注意点として、EditorGUILayout.PropertyField を呼び出すだけでは表示がされるだけになってしまいます。
serializedObject.Update(); と serializedObject.ApplyModifiedProperties(); を呼び出すことでプロパティをちゃんと更新されるようにもしましょう。
サンプルでは更に、そのプロパティの補足としてラベル表示もしてみました。

SampleComponentEditor.cs

using UnityEngine;
using UnityEditor;

// UnityEditor.Editorを継承する
// CustomEditor属性を使用してSampleComponentの拡張であることを指定する
[CustomEditor(typeof(SampleComponent))]
public class SampleComponentEditor : Editor
{
    // OnInspectorGUIをオーバーライドする
    public override void OnInspectorGUI()
    {
        // こちらを呼び出すことで、SampleComponentのプロパティを表示できます
        // base.OnInspectorGUI();

        // プロパティを最新の状態にする
        serializedObject.Update();

        // serializedObjectからSampleComponentのプロパティを取得
        SerializedProperty valueProperty = serializedObject.FindProperty("_value");
        SerializedProperty textProperty = serializedObject.FindProperty("_text");

        // ラベル表示と共にSampleComponent._valueのプロパティを表示
        EditorGUILayout.LabelField("数値を入力してください");
        EditorGUILayout.PropertyField(valueProperty);
        
        // スペースを空ける
        GUILayout.Space(EditorGUIUtility.singleLineHeight);

        // ラベル表示と共にSampleComponent._textのプロパティを表示
        EditorGUILayout.LabelField("テキストを入力してください");
        EditorGUILayout.PropertyField(textProperty);

        // プロパティへの変更があった場合、それを反映する
        serializedObject.ApplyModifiedProperties();
    }
}

*2 : EditorGUILayout はエディタのGUIのレイアウトについて定義されたクラスです

結果

SampleComponentのInspector上の表示が変わっています。

Hierarchyウィンドウの拡張

Hierarchyウィンドウでは、現在開いているシーンのゲームオブジェクトのリストが表示されており、作成や削除を行うことができます。
Hierarchyウィンドウを拡張する手順は以下。

  1. 拡張に必要なイベントを受け取るためのクラスを作成する

  2. GUIを実装する

1. 拡張に必要なイベントを受け取るためのクラスを作成する

Hierarchyウィンドウを拡張するためには、EditorApplication.hierarchyWindowItemOnGUI のコールバックを受け取る必要があります。
このイベントはHierarchyウィンドウのGUIが更新される際に呼び出され、表示されるリストの項目毎の表示を制御しています。
引数にinstanceId と selectionRect を持っており、instanceId は項目の要素を判別するためのIDとなっており、selectionRect は描画される座標を矩形情報として取得することができます。

SampleHierarchyExtention.cs

using UnityEngine;
using UnityEditor;

public class SampleHierarchyExtention
{
    [InitializeOnLoadMethod]
    private static void Initialize()
    {
        // エディタが起動されたときにイベントを受け取れるようにする
        EditorApplication.hierarchyWindowItemOnGUI += HierarchyWindowItemOnGUI;
    }

    // HierarchyウィンドウのGUIを実装します
    private static void HierarchyWindowItemOnGUI(int instanceId, Rect selectionRect)
    {

    }
}

2. GUIを実装する

Hierarchyの拡張については項目自体は表示されているため、追加の表示や機能を追加する形となります。
今回のサンプルとしては、Inspectorの拡張で作成したSampleComponentを所有しているゲームオブジェクトに対して、「●」が表記されるようにしてみたいと思います。

SampleHierarchyExtention.cs

using UnityEngine;
using UnityEditor;

public class SampleHierarchyExtention
{
    [InitializeOnLoadMethod]
    private static void Initialize()
    {
        EditorApplication.hierarchyWindowItemOnGUI += HierarchyWindowItemOnGUI;
    }

    private static void HierarchyWindowItemOnGUI(int instanceId, Rect selectionRect)
    {
        // instanceIdからゲームオブジェクトを取得します
        GameObject gameObject = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
        if (gameObject == null)
        {
            return;
        }

        // SampleComponentを持っているかの確認
        bool hasComponent = gameObject.GetComponent<SampleComponent>() != null;
        if (!hasComponent)
        {
            return;
        }

        // 「●」のサイズを設定
        Vector2 labelSize = new Vector2(12f, selectionRect.size.y);

        // 「●」を表示するための矩形情報を設定
        Rect labelRect = selectionRect;
        labelRect.x = labelRect.xMax - labelSize.x;
        labelRect.size = labelSize;

        // 「●」を表示
        EditorGUI.LabelField(labelRect, "●");
    }
}

結果

画像では Sample1, Sample2-1, Sample4-2 がSampleComponentを所有しているのがわかります。


Projectウィンドウの拡張

Projectウィンドウはプロジェクトの構造を階層的に表示してくれます。
拡張手順については、Hierarchyウィンドウと似ています。

  1. 拡張に必要なイベントを受け取るためのクラスを作成する

  2. GUIを実装する

1. 拡張に必要なイベントを受け取るためのクラスを作成する

Projectウィンドウでは、EditorApplication.projectWindowItemOnGUI のコールバックを受け取る必要があります。
このイベントもGUI更新時に呼び出され、表示されるリストの項目毎の表示を制御しています。
引数にguid と selectionRect を持っており、guid は項目を識別するためのIDとなっており、selectionRect は描画される座標を矩形情報として取得することができます。

SampleProjectExtention.cs

using UnityEngine;
using UnityEditor;

public class SampleProjectExtention
{
    [InitializeOnLoadMethod]
    private static void Initialize()
    {
        // エディタが起動されたときにイベントを受け取れるようにする
        EditorApplication.projectWindowItemOnGUI += ProjectWindowItemOnGUI;
    }

    // ProjectウィンドウのGUIを実装します
    private static void ProjectWindowItemOnGUI(string guid, Rect selectionRect)
    {

    }
}

2. GUIを実装する

Hierarchyの拡張と同様で、項目自体は表示されているため、追加の表示や機能を追加する形となります。
今回はサンプルとして、アセットのパスをコピーできるボタンを配置したいと思います。

using System.IO;
using UnityEngine;
using UnityEditor;

public class SampleProjectExtention
{
    [InitializeOnLoadMethod]
    private static void Initialize()
    {
        EditorApplication.projectWindowItemOnGUI += ProjectWindowItemOnGUI;
    }

    private static void ProjectWindowItemOnGUI(string guid, Rect selectionRect)
    {
        // guidからアセットのパスを取得します
        string assetPath = AssetDatabase.GUIDToAssetPath(guid);
        if (string.IsNullOrEmpty(assetPath))
        {
            return;
        }

        // Packagesフォルダは除外
        if (assetPath.StartsWith("Packages"))
        {
            return;
        }

        // アイコンが最小化している場合にのみ有効化する
        if (selectionRect.height >= 20f)
        {
            return;
        }

        // ボタンが表示できる幅が十分確保できていなければ表示しない
        if (selectionRect.width < 300f)
        {
            return;
        }

        // ボタンの表示範囲を設定します
        Vector2 buttonSize = new Vector2(80f, selectionRect.size.y);
        Rect buttonRect = selectionRect;
        buttonRect.x = buttonRect.xMax - buttonSize.x;
        buttonRect.size = buttonSize;
        
        // ボタンを配置します
        if (GUI.Button(buttonRect, "Copy Path"))
        {
            // ボタンが押されたときにパスをクリップボードに設定します
            EditorGUIUtility.systemCopyBuffer = assetPath;
        }        
    }
}

結果

画像のように「Copy Path」というボタンが表示され、ボタンを押下するとパスをコピーできます。

SceneViewの拡張

SceneViewに対しても拡張を行うことができます。
拡張する方法としては複数あります。

  • SceneViewクラスを使用する

  • EditorクラスのOnSceneGUIを使用する

今回はSceneViewクラスを使用する方法について紹介します。
拡張する手順としては以下になります。

  1. 拡張に必要なイベントを受け取るためのクラスを作成する

  2. GUIを実装する

1. 拡張に必要なイベントを受け取るためのクラスを作成する

SceneViewの拡張では、SceneView.duringSceneGui のコールバックを受け取ることで可能となります。*3
このイベントはSceneViewのGUI更新時に呼び出され、SceneViewの表示を制御できます。
引数として、SceneView を持っており、SceneViewに対しての操作が可能です。

SampleSceneViewExtention.cs

using UnityEngine;
using UnityEditor;

public class SampleSceneViewExtention
{
    [InitializeOnLoadMethod]
    private static void Initialize()
    {
        // エディタが起動されたときにイベントを受け取れるようにする
        SceneView.duringSceneGui += OnSceneGUI;
    }

    // SceneViewのGUIを実装します
    private static void OnSceneGUI(SceneView view)
    {

    }
}

*3 : SceneView.beforeSceneGui もありますが、GUIについてはSceneView.duringSceneGui こちらを使用します。

2. GUIを実装する

SceneViewでは3Dを表示しているため、2DのGUIを描画する際に開始と終了に宣言を行う必要があることに注意してください。
開始は Handles.BeginGUI(); 、終了は Handles.EndGUI(); を使用します。
HandlesおよびHandleUtilityクラスはSceneViewのGUIを制御するために必要な制御を行ってくれます。
今回のサンプルは選択中のゲームオブジェクトの情報をSceneView上に表示する機能を実装したいと思います。

SampleSceneViewExtention.cs

using UnityEngine;
using UnityEditor;

public class SampleSceneViewExtention
{
    [InitializeOnLoadMethod]
    private static void Initialize()
    {
        SceneView.duringSceneGui += OnSceneGUI;
    }

    private static void OnSceneGUI(SceneView view)
    {
        // Inspectorで表示されているゲームオブジェクトを取得します
        GameObject gameObject = Selection.activeGameObject;
        if (gameObject == null)
        {
            return;
        }
        
        // SceneViewの3D空間の座標からGUI用の座標に変換する
        Vector2 guiPoint = HandleUtility.WorldToGUIPoint(gameObject.transform.position);

        // SceneViewのカメラの範囲外なら表示しない
        Rect viewSize = view.position;
        viewSize.x = 0;
        viewSize.y = 0;
        if (!viewSize.Contains(guiPoint))
        {
            return;
        }

        // GUIの開始を宣言をします
        Handles.BeginGUI();

        // GUIの表示範囲を設定
        Vector2 offset = new Vector2(10f, 10f);
        Rect guiAreaRect = new Rect(guiPoint.x + offset.x, guiPoint.y + offset.y, 200f, 100f);

        // GUIStyleをウィンドウ形式にして、エリアを表示する
        using (new GUILayout.AreaScope(guiAreaRect, "ゲームオブジェクトの情報", GUI.skin.window))
        {
            // ゲームオブジェクトの名前を表示
            GUILayout.Label(gameObject.name);

            // 位置情報を表示
            Vector3 pos = gameObject.transform.position;
            GUILayout.Label(string.Format("x:{0:f2} y:{1:f2} z:{2:f2}", pos.x, pos.y, pos.z));
        }

        // GUIの終了を宣言します
        Handles.EndGUI();
    }
}

結果

画像では、シーンに Cube, Sphere, Cylinder を配置し、それを選択するとゲームオブジェクトの情報として、名前と位置情報が表示されています。

自作ウィンドウの作成

最後に自作のウィンドウを作成する方法を紹介したいと思います。
手順としては以下になります。

  1. 自作ウィンドウとなるクラスを作成する

  2. メニューから呼び出せるようにする

  3. GUIを実装する

1. 自作ウィンドウとなるクラスを作成する

自作のウィンドウを作成する際は、EditorWindowクラスを継承したクラスを作成します。

SampleEditorWindow.cs

using UnityEngine;
using UnityEditor;

// EditorWindowを継承させます
public class SampleEditorWindow : EditorWindow
{
}

2. メニューから呼び出せるようにする

自作したウィンドウはエディタ上から開く場所を用意しなければなりません。
そんな時に役立つのがMenuItem属性です。
MenuItem属性はUnityのメニューに独自の項目を追加することができます。
今回はメニューに「Tools」を追加し、「SampleWindow」が押下されたら、ウィンドウが開くようにしたいと思います。
ウィンドウを開くには、EditorWindow.GetWindow からウィンドウを取得し、EditorWindow.Show(); を実行することで開くことができます。

SampleEditorWindow.cs

using UnityEngine;
using UnityEditor;

public class SampleEditorWindow : EditorWindow
{
    // メニューに「Tools -> SampleWindow」を追加
    // SampleWindow が押下されるとこのメソッドが呼び出されます
    [MenuItem("Tools/SampleWindow")]
    private static void OpenWindow()
    {
        SampleEditorWindow window = GetWindow<SampleEditorWindow>();
        window.Show();
    }
}

3. GUIを実装する

GUIを実装するにはOnGUIメソッドを作成します。
今回はサンプルとして、Assetsフォルダ以下にあるアセットを検索し、そのパスを検索結果として表示するウィンドウを作成しようと思います。

SampleEditorWindow.cs

using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEditor;

public class SampleEditorWindow : EditorWindow
{
    // フィルター
    private string _searchFilter = string.Empty;

    // 検索結果
    private readonly List<string> _searchResult = new List<string>();

    // スクロール位置
    private Vector2 _scrollPosition = Vector2.zero;

    [MenuItem("Tools/SampleWindow")]
    private static void OpenWindow()
    {
        SampleEditorWindow window = GetWindow<SampleEditorWindow>();
        window.Show();
    }

    // GUIを実装します
    private void OnGUI()
    {
        // ボタンの幅の指定
        GUILayoutOption[] buttonOption = new GUILayoutOption[]
        {
            GUILayout.Height(40)
        };

        // 検索用フィルターを入力するフィールドを作成
        _searchFilter = EditorGUILayout.TextField("検索フィルター", _searchFilter);
        
        // スペース
        GUILayout.Space(EditorGUIUtility.singleLineHeight);

        // 検索用ボタンを配置
        if (GUILayout.Button("検索", buttonOption))
        {
            // アセットの検索を実行
            string[] guids = AssetDatabase.FindAssets(_searchFilter, new[]{"Assets"});

            // 検索結果としてアセットのパスを取得する
            _searchResult.Clear();
            foreach (string guid in guids)
            {
                string assetPath = AssetDatabase.GUIDToAssetPath(guid);
                if (File.Exists(assetPath))
                {
                    _searchResult.Add(assetPath);
                }
            }
        }

        // スペース
        GUILayout.Space(EditorGUIUtility.singleLineHeight * 2f);

        // ラベル表示
        GUILayout.Label("検索結果");

        // スペース
        GUILayout.Space(EditorGUIUtility.singleLineHeight);

        if (_searchResult.Count != 0)
        {
            // スクロールビューの開始
            _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition);

            // 検索結果を表示
            foreach (string resule in _searchResult)
            {
                GUILayout.Label(resule);
            }

            // スクロールビューの終了
            EditorGUILayout.EndScrollView();
        }
    }
}

結果

画像では、Assets以下にあるプレファブを抽出して表示しています。
検索フィルターについては、AssetDatabase.FindAssets で指定するものをそのまま設定するようになっています。
また、検索結果がウィンドウに収まりきらないこともあると思ったので、スクロール機能も実装しておきました。

おわりに

紹介できていないものもありますが、基本的な拡張方法は網羅できたのではないでしょうか?
より深いEditor拡張を行う場合は、GUIの実装に更に目を向けなければなりません。
今回のサンプルではありませんでしたが、GUIのイベントからマウスの入力やキーボード入力を受け取ったり、ドラッグアンドドロップでファイル操作したりなども可能です。
また、GUIを綺麗に見せるため GUI, GUILayout, EditorGUI, EditorGUILayout のリファレンスは一度目を通してみるのもいいと思います。
Unityをより便利に使えるようにして、開発効率の向上を図り、チームに貢献していきましょう。

明日はクライアントエンジニアのもっさんによる「InputBlockerとusingステートメント」となっております!
お楽しみに!