Community Made Tools

Have you made any useful utilities with Odin?

Login and submit your creations here

Serializing references to Scriptable Objects at runtime

Authored by Lazersquid
Shared 19-05-2020

You can easily save references to scriptable objects at runtime using Odin and this script. It will store a reference to every scriptable object asset, that is of a type that you flagged for caching at design time. It can then be used at runtime to serialize references to those scriptable objects when serializing with Odin.

The cache/database is a scriptable object that implements Odin's IExternalStringReferenceResolver interface and only has to be passed to the serialization/deserialization context to work.

The reference cache is serialized at design time using Unity's serialization backend.


Serializing references by their asset GUID:

The cache will collect references to all scriptable object assets that implement ISerializeReferenceByAssetGuid together with their respective Unity asset GUID. The cache is then used by Odin to resolve references by their GUID when serializing/deserializing at runtime.

You can limit the search for scriptable objects to certain folders, inside the inspector of the cache

public class Item : ScriptableObject, ISerializeReferenceByAssetGuid
{
    // your code 
}

Just pass the cache to Odin's serialization/deserialization context:

The cache will be initialized lazily if you don't initialize it with referenceCache.Initialize() before using it though!

private void SaveState(string savegamePath, ScriptableObjectReferenceCache referenceCache)
{
    if (string.IsNullOrEmpty(savegamePath)) return;

    var context = new SerializationContext
    {
        StringReferenceResolver = referenceCache
    };

    var state = new InventoryState()
    {
        Items = items,
        Upgrades = upgrades
    };

    var bytes = SerializationUtility.SerializeValue(state, savegameDataFormat, context);
    File.WriteAllBytes(savegamePath, bytes);
}


private void LoadState(string savegamePath, ScriptableObjectReferenceCache referenceCache)
{
    if (!File.Exists(savegamePath)) return;

    var context = new DeserializationContext
    {
        StringReferenceResolver = referenceCache
    };

    var bytes = File.ReadAllBytes(savegamePath);
    var state = SerializationUtility.DeserializeValue<InventoryState>(bytes, savegameDataFormat, context);
    items = state.Items;
    upgrades = state.Upgrades;
}

The reference can also be serialized with a custom GUID:

Your scriptable object simply has to implement ISerializeReferenceByCustomGuid instead of ISerializeReferenceByAssetGuid

public class Item: ScriptableObject, ISerializeReferenceByCustomGuid
{
    [SerializeField] private string guid;
    public string Guid => guid;
    
    // your code
}

ScriptableObjectReferenceCache.cs

Just add this file to your project and create an asset instance of this class in your project folder.

using System;
using System.Collections.Generic;
using System.Linq;
using Sirenix.OdinInspector;
using Sirenix.Serialization;
using Sirenix.Utilities;
using UnityEditor;
using UnityEngine;

[CreateAssetMenu]
public class ScriptableObjectReferenceCache : ScriptableObject, IExternalStringReferenceResolver
{
    [FolderPath(RequireExistingPath = true)]
    [SerializeField] private string[] foldersToSearchIn;

    [InlineButton(nameof(ClearReferences))] [InlineButton(nameof(FetchReferences))] [LabelWidth(140)] [PropertySpace(10)]
    [SerializeField] private bool autoFetchInPlaymode = true;
    
    [ReadOnly]
    [SerializeField] private List<SOCacheEntry> cachedReferences;

    private Dictionary<string, ScriptableObject> guidToSoDict;
    private Dictionary<ScriptableObject, string> soToGuidDict;
    
    [ShowInInspector][HideInEditorMode]
    public bool IsInitialized => guidToSoDict != null && soToGuidDict != null;

    /// <summary>
    /// Populate the dictionaries with the cached references so that they can be retrieved fast for serialization at runtime
    /// </summary>
    public void Initialize()
    {
        if (IsInitialized) return;
        
#if UNITY_EDITOR
        if(autoFetchInPlaymode)
            FetchReferences();
#endif

        guidToSoDict = new Dictionary<string, ScriptableObject>();
        soToGuidDict = new Dictionary<ScriptableObject, string>();
        foreach (var cacheEntry in cachedReferences)
        {
            guidToSoDict[cacheEntry.Guid] = cacheEntry.ScriptableObject;
            soToGuidDict[cacheEntry.ScriptableObject] = cacheEntry.Guid;
        }
    }

#if UNITY_EDITOR
    private void ClearReferences()
    {
        cachedReferences.Clear();
    }
    
    /// <summary>
    /// Searches for all scriptable objects that implement ISerializeReferenceByAssetGuid or ISerializeReferenceByAssetGuid and saves them in a list together with their guid
    /// </summary>
    private void FetchReferences()
    {
        cachedReferences = new List<SOCacheEntry>();
        
        var assetGuidTypes = GetSoTypesWithInterface<ISerializeReferenceByAssetGuid>();
        var instancesWithAssetGuid = GetAssetsOfTypes<ISerializeReferenceByAssetGuid>(assetGuidTypes, foldersToSearchIn);
        foreach (var scriptableObject in instancesWithAssetGuid)
        {
            var assetGuid = AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(scriptableObject));
            cachedReferences.Add(new SOCacheEntry(assetGuid, scriptableObject));
        }
        
        var customGuidTypes = GetSoTypesWithInterface<ISerializeReferenceByCustomGuid>();
        var instancesWithCustomGuid = GetAssetsOfTypes<ISerializeReferenceByCustomGuid>(customGuidTypes, foldersToSearchIn);
        foreach (var scriptableObject in instancesWithCustomGuid)
        {
            var guid = ((ISerializeReferenceByCustomGuid) scriptableObject).Guid;
            cachedReferences.Add(new SOCacheEntry(guid, scriptableObject));
        }
    }

    /// <summary>
    /// Get all types that derive from scriptable object and implement interface T
    /// </summary>
    private List<Type> GetSoTypesWithInterface<T>()
    {
        return AssemblyUtilities.GetTypes(AssemblyTypeFlags.All)
            .Where(t =>
                !t.IsAbstract &&
                !t.IsGenericType &&
                typeof(T).IsAssignableFrom(t) &&
                t.IsSubclassOf(typeof(ScriptableObject)))
            .ToList();
    }
    
    /// <summary>
    /// Returns all scriptable objects that are of one of the passed in types and implement T as well.
    /// </summary>
    /// <param name="searchInFolders"> Optionally limit the search to certain folders </param>
    private List<ScriptableObject> GetAssetsOfTypes<T>(IEnumerable<Type> types, params string[] searchInFolders)
    {
        return types
            .SelectMany(type =>
                AssetDatabase.FindAssets($"t:{type.Name}", searchInFolders))
            .Select(AssetDatabase.GUIDToAssetPath)
            .Select(AssetDatabase.LoadAssetAtPath<ScriptableObject>)
            .Where(scriptableObject => scriptableObject is T) // make sure the scriptable object implements the interface T because AssetDatabase.FindAssets might return wrong assets if types of different namespaces have the same name
            .ToList();
    }
#endif

    #region Members of IExternalStringReferenceResolver
    public bool CanReference(object value, out string id)
    {
        EnsureInitialized();
        
        id = null;
        if (!(value is ScriptableObject so))
            return false;
        
        if (!soToGuidDict.TryGetValue(so, out id))
            id = "not_in_database";
        
        return true;
    }
    
    public bool TryResolveReference(string id, out object value)
    {
        EnsureInitialized();
        
        value = null;
        if (id == "not_in_database") return true;
        
        var containsId = guidToSoDict.TryGetValue(id, out var scriptableObject);
        value = scriptableObject;
        return containsId;
    }

    public IExternalStringReferenceResolver NextResolver { get; set; }
    
    private void EnsureInitialized()
    {
        if (IsInitialized) return;
        
        Initialize();
        Debug.LogWarning($"Had to initialize {nameof(ScriptableObjectReferenceCache)} lazily because it wasn't initialized before use!");
    }
    #endregion
}

[Serializable]
public class SOCacheEntry
{
    [SerializeField] private string guid;
    public string Guid => guid;
    
    [SerializeField] private ScriptableObject scriptableObject;
    public ScriptableObject ScriptableObject => scriptableObject;
    
    
    public SOCacheEntry(string guid, ScriptableObject scriptableObject)
    {
        this.guid = guid;
        this.scriptableObject = scriptableObject;
    }
}

public interface ISerializeReferenceByAssetGuid
{
}

public interface ISerializeReferenceByCustomGuid
{
    string Guid { get; }
}