In my current project I am making use of scriptable objects that store the data for various things the game checks. At no point during the running of the game does the data on these scriptable objects change, so ideally I would be able to set all values via the inspector. One of the things I need to store on the scriptable object is a hashset, which can’t be readily serialized and thus can’t be edited normally via the inspector. I do however also have an array that needs to have the same contents as the hashset. Due to a few places having this same combination I decided to create a struct that would contain the array and hashset pair and handle relevant functions relating to the two.
The problem is that I am unsure how to go about making it so that editing the array in the inspector will also change the hashset. I’ve found some various discussions relating to the idea of using an array or list as an intermediary to edit a hashset, but most of the topics seem to be rather old with comments indicating they run into various problems or are out of date. Ideally, any relevant code could be kept to an editor or drawer script, or marked editor only so that it won’t compile into the final game.
The main relevant traits would be ensuring that editing the array will edit the hashset to include any new values added to the array and remove any removed from the array, as well as to prevent duplicate values being added to the array.
If someone could help me figure out how to do this I would greatly appreciate it.
A custom property drawer would likely be the preferred method, though I’ve only some passing familiarity with them from past projects so am uncertain how specifically I would set it up to work as desired in this case. OdinInspector would be unnecessary extra bloat for the project.
No, not really. PropertyDrawers just allow you to change how serialized data is displayed / presented to the user. It can’t change what data is serialized and it should never be used to convert serialized to non serialized data since PropertyDrawers only work inside the editor. So this functionality would not be available at runtime. Well unless the hashset is only relevant inside the editor?
When you actually need the hashset at runtime, the best approach is what spiney said to use a property that lazy initializes the hashset at the first access.
Yes a property drawer may be used to restrict the editing.
@spiney199 your property inside your SerializedHashSet class is missing a name
The issue with this is that, from what I can tell, it would only ever create the hashset once, and since it would be stored on scriptable objects, it would stick around and become out of date if I ever edited the array.
My thought was more along the lines of finding some way to automatically invoke a function that would be contained in conditional compilation tags so that it’s only compiled in the unity editor and would be left of any actual builds. So something akin to:
struct ArrayHashSetPair<T>
{
public T[] tArray
public HashSet<T> tHashSet
#if UNITY_EDITOR
private void setHashSet()
{
// Code to set HashSet values and if needed remove duplicate values from the array, though ideally that would be prevented by the drawer.
}
#endif
}
Then there would be something in the drawer to invoke the setHeshSet function any time tArray has its value changed. In theory I could try doing something like using a ContextMenu to invoke the function from the inspector, but then I’d have to remember to manually do so any time I changed values of the array which just seems like a good way to end up with problems when I inevitably forget on one or more edits.
I’ve seen code where methods are invoked from drawers, but it tends to be on things like a button being pressed rather than an edit being made, which runs into the same issue. If there is a way to have the drawer run a method in response to changes being made, that would likely handle the issue.
So from those two statements you don’t want to change the values during runtime. The hashset is not serialized (since it can not be serialized) So when you enter playmode, the hashset should always be null and is initialized at the first access. However if runtime edit is wanted, you could simply add the OnValidate callback and simply set the hashset to null, so it would be recreated when you change something on that scriptable object.
To avoid garbage, you could instead of recreating the hashset use Clear and UnionWith or a simple loop to re-add the array in the property getter.
public HashSet<T> HashSet
{
get
{
if (_hashSet == null)
{
_hashSet = new(_hashArray);
}
else if (_hashSet.Count == 0)
{
for (int i = 0; i < _hashArray.Length; i++)
_hashSet.Add(_hashArray[i]);
}
return _hashSet;
}
}
public void ClearHashSet()
{
_hashSet.Clear();
}
So, normally scriptable objects edited during runtime when running in the editor will keep those changes even after the game stops running. However your comment had me decide to try testing to confirm if that would be the case for HashSets. A quick test determined that it was not, and while I could for instance edit an array via code during runtime and it would keep its values, the changes made to the HashSet would be discarded.
After that I tested using ISerializationCallbackReciever to make the HashSet match the values of the array (and was able to similarly use it to ensure the array couldn’t have duplicate values), and did find that had the HashSet values seemingly stick between sessions, but upon commenting out the ISerializationCallbackReciever portions of the code, I found that they were once again empty, even when the values of the array were unchanged by the code being commented. From this I can determine that my desired outcome of having the HashSets actually stored in the scriptable objects is seemingly impossible (at least with this version of unity), and instead it is required to have some piece of code that actually generate the HashSets at some point prior to their usage in the game, which is what I’d hoped to avoid, but it seems there’s no alternative.
That said, I did manage to confirm that the ISerializationCallbackReciever code can be enclosed in “#if UNITY_EDITOR” tags and despite the documentation saying otherwise also works with Structs. Thus while it’s not ideal, it seems the best I can do is combine the suggested code from spiney199 with the ISerializationCallbackReciever in tags that will only have it compile in the editor for an end result that will set the value of the HashSet the first time it is referenced, but while testing in the editor will use the OnBeforeSerialization and OnAfterSerialization to allow runtime changes via the inspector while testing. End result looks like:
using System;
using System.Collections.Generic;
#if UNITY_EDITOR // These two libraries are only used for the part allowing inspector editing while testing.
using UnityEngine;
using System.Linq;
#endif
[Serializable]
public struct Tags<T>
#if UNITY_EDITOR
: ISerializationCallbackReceiver
#endif
{
public T[] array;
private HashSet<T> _hashSet;
public HashSet<T> hashSet
{
get
{
if(_hashSet == null)
{
_hashSet = new HashSet<T>(array);
}
return _hashSet;
}
}
#if UNITY_EDITOR
public void OnBeforeSerialize()
{
// Ensure neither variable is null.
if(_hashSet == null)
{
_hashSet = new HashSet<T>();
}
if(array == null)
{
array = new T[0];
}
// Get rid of any duplicate values in the array, but keep the length so it can actually be changed.
T[] temp = _hashSet.ToArray();
for(int i = 0; i < array.Length; i++)
{
if(i < temp.Length)
{
array[i] = temp[i];
}
else
{
array[i] = default(T);
}
}
}
public void OnAfterDeserialize()
{
// Ensure the HashSet isn't null, then clear current values and add contents of the array.
if(_hashSet == null)
{
_hashSet = new HashSet<T>();
}
_hashSet.Clear();
if(array != null)
{
for(int i = 0; i < array.Length; i++)
{
_hashSet.Add(array[i]);
}
}
}
#endif
}
From some initial testing, this will generate a HashSet that matches the array the first time the HashSet is referenced on a build, but when open in the editor will instead create the HashSet on deserialization allowing changes while testing (with the added benefit that this also ensures no duplicate values in the array).
This is probably not the final version I’ll end up using, but mostly because I’ll most likely end up adjusting it so that rather than directly accessing the array or hashset, they instead have needed functions or values obtained via methods such as:
public bool Contains(T item)
{
return _hashSet.Contains(item);
}
public T index(int index)
{
return array[index];
}
Anyway, to the various people that helped, thank you. I might not have been able to accomplish what I’d hoped to, but I did discover it wasn’t possible and you helped direct me to a workaround.
Yeah this was perhaps something that we could’ve made clearer.
You have to work within the constraints of Unity’s serialisation system, which is designed to be fast over being comprehensive.
You can’t replace Unity’s serialisation either, only extend it. Addons like the Odin Serialiser just work ‘on top’ of Unity’s serialiser, by just serialising everything into a form Unity can store and back again. They too, are just using the ISerializationCallbackReciever interface in their ‘SerializedMonobehaviour’ and similar classes:
namespace OdinSerializer
{
using UnityEngine;
/// <summary>
/// A Unity MonoBehaviour which is serialized by the Sirenix serialization system.
/// </summary>
#if ODIN_INSPECTOR
[Sirenix.OdinInspector.ShowOdinSerializedPropertiesInInspector]
#endif
public abstract class SerializedMonoBehaviour : MonoBehaviour, ISerializationCallbackReceiver, ISupportsPrefabSerialization
{
[SerializeField, HideInInspector]
private SerializationData serializationData;
SerializationData ISupportsPrefabSerialization.SerializationData { get { return this.serializationData; } set { this.serializationData = value; } }
void ISerializationCallbackReceiver.OnAfterDeserialize()
{
UnitySerializationUtility.DeserializeUnityObject(this, ref this.serializationData);
this.OnAfterDeserialize();
}
void ISerializationCallbackReceiver.OnBeforeSerialize()
{
this.OnBeforeSerialize();
UnitySerializationUtility.SerializeUnityObject(this, ref this.serializationData);
}
/// <summary>
/// Invoked after deserialization has taken place.
/// </summary>
protected virtual void OnAfterDeserialize()
{
}
/// <summary>
/// Invoked before serialization has taken place.
/// </summary>
protected virtual void OnBeforeSerialize()
{
}
}
}
The serialiser is open source (hence why I can post the code), so you could integrate it into your project if that eases up the need to maintain serialisation code yourself.