Speaking of polymorphism in programming most people would think of C++ virtual
function, which is a form of runtime polymorphism.
A related concept that we call polymorphic serialization is also seen a lot in game programming. But somehow it's less talked about. In this post we'll:
Here's a classical toy example of C++ polymorphism: a base Shape
class with derived Rect
and Circle
.
struct Shape
{
virtual float Size() = 0;
};
struct Rect : public Shape
{
float Width;
float Height;
float Size() override { return Width * Height; }
};
struct Circle : public Shape
{
float Radius;
float Size() override { return 3.14f * Radius * Radius; }
};
Now virtual float Size()
is a virtual function which implements runtime polymorphism. The thing we're more interested in is that given a Shape
pointer or reference, how can we serialize it with correct type info and its member fields? This process is what we call polymorphic serialization.
Note that there's no out of the box solution with stock C++ and STL. It's actually pretty tricky to implement when you think about it:
Shape
pointer.Rect
and Circle
have different sizes.Shape
, which can have arbitrary size.The good news is that Unreal Engine supports this out of the box. Let's take a look at how it's done.
We know that UObject
is Unreal Engine's magical class that support reflection, serialization and everything. The first solution here is to take advantage of Instanced Object, which is toggled with UCLASS(DefaultToInstanced)
:
UCLASS(Abstract, BlueprintType, EditInlineNew, DefaultToInstanced)
class DATACONFIGEXTRA_API UDcBaseShape : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere) FName ShapeName;
};
UCLASS()
class DATACONFIGEXTRA_API UDcShapeBox : public UDcBaseShape
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere) float Height;
UPROPERTY(EditAnywhere) float Width;
};
UCLASS()
class DATACONFIGEXTRA_API UDcShapeSquare : public UDcBaseShape
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere) float Radius;
};
Here're some good docs on the UPROPERTY and UCLASS flags. To use these classes simply setup a UDcBaseShape*
property with Instanced
flag.
UPROPERTY(EditAnywhere, Instanced)
UDcBaseShape* ShapeField1;
In Unreal Editor it looks like this:
Under the hood instanced children UObject
s would be created for each field. These sub objects get serialized along with their parent object and have the same lifetime.
Note that it also works as intended with TArray/TMap
, with proper editor and serialization support.
Instanced objects are used in UE5's Modular Game Features plugin. There're sample setups of GameFeatureData
in Lyra. One primary DataAsset class looks like this:
UCLASS()
class GAMEFEATURES_API UGameFeatureData : public UPrimaryDataAsset
{
// ...
UPROPERTY(EditDefaultsOnly, Instanced, Category="Actions")
TArray<TObjectPtr<UGameFeatureAction>> Actions;
};
In Lyra the ShooterCore
is a instance of of GameFeatureData
and the actions setup in the editor looks like this:
This is an example of UE's way of doing data driven game development. By setting up polymorphic instanced objects you can mix and match these actions and eventually build up modular features without hard code stuff in C++.
Instanced objects works but there're obvious drawbacks:
UObject
overhead. For simple classes like UDcShapeBox
the overhead outweights its two float
members already.UObject
semantics. UObject
can be GCed, referenced and stuff but in many cases we just want a simple data container that's owned by its parent.Well turns out there's a solution to all the issues above. Starting with UE5.0 there's a new plugin StructUtils with a struct FInstancedStruct
. It does exactly what it's name suggests: same feature as instanced objects but now uses all USTRUCT
s.
The class hierarchy above ported struct looks like this:
USTRUCT(BlueprintType)
struct DATACONFIGEXTRA_API FDcStructShapeBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere) FName ShapeName;
};
USTRUCT(BlueprintType)
struct DATACONFIGEXTRA_API FDcStructShapeRectangle : public FDcStructShapeBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere) float Height;
UPROPERTY(EditAnywhere) float Width;
};
USTRUCT(BlueprintType)
struct DATACONFIGEXTRA_API FDcStructShapeCircle : public FDcStructShapeBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere) float Radius;
};
Note all these structs are all plain USTRUCT
s. To use these structs setup a FInstancedStruct
property like this:
UPROPERTY(EditAnywhere, meta = (BaseStruct = "DcStructShapeBase"))
FInstancedStruct ShapeField1;
In Unreal Editor it looks almost the same as instanced objects:
As you would expect serialization also works as intended. This is almost a drop in replacement for instanced objects for most use cases.
Instanced structs comes along with Mass framework and it's used a lot there. In the City Sample there's an sample of FInstancedStruct
in action:
UCLASS(meta=(DisplayName="Assorted Fragments"))
class MASSSPAWNER_API UMassAssortedFragmentsTrait : public UMassEntityTraitBase
{
// ...
UPROPERTY(Category="Fragments", EditAnywhere, meta = (BaseStruct = "/Script/MassEntity.MassFragment", ExcludeBaseStruct))
TArray<FInstancedStruct> Fragments;
};
In the editor the Fragments
looks like this:
It also looks the same as the instanced object counter parts. Under the hood it saves some memory but more importantly it gives you peace of mind for cutting the unneeded overhead for UObject
.
If you haven't noticed yet, we're working on DataConfig which is a serialization framework that supports JSON and MsgPack. At the very start we're determined to support polymorphic serialization as a very typical use case.
In the last DataConfig JSON Asset release we added a feature to dump DataAssets as JSON. The ShooterCore
example above would be dumped like this:
// /ShooterCore/ShooterCore
{
"$type" : "GameFeatureData",
"Actions" : [
{
"$type" : "GameFeatureAction_AddComponents",
"ComponentList" : [
{
"ActorClass" : "GameStateBase",
"ComponentClass" : "/ShooterCore/Accolades/B_EliminationFeedRelay",
"bClientComponent" : true,
"bServerComponent" : true
},
{
"ActorClass" : "LyraCharacter",
"ComponentClass" : "LyraEquipmentManagerComponent",
"bClientComponent" : true,
"bServerComponent" : true
},
// ...
]
},
{
"$type" : "GameFeatureAction_DataRegistry",
"RegistriesToAdd" : [
"/ShooterCore/Accolades/AccoladeDataRegistry"
]
}
// ...
]
}
You can see more dump results from Lyra and CitySample here. With DataConfig both instanced object and struct can be serialized and deserialized. For more details:
Finally we have a cool example which takes an extra step over instanced struct: Inline Struct behaves like instanced struct but it stores the struct instance inline. This avoids heap allocation and offers better data locality. The trade off is that the contained struct sizes are limited.
We provide FDcInlineStruct64/FDcInlineStruct128/FDcInlineStruct256/FDcInlineStruct512
which have varying sizes. The deserialization process also verify that the struct class can be contained within the inline buffer. Otherwise it would report the dialgnose message below:
# DataConfig Error: Inline struct too big: BufSize '56', Struct 'DcExtraTestStruct128' Size '128'
- [WideCharDcJsonReader] --> <in-memory>5:38
3 | {
4 | "InlineField1" : {
5 | "$type" : "DcExtraTestStruct128",
| ^
6 | "NameField" : "Foo"
7 | },
DataConfig has many other cool samples. Checkout links below if this interests you: