深入Unity引擎的Undo/Redo模块

简介

Unity中的Undo模块允许用户撤销之前的操作,为开发者提供了不少的便利。
本篇文章的内容包含Undo模块C#部分的API接口以及C++部分的具体实现。


认识Undo接口

体验Undo操作

首先我们在场景中生成一个新的Cube。然后我们调整Inpsector窗口中的Transform.Scale,将其设置为(2,2,2)。

我们可以使用Ctrl+Z来撤销之前对Transform.Scale做出的调整。

撤销操作后,我们还可以使用Ctrl+Y来重做(redo)之前撤销的操作。

Undo/Redo模块主要的功能是将某个操作变为可逆(undoable),由于撤销(undo)本身也是一种操作,所以撤销本身也可以被撤销,撤销的撤销,也就是重做(redo)。
因此Undo/Redo模块会呈现出对称的感觉。

为何代码修改的属性无法撤销?

上一小节中,我们可以撤销对属性的修改。但如果我们直接使用下面的代码,

[MenuItem("LearnUndo/SetScale")]
static void SetScale()
{
    var transform = Selection.activeTransform;
    if (transform != null)
    {
        transform.localScale = Vector3.one * 2;
    }
}

我们会发现Scale被设置成(2,2,2)之后,无法通过Ctrl+Z来撤销这一操作。
这是因为我们没有在Undo模块里面注册这个操作,将其变为可逆(undoable)。我们可以通过添加一行代码使其变得可逆。

[MenuItem("LearnUndo/SetScaleUndo")]
static void SetScaleUndo()
{
    var transform = Selection.activeTransform;
    if (transform != null)
    {
        Undo.RecordObject(tr, "Rotate " + tr.name);
        transform.localScale = Vector3.one * 2;
    }
}

Undo.RecordObject接口的作用是将传入对象上的属性变化变得可逆。
既然是变化,那么我们就需要一个变化前的状态,以及变化后的状态。这里我们需要记录变化前的对象
编辑器随后会调用GetPropertyDiffUndoRecorder().Flush();函数将当前状态和之前的状态进行对比,记录其中变化的属性到UndoManager上面。

不同的操作需要不同的Undo接口

除了上一小节提到的Undo.RecordObject用于撤销属性的变化。还有以下接口用于不同的目的。

  • RegisterCreatedObjectUndo用于撤销对象的创建
  • DestroyObjectImmediate用于撤销对象的销毁
  • AddComponent用于撤销添加的组件

以及我们可以在代码中使用Undo.PerformUndo来进行撤销操作。


Undo底层代码是怎么工作的?

UndoStack和RedoStack

UndoManager是整个Undo模块的核心,在UndoManager上面维护着两个Stack,一个是m_UndoStack,一个是m_RedoStack。两个栈中存储的是UndoBase的指针。UndoBase的派生类对应着不同的撤销操作。比如,

  • PropertyDiffUndo对应Undo.RecordObject接口,用于撤销属性的变化。
  • CreatedObjectUndo对应Undo.RegisterCreatedObjectUndo接口,用于撤销对象的创建。
  • ObjectUndo对应Undo.RegisterCompleteObjectUndo接口,用于撤销对象的删除,是上一个Undo的逆操作。

m_UndoStack栈中存储的是用户通过接口存储的undo。比如Undo.RecordObject函数最终会在UndoManager里的m_UndoStack入栈一个可以用于撤销属性变化的Undo操作 PropertyDiffUndo

这里我们定义,如果一个undo能够抵消另一个undo的作用,我们将其中一个undo称为另一个undo的逆undo,也就是说redo的本质是undo的undo。

Undo/Redo模块主要的功能是将某个操作变为可逆(undoable),由于撤销(undo)本身也是一种操作,所以撤销本身也可以被撤销,撤销的撤销,也就是重做(redo)。
因此Undo/Redo模块会呈现出对称的感觉。

当我们按下Ctrl+Z时,Unity会执行m_UndoStack中最近事件对应的Undo的的Restore函数,该函数会撤销对应的操作。
并且每次执行m_UndoStack中undo的Restore撤销操作时,Unity都会在m_RedoStack中存储对应刚刚undo的逆undo,逆undo用于撤销刚刚的撤销。

当我们按下Ctrl+Y时,Unity会执行m_RedoStack中的undo的Restore函数,该函数会撤销刚刚的撤销。
并且每次执行m_RedoStack中undo的Restore撤销操作时,Unity都会在m_UndoStack中存储对应刚刚逆undo的逆undo,也就是一次正常的undo操作。

Undo操作都对应一个Event

每个Undo操作会有一个对应的Group,Group实际上对应的就是鼠标键盘的点击事件。在执行Undo操作时,Unity会执行对应最近一个事件的所有的Undo操作。
因此对于下面代码,当我们按下Ctrl+Z时,两个创建的cube会被同时销毁。

    [MenuItem("LearnUndo/CreateObjectUndo")]
    static void CreateObjectUndo()
    {
        var cube1 = GameObject.CreatePrimitive(PrimitiveType.Cube);
        Undo.RegisterCreatedObjectUndo(cube1, "Create Cube");

        var cube2 = GameObject.CreatePrimitive(PrimitiveType.Cube);
        Undo.RegisterCreatedObjectUndo(cube2, "Create Cube");

    }

我们可以通过调用Undo.IncrementCurrentGroup();令这两个Undo操作属于不同的Event,从而达到一次撤销对应一个物体的效果。

    [MenuItem("LearnUndo/CreateObjectUndo")]
    static void CreateObjectUndo()
    {
        var cube1 = GameObject.CreatePrimitive(PrimitiveType.Cube);
        Undo.RegisterCreatedObjectUndo(cube1, "Create Cube");

        Undo.IncrementCurrentGroup();
        var cube2 = GameObject.CreatePrimitive(PrimitiveType.Cube);
        Undo.RegisterCreatedObjectUndo(cube2, "Create Cube");

    }

存在延时的Undo.RecordObject()

上文提到Undo.RecordObject()函数最终会在UndoManager里的m_UndoStack入栈一个用于撤销属性修改的PropertyDiffUndo
虽然用户感受不出来,但实际上这个PropertyDiffUndo是存在延时的。因为PropertyDiffUndo对应的是属性变化,既然是变化,那么就需要有一个初始状态,和一个修改后的状态。
Undo.RecordObject()函数会通过底层的PropertyDiffUndoRecorder::RecordObject生成一个对象的快照RecordedObject,存储在PropertyDiffUndoRecorder.m_CurrentlyRecording链表里面,对象序列化后的数据会存储到 RecordedObject.preEditState 动态数组中。 这里记录的是初始状态。

接着,开发者会紧接着Undo.RecordObject()修改传入对象的属性值。

Unity编辑器的主循环函数为Application::TickTimer,这里会执行PropertyDiffUndoRecorder::Flush(),该函数会将对象当前的状态和之前记录的快照进行对比,生成差异信息PropertyDiffUndo,然后会调用m_PostprocessCallback,对应到C#里面就是Undo.postprocessModifications,该回调函数用于对已生成的差异信息进行再次加工。

Undo.postprocessModifications回调函数结束后,Unity才会将PropertyDiffUndo注册到UndoManager里面。

最后会清空当前正在记录的对象m_CurrentlyRecording,因此m_CurrentlyRecording常态状态下应该是空的。

我们可以通过Undo.postprocessModifications打印Unity修改的属性值。

  [MenuItem("LearnUndo/CheckPostProcessCallback")]
  static void CheckPostProcessCallback()
  {
      var transform = Selection.activeTransform;
      if (transform == null)
          return;

      if (callbackHooked == false)
      {
          callbackHooked = true;
          Undo.postprocessModifications += PostprocessModifications;
      }

      Undo.RecordObject(transform, "transform");
      transform.localScale = transform.localScale * 2;

      PrintUndoStackCount();
  }

  private static UndoPropertyModification[] PostprocessModifications(UndoPropertyModification[] modifications)
  {

      foreach(var undoPropertyMod in modifications)
      {
          Debug.Log("prev");
          PrintPropertyModification(undoPropertyMod.previousValue);
          Debug.Log("current");
          PrintPropertyModification(undoPropertyMod.currentValue);
      }

      PrintUndoStackCount();
      return  modifications;


  }

  private static void PrintUndoStackCount()
  {
      return;
      var strList = new List<string>();
      Undo.GetUndoList(strList, out int undoIndex);
      Debug.Log(" undo count is" + strList.Count);
  }


  private static void PrintPropertyModification(PropertyModification mod)
  {
      Debug.Log($"Object is {mod.target.name}");
      Debug.Log($"property is {mod.propertyPath}");
      Debug.Log($"value is {mod.value}");

  }


参考

Unity3D Editor Undo回退效果实现1

posted @ 2025-03-14 23:49  dewxin  阅读(525)  评论(0)    收藏  举报