How to Serialize Content Placed at Runtime for Persistence
This how-to demonstrates:
- Registering prefabs for spawning at runtime
- Storing transform and stateful data for restoring content in a future session
- Updating previously serialized data
Note
This how-to uses manually constructed verbose strings to demonstrate serialization in a human-readable format. For
proper serialization, it is recommended to use [Serializable]
structs with a JSON serializer for
a concrete structure, or a binary serializer for compact storage.
Prerequisites
You will need a Unity project with Lightship AR enabled. For more information, see Installing ARDK 3.0.
Basic Concepts for Serialization of Runtime Content
As opposed to Remote Content Authoring, where a Unity scene is built with statically placed objects per ARLocation, Runtime Content serialization allows players to place content arbitrarily, then serialize the placed content into a known format so that it can be re-spawned in the future.
To serialize a player constructed scene, the following information is needed:
- The root transform to which the content is anchored (ARLocation, Image Target, etc)
- This ensures that deserialized content in the future will be placed in the same location
- All GameObjects that the player has placed
- The type and state (transform and color, in this example) of each of these GameObjects
This data is then compacted into a string or binary blob for future retrieval (ie, next time a player tracks the same ARLocation)
To deserialize the data and reconstruct the scene:
- Each GameObject must be retrieved from the serialized data, and the proper GameObject Instatiated
- Each GameObject is placed under the same root transfrom (ARLocation) so that it appears in the same location
- The stateful data (transform, color) of each GameObject is applied
A sample scene "VpsSceneSerialization" is provided to follow along all of the scripts in this how-to.
Registering Prefabs for Serialization
The first problem to solve is to gather a list of all possible Prefabs that players can place. This is necessary to reconstruct the scene in the future.
The script PersistentObject.cs
achieves this purpose, so that spawnable Prefabs can be found with a
GetComponent
, and each unique Prefab is identified by a PrefabId
using UnityEngine;
public class PersistentObject : MonoBehaviour
{
[SerializeField]
private long _prefabId;
public long PrefabId
{
get { return _prefabId; }
internal set
{
_prefabId = value;
}
}
... // Serialization code
}
In the "VpsSceneSerialization" sample, there are two PersistentObject
Prefabs, a cube and a sphere,
with PrefabIds
0 and 1 respectively.
These PersistentObjects
are then registered to a PersistentObjectManager
, to have a complete
list of spawnable objects.
using System.Collections.Generic;
using System.Collections.ObjectModel;
public class PersistentObjectManager : MonoBehaviour
{
[SerializeField]
private List<PersistentObject> _persistentObjects = new List<PersistentObject>();
public ReadOnlyCollection<PersistentObject> PersistentObjects => _persistentObjects.AsReadOnly();
... // Serialization and validation code
}
Runtime Content Placement
The "VpsSceneSerialization" sample has two scripts PersistentContentDemoManager.cs
and PersistentObjectPlacementBehaviour.cs
that manages spawning/destroying Cubes and Spheres whenever the player taps a plane or existing GameObject.
A real game will have its own spawning/destroying rules, and this how-to won't go into depth about how these scripts work. The only relevant implementation detail is that all spawned objects are childed to the ARLocation after instantiation:
// _root is a reference to the ARLocation that is currently tracked
spawned.transform.SetParent(_root.transform, true);
Serializing and Storing Spawned Objects
After the player has built their scene, and is ready to serialize it for storage, a list of all spawned
GameObjects needs to be gathered. This can be done several ways, such as holding each GameObject in a List,
or, as this sample does, using a GetComponent<PersistentObject>
on the root GameObject:
public class PersistentObjectManager : MonoBehaviour
{
[SerializeField]
private List<PersistentObject> _persistentObjects = new List<PersistentObject>();
private const char Delimiter = ';';
private readonly Dictionary<long, PersistentObject> _validObjects =
new Dictionary<long, PersistentObject>();
public string SerializeObjectsToString(GameObject rootGo = null)
{
var root = rootGo ? rootGo.transform : this.transform;
var serializedObjects = new List<string>();
ValidateAndRefreshPersistentObjectList();
foreach (var child in root)
{
var persistentObject = ((Transform)child).GetComponent<PersistentObject>();
if (persistentObject)
{
if (!_validObjects.Keys.ToList().Contains(persistentObject.PrefabId))
{
continue;
}
serializedObjects.Add(persistentObject.SerializeObjectToString());
}
}
return string.Join(Delimiter, serializedObjects.ToArray());
}
... // Deserialization and validation code
}
For simplicity, this sample assumes that all Prefabs are childed to the same root, and there are no nested Prefabs. For a real game, better tracking and handling of Prefabs in the heirarchy may be needed.
After gathering the List
of PersistentObjects
, each PersistentObject
handles its own serialization
into a string, which are then joined into a single string representation for storage.
public class PersistentObject : MonoBehaviour
{
[SerializeField]
private long _prefabId;
public long PrefabId
{
get { return _prefabId; }
internal set
{
_prefabId = value;
}
}
public virtual string SerializeObjectToString()
{
return SimpleObjectToString();
}
protected string SimpleObjectToString()
{
var stringFormat = $"PrefabId: {_prefabId}, Position: {SerializeVector3(transform.localPosition)}, " +
$"Rotation: {SerializeQuaternion(transform.localRotation)}, Scale: {SerializeVector3(transform.localScale)}";
return stringFormat;
}
private string SerializeVector3(Vector3 vector)
{
return string.Format("{0},{1},{2}", vector.x, vector.y, vector.z);
}
private string SerializeQuaternion(Quaternion quat)
{
return string.Format("{0},{1},{2},{3}", quat.x, quat.y, quat.z, quat.w);
}
... // Deserialization code
}
Upon serialization, a string such as PrefabId: 0, Position: 0,0,0, Rotation: 0,-0.2323422,0,0.9726341, Scale: 1,1,1;PrefabId: 1, Position: 0,0,-1.689, Rotation: 0,0,0,1, Scale: 1,1,1
is returned. This string contains all of the relevant information
for reconstructing the scene in a future session.
The serialized string can then be stored in whichever backend or service for future retrieval. In this sample, it is just stored locally in PlayerPrefs, keyed by the ARLocation's Anchor's name:
// Serialize the content and store it
var serializedString = PersistentObjectManager.SerializeObjectsToString(_root);
// _locationString is just persistentAnchor.name
PlayerPrefs.SetString(_locationString, serializedString);
Extending PersistentObject to Store More State
In the "VpsSceneSerialization" sample, the Cube and Sphere are ColorfulPersistentObjects
rather than
base PersistentObjects
. This distinction is made to demonstrate serializing more custom stateful data
on top of the basic transform.
using UnityEngine;
public class ColorfulPersistentObject : PersistentObject
{
public override string SerializeObjectToString()
{
Color color;
if (!Application.isPlaying)
{
// Have to use sharedMaterial in edit mode to not leak materials,
// this means all gameobjects that share a material (default) will have the same color
color = GetComponent<Renderer>().sharedMaterial.color;
}
else
{
color = GetComponent<Renderer>().material.color;
}
var colorStr = SerializeColor(color);
return $"{base.SerializeObjectToString()}, Color: {colorStr}";
}
private string SerializeColor(Color color)
{
return $"{color.r},{color.g},{color.b},{color.a}";
}
... // Deserialization code
}
An additional field "Color" is added to each serialized GameObject's data, which can be restored in the future. The full data string looks like:
PrefabId: 0, Position: 0,0,0, Rotation: 0,-0.2323422,0,0.9726341, Scale: 1,1,1, Color: 1,1,1,1;PrefabId: 1, Position: 0,0,-1.689, Rotation: 0,0,0,1, Scale: 1,1,1, Color: 1,1,1,1
Restoring Serialized Content (Deserialization)
The previously serialized data string is restored in a future session by the PersistentObjectManager.cs
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text.RegularExpressions;
using UnityEngine;
public class PersistentObjectManager : MonoBehaviour
{
[SerializeField]
private List<PersistentObject> _persistentObjects = new List<PersistentObject>();
public ReadOnlyCollection<PersistentObject> PersistentObjects => _persistentObjects.AsReadOnly();
private const char Delimiter = ';';
private const string PrefabIdReg = "PrefabId: (?<pid>\\d*)";
private Regex _prefabIdRegex = new Regex(PrefabIdReg);
private readonly Dictionary<long, PersistentObject> _validObjects =
new Dictionary<long, PersistentObject>();
... // Serialization and validation code
public List<GameObject> DeserializeObjectsFromString(string str, GameObject rootGo = null)
{
var spawnedObjects = new List<GameObject>();
var root = rootGo ? rootGo.transform : this.transform;
ValidateAndRefreshPersistentObjectList();
var objs = str.Split(Delimiter);
foreach (var serializedObj in objs)
{
var match = _prefabIdRegex.Match(serializedObj);
if (!match.Success)
{
Debug.LogError("Could not parse out prefab id");
continue;
}
var prefabId = long.Parse(match.Groups["pid"].Value);
if (!_validObjects.ContainsKey(prefabId))
{
Debug.LogError($"Could not find prefab id {prefabId}");
continue;
}
var persistentObject = _validObjects[prefabId];
var newObject = Instantiate(persistentObject.gameObject, root);
newObject.GetComponent<PersistentObject>().ApplyStringToObject(serializedObj);
spawnedObjects.Add(newObject);
}
return spawnedObjects;
}
}
The DeserializeObjectsFromString
method takes the previously serialized string, splits it into
individual GameObjects, then attempts to spawn registered PersistentObjects
according to the
recorded PrefabIds
.
It is important that the list and contents of the _peristentObjects
List stays
consistent between serialization and deserialization, otherwise nonsense objects may be restored. For a
real game, some versioning scheme to ensure that the _persistentObjects
List is consistent is
recommended.
Each GameObject's string is then passed to the PersistentObject
(or extension ColorfulPersistentObject
)
for each GameObject to handle individually.
using System.Text.RegularExpressions;
using UnityEngine;
public class ColorfulPersistentObject : PersistentObject
{
private const string ColourReg = "Color: (?<color>.*,.*,.*,.*)";
private static Regex _colourRegex = new Regex(ColourReg);
... // Serialization code
public override void ApplyStringToObject(string str)
{
base.ApplyStringToObject(str);
var match = _colourRegex.Match(str);
if (!match.Success)
{
Debug.Log("No match");
return;
}
var colorString = match.Groups["color"].Value;
var color = DeserializeColor(colorString);
ApplyColorToObject(color);
}
public void ApplyColorToObject(Color color)
{
Material mat;
if (!Application.isPlaying)
{
// Have to use sharedMaterial in edit mode to not leak materials
mat = GetComponent<Renderer>().sharedMaterial;
}
else
{
mat = GetComponent<Renderer>().material;
}
mat.color = color;
}
private Color DeserializeColor(string str)
{
// Deserialize a color from a string
var split = str.Split(',');
return new Color(float.Parse(split[0]), float.Parse(split[1]), float.Parse(split[2]),
float.Parse(split[3]));
}
}
The extension ColorfulPersistentObject
only handles the Color portion of the serialized string,
and hands the string to the base PersistentObject
to handle transform data. These multiple matches are
inefficient.
using System.Text.RegularExpressions;
using UnityEngine;
public class PersistentObject : MonoBehaviour
{
[SerializeField]
private long _prefabId;
public long PrefabId
{
get { return _prefabId; }
internal set
{
_prefabId = value;
}
}
private static string regStr =
"PrefabId: (?<pid>\\d*), Position: (?<pos>.*,.*,.*), Rotation: (?<rot>.*,.*,.*,.*), Scale: (?<scale>.*,.*,.*)";
private static Regex regex = new Regex(regStr);
public virtual void ApplyStringToObject(string str)
{
// Parse the string and apply it to the object
SimpleApplyStringToObject(str);
}
protected void SimpleApplyStringToObject(string str)
{
var match = regex.Match(str);
if (!match.Success)
{
Debug.Log("No match");
return;
}
var prefabId = match.Groups["pid"].Value;
var position = match.Groups["pos"].Value;
var rotation = match.Groups["rot"].Value;
var scale = match.Groups["scale"].Value;
_prefabId = long.Parse(prefabId);
transform.localPosition = DeserializeVector3(position);
transform.localRotation = DeserializeQuaternion(rotation);
transform.localScale = DeserializeVector3(scale);
}
private Vector3 DeserializeVector3(string str)
{
var split = str.Split(',');
return new Vector3(float.Parse(split[0]), float.Parse(split[1]), float.Parse(split[2]));
}
private Quaternion DeserializeQuaternion(string str)
{
var split = str.Split(',');
return new Quaternion(float.Parse(split[0]), float.Parse(split[1]), float.Parse(split[2]), float.Parse(split[3]));
}
}
Updating Previously Serialized Data
Previously serialized data can be updated by restoring the data, allowing players to modify the scene (deleting GameObject, moving them), and then reserializing and overwriting the previously stored data.
For very large scenes, constantly reading and rewriting the entire scene is inefficient, so an incremental update system is recommended.