🙌

Transformインスペクタの拡張

2023/12/28に公開

前書き

Unityとはかれこれ長いお付き合いをしているのですが、これといったTransformのインスペクタ拡張に出会ったことがありません。需要がないんでしょうか。あまり多くの望みがあるわけではなく、ワールド位置が入力できたり、LossyScaleが見れたりすれば良いのですが有名なものとかがいまだにない気がします。
インスペクタの拡張は割と簡単にできてしまうので、僕も例に漏れず自身で拡張していました。5年ほど前に作ったものをずっと使っていて、デフォルトのTransformレイアウトのことなど露知らず、最近になってScaleのところ見慣れないアイコンがあることを知ったのです。

これ
なんだこれ!?!?!?!?使いたいぞうううう!
とはいっても、愛着がある拡張オレオレインスペクタから離れることができず、この謎機能を取り入れるべく、数年ぶりに古のフォルダに眠るCustomTransformInspector.csの修正に入ったわけで御座います。

秘密だよ~ん

Transformの拡張、もといComponentのインスペクタ拡張は以下の御まじないから始まります。

CustomEditor(typeof(Transform))

UnityEditor.Editorを継承したクラスにこの属性をあて、OnInspectorGUI()をoverrideして、簡単便利クラスであるEditorGUILayoutを使って書いていくのが主流でしょう。
しかし、既存のコンポーネントについてBase.OnInspectorGUI()などと呼び出しても、デフォルトのレイアウトは表示されないのです。表示されるのはSreializableなメンバーがシンプルに表示されるだけ。スケールの謎アイコンくんに至ってはトグルになっちゃいます。

これは、UnityEditor.Editorを継承しているのが原因です。
Unityが用意したデフォルトのレイアウトを使いたくば、それを実装したクラスを継承せよ、ということなのです。どこかにUnity様が作ったCustomTransformInspector.csがあるはずなのです。しかし、ctrl+spaceをいくら連打してもそれらしいエディタクラスはなく、ググってみると・・・・

秘密(internal)だよ~ん。ズコーーーーーー!!

ということで、デフォルトのレイアウトに倣ってPRS三姉妹を実装するのも面倒くさいし、公開されてるコード丸コピだとなんか公開しにくいしってことでないのかななんて思いました。たった3つのフィールドなのにすごい複雑です。
自身のコードでも実装しなおしてました。当時丸コピすればいいものの出来なかったか、恐らくgithubの当該コードの存在に気づいていなかったのでしょう。
実装し直すといっても複数選択されたときなどのことを考えると、EditorGUILayout.Vector3Field3連発じゃい!などと簡単には行きません。

workaround丸コピ

丸コピするかーと思った矢先、internalクラスでもリフレクションを通してデフォルトのレイアウトを実現する方法を紹介したスレッドを見つけました。
https://forum.unity.com/threads/extending-instead-of-replacing-built-in-inspectors.407612/
https://forum.unity.com/threads/recttransform-custom-inspector.517878/
まるまる参考にさせていただき、丸コピは避けられました。やった!!!

仕組み

まず、エディタのインスタンスを取得します。

Editor defaultEditor;
void OnEnable()
{
    defaultEditor = Editor.CreateEditor(targets, Type.GetType("UnityEditor.TransformInspector, UnityEditor"));
}

取得したインスタンスは何にも紐付いておらず、ライフサイクルイベントが一部発生しません。このままだと、何やらやばそうなメモリリークのエラーが大量にでます。
呼ばれるべきイベントが呼ばれていないためのようなので、自身のカスタムクラスでイベントを受けて、インスタンスへ伝搬させます。また自身でDestroyする必要もあるようです。

void OnDisable()
{
    MethodInfo disableMethod = defaultEditor.GetType().GetMethod("OnDisable", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
    disableMethod?.Invoke(defaultEditor,null);
    DestroyImmediate(defaultEditor);
}

これら下準備をして晴れて、追加したい機能を実装していくことができます。

public override void OnInspectorGUI()
{
    defaultEditor.OnInspectorGUI();
    // これ以降に追加したい機能を書く   
}

作ったもの


ちょっとずれている・・・
少し紹介させてください。

W Position, W Rotation, LossyScale

普通にワールド座標見たいですよ!なんでビルトインに組み込まれてないのか・・・Transformは全てのゲームオブジェクトに必須だから可能な限り縦幅をコンパクトにしたかったからなのか、でもRectTransformは・・・あれこれ考えても仕方がないので表示します。

protected void _OnGUIWorldPosition()
{
    var width_default = EditorGUIUtility.labelWidth;
    foreach (var i in _targets)
        for (int j = 0; j < 3; ++j)
            _mixed[j] |= !Mathf.Approximately(i.position[j], _target.position[j]);
    using (var scope = new EditorGUILayout.HorizontalScope())
    {
        EditorGUILayout.PrefixLabel("W Position");
        EditorGUIUtility.labelWidth = 12;//EditorStyles.label.CalcSize(new GUIContent("X")).x;
        for (int i = 0; i < 3; ++i)
        {
            EditorGUI.showMixedValue = _mixed[i];
            var value = EditorGUILayout.FloatField(_XYZ[i], _target.position[i]);
            if (GUI.changed)
            {
                Undo.RegisterCompleteObjectUndo(targets, $"Set Position in {(Selection.count == 1 ? target.name : "Selected Objects")}");
                foreach (var j in _targets)
                {
                    var p = j.position;
                    p[i] = value;
                    j.position = p;
                }
                serializedObject.SetIsDifferentCacheDirty();
                break;
            }
        }
        if (Event.current.type == EventType.ContextClick && scope.rect.Contains(Event.current.mousePosition))
        {
            var context = new GenericMenu();
            context.AddItem(new GUIContent("Copy"), false, () => _WriteVector3(_target.position));
            context.AddItem(new GUIContent("Paste"), false, () => { if (_ParseVector3(out var vec3)) _target.position = vec3; });
            context.ShowAsContext();
            Event.current.Use();
        }
    }
    EditorGUI.showMixedValue = false;
    EditorGUIUtility.labelWidth = width_default;
}
  • ワールド位置はtransform.positionなのですが、複数選択したとき、値が違うことを示す-表示のせいで、FloatFieldに分解する必要があります。EditorGUI.showMixedValueこいつのせいです。公式のコードは波及するクラスが多いので、最低限のものにまとめました。多分大丈夫・・・
  • コンテキストメニューのコピーペーストも対応済みです。めんどいので深く考えずにVector3部分だけ丸コピしました。

HideFlags

Hideflagsを操作するチェックが欲しい。
HideFlags.DontSaveInBuildが非常に便利。AwakeでDestroyとか、そんなんとは次元が違います。エディタ専用のゲームオブジェクトにばんばんつけています。
おまけでHideFlags.HideInHierarchyを使って子要素をヒエラルキーに表示しない機能の追加と、
HideFlags.NotEditableを配置しました。これはインスペクタ全体をReadonlyにする機能があります。

カスタムアクション

ドロップダウン形式でメソッドをコールします。
とくに僕がよく使うのは、
子要素のTransform値維持したまま親のTransform値をゼロにリセットするショートカットと、子要素位置の中心あたりに親の位置を持ってくるショートカットです。
シーン内のオブジェクト整理時によく使うんですよね。

プロジェクトに応じてリストアイテムを増やしていくことができます。

最後に

これでまたしばらくは大丈夫そうです。結局スケールのアイコンのことなんか忘れてるんですよね。師走で忙しいのに現実逃避ムーブがこの頃過ぎていて、もうひとつ、あったらいいな拡張エディタを合間を縫って作成しています・・・。

ソースを整理している最中、LinqのAggregateは累積する値の型と列挙する型は別々でもいいということを知り便利だなと知りました。いろいろなことに使えそうです。

// 基準位置から対象のうち最大距離を測る
var cp = _GetCenterPosition(_targets);
var max_distance = _targets.Aggregate(0f, (a, e) => { 
    var d = Vector3.Distance(e.position, cp); 
    return a < d ? d : a; 
});

最後に作成したリポジトリのURLになります。
https://github.com/emptybraces/Unity_TransformInspector/tree/main
ありがとうございました。

Discussion