UE5运行时撤销系统:基于命令模式的插件设计与实现
2026/9/19 13:05:17 网站建设 项目流程

做UE5开发的朋友应该都体会过:引擎自带的Undo系统只在编辑器里管用,到了运行时,游戏里玩家拖了个物件、改了个属性,想按Ctrl+Z撤销,结果一点反应没有。这不怪引擎,Undo系统本来就是给编辑器状态管理设计的,跟运行时是两个世界。我因为要在项目里做一个类似“建造模式”的功能,玩家能实时摆放、移动、删除场景物体,迫切需要一套运行时可用的操作撤销系统,于是自己写了一个插件。这套插件以命令模式为核心,把每次修改封装成可逆命令,支持Undo和Redo,还能接入蓝图方便策划调用。

这篇文章会把这个插件的设计思路、核心实现、踩坑过程全部拆开讲。如果你正在做玩法编辑器、沙盒建造、创意工坊类的地图编辑功能,希望实现“玩家操作可反悔”,这篇内容可以直接拿来参考。我会把核心类结构、命令栈逻辑、内存管理要点、蓝图接口方式,以及我调了三天才搞定的经典Bug都列出来,纯干货,没水分。

1. 运行时撤销系统的痛点与设计思路

1.1 为什么编辑器里的Undo在运行时完全不可用

UE5自带的Undo机制是挂在事务系统上的,核心类叫FUndoHistory、FTransaction,和编辑器Subsystem绑在一起。这些代码大量依赖Editor模块,比如FEdMode、FEditorViewportClient、SCSEditor等。当你打到Shipping包或者独立的Runtime构建里,这些模块根本不会被打包进去,Undo自然成了空中楼阁。

再说设计角度。编辑器Undo是围绕“工具操作”做的,比如你移动了一个StaticMeshComponent,它记录的是编辑器UI层的事务快照。运行时需要的是玩家操作级的撤销,比如“这次点击生成的箱子要能被拿掉”“这个炮台被我挪过位置,撤销要回到挪之前”。两者不仅生命周期不同,记录层级也不同。想把编辑器Undo直接搬到运行时,等于让赛车的悬挂去骑山地车,底盘逻辑根本对不上。

所以运行时撤销系统必须自己实现,并且最好做到两件事:

  • 只依赖Runtime模块(Core、CoreUObject、Engine等),不引用Editor。
  • 能记录和回放“有业务含义”的命令,而不是单纯还原底层属性。

1.2 插件架构选型:命令模式加双栈回滚

这套系统我选择经典的命令模式,配上Undo栈和Redo栈。核心思路是把每个玩家操作包装成一个命令对象,命令对象内部保存逆向操作所需的所有数据。执行某个操作时调用命令的Execute,撤销时调用命令的Undo,撤销过后想重做就调用Redo。过程中不直接改场景里的数据,而是通过命令对象间接完成。

比如“移动Actor”这个命令,命令内部会保存“移动前位置”和“移动后位置”。第一次执行时,把Actor设到新位置;撤销时,设置回旧位置;重做时,再设到新位置。这种方式的优势非常明显:命令对象是自包含的,你可以随时入栈出栈;逻辑统一,所有可撤销操作都走同一套流程;扩展新操作类型只需新增一个命令子类,不需要改栈的代码。

同时定义两个栈:

  • Undo栈:存放已执行且可撤销的命令。
  • Redo栈:存放被撤销后、可再次执行的命令。

当新命令执行时,会清空Redo栈。原因很简单,一旦撤销后你又做了新操作,历史分叉了,旧的重做路径已经无法对应到当前状态,继续保留会产生逻辑混乱。这个规则和主流编辑器的撤销行为一致,用户也容易理解。

1.3 支持的操作类型与扩展点

运行时可撤销的操作类型,我一开始只做了移动和删除,后面逐渐扩展成一套通用框架。现在插件里内置了这样几类:

  • Actor的生成与销毁(Spawn/Destroy)。
  • Actor的Transform修改,包括位置、旋转、缩放。
  • 组件属性修改,比如某组件的Visibility、碰撞开关。
  • 变量/数据对象的属性覆盖,比如自定义结构体数组的增删改。
  • 分组批量操作,把一次组合动作合并成单一撤销步骤。

扩展点是UUndoCommand基类。任何想要支持“可撤销”的操作,都继承这个基类,重写几个虚函数就行。做新玩法时,策划不需要看底层栈逻辑,只需要提供“怎么执行”和“怎么回退”,剩下交给插件。

2. 核心实现细节与实操要点

2.1 命令基类设计:为什么要把Execute和Redo分开

这是整个插件的基础,类的设计直接决定后面好用不好用。我的UUndoCommand基类长这样:

UCLASS(Abstract, BlueprintType, Blueprintable) class MYUNDOSYSTEM_API UMyUndoCommand : public UObject { GENERATED_BODY() public: UFUNCTION(BlueprintCallable, Category = "Undo System") virtual bool Execute(); UFUNCTION(BlueprintCallable, Category = "Undo System") virtual bool Undo(); UFUNCTION(BlueprintCallable, Category = "Undo System") virtual bool Redo(); // 命令名称,用于调试或UI显示 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Undo System") FString CommandName; // 时间戳,用于按时间排序或合并 UPROPERTY() double Timestamp = 0.0; // 合并ID,连续同类命令可以合并成一条 UPROPERTY() int32 MergeGroupID = INDEX_NONE; };

Execute和Redo看起来都是“做这个操作”,我为什么强行分开?因为第一次执行和重做有本质不同。第一次执行前,场景是旧状态,命令通常需要在执行的同时捕获一次旧值。比如“生成Actor”命令,第一次执行需要先Spawn出来,然后把生成的Actor引用存到命令内部;而Redo时Actor已经被销毁了,你需要再Spawn一个新对象并重新建立引用。如果只写一个Execute,很容易在Redo路径上出问题——你以为变量里还有引用,其实已经悬空。

所以我的建议是:

  • Execute:负责“从未执行到已执行”,同时负责收集命令依赖的场景对象和旧数据。
  • Undo:负责把场景恢复到命令执行前。
  • Redo:复用Undo中保存的“新数据”,重新应用一遍。

如果你在子类里发现Undo和Redo代码一模一样,说明你的命令设计成“值对象”了,可以偷懒,但最好还是保留区分,方便以后扩展。比如“属性修改”命令,Undo写旧值,Redo写新值,大多数情况下对称,但真正的复杂命令往往不对称。

2.2 命令栈实现:上限、清空Redo、防递归

命令栈我封装在一个叫UMyUndoStack的Object里,内部维护两个TArray:

UPROPERTY() TArray<TObjectPtr<UMyUndoCommand>> UndoStack; UPROPERTY() TArray<TObjectPtr<UMyUndoCommand>> RedoStack; UPROPERTY(EditAnywhere, Category = "Undo System") int32 MaxUndoCount = 128;

这里有几个关键逻辑要注意:

第一,栈顶在数组末尾。压栈用Add,出栈用Pop,这样避免频繁地在数组头部插入删除。千万别图方便用Insert(0),否则栈深一深性能就崩。

第二,超上限时丢弃最老的命令。这个逻辑不能放在执行命令时硬丢。正确做法是:执行完命令并压栈后,检查UndoStack.Num() > MaxUndoCount,成立就RemoveAt(0)弹出最老的一条。为什么用数组而非TQueue?因为我们需要随机访问和删除最老元素,TArray在栈深度不大时性能非常合适。

第三,每次新命令执行时,RedoStack.Empty()。这能避免分叉历史带来的诡异状态。

第四,防止递归。命令执行过程中如果调用了Execute,而它又调用了栈,很容易把栈搞乱。我的做法是给UMyUndoStack加一个bool bIsExecutingFlag,执行命令期间再次修改栈就报错并返回false。

2.3 在运行时把操作变成命令:捕获旧值、弱引用避免GC

很多人在写撤销系统的第一步就栽了:直接保存原始对象指针。这是大坑。你的命令对象是UObject,如果它强引用了一个场景里的Actor,那么这个Actor就算被销毁了,也会因为引用计数留在内存里。表面上撤销能用,实际上因为Actor没被销毁,后续逻辑疯狂吃到幽灵对象。

我推荐的方案是改用FWeakObjectPtr保存所有场景对象引用,用之前先查有效性:

TWeakObjectPtr<AActor> TargetActorPtr; bool UMyMoveActorCommand::Undo() { if (!TargetActorPtr.IsValid()) { // 目标已销毁,命令失效,返回false,栈会移除它 return false; } TargetActorPtr->SetActorLocation(OldLocation); return true; }

除了弱引用,还要考虑对象被序列化或者跨关卡加载的场景。我自己项目里,命令存活期一般很短(当前关卡内),所以弱引用足够。如果你的游戏支持关卡跳转、存档,那么命令对象要跟着存档走,这里就得设计成可序列化数据加全局对象ID,而不是直接存UObject引用。这个复杂度比较大,建议前期先别过度设计,等功能稳定再加。

2.4 内存与性能:不要让命令变成快照堆

初学者最容易犯的错误是把撤销命令做成“全场景快照”。撤销一个箱子位置,就在命令里存下整张地图的原始数据。这样一是内存爆炸,二是序列化慢。一百条命令下来,几个GB就没了。

正确思路是:命令只保存“最小反转信息”。移动了就存Transform;生成了就存类信息和可选初始参数;删除场景物体时,才需要保存该物体及其附件的完整数据。删除命令是快照型命令里最特殊的一类,因为你需要删除后能把对象恢复,所以必须把该Actor当前的所有状态和层级关系记录下来。我实现时是把Actor用InternalToWorld转换后的数据,连同组件列表打包成字节数组,Undo时再重新载入。这里建议使用ULevel::DuplicateActor或手动拷贝组件数据来辅助。

性能上的另一条建议是:不要在移动命令里逐帧记录。玩家拖动一个物体的过程如果每帧都新建命令,Undo操作会被拖拽过程填满。我用两种方式解决:

  • 把指令的启动和结束分开,用BeginRecord和EndRecord包装。
  • 在移动过程中不断覆盖“当前命令”的NewValue,直到松手才压入栈。

这样一次拖拽只产生一次撤销记录,玩家体验最自然。

3. 从C++到蓝图的跨界接缝

3.1 插件模块划分:Runtime与Editor相互独立

插件要同时服务运行时和编辑器。最稳的做法是创建两个模块,一个是Runtime模块,一个是Editor模块。Runtime模块包含撤销栈、命令基类、子系统以及蓝图接口,Editor模块可以包含调试工具、编辑器窗口、日志面板等辅助功能,但运行时完全不依赖它。

插件目录结构大概长这样:

MyUndoSystem/ MyUndoSystem.uplugin Source/ MyUndoSystem/ // Runtime模块 Public/ Private/ MyUndoSystemEditor/ // Editor模块 Public/ Private/

.uplugin里用Modules数组声明两个模块:

{ "Modules": [ { "Name": "MyUndoSystem", "Type": "Runtime", "LoadingPhase": "Default" }, { "Name": "MyUndoSystemEditor", "Type": "Editor", "LoadingPhase": "PostEngineInit" } ] }

这保证了在打正式包时不包含编辑器模块,也避免在Game线程里意外引用Editor API导致链接错误。

3.2 给蓝图提供操作入口

纯C++写完之后,策划同学是没法直接用的。所以我把插件的主要功能以Subsystem形式暴露给蓝图,Method都标记为BlueprintCallable。具体来说,我做了UMyUndoSubsystem,继承UGameInstanceSubsystem。因为游戏运行时全局只需要一个撤销管理器,GameInstanceSubsystem生命周期和游戏匹配,比挂在某个Actor上更稳。

核心暴露接口包括:

UFUNCTION(BlueprintCallable, Category = "Undo System") UMyUndoCommand* PushCommand(TSubclassOf<UMyUndoCommand> CommandClass); UFUNCTION(BlueprintCallable, Category = "Undo System") bool Undo(); UFUNCTION(BlueprintCallable, Category = "Undo System") bool Redo(); UFUNCTION(BlueprintCallable, Category = "Undo System") void BeginUndoGroup(const FString& GroupName); UFUNCTION(BlueprintCallable, Category = "Undo System") void EndUndoGroup(); UFUNCTION(BlueprintCallable, Category = "Undo System") void ClearHistory();

BeginUndoGroup和EndUndoGroup是一组批量操作包装。比如一次“建造塔楼”由生成地基、生成墙体、生成塔尖三次命令组成,如果三次命令都独立撤销,玩家会觉得奇怪。把它们包进同一个Group之后,撤销一次,整个塔楼消失,体验自然得多。我在Stack内部用了一个临时GroupIndex,Begin时把当前栈高度记录为标记,End时候把所有从标记点后压入的命令打上同一个GroupID。

3.3 在Blueprint中创建自定义命令子类

因为UMyUndoCommand是Blueprintable的,策划可以在编辑器里新建蓝图类继承它,然后重写Execute/Undo/Redo三个事件。这也是我整个插件最自豪的设计——不需要写一行C++,就能扩展新的可撤销操作。

Blueprint子类里面,你可以访问TargetActorPtr、NewValue、OldValue这些UPROPERTY变量。在执行和撤销逻辑里使用“Set Actor Location”之类的节点即可。等于把命令模式变成了可视化编程拼图,策划用起来反馈很好。

需要提醒的是:Blueprint自定义命令一定要重写IsValid()或检查WeakObjectPtr有效性。蓝图事件没法判断悬空引用,如果玩家已经删掉了命令里的目标Actor,Undo时执行SetActorLocation会直接报错。所以我在基类里加了一个BlueprintPure的IsCommandValid函数,在蓝图执行Undo前先判断一次。

4. 实操过程:从创建插件到实现一个可撤销的移动命令

4.1 搭建插件骨架

这部分我会完整走一遍,新手跟着做可以直接落地。

第一步,在UE5编辑器里打开你的工程,点菜单“Edit” -> “Plugins” -> 右上角“Add”按钮,选择“Blank”类型插件,填好名称,比如MyUndoSystem。注意勾选“Can contain content”和“Supported target platforms”里的Win64,底层是否支持移动平台可以先不勾,但代码层面我们尽量平台无关。

如果不想用编辑器生成,也可以直接在项目根目录创建Plugins/MyUndoSystem文件夹,手动写.uplugin和Source目录。我更推荐编辑器生成,它能自动生成ModuleRules和基本文件,省去自己配置头文件引用路径的功夫。

第二步,修改生成出来的MyUndoSystem.Build.cs,确认包含以下依赖:

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "SlateCore", "Slate" });

如果纯Runtime模块,其实Slate/SlateCore都可以不引,但为了将来在Runtime里也能弹出调试UI,我把它们加上了。注意Runtime模块里绝对不能加UnrealEd、EditorStyle、KismetCompiler这些依赖。加了会导致打包失败或运行期加载不稳定。

第三步,在Private里添加模块启动类,继承IModuleInterface,至少要实现StartupModule和ShutdownModule。代码很简单:

class FMyUndoSystemModule : public IModuleInterface { public: virtual void StartupModule() override {} virtual void ShutdownModule() override {} }; IMPLEMENT_MODULE(FMyUndoSystemModule, MyUndoSystem)

4.2 编写“移动Actor”命令的完整流程

现在从空文件开始实现第一个命令类。

头文件MyMoveActorCommand.h:

#pragma once #include "CoreMinimal.h" #include "UndoCommand.h" #include "MyMoveActorCommand.generated.h" UCLASS() class MYUNDOSYSTEM_API UMyMoveActorCommand : public UMyUndoCommand { GENERATED_BODY() public: virtual bool Execute() override; virtual bool Undo() override; virtual bool Redo() override; UPROPERTY() TWeakObjectPtr<AActor> TargetActor; UPROPERTY() FTransform OldTransform; UPROPERTY() FTransform NewTransform; };

实现文件MyMoveActorCommand.cpp:

#include "MyMoveActorCommand.h" #include "GameFramework/Actor.h" bool UMyMoveActorCommand::Execute() { if (!TargetActor.IsValid()) { return false; } // 第一次执行时,记住当前位置是旧位置 OldTransform = TargetActor->GetActorTransform(); TargetActor->SetActorTransform(NewTransform); return true; } bool UMyMoveActorCommand::Undo() { if (!TargetActor.IsValid()) { return false; } TargetActor->SetActorTransform(OldTransform); return true; } bool UMyMoveActorCommand::Redo() { if (!TargetActor.IsValid()) { return false; } TargetActor->SetActorTransform(NewTransform); return true; }

这里有个细节:Execute里记录OldTransform不放在构造函数里,是因为构造函数往往在“操作发生前”执行,此时我们可能还不知道目标Actor是谁,或者Actor还没生成。把旧数据捕获延迟到Execute,更符合实际调用流。

调用方示例:

UMyMoveActorCommand* Cmd = NewObject<UMyMoveActorCommand>(); Cmd->CommandName = TEXT("MovePlayerPlatform"); Cmd->TargetActor = SomeActor; FTransform NewTrans = SomeActor->GetActorTransform(); NewTrans.SetLocation(FVector(100.f, 0.f, 0.f)); Cmd->NewTransform = NewTrans; UMyUndoSubsystem* Undo = GetGameInstance()->GetSubsystem<UMyUndoSubsystem>(); Undo->PushCommand(Cmd);

PushCommand内部会先调用Execute,Execute成功后才压入Undo栈并清空Redo栈。这样命令对象的写入顺序统一是“先执行,后入栈”。

4.3 在场景里测试插件的Undo/Redo

我在场景里放了一个Cube,一个方向键控制器。玩家按F触发移动命令,按Z撤销,按Y重做。每按一次F,Cube沿X轴正方向移动100单位,并且撤销栈里多一条命令。按Z回到之前的位置,再按Y再前进一次。

实测下来最顺的接法是用Enhanced Input Action。在项目设置里映射“UndoAction”到UMyUndoSubsystem::Undo,用蓝图直接连,不用额外写输入代码。按我的经验,我把Undo绑定在Z键、Redo绑定在Y键,F键作为“生成移动命令”的测试键。注意在蓝图调用PushCommand前,要NewObject一个子类命令并设置参数,别直接Push基类,因为基类是Abstract。

4.4 处理批量操作:生成并移动一组物体的一次性撤销

如果我按一次键要同时生成10个箱子,并且把其中一个箱子移动了一下,玩家可能希望一次撤销恢复这全部。用BeginUndoGroup和EndUndoGroup包装即可。

在蓝图里这么连:

  • 事件开始 -> BeginUndoGroup
  • 分支A:生成箱子1、生成箱子2……生成箱子10
  • 分支B:移动箱子5
  • EndUndoGroup

这一步会让中间产生的所有命令被打上同一个GroupID,撤销时一次弹掉全部。实现上有两种策略,一种是用GroupIndex连续区间,另一种是给每个命令一个GroupTag字段、在Undo时找到最后一个GroupTag连续区间弹出。后者实现更灵活,因为不同类命令可以在不同时间进入同一个组,不影响结束标记。我最终选了GroupTag实现,代码也很简单,在UMyUndoCommand里加int32 GroupID,每开始一个Group就把GroupID++,组内的命令都使用同一个GroupID,Undo时不断弹出栈顶直到遇到GroupID不同的命令为止。

5. 常见问题与排查技巧实录

5.1 撤销之后Actor明明还活着,但命令报错

这是我最常被问到的问题。表现是:Undo完,再执行其他操作,日志里出现“Accessed None”或“Object is no longer valid”之类的报错。原因多半是命令里用了TWeakObjectPtr,但你在Undo里没检查有效性,或者某条命令正在执行时目标Actor被其他系统销毁了。

排查方式很简单:在命令子类里重写IsCommandValid,确保执行前检查所有WeakObjectPtr:

UFUNCTION(BlueprintPure, Category = "Undo System") bool IsCommandValid() const;

我还在Undo栈里加了一个“清理无效命令”的步骤。如果栈顶命令IsCommandValid返回false,直接弹出丢弃,并继续检查下一条。这样可以避免历史栈被失效命令堵住。

5.2 撤销Stack的压栈顺序导致的上层逻辑错乱

这个典型场景是:玩家生成一个Actor,然后给Actor绑定了一个动态委托,委托里又触发了一次PushCommand。如果顺序没控制好,第二次PushCommand的Execute可能在第一次命令入栈之前就执行了,导致栈里的顺序和玩家操作顺序不一致。

解决办法是给命令加时间戳和唯一序号。每次PushCommand时由Subsystem分配自增序号,Command里的OrderID = ++GlobalOrderID。调试时按OrderID排序显示,你会发现实际入栈顺序一目了然。同时,在设计API时明确要求调用方不要在同一个栈操作中再Push命令,尽量延迟到下帧执行。

5.3 蓝图子类命令引发循环调用

蓝图子类里,如果你在Undo里又调用了PushCommand,或者调用了一个会触发其他Undo事件的函数,就会导致栈内循环。比如有一个“门锁开关”命令,Undo的时候想播放一个音效,音效回调又调用Undo,然后再次进入这个命令。这种情况我在实际项目里碰到过两次。

根治办法是给Subsystem加一个bIsRollingBack标志。在Undo/Redo过程中设置标志为true,这期间PushCommand函数直接拒绝执行,只有标志复位后才能继续。另外在蓝图里尽量保持Undo逻辑纯净,不播放动画、不异步调用、不延迟执行,只做数据回滚,这样既稳定又高效。

5.4 栈深度过高造成内存上涨

如果MaxUndoCount设置成无限,并且命令里保存了很大的TArray或结构体数组,内存会被撑爆。我项目的经验值如下:

  • 如果命令里只存Transform,128~256条很安全。
  • 如果命令里保存Actor序列化数据,建议控制在32条以内,并且单个Actor体积不要太大。
  • 对于体积特别大的命令,我实现了“压缩阈值”,当命令大小超过1MB时,自动改用外部存档快照来存储,Undo时从存档恢复。

调试时可以在Subsystem里加一个Markup显示当前Undo栈深度和预估内存值:

int32 GetUndoMemoryUsage() const;

用“命令数量 * 平均命令大小”估算就可以。如果发现内存异常偏高,优先怀疑是不是有命令保存了巨大引用。

5.5 表格:快速排查指南

现象可能原因解决方法
Undo没有任何反应栈空或栈顶命令无效检查命令是否Push成功,用IsCommandValid排查
撤销后场景状态错乱命令里保存了旧数据前就被其他逻辑修改在命令Execute里再捕获一次旧数据
Redo栈被清空用户在执行后做了新操作这是正常行为,不需要修
编辑器里测试正常,打包后崩溃引用了Editor模块检查Build.cs是否包含UnrealEd等依赖
蓝图调用PushCommand没反应没有设置命令属性检查蓝图节点是否创建了子类命令对象并赋值
命令执行期间日志疯狂报错递归调用Undo开启bIsRollingBack保护标志

6. 我的几点经验和后续扩展方向

如果让我重写一遍这个插件,我会在一开始就加入命令合并框架。当前实现里,连续的同类型命令虽然可以通过Group合并,但玩家长按方向键移动角色时,每帧产生的移动命令即使被合并,仍然在栈里占位置。更好的做法是给“移动命令”实现TryMerge,判断新命令和栈顶命令的操作目标是否同一个Actor,如果是,直接替换栈顶命令的NewLocation,而不新增一条命令。这个改动能把移动类的撤销开销压缩到接近零。

针对存档和网络同步,插件也可以继续扩展。运行时撤销系统天然适合做“操作回放”和“操作广播”。在每个命令里增加一个ToBytes/FromBytes接口,就能把玩家的操作序列化通过网络发给其他客户端,或者写入回放文件。这比直接同步最终游戏状态要直观得多,也更能保证所有客户端看到一致的撤销/重做过程。

最后说一点个人体会:撤销系统看似简单,难点全在边界条件。对象生命周期、命令顺序、批量操作、跨帧回滚,每个细节都可能让系统雪崩。我反复建议,先从一个最小的“移动命令”跑通全流程,再逐步添加复杂命令,千万别一上来就想做“全场景撒销”。框架稳定后,扩展新命令类型其实很快。现在我项目里已经有二十多种命令,从生成、删除、移动、旋转到修改材质参数、调整灯光颜色、改变地形高度,全部走同一个命令栈,策划已经离不开它了。这套东西做下来,我最大的收获不是代码本身,而是学会了用命令模式去重新审视所有“可逆行为”——凡是能说清楚“做了什么”和“怎么还回去”的操作,都应该放进命令栈里统一管理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询