Unreal Engine has a strong networking integration which makes it a great engine for multiplayer games. At the base of its client-server communication protocol are properties replication and RPC (remote procedure call). When it comes to optimization, there are several things you can do to reduce the traffic bandwidth[a]: basically you should not send data too often for actors that are not relevant for the player. But when you have to send data, you should try to use some form of compression to reduce the bandwidth. You can leverage some quantization functionalities exposed by the engine such has Vector quantization[b][c][d][e] and Quaternion quantization[f][g]. But what if you have defined a USTRUCT
that you want to use as a replicated property or for some RPC call? It turns out that each Unreal Engine USTRUCT
can define a custom network serialization for its data. You can do custom compression before the data is sent to the network and decompression after the data is received.
USTRUCT NetSerialize
When you declare a USTRUCT
in Unreal Engine you can add a NetSerialize
method which is part of the Unreal Engine struct trait system. If you define this method, the engine will use it while serializing and deserializing your struct for networking both during properties replication and RPC.
You can find a lot of documentation about serialization in this source file:
Runtime/Engine/Classes/Engine/NetSerialization.h
Here is the NetSerialize
method signature:
1 2 3 4 5 6 7 8 9 10 11 12 |
/** * @param Ar FArchive to read or write from. * @param Map PackageMap used to resolve references to UObject* * @param bOutSuccess return value to signify if the serialization was succesfull (if false, an error will be logged by the calling function) * * @return return true if the serialization was fully mapped. If false, the property will be considered 'dirty' and will replicate again on the next update. * This is needed for UActor* properties. If an actor's Actorchannel is not fully mapped, properties referencing it must stay dirty. * Note that UPackageMap::SerializeObject returns false if an object is unmapped. Generally, you will want to return false from your ::NetSerialize * if you make any calls to ::SerializeObject that return false. * */ bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess); |
And here is how to add it to a USTRUCT:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
USTRUCT() struct FMyCustomNetSerializableStruct { UPROPERTY() float SomeProperty; bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess); } template<> struct TStructOpsTypeTraits<FMyCustomNetSerializableStruct> : public TStructOpsTypeTraitsBase2<FMyCustomNetSerializableStruct> { enum { WithNetSerializer = true }; }; |
Pay attention to the last part: to tell the engine that the ustruct
defines a custom NetSerializer
function, you have to set to true the type trait WithNetSerializer
for the struct FMyCustomNetSerializableStruct
. If you don’t add that piece of code, the NetSerialize method will never be called.
If you are wondering about that wibbly wobbly template thing, it is a C++ programming pattern called C++ Type Traits[h] and it is part of the wide use of C++ template metaprogramming in Unreal Engine. With this technique, class code specialization is moved from runtime to compile time. The basic idea is to resolve at compile time what is known at compile time. The result is a performance boost due to a reduced runtime overhead.
The Unreal Engine USTRUCT
supports many other types of customization. You can find the complete list in the header file Runtime/CoreUObject/Public/UObject/Class.h:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// Runtime/CoreUObject/Public/UObject/Class.h /** type traits to cover the custom aspects of a script struct **/ struct TStructOpsTypeTraitsBase { enum { WithZeroConstructor = false, // struct can be constructed as a valid object by filling its memory footprint with zeroes. WithNoInitConstructor = false, // struct has a constructor which takes an EForceInit parameter which will force the constructor to perform initialization, where the default constructor performs 'uninitialization'. WithNoDestructor = false, // struct will not have its destructor called when it is destroyed. WithCopy = false, // struct can be copied via its copy assignment operator. WithIdenticalViaEquality = false, // struct can be compared via its operator==. This should be mutually exclusive with WithIdentical. WithIdentical = false, // struct can be compared via an Identical(const T* Other, uint32 PortFlags) function. This should be mutually exclusive with WithIdenticalViaEquality. WithExportTextItem = false, // struct has an ExportTextItem function used to serialize its state into a string. WithImportTextItem = false, // struct has an ImportTextItem function used to deserialize a string into an object of that class. WithAddStructReferencedObjects = false, // struct has an AddStructReferencedObjects function which allows it to add references to the garbage collector. WithSerializer = false, // struct has a Serialize function for serializing its state to an FArchive. WithPostSerialize = false, // struct has a PostSerialize function which is called after it is serialized WithNetSerializer = false, // struct has a NetSerialize function for serializing its state to an FArchive used for network replication. WithNetDeltaSerializer = false, // struct has a NetDeltaSerialize function for serializing differences in state from a previous NetSerialize operation. WithSerializeFromMismatchedTag = false, // struct has a SerializeFromMismatchedTag function for converting from other property tags. }; }; |
NetSerialize in Action
To see an example of a struct using this method in the engine, have a look at the FRepMovement
struct in EngineTypes.h
, where you’ll find this code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
// Runtime/Engine/Classes/Engine/EngineTypes.h bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess) { // pack bitfield with flags uint8 Flags = (bSimulatedPhysicSleep << 0) | (bRepPhysics << 1); Ar.SerializeBits(&Flags, 2); bSimulatedPhysicSleep = ( Flags & ( 1 << 0 ) ) ? 1 : 0; bRepPhysics = ( Flags & ( 1 << 1 ) ) ? 1 : 0; bOutSuccess = true; // update location, rotation, linear velocity bOutSuccess &= SerializeQuantizedVector( Ar, Location, LocationQuantizationLevel ); switch(RotationQuantizationLevel) { case ERotatorQuantization::ByteComponents: { Rotation.SerializeCompressed( Ar ); break; } case ERotatorQuantization::ShortComponents: { Rotation.SerializeCompressedShort( Ar ); break; } } bOutSuccess &= SerializeQuantizedVector( Ar, LinearVelocity, VelocityQuantizationLevel ); // update angular velocity if required if ( bRepPhysics ) { bOutSuccess &= SerializeQuantizedVector( Ar, AngularVelocity, VelocityQuantizationLevel ); } return true; } |
As you can see, the method takes a FArchive
where to pack and unpack the struct data. The FArchive
is a class which implements a common pattern for data serialization, allowing the writing of two-way functions. Basically, when it comes to serialization, you have to make sure that the way you serialize your data is exactly the same you use for deserialization. The best way to ensure this behavior is to write one single context-sensitive function that does both. The black magic of the FArchive
lays in its overloaded <<
operator. This operator is at the base of the creation of two-way functions. Its behavior is context-sensitive: when the FArchive
is in write mode, it copies data from right to left, when the FArchive
is in read mode, it copies data from left to right. The only problem here is that the Epic guys chose to overload an operator with a strong directional meaning and at first this mechanism may result a little confusing. You can find out more about the use of FArchive
in this article by Rama. For an example of struct data compression let’s have a look at this piece of code in UnrealMath.cpp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
// Runtime/Core/Private/Math/UnrealMath.cpp void FRotator::SerializeCompressed( FArchive& Ar ) { uint8 BytePitch = FRotator::CompressAxisToByte(Pitch); uint8 ByteYaw = FRotator::CompressAxisToByte(Yaw); uint8 ByteRoll = FRotator::CompressAxisToByte(Roll); uint8 B = (BytePitch!=0); Ar.SerializeBits( &B, 1 ); if( B ) { Ar << BytePitch; } else { BytePitch = 0; } B = (ByteYaw!=0); Ar.SerializeBits( &B, 1 ); if( B ) { Ar << ByteYaw; } else { ByteYaw = 0; } B = (ByteRoll!=0); Ar.SerializeBits( &B, 1 ); if( B ) { Ar << ByteRoll; } else { ByteRoll = 0; } if( Ar.IsLoading() ) { Pitch = FRotator::DecompressAxisFromByte(BytePitch); Yaw = FRotator::DecompressAxisFromByte(ByteYaw); Roll = FRotator::DecompressAxisFromByte(ByteRoll); } } |
The code above performs quantization of an FRotator
Pitch, Yaw and Roll properties into byte values and vice versa. In the first 3 lines the current values are quantized from float to byte values. Then comes the two-way part of the method where for each value a bit which tells if the value is zero is written/read. If it is different from zero, then the value is written/read. The last part of the method is context-sensitive: only if the archive is in read mode, the data is decompressed and written into the float properties of the struct.
This technique can be very useful in a multiplayer networking context; for example, if you are using a struct to replicate your player’s controls, you can compress your data using a bit for each control to tell if the value is different from zero (many input controls are zero most of the time), than you can store them using byte quantization (you don’t really need float precision for an analog user input). Here is an example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess) { uint8 ByteAcceleration = FMath::Quantize8UnsignedByte(Acceleration); uint8 ByteBrake = FMath::Quantize8UnsignedByte(Brake); uint8 ByteTurn = FMath::Quantize8SignedByte(Turn); uint8 BytePitch = FMath::Quantize8SignedByte(Pitch); uint8 ByteRoll = FMath::Quantize8SignedByte(Roll); uint8 B = (ByteAcceleration != 0); Ar.SerializeBits(&B, 1); if (B) Ar << ByteAcceleration; else ByteAcceleration = 0; B = (ByteBrake != 0); Ar.SerializeBits(&B, 1); if (B) Ar << ByteBrake; else ByteBrake = 0; B = (ByteTurn != 0); Ar.SerializeBits(&B, 1); if (B) Ar << ByteTurn; else ByteTurn = 0; B = (BytePitch != 0); Ar.SerializeBits(&B, 1); if (B) Ar << BytePitch; else BytePitch = 0; B = (ByteRoll != 0); Ar.SerializeBits(&B, 1); if (B) Ar << ByteRoll; else ByteRoll = 0; if (Ar.IsLoading()) { Acceleration = Decompress8UnsignedByte(ByteAcceleration); Brake = Decompress8UnsignedByte(ByteBrake); Turn = Decompress8SignedByte(ByteTurn); Pitch = Decompress8SignedByte(BytePitch); Roll = Decompress8SignedByte(ByteRoll); } return true; } |
NetDeltaSerialize and Fast TArray Replication
Unreal Engine implements a generic data serialization for atomic properties of an Actor like ints, floats, objects* and a generic delta serialization for dynamic properties like TArrays. Delta serialization is performed by comparing a previous base state with the current state and generating a diff state and a full state to be used as a base state for the next delta serialization.
As seen above with NetSerialize, it is possible to customize the delta serialization by defining a NetDeltaSerialize function in a USTRUCT.
Here is the method signature:
1 2 3 4 5 6 7 8 9 10 |
/** * @param DeltaParms Generic struct of input parameters for delta serialization * * @return return true if the serialization was fully mapped. If false, the property will be considered 'dirty' and will replicate again on the next update. * This is needed for UActor* properties. If an actor's Actorchannel is not fully mapped, properties referencing it must stay dirty. * Note that UPackageMap::SerializeObject returns false if an object is unmapped. Generally, you will want to return false from your ::NetSerialize * if you make any calls to ::SerializeObject that return false. * */ bool NetDeltaSerialize(FNetDeltaSerializeInfo & DeltaParms); |
The source file Runtime/Engine/Classes/Engine/NetSerialization.h
contains a lot of documentation about how the Unreal Engine net serialization works. It contains also commented code examples on how to implement custom struct serialization; I strongly recommend reading it.
Custom net delta serialization is mainly used in combination with fast TArray replication (FTR).
Basically, if you want to replicate a TArray efficiently, or if you want events to be called on client for adds and removal, just wrap the array into a ustruct
and use FTR. Here is what the code documentation says about FTR:
Fast TArray Replication is a custom implementation of NetDeltaSerialize that is suitable for TArrays of UStructs. It offers performance improvements for large data sets, it serializes removals from anywhere in the array optimally, and allows events to be called on clients for adds and removals. The downside is that you will need to have game code mark items in the array as dirty, and well as the order of the list is not guaranteed to be identical between client and server in all cases.
Here is the commented code example for FTR extracted from the documentation:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
/** Step 1: Make your struct inherit from FFastArraySerializerItem */ USTRUCT() struct FExampleItemEntry : public FFastArraySerializerItem { GENERATED_USTRUCT_BODY() // Your data: UPROPERTY() int32 ExampleIntProperty; UPROPERTY() float ExampleFloatProperty; /** * Optional functions you can implement for client side notification of changes to items; * Parameter type can match the type passed as the 2nd template parameter in associated call to FastArrayDeltaSerialize * * NOTE: It is not safe to modify the contents of the array serializer within these functions, nor to rely on the contents of the array * being entirely up-to-date as these functions are called on items individually as they are updated, and so may be called in the middle of a mass update. */ void PreReplicatedRemove(const struct FExampleArray& InArraySerializer); void PostReplicatedAdd(const struct FExampleArray& InArraySerializer); void PostReplicatedChange(const struct FExampleArray& InArraySerializer); }; /** Step 2: You MUST wrap your TArray in another struct that inherits from FFastArraySerializer */ USTRUCT() struct FExampleArray: public FFastArraySerializer { GENERATED_USTRUCT_BODY() UPROPERTY() TArray<FExampleItemEntry> Items; /** Step 3: You MUST have a TArray named Items of the struct you made in step 1. */ /** Step 4: Copy this, replace example with your names */ bool NetDeltaSerialize(FNetDeltaSerializeInfo & DeltaParms) { return FFastArraySerializer::FastArrayDeltaSerialize<FExampleItemEntry, FExampleArray>( Items, DeltaParms, *this ); } }; /** Step 5: Copy and paste this struct trait, replacing FExampleArray with your Step 2 struct. */ template<> struct TStructOpsTypeTraits< FExampleArray > : public TStructOpsTypeTraitsBase { enum { WithNetDeltaSerializer = true, }; }; #endif /** Step 6 and beyond: * -Declare a UPROPERTY of your FExampleArray (step 2) type. * -You MUST call MarkItemDirty on the FExampleArray when you change an item in the array. You pass in a reference to the item you dirtied. * See FFastArraySerializer::MarkItemDirty. * -You MUST call MarkArrayDirty on the FExampleArray if you remove something from the array. * -In your classes GetLifetimeReplicatedProps, use DOREPLIFETIME(YourClass, YourArrayStructPropertyName); * * You can override the following virtual functions in your structure (step 1) to get notifies before add/deletes/removes: * -void PreReplicatedRemove(const FFastArraySerializer& Serializer) * -void PostReplicatedAdd(const FFastArraySerializer& Serializer) * -void PostReplicatedChange(const FFastArraySerializer& Serializer) * * Thats it! */ |
And here are some code examples about implementing the above “step 6 and beyond”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
// adding a FExampleArray property to an Actor UCLASS() class MyActor : public AActor { GENERATED_UCLASS_BODY() public: UPROPERTY(Replicated) FExampleArray DeltaTest; } // Adding DOREPLIFETIME to the GetLifetimeReplicatedProps method void MyActor::GetLifetimeReplicatedProps(TArray< FLifetimeProperty > & OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(MyActor, DeltaTest); } // Adding an element to the array void MyActor::AddItem() { FExampleItemEntry a; a.ExampleFloatProperty = 3.14; a.ExampleIntProperty = 1234; DeltaTest.MarkItemDirty(DeltaTest.Items.Add_GetRef(a)); } // Modifying an element void MyActor::ChangeItem(int32 ItemID) { if (DeltaTest.Items.Num() > ItemID) { DeltaTest.Items[ItemID].ExampleFloatProperty = 6.28; DeltaTest.Items[ItemID].ExampleIntProperty = 5678; DeltaTest.MarkItemDirty(DeltaTest.Items[ItemID]); } } // Removing an element void MyActor::RemoveLastItem() { if (DeltaTest.Items.Num() > 0) { DeltaTest.Items.RemoveAt(DeltaTest.Items.Num()-1, 1); DeltaTest.MarkArrayDirty(); } } |
As you can see above, I’m marking an item as dirty when adding or modifying it. I’m marking the whole array as dirty when removing some item.
Notes
- Unreal Engine Multiplayer: Performance and Bandwidth Tips [↩]
- Unreal Engine: FVector_NetQuantize [↩]
- Unreal Engine: FVector_NetQuantize10 [↩]
- Unreal Engine: FVector_NetQuantize100 [↩]
- Unreal Engine: FVector_NetQuantizeNormal [↩]
- Unreal Engine: FRotator::SerializeCompressed [↩]
- Unreal Engine: FRotator::SerializeCompressedShort [↩]
- An introduction to C++ Traits [↩]
// Adding an element to the array
void MyActor::AddItem() {
FExampleItemEntry a;
a.ExampleFloatProperty = 3.14;
a.ExampleIntProperty = 1234;
DeltaTest.MarkItemDirty(a);
DeltaTest.Items.Add(a);
}
needs to be
// Adding an element to the array
void MyActor::AddItem() {
FExampleItemEntry a;
a.ExampleFloatProperty = 3.14;
a.ExampleIntProperty = 1234;
DeltaTest.MarkItemDirty(DeltaTest.Items.Add_GetRef(a));
;
}
can be made cleaner but you get what i mean
Thanks, fixed.
Pingback: Better Burst Counters – James Baxter
Great article.
Is it possible to use FastTArrayReplication on local method variables before sent via RPC (Client/Server/Multicast)?
At the moment I have only gotten it to work with class members marked Replicated.
Greetings
Hello, this is a great article that I refer back to regarding the fast array serializer.
Is it possible you can explain how to batch multiple additions or changes in one function? Or would you just call MarkArrayDirty() after you’ve modified the array?
For example:
void AddItems(TArray InItems)
{
// Append them to the array
}
Thank you for this post, very useful
Great article, but for me, when I change a value in the array, its not be replicated to the client for some reason.