Search Unity

Is using ScriptableObject instances for type/target resolve a good idea?

Discussion in 'Scripting' started by Ardenian, Mar 18, 2020.

  1. Ardenian

    Ardenian

    Joined:
    Dec 7, 2016
    Posts:
    313
    Introduction: Imagine a game that has a player character instance. The player character instance has certain values associated with it, such as health, shield or energy that are vital to the gameplay. Pretty much any generic RPG/ARPG fits for this example.

    When it comes to introducing these values as actual objects, one might be tempted to create a C# enumeration to keep track of which values a character has and writing a component that offers functions taking such an enumeration value to return the appropriate value from a dictionary that relates enumeration values as keys to values that are needed at runtime.

    While this is a quick approach that works, it is hard to maintain during development. If new character values have to be added at some point, the enumeration has to be edited and if done very dull, characters might end up with character values that they don't even use, special stuff and mechanics like Rage or Charges that are unique to the player character.

    Question: Now I came to wonder, could one use ScriptableObjects instances to solve the issue of maintainability, as well as scalability during development? Everyone who has worked with Unity for some time knows how much Unity loves if developers create objects definitions which we can use to create masses of object instances, ScriptableObjects being a prime example. So why not using the emphasis of the engine instead of working around it.

    However, I was "raised" as a developer that "hashing is expensive", which is exactly what one would have to do, regardless if one chose the enumeration values approach or the SO instances approach. Nonetheless, I tested the performance and unless I did something wrong, using SO instances for hashing with no overriden methods is surprisingly cheap. I used this script to test it:

    Code (CSharp):
    1. using System;
    2. using UnityEngine;
    3. using UnityEngine.Profiling;
    4.  
    5. [ExecuteInEditMode]
    6. public class Test : MonoBehaviour
    7. {
    8.     [SerializeField]
    9.     private TestScriptableObject o1;
    10.     [SerializeField]
    11.     private TestScriptableObject o2;
    12.  
    13.     [SerializeField]
    14.     private int i1;
    15.     [SerializeField]
    16.     private int i2;
    17.  
    18.     private void OnEnable()
    19.     {
    20.         CheckSO();
    21.         CheckInt();
    22.     }
    23.  
    24.     private void CheckSO()
    25.     {
    26.         CustomSampler sampler = CustomSampler.Create("MyCustomSampler");
    27.         Recorder recorder = sampler.GetRecorder();
    28.         recorder.enabled = true;
    29.         sampler.Begin();
    30.  
    31.         const int iterations = 100000;
    32.         for (int îndex = 0; îndex < iterations; îndex++)
    33.         {
    34.             var b = o1.Equals(o2);
    35.         }
    36.  
    37.         sampler.End();
    38.         recorder.enabled = false;
    39.         TimeSpan time = TimeSpan.FromMilliseconds(((recorder.elapsedNanoseconds / 1000.0) / 1000.0));
    40.         Debug.Log("Checking SO for equality: "+ time.TotalSeconds + "s in " + iterations + " iterations");
    41.     }
    42.  
    43.     private void CheckInt()
    44.     {
    45.         CustomSampler sampler = CustomSampler.Create("MyCustomSampler2");
    46.         Recorder recorder = sampler.GetRecorder();
    47.         recorder.enabled = true;
    48.         sampler.Begin();
    49.  
    50.         const int iterations = 100000;
    51.         for (int îndex = 0; îndex < iterations; îndex++)
    52.         {
    53.             var b = i1.Equals(i2);
    54.         }
    55.  
    56.         sampler.End();
    57.         recorder.enabled = false;
    58.         TimeSpan time = TimeSpan.FromMilliseconds(((recorder.elapsedNanoseconds / 1000.0) / 1000.0));
    59.         Debug.Log("Checking Int for equality: " + time.TotalSeconds + "s in " + iterations + " iterations");
    60.     }
    61. }
    The results are, while checking SO instances for equality is six times more expensive when it comes to time, it is still neglectable. Even with 100k iterations, it takes only 12 milliseconds. My conclusion from this is, that using SO instances as keys, thus hashing them, is an appropriate approach.

    Problem: However, my problem is deciding on one solution. So far, I see three solutions:
    • Use an enumeration value as a key to retrieve a character value from a character instance
    • Use a SO instance as a key to retrieve a character value from a character instance
    • Use explicitely typed classes instead of hashing anything.
    The last point is actually one of the more interesting ones. I went a bit into detail with it in To use explicit typed classes or not to use explicit typed classes? without being able to draw a conclusion if it is the way to go.

    Which solution should I favor and why?

    Summary: A character type has certain character values, such as health, shield or energy, with each character instance of this character type having their own copies of these character values. My problem here is the nature of these character values. Many other objects, such as condition validators and value modifiers, should be able to interact with these character values, raising the need of being able to somehow tell such a modifier, for instance, that it should target the health of a character.

    This is the very problem that I struggle to find a solution for, as easy as it appears to be at first. If I use an enumeration, I risk bad maintainability and awkward scalability. I do not have experience using SO instances for this and it creates the need for dictionaries as well. While the third approach looks very appealing, especially when considering automated code generation, it creates a massive workload. If you introduce a new character value, you have to create a new object definiton for everything else as well, to allow it to interact with the new character value. If you introduce RageCharacterValue, you have to introduce RageValueValidator and RageMaximumValueModifier as well, if you want to enable this functionality for this new character value as well.

    What are your experiences with this topic, do you have a recommendation for which solution I should go or other things that I haven't considered?
     
  2. BPPHarv

    BPPHarv

    Joined:
    Jun 9, 2012
    Posts:
    318
    Write working code now.... Optimize later.
     
  3. Ardenian

    Ardenian

    Joined:
    Dec 7, 2016
    Posts:
    313
    In other cases, I would agree with you, however, in this particular case, this is not about optimization, but choosing the right design. This is essentially one of the core systems of my project and not something I want to touch later down the road when optimizing. Even if I wanted to go straight ahead, I wouldn't know which of my solutions I should choose.