Serializing Data
Overview
The core of the NeoFPS save games system is the pairing of the SceneSaveInfo and NeoSerializedGameObject. The latter is used to identify which objects, along with which of their components, to save and load. The former is required for each saved scene and inside the save files it acts as the container for all the root serialized objects in the scene.
NeoSerializedGameObject
The NeoSerializedGameObject is placed on every object that needs to be saved, or that has components that need saving. On its own it can save the transform properties of the object along with its name. It is also used to filter all of the child objects, neo-serialized components and non-neo-serialized components.
Child Objects
By default, a NeoSerializedGameObject has its child object filter set to Exclude. This means that it will save all child NeoSerializedGameObjects, and to block a child from being saved, it must be added to the filter list. You can also set the filter to Include. This means that none of the child objects will be saved, unless they are specifically added to the filter list.
Child objects do not need to be direct children of the parent NeoSerializedGameObject. Other GameObjects can sit inbetween them in the hierarchy. The exception to this is when objects are instantiated at runtime. When the hierarchy is rebuilt while loading from save, any runtime instantiated objects will be recreated as direct child objects of their parent NeoSerializedGameObject. Therefore it is advisable to make sure that this relationship is in place from the start. For more information on the instantiation of objects at runtime, see Runtime Objects.
Neo-Serialized Components
Neo-serialized components are any monobehaviour which implements the INeoSerializableComponent interface, requiring the following 2 methods:
public void WriteProperties(INeoSerializer writer, NeoSerializedGameObject nsgo, SaveMode saveMode);
public void ReadProperties(INeoDeserializer reader, NeoSerializedGameObject nsgo);
The nsgo parameter is the object the component is attached to (useful if you want to store references to other components), while the saveMode parameter is used for overrides (see Overrides And Persistence).
Other Components
Components which do not implement the INeoSerializableComponent interface require a "formatter" to handle writing and reading their data. The following is an example implementation:
public class RigidbodyFormatter : NeoSerializationFormatter<Rigidbody>
{
// Register the formatter with the save manager.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
#if UNITY_EDITOR // Required for the formatter to be recognised when selecting components to serialize
[UnityEditor.InitializeOnLoadMethod]
#endif
static void Register()
{
NeoSerializationFormatters.RegisterFormatter(new RigidbodyFormatter());
}
private static readonly NeoSerializationKey k_VelocityKey = new NeoSerializationKey("velocity");
protected override void WriteProperties(INeoSerializer writer, Rigidbody from)
{
writer.WriteValue(k_VelocityKey, from.velocity);
// Write other properties here...
}
protected override void ReadProperties(INeoDeserializer reader, Rigidbody to)
{
Vector3 v;
if (reader.TryReadValue(k_VelocityKey, out v, Vector3.zero))
to.velocity = v;
// Read other properties here...
}
}
Inside the ReadProperties() method you can also perform logic such as simulating a particle system or starting a coroutine.
Formatters allow you to save components which you do not have source control over, such as in-built Unity components or the scripts from other assets.
SceneSaveInfo
The SceneSaveInfo behaviour must be placed in each scene that needs to be saved. It allows you to specify the scene name, along with a thumbnail to show in the save browser if the manager is not set to use a screenshot. It also acts as a root container for each of the NeoSerializedGameObjects in the scene (though they should not be its child in the scene hierarchy).
The SceneSaveInfo component can also be used to register prefabs that can be instantiated at runtime and rebuilt by the save system. For more information on this see Runtime Objects.
The Binary Serializer / Deserializer
NeoFPS uses a custom written binary serializer and deserializer for save games. It is designed with the following principles in mind: it should be very fast to gather and write the save data. It should be very robust when reading the data.
Speed is important when writing saves so that the save system does not cause large frame hitches and interfere with the flow of the game. To achieve this, the serializer uses pre-allocated buffers and keeps the save size small by storing the values using a hash instead of a name. This is very different from save systems which prioritise human readability by saving in JSON or XML formats, but makes a huge difference to performance. The actual file writing from the buffers to the save file is performed on a separate thread to the rest of the game.
Robustness is most important when reading because there is no guarantee that the saved data will be up to date with the game when it is run. The player might save the game and then not return to it until a long time has passed and the game has received multiple content updates. The deserializer builds a map of all the properties that is queried by the gameobjects and components in the scene immediately after the scene itself has loaded. Firstly, any objects are destroyed or instantiated as required to recreate the hierarchy in the save file (objects are only destroyed here if they were destroyed in the save so new content is not affected). After that, all the objects and their components are asked to take the data they need from the save data in turn. This allows the components to handle missing data gracefully while redundant data will just be ignored. Speed is also less important at the point of deserialization as it is usually behind a loading screen and not during ongoing play.
The custom serializer uses what's called "unsafe" code to achieve high performance. This means that it uses pointers to move around the buffers and break values into individual bytes. This requires the code to be placed in an assembly definition which enables unsafe code for its contents. This also means that the serializer cannot work for WebGL builds. For this situation, another serializer / deserializer combo has been created called the SafeSerializer. This relies on the .NET binary formatter and streams. It has a higher performance overhead than the custom version but is able to run on all platforms.
Property Keys And Hashes
For the best performance, when writing your own formatter and INeoSerializableComponent scripts, you can store the property key hashes instead of saving and loading using the key names. If you save or load a value using a string then it will be hashed there and then. If this is done thousands of times in a frame then it can be a very expensive process. To make storing the hash easier, you can use the NeoSerializationKey helper class. Some examples of writing a value are as follows:
public class ExampleNeoComponent : MonoBehaviour, INeoSerializableComponent
{
// Create a static readonly key helper once which can be used multiple times
private static readonly NeoSerializationKey k_PropertyKey = new NeoSerializationKey("propertyName");
public int propertyValue = 5;
public void WriteProperties(INeoSerializer writer, NeoSerializedGameObject nsgo, SaveMode saveMode)
{
// Use the property name directly. Easiest, and good for prototyping, but poor performance
writer.WriteValue("propertyName", v);
// Hash the property name. This is what the serializer does behind the scenes in the previous case
writer.WriteValue(NeoSerializationUtilities.StringToHash("propertyName"), v);
// Use the key that was hashed once on creation. Best performance
writer.WriteValue(k_PropertyKey, v);
}
// ...
}
Component And Object References
The save system can serialize references to other NeoSerializedGameObjects or the components on them. It cannot serialize references to components on an object that does not have a NeoSerializedGameObject component attached and is not also serialized.
The following helper functions exist to aid in saving or loading references:
public class ExampleNeoComponent : MonoBehaviour, INeoSerializableComponent
{
public GameObject testGameObject;
public NeoSerializedGameObject testNsgo;
public Transform testTransform;
public NeoComponent testComponent;
public void WriteProperties(INeoSerializer writer, NeoSerializedGameObject nsgo, SaveMode saveMode)
{
writer.WriteGameObjectReference("testGameObject", testGameObject, nsgo);
writer.WriteNeoSerializedGameObjectReference("testNsgo", testNsgo, nsgo);
writer.WriteTransformReference("testTransform", testTransform, nsgo);
writer.WriteComponentReference("testComponent", testComponent, nsgo);
}
public void ReadProperties(INeoDeserializer reader, NeoSerializedGameObject nsgo)
{
reader.TryReadGameObjectReference("testGameObject", out testGameObject, nsgo);
reader.TryReadNeoSerializedGameObjectReference("testNsgo", out testNsgo, nsgo);
reader.TryReadTransformReference("testTransform", out testTransform, nsgo);
reader.TryReadComponentReference("testComponent", out testComponent, nsgo);
}
}