Skip to content

Instantly share code, notes, and snippets.

@OscarAbraham
Last active March 18, 2024 15:58
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save OscarAbraham/72a636234c2df2abe407cb6d632ca57b to your computer and use it in GitHub Desktop.
Save OscarAbraham/72a636234c2df2abe407cb6d632ca57b to your computer and use it in GitHub Desktop.
PlainDataInstancer ScriptableObject for Unity, so you can create deep clones of data stored in SO assets at runtime without the overhead of ScriptableObject copies.

It can be useful make ScriptableObject assets that are meant to be cloned at runtime with Instantiate(object). This prevents modification of the original asset in the Editor, and allows to create multiple copies. The problem is that ScriptableObjects can have considerable overhead when they are instantiated more than a few times. They also need to be destroyed to avoid potential leaks and performance problems.

PlainDataInstancer is a ScriptableObject that instantiates plain serializable objects or structs. We get the convenience of SO assets without the overhead. The instances are deep clones, which is good in these cases, and creation is surprisignly fast when compared to other solutions.

Here's an example of how to define a data instancer that can be edited in the inspector, just like normal SO assets:

using System.Collections.Generic;
using UnityEngine;

// A class to be instantiated. It must be a plain class or struct with [System.Serializable].
[System.Serializable]
public class ExampleData
{
    // Only supported fields that are public or marked with [SerializeField] can be copied to new instances.
    public List<string> someTexts;
    [SerializeField]private Vector3 aVector;
    // References to Unity Objects also work out of the box.
    public GameObject obj;
}

// This is a ScriptableObject that creates instances of type ExampleData. Make sure to put it on a file with the same name. 
[CreateAssetMenu]
public class DataInstancerExample : PlainDataInstancer<ExampleData> { }

Here's how you use a PlainDataInstancer asset:

using UnityEngine;

public class DataInstancerUsage : MonoBehaviour
{
    // Create a DataInstancerExample asset in the project window, then drag it here in the inspector.
    public DataInstancerExample instancer;

    void Start()
    {
        if (instancer == null) return;

        // CreateDataInstance returns a new object that's a copy of the instancer's PrototypeData.
        // You can edit the PrototypeData in the instancer's inspector.
        ExampleData instance = instancer.CreateDataInstance();
    }
}
using System.Collections.Generic;
using UnityEngine;
// TData should be a plain class or struct with the [System.Serializable] attribute.
// TData can implement ISerializationCallbackReceiver if needed for more advanced uses.
public class PlainDataInstancer<TData> : ScriptableObject
{
private static bool s_DataTypeValidated;
[SerializeField] private TData m_PrototypeData;
[System.NonSerialized] private string m_CachedJson;
public TData CreateDataInstance()
{
// Caching the JSON helps a lot with performance in tight cases.
if (string.IsNullOrEmpty(m_CachedJson))
m_CachedJson = JsonUtility.ToJson(m_PrototypeData);
return JsonUtility.FromJson<TData>(m_CachedJson);
}
// An utility method to set the PrototypeData from code. It shouldn't be needed for most use cases.
public void SetPrototypeDataValues(TData data)
{
if (data == null)
{
Debug.LogError("Cannot set prototype to null.", this);
return;
}
// We copy data for consistency; in case it changes after being assigned. Otherwise, those
// changes would only be ignored if they happen after CreatePocoInstance assigns m_CachedJson.
m_CachedJson = JsonUtility.ToJson(data);
m_PrototypeData = JsonUtility.FromJson<TData>(m_CachedJson);
}
protected virtual void OnEnable()
{
#if (UNITY_EDITOR || DEVELOPMENT_BUILD)
if (!s_DataTypeValidated)
{
s_DataTypeValidated = true;
var type = typeof(TData);
if (type.IsPrimitive
|| type.IsEnum
|| type.IsArray
|| (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)))
{
Debug.LogError($"{type} is not compatible with PlainDataInstancer. Instead of primitive, list or array types, use a custom serializable container object or struct.", this);
}
else if (type.IsAbstract || type.IsSubclassOf(typeof(UnityEngine.Object)))
{
Debug.LogError($"{type} is not supported by PlainDataInstancer. Make sure it's neither abstract nor a UnityEngine.Object.", this);
}
}
#endif
}
protected virtual void OnValidate()
{
m_CachedJson = null;
}
}

Copyright (c) 2021 Oscar Abraham

Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

@zukas3
Copy link

zukas3 commented Oct 12, 2022

Have you benchmarked this approach? I do not doubt memory improvements, but in regard to execution speed, I would not be surprised if repetitive JSON deserialization were slower in comparison to the inbuilt Instantiate method.

@OscarAbraham
Copy link
Author

OscarAbraham commented Oct 17, 2022

@zukas3 Yes :). It is relatively faster, specially in some cases. Here is what I know:

Instantiating ScriptableObjects in the Editor is slower than in the Player. A very simple SO can take 4 times as much when instantiating in the Editor. The more complex the SO is, the smaller the difference between runtime and the Editor.

My technique is always noticeably faster in the Editor, even for complex data. It can be 14 times faster for simple types, and about twice as fast for very complex types that contain stuff like [SerializeReference]. Outside the editor, my technique is about 5 times faster for simple types, and it can get to just a little faster for very complex types.

These timings apply for instantiations done after the first one; a big part of this optimization is caching the JSON. Unity doesn't cache the serialized data when calling Instantiate; it must serialize and deserialize it every time. Also, this optimization seems even better when one considers the savings for GC and memory; GC is specially heavy with Unity Objects.

I've found an improvement for this technique that supports any serialized type directly, even simple types like float or int. It works by deserializing a generic struct instead of deserializing the type directly. I'll try to update this code to use that later.

EDIT
Another optimization for this approach is to use Unity.Collections.LowLevel.Unsafe.UnsafeUtility.IsUnmanaged to detect if the type of data needs JSON deserialization. Unmanaged structs, strings, and UnityEngine.Object references can be returned directly from m_PrototypeData; Unmanaged structs are already copied when assigned, strings are immutable, and Unity Objects are expected to be references.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment