Files
sbox-public/engine/Sandbox.Engine/Scene/Components/Component.Serialize.cs
Lorenz Junglas 238425e6d3 Omit nulls when serializing for network (#4376)
When serializing and deserializing to/from JSON we need to explicitly write nulls.
Because for development/editing we need to differentiate the following cases:
1. JSON is of an older version (field missing) -> initialize with code defined default
2. JSON is of the current version and field is actually null -> initialize with null

For networking we don't need to differentiate between those cases since client and server have the same version of the code at runtime.

So in networking case we can omit nulls from the JSON and skip all of these:

```
          "OnComponentDestroy": null,
          "OnComponentDisabled": null,
          "OnComponentEnabled": null,
          "OnComponentFixedUpdate": null,
          "OnComponentStart": null,
          "OnComponentUpdate": null,
          "OnPropBreak": null,
          "OnPropTakeDamage": null,
```

However, this wont reduce the amount of data that ends up on the wire, because compression already does a good job at deduplicating those strings.

```
Raw JSON    with nulls :      41Kb
Raw JSON    skip nulls :      27Kb  (13Kb saved, 33.6% smaller, 1.51x ratio)
LZ4 fast    with nulls :       7Kb  (34Kb saved, 81.9% smaller, 5.54x ratio)
LZ4 fast    skip nulls :       7Kb  (34Kb saved, 82.6% smaller, 5.76x ratio)
```

The main benefit instead comes from saving CPU resources as this makes compression/decompression faster, because there is less data to process.

```
LZ4  with nulls          compress :    0.210 ms/op
LZ4  with nulls          decomp   :    0.232 ms/op
 
LZ4  skip nulls          compress :    0.165 ms/op
LZ4  skip nulls          decomp   :    0.074 ms/op
```
2026-03-27 10:59:06 +01:00

382 lines
10 KiB
C#

using Facepunch.ActionGraphs;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Sandbox;
public abstract partial class Component : BytePack.ISerializer
{
public JsonNode Serialize( GameObject.SerializeOptions options = null )
{
var isSceneForNetwork = options?.SceneForNetwork ?? false;
var isSingleNetworkObject = options?.SingleNetworkObject ?? false;
var isNetworked = isSceneForNetwork || isSingleNetworkObject;
var skipNulls = options?.SkipNulls ?? false;
if ( isNetworked && Flags.Contains( ComponentFlags.NotNetworked ) )
return null;
if ( Flags.Contains( ComponentFlags.NotCloned ) )
return null;
if ( !isNetworked && Flags.Contains( ComponentFlags.NotSaved ) )
return null;
var t = Game.TypeLibrary.GetType( GetType() );
if ( t is null )
{
Log.Warning( $"TypeLibrary could not find {GetType()}" );
return null;
}
using var sceneScope = Scene.Push();
// Will omit serializing the target of embedded Action Graphs
using var targetScope = ActionGraph.PushTarget( InputDefinition.Target( typeof( GameObject ) ) );
var json = new JsonObject
{
{ JsonKeys.Type, t.SerializedName },
{ JsonKeys.Id, Id },
{ JsonKeys.Enabled, Enabled },
{ JsonKeys.Flags, (long)Flags }
};
if ( (isSceneForNetwork || isSingleNetworkObject) && this is INetworkSnapshot sw )
{
var writer = ByteStream.Create( 8 );
sw.WriteSnapshot( ref writer );
if ( writer.Length > 0 )
{
json[JsonKeys.Snapshot] = Json.ToNode( writer.ToArray() );
}
writer.Dispose();
}
if ( ComponentVersion != 0 ) json[JsonKeys.Version] = ComponentVersion;
foreach ( var member in ReflectionQueryCache.OrderedSerializableMembers( GetType() ) )
{
if ( member is FieldDescription field )
{
var value = field.GetValue( this );
if ( skipNulls && value is null ) continue;
try
{
json.Add( field.Name, Json.ToNode( value, field.FieldType ) );
}
catch ( System.Exception e )
{
Log.Warning( e, $"Error when serializing {this}.{field.Name} ({e.Message})\n{value}" );
}
}
else if ( member is PropertyDescription prop )
{
var value = prop.GetValue( this );
if ( skipNulls && value is null ) continue;
try
{
json.Add( prop.Name, Json.ToNode( value, prop.PropertyType ) );
}
catch ( System.Exception e )
{
Log.Warning( e, $"Error when serializing {this}.{prop.Name} ({e.Message})\n{value}" );
}
}
}
return json;
}
JsonObject jsonData;
// When true, properties absent from jsonData are explicitly cleared in PostDeserialize
// rather than being left at their current value.
bool _clearAbsentFields;
public void Deserialize( JsonObject node ) => DeserializeInternal( node, false );
/// <summary>
/// When <paramref name="clearAbsentFields"/> is <see langword="true"/>, properties absent from
/// <paramref name="node"/> (e.g. omitted by <see cref="GameObject.SerializeOptions.SkipNulls"/>)
/// are explicitly cleared to null/default rather than left at their existing value.
/// Used for all network receive paths.
/// </summary>
internal void DeserializeInternal( JsonObject node, bool clearAbsentFields )
{
var serializedVersion = (int)(node[JsonKeys.Version] ?? 0);
if ( serializedVersion < ComponentVersion )
{
//Log.Warning( $"{this} needs an API update, running upgraders" );
JsonUpgrader.Upgrade( serializedVersion, node, GetType() );
}
if ( node.TryGetPropertyValue( JsonKeys.Id, out var id ) )
{
Id = (Guid)id;
}
DeserializeFlags( node );
if ( node.TryGetPropertyValue( JsonKeys.Snapshot, out var snapshotNode ) && this is INetworkSnapshot sw )
{
var data = snapshotNode.Deserialize<byte[]>();
var reader = ByteStream.CreateReader( data );
sw.ReadSnapshot( ref reader );
reader.Dispose();
}
jsonData = node;
_clearAbsentFields = clearAbsentFields;
InitializeComponent();
Enabled = (bool)(jsonData[JsonKeys.Enabled] ?? true);
}
private void DeserializeFlags( JsonObject node )
{
if ( !node.TryGetPropertyValue( JsonKeys.Flags, out var inFlagNode ) )
return;
var inFlags = (ComponentFlags)(long)inFlagNode;
const ComponentFlags savedFlags = ComponentFlags.ShowAdvancedProperties;
Flags = (Flags & ~savedFlags) | (inFlags & savedFlags);
}
internal void PostDeserialize()
{
if ( jsonData is null )
return;
using var sceneScope = Scene.Push();
// Inject the host object into embedded Action Graphs
using var targetScope = ActionGraph.PushTarget( InputDefinition.Target( typeof( GameObject ), GameObject ) );
try
{
foreach ( var field in ReflectionQueryCache.OrderedSerializableMembers( GetType() ) )
{
// Skip fields that are not PRESENT in json data.
// We want those to be set to code defined defaults, when deserializing older versions from disk.
//
// Except, during a network refresh with SkipNulls, a missing key means the sender had null for
// that property, so we explicitly clear it.
if ( !jsonData.ContainsKey( field.Name ) )
{
if ( _clearAbsentFields )
{
DeserializeProperty( field, null );
}
continue;
}
// This also includes fields that are null
var v = jsonData[field.Name];
try
{
DeserializeProperty( field, v );
}
catch ( System.Exception e )
{
Log.Warning( e, $"Error when deserializing {this}.{field.Name} ({e.Message})\n{v}" );
}
}
}
finally
{
jsonData = null;
_clearAbsentFields = false;
}
CheckRequireComponent();
}
/// <summary>
/// Deserialize this component as per <see cref="Deserialize"/> but update <see cref="GameObject"/> and <see cref="Component"/> property
/// references immediately instead of having them deferred.
/// </summary>
public void DeserializeImmediately( JsonObject node )
{
DeserializeInternal( node, false );
PostDeserialize();
}
[ConVar( "serialization_warn_time", ConVarFlags.Protected, Help = "Warn if deserializing a component property takes longer than this number of milliseconds." )]
internal static int DeserializeTimeWarnThreshold { get; set; }
private void StopTiming( MemberDescription member, Type type, FastTimer timer )
{
if ( DeserializeTimeWarnThreshold <= 0 ) return;
if ( Scene.IsLoading ) return;
var duration = timer.ElapsedMilliSeconds;
if ( duration > DeserializeTimeWarnThreshold )
{
Log.Warning( $"Deserializing {type.Name} {member.TypeDescription.Name}.{member.Name} took {duration:F1}ms!" );
}
}
internal void DeserializeProperty( MemberDescription member, JsonNode node )
{
var startTime = FastTimer.StartNew();
if ( member is PropertyDescription prop )
{
if ( !prop.IsSetMethodPublic )
{
// By getting the property from the declaring class, we can allow setting
// properties with a private setter.
var declaringType = Game.TypeLibrary.GetType( prop.MemberInfo.DeclaringType );
prop = declaringType.GetProperty( member.Name ) ?? prop;
}
if ( prop.PropertyType.IsAssignableTo( typeof( IJsonPopulator ) ) )
{
var value = prop.GetValue( this );
if ( value == null ) value = Activator.CreateInstance( prop.PropertyType );
if ( value is IJsonPopulator jsonConvert )
{
jsonConvert.Deserialize( node );
}
if ( prop.PropertyType.IsValueType )
{
prop.SetValue( this, value );
}
}
else
{
prop.SetValue( this, Json.FromNode( node, prop.PropertyType ) );
}
StopTiming( prop, prop.PropertyType, startTime );
return;
}
if ( member is FieldDescription field )
{
if ( !field.IsPublic )
{
// By getting the field from the declaring class, we can allow setting
// private fields.
var declaringType = Game.TypeLibrary.GetType( field.MemberInfo.DeclaringType );
field = declaringType.GetField( member.Name ) ?? field;
}
if ( field.FieldType.IsAssignableTo( typeof( IJsonPopulator ) ) )
{
var value = field.GetValue( this );
if ( value == null ) value = Activator.CreateInstance( field.FieldType );
if ( value is IJsonPopulator jsonConvert )
{
jsonConvert.Deserialize( node );
}
if ( field.FieldType.IsValueType )
{
field.SetValue( this, value );
}
}
else
{
field.SetValue( this, Json.FromNode( node, field.FieldType ) );
}
StopTiming( field, field.FieldType, startTime );
return;
}
}
static object BytePack.ISerializer.BytePackRead( ref ByteStream bs, Type targetType )
{
var id = bs.Read<Guid>();
if ( !Game.ActiveScene.IsValid() ) return default;
return Game.ActiveScene.Directory.FindComponentByGuid( id );
}
static void BytePack.ISerializer.BytePackWrite( object value, ref ByteStream bs )
{
if ( value is not Component component )
{
bs.Write( Guid.Empty );
return;
}
bs.Write( component.Id );
}
public static object JsonRead( ref Utf8JsonReader reader, Type targetType )
{
if ( reader.TokenType == JsonTokenType.StartObject )
{
var compRef = JsonSerializer.Deserialize<ComponentReference>( ref reader );
return compRef.Resolve( Game.ActiveScene, targetType, warn: true );
}
//
// Legacy way
//
if ( reader.TryGetGuid( out Guid guid ) )
{
if ( !Game.ActiveScene.IsValid() )
{
Log.Warning( "Tried to read component - but active scene was null!" );
return null;
}
var go = Game.ActiveScene.Directory.FindByGuid( guid );
if ( go is null )
throw new( $"GameObject {guid} was not found" );
var component = go.Components.Get( targetType, FindMode.EverythingInSelf );
if ( component is null )
throw new( $"Component {targetType} was not found on {go}" );
return component;
}
return null;
}
public static void JsonWrite( object value, Utf8JsonWriter writer )
{
if ( value is not Component component )
throw new NotImplementedException();
if ( !component.IsValid )
{
writer.WriteNullValue();
return;
}
JsonSerializer.Serialize( writer, ComponentReference.FromInstance( component ), Json.options );
}
/// <summary>
/// Json Keys used for serialization and deserialization of Components.
/// Kept here so they are easier to change, and we are less susceptible to typos.
/// </summary>
internal static class JsonKeys
{
internal const string Id = "__guid";
internal const string Flags = "Flags";
internal const string Version = "__version";
internal const string Type = "__type";
internal const string Snapshot = "__snapshot";
internal const string Enabled = "__enabled";
}
}