type
status
date
slug
summary
tags
category
icon
password
fullWidth
fullWidth
🐈‍⬛
”开发一个可视化节点的AVG剧情编辑器”应用场景为例,讲述如何扩展虚幻编辑器。
  • 虚幻版本:5.5.4

创建插件

我希望以插件的形式扩展编辑器,因此需要先创建插件。创建项目后,来到EditPlugins,选择Blank,填写基本信息后点击Create Plugin ,等待引擎创建代码。
notion image
notion image
回到Visual Studio,发现Plugins目录下已有刚才创建的插件代码,我将插件命名为PlotEditor,发现创建出PlotEditor.uplugin(插件描述文件)。
notion image
打开Source/PlotEditor.Build.cs,在PrivateDependencyModuleNames中加入必要模块依赖:
然后右键.uproject,重新生成工程并编译。

自定义资产类型和行为

虚幻的编辑器扩展体系分三层:
目标
实现类
功能
定义资产类型
UObject 派生类
存放数据
注册资产到 Content Browser
UFactory+FAssetTypeActions
支持创建与双击打开
自定义编辑器
FAssetEditorToolkit+Slate Widget
自定义编辑界面
对此,可以先在/Source/PlotEditor目录下先规划出必要的类:
文件
作用
Private/UPlotEditorEntry.h
定义资产类型
Private/UPlotEditorEntryFactory.h/.cpp
创建资产的工厂
Public/UPlotEditorEntryActions.h/.cpp
注册到内容浏览器
Private/UPlotEditorToolkit.h/.cpp
自定义资产编辑器
Public/PlotyEditorModule.cpp
插件入口模块(插件生成时已定义,可以改个名)
接下来一一实现它们。

自定义资产类型

自定义资产类型UPlotEditorEntry,继承自UObject;保存资产数据。

资产工厂实现

资产工厂UPlotEditorEntryFactory 继承自UFactory,并覆写FactoryCreateNew接口。
实现构造函数和FactoryCreateNew接口。
至此为止,已经可以在内容浏览器菜单右键创建UPlotEditorEntry资产;但是发现双击没反应,这首由于缺少资产行为导致的。
notion image
notion image

资产行为注册

声明资产行为类UPlotEditorEntryActions,继承自FAssetTypeActions_Base,覆写其中的某些方法用于自定义资产在内容浏览器中的行为和外观。
其中OpenAssetEditor必不可少的,其实现中应打开自定义资产编辑器。
实现上述接口,指定编辑器命名、样式、支持的资产等信息。
还需要把资产行为类注册到模块中,模块类FPlotEditorModule继承自IModuleInterface接口,必须实现两个接口函数:
  • StartupModule:模块启动时调用
  • ShutdownModule:模块卸载时调用
实现上述接口,可以将AssetTypeAction的引用保存到缓存中,用于注销

自定义资产编辑器

初始化和Tab注册

上文中我们留下了一个TODO,需要打开自定义资产编辑器,现在来实现它。
首先需要创建一个Toolkit类FPlotEditorToolkit继承自FAssetEditorToolkit,实现必须要实现的虚函数:
  • GetToolkitFName:编辑器的唯一标识符(内部名称)
  • GetBaseToolkitName:编辑器的显示名称
  • GetWorldCentricTabPrefix:世界中心模式下标签页的前缀(嵌入到关卡编辑器时,标签页显示为"Plot:资源名称”)
  • GetWorldCentricTabColorScale:在世界中心模式下标签页的颜色
覆写RegisterTabSpawnersUnregisterTabSpawners接口。
函数
用途
调用时机(由引擎自动控制)
RegisterTabSpawners()
TabManager注册所有可生成的编辑器窗口
在调用 InitAssetEditor() 时,UE自动调用
UnregisterTabSpawners()
注销所有TabSpawners,防止内存泄漏
当编辑器关闭时(Toolkit销毁)由引擎自动调用
因此可以在RegisterTabSpawners创建Tab对应的Slate控件,实现如下。
InitAssetEditor 函数中会调用RegisterTabSpawners ,通过打断点发现调用栈如下:
notion image
一切就绪之后,把OpenAssetEditor方法中的TODO补上。
现在双击资产能打开资产编辑器,发现默认打开的Tab名称为”Plot Graph” 。
notion image
“窗口 > 工作区”菜单的“Plot Editor”下有此Tab。
notion image
同理,可以再创建一个细节面板。细节面板通过虚幻的反射机制渲染UObject中的各属性。
notion image
创建细节面板的具体方法:

图节点定义和渲染

根据经验,AVG游戏中的剧情节点可以分为两类:对话节点选择节点。对话节点理解为为单线程播片,其呈现内容是固定的;而选择节点会影响剧情走向,后续呈现内容会随着玩家的选择而改变。
因此,需要一个UPlotNodeBase基类,继承自虚幻原生节点UEdGraphNode,并派生出UPlotNode_DialogUPlotNode_Choice两个子类。
.cpp实现,Bind方法处理UEdGraphNode和其对应数据的双向绑定关系(后面会说):
对话节点只有一个输入节点PrevPin和一个输出节点NextPin
.cpp实现:
  • PostTransacted在Transactions结束后调用,这里调用数据类的DoTransacted()序列化Json
  • AutowireNewNode在右键创建节点时调用,根据输入引脚自动连接新节点
  • AllocateDefaultPinsCreateNewNode时自动调用,这里只需创建默认节点
  • CreateVisualWidget创建Slate控件
选择节点有一个输入节点PrevPin和多个输出节点ChoicePins对应不同选项的后续分支。
.cpp实现,CreateOutputPinsByOptions根据数据类UPlotData_ChoiceOptions调整节点引脚数量,简单理解为多退少补,这个方法在后面会用到。
除了节点对象,还需要与节点一一对应的数据模型用于序列化成Json。因此针对上述三个节点类,创建对应的数据类UPlotDataBaseUPlotData_DialogUPlotData_Choice
节点数据基类UPlotDataBase保存共同属性(如节点ID、节点类型)。DoTransacted方法里调用工具类的序列化方法。
.cpp实现:
子类需要在构造函数中指定NodeType(这个变量后面有用,比如在反序列化时读取NodeType创建子类);子类的DoTransacted中根据节点的引脚连接更新数据层。
UPlotData_Dialog需要知道下一个节点的节点ID,由于剧情内容不影响节点结构,打算后面再加:
.cpp实现:
UPlotData_Choice需要知道各选项对应的下一个节点的节点ID,这里用一个数组存储,由于选项个数会影响节点结构,先把选项内容加上了:
.cpp实现:
两种节点对应的Slate控件无需基类,这里覆写CreateBelowPinControls函数,随便渲染一些文字:
notion image
SPlotNode_Dialog实现:必须在构造函数中先赋值GraphNode,再调用UpdateGraphNode;否则无法绘制节点。
SPlotNode_Choice实现:

创建和删除节点

现期望右键或从已有节点的引脚拖出后创建新节点,大概这样:
notion image
对此需要创建UEdGraph专门的Schema类UPlotEditorGraphSchema;创建节点的右键操作需要在FEdGraphSchemaAction中封装和实现;此外还可以覆写CanCreateConnection决定连接规则:
🍓
UEdGraphSchema定义了图中节点之间如何连接、节点类型、Pin 类型等规则;搞过前端的应该熟悉Schema-Renderer模型。
而实际的创建节点可以在工具类FPlotEditorToolkit中实现。如果想要创建节点可撤销/恢复,需要把创建过程封装在一个事务里,即FScopedTransaction Transaction
创建Dialog节点:
创建Choice节点:
这里不用FEdGraphSchemaAction::CreateNode是因为涉及Data和EdGraphNode的绑定,以及一些自定义事务处理,但一些必须要调用的方法和CreateNode里写的是一致,如SetFlagsCreateNewGuidPostPlacedNewNodeAllocateDefaultPinsSnapToGridAutowireNewNode
这两行是因为,在Modify之后修改数据,就会触发事务系统记录以及PostTransacted回调,回调函数里会进行Json序列化(新创一个节点后肯定是需要保存的)。
有添加功能自然也有删除功能,不过删除节点不是写在Schema中,而是写在GraphView中,趁此也把一些其他的命令,如选择变化回调、双击回调、文本提交回调都补全:
当然具体的删除节点的实现还是在工具类FPlotEditorToolkit中实现,需要修改数据上下文EditorContext中保存的节点Map,移除该节点。
说起数据上下文EditorContext,它通过PlotDataMap保存所有节点的数据,并且管理节点ID递增;中心数据类不应包含过多的逻辑,只是作为数据传递的纽带。

序列化和反序列化

工具类

这里不走虚幻资产的序列化方式
需要一个工具类FJsonSerializationHelper来处理数据的序列化和反序列化,实现引擎资产和Json数据互通。DeserializePlotMap反序列化会根据NodeType创建具体子类。
然后再FPlotEditorToolkit中再次封装:

节点和连接初始化

有了反序列化功能,将Json中的节点数据存入EditorContextPlotDataMap中,每次打开资产后,需要根据数据创建节点并建立连接关系。
对于Dialog节点,其输出节点可能连接另一个Dialog节点的输入节点或另一个Choice节点的输入节点:
对于Choice节点的每个输出节点,都需要尝试寻找后继节点并连接,连接方式同上:
到这里为止 ,这个编辑器的框架已经有了。
notion image

节点数据定义

对话节点

notion image
AVG游戏中对话的呈现由说话人+内容构成,因此需要在对话节点中声明一个包含说话人+内容的结构体数组,存储一段连续的对话。
至此为止,发现细节面板可以编辑对话内容数组。
notion image
修改对话内容数组后,应当立即序列化成Json存盘,因此需要覆写属性变化的回调函数PostEditChangeProperty ;这里可以创建在节点数据基类UPlotDataBase中,调用序列化方法。

选择节点

选择节点UPlotData_ChoiceOptions属性由于会动态影响节点引脚个数,在上文中已经写过了。这里只做小优化,在新增数组元素时让新增选项文本呈现”选项x”。
notion image

节点渲染

SGraphNode提供了许多虚函数,可以覆写这些虚函数自定义节点样式。
  • CreateTitleWidget:节点标题
  • CreateTitleRightWidget:标题右侧小区域
  • CreateNodeContentArea:标题下面的整个主体区域(左右 Pin + 中间内容)
  • CreateInputSideAddButton:输入Pin列表最下方,比如给输入Pins下面增加”+”按钮
  • CreateOutputSideAddButton:输出Pin列表最下方,比如给输出Pins下面增加”+”按钮
  • CreateBelowPinControls:Pin区域下方内容
  • CreateBelowWidgetControls:整个节点的最底部
  • CreateAdvancedViewArrow:标题左下角(可折叠箭头位置)

对话节点

对于Dialog节点,我希望实现以下功能:
  • 将”说话人+内容”显示在节点上
  • 对话内容在节点上和细节面板都能编辑
  • 并且节点上提供”+”按钮可以新增对话条目
notion image
创建样式宏。
重写CreateBelowPinControls,渲染节点下方内容。
“+”按钮的回调函数中,需要在数据层新增一条对话条目,并序列化保存。
为了避免节点宽度因对话内容变得过长,可以使用SMultiLineEditableText控件渲染多行可编辑文本;重写CreateNodeContentArea函数,将节点内容包裹在SBox中并指定WidthOverride属性。
最后放一下SPlotNode_Dialog这个Slate控件的头文件。

选择节点

对于Choice节点,希望实现以下功能:
  • 将”选项内容”显示在节点上
  • 选项内容在节点上和细节面板都能编辑
  • 并且节点上提供”+”按钮可以新增选项条目
notion image
首先,将”选项内容”显示在输出节点边上,需要自己写一个节点专用的Pin控件SPlotPin_Choice,覆写GetLabelWidget方法,渲染选项内容为可编辑文本,并提供文本提交回调函数作为Slate Widget的参数
.cpp实现:
由于SGraphPin隶属于SGraphNode,因此还需要覆写SGraphNode中和创建引脚有关的方法,来创建自定义引脚类的实例。
  • 首先是CtreatePinWidgets,把原生的方法抄过来进行魔改。这里用OutputIndex记录每一个输出引脚在所有输出引脚中的索引,这个变量对于后面从Options中取对应的选项有用。
此外,为了接收OutpunIndex变量,需要创建函数CreateStantardPinWidgetChoice(覆写需要函数签名相同,因此只能另外创建了),同样抄原生的方法就行,把里面创建Pin的具体方法替换成自己写的CreatePinWidget_Choice
最后CreatePinWidget_Choice中创建自定义引脚控件SPlotPin_Choice的实例。
同样,SPlotNode_Choice也需要限制节点宽度,并在下方渲染添加选项的按钮。

撤销和重做

事务系统

虚幻的事务系统(Transaction System)负责编辑器中所有撤销和重做的机制;通过FScopedTransaction保存一次操作的记录块,通过Apply()在撤销或重做时恢复对象数据。 具体可以详细看EditorTransaction的源码。 Undo时调用栈:
notion image
Redo时调用栈:
notion image
因此对于自定义的对象(自定义的Node、Graph、Data)都需要设置RF_Transactional标识,使之可以被事务系统捕捉。
notion image
补充一下,对于之前写的在节点面板点”+”按钮添加对话或条目的功能,需要自己创建一个事务块。
这样在节点处添加对话条目也能丝滑撤销了。
notion image
选择节点同理,也需要添加事务块。
选项条目的丝滑撤销。
notion image

注释节点功能

接下来将实现在节点图中创建注释节点的功能,并借此梳理虚幻的资产序列化机制。
notion image

注释节点功能

资产序列化

数据序列化是指将对象转换成可存取的格式(通常是二进制格式或Json);反序列化是指从特定存取格式复原对象。
虚幻中所有的资产(.uasset.umap)底层都是UObject,点击保存资产按钮后,通过AssetEditorToolkit::GetSaveableObjects收集需要保存的资产;然后调用FEditorFileUtils::PromptForCheckoutAndSave进行Package保存。
只要对象的Outer链最终指向Package就会被序列化,并且Packge对象只序列化UPROPERTY标记的属性。
这个案例中,注释节点是纯虚幻编辑器数据,不参与运行时,因此希望走虚幻原生的uasset序列化机制(当然也可以加入自己写的Json序列化机制,只是有点麻烦);而剧情节点(UPlotNode_DialogUPlotNode_Choice)走的是自己写的Json序列化机制。

Outer链

回顾之前的代码,之前在Slate控件的构造函数中创建UEdGraph,然后指定该Graph的Outer对象为EditorContext
这就出现了一个问题,每次打开资产调用FPlotEditorToolkit::InitPlotEditor时,EditorContext都会重新创建:
因此无法序列化Graph,更无法序列化里面的注释节点。
notion image
现进行如下修改,在资产UPlotEditorEntry里新增UPROPERTY,保存Graph
然后在节点图Slate Widget构造函数中创建并传入Graph设置GraphOuter对象为资产
由于需要在Graph对象中保存EditorContext的引用,需要新创建一个变量。
现在的Outer链如下,Graph和其中的Node能被序列化。
notion image
由于Outer链的改变,右键创建新节点的方法相应调整,直接从Graph对象中获取EditorContext

节点创建

注释节点的创建,应当是在选中节点后右键上下文菜单中出现的选项。这个菜单的实现需要覆写Schema中的GetContextMenuActions函数。值得注意的是,之前覆写的GetGraphContextActions空白处的右键菜单,而GetContextMenuActions 是节点上的右键菜单。
然后实现AddComment,里面调用FPlotEditorToolkit的类方法Action_NewComment创建实际的注释节点(和创建剧情节点的方法类似)。
最后需要在FPlotEditorToolkit中补充Action_NewComment函数,实现真正的注释节点创建。
截至目前,能成功创建节点,但发现节点文本提交后无法修改。
notion image

文本提交

针对上述问题,覆写SPlotGraphViewOnNodeTextCommit回调函数,把注释节点的NodeComment属性设置成传入的文本。
这个解决办法在虚幻论坛中亦有记载Comment text doesn’t update。现在注释文本可以成功修改了。
notion image

节点删除

SPlotGraphViewDeleteSelectNodes函数中补充删除注释节点的逻辑,因为走的是虚幻原生的序列化,无需进行数据层的修改;直接调用DestroyNode即可。
注释节点可以被删除。
notion image

自定义细节面板

有两种自定义细节面板的方式,一种是自定义类,第二种是自定义属性类型。自定义类需继承自IDetailCustomization接口,而自定义属性类型需继承自IPropertyTypeCustomization接口。
特性
属性定制
类定制
作用范围
特定数据类型
整个类
复用性
高(任何使用该类型的地方)
低(仅针对特定类)
控制粒度
单个属性
整个面板
常见用途
数据类型的特殊编辑器
类的专用工具面板
继承影响
影响所有子类使用该类型处
只影响当前类

属性类型定制

讲讲自定义属性类型细节面板显示的方法。从具体业务场景入手:
这个剧情节点中有大量柯罗莎和女子的对话,如果对话内容折叠起来,每个索引都显示”2个成员”(说话人和说话内容),非常不方便查看,我希望把 ”2个成员” 的文本替换成人名。
notion image
创建一个自定义属性类型的类FPlotDialogLineCustomization,继承自IPropertyTypeCustomization接口,必须覆写其中的CustomizeHeaderCustomizeChildren函数,因为在父接口中这两个函数是虚函数。
notion image
除此之外,还要在FPlotEditorModule中注册和注销细节面板定制。
StartupModule()中增加以下代码注册对结构体FPlotDialogLine的定制,注意结构体名要去掉前缀F。因为在UPROPERTYUSTRUCTUCLASS的元数据里,反射系统会去掉前缀作为类型名称
ShutdownModule()增加以下代码注销定制:
实现这两个接口:
我们先用Test Custom Content文本测试下显示是否正常,发现已经能正常显示了:
notion image
现在只需要将测试文本替换成Speaker文本即可,通过GetChildHandle获取成员属性Speaker的属性句柄,再自己封装一个类方法GetSpeakerText获取属性值,赋值给Text控件的构造函数:
再次打开对话节点的细节面板,已经奏效了:
notion image

类定制

讲讲自定义类的细节面板显示的方法。从具体业务场景入手:
想要新增一个节点信息汇总的Category,展示节点信息。
notion image
创建一个自定义类细节面板定制的类FPlotDataDetailCustomization,继承自IDetailCustomization接口,覆写其中的CustomizeDetails函数。
覆写CustomizeDetails(),在其中新增一个Category,渲染节点信息:
同样需要在StartupModule()中注册:
ShutdownModule()中注销:
Relate Posts
【独立游戏】Blizzard Syndrome
Lazy loaded image
【独立游戏】MeloGo
Lazy loaded image
【独立游戏】Grey School Plot
Lazy loaded image
【学校项目】智能家居数字人管家
Lazy loaded image
【毕业设计】基于GarmentCode的参数化和组件化服装版片生成
Lazy loaded image
【Built-in渲染】3D卡通角色NPR前向渲染和物理模拟
Lazy loaded image
【Built-in渲染】绘画色彩理论在卡渲后处理中的应用【招聘笔试】CrossChess-井字棋单机小游戏
Loading...
Latest posts
【虚幻Slate】虚幻编辑器扩展之开发一个AVG游戏剧情编辑器
2025-12-12
【TypeScript】性能记录框架和装饰器
2025-12-12
【Built-in渲染】绘画色彩理论在卡渲后处理中的应用
2025-12-12
【散文随笔】我的高中生活(二)
2025-12-12
【散文随笔】我的高中生活(一)
2025-12-12
【兴趣爱好】音乐创作存档
2025-12-12