第9章 CustomEditor - エディター拡張入門

第9章 CustomEditor

カスタムエディターは、インスペクターやシーンビューに表示されている GUI をカスタマイズするための機能です。本章ではカスタムエディターの基本的な使い方に加え、インスペクターの仕組みについても紹介していきます。

9.1 インスペクターの Debug モード

例えば、Cube を作成し、インスペクターを見ると BoxCollider や MeshRenderer などのコンポーネントがアタッチされていることがわかります。

Cube を作成し、Cube を選択している状態

図9.1: Cube を作成し、Cube を選択している状態

その時に、インスペクターのタブ部分を右クリック、または「 ≡ 」をクリックすると 図9.2 のようにコンテキストメニューが表示され「Normal」と「Debug」項目を見つけることができます。

通常は Normal にチェックが付いている

図9.2: 通常は Normal にチェックが付いている

ここで Debug を選択すると、図9.3 のように普段見ているインスペクターとは少し違う見た目になります。

普段見ない Instance ID や File ID のプロパティーを見ることができる

図9.3: 普段見ない Instance ID や File ID のプロパティーを見ることができる

Debug モードは、インスペクターがカスタマイズされる前の素の状態を表示します。*1Unity エディターはデフォルトで、インスペクターに表示したい要素を取捨選択し、GUI をカスタマイズして表示しています。

9.2 オブジェクトと Editor クラス

Editor クラスは、オブジェクトの情報をインスペクターやシーンビューに表示するための橋渡しとなる機能です。インスペクターになんらかの情報が表示されるときに、各オブジェクトに対応した Editor オブジェクトが生成され、Editor オブジェクトを介して必要な情報を GUI で表示します。

ボックスコライダーを Editor オブジェクトを介して GUI を表示するイメージ

図9.4: ボックスコライダーを Editor オブジェクトを介して GUI を表示するイメージ

また、インスペクターに表示する必要のない要素があったり、ボタンなど独自に追加したい GUI 要素があるかもしれません。そのときには、CustomEditor(カスタムエディター)の機能を使うことで Editor オブジェクトをカスタマイズできます。

普段見ているインスペクターはすでにカスタムエディターが使われている

普段インスペクターで触れているコンポーネントは、すでにカスタムエディターによってカスタマイズされています。本来の姿は本章の最初に説明した Debug モードの状態です。

語尾が Inspector と Editor で表記にゆれがあるが機能面では違いはない

図9.5: 語尾が Inspector と Editor で表記にゆれがあるが機能面では違いはない

つまり、普段見ているインスペクターの表示を、ユーザーの手でもカスタムエディターを使うことによって同じような実装が可能です。ユーザーの手で実装するために、あらためて「何ができるか」の参考としてインスペクターの表示を確認してみるのもいいかもしれません。

9.3 カスタムエディターを使う

例えば、ゲーム中に使用する実際の攻撃力は、キャラクターの「ちから」や「武器の強さ」などさまざまな要素が合わさって決まるとします。そのときにプログラム上で使用する実際の「攻撃力」というプロパティーを持ち getter で攻撃力を求める計算を行います。

ソースコードは計算式がちょっとだけ分かりやすいように日本語変数にしてみました。

using UnityEngine;

public class Character : MonoBehaviour
{
  [Range (0, 255)]
  public int 基本攻撃力;
  [Range (0, 99)]
  public int 剣の強さ;
  [Range (0, 99)]
  public int ちから;

  //プレイヤーの能力と、剣の強さから攻撃力を求めるプロパティー
  public int 攻撃力 {
      get {
        return 基本攻撃力 + Mathf.FloorToInt (基本攻撃力 * (剣の強さ + ちから - 8) / 16);
      }
  }
}

プログラム上はこれでいいのですが、攻撃力の値を Unity エディターのインスペクターで確認したい場合は少し困ったことになります。インスペクターは、シリアライズ可能なフィールドを表示します。シリアライズ対象ではないプロパティーは表示されません。

下記コンポーネントをインスペクターで見た図

図9.6: 下記コンポーネントをインスペクターで見た図

今回は、図9.7 のようにプロパティーである攻撃力をインスペクター上に表示して、確認しながらパラメーターを調整できるよう実装してみます。

インスペクターに攻撃力を表示

図9.7: インスペクターに攻撃力を表示

Editor クラスの派生クラスを作成

Editor クラスの派生クラスを作成した後、Character コンポーネントに対しての Editor クラスとして CustomEditor 属性を付加します。これでカスタムエディターでカスタマイズする準備が整いました。

using UnityEngine;
using UnityEditor;

[CustomEditor (typeof(Character))]
public class CharacterInspector : Editor
{
}

インスペクターの GUI のカスタマイズ

インスペクターの GUI は OnInspectorGUI をオーバーライドすることでカスタマイズできます。

using UnityEngine;
using UnityEditor;

[CustomEditor (typeof(Character))]
public class CharacterInspector : Editor
{
    Character character = null;

    void OnEnable ()
    {
        //Character コンポーネントを取得
        character = (Character) target;
    }

    public override void OnInspectorGUI ()
    {
        base.OnInspectorGUI ();

        //攻撃力の数値をラベルとして表示する
        EditorGUILayout.LabelField ("攻撃力", character.攻撃力.ToString ());
    }
}

これで 図9.7 と同じ表示を行うことができました。このように、インスペクターの GUI をカスタマイズするときに、OnInspectorGUI をオーバーライドするだけでなく、base.OnInspectorGUI を呼び出してあげることで、元の GUI 要素はそのままで、インスペクターにカスタム要素を追加することができます。

シーンビューの GUI のカスタマイズ

シーンビューの GUI は OnSceneGUI を使うことでカスタマイズできます。OnSceneGUI は、主にゲームオブジェクトに対して使用されます。そして OnSceneGUI が実行されるタイミングは、ゲームオブジェクトを選択している(インスペクターが表示されている)時です。

OnSceneGUI では少し特殊な 3D に特化した GUI を扱います。この説明は 第17章「Handle(ハンドル)」 にて詳しく説明しているので、そちらをご覧ください。

9.4 カスタムエディターでデータのやり取り

カスタムエディターからコンポーネントの値にアクセスする方法は 2 種類あります。「Unityのシリアライザを通してアクセスする方法」と「コンポーネントに直接アクセスする方法」です。

コンポーネントの値にアクセスするときのイメージ

図9.8: コンポーネントの値にアクセスするときのイメージ

これからその 2 種類の方法について説明していきます。説明時には、次のコンポーネントがあり、このカスタムエディターを作成するものとして話を進めていきます。

using UnityEngine;

public class Character : MonoBehaviour
{
    public int hp;
}

Unityのシリアライザを通してアクセスする方法

Unity エディターはデータの持ち方として SerializedObject ですべてのデータを管理しています。SerializedObject 経由でデータにアクセスすることによって、データを操作する際に、柔軟な対応が可能になります。SerializedObject の詳しい説明は 第5章「SerializedObject について」 をご覧ください。

Editor オブジェクトが生成されると同時に、コンポーネントがシリアライズされ、Editor クラスの serializedObject 変数に格納されます。そして serializedObject 変数からシリアライズされた各値にアクセスできます。

次のコードのように、「SerializedProperty にアクセスする "前" は必ず SerializedObject を最新に更新」しなければいけません。これは同じコンポーネントの SerializedObject が他の場所で更新された場合に、その変更点を適用するためです。

SerializedProperty にアクセスした "後" は必ずプロパティーの変更点を SerializedObject に適用」します。これによりデータを保存する処理が実行されます。

using UnityEngine;
using UnityEditor;

[CustomEditor (typeof(Character))]
public class CharacterInspector : Editor
{
    SerializedProperty hpProperty;

    void OnEnable ()
    {
        hpProperty = serializedObject.FindProperty ("hp");
    }

    public override void OnInspectorGUI ()
    {
        serializedObject.Update ();

        EditorGUILayout.IntSlider (hpProperty, 0, 100);

        serializedObject.ApplyModifiedProperties ();
    }
}

コンポーネントに直接アクセスする方法

コンポーネントに直接アクセスすることで、値の変更や GUI の作成を簡単に行うことができます。

対象のコンポーネントは Editor オブジェクトの target 変数でアクセスできます。 UnityEngine.Object 型なのでキャストする必要があります。

Undo を実装すること

コンポーネントに直接アクセスする方法はとても楽な方法です。文字列でプロパティーにアクセスする SerializedObject と比べると、typo などケアレスミスも少なくなります。ですがこの方法で、変更した値を保存するには Undo 処理を実装しなければいけません。Undo は自動で登録されるものではなく、値を保存/変更するときには Undo 処理を自前で実装します。対して、SerializedObject では Undo は自動で登録されるので Undo について気にする必要はありません。Undo の詳しい説明は 第12章「Undo について」 をご覧ください。

アセットが更新されたことをエディターに通知する SetDirty

Unity5.2 までは 最後に EditorUtility.SetDirty を呼び出すことで、変更された値を保存(コンポーネントの値であればシーンに保存)することができました。ですが Unity5.3 からは SetDirty はアセットに対してのみ動作するようになります。アセットの値を変更したときは、必ず EditorUtility.SetDirty を呼び出します。これは、Unity エディターにアセットの状態が更新されたことを通知するために使用されます。

アセットには Dirty flag(ダーティーフラグ)があり、このフラグを立てることにより、Unity エディターは「アセットを最新の状態にする」ことができます。例えば、プレハブにアタッチされているコンポーネントの値を変更した時に EditorUtility.SetDirty を使用します。そして Unityプロジェクトを保存(File -> Save Project や AssetDatabase.SaveAssets)したとき、ダーティーフラグの立ったオブジェクトすべてがアセットに書き込まれます。

Character character;

void OnEnable ()
{
    character = (Character) target;
}

public override void OnInspectorGUI ()
{
    EditorGUI.BeginChangeCheck ();

    var hp = EditorGUILayout.IntSlider ("Hp", character.hp, 0, 100);

    if (EditorGUI.EndChangeCheck ()) {

        //変更前に Undo に登録
        Undo.RecordObject (character, "Change hp");

        character.hp = hp;

    }
}

9.5 複数コンポーネントの同時編集

Unity ではゲームオブジェクトを複数選択し同時に同じプロパティーの値を編集できます。ただし、同時編集ができるのは同時編集を許可されたコンポーネントのみです。

同時編集が許可されていないコンポーネント

図9.9: 同時編集が許可されていないコンポーネント

ユーザーの手でカスタムエディターを実装していない通常のコンポーネントでは、デフォルトで同時編集が可能ですが、カスタムエディターを実装したコンポーネントではデフォルトで同時編集できるようにはなりません。

CanEditMultipleObjects

同時編集を可能にするには CanEditMultipleObjects 属性を Editor の派生クラスに付加する必要があります。

using UnityEngine;
using UnityEditor;

[CanEditMultipleObjects]
[CustomEditor (typeof(Character))]
public class CharacterInspector : Editor
{
}

CanEditMultipleObjects 属性をつけることで同時編集を行う下準備ができました。これから実際にプロパティーの同時編集をしていきます。ここでもプロパティーへのアクセスが SerializedObject 経由か直接コンポーネントかによって実装方法が異なります。

SerializedObject を使った同時編集

SerializedObject を通して編集をしている場合は CanEditMultipleObjects 属性を付加するだけで、SerializedObject 側で同時編集に対応します。

using UnityEngine;
using UnityEditor;

[CanEditMultipleObjects]
[CustomEditor (typeof(Character))]
public class CharacterInspector : Editor
{
    SerializedProperty hpProperty;

    void OnEnable ()
    {
        hpProperty = serializedObject.FindProperty ("hp");
    }

    public override void OnInspectorGUI ()
    {
        serializedObject.Update ();

        EditorGUILayout.IntSlider (hpProperty, 0, 100);

        serializedObject.ApplyModifiedProperties ();
    }
}

コンポーネントに直接アクセスしての同時編集

同時編集を可能にするには複数のコンポーネントにアクセスしなければいけません。複数選択した場合は target 変数ではなく targets 変数を使用します。targets に現在選択中のオブジェクトすべてが格納されています。

複数選択した時にインスペクターに表示されるものは、最初に選択したコンポーネントです。これは target に格納されており、また targets の1番目の要素でもあります。

選択したコンポーネントの各プロパティーがすべて同じ値ということもあれば、異なる場合もあります。同じ値でない場合は Unity は 「- 」を表示して、異なる値が代入されていると表現できます。

複数選択時、左が同じ値の場合。右が異なる値の場合。

図9.10: 複数選択時、左が同じ値の場合。右が異なる値の場合。

- 」を表示する仕組みは、コンポーネントに直接アクセスしている方法だと自動で適用されません。自分で実装する必要があります。EditorGUI.showMixedValue の static 変数があり、GUI のコードの前に true を設定することで「異なる値である」と表現することが可能です。

次のコードが上記で説明したことをすべて含めたコードとなります。

using UnityEngine;
using UnityEditor;
using System.Linq;

[CanEditMultipleObjects]
[CustomEditor (typeof(Character))]
public class CharacterInspector : Editor
{
    Character[] characters;

    void OnEnable ()
    {
        characters = targets.Cast<Character> ().ToArray ();
    }

    public override void OnInspectorGUI ()
    {
        EditorGUI.BeginChangeCheck ();

        //異なる値が 2 以上であれば true
        EditorGUI.showMixedValue =
            characters.Select (x => x.hp).Distinct ().Count () > 1;

        var hp = EditorGUILayout.IntSlider ("Hp", characters [0].hp, 0, 100);

        EditorGUI.showMixedValue = false;

        if (EditorGUI.EndChangeCheck ()) {

            //すべてのコンポーネントを Undo に登録
            Undo.RecordObjects (characters, "Change hp");

            //すべてのコンポーネントに値を代入して更新
            foreach (var character in characters) {
                character.hp = hp;
            }
        }
    }
}

9.6 カスタムエディター内で PropertyDrawer を使用する

カスタムエディター内でも PropertyDrawer を使用できます。使い方は EditorGUILayout.PropertyField に対象の SerializedProperty を渡すだけです。PropertyDrawer の詳しい説明は 第10章「PropertyDrawer」 をご覧ください。

図9.11 のような PropertyDrawer を作成し、カスタムエディター内で表示してみます。

MinMaxSlider

図9.11: MinMaxSlider

まずは PropertyDrawer 対象となる Example クラスを作成し、Character クラスに変数として記述します。

[System.Serializable]
public class Example
{
    public int minHp;
    public int maxHp;
}
using UnityEngine;

public class Character : MonoBehaviour
{
    public Example example;
}

次に、Example クラスの PropertyDrawer を作成します。MinMaxSlider の実装と、それぞれの値をラベルとして表示しています。

[CustomPropertyDrawer (typeof(Example))]
public class ExampleDrawer : PropertyDrawer
{
    public override void OnGUI (Rect position,
                           SerializedProperty property, GUIContent label)
    {
        using (new EditorGUI.PropertyScope (position, label, property)) {

            //各プロパティー取得
            var minHpProperty = property.FindPropertyRelative ("minHp");
            var maxHpProperty = property.FindPropertyRelative ("maxHp");

            //表示位置を調整
            var minMaxSliderRect = new Rect (position) {
                height = position.height * 0.5f
            };

            var labelRect = new Rect (minMaxSliderRect) {
                x = minMaxSliderRect.x + EditorGUIUtility.labelWidth,
                y = minMaxSliderRect.y + minMaxSliderRect.height
            };

            float minHp = minHpProperty.intValue;
            float maxHp = maxHpProperty.intValue;

            EditorGUI.BeginChangeCheck ();

            EditorGUI.MinMaxSlider (label,
                        minMaxSliderRect, ref minHp, ref maxHp, 0, 100);

            EditorGUI.LabelField (labelRect, minHp.ToString (), maxHp.ToString ());

            if (EditorGUI.EndChangeCheck ()) {
                minHpProperty.intValue = Mathf.FloorToInt (minHp);
                maxHpProperty.intValue = Mathf.FloorToInt (maxHp);
            }
        }
    }

    //GUI 要素の高さ
    public override float GetPropertyHeight (SerializedProperty property, GUIContent label)
    {
        return base.GetPropertyHeight (property, label) * 2;
    }
}

あとはカスタムエディター内で使用するだけです。

using UnityEngine;
using UnityEditor;

[CanEditMultipleObjects]
[CustomEditor (typeof(Character))]
public class CharacterInspector : Editor
{
    SerializedProperty exampleProperty;

    void OnEnable ()
    {
        exampleProperty = serializedObject.FindProperty ("example");
    }

    public override void OnInspectorGUI ()
    {
        serializedObject.Update ();

        EditorGUILayout.PropertyField (exampleProperty);

        serializedObject.ApplyModifiedProperties ();
    }
}

このように、細かく部品にできるものは PropertyDrawer として実装すると、複雑なスパゲティーコード*2 にならずに済むかもしれません。これは冗談ではなく、GUI の描画のためのコードは冗長になりがちなのでオススメです。

9.7 プレビュー

インスペクターではメッシュやテクスチャ、スプライトなどプレビュー可能な要素がある場合にプレビュー画面で確認できます。

Cube のプレハブを選択した状態のインスペクターにあるプレビューウィンドウ

図9.12: Cube のプレハブを選択した状態のインスペクターにあるプレビューウィンドウ

プレビュー画面の表示

カスタムエディターを実装するとき、プレビューの表示はデフォルトでは無効となっています。「プレビューできる状態である」とインスペクターに判断させるには HasPreviewGUI メソッドをオーバーライドし、戻り値として true を返す必要があります。

public override bool HasPreviewGUI ()
{
    //プレビュー表示できるものがあれば true を返す
    return true;
}

これにより 図9.13 のように、普段は空のゲームオブジェクトではプレビューの表示はできませんが、プレビュー画面が表示できるようになりました。

左が無効状態(false)、右が有効状態(true)

図9.13: 左が無効状態(false)、右が有効状態(true)

プレビューの表示

事前準備

事前に次のスクリプトファイルを作成しておきます。

using UnityEngine;

public class PreviewExample : MonoBehaviour {

}
using UnityEngine;
using UnityEditor;

[CustomEditor (typeof(PreviewExample))]
public class PreviewExampleInspector : Editor
{
    public override bool HasPreviewGUI ()
    {
        return true;
    }
}

そして新規作成した Cube に PreviewExample をアタッチします。

プレビューの最低限の実装

プレビューのウィンドウが表示する最低限の実装のために3つのメソッドを知っておかなくてはいけません。

GetPreviewTitle
プレビュー名を設定します。1つのオブジェクトに対して複数のプレビューを持っている場合は、識別子にもなります。
プレビューが複数ある場合はプレビュー名の部分がドロップダウンになる

図9.14: プレビューが複数ある場合はプレビュー名の部分がドロップダウンになる

public override GUIContent GetPreviewTitle ()
{
    return new GUIContent ("プレビュー名");
}
OnPreviewSettings
右上のヘッダーに GUI を追加するために使用します。プレビュー環境を変更するボタンや情報を記載します。ここに適切な GUIStyle がドキュメント化されておらず、見つけにくいですが「ラベルは preLabel」「ボタンは preButton」「ドロップダウンは preDropDown」「スライダーは preSlider」となります。また、ここでは (Editor)GUILayout を使うのを推奨しており、EditorGUILayout.BeginHorizontal によって水平に GUI が並べられるように設定されています。
一番右端から並べられていく

図9.15: 一番右端から並べられていく

public override void OnPreviewSettings ()
{
    GUIStyle preLabel = new GUIStyle ("preLabel");
    GUIStyle preButton = new GUIStyle ("preButton");

    GUILayout.Label ("ラベル", preLabel);
    GUILayout.Button ("ボタン", preButton);
}
OnPreviewGUI

プレビューを表示(つまりテクスチャやレンダリング結果を表示するための GUI を表示)する場所です。メソッドの引数として描画すべき領域の Rect を取得できるのでプレビューに合わせ Rect をカスタマイズできます。

プレビュー領域全体に Box が描画されている

図9.16: プレビュー領域全体に Box が描画されている

public override void OnPreviewGUI (Rect r, GUIStyle background)
{
    GUI.Box (r, "Preview");
}

9.8 プレビューでカメラを使う

モデルデータやアニメーション編集時にプレビュー画面で、マウスドラッグによって対象オブジェクトを回転させ、隅々まで見渡せる機能があります。

AnimationClip のプレビュー。マウスでグリグリ動かすことができる

図9.17: AnimationClip のプレビュー。マウスでグリグリ動かすことができる

これらの仕組みは特別なことをやっているわけではありません。図9.17 までリッチにするのは本章では行いませんが、最低限の実装するための手順を紹介していきます。

PreviewRenderUtility

プレビューのユーティリティクラスとして PreviewRenderUtility があります。このクラスにはプレビュー専用のカメラが用意されており、簡単にシーン内の景色を映し出すことができます。

例として「対象のゲームオブジェクトをカメラで映し続けるプレビュー画面」を作成してみます。

完成図、プレビュー画面で特定の位置からゲームオブジェクトを見ることができる

図9.18: 完成図、プレビュー画面で特定の位置からゲームオブジェクトを見ることができる

まずは OnEnable メソッドの中で PreviewRenderUtility のインスタンスを生成し LookAt する対象のゲームオブジェクトをコンポーネント経由で取得します。

using UnityEngine;
using UnityEditor;

[CustomEditor (typeof(PreviewExample))]
public class PreviewExampleInspector : Editor
{
    PreviewRenderUtility previewRenderUtility;
    GameObject previewObject;

    void OnEnable ()
    {
        //true にすることでシーン内のゲームオブジェクトを描画できるようになる
        previewRenderUtility = new PreviewRenderUtility (true);

        //FieldOfView を 30 にするとちょうどいい見た目になる
        previewRenderUtility.m_CameraFieldOfView = 30f;

        //必要に応じて nearClipPlane と farClipPlane を設定
        previewRenderUtility.m_Camera.nearClipPlane = 0.3f;
        previewRenderUtility.m_Camera.farClipPlane = 1000;

        //コンポーネント経由でゲームオブジェクトを取得
        var component = (Component)target;
        previewObject = component.gameObject;
    }
}

そして描画を行う部分です。BeginPreviewEndAndDrawPreview で囲み、その中で Camera.Render を呼び出します。そうすることでプレビュー画面に「PreviewRenderUtility が持つカメラからのレンダリング結果」が表示されるようになります。

public override void OnPreviewGUI (Rect r, GUIStyle background)
{
    previewRenderUtility.BeginPreview (r, background);

    var previewCamera = previewRenderUtility.m_Camera;

    previewCamera.transform.position =
        previewObject.transform.position + new Vector3 (0, 2.5f, -5);

    previewCamera.transform.LookAt (previewObject.transform);

    previewCamera.Render ();

    previewRenderUtility.EndAndDrawPreview (r);


    //描画タイミングが少ないことによって
    //カクつきがきになる時は Repaint を呼び出す(高負荷)
    //Repaint ();
}

これで、図9.18 のようなプレビュー画面ができました。

using UnityEngine;
using UnityEditor;

[CustomEditor (typeof(PreviewExample))]
public class PreviewExampleInspector : Editor
{
    PreviewRenderUtility previewRenderUtility;
    GameObject previewObject;

    void OnEnable ()
    {
        previewRenderUtility = new PreviewRenderUtility (true);
        previewRenderUtility.m_CameraFieldOfView = 30f;

        previewRenderUtility.m_Camera.farClipPlane = 1000;
        previewRenderUtility.m_Camera.nearClipPlane = 0.3f;

        var component = (Component)target;
        previewObject = component.gameObject;
    }

    void OnDisable ()
    {
        previewRenderUtility.Cleanup ();
        previewRenderUtility = null;
        previewObject = null;
    }

    public override bool HasPreviewGUI ()
    {
        return true;
    }

    public override void OnPreviewGUI (Rect r, GUIStyle background)
    {
        previewRenderUtility.BeginPreview (r, background);

        var previewCamera = previewRenderUtility.m_Camera;

        previewCamera.transform.position =
            previewObject.transform.position + new Vector3 (0, 2.5f, -5);

        previewCamera.transform.LookAt (previewObject.transform);

        previewCamera.Render ();

        previewRenderUtility.EndAndDrawPreview (r);

    }
}

プレビュー用オブジェクトを作成する

次は 図9.19 のような、マウスでグリグリ動かすプレビューを作成します。

マウスでドラッグすると Cube が回転する

図9.19: マウスでドラッグすると Cube が回転する

プレビューのゲームオブジェクトの生成場所

プレビューで使用されているゲームオブジェクトも、シーンの中に生成されています。

次の手順を行うことで、シーン内にあるプレビュー用ゲームオブジェクトを、プレビュー画面で表示することが可能です。

  1. Object.Instantiate でプレビュー用ゲームオブジェクトを生成する
  2. プレビュー用ゲームオブジェクトに Preview 専用のレイヤー「PreviewCullingLayer」を設定する
  3. Camera.Render の直前後にプレビュー用オブジェクトをアクティブ/非アクティブにする

1. Object.Instantiate でプレビュー用ゲームオブジェクトを生成する

コンポーネントからゲームオブジェクトを取得し、Instantiate で複製を行います。この時、必ず HideFlags.HideAndDontSave を設定します。これにより、ゲームオブジェクトは、ヒエラルキーにゲームオブジェクトの表示を行わず、かつ、シーンに保存されなくなります。

最後に、ゲームオブジェクトを非アクティブにしてメッシュなどをシーン内でレンダリングをシーン内で表示させないようにします。

GameObject previewObject;

void OnEnable ()
{
    var component = (Component)target;
    previewObject = Instantiate (component.gameObject);
    previewObject.hideFlags = HideFlags.HideAndDontSave;
    previewObject.SetActive (false);
}

2. プレビュー用ゲームオブジェクトに Preview 専用のレイヤー「PreviewCullingLayer」を設定

プレビュー専用のレイヤーとして Camera.PreviewCullingLayer が用意されています。ですが、public ではないので Reflection でアクセスする必要があります。

var flags = BindingFlags.Static | BindingFlags.NonPublic;
var propInfo = typeof(Camera).GetProperty ("PreviewCullingLayer", flags);
int previewLayer = (int)propInfo.GetValue (null, new object[0]);

取得した previewLayer をプレビュー用のカメラとゲームオブジェクトに設定します。

previewRenderUtility = new PreviewRenderUtility (true);

//previewLayer のみ表示する
previewRenderUtility.m_Camera.cullingMask = 1 << previewLayer;

階層下すべてに previewLayer を設定します。

previewObject.layer = previewLayer;
foreach (Transform transform in previewObject.transform) {
    transform.gameObject.layer = previewLayer;
}

3. Camera.Render の直前後にプレビュー用オブジェクトをアクティブ/非アクティブにする

Camera.Render を実行する前後に、ゲームオブジェクトを有効/無効にします。こうすることでプレビューのゲームオブジェクトはプレビュー時のみ描画されるようになります。

もし、ゲーム再生中にプレビューを表示する場合、ゲームに影響のあるコンポーネントは無効にするか破棄するようにしてください。プレビュー画面で表示しているゲームオブジェクトは、シーンの中のゲームオブジェクトを表示しているだけなので、ゲームサイクルの影響を受けてしまいます。

public override void OnInteractivePreviewGUI (Rect r, GUIStyle background)
{
    previewRenderUtility.BeginPreview (r, background);

    previewObject.SetActive (true);

    previewRenderUtility.m_Camera.Render ();

    previewObject.SetActive (false);

    previewRenderUtility.EndAndDrawPreview (r);

}

グリグリ動かす

マウスでドラッグして、プレビューのゲームオブジェクトをグリグリ動かします。

マウスドラッグ時のマウス位置の差分は Event.current.delta で取得可能です。この差分で得たものを transform.RotateAround を使ってプレビュー用のゲームオブジェクトを回転させます。

この時に1つ問題が起こります。transform.RotateAround で回転させるには、ゲームオブジェクトの中心位置を把握しておかなくてはいけません。

中心位置を取得する

transform.position で取得できるものは必ずゲームオブジェクトの中心位置とは限りません。モデルデータであれば足元が原点である可能性もあります。中心位置を求めるには、プレビュー対象がメッシュの場合 Bounds を取得し、その中心位置を求めなければいけません。

少し力技ですが、プレビュー対象のゲームオブジェクトがシーン内のものであれば、簡単に中心位置を求める方法があります。Pivot が PivotMode.Center の場合、ゲームオブジェクトは原点ではなくゲームオブジェクト全体の中心位置に Pivot を設定するようになります。これにより、ツール系(位置、回転、スケールなどのハンドル)の表示位置が変化します。この仕様を使って、Tools.handlePosition でゲームオブジェクトの中心位置を取得することが可能です。

左が <code class="inline-code tt">PivotMode.Pivot</code>、右が <code class="inline-code tt">PivotMode.Center</code>

図9.20: 左が PivotMode.Pivot、右が PivotMode.Center

ただ、この方法だとプレハブをプレビューした時にうまくいきません。プレハブはアセットでありシーン上にはないため、ツール系を表示する位置である Tools.handlePosition は、使用できないからです。プレハブ以外ではこの方法使うことができるので、多用できませんが方法の1つとして覚えておくといいかもしれません。

シーン内のゲームオブジェクト、そしてプレハブの両方をサポートしたプレビューを行いたい場合は、中心位置を求める計算を独自で実装する必要があります。この時、メッシュがあるなら Bounds から求めることになります。

プリミティブな Cube などであれば、同じゲームオブジェクトにアタッチされている Renderer コンポーネントを取得して、Renderer.bounds.center で中心位置を求めることができます。

ただし、モデルのような複数の Renderer を持ったゲームオブジェクトになると、少し工夫が必要になります。どの Renderer コンポーネントの Bounds.center を使えばいいか判断しなくてはいけません。

どちらの Rederer コンポーネントを使えばいいかわからない

図9.21: どちらの Rederer コンポーネントを使えばいいかわからない

ほとんどの場合は、一番大きな Bounds を使えば問題ないので Bounds.Encapsulate を使用します。これは引数として与えた Bounds と比較して最大サイズに置き換えます。

Bounds bounds = new Bounds (component.transform.position, Vector3.zero);

//階層下の Renderer コンポーネントをすべて取得
foreach (var renderer in previewObject.GetComponentsInChildren<Renderer>()) {
        bounds.Encapsulate (renderer.bounds);
}

//一番大きい Bounds の中心位置
var centerPosition = bounds.center;

オブジェクトを回転させる

中心位置を求めることができたので、Event.current.deltatransform.RotateAround を組み合わせてオブジェクトを回転させます。

まずはマウスの移動量を取得します。これは Event.current.type が ドラッグ時である EventType.MouseDrag の時に Event.current.delta の値を取得するだけです。

public override void OnInteractivePreviewGUI (Rect r, GUIStyle background)
{
    var drag = Vector2.zero;

    if (Event.current.type == EventType.MouseDrag) {
        drag = Event.current.delta;
    }
    //...略...
}

そして Bounds で求めた中心位置を使い、X 軸と Y 軸に合わせて回転させます。

private void RotatePreviewObject (Vector2 drag)
{
  previewObject.transform.RotateAround (centerPosition, Vector3.up, -drag.x);
  previewObject.transform.RotateAround (centerPosition, Vector3.right, -drag.y);
}

たったこれだけで、マウスでグリグリ動かせるプレビュー画面が完成します。

完全なソースコードは次になります。

using UnityEngine;
using UnityEditor;
using System.Reflection;

[CustomEditor (typeof(PreviewExample))]
public class PreviewExampleInspector : Editor
{
    PreviewRenderUtility previewRenderUtility;
    GameObject previewObject;
    Vector3 centerPosition;

    void OnEnable ()
    {

        var flags = BindingFlags.Static | BindingFlags.NonPublic;
        var propInfo = typeof(Camera).GetProperty ("PreviewCullingLayer", flags);
        int previewLayer = (int)propInfo.GetValue (null, new object[0]);

        previewRenderUtility = new PreviewRenderUtility (true);
        previewRenderUtility.m_CameraFieldOfView = 30f;
        previewRenderUtility.m_Camera.cullingMask = 1 << previewLayer;

        var component = (Component)target;
        previewObject = Instantiate (component.gameObject);
        previewObject.hideFlags = HideFlags.HideAndDontSave;

        previewObject.layer = previewLayer;

        foreach (Transform transform in previewObject.transform) {
            transform.gameObject.layer = previewLayer;
        }

        //初期値の Bounds を作成
        Bounds bounds = new Bounds (component.transform.position, Vector3.zero);

        //すべての Renderer コンポーネントを取得
        foreach (var renderer in previewObject.GetComponentsInChildren<Renderer>()) {
            //一番大きい Bounds を取得する
            bounds.Encapsulate (renderer.bounds);
        }

        //プレビューオブジェクトの中心位置として変数に代入
        centerPosition = bounds.center;

        previewObject.SetActive (false);

        //オブジェクト角度の初期値
        //このくらいの値が斜めから見下ろす形になる
        RotatePreviewObject (new Vector2 (-120, 20));
    }

    public override GUIContent GetPreviewTitle ()
    {
        return new GUIContent (target.name + " Preview");
    }

    void OnDisable ()
    {
        DestroyImmediate (previewObject);
        previewRenderUtility.Cleanup ();
        previewRenderUtility = null;
    }

    public override bool HasPreviewGUI ()
    {
        return true;
    }

    public override void OnInteractivePreviewGUI (Rect r, GUIStyle background)
    {
        previewRenderUtility.BeginPreview (r, background);

        var drag = Vector2.zero;
        //ドラッグ時のマウスの移動量を取得
        if (Event.current.type == EventType.MouseDrag) {
            drag = Event.current.delta;
        }

        //中心位置から一定の距離離れたところにカメラを設置
        previewRenderUtility.m_Camera.transform.position =
                                        centerPosition + Vector3.forward * -5;

        //マウスの移動量をオブジェクトの角度に適用
        RotatePreviewObject (drag);

        previewObject.SetActive (true);
        previewRenderUtility.m_Camera.Render ();
        previewObject.SetActive (false);

        previewRenderUtility.EndAndDrawPreview (r);

        //ドラッグした時は再描画処理を行う
        //これを行わないとカクカクした動きになってしまう
        if (drag != Vector2.zero)
            Repaint ();
    }

    private void RotatePreviewObject (Vector2 drag)
    {
        previewObject.transform.RotateAround (centerPosition, Vector3.up, -drag.x);
        previewObject.transform.RotateAround (centerPosition, Vector3.right, -drag.y);
    }
}

9.9 Unity がサポートしていないアセットのカスタムエディター

例えば Zip ファイルや Excel ファイルを Unity 上で扱いたいときがあるかもしれません。ですが、それらのファイルは Unity がサポートしておらず、 Unity 上でアクションを行うことはできません。

このとき Unity 上では、サポートしていないファイルはすべて DefaultAsset として認識されています。つまり、DefaultAsset のカスタムエディターを作成すれば、サポートしていないファイルも ほかの「アセット」と同様に扱うことが可能になります。

そこで、汎用性を考えた CustomEditor 属性のような CustomAsset 属性を作成します。

[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class CustomAssetAttribute : Attribute
{
    public string[] extensions;

    public CustomAssetAttribute(params string[] extensions)
    {
        this.extensions = extensions;
    }
}

次のコードは CustomAsset の使用イメージです。属性の引数に拡張子を渡すことで対応するアセットのインスペクターをカスタマイズできるようになります。

//Zip ファイル
[CustomAsset(".zip")]
public class ZipInspector : Editor
{
    public override void OnInspectorGUI()
    {
        GUILayout.Label("例: zip の中身をプレビューとして階層表示");
    }
}

//Excel ファイル
[CustomAsset(".xlsx", ".xlsm", ".xls")]
public class ExcelInspector : Editor
{
    public override void OnInspectorGUI()
    {
        GUILayout.Button("例: ScriptableObject に変換するボタンを追加");
    }
}
Excel のアイコンは自動的に紐づいているアプリのアイコンになる

図9.22: Excel のアイコンは自動的に紐づいているアプリのアイコンになる

最後に、DefaultAsset に対するカスタムエディターを作成します。すべての実装が終われば、 図9.22 の表示になります。

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

[CustomEditor(typeof(DefaultAsset))]
public class DefaultAssetInspector : Editor
{
    private Editor editor;
    private static Type[] customAssetTypes;

    [InitializeOnLoadMethod]
    static void Init()
    {
        customAssetTypes = GetCustomAssetTypes();
    }

    /// <summary>
    /// CustomAsset 属性のついたクラスを取得する
    /// </summary>
    private static Type[] GetCustomAssetTypes()
    {
        // ユーザーの作成した DLL 内から取得する
        var assemblyPaths = Directory.GetFiles("Library/ScriptAssemblies", "*.dll");
        var types = new List<Type>();
        var customAssetTypes = new List<Type>();

        foreach (var assembly in assemblyPaths
            .Select(assemblyPath => Assembly.LoadFile(assemblyPath)))
        {
            types.AddRange(assembly.GetTypes());
        }

        foreach (var type in types)
        {
            var customAttributes =
                type.GetCustomAttributes(typeof(CustomAssetAttribute), false)
                                                      as CustomAssetAttribute[];

            if (0 < customAttributes.Length)
                customAssetTypes.Add(type);
        }
        return customAssetTypes.ToArray();
    }

    /// <summary>
    /// 拡張子に対応した CustomAsset 属性のついたクラスを取得する
    /// </summary>
    /// <param name="extension">拡張子(例: .zip)</param>
    private Type GetCustomAssetEditorType(string extension)
    {
        foreach (var type in customAssetTypes)
        {
            var customAttributes =
              type.GetCustomAttributes(typeof(CustomAssetAttribute), false)
                                                      as CustomAssetAttribute[];

            foreach (var customAttribute in customAttributes)
            {
                if (customAttribute.extensions.Contains(extension))
                    return type;
            }
        }
        return typeof(DefaultAsset);
    }

    private void OnEnable()
    {
        var assetPath = AssetDatabase.GetAssetPath(target);

        var extension = Path.GetExtension(assetPath);
        var customAssetEditorType = GetCustomAssetEditorType(extension);
        editor = CreateEditor(target, customAssetEditorType);
    }

    public override void OnInspectorGUI()
    {
        if (editor != null)
        {
            GUI.enabled = true;
            editor.OnInspectorGUI();
        }
    }

    public override bool HasPreviewGUI()
    {
        return editor != null ? editor.HasPreviewGUI() : base.HasPreviewGUI();
    }

    public override void OnPreviewGUI(Rect r, GUIStyle background)
    {
        if (editor != null)
            editor.OnPreviewGUI(r, background);
    }

    public override void OnPreviewSettings()
    {
        if (editor != null)
            editor.OnPreviewSettings();
    }

    public override string GetInfoString()
    {
        return editor != null ? editor.GetInfoString() : base.GetInfoString();
    }

    //以下、任意で扱いたい Editor クラスの拡張を行う
}

これで、サポートしていないアセットのカスタムエディターを作成することができるようになりました。ただ、基礎の部分である DefaultAsset をカスタマイズしているため「他のアセットで同様に DefaultAsset を拡張している場合は動作しない」ことに注意してください。

[*1] Unity 内部で非表示設定となっている場合は除きます。また Debug モードは独自機能として編集はできませんが private フィールドも表示します。

[*2] プログラムのソースコードがそれを制作したプログラマ以外にとって解読困難である事を表す俗語

第8章 MenuItem 第10章 PropertyDrawer