Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. We have updated the language to the Editor Terms based on feedback from our employees and community. Learn more.
    Dismiss Notice

Question How can one assign values to a hashset via an array in the inspector

Discussion in 'Scripting' started by owrtho, Mar 25, 2023.

  1. owrtho

    owrtho

    Joined:
    Sep 19, 2022
    Posts:
    5
    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.

    Thank you,
    owrtho
     
  2. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    6,008
    The most basic way would be to be defer your hashset behind a property, and generate it the first time it's accessed.

    Otherwise you could look to use the ISerializationCallbackReceiver interface: https://docs.unity3d.com/ScriptReference/ISerializationCallbackReceiver.html

    Otherwise otherwise you're dealing with custom inspectors/property drawers. At that point you might as well just get OdinInspector.
     
    Bunny83 likes this.
  3. owrtho

    owrtho

    Joined:
    Sep 19, 2022
    Posts:
    5
    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.

    Still, thank you for the feedback.
    owrtho
     
  4. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    6,008
    I think a property drawer is a bit overkill for what is effectively a fancy collection. You could get 90% of what you need with the following:

    Code (CSharp):
    1. [System.Serializable]
    2. public class SerializedHashSet<T>
    3. {
    4.     [SerializeField]
    5.     private T[] _hashArray;
    6.  
    7.     private HashSet<T> _hashSet;
    8.  
    9.     public HashSet<T> HashSet
    10.     {
    11.         get
    12.         {
    13.             if (_hashSet == null)
    14.             {
    15.                 _hashSet = new(_hashArray);
    16.             }
    17.          
    18.             return _hashSet;
    19.         }
    20.     }
    21. }
    Though from there, yes, you could write a property drawer to enforce the unique entries in the array.
     
    Last edited: Mar 25, 2023
    Bunny83 likes this.
  5. Bunny83

    Bunny83

    Joined:
    Oct 18, 2010
    Posts:
    3,571
    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 :)
     
    spiney199 likes this.
  6. owrtho

    owrtho

    Joined:
    Sep 19, 2022
    Posts:
    5
    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:
    Code (CSharp):
    1. struct ArrayHashSetPair<T>
    2. {
    3.     public T[] tArray
    4.     public HashSet<T> tHashSet
    5.  
    6. #if UNITY_EDITOR
    7.     private void setHashSet()
    8.     {
    9.         // Code to set HashSet values and if needed remove duplicate values from the array, though ideally that would be prevented by the drawer.
    10.     }
    11. #endif
    12. }
    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.

    Thank you,
    owrtho
     
  7. Bunny83

    Bunny83

    Joined:
    Oct 18, 2010
    Posts:
    3,571
    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.

    Code (CSharp):
    1.     public HashSet<T> HashSet
    2.     {
    3.         get
    4.         {
    5.             if (_hashSet == null)
    6.             {
    7.                 _hashSet = new(_hashArray);
    8.             }
    9.             else if (_hashSet.Count == 0)
    10.             {
    11.                 for (int i = 0; i < _hashArray.Length; i++)
    12.                     _hashSet.Add(_hashArray[i]);
    13.             }
    14.             return _hashSet;
    15.         }
    16.     }
    17.     public void ClearHashSet()
    18.     {
    19.         _hashSet.Clear();
    20.     }
    21.  
     
  8. owrtho

    owrtho

    Joined:
    Sep 19, 2022
    Posts:
    5
    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:
    Code (CSharp):
    1. using System;
    2. using System.Collections.Generic;
    3. #if UNITY_EDITOR    // These two libraries are only used for the part allowing inspector editing while testing.
    4. using UnityEngine;
    5. using System.Linq;
    6. #endif
    7.  
    8. [Serializable]
    9. public struct Tags<T>
    10. #if UNITY_EDITOR
    11.     : ISerializationCallbackReceiver
    12. #endif
    13. {
    14.     public T[] array;
    15.     private HashSet<T> _hashSet;
    16.  
    17.     public HashSet<T> hashSet
    18.     {
    19.         get
    20.         {
    21.             if(_hashSet == null)
    22.             {
    23.                 _hashSet = new HashSet<T>(array);
    24.             }
    25.             return _hashSet;
    26.         }
    27.     }
    28.  
    29. #if UNITY_EDITOR
    30.     public void OnBeforeSerialize()
    31.     {
    32.         // Ensure neither variable is null.
    33.         if(_hashSet == null)
    34.         {
    35.             _hashSet = new HashSet<T>();
    36.         }
    37.         if(array == null)
    38.         {
    39.             array = new T[0];
    40.         }
    41.  
    42.         // Get rid of any duplicate values in the array, but keep the length so it can actually be changed.
    43.         T[] temp = _hashSet.ToArray();
    44.         for(int i = 0; i < array.Length; i++)
    45.         {
    46.             if(i < temp.Length)
    47.             {
    48.                 array[i] = temp[i];
    49.             }
    50.             else
    51.             {
    52.                 array[i] = default(T);
    53.             }
    54.         }
    55.     }
    56.  
    57.     public void OnAfterDeserialize()
    58.     {
    59.         // Ensure the HashSet isn't null, then clear current values and add contents of the array.
    60.         if(_hashSet == null)
    61.         {
    62.             _hashSet = new HashSet<T>();
    63.         }
    64.         _hashSet.Clear();
    65.         if(array != null)
    66.         {
    67.             for(int i = 0; i < array.Length; i++)
    68.             {
    69.                 _hashSet.Add(array[i]);
    70.             }
    71.         }
    72.     }
    73. #endif
    74. }
    75.  
    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:
    Code (CSharp):
    1.  
    2.     public bool Contains(T item)
    3.     {
    4.         return _hashSet.Contains(item);
    5.     }
    6.  
    7.     public T index(int  index)
    8.     {
    9.         return array[index];
    10.     }
    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.

    Thank you,
    owrtho
     
  9. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    6,008
    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:
    Code (CSharp):
    1. namespace OdinSerializer
    2. {
    3.     using UnityEngine;
    4.  
    5.     /// <summary>
    6.     /// A Unity MonoBehaviour which is serialized by the Sirenix serialization system.
    7.     /// </summary>
    8. #if ODIN_INSPECTOR
    9.     [Sirenix.OdinInspector.ShowOdinSerializedPropertiesInInspector]
    10. #endif
    11.  
    12.     public abstract class SerializedMonoBehaviour : MonoBehaviour, ISerializationCallbackReceiver, ISupportsPrefabSerialization
    13.     {
    14.         [SerializeField, HideInInspector]
    15.         private SerializationData serializationData;
    16.  
    17.         SerializationData ISupportsPrefabSerialization.SerializationData { get { return this.serializationData; } set { this.serializationData = value; } }
    18.  
    19.         void ISerializationCallbackReceiver.OnAfterDeserialize()
    20.         {
    21.             UnitySerializationUtility.DeserializeUnityObject(this, ref this.serializationData);
    22.             this.OnAfterDeserialize();
    23.         }
    24.  
    25.         void ISerializationCallbackReceiver.OnBeforeSerialize()
    26.         {
    27.             this.OnBeforeSerialize();
    28.             UnitySerializationUtility.SerializeUnityObject(this, ref this.serializationData);
    29.         }
    30.  
    31.         /// <summary>
    32.         /// Invoked after deserialization has taken place.
    33.         /// </summary>
    34.         protected virtual void OnAfterDeserialize()
    35.         {
    36.         }
    37.  
    38.         /// <summary>
    39.         /// Invoked before serialization has taken place.
    40.         /// </summary>
    41.         protected virtual void OnBeforeSerialize()
    42.         {
    43.         }
    44.     }
    45. }
    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.
     
    Last edited: Mar 28, 2023