🤚

[Unity]Vector3やEnumをSceneView上で編集できるようにする

2023/12/25に公開

この記事は、神戸電子専門学校 ゲーム技研部 Advent Calendar 2023の25日目の記事です。
https://qiita.com/advent-calendar/2023/kdgamegiken

はじめに

UnityエディタのSceneViewを拡張して、下画像のようなハンドルでVector3の値を編集できるようにする記事です。
Unityでの開発をちょっぴり便利に&効率化できます。

つまり、どう役に立つの?

「AI用のWayPoint(巡回地点)を配置したい」という状況を想定してみます。

このとき、どうやってWayPointを配置するか、配置しやすくするかという手段について考えてみます。

最も簡単な方法は、WayPointの地点ごとに空のGameObjectを配置して、そのTransformを取ってくる(Listに登録などする) ことです。

GameObjectなので、特別なことをせずとも、選択するとハンドルが表示されますが、
一方で、WayPointの数だけGameObjectが増えることになります。
WayPointが100個であれば、100個のGameObject、1000個ならば1000個のGameObjectです。
本当は大量のGameObjectである必要はなく、Vector3のListで充分なはずです。

…ではありますが、Vector3型のフィールドやプロパティに対してはハンドルが表示されません。
本来は頑張って数値を直接調整して、位置を調整するしかありません。

これをエディタ拡張でハンドルが描画されるようにして便利にしよう!!!、というお話です!

動作確認環境

Unity 2022.3.12f1
Windows 11 22H2

編集する対象

今回編集対象とするクラスは、さきほど例に挙げた、キャラクターAIが巡回する地点 「WayPoint」 を保持するクラスにします。

ScriptableObjectです。(MonoBehaviourでも同様のことは可能です。)

WayPoints.csを、適当な場所に作成し、以下のように書いておきます。

WayPoints.cs
using System.Collections.Generic;
using UnityEngine;

// AIの目印となるWayPointのデータ
[System.Serializable]
public class WayPoint
{
    public enum TagTypes
    {
        None,
        Start,
        Treasure,
        Goal,
    }

    public Vector3 Position = Vector3.zero;
    public TagTypes Tag = TagTypes.None;
}

// WayPointのデータをまとめたScriptableObject
[CreateAssetMenu(fileName = "WayPoints", menuName = "MyGame/WayPoints", order = 1)]
public class WayPoints : ScriptableObject
{
    [field: SerializeField]
    public List<WayPoint> Points { get; private set; } = new List<WayPoint>();
}

WayPointに情報を持たせたいかもなぁと思ったので、フィールドTagも入れておきました。

ひとまず、ScriptableObjectWayPointsのファイルを作成しておきます。

開いてみると、WayPointsを追加・編集できるはずです。

しかし、いまはまだハンドルが無いので数値を直接弄って編集するしかない状態です。
とても編集する気にはなれません…

ハンドルを表示して編集できるようにする

Assets/Editor/に、スクリプトWaiPointsEditor.csを新規作成します。

以下の通りに書きます。
(ここは変に小分けにして説明しても長々しくなるだけなので、一度に行きます)

WayPointsEditor.cs
using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(WayPoints))]
public class WayPointsEditor : Editor
{
    // 操作する対象のScriptableObject
    private static WayPoints instance = null;

    // 選択されたとき
    private void OnEnable()
    {
        instance = target as WayPoints;
        SceneView.duringSceneGui += OnSceneGUI;
    }

    // 選択が解除されたとき
    private void OnDisable()
    {
        instance = null;
        SceneView.duringSceneGui -= OnSceneGUI;
    }

    // SceneViewのGUIを描画する
    private static void OnSceneGUI(SceneView sceneView)
    {
        if (instance == null) return;

        // WayPointの位置をSceneView上で変更できるハンドルを表示する
        for (int i = 0; i < instance.Points.Count; i++)
        {
            var wayPoint = instance.Points[i];

            // WayPointの位置を取得する
            Vector3 pos = wayPoint.Position;

            // ハンドルを表示する
            EditorGUI.BeginChangeCheck();
            pos = Handles.PositionHandle(pos, Quaternion.identity);

            // WayPointの位置が変更されたら反映する
            if (EditorGUI.EndChangeCheck())
            {
                wayPoint.Position = pos;
                EditorUtility.SetDirty(instance);
            }
        }
    }
}

Handles.PositionHandleでハンドルを表示し、EditorGUI.BeginChangeCheckEditorGUI.EndChangeCheckで編集を検知しています。
また、EditorUtility.SetDirtyで、編集されたことをエディタに通知してます。

ここで、さきほど作成したScriptableObjectWayPointsをクリックして選択してみると、パラメータ通りの位置にハンドルが表示されていることが確認できます。
また、ハンドルを操作することで、値が編集されるようにもなっているはずです。

Undo/Redo対応

ついでにUndo/Redo対応もします。

今回のように、ScriptableObjectMonoBehaviour等、Unity.Objectを継承した型を扱う場合はとても簡単です。
Undo.RecordObjectを呼び出すだけです。

WayPointsEditor.cs
...
if (EditorGUI.EndChangeCheck())
{
    // 追記-------------------------
+    Undo.RecordObject(instance, "Move WayPoint");
    // -----------------------------
    wayPoint.Position = pos;
    EditorUtility.SetDirty(instance);
}
...

ハンドルの位置にラベル(文字)を表示する

今の状態ではどれが何のハンドルか識別することが困難なので、ハンドルがある位置に文字を表示してみます。

Handles.Labelでラベルを表示できます。

OnSceneGUI関数の末尾に追記します。

WayPointsEditor.cs
...
// ラベルを表示する
for (int i = 0; i < instance.Points.Count; i++)
{
    var wayPoint = instance.Points[i];

    // WayPointの位置を取得する
    Vector3 pos = wayPoint.Position;

    // WayPointの位置にラベルを表示する
    Handles.Label(pos, $"\n\n{i}: {wayPoint.Tag}");
}
...

WayPointsのList上のインデックス番号と、Tagに設定されている値を表示しています。
はじめに改行\nを2つ入れることで文字の位置を少しだけ下にずらして、ハンドルと被って見えづらくなることを防いでいます。

ハンドルの下に文字が出てきました。

ラベルの文字色(スタイル)を設定する

せっかく色々表示できるようにしましたが、まだパッと見ただけでは違いが分かりません。
Tagによって文字色が変わるようにしてみます。

文字色は、Handles.Label()を呼び出す際に引数styleを渡すことで指定できます。

さきほどの実装を、以下の通りに書き換えます。

WayPointsEditor.cs
...
// WayPointの位置にラベルを表示する
GUIStyle style = new GUIStyle();
switch (wayPoint.Tag)
{
    case WayPoint.TagTypes.None:
        style.normal.textColor = Color.white;
        break;
    case WayPoint.TagTypes.Start:
        style.normal.textColor = Color.green;
        break;
    case WayPoint.TagTypes.Treasure:
        style.normal.textColor = Color.yellow;
        break;
    case WayPoint.TagTypes.Goal:
        style.normal.textColor = Color.red;
        break;
}
Handles.Label(pos, $"\n\n{i}: {wayPoint.Tag}", style);
...

Tagによって色が変わるようになりました。

ドロップダウンでTypeも編集できるようにしてみる

ここまでで充分ではあります。
しかし、ここまで色々できると、もっと色々やりたくなるのが人の性。

というわけで、WayPointのTypeも、SceneView上で編集できるようにしてみます。

ドロップダウンを表示する

Handles.BeginGUI() Handles.EndGUI()を使うと、通常のエディタ拡張と同様に、EditorGUIを使って多様なUIを表示することができます。
(Handles.BeginGUI()Handles.EndGUI()の間に、EditorGUIを使ったUIを実装します)

あとは、EditorGUI.EnumPopup()を呼び出し、ドロップダウンを表示します。

なお、表示する座標はスクリーン座標で指定する必要があります。
スクリーン座標の計算は、HandleUtility.WorldToGUIPointWithDepth()でワールド座標を画面座標に変換するだけです。

さきほどラベルを表示したforループの中に追記します。
Undo/Redo対応も併せてやってしまいます。

WayPointsEditor.cs
...
Handles.Label(pos, $"\n\n{i}: {wayPoint.Tag}", style);
// 追記-------------------------
+Handles.BeginGUI();
+// コンボボックス
+// スクリーン座標に変換する
+var screenPos = HandleUtility.WorldToGUIPointWithDepth(pos);
+// コンボボックスを表示する
+EditorGUI.BeginChangeCheck();
+var rect = new Rect(screenPos.x, screenPos.y + 10, 100, 20);
+var editedTag = (WayPoint.TagTypes)EditorGUI.EnumPopup(rect, wayPoint.Tag);
+// 変更されたら反映する
+if (EditorGUI.EndChangeCheck())
+{
+    Undo.RecordObject(instance, "Edit Destination");
+    wayPoint.Tag = editedTag;
+    EditorUtility.SetDirty(instance);
+}
+
+Handles.EndGUI();
// -----------------------------
...

後ろにあるときは表示しないようにする

うまく動いているように見えますが、まだ問題があります。

ハンドルの位置に対して後ろを向くと…

ドロップダウンが、ハンドルが無いはずの場所に表示されてしまいます。

正確には、SceneViewのカメラの真後ろにあるときにも表示されているようです。
真後ろにあるときは、表示しないようにする必要があります。

カメラに対して前後どちらにあるかは、さきほどHandleUtility.WorldToGUIPointWithDepth()で取得してきた画面座標のz成分の正負を調べることで判定できます。
WorldToGUIPointWithDepthという関数名の通り、z成分に深度が格納されています)

WayPointsEditor.cs
...
// コンボボックス
// スクリーン座標に変換する
var screenPos = HandleUtility.WorldToGUIPointWithDepth(pos);
// 追記-------------------------
+// カメラの後ろには表示しない
+if (screenPos.z < 0) continue;
// -----------------------------
...

ここまでのソース全文

長いので折りたたんでいます。

WayPoints.cs
WayPoints.cs
using System.Collections.Generic;
using UnityEngine;

// AIの目印となるWayPointのデータ
[System.Serializable]
public class WayPoint
{
    public enum TagTypes
    {
        None,
        Start,
        Treasure,
        Goal,
    }

    public Vector3 Position = Vector3.zero;
    public TagTypes Tag = TagTypes.None;
}

// WayPointのデータをまとめたScriptableObject
[CreateAssetMenu(fileName = "WayPoints", menuName = "MyGame/WayPoints", order = 1)]
public class WayPoints : ScriptableObject
{
    [field: SerializeField]
    public List<WayPoint> Points { get; private set; } = new List<WayPoint>();
}
WayPointsEditor.cs
WayPointsEditor.cs
using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(WayPoints))]
public class WayPointsEditor : Editor
{
    // 操作する対象のScriptableObject
    private static WayPoints instance = null;

    // 選択されたとき
    private void OnEnable()
    {
        instance = target as WayPoints;
        SceneView.duringSceneGui += OnSceneGUI;
    }

    // 選択が解除されたとき
    private void OnDisable()
    {
        instance = null;
        SceneView.duringSceneGui -= OnSceneGUI;
    }

    // SceneViewのGUIを描画する
    private static void OnSceneGUI(SceneView sceneView)
    {
        if (instance == null) return;

        // WayPointの位置をSceneView上で変更できるハンドルを表示する
        for (int i = 0; i < instance.Points.Count; i++)
        {
            var wayPoint = instance.Points[i];

            // WayPointの位置を取得する
            Vector3 pos = wayPoint.Position;

            // ハンドルを表示する
            EditorGUI.BeginChangeCheck();
            pos = Handles.PositionHandle(pos, Quaternion.identity);

            // WayPointの位置が変更されたら反映する
            if (EditorGUI.EndChangeCheck())
            {
                Undo.RecordObject(instance, "Move WayPoint");
                wayPoint.Position = pos;
                EditorUtility.SetDirty(instance);
            }
        }

        // ラベルを表示する
        for (int i = 0; i < instance.Points.Count; i++)
        {
            var wayPoint = instance.Points[i];
            
            // WayPointの位置を取得する
            Vector3 pos = wayPoint.Position;

            // WayPointの位置にラベルを表示する
            GUIStyle style = new GUIStyle();
            switch (wayPoint.Tag)
            {
                case WayPoint.TagTypes.None:
                    style.normal.textColor = Color.white;
                    break;
                case WayPoint.TagTypes.Start:
                    style.normal.textColor = Color.green;
                    break;
                case WayPoint.TagTypes.Treasure:
                    style.normal.textColor = Color.yellow;
                    break;
                case WayPoint.TagTypes.Goal:
                    style.normal.textColor = Color.red;
                    break;
            }
            Handles.Label(pos, $"\n\n{i}: {wayPoint.Tag}", style);

            Handles.BeginGUI();

            // コンボボックス
            // スクリーン座標に変換する
            var screenPos = HandleUtility.WorldToGUIPointWithDepth(pos);
            // カメラの後ろには表示しない
            if (screenPos.z < 0) continue;

            // コンボボックスを表示する
            EditorGUI.BeginChangeCheck();
            var rect = new Rect(screenPos.x, screenPos.y + 10, 100, 20);
            var editedTag = (WayPoint.TagTypes)EditorGUI.EnumPopup(rect, wayPoint.Tag);
            // 変更されたら反映する
            if (EditorGUI.EndChangeCheck())
            {
                Undo.RecordObject(instance, "Edit Destination");
                wayPoint.Tag = editedTag;
                EditorUtility.SetDirty(instance);
            }

            Handles.EndGUI();
        }
    }
}

おまけ:ほかにも描画したい

今回はハンドルの位置に文字を表示しましたが、ほかにも色々と表示することが出来ます。

一部の例を紹介しますが、詳しくは公式リファレンスをご確認ください。

https://docs.unity3d.com/ja/2022.3/ScriptReference/Handles.html

球体と線を描画

例えばもし巡回するポイントであれば、球を表示して目立たせたり、線で結んで順路を分かりやすくすることが出来そうです。

// ハンドルの位置に球体を表示する
Handles.color = new Color(1f, 0f, 0f, 0.2f);
Handles.SphereHandleCap(0, pos, Quaternion.identity, 5f, EventType.Repaint);
Handles.color = Color.white; // 色をもとに戻しておく
// 線で結ぶ
if (i > 0)
{
    Handles.DrawLine(instance.Points[i - 1].Position, instance.Points[i].Position);
}

アイコンを表示

さきほどのドロップダウン表示と同じように、Handles.BeginGUI() Handles.EndGUI()を使うことで、通常のエディタ拡張と同様にアイコンを表示できます。

// アイコン画像を表示する
Handles.BeginGUI();
// SceneViewのワールド座標をGUI座標に変換する
var guiPos = HandleUtility.WorldToGUIPoint(pos);
// GUI座標にアイコン画像を表示する
var icon = EditorGUIUtility.IconContent("d_console.erroricon@2x");
var rect = new Rect(guiPos.x, guiPos.y, 32, 32);
GUI.Label(rect, icon);
Handles.EndGUI();

ここでのアイコンは、EditorGUIUtility.IconContentを使ってUnityエディタのビルトインのアイコンを取得しています。
以下の一覧を参考にしました。

https://github.com/halak/unity-editor-icons/tree/master

おわりに

後半ほとんど余談だらけでしたが、もうちょっとだけ余談をしつつ締めます。

とても便利ではあるのですが、要素を増やしていくと、ごちゃごちゃして見づらくなってしまいます。
下画像は、実際に開発でのスクリーンショットです。

そのため、実際の開発では、絞り込み表示機能や、最大表示距離機能、ドロップダウンなどの非表示機能など、表示する情報量を制限する機能を実装することとで、扱いやすくもしていました。(下画像右下のオーバーレイ)

この記事が、Unityでのゲーム開発を良い感じに効率化する一助になれば幸いです。

神戸電子専門学校ゲーム技術研究部

Discussion