Custom EditorでシーンビューにHandleやGUIを出して調整作業を効率化する

Unityでコンポーネントのプロパティ値を調整するとき、特に座標値などを扱う場合は、インスペクター上の値とにらめっこしながら調整するより、シーンビュー上にGUIなどを出して直接調整するほうが効率的に作業できることがあります。また、シーンビュー上に調整用のGUIが出ていると何かと便利なこともあります。

下記のようなコンポーネントの編集を例にとって、これをシーンビュー上で編集するようなGUIを検討してみることにします。

using UnityEngine;

public class Enemy : MonoBehaviour
{
    public string Name => _name;
    public Vector3 CameraFollowOffset => _hpBarOffset;

    [SerializeField]
    [Header("敵の名前")]
    private string _name;
    [SerializeField]
    [Header("敵のHPバーを表示する位置オフセット")]
    private Vector3 _hpBarOffset;
}

Custom Editorの基本形

コンポーネントを編集するGUIを制作する方法は様々ですが、今回はCustom Editorを利用してみます。

Unity - Manual: Custom Editors

先程の Enemy コンポーネントのCustom Editorの基本形を示します。

using UnityEditor;

[CustomEditor(typeof(Enemy))]
public class EnemyEditor : Editor
{
    private SerializedProperty _name;
    private SerializedProperty _cameraFollowPosition;

    private void OnEnable()
    {
        // serializedObjectは、ヒエラルキーで選択中の
        // EnemyコンポーネントのSerializedObject
        _name = serializedObject.FindProperty("_name");
        _cameraFollowPosition = serializedObject.FindProperty("_cameraFollowPosition");
    }

    public override void OnInspectorGUI()
    {
        // インスペクターの表示をカスタマイズしたいときはこっちで行う

        // これを呼び出すと、標準で用意されるインスペクターがそのまま表示される
        base.OnInspectorGUI();

        // 独自のインスペクター表示を行いたいときは、上記を呼び出さずに
        // 自前でビューをつくるなどする
    }

    public void OnSceneGUI()
    {
        // シーンビューでGUIを描画したいときはこっちで行う
    }
}

Custom Editorは編集したいコンポーネントに対して1対1で作成します。今回は Enemy コンポーネントと対となる EnemyEditor を作成しています。UnityEditor.Editor クラスを継承し、さらに CustomEditor 属性を指定しておきます。

OnEnable でCustom Editorで編集したいシリアライズ可能なフィールドから SerializedProperty を初期化しておきます。フィールドやセッターが public であれば直接して編集しても良いのですが、エディター実装だけのためにそれらを public にするのは良くないので、特別な理由がない限りは SerializedProperty でフィールドを編集するようにします。

private void OnEnable()
{
    // serializedObjectは、ヒエラルキーで選択中の
    // EnemyコンポーネントのSerializedObject
    _name = serializedObject.FindProperty("_name");
    _cameraFollowPosition = serializedObject.FindProperty("_cameraFollowPosition");
}

あとは OnInspectorGUIOnSceneGUI を実装してインスペクターなどの見た目をカスタマイズしていきます。今回は、シーンビュー上でコンポーネントを編集するビューを作成していきたいので、 OnSceneGUI に処理を記述していきます。

Handles

Unityにはシーンビュー上で座標値やスケールなどを調整する3D GUIを描画する Handles という機能が標準で提供されています。

Unity - Scripting API: Handles

名前を聞くと馴染みがないかもしれませんが、ゲームオブジェクトの位置や回転などを調整するときに出てくるxyz方向に向いた矢印のGUIもHandlesによって提供されています。

(コードそのものを確認できていませんが、Handlesに同じGUIを描画する機能があるのでおそらく同じ機能を呼び出しているはず…)

87882D94D5CE8A33DE6DADFE614712B0

この機能はユーザーも利用できます。さきほどの Enemy クラスの _hpBarOffset の位置調整を、上図の位置調整用のHandleを用いて行えるようにしてみます。まずはコード全文を示します。

public void OnSceneGUI()
{
    // シーンビューでGUIを描画したいときはこっちで行う
    var enemy = target as Enemy;

    serializedObject.Update();

    var hpBarOffsetWorldPosition =
        enemy.transform.position +
        enemy.transform.localToWorldMatrix.MultiplyVector(enemy.HPBarOffset);

    EditorGUI.BeginChangeCheck();
    var newHPBarOffset = Handles.PositionHandle(
        hpBarOffsetWorldPosition,
        enemy.transform.rotation
    );
    if (EditorGUI.EndChangeCheck())
    {
        Undo.RecordObject(target, "Change Hp Bar Offset Position");
        _hpBarOffset.vector3Value =
            enemy.transform.worldToLocalMatrix.MultiplyVector(
                newHPBarOffset - enemy.transform.position
            );
    }

    serializedObject.ApplyModifiedProperties();
}

位置調整のGUIは Handles.PositionHandle を呼び出すことで利用できます。

Unity - Scripting API: Handles.PositionHandle

引数として現在の位置とGUIの回転をそれぞれワールド座標で取り、戻り値としてユーザー操作によって移動した後の位置をワールド座標で返却します。

このとき、実際に移動したかどうかを EditorGUI.BeginChangeCheckEditorGUI.EndChangeCheck によって判定できるので、移動後のみ値を更新するようにします。また、Undo用の設定も合わせて行います。

var hpBarOffsetWorldPosition =
    enemy.transform.position +
    // 現在のenemyの位置+オフセットにenemyの位置を反映させる
    enemy.transform.localToWorldMatrix.MultiplyVector(
        enemy.HPBarOffset
    );

var newHPBarOffset = Handles.PositionHandle(
    hpBarOffsetWorldPosition,
    enemy.transform.rotation
);
// 更新検知
if (EditorGUI.EndChangeCheck())
{
    // Undo用のコマンドをつんでおく
    Undo.RecordObject(target, "Change Hp Bar Offset Position");
    // ローカルオフセットに戻す
    // hpBarOffsetWorldPositionの逆操作
    _hpBarOffset.vector3Value =
        enemy.transform.worldToLocalMatrix.MultiplyVector(
            newHPBarOffset - enemy.transform.position
        );
}

これで実装ができました。実際に Handles.PositionHandle によってオフセットを調整する様子を下図に示します。

Handles.DrawingScope でオブジェクトのローカル座標で処理を記述する

オフセット更新のために Handles.PositionHandle に渡す引数の位置や回転、戻り値の位置がすべてワールド座標なのでオフセットからワールド座標に変換して、また、受け取る座標もワールド座標なのでそこからローカル座標に戻してオフセットに代入していました。

// PositionHandleの座標値はワールド座標なので座標変換
var hpBarOffsetWorldPosition =
    enemy.transform.position +
    // 現在のenemyの位置+オフセットにenemyの位置を反映させる
    enemy.transform.localToWorldMatrix.MultiplyVector(
        enemy.HPBarOffset
    );
var newHPBarOffset = Handles.PositionHandle(
    hpBarOffsetWorldPosition,
    enemy.transform.rotation
);

// オフセットはそのオブジェクトのローカル座標なので
// ワールド座標からローカル座標に変換して代入
_hpBarOffset.vector3Value =
    enemy.transform.worldToLocalMatrix.MultiplyVector(
        newHPBarOffset - enemy.transform.position
    );

上記のコードのようにローカルとワールド座標の変換を意識してコードを書くと煩雑になって分かりづらくなります。

これを解決するために Handles には Handles.matrix というstaticな変数が用意されています。ここに値を代入しておくと、 Handles の操作に Handles.matrix の行列(または逆行列)が乗算されます。

利用例としてはゲームオブジェクトの Transform.localToWorldMatrix を代入すると、ハンドルに渡す座標や回転を、そのゲームオブジェクトのローカル座標で扱われるようになるなどです。

ちょっとややこしいのですが、さきほどのオフセットの編集の実装を Handles.matrix を用いると、下記コードで置き換えることができます。

using (new Handles.DrawingScope(enemy.transform.localToWorldMatrix))
{
    // このusingスコープ中は、Handles.matrixに
    // enemy.transform.localToWorldMatrixが代入されている

    // 座標・回転・スケールは enemy のローカル空間で扱う事ができる
    EditorGUI.BeginChangeCheck();
    var newHpBarOffset = Handles.PositionHandle(
        _hpBarOffset.vector3Value,
        Quaternion.identity
    );
    Handles.Label(_hpBarOffset.vector3Value, "HP Bar Offset");
    if (EditorGUI.EndChangeCheck())
    {
        Undo.RecordObject(target, "Change Hp Bar Offset Position");
        _hpBarOffset.vector3Value = newHpBarOffset;
    }
}

usingスコープ中はハンドルの操作がすべて enemy のローカル座標で行われるため、ワールド座標とローカル座標の変換がコード中から消えて、かなりすっきりとしたかと思います。

シーンビューにGUIを出す

次に、シーンビュー上にGUIを出して、コンポーネント編集の効率化してみたいと思います。

シーンビュー上にGUIを出す方法もいくつかありますが、 OnSceneGUIHandles.BeginGUI を呼び出すと、 Handles.EndGUI を呼び出す間はIMGUIの表示を行えるようになります。

試しに、下図のような Enemy._name をシーンビュー上で直接編集するGUIを作ってみます。

CD0FC504D4750897F4D5192355C3AF76

まずはコード全文を示します。

// ワールド座標からGUI座標を取得する
var enemyGUIPosition = HandleUtility.WorldToGUIPoint(
    enemy.transform.position
);

// BeginGUI~EndGUI間ではGUI描画を行うことができる
Handles.BeginGUI();
// 試しにEnemy._nameを編集するGUIをシーンビュー上に配置
_name.stringValue = EditorGUI.TextField(
    new Rect(
        // 3Dの位置から少し下に表示してみる
        enemyGUIPosition + new Vector2(-100, 100),
        new Vector2(200, 20)
    ),
    _name.stringValue
);
Handles.EndGUI();

GUIで、3Dオブジェクトの位置を基準になにか出す、ということを行うには、3Dオブジェクトのワールド座標から対応するGUI座標の位置を取得する必要があります。

HandleUtility.WorldToGUIPoint を用いると、ゲームオブジェクトの位置から対応するGUI座標上の位置を取得できます。

// 対象のenemyのワールド座標からGUI座標上の位置を取得している
var enemyGUIPosition = HandleUtility.WorldToGUIPoint(
    enemy.transform.position
);

あとはGUI描画を行います。 テキストフィールドを描画して Enemy._name を編集できるようにしてみます。

// BeginGUI~EndGUI間ではGUI描画を行うことができる
Handles.BeginGUI();
_name.stringValue = EditorGUI.TextField(
    new Rect(
        // 3Dの位置から少し下に表示してみる
        enemyGUIPosition + new Vector2(-100, 100),
        new Vector2(200, 20)
    ),
    _name.stringValue
);
Handles.EndGUI();

上記の実装で、下図のように名前編集のテキストフィールドをシーンビュー上に表示することができました。

まとめ

シーンビュー上で調整用のGUIや3Dを表示する方法や、Handlesを扱うときの座標の扱いを少し簡単にする Handles.DrawingScope の使い方などについて解説しました。

調整用のビューの実装にこだわるのはなかなか大変で時間もかかりますが、上手に作るとコンテンツ制作の効率化に大きく貢献します。

そのための手助けとして、この記事が役に立つと幸いです。