UE5运行时撤销系统:基于命令模式的插件设计与实现 📅 发布时间:2026/9/19 13:05:30 👁 浏览次数: 做UE5开发的朋友应该都体会过引擎自带的Undo系统只在编辑器里管用到了运行时游戏里玩家拖了个物件、改了个属性想按CtrlZ撤销结果一点反应没有。这不怪引擎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里内部维护两个TArrayUPROPERTY() TArrayTObjectPtrUMyUndoCommand UndoStack; UPROPERTY() TArrayTObjectPtrUMyUndoCommand 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保存所有场景对象引用用之前先查有效性TWeakObjectPtrAActor 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(TSubclassOfUMyUndoCommand 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内部用了一个临时GroupIndexBegin时把当前栈高度记录为标记End时候把所有从标记点后压入的命令打上同一个GroupID。3.3 在Blueprint中创建自定义命令子类因为UMyUndoCommand是Blueprintable的策划可以在编辑器里新建蓝图类继承它然后重写Execute/Undo/Redo三个事件。这也是我整个插件最自豪的设计——不需要写一行C就能扩展新的可撤销操作。Blueprint子类里面你可以访问TargetActorPtr、NewValue、OldValue这些UPROPERTY变量。在执行和撤销逻辑里使用“Set Actor Location”之类的节点即可。等于把命令模式变成了可视化编程拼图策划用起来反馈很好。需要提醒的是Blueprint自定义命令一定要重写IsValid()或检查WeakObjectPtr有效性。蓝图事件没法判断悬空引用如果玩家已经删掉了命令里的目标ActorUndo时执行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() TWeakObjectPtrAActor 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 NewObjectUMyMoveActorCommand(); 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()-GetSubsystemUMyUndoSubsystem(); Undo-PushCommand(Cmd);PushCommand内部会先调用ExecuteExecute成功后才压入Undo栈并清空Redo栈。这样命令对象的写入顺序统一是“先执行后入栈”。4.3 在场景里测试插件的Undo/Redo我在场景里放了一个Cube一个方向键控制器。玩家按F触发移动命令按Z撤销按Y重做。每按一次FCube沿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移动箱子5EndUndoGroup这一步会让中间产生的所有命令被打上同一个GroupID撤销时一次弹掉全部。实现上有两种策略一种是用GroupIndex连续区间另一种是给每个命令一个GroupTag字段、在Undo时找到最后一个GroupTag连续区间弹出。后者实现更灵活因为不同类命令可以在不同时间进入同一个组不影响结束标记。我最终选了GroupTag实现代码也很简单在UMyUndoCommand里加int32 GroupID每开始一个Group就把GroupID组内的命令都使用同一个GroupIDUndo时不断弹出栈顶直到遇到GroupID不同的命令为止。5. 常见问题与排查技巧实录5.1 撤销之后Actor明明还活着但命令报错这是我最常被问到的问题。表现是Undo完再执行其他操作日志里出现“Accessed None”或“Object is no longer valid”之类的报错。原因多半是命令里用了TWeakObjectPtr但你在Undo里没检查有效性或者某条命令正在执行时目标Actor被其他系统销毁了。排查方式很简单在命令子类里重写IsCommandValid确保执行前检查所有WeakObjectPtrUFUNCTION(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或结构体数组内存会被撑爆。我项目的经验值如下如果命令里只存Transform128~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接口就能把玩家的操作序列化通过网络发给其他客户端或者写入回放文件。这比直接同步最终游戏状态要直观得多也更能保证所有客户端看到一致的撤销/重做过程。最后说一点个人体会撤销系统看似简单难点全在边界条件。对象生命周期、命令顺序、批量操作、跨帧回滚每个细节都可能让系统雪崩。我反复建议先从一个最小的“移动命令”跑通全流程再逐步添加复杂命令千万别一上来就想做“全场景撒销”。框架稳定后扩展新命令类型其实很快。现在我项目里已经有二十多种命令从生成、删除、移动、旋转到修改材质参数、调整灯光颜色、改变地形高度全部走同一个命令栈策划已经离不开它了。这套东西做下来我最大的收获不是代码本身而是学会了用命令模式去重新审视所有“可逆行为”——凡是能说清楚“做了什么”和“怎么还回去”的操作都应该放进命令栈里统一管理。