Custom K2 Node and Thunk for an Array of FInstancedStruct.
Updated for UE 5.8:
FInstancedStructnow lives in CoreUObject (StructUtils/InstancedStruct.h) instead of the StructUtils plugin,FInstancedStructFilternow comes from theStructUtilsEditormodule (SInstancedStructPicker.h), and I fixed several typos and bugs in the thunk and node code that stopped it compiling.
This is good for anything derived from a base struct type where you want to store a mixed TArray<FInstancedStruct> in a data asset, and let Blueprint pull out one specific struct type with a picker filtered to children of your base struct. The end result is a node with a struct type picker, Valid/NotValid exec pins, and an output pin that takes on the picked struct type.
There is a fair bit of code here: a data asset with a custom thunk, plus a custom module which is UncookedOnly for the K2Node and its pin widget to live in.
The Basics
We will define a struct which will be our base, and 2 derived structs. We will hide the base struct from being selected as it should contain no properties.
USTRUCT(BlueprintType)
struct FMyBaseInstancedStruct
{
GENERATED_BODY()
};
USTRUCT(BlueprintType)
struct FMyChildAInstancedStruct : public FMyBaseInstancedStruct
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FText SomeText;
};
USTRUCT(BlueprintType)
struct FMyChildBInstancedStruct : public FMyBaseInstancedStruct
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
bool bSomeBool = false;
};
With these structs created and in your main module, let’s make a DataAsset that will hold our TArray of FInstancedStruct.
#include "StructUtils/InstancedStruct.h"
UENUM()
enum class EMyFindInstanceStructResult : uint8
{
Valid,
NotValid,
};
UCLASS()
class MYGAMEMODULE_API UMyDataAsset : public UDataAsset
{
GENERATED_BODY()
protected:
// The array of FInstancedStructs. BaseStruct restricts the pickable types to children of our base
// struct (use the struct's path name without the F prefix), and ExcludeBaseStruct removes the base itself.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Data, meta = (BaseStruct = "/Script/MyGameModule.MyBaseInstancedStruct", ExcludeBaseStruct))
TArray<FInstancedStruct> MyData;
private:
// Function called from our custom K2 Node, using a custom thunk.
// The thunk uses InstancedStructType to find the matching instanced struct in the array and
// copies it into Value. Value is declared as int32 here only as a placeholder: CustomStructureParam
// makes it a wildcard, and our K2Node sets its real struct type.
UFUNCTION(BlueprintCallable, CustomThunk, Category = "MyData", meta = (DisplayName = "GetMyData", CustomStructureParam = "Value", ExpandEnumAsExecs = "FindResult", BlueprintInternalUseOnly="true"))
void GetMyDataBP(EMyFindInstanceStructResult& FindResult, UScriptStruct* InstancedStructType, int32& Value);
// Declares the exec thunk that CustomThunk expects us to write by hand.
DECLARE_FUNCTION(execGetMyDataBP);
// So our K2Node can access this private function, we don't want the function above
// called by anything BUT the K2Node.
friend class UK2Node_GetMyData;
};
Now we need to implement our CustomThunk in the cpp file
void UMyDataAsset::GetMyDataBP(EMyFindInstanceStructResult& FindResult, UScriptStruct* InstancedStructType, int32& Value)
{
// We should never hit this! The exec thunk below is what Blueprint runs.
checkNoEntry();
}
DEFINE_FUNCTION(UMyDataAsset::execGetMyDataBP)
{
//Get the result enum (out ref)
P_GET_ENUM_REF(EMyFindInstanceStructResult, FindResult);
// Get the struct type we want to match
P_GET_OBJECT(UScriptStruct, InstancedStructType);
// Read wildcard Value input.
Stack.MostRecentPropertyAddress = nullptr;
Stack.MostRecentPropertyContainer = nullptr;
Stack.StepCompiledIn<FStructProperty>(nullptr);
const FStructProperty* ValueProp = CastField<FStructProperty>(Stack.MostRecentProperty);
void* ValuePtr = Stack.MostRecentPropertyAddress;
P_FINISH;
//Set the result as Not Valid for starters
FindResult = EMyFindInstanceStructResult::NotValid;
// The wildcard pin may not have been resolved to a struct type (e.g. nothing picked yet).
if (!ValueProp || !ValuePtr)
{
return;
}
P_NATIVE_BEGIN;
for (const FInstancedStruct& Item : P_THIS->MyData) //Loop through our array of structs
{
//If its valid, its the requested type, and the Value pin has that same type
if (Item.IsValid() && Item.GetScriptStruct() == InstancedStructType && ValueProp->Struct == InstancedStructType)
{
//Copy the memory (data) to the Value out param
ValueProp->Struct->CopyScriptStruct(ValuePtr, Item.GetMemory());
// Set result as valid
FindResult = EMyFindInstanceStructResult::Valid;
//No more need to loop. break out.
break;
}
}
P_NATIVE_END;
}
The K2Node
You first need to make an UncookedOnly module in your plugin or project (set the module Type to UncookedOnly in the .uplugin/.uproject). You can refer to the modules documentation if you need info on modules. Besides your main module it will need dependencies on roughly UnrealEd, BlueprintGraph, GraphEditor, StructViewer, StructUtilsEditor, Slate and SlateCore; add whatever else the linker asks for.
Create 2 classes in your uncooked module, one called
SMyDataGraphPin and another called: UK2Node_GetMyData.
SMyDataGraphPin will define some filters for our K2Node pin and the K2Node will contain a very small amount of code to glue it all together.
SMyDataGraphPin
#include "Framework/SlateDelegates.h"
#include "Input/Reply.h"
#include "Internationalization/Text.h"
#include "KismetPins/SGraphPinObject.h"
#include "Templates/SharedPointer.h"
#include "Widgets/DeclarativeSyntaxSupport.h"
class SWidget;
class UEdGraphPin;
class UScriptStruct;
/////////////////////////////////////////////////////
// SMyDataGraphPin
class SMyDataGraphPin : public SGraphPinObject
{
public:
SLATE_BEGIN_ARGS(SMyDataGraphPin) {}
SLATE_END_ARGS()
void Construct(const FArguments& InArgs, UEdGraphPin* InGraphPinObj);
protected:
// Called when a new struct was picked via the struct picker
void OnPickedNewStruct(const UScriptStruct* ChosenStruct);
//~ Begin SGraphPinObject Interface
virtual FReply OnClickUse() override;
virtual bool AllowSelfPinWidget() const override { return false; }
virtual TSharedRef<SWidget> GenerateAssetPicker() override;
virtual FText GetDefaultComboText() const override;
virtual FOnClicked GetOnUseButtonDelegate() override;
//~ End SGraphPinObject Interface
};
CPP File
#include "SMyDataGraphPin.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraph/EdGraphSchema.h"
#include "Editor.h"
#include "Engine/UserDefinedStruct.h"
#include "Modules/ModuleManager.h"
#include "ScopedTransaction.h"
#include "Selection.h"
#include "SInstancedStructPicker.h"
#include "StructViewerModule.h"
#include "Styling/AppStyle.h"
#include "UObject/Class.h"
#include "Widgets/Input/SMenuAnchor.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SBox.h"
#include "Widgets/SBoxPanel.h"
#define LOCTEXT_NAMESPACE "SMyDataGraphPin"
/////////////////////////////////////////////////////
// SMyDataGraphPin
void SMyDataGraphPin::Construct(const FArguments& InArgs, UEdGraphPin* InGraphPinObj)
{
SGraphPin::Construct(SGraphPin::FArguments(), InGraphPinObj);
}
FReply SMyDataGraphPin::OnClickUse()
{
FEditorDelegates::LoadSelectedAssetsIfNeeded.Broadcast();
UObject* SelectedObject = GEditor->GetSelectedObjects()->GetTop(UScriptStruct::StaticClass());
if (SelectedObject)
{
const FScopedTransaction Transaction(NSLOCTEXT("GraphEditor", "ChangeStructPinValue", "Change Struct Pin Value"));
GraphPinObj->Modify();
GraphPinObj->GetSchema()->TrySetDefaultObject(*GraphPinObj, SelectedObject);
}
return FReply::Handled();
}
TSharedRef<SWidget> SMyDataGraphPin::GenerateAssetPicker()
{
FStructViewerModule& StructViewerModule = FModuleManager::LoadModuleChecked<FStructViewerModule>("StructViewer");
// Fill in options
FStructViewerInitializationOptions Options;
Options.Mode = EStructViewerMode::StructPicker;
Options.bShowNoneOption = true;
// Set your instanced struct here!
const UScriptStruct* MetaStruct = FMyBaseInstancedStruct::StaticStruct();
//We use FInstancedStructFilter because it's convenient
TSharedRef<FInstancedStructFilter> StructFilter = MakeShared<FInstancedStructFilter>();
Options.StructFilter = StructFilter;
StructFilter->BaseStruct = MetaStruct;
StructFilter->bAllowBaseStruct = false;
return
SNew(SBox)
.WidthOverride(280)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.FillHeight(1.0f)
.MaxHeight(500)
[
SNew(SBorder)
.Padding(4)
.BorderImage( FAppStyle::GetBrush("ToolPanel.GroupBorder") )
[
StructViewerModule.CreateStructViewer(Options, FOnStructPicked::CreateSP(this, &SMyDataGraphPin::OnPickedNewStruct))
]
]
];
}
FOnClicked SMyDataGraphPin::GetOnUseButtonDelegate()
{
return FOnClicked::CreateSP(this, &SMyDataGraphPin::OnClickUse);
}
void SMyDataGraphPin::OnPickedNewStruct(const UScriptStruct* ChosenStruct)
{
if (GraphPinObj->IsPendingKill())
{
return;
}
FString NewPath;
if (ChosenStruct)
{
NewPath = ChosenStruct->GetPathName();
}
if (GraphPinObj->GetDefaultAsString() != NewPath)
{
const FScopedTransaction Transaction( NSLOCTEXT("GraphEditor", "ChangeStructPinValue", "Change Struct Pin Value" ) );
GraphPinObj->Modify();
AssetPickerAnchor->SetIsOpen(false);
GraphPinObj->GetSchema()->TrySetDefaultObject(*GraphPinObj, const_cast<UScriptStruct*>(ChosenStruct));
}
}
FText SMyDataGraphPin::GetDefaultComboText() const
{
return LOCTEXT("DefaultComboText", "Select Struct");
}
#undef LOCTEXT_NAMESPACE
I won’t go over every detail in the above, but inside the function GenerateAssetPicker you will see the base struct defined. FInstancedStructFilter is what the instanced struct picker in the details panel uses, so this behaves like the one you get on an array element. It is declared in SInstancedStructPicker.h in the StructUtilsEditor module, which is why that module is a dependency here.
UK2Node_GetMyData
struct FMyInstancedStructPinFactory : public FGraphPanelPinFactory
{
public:
virtual TSharedPtr<class SGraphPin> CreatePin(class UEdGraphPin* Pin) const override;
};
/** Call function node that resolves its wildcard Value pin to the struct type picked on InstancedStructType. */
UCLASS()
class UK2Node_GetMyData : public UK2Node_CallFunction
{
GENERATED_BODY()
public:
virtual void GetMenuActions(FBlueprintActionDatabaseRegistrar& ActionRegistrar) const override;
virtual void PinDefaultValueChanged(UEdGraphPin* ChangedPin) override;
virtual void PostReconstructNode() override;
virtual void ClearCachedBlueprintData(UBlueprint* Blueprint) override;
void RefreshOutputStructType();
};
The CPP file
TSharedPtr<class SGraphPin> FMyInstancedStructPinFactory::CreatePin(class UEdGraphPin* Pin) const
{
if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Object)
{
//Only if the node is our special custom K2Node.
if (Cast<UK2Node_GetMyData>(Pin->GetOwningNode()))
{
//Only use this custom graph slate if its the pin we want to use it on
//this must be the same name as your UScriptStruct* parameter name in your
//custom thunk!
if (Pin->PinName == "InstancedStructType")
{
//Return our custom GraphPin we made earlier.
return SNew(SMyDataGraphPin, Pin);
}
}
}
//Let other factories try, we don't want it.
return nullptr;
}
void UK2Node_GetMyData::GetMenuActions(FBlueprintActionDatabaseRegistrar& ActionRegistrar) const
{
Super::GetMenuActions(ActionRegistrar);
UClass* Action = GetClass();
if (ActionRegistrar.IsOpenForRegistration(Action))
{
auto CustomizeLambda = [](UEdGraphNode* NewNode, bool bIsTemplateNode, const FName FunctionName)
{
UK2Node_GetMyData* Node = CastChecked<UK2Node_GetMyData>(NewNode);
UFunction* Function = UMyDataAsset::StaticClass()->FindFunctionByName(FunctionName);
check(Function);
Node->SetFromFunction(Function);
};
// Our custom thunk
UBlueprintNodeSpawner* GetNodeSpawner = UBlueprintNodeSpawner::Create(GetClass());
check(GetNodeSpawner != nullptr);
GetNodeSpawner->CustomizeNodeDelegate = UBlueprintNodeSpawner::FCustomizeNodeDelegate::CreateStatic(CustomizeLambda, GET_FUNCTION_NAME_CHECKED(UMyDataAsset, GetMyDataBP));
ActionRegistrar.AddBlueprintAction(Action, GetNodeSpawner);
}
}
void UK2Node_GetMyData::PinDefaultValueChanged(UEdGraphPin* ChangedPin)
{
Super::PinDefaultValueChanged(ChangedPin);
//Refresh our wildcard pin if the default value is changed
if (ChangedPin->PinName == "InstancedStructType")
{
if (ChangedPin->LinkedTo.Num() == 0)
{
RefreshOutputStructType();
}
}
}
void UK2Node_GetMyData::PostReconstructNode()
{
Super::PostReconstructNode();
//Refresh our wildcard pin if the node has been rebuilt
RefreshOutputStructType();
}
void UK2Node_GetMyData::ClearCachedBlueprintData(UBlueprint* Blueprint)
{
Super::ClearCachedBlueprintData(Blueprint);
//Refresh our wildcard pin if the cached data has been cleared
RefreshOutputStructType();
}
void UK2Node_GetMyData::RefreshOutputStructType()
{
//Grab the value pin (the output pin with our data)
UEdGraphPin* ValuePin = FindPinChecked(TEXT("Value"));
//Our type struct pin
UEdGraphPin* StructTypePin = FindPinChecked(TEXT("InstancedStructType"));
if (StructTypePin->DefaultObject != ValuePin->PinType.PinSubCategoryObject)
{
if (ValuePin->SubPins.Num() > 0)
{ //If the pin has been broken (split), recombine it.
GetSchema()->RecombinePin(ValuePin);
}
//Set the value of our value pin to your selected InstancedStructType
ValuePin->PinType.PinSubCategoryObject = StructTypePin->DefaultObject;
ValuePin->PinType.PinCategory = (StructTypePin->DefaultObject == nullptr) ? UEdGraphSchema_K2::PC_Wildcard : UEdGraphSchema_K2::PC_Struct;
}
}
One last step is to register our custom pin factory, in your uncooked only module’s StartupModule and ShutdownModule
void FMyUncookedOnlyModule::StartupModule()
{
MyInstancedStructPinFactory = MakeShared<FMyInstancedStructPinFactory>();
FEdGraphUtilities::RegisterVisualPinFactory(MyInstancedStructPinFactory);
}
void FMyUncookedOnlyModule::ShutdownModule()
{
FEdGraphUtilities::UnregisterVisualPinFactory(MyInstancedStructPinFactory);
}
//Place the following in the header:
TSharedPtr<FMyInstancedStructPinFactory> MyInstancedStructPinFactory;
Notes
- The pin names used in the factory and in
FindPinCheckedmust match the thunk’s parameter names exactly (InstancedStructTypeandValue), or the node will assert or fall back to a plain object pin. - The thunk only copies when the requested struct type exactly matches the stored type, not for child types of it.
Well, that was a lot, but that should be everything to make it work.
I will provide some screenshots in a bit of it in action :)