第12章 Undo について - エディター拡張入門

第12章 Undo について

エディター拡張によって変更した状態は「元に戻す」処理を自分で実装しなければいけません。そこでこの章では簡単なサンプルを元に Undo の使い方をマスターしていきます。

12.1 Undo の操作を実感してみる

まずはどのような操作が Undo なのか実感してみましょう。まずは、Cube を作成します。

<code class="inline-code tt">Assets/Create/3D Object/Cube</code> で作成した状態

図12.1: Assets/Create/3D Object/Cube で作成した状態

次に Edit/Undo Create Cube を実行します。ショートカットキーで実行する場合は「command/ctrl + Z」です。

メニュー名が <code class="inline-code tt">Undo Create Cube</code> ではない場合、余計な操作を行っている可能性があります。もう一度 Cube を生成しなおしてください。

図12.2: メニュー名が Undo Create Cube ではない場合、余計な操作を行っている可能性があります。もう一度 Cube を生成しなおしてください。

生成された Cube が削除されましたか?Cube を生成する前に戻ったことになります。これが「元に戻る = Undo」という操作です。


12.2 Undo の仕組み

Undo の管理はスタックで行われています。*1

[*1] スタックは、後から入れたものを先に出す「LIFO(後入先出)」です。

スタックのイメージ

図12.3: スタックのイメージ


12.3 Undo を実装してみる

Undo の操作を実感してみたところで、次は Undo を実装してみましょう。

前回の Undo 履歴をリセットするために File/New Scene で新規シーンにしておきましょう。

オブジェクトの作成に対する Undo

下記コードは Cube を生成するためのコードです。Example/Create Cube を実行することによって Cube を生成できます。

using UnityEngine;
using UnityEditor;

public class Example
{
    [MenuItem("Example/Create Cube")]
    static void CreateCube ()
    {
        GameObject.CreatePrimitive (PrimitiveType.Cube);
    }
}

Cube を生成しても Undo を行うことはできません。これは Undo の実装が行われていないためです。

Undo という文字が灰色になって選択できないことがわかる

図12.4: Undo という文字が灰色になって選択できないことがわかる

さっそく Undo クラスを使って、Undo を実装していきます。

Undo 操作の1例として Undo.RegisterCreatedObjectUndo 関数を使用して Undo を実装してみましょう。この関数はオブジェクトが生成された時に使用する Undo で、この関数によって登録されたオブジェクトは、Undo 実行時に破棄されます。

RegisterCreatedObjectUndo 関数と Undo を実行した時の動き

図12.5: RegisterCreatedObjectUndo 関数と Undo を実行した時の動き

図12.5 では、RegisterCreatedObjectUndo 関数を実行すると「Cube を作成する」という処理を差分としてスタックに追加します *2。Undo が実行される際には「Cube を作成する前に戻す」つまり、Cube を削除するという処理を行うことになります。

[*2] 実際にはオブジェクトと Undo 名がまとめられてスタックに保存されます。

下記コードのように実装して、実行してみましょう。

また Undo 履歴をリセットするために File/New Scene で新規シーンにしましょう。

using UnityEngine;
using UnityEditor;

public class Example
{
    [MenuItem("Example/Create Cube")]
    static void CreateCube ()
    {
        var cube = GameObject.CreatePrimitive (PrimitiveType.Cube);
        Undo.RegisterCreatedObjectUndo (cube, "Create Cube");
    }
}

下の画像のように Undo が登録されていれば成功です。実際に Undo してみましょう。

Undo を実行した後は Undo したものを元に戻す Redo(取り消す)も実行できます。

プロパティーの変更に対する Undo

下記コードはオブジェクトの回転をランダムに設定するコードです。Example/Random Rotate を実行することによって選択しているオブジェクトをランダムに回転させることができます。

using UnityEngine;
using UnityEditor;

public class Example
{
    [MenuItem("Example/Random Rotate")]
    static void RandomRotate ()
    {
        var transform = Selection.activeTransform;

        if (transform) {
            transform.rotation = Random.rotation;
        }
    }
}
変更前と変更後

図12.6: 変更前と変更後

これに Undo.RecordObject 関数を使用することによって Undo を実装します。

using UnityEngine;
using UnityEditor;

public class Example
{
    [MenuItem("Example/Random Rotate")]
    static void RandomRotate ()
    {
        var transform = Selection.activeTransform;

        if (transform) {
            Undo.RecordObject (transform, "Rotate " + transform.name);
            transform.rotation = Random.rotation;
        }
    }
}

ランダムな回転が設定される前に Undo.RecordObject 関数を実行します。これにより「変更前」の Transform のプロパティーを Undo スタックに保存できるようになります。

Undo が登録されているとメニューに表示され正しく回転を

図12.7: Undo が登録されているとメニューに表示され正しく回転を

12.4 どのプロパティーが変更されたかを知る PropertyDiffUndoRecorder

Undo のスタックに保存されるのは「値の変更前と変更後の差分」です。

なので「どのプロパティーが変更されたか」を知る必要があります。その役割を担うのが PropertyDiffUndoRecorder です。

PropertyDiffUndoRecorder はプロファイラで確認できる

図12.8: PropertyDiffUndoRecorder はプロファイラで確認できる

PropertyDiffUndoRecorder は、Unity エディターのライフサイクルの最後に Undo の Flush を呼びだします。その時に RecordObject で登録されたオブジェクトの各プロパティーと、Flush が呼び出された時の各プロパティーを使用して差分を求めます。

以下の順に実行され、図にしたものが 図12.9 です。

  1. オブジェクトの登録
  2. 値の変更
  3. Flush 実行
  4. 差分の出力
PropertyDiffUndoRecorder のサイクル

図12.9: PropertyDiffUndoRecorder のサイクル

差分のイメージ(画像は <a href="https://gist.github.com" class="link">gist</a> の diff ビュー)

図12.10: 差分のイメージ(画像は gist の diff ビュー)

図12.9 で示したサイクルを確認してみましょう。以下のコードで確認できます。

using UnityEngine;
using UnityEditor;

public class Example
{
    [MenuItem("Example/Random Rotate")]
    static void RandomRotate ()
    {
        var transform = Selection.activeTransform;

        if (transform) {
            Undo.willFlushUndoRecord += () => Debug.Log ("flush");

            Undo.postprocessModifications += (modifications) => {
                Debug.Log ("modifications");
                return modifications;
            };

            Undo.RecordObject (transform, "Rotate " + transform.name);
            Debug.Log ("recorded");

            transform.rotation = Random.rotation;
            Debug.Log("changed");
        }
    }
}

実行すると 図12.11 の順番でログが出力されます。

<code class="inline-code tt">Example/Random Rotate</code> を実行した後に出力されたログ

図12.11: Example/Random Rotate を実行した後に出力されたログ

12.5 Redo の仕組み

Redo は Undo で処理したものを元に戻す機能です。Redo により Undo の実行をなかったことにします。

Cube を作成した後の Undo 実行、Redo 実行の流れ

図12.12: Cube を作成した後の Undo 実行、Redo 実行の流れ

Redo もスタックで管理されています。

Undo と Redo のスタック

図12.13: Undo と Redo のスタック

Undo が実行されると、取り出されたものは Redo のスタックに積まれます。

Undo を2回実行し、3と2が Redo スタックに積まれた状態

図12.14: Undo を2回実行し、3と2が Redo スタックに積まれた状態

Redo を実行すると、Redo スタックに格納されたものは再度 Undo スタックに格納されます。

Redo を実行し、2が Redo スタックから取り出され Undo スタックに積まれた状態

図12.15: Redo を実行し、2が Redo スタックから取り出され Undo スタックに積まれた状態

このように、Redo は Undo で処理されたものを利用しますので特別な実装は必要ありません。

12.6 Undo の対象

Undo の対象となるものは UnityEngine.Object を継承した、シリアライズ可能なオブジェクトです。

よく Undo の実装で対象となるもの

よく Undo の実装で対象となるものは以下の3つです。これらのオブジェクトを生成したり、オブジェクトのプロパティーの値を変更する場合は Undo の実装を行うべきと考えましょう。

  • GameObject
  • Component (MonoBehaviour も含む)
  • ScriptableObject

System.Serializable 属性を付けたクラスを Undo 対象にするには

System.Serializable 属性を付けたクラスを Undo 対象にする場合は、Component や ScriptableObject のプロパティーとしてもつことで Undo 対象とさせることができます。

[System.Serializable]
public class PlayerInfo
{
    public string name;
    public int hp;
}

例として Player コンポーネントに変数として持たせます。

using UnityEngine;

public class Player : MonoBehaviour
{
    [SerializeField]
    PlayerInfo info;
}

そして Player コンポーネントを Undo 対象として登録します。

using UnityEngine;
using UnityEditor;

public class Example
{
    [MenuItem("Example/Change PlayerInfo")]
    static void ChangePlayerInfo ()
    {
        var player = Selection.activeGameObject.GetComponent<Player> ();

        if (player) {
            Undo.RecordObject (player, "Change PlayerInfo");
            player.info = new PlayerInfo{
                name = "New PlayerName",
                hp = Random.Range(0,10)
            };
        }
    }
}

12.7 Undo の種類

Undo の種類は大きく分けて2種類になります。

  • オブジェクトのプロパティー(値)変更に対する Undo
  • オブジェクトへのアクションに対する Undo

プロパティー(値)に対する Undo

Undo.RecordObject(s)

Undo の実装は大抵 Undo.RecordObject で済みます。まずはこの API を覚えましょう。

using UnityEngine;
using UnityEditor;

public class NewBehaviourScript
{
    [MenuItem("Undo/RecordObject")]
    static void RecordObject ()
    {
        //選択状態の Transform を取得する
        Transform transform = Selection.activeTransform;

        //これから変更するプロパティーのオブジェクトの指定と Undo 名
        Undo.RecordObject (transform, "position を Vector3(0,0,0)に変更");
        transform.position = new Vector3 (0, 0, 0);
    }
}

アクションに対する Undo

Undo.AddComponent

ゲームオブジェクトにコンポーネントを追加し、Undo 対象とします。Editor 拡張でコンポーネントを追加する時はこちらを使いましょう。

using UnityEngine;
using UnityEditor;

public class NewBehaviourScript
{

    [MenuItem("Undo/AddComponent")]
    static void AddComponent ()
    {
        GameObject go = Selection.activeGameObject;

        Rigidbody rigidbody = Undo.AddComponent<Rigidbody> (go);

        //この後、Undo 実行をすればコンポーネントが削除される
    }
}

Undo.RegisterCreatedObjectUndo

オブジェクトを作成した時に Undo で破棄するために使用します。よく使用するのはゲームオブジェクト作成時と ScriptableObject 作成時です。

using UnityEngine;
using UnityEditor;

public class NewBehaviourScript
{
    [MenuItem("Undo/RegisterCreatedObjectUndo")]
    static void RegisterCreatedObjectUndo ()
    {
        GameObject go = new GameObject ();
        Undo.RegisterCreatedObjectUndo (go, "GameObject を作成");

        //グループをインクリメント
        Undo.IncrementCurrentGroup ();

        Hoge hoge = ScriptableObject.CreateInstance<Hoge> ();
        Undo.RegisterCreatedObjectUndo (hoge, "Hoge を作成");
        //実際に hoge が Undo されるのか確認。Undo されたら null になる
        EditorApplication.update += () => Debug.Log (hoge);
    }
}

Undo.DestroyObjectImmediate

RegisterCreatedObjectUndo とは逆に、破棄したものを Undo で元に戻す(再生成する)ために使用します。

using UnityEngine;
using UnityEditor;

public class NewBehaviourScript
{
    [MenuItem("Undo/DestroyObjectImmediate")]
    static void DestroyObjectImmediate ()
    {
        GameObject go = Selection.activeGameObject;
        //選択した GameObject を破棄。Undo で破棄以前の状態に戻る
        Undo.DestroyObjectImmediate (go);
    }
}

Undo.SetTransformParent

ゲームオブジェクトの親子関係を作成/変更するときに使用する Undo の実装です。

using UnityEngine;
using UnityEditor;

public class NewBehaviourScript
{
    [MenuItem("Undo/SetTransformParent")]
    static void SetTransformParent ()
    {

        Transform root = GameObject.Find("Main Cameta").transform;

        Transform transform = Selection.activeTransform;

        Undo.SetTransformParent (transform, root, "Main Cameta オブジェクトの子要素にする");
    }
}

12.8 キャンセル処理(Revert)

Undo 登録して値を変更している中で「Esc」ボタンなどで、その操作自体をキャンセルしたいときがあります。キャンセル時は Undo 登録した時の値に戻りますが、キャンセルなので Redo(Esc 押す直前の状態)が実行できてしまうのは不自然です。なので Redo を行わない Undo の実装をしなければなりません。

このキャンセル処理のことを Revert と言います。

図12.16:

Undo.RevertAllInCurrentGroup

現在のグループの Undo を実行します。「Revert」とあるように、戻すという処理だけなので Redo はできません。

using UnityEngine;
using UnityEditor;

public class NewBehaviourScript
{
    //必ず宝くじが当選するメソッド
    [MenuItem("Undo/RevertAllInCurrentGroup")]
    static void RevertAllInCurrentGroup ()
    {
        GameObject ticket = new GameObject ("Ticket");

        Undo.RegisterCreatedObjectUndo (ticket, "宝くじ買いました");

        //チケットの番号はこれです
        int number = ticket.GetInstanceID ();

        //当選番号はこれです
        int winningNumber = 1234;

        if (ticket.GetInstanceID () != winningNumber) {
          //当たりませんでした
          //宝くじを買ったのをなかったことにします
          Undo.RevertAllInCurrentGroup ();
        }
    }
}

Undo.RevertAllDownToGroup

もう1つ Undo.RevertAllDownToGroup があります。これは指定したグループインデックスまで戻します。

これは、連続的に値が変更しているものを Revert するときに RevertAllDownToGroup を使用します。たとえば、

ゲームオブジェクトに3つのコンポーネントをつけるとしましょう。この時、グループインデックスはそれぞれ異なるものとします。

「1つめのコンポーネントを追加した状態まで戻る」というような実装を行うには下記のコードになります。

using UnityEngine;
using UnityEditor;

public class ExampleWindow : EditorWindow
{

    //ウィンドウ作成
    [MenuItem("Window/ExampleWindow")]
    static void Open ()
    {
      GetWindow<ExampleWindow> ();
    }

    GameObject go;

    int group1 = 0;
    int group2 = 0;
    int group3 = 0;

    void OnEnable ()
    {
        go = GameObject.Find ("New Game Object");
    }

    void OnGUI ()
    {
        //マウスをクリックしたら
        if (Event.current.type == EventType.MouseDown) {

            //現在のグループインデックスを保持
            group1 = Undo.GetCurrentGroup();

            //1つめ追加
            Undo.AddComponent<Rigidbody> (go);

            //インクリメント
            Undo.IncrementCurrentGroup();

            //現在のグループインデックスを保持
            group2 = Undo.GetCurrentGroup();

            //2つめ追加
            Undo.AddComponent<BoxCollider> (go);

            //インクリメント
            Undo.IncrementCurrentGroup();

            //現在のグループインデックスを保持
            group3 = Undo.GetCurrentGroup();

            //3つめ追加
            Undo.AddComponent<ConstantForce>(go);
        }

        if (Event.current.type == EventType.MouseUp) {
            //group2まで戻る(1つ目のみが追加されている状態)
            Undo.RevertAllDownToGroup(group2);
            //コンポーネントの GUI が変更されたことによる
            //描画エラーを回避するために ExitGUI を呼び出す
            EditorGUIUtility.ExitGUI();
        }
    }
}

12.9 グループを理解する

Undo にはグループという概念が存在します。

例えば、下記のコード

GameObject enemy = new GameObject ("Enemy");
Undo.RegisterCreatedObjectUndo (enemy, "Enemy を作成");

GameObject effect = new GameObject ("Effect");
Undo.RegisterCreatedObjectUndo (effect, "Effect を作成");

のように書き、複数の Undo 対象を登録して、これを実際に Undo 実行するとどうなるでしょうか。

まとめて Undo する

先ほどのコードで Undo 実行をすると、2つのオブジェクトの Undo が同時に実行されます。

これは PropertyDiffUndoRecorder が Flush されるまで、1つのグループとしてまとめられるからです。Undo 時には 1グループまとめて処理が走ります。

この挙動で問題ない場合もあるかもしれませんが、「それぞれの Undo を個別に実行したい」という時もあります。

それぞれの Undo を個別に実行する

1つのグループになっているため、まとめて Undo されるわけなので、これを2つのグループに分ければ問題ありません。そのための API が用意されています。

Undo.IncrementCurrentGroup

グループは int 型で index として管理されているので、それをインクリメント(+1)してグループを分けます。そうすると、それぞれのものは別のグループとして認識され個別に Undo できるようになります。

GameObject enemy = new GameObject ("Enemy");
Undo.RegisterCreatedObjectUndo (enemy, "Enemy を作成");

Undo.IncrementCurrentGroup();

GameObject effect = new GameObject ("Effect");
Undo.RegisterCreatedObjectUndo (effect, "Effect を作成");

個別だった Undo を1つにまとめる

最初は個別で独立した Undo 処理でしたが、最後には Undo を1つにまとめてしまう事が可能です。

カラーピッカーでこの例を見ることができます。

カラーピッカーの表示中は、各 RGBA 成分に対しての Undo が適用されます。しかし、カラーピッカーで色を決定した後は、カラーピッカーを開く前の色へと Undo しなくてはいけません。これはカラーピッカーを閉じた時に、各 RGBA 成分で分かれていた Undo を 1 つにまとめることで実現が可能になります。

各成分に対する Undo と、色に対する Undo の違い

図12.17: 各成分に対する Undo と、色に対する Undo の違い

CollapseUndoOperations

Undo を一つにまとめる方法は CollapseUndoOperations を使用します。

CollapseUndoOperations は指定した group から今までのグループをすべて1つにまとめる機能です。

下記コードは、Cube/Plane/Cylinder を作成するコードです。各ゲームオブジェクトの生成に対して Undo が行われます。また、EditorWindow を閉じた時には Undo を実行した時に Cube/Plane/Cylinder がすべて削除されます。

下記コードのように OnEnable 内でグループ ID を保持しておき、OnDisable で今までの Undo を1つにまとめます。

using UnityEngine;
using UnityEditor;

public class NewBehaviourScript : EditorWindow
{
    int groupID = 0;

    [MenuItem ("Window/Example")]
    static void Open ()
    {
        GetWindow<NewBehaviourScript> ();
    }

    void OnEnable ()
    {
        groupID = Undo.GetCurrentGroup ();
    }

    void OnDisable ()
    {
        Undo.CollapseUndoOperations (groupID);
    }

    void OnGUI ()
    {
        if (GUILayout.Button ("Cube 作成")) {
            var cube = GameObject.CreatePrimitive (PrimitiveType.Cube);
            Undo.RegisterCreatedObjectUndo (cube, "Create Cube");
        }

        if (GUILayout.Button ("Plane 作成")) {
            var plane = GameObject.CreatePrimitive (PrimitiveType.Plane);
            Undo.RegisterCreatedObjectUndo (plane, "Create Plane");
        }

        if (GUILayout.Button ("Cylinder 作成")) {
            var cylinder = GameObject.CreatePrimitive (PrimitiveType.Cylinder);
            Undo.RegisterCreatedObjectUndo (cylinder, "Create Cylinder");
        }
    }
}
第11章 ProjectWindowUtil 第13章 さまざまなイベントのコールバック