Appearance
0. General information
Custom nodes can be separated into 3 groups:
- Function Library node - for straight-forward, purely async operations;
- Complex node - for operations that require a lot of flexibility;
- Simple ISPC node - for simple, high-performance operations.
1. Creating Function Library C++ nodes
To create Function Library C++ node, it is necessary to create UVoxelFunctionLibrary
class, and create UFUNCTION functions. These will be visible within voxel graphs.
Function Library nodes are called in Async thread. As such, they cannot communicate with UObjects or UWorld.
Example:
cpp
#pragma once
#include "CoreMinimal.h"
#include "VoxelFunctionLibrary.h"
#include "Buffer/VoxelBaseBuffers.h"
#include "TestFunctionLibrary.generated.h"
UCLASS()
class UTestFunctionLibrary : public UVoxelFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(Category = "Test")
FVoxelFloatBuffer OneMinus(const FVoxelFloatBuffer& Value)
{
FVoxelFloatBuffer Result;
Result.Allocate(Value.Num());
for (int32 Index = 0; Index < Value.Num(); Index++)
{
Result.Set(Index, 1.f - Value[Index]);
}
return Result;
}
};
INFO
Pure math operations such as this example are generally better off being done in ISPC, as this usually increases performance by a factor ten.
More examples can be found in:
Source/VoxelGraph/(Public/Private)/FunctionLibrary/...
2. Creating Complex C++ nodes
Complex C++ nodes can:
- Query information;
- Be calculated in several steps (i.e. wait for different pins or queries to collect information);
- Selectively be chosen to be processed in async/game threads;
- Have wildcard pins;
- Have custom logic on what to do when one pin is promoted.
2.1. Header:
cpp
#pragma once
#include "CoreMinimal.h"
#include "VoxelNode.h"
#include "Buffer/VoxelBaseBuffers.h"
#include "MyCustomNode.generated.h"
USTRUCT(Category = "Custom")
struct FVoxelNode_MyCustomNode : public FVoxelNode
{
GENERATED_BODY()
GENERATED_VOXEL_NODE_BODY()
// My tooltip as a comment
VOXEL_INPUT_PIN(float, CustomPin1, 5.f, AdvancedDisplay);
VOXEL_INPUT_PIN(FVoxelFloatBuffer, CustomPin2, 1.f, DisplayName("My Custom Pin"), Category("Category A"));
VOXEL_OUTPUT_PIN(FVoxelFloatBuffer, CustomOutputPin);
virtual void Compute(FVoxelGraphQuery Query) const override;
// Optional
#if WITH_EDITOR
virtual FVoxelPinTypeSet GetPromotionTypes(const FVoxelPin& Pin) const override;
virtual void PromotePin(FVoxelPin& Pin, const FVoxelPinType& NewType) override;
#endif
};
Key elements in the header important are:
- Have
GENERATED_VOXEL_BODY()
after theGENERATED_BODY()
declaration; - Pins declarations.
- Promotion - this is only necessary if you want pins to be promoted to different types;
2.2. Pins:
Pins can be declared in four ways:
VOXEL_INPUT_PIN( {TYPE}, {NAME}, {DEFAULT_VALUE}, {META_DATA} );
VOXEL_OUTPUT_PIN( {TYPE}, {NAME}, {META_DATA} );
VOXEL_TEMPLATE_INPUT_PIN( {TYPE}, {NAME}, {DEFAULT_VALUE}, {META_DATA} );
VOXEL_TEMPLATE_OUTPUT_PIN( {TYPE}, {NAME}, {META_DATA} );
Explanation:
TEMPLATE
type pins mean will act together when converting from Uniform to Buffer and vice versa. If you promote one of Template pins container type, it will change all other template pins container type to match promoted pin container;TYPE
is the pin type. Some types have matching buffer types, for examplefloat - FVoxelFloatBuffer
. If a wildcard pin is necessary, then type can be created asFVoxelWildcard
orFVoxelWildcardBuffer
.- IMPORTANT! Object type pins must have their own specific pin type created (more on this later);
DEFAULT_VALUE
- The value to use when unplugged. This can be left as nullptr for most types;META_DATA
- Meta Data is optional. All meta data types are declared inVoxelNode.h
, under theFVoxelPinMetaDataBuilder
namespace. Some examples:DisplayName
changes the pin’s user-facing name. Accepts string;ToolTip
gives the pin a tooltip, visible when hovered (tooltips can also be set by comment above the pin's declaration);Category
moves the pin to a category. Accepts string;AdvancedDisplay
moves the pin into theAdvanced
group (collapsed-by-default dropdown);ArrayPin
will take in array type inputs;ShowInDetail
means the pin will be hidden by default and the value will be shown in details panel (can be exposed as pin from details panel);HideDefault
- Will require pin to be connected (default value will be hidden);PositionPin
- Works only with Vector and Vector2D buffers and will use position as default value for pin;
2.3. CPP:
cpp
#include "MyCustomNodeHeader.h"
void FVoxelNode_MyCustomNode::Compute(const FVoxelGraphQuery Query) const
{
// Each pin has to be registered for querying, by creating
// variable TValue<Type> Value = {PinName}Pin.Get(Query);
// here we do not yet have the values from pins
TValue<float> CustomPin1Value = CustomPin1Pin.Get(Query);
TValue<FVoxelFloatBuffer> CustomPin2Value = CustomPin2Pin.Get(Query);
// All pins we want to get values must be passed to VOXEL_GRAPH_WAIT
VOXEL_GRAPH_WAIT(CustomPin1Value, CustomPin2Value)
{
// Here value variables will have their values computed, so work can be done here
FVoxelFloatBuffer ReturnValue;
// Allocate necessary amount of values in it
ReturnValue.Allocate(CustomPin2Value->Num());
for (int32 Index = 0; Index < CustomPin2Value->Num(); Index++)
{
// We can write into BufferStorage by accessing necessary index
// and we can read from normal Buffer by accessing index
ReturnValue.Set(Index, (*CustomPin2Value)[Index] + CustomPin1Value);
}
// We need to set computed value to output pin
CustomOutputPinPin.Set(Query, MoveTemp(ReturnValue));
};
}
// If promotion is required:
#if WITH_EDITOR
// This function allows to gather all allowed pin types for specific pin.
FVoxelPinTypeSet FVoxelNode_MyCustomNode::GetPromotionTypes(const FVoxelPin& Pin) const
{
if (Pin == CustomPin1Pin)
{
return FVoxelPinTypeSet::AllBufferArrays();
}
else
{
return FVoxelPinTypeSet::AllBuffers();
}
}
// This function is called when we want to promote specific pin
// if theres some additional work to be done with other pins, influenced
// by specific pin it must be done here.
void FVoxelNode_MyCustomNode::PromotePin(FVoxelPin& Pin, const FVoxelPinType& NewType)
{
Pin.SetType(NewType);
if (Pin == CustomPin1Pin)
{
GetPin(CustomPin1Pin).SetType(NewType.WithBufferArray(false));
}
else
{
GetPin(CustomPin2Pin).SetType(NewType.WithBufferArray(true));
}
}
#endif
To retrieve data from pins, querying must be done, using return VOXEL_GRAPH_WAIT(PinA, PinB….){}
. This part will put node execution into tasks pool and when all pin values are computed will execute inner lambda part. This can be stacked.
2.4. Examples:
- Source/VoxelGraph/(Private/Public)/Nodes/VoxelObjectNodes.(cpp/h)
- Source/VoxelGraph/(Private/Public)/Nodes/VoxelArrayNodes.(cpp/h)
- Source/VoxelGraph/(Private/Public)/Nodes/VoxelAppendNamesNode.(cpp/h)
- Source/VoxelGraph/(Private/Public)/Nodes/VoxelAdvancedNoiseNode.(cpp/h)
2.5. Querying parameter from context:
Nodes can convey information to downstream nodes as additional payloads on pins. This sort of additional information is called a QueryParameter. This is used within the plugin for information like Position and LOD.
INFO
QueryParameters are only available to downstream nodes. Because graph execution is done right-to-left, a node is downstream if its output is plugged into another node’s input.
QueryParamaters can be retrieved using QueryParameter using Query->FindParameter<ParameterType>();
cpp
#include "VoxelGraphPositionParameter.h"
void FVoxelNode_MyCustomNode::Compute(const FVoxelGraphQuery Query) const
{
const FVoxelGraphParameters::FLOD* LODParameter = Query->FindParameter<FVoxelGraphParameters::FLOD>();
// For Position2D it is also possible to use FVoxelGraphParameters::FPosition2D::Find(Query);
// this will also check if it's 3D position and convert it to 2D position parameter
const FVoxelGraphParameters::FPosition2D* PositionParameter = Query->FindParameter<FVoxelGraphParameters::FPosition2D>();
if (!PositionParameter)
{
VOXEL_MESSAGE(Error, "{0}: position parameter not found", this);
return;
}
int32 LOD = 0;
if (LODParameter)
{
LOD = LODParameter->Value;
}
FVoxelVector2DBuffer Positions = PositionParameter->GetLocalPosition_Float(Query);
}
2.6. Creating query parameter and writing into it:
It is also possible to create custom QueryParameters, to allow downstream nodes to query custom data.
To do so, you need to create a FUniformParameter of FBufferParameter derived struct
cpp
namespace FVoxelGraphParameters
{
struct FVoxelCustomQueryParameter : public FUniformParameter
{
FVector MyCustomData;
};
}
With the QueryParameter struct in place, we can configure the QueryParameters container to make use of it for the required pins. Keep in mind that QueryParameters are passed on a per-pin level, so it has to be declared for each pin individually.
cpp
void FVoxelNode_MyCustomNode::Compute(const FVoxelGraphQuery Query) const
{
// First we clone existing query parameters
FVoxelGraphQueryImpl& NewQuery = Query->CloneParameters();
// Then add our custom query parameter with data
NewQuery.AddParameter<FVoxelGraphParameters::FVoxelCustomQueryParameter>().MyCustomData = FVector(0.f, 1.f, 0.f);
// Now it is necessary to compute pin with query data
const TValue<float> CustomPin1Value = CustomPin1Pin.Get(FVoxelGraphQuery(NewQuery, Query.GetCallstack()));
const TValue<FVoxelFloatBuffer> CustomPin2Value = CustomPin2Pin.Get(Query);
VOXEL_GRAPH_WAIT(CustomPin1Value, CustomPin2Value)
{
...
};
}
In this example, CustomPin1 will have newly created CustomQueryParameter and CustomPin2 will not and all nodes which will be in the downstream of CustomPin1 will be able to access FVoxelCustomQueryParameter.
3. Creating simple ISPC Node
ISPC nodes can be implemented without needing to write ISPC manually. To achieve this, create struct derived from FVoxelISPCNode
:
cpp
#pragma once
#include "CoreMinimal.h"
#include "VoxelComputeNode.h"
#include "MyCustomNode.generated.h"
USTRUCT(Category = "Custom")
struct FVoxelComputeNode_MyCustomNode : public FVoxelComputeNode
{
GENERATED_BODY()
GENERATED_VOXEL_NODE_BODY()
VOXEL_TEMPLATE_INPUT_PIN(float, Value, nullptr);
VOXEL_TEMPLATE_INPUT_PIN(float, Value2, nullptr);
VOXEL_TEMPLATE_OUTPUT_PIN(float, ReturnValue);
virtual FString GenerateCode(FCode& Code) const override
{
// This is useful when it is necessary to use some custom functions from already written functions in ISPC
// Usually this is not necessary.
Code.AddInclude("VoxelNoiseNodesImpl.isph");
return "{ReturnValue} = min({Value} * {Value2}, {Value2} + {Value})";
}
}
The plugin will look for these structs, and will generate ISPC
code from the string returned by GeneratedCode
. This type of node can only do relatively basic operations, but it does them very efficiently.
Examples:
- Source/VoxelGraph/Public/VoxelNoiseNodes.h
- Source/VoxelGraph/Public/VoxelTemplatedMathNodes.h
4. Calling ISPC in C++ Nodes
In itself, calling ISPC in C++ code is the same as calling global c++ functions. The main difference is to access them through ispc
namespace and create ISPC code in *.ispc
files.
First create ISPC function:
MyCustomISPC.ispc
cpp
#include "VoxelMinimal.isph"
export void MyCustomNode_DoCalculation(const uniform float FloatBuffer[], const uniform int32 Num, uniform float ReturnBuffer[])
{
FOREACH(Index, 0, Num)
{
// Do calculations
}
}
And in the c++ node you want to call the ispc function, do:
cpp
#include "**MyCustomISPC**.ispc.generated.h"
void FVoxelNode_MyCustomNode::Compute(const FVoxelGraphQuery Query) const
{
const TValue<FVoxelFloatBuffer> CustomPin2Value = CustomPin2Pin.Get(Query);
VOXEL_GRAPH_WAIT(CustomPin2Value)
{
const int32 Num = CustomPin2Value->Num();
FVoxelFloatBuffer ReturnValue;
ReturnValue.Allocate(Num);
ispc::MyCustomNode_DoCalculation(
CustomPin2Value->GetData(),
Num,
ReturnValue.GetData());
};
}
Including VoxelMinimal.isph in ispc file will allow access to our helper macros/functions like FOREACH
, which helps to iterate buffers.
Examples:
- Source/Private/VoxelGraph/Nodes/VoxelAdvancedNoiseNode.cpp (calls ISPC function)
- Source/Private/VoxelGraph/Nodes/VoxelAdvancedNoiseNodesImpl.ispc (ispc functions declaration)
- Source/Private/VoxelGraph/Nodes/VoxelHeightSplitterNode.cpp (calls ISPC function)
- Source/Private/VoxelGraph/Nodes/VoxelHeightSplitterNodeImpl.ispc (ispc functions declaration)
5. Custom Pin Types:
Custom pin types can be divided into two:
- Struct;
- UObject pin type.
5.1. Struct Pin Type:
cpp
USTRUCT()
struct FMyCustomStruct
{
GENERATED_BODY()
UPROPERTY()
int32 A = 0;
UPROPERTY()
float B = 1;
UPROPERTY()
FVector C = FVector::ZeroVector;
};
Now for this struct to appear in voxel graphs, struct needs to be used in any of C++ nodes.
One way would be to define Make/Break nodes for this struct, other way would be to create any function and use this struct as parameter.
INFO
Without Make/Break function nodes, struct can only be used in function arguments and you will not be able to split struct into separate pins.
DANGER
Custom struct CANNOT contain UObjects.
INFO
If custom struct contains other custom struct, that other struct also needs to have a buffer version implemented.
5.1.1. Define custom struct buffer:
- Declare it using
DECLARE_VOXEL_BUFFER(TypeBuffer, Type);
- Create buffer USTRUCT, derived from FVoxelBuffer;
- Add buffer variation of every inner property;
- Create [] operator, to access uniform type at index position;
- Create static Make function to create buffer from uniform value;
- Create static Make function to create buffer from separated inner buffer values;
- Override InitializeFromConstant function.
Example:
cpp
#pragma once
#include "CoreMinimal.h"
#include "Buffer/VoxelBaseBuffers.h"
#include "Buffer/VoxelFloatBuffers.h"
#include "MyCustomStructBuffer.generated.h"
DECLARE_VOXEL_BUFFER(FMyCustomStructBuffer, FMyCustomStruct);
USTRUCT()
struct FMyCustomStructBuffer final : public FVoxelBufferStruct
{
GENERATED_BODY()
GENERATED_VOXEL_BUFFER_STRUCT_BODY(FMyCustomStructBuffer, FMyCustomStruct);
// Declare buffer variations of inner components
UPROPERTY()
FVoxelInt32Buffer A;
UPROPERTY()
FVoxelFloatBuffer B;
UPROPERTY()
FVoxelVectorBuffer C;
// Define [] operator
FORCEINLINE const FMyCustomStruct operator[](const int32 Index) const
{
// Since FMyCustomStruct::C is FVector and FVoxelVectorBuffer contains FVector3f, we need to cast it
return FMyCustomStruct(A[Index], B[Index], FVector(C[Index]));
}
// Define set function
FORCEINLINE void Set(const int32 Index, const FMyCustomStruct& Value)
{
A.Set(Index, Value.A);
B.Set(Index, Value.B);
// Since FMyCustomStruct::C is FVector and FVoxelVectorBuffer accepts FVector3f, we need to cast it
C.Set(Index, FVector3f(Value.C));
}
};
5.1.2. Defining Make/Break functions:
Create UVoxelFunctionLibrary class and create two UFUNCTIONS, Make and Break.
It is important, that Make function would contain meta attribute NativeMakeFunc
and break function would contain NativeBreakFunc
Example:
cpp
UCLASS()
class UMyCustomStructFunctionLibrary : public UVoxelFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(Category = "Custom", meta = (NativeMakeFunc))
FMyCustomStructBuffer MakeCustomStruct(const FVoxelInt32Buffer& A, const FVoxelFloatBuffer& B, const FVoxelVectorBuffer& C) const
{
// Need to perform check, are all buffers same size.
CheckVoxelBuffersNum_Return(A, B, C);
FMyCustomStructBuffer Result;
Result.A = A;
Result.B = B;
Result.C = C;
return Result;
}
UFUNCTION(Category = "Custom", meta = (NativeBreakFunc))
void BreakCustomStruct(const FMyCustomStructBuffer& Value, FVoxelInt32Buffer& A, FVoxelFloatBuffer& B, FVoxelVectorBuffer& C)
{
A = Value.A;
B = Value.B;
C = Value.C;
}
};
5.1.. Custom Struct Pin Type ending notes:
After creating buffer and break/make types, custom struct type will be fully functional in voxel graphs.
5.2. Object pin types:
Voxel graphs does not support direct access to object pin types, since most of our execution is being done in async threads, which makes direct access unsafe and may crash.
To bypass direct access, we need to create custom struct as object pin type, which holds data, we want to access from object.
5.2.1. Create object pin type:
- Create custom struct;
- Create object pin type struct, which reads data from UObject and stores it in custom struct;
- Declare buffer type;
- Make asset to data map.
Example:
cpp
**HEADER**
#pragma once
#include "CoreMinimal.h"
#include "VoxelObjectPinType.h"
#include "VoxelTerminalBuffer.h"
#include "TestDataAsset.generated.h"
UCLASS()
class UTestDataAsset : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, Category = "Appearance")
FVector Scale;
};
// Struct which will hold data
USTRUCT()
struct FTestDataAssetData
{
GENERATED_BODY()
TVoxelObjectPtr<UTestDataAsset> Object;
FVector Scale;
};
// Declare object pin type
DECLARE_VOXEL_OBJECT_PIN_TYPE(FTestDataAssetData);
USTRUCT()
struct FTestDataAssetDataPinType : public FVoxelObjectPinType
{
GENERATED_BODY()
DEFINE_VOXEL_OBJECT_PIN_TYPE(FTestDataAssetData, UTestDataAsset)
{
if (bSetObject)
{
OutObject = Struct.Object;
}
else
{
Struct.Object = InObject;
Struct.Scale = InObject.Scale;
}
}
};
// Declare buffer type
DECLARE_VOXEL_TERMINAL_BUFFER(FTestDataAssetDataBuffer, FTestDataAssetData);
USTRUCT()
struct FTestDataAssetDataBuffer final : public FVoxelTerminalBuffer
{
GENERATED_BODY()
GENERATED_VOXEL_TERMINAL_BUFFER_BODY(FTestDataAssetDataBuffer, FTestDataAssetData);
};
5.2.2. Use custom object pin type:
After creating custom object pin type, it is now possible to use the type in any of voxel nodes.
Example in Function Library:
cpp
UCLASS()
class UTestDataAssetFunctionLibrary : public UVoxelFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(Category = "Custom")
FVoxelVectorBuffer GetScaleFromTestDataAsset(const FTestDataAssetDataBuffer& AssetBuffer)
{
FVoxelVectorBuffer Result;
Result.Allocate(AssetBuffer.Num());
for (int32 Index = 0; Index < AssetBuffer.Num(); Index++)
{
Result.Set(Index, FVector3f(AssetBuffer[Index].Scale));
}
return Result;
}
};