In the previous post, we created an asset with a graph editor:

In this post, let’s learn to add nodes to our graph editor and add command menus to nodes.

Node that in this post, I implement both UEdGraphSchema and UGraphNode in the editor module, which is definately wrong, I will fix this bug in post XI, they are actually all runtime classes, should be added into runtime module, please pay attention to this problem and sorry about my fault.

1. Custom GraphNode

In Unreal Engine 5, a node corresponds to a UEdGraphNode, and tis corresponding editor concept is SGraphNode, which is widely used in UE build-in assets, including but not limited to animation blueprints, blueprints, state machine and behaviour trees.

We create a UOurGraphNode that inherits from UEdGraphNode:

UCLASS()
class ADVANCEDALSEDITOR_API UOurGraphNode : public UEdGraphNode
{
	GENERATED_BODY()

public:

	inline static FName OurGraphNodePinCategory {"OurGraphNodePinCategory"};
	inline static FName OurGraphNodePinSubCategory {"OurGraphNodePinSubCategory"};
	
	/** Begin UEdGraphNode Interface */
	
	// 分配默认的引脚
	virtual void AllocateDefaultPins() override;
	// 节点的Title
	virtual FText GetNodeTitle(ENodeTitleType::Type TitleType) const override;
	// 节点Title的颜色
	virtual FLinearColor GetNodeTitleColor() const override;
	// Tooltip内容
	virtual FText GetTooltipText() const override;
	
	/** End UEdGraphNode Interface */
};

Which is implemented as:

#include "OurGraphNode.h"

void UOurGraphNode::AllocateDefaultPins()
{
	CreatePin(EGPD_Output, OurGraphNodePinCategory, OurGraphNodePinSubCategory, nullptr, TEXT(""));
}

FText UOurGraphNode::GetNodeTitle(ENodeTitleType::Type TitleType) const
{
	return NSLOCTEXT("EditorExtension", "Our Graph Node Title", "我们的节点");
}

FLinearColor UOurGraphNode::GetNodeTitleColor() const
{
	return FLinearColor::Red;
}

FText UOurGraphNode::GetTooltipText() const
{
	return NSLOCTEXT("EditorExtenstion", "Our Graph Node Tooltip", "我们的节点的Tooltip");
}

Note that:

  • The AllocateDefaultPins method is used to determine the default pins of this node, but pins can be added after the node is created, this situation is very common in UE, for example:

  • The interface CreatePins used by the AllocateDefaultPins method, which also prompts us to add the interface of the pin: by using CreatePins().
  • Other functions are used to display the content of the node, like title, color of the title and tooltips.
  • Before we go to the next phase, let’s display this node first to see if we are doing right. Let’s go back to OurAssetEditorToolkit and add ad intermediate function:
class ADVANCEDALSEDITOR_API FOurAssetEditorToolkit final : public FAssetEditorToolkit
{
public:
	// 必须实现的接口
	virtual FName GetToolkitFName() const override { return FName("OurAssetsEditorToolkit"); }
	virtual FText GetBaseToolkitName() const override { return NSLOCTEXT("EditorExtension", "Out Asset Toolkit Name", "我们的资产编辑器"); }
	virtual FString GetWorldCentricTabPrefix() const override { return NSLOCTEXT("EditorExtension", "Out Asset Toolkit Tab Prefix", "我们的资产").ToString(); }
	virtual FLinearColor GetWorldCentricTabColorScale() const override { return FLinearColor::Green; }
	
public:
	virtual void RegisterTabSpawners(const TSharedRef<FTabManager>& InTabManager) override;
	virtual void UnregisterTabSpawners(const TSharedRef<FTabManager>& InTabManager) override;

	// 这个函数并不是虚函数,也不含有模式匹配,为公开函数被外部调用
	void InitializeAssetEditor(const EToolkitMode::Type Mode, const TSharedPtr<IToolkitHost>& InitToolkitHost, UObject* InAssets);

private:

	// 生成细节面板
	TSharedRef<SDockTab> SpawnDetailTab(const FSpawnTabArgs& SpawnTabArgs) const;
	TObjectPtr<UEdGraph> EdGraph = {};

	inline static const FName GraphEditorTabName {"OurAssetsGraphEditor"};
	inline static const FName PropertyEditorTabName {"OurAssetsPropertyEditor"};

private:

	UEdGraphNode* CreateDebugNode(UEdGraph* ParentGraph, const FVector2D NodeLocation) const;
};

The implementation of CreateDebugNode is :

UEdGraphNode* FOurAssetEditorToolkit::CreateDebugNode(UEdGraph* ParentGraph, const FVector2D NodeLocation) const
{
	check(ParentGraph != nullptr)
	UEdGraphNode* ResultGraphNode = NewObject<UOurGraphNode>(ParentGraph);
	ParentGraph->Modify();
	ResultGraphNode->SetFlags(RF_Transactional);

	ResultGraphNode->Rename(nullptr, ParentGraph, REN_NonTransactional);
	ResultGraphNode->CreateNewGuid();
	ResultGraphNode->NodePosX = NodeLocation.X;
	ResultGraphNode->NodePosY = NodeLocation.Y;

	ResultGraphNode->AllocateDefaultPins();
	return ResultGraphNode;
}

Then, we can call it when initializeAssetEditor:

void FOurAssetEditorToolkit::InitializeAssetEditor(const EToolkitMode::Type Mode,
	const TSharedPtr<IToolkitHost>& InitToolkitHost, UObject* InAssets)
{
	// 这里我只是为了让他简单的显示出来才这么做的。
	// 如果真的要编辑一个可以被持久化存储的图,图应该存在自定义资产中,随自定义产的创建初始化。
	
	EdGraph = NewObject<UEdGraph>();
	EdGraph->Schema = UEdGraphSchema::StaticClass();
	EdGraph->AddToRoot();

	UEdGraphNode* EdGraphNode = CreateDebugNode(EdGraph, {0, 0});
	EdGraph->AddNode(EdGraphNode);
	
	const TSharedRef<FTabManager::FLayout> StandaloneOurAssetEditor = FTabManager::NewLayout("OutAssetEditor")->AddArea
			(
				FTabManager::NewPrimaryArea()->SetOrientation(EOrientation::Orient_Horizontal)
				->Split(FTabManager::NewStack()->AddTab(FName("OutAssetsGraphEditorTab"), ETabState::OpenedTab))
			);
	InitAssetEditor(Mode, InitToolkitHost, FName("OurAssetEditor"), StandaloneOurAssetEditor, true, true, InAssets);
	RegenerateMenusAndToolbars();
}

build the project and restart the editor:

So far so good, because our graph is reset during init the editor, and stored in the editor rather the asset, there will be no change every time we open the asset. But if the graph is a member variable of the asset, we can see the change, that what we are going to see in next post.

2. Custom Schema

we have made our node display, but this is not the creation method we wish to see, we want to right-click the graph to create, but it is now not possible:

The class related to creating and linking pins is the UEdGraphSchema, which specifies the creation of node, pin linking rules etc. In this post, we focus on creating nodes and node command menu. In the last post, we used a default schema:

EdGraph->Schema = UEdGraphSchema::StaticClass();

In order to meet out need, we need our own EdGraphSchema:

UCLASS()
class ADVANCEDALSEDITOR_API UOurGraphSchema : public UEdGraphSchema
{
	GENERATED_BODY()
	
protected:

	inline static FName OurNodeContextMenuSection {"OurNodeContextMenuSection"};
	inline static FName OurPinContextMenuSection {"OurPinContextMenuSection"};
	
public:

	/** Begin UEdGraphSchema Interface */

	// 右键空白处或者拖拽引脚调用函数
	virtual void GetGraphContextActions(FGraphContextMenuBuilder& ContextMenuBuilder) const override;
	// 右键一个节点或者引脚出现的函数
	virtual void GetContextMenuActions(UToolMenu* Menu, UGraphNodeContextMenuContext* Context) const override;
	
	/** End UEdGraphSchema Interface */
};

We’ll pay attention to connect the pins in next post, now, we focus on there two functions: GetGraphContextActions and GetContextMenuActions.

Add context menu for nodes/pins

In order to coping, pasting, cutting and deleting nodes, we need to implement GetContextMenuActions. The implmentation of this function is very similar to the MenuBar extension we have talked before:

void UOurGraphSchema::GetContextMenuActions(UToolMenu* Menu, UGraphNodeContextMenuContext* Context) const
{
	if (Context->Node)
	{
		FToolMenuSection& Section = Menu->AddSection(OurNodeContextMenuSection, NSLOCTEXT("EditorExtension", "Our Node Context Menu Section Name", "我们的节点操作"));
		Section.AddMenuEntry(FGenericCommands::Get().Copy);
		Section.AddMenuEntry(FGenericCommands::Get().Cut);
		Section.AddMenuEntry(FGenericCommands::Get().Paste);
		Section.AddMenuEntry(FGenericCommands::Get().Delete);
	}
	if (Context->Pin)
	{
		FToolMenuSection& Section = Menu->AddSection(OurPinContextMenuSection, NSLOCTEXT("EditorExtension", "Our Node Pin Menu Section Name", "我们的引脚操作"));
		Section.AddMenuEntry(FGenericCommands::Get().Delete);
	}
	Super::GetContextMenuActions(Menu, Context);
}

Pay attention to the distinction between Node and Pin. It is meaningless to copy a Pin.

Bug fix

You will that now you can get the command buttons but it doesn’t work acutually, that is because the GenericCommand only decribes the style of the button and have no callback by default, you need to bind callback for them.

An example is the “Delete” command, first, you need to pass a param with TSharedPtr<FUICommandList> type for your graph editor widget:

TSharedRef<SDockTab> FAdvancedDialogueAssetEditorToolkit::SpawnGraphEditorTab(const FSpawnTabArgs& SpawnTabArgs)
{
	CreateCommandList();
	
	SGraphEditor::FGraphEditorEvents GraphEditorEvents;
	GraphEditorEvents.OnSelectionChanged = SGraphEditor::FOnSelectionChanged::CreateSP(this, &FAdvancedDialogueAssetEditorToolkit::OnSelectedNodesChanged);

	check(EditingAsset)
	check(EditingAsset->AdvancedDialogueGraph != nullptr)
	
	GraphEditorWidget = SNew(SGraphEditor)
						.GraphToEdit(EditingAsset->AdvancedDialogueGraph)
						.GraphEvents(GraphEditorEvents)
						.AdditionalCommands(AdvancedDialogueCommandList);
	return SNew(SDockTab)
	[
		GraphEditorWidget.ToSharedRef()
	];
}

Use MapAction method of TSharedPtr<FUICommandList> to bind callback for your command:

void FAdvancedDialogueAssetEditorToolkit::CreateCommandList()
{
	AdvancedDialogueCommandList = MakeShareable(new FUICommandList);
	AdvancedDialogueCommandList->MapAction(FGenericCommands::Get().Delete, FExecuteAction::CreateRaw(this, &FAdvancedDialogueAssetEditorToolkit::DeleteSelectedNode));
}

Implement the &FAdvancedDialogueAssetEditorToolkit::DeleteSelectedNode method:

void FAdvancedDialogueAssetEditorToolkit::DeleteSelectedNode() const
{
	if (!GraphEditorWidget.IsValid())
	{
		return;
	}

	const FScopedTransaction Transaction(FGenericCommands::Get().Delete->GetDescription());
	GraphEditorWidget->GetCurrentGraph()->Modify();
	
	const FGraphPanelSelectionSet SelectedNodes = GraphEditorWidget->GetSelectedNodes();
	GraphEditorWidget->ClearSelectionSet();

	for (FGraphPanelSelectionSet::TConstIterator NodeIterator(SelectedNodes); NodeIterator; ++NodeIterator)
	{
		if (UEdGraphNode* GraphNodeToDelete = Cast<UEdGraphNode>(*NodeIterator))
		{
			if (GraphNodeToDelete->CanUserDeleteNode())
			{
				GraphNodeToDelete->Modify();
				GraphNodeToDelete->DestroyNode();
			}
		}
	}
}

Remeber to modify:

void FOurAssetEditorToolkit::InitializeAssetEditor(const EToolkitMode::Type Mode,
	const TSharedPtr<IToolkitHost>& InitToolkitHost, UObject* InAssets)
{
	// 这里我只是为了让他简单的显示出来才这么做的。
	// 如果真的要编辑一个可以被持久化存储的图,图应该存在自定义资产中,随自定义产的创建初始化。
	
	EdGraph = NewObject<UEdGraph>();
	EdGraph->Schema = UOurGraphSchema::StaticClass();
	EdGraph->AddToRoot();

	UEdGraphNode* EdGraphNode = CreateDebugNode(EdGraph, {0, 0});
	EdGraph->AddNode(EdGraphNode);
	
	const TSharedRef<FTabManager::FLayout> StandaloneOurAssetEditor = FTabManager::NewLayout("OutAssetEditor")->AddArea
			(
				FTabManager::NewPrimaryArea()->SetOrientation(EOrientation::Orient_Horizontal)
				->Split(FTabManager::NewStack()->AddTab(FName("OutAssetsGraphEditorTab"), ETabState::OpenedTab))
			);
	InitAssetEditor(Mode, InitToolkitHost, FName("OurAssetEditor"), StandaloneOurAssetEditor, true, true, InAssets);
	RegenerateMenusAndToolbars();
}

Restart the editor, LINK@019 should explode without accident, just import the ToolMenu module to fix it:

Again!

The menu when right-clicking the graph and dragging the pin

First of all, let’s think about it: we have a bunch of options in the right-click menu, so it must be that each option corresponds to a node?

That’s not necessarily the case, we know that UE is very flexiable, what we get is a bunch of MenuEntry, which is no different from the buttons in engine editor that we have seen for so many times.

Therefore, let’s do an operation first: let’s create a one that can be clicked to print Hello World, and then we will look at creating nodes.

Each available button is a FEdGraphSchemaAction. So let’s create our own FEdGraphSchemaAction:

USTRUCT()
struct FPrintHelloGraphSchemaAction : public FEdGraphSchemaAction
{
	GENERATED_USTRUCT_BODY()

public:

	FPrintHelloGraphSchemaAction():FEdGraphSchemaAction(
		NSLOCTEXT("EditorExtension", "Special Actions Category", "特殊操作菜单"),
		NSLOCTEXT("EditorExtension", "Print Hello Action", "打印Hello World"),
		NSLOCTEXT("EditorExtension", "Print Hello Tooltip", "打印Hello World的提示"),
		0
	){}

	// 核心函数
	virtual UEdGraphNode* PerformAction(UEdGraph* ParentGraph, UEdGraphPin* FromPin, const FVector2D Location, bool bSelectNewNode) override;
};

The core content is PerformAction, which specifies what will happen after pressing. However, now you can see your Action. Just need to register in GetGraphContextActions:

void UOurGraphSchema::GetGraphContextActions(FGraphContextMenuBuilder& ContextMenuBuilder) const
{
	const TSharedPtr<FPrintHelloGraphSchemaAction> Action = MakeShareable(new FPrintHelloGraphSchemaAction);
	ContextMenuBuilder.AddAction(Action);
	Super::GetGraphContextActions(ContextMenuBuilder);
}

After doing this, let’s implent the PerformAction method:

UEdGraphNode* FPrintHelloGraphSchemaAction::PerformAction(UEdGraph* ParentGraph, UEdGraphPin* FromPin,
	const FVector2D Location, bool bSelectNewNode)
{
	if (FromPin)
	{
		FMessageDialog::Open(EAppMsgType::Ok, NSLOCTEXT("EditorExtension", "Print Action With Pin", "Hello World,并且带了个节点"));
	}
	else
	{
		FMessageDialog::Open(EAppMsgType::Ok, NSLOCTEXT("EditorExtension", "Print Action Without Pin", "Hello World"));
	}
	return nullptr;
}

FromPin is a nice parameter that lets you know if a pin has been pulled from the node.

This is fine for nodes that will automatically connect the pins of the nodes.

Ok, now let’s look at how to add a node, UE helped us build the wheel, which is:

struct ENGINE_API FEdGraphSchemaAction_NewNode : public FEdGraphSchemaAction

...
FEdGraphSchemaAction_NewNode() 
		: FEdGraphSchemaAction()
		, NodeTemplate(nullptr)
	{}

	FEdGraphSchemaAction_NewNode(FText InNodeCategory, FText InMenuDesc, FText InToolTip, const int32 InGrouping)
		: FEdGraphSchemaAction(MoveTemp(InNodeCategory), MoveTemp(InMenuDesc), MoveTemp(InToolTip), InGrouping)
		, NodeTemplate(nullptr)
	{}

...

Inherits from it:

USTRUCT()
struct FOurGraphSchemaAction_NewNode : public FEdGraphSchemaAction_NewNode
{
	GENERATED_USTRUCT_BODY()
public:

	UClass* NodeClass;

	FOurGraphSchemaAction_NewNode():FEdGraphSchemaAction_NewNode(
	NSLOCTEXT("EditorExtension", "Create Node Category", "创建节点菜单"),
		NSLOCTEXT("EditorExtension", "Create Out Node Action", "创建我们的节点"),
	NSLOCTEXT("EditorExtension", "Create Out Node Tooltip", "创建我们的节点"),
	0
	), NodeClass(nullptr){}

	void SetNodeClass(UClass* InNodeClass);
	virtual UEdGraphNode* PerformAction(UEdGraph* ParentGraph, UEdGraphPin* FromPin, const FVector2D Location, bool bSelectNewNode) override;
};

Why should I add a NodeClass member is that our Node is UObject, so we can get all its subclasses.

We can automatically register all subclasses of a specific node base class.

Among them, the implementation of PerformAction is:

UEdGraphNode* FOurGraphSchemaAction_NewNode::PerformAction(UEdGraph* ParentGraph, UEdGraphPin* FromPin,
                                                           const FVector2D Location, bool bSelectNewNode)
{
	UEdGraphNode* GraphNodeTemplate = Cast<UEdGraphNode>(NodeClass->ClassDefaultObject);
	UEdGraphNode* NewGraphNode = CreateNode(ParentGraph, FromPin, Location, GraphNodeTemplate);
	return nullptr;
}

void FOurGraphSchemaAction_NewNode::SetNodeClass(UClass* InNodeClass)
{
	NodeClass = InNodeClass;
}

This CreateNode is a function implemented by FEdGraphSchemaAction_NewNode. I don’t want to change it and use it directly. But this function requires a template object instead of a class. What should I do? Shall we construct one? Not necessarily, the incoming CDO is very suitable.

void UOurGraphSchema::GetGraphContextActions(FGraphContextMenuBuilder& ContextMenuBuilder) const
{
	const TSharedPtr<FPrintHelloGraphSchemaAction> Action = MakeShareable(new FPrintHelloGraphSchemaAction);
	ContextMenuBuilder.AddAction(Action);
	
	const TSharedPtr<FOurGraphSchemaAction_NewNode> CreateNodeAction = MakeShareable(new FOurGraphSchemaAction_NewNode);
	CreateNodeAction->SetNodeClass(UOurGraphNode::StaticClass());
	ContextMenuBuilder.AddAction(CreateNodeAction);
	
	Super::GetGraphContextActions(ContextMenuBuilder);
}

In actual development, you can directly register all subclasses of UOurGraphNode in a for loop.

By JiahaoLi

Hypergryph - Game Programmer 2023 - Now Shandong University - Bachelor 2019-2023

2 thoughts on “Using Slate for UI development and editor(10): Create custom nodes and schema”

Leave a Reply

Your email address will not be published. Required fields are marked *