Linq performance

In this course function GetStat() called every Update().
But in case we call it rarely, will Linq be better approach?

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

namespace RPG.Stats
{
   [CreateAssetMenu(fileName = "Progression", menuName = "Stats/New Progression")]
   public class Progression : ScriptableObject
   {
      [SerializeField] private ProgressionCharacterClass[] characterClasses;

      Dictionary<CharacterClass, Dictionary<Stat,float[]>> lookupTable;
      
      public float GetStat(Stat stat,CharacterClass characterClass, int level)
      {
#region  Course Approach
         BuildLookup();

         float[] levels = lookupTable[characterClass][stat];

         if(levels.Length < level) return 0;

         return levels[level - 1];
#endregion


#region  linq

         List<ProgressionCharacterClass> progressionCharacterClasses = characterClasses.ToList();

         var statValue = from progressionCharacterClass in progressionCharacterClasses
                           where progressionCharacterClass.characterClass == characterClass
                           select progressionCharacterClass.stats into progressionStats
                         from progressionStat in progressionStats
                           where progressionStat.stat == stat
                           where progressionStat.levels.Length >= level
                           select progressionStat.levels[level - 1];
         
      

         return statValue.First();

#endregion
      }

      private void BuildLookup()
      {
         if(lookupTable != null) return;

         lookupTable = new Dictionary<CharacterClass, Dictionary<Stat, float[]>>();

         foreach(ProgressionCharacterClass progressionClass in characterClasses)
         {
            Dictionary<Stat, float[]> statLookupTable = new Dictionary<Stat, float[]>();

            foreach(ProgressionStat progressionStat in progressionClass.stats)
            {
               statLookupTable.Add(progressionStat.stat, progressionStat.levels);
            }

            lookupTable.Add(progressionClass.characterClass, statLookupTable);
         }
      }

      [Serializable]
      class ProgressionCharacterClass
      {
         public CharacterClass characterClass;
         public ProgressionStat[] stats;
      }
      [Serializable]
      public class ProgressionStat
      {
         public Stat stat;
         public float[] levels;
      }
   }
}

First of all, thatā€™s a beautiful SQL statement!

The problem is that Linq creates a chunk of garbage to be collected for each entry in the statement. Due to itā€™s overhead, itā€™s also technically a bit slower than simple for loops. For example, we could write

        public float GetStatTheHardWay(Stat stat, CharacterClass characterClass, int level)
        {
            foreach (ProgressionCharacterClass progressionCharacterClass in characterClasses)
            {
                if (progressionCharacterClass.characterClass == characterClass)
                {
                    foreach (ProgressionStat progressionStat in progressionCharacterClass.stats)
                    {
                        if (progressionStat.stat == stat)
                        {
                            level = Mathf.Min(progressionStat.levels.Length, level); //if exceeds levels, will return last
                            if (level > 0) return progressionStat.levels[level];
                        }
                    }
                }
            }
            return 0;
        }

and while not as pretty, it would run slightly faster than the Linq statement (the lower the element index for class and stat, the faster it will actually run, but not by much).
The big difference is that the GetStatTheHardWay wonā€™t generate any garbage to collect.

Hereā€™s a variant that uses Linq, but should generate about 1/2 the garbage (I still wouldnā€™t use this in an Update loop).

        public float GetStatWithSomeLinq(Stat stat, CharacterClass characterClass, int level)
        {
            var progressionCharacterClass = characterClasses.FirstOrDefault(c => c.characterClass == characterClass);
            if (progressionCharacterClass == null) return 0;
            var progressionStat = progressionCharacterClass.stats.FirstOrDefault(s => s.stat == stat);
            if (progressionStat == null) return 0;

            level = Math.Min(progressionStat.levels.Length, level);
            return level > 0 ? progressionStat.levels[level] : 0;
        }

This is the real problem. We need to eliminate the bottlenecks where GetStat() is called every update.

While I wonā€™t pretend to have the entire course codebase memorized, the places that most come to mind are in the display we have to show current Health, experience, etc, and in the health bars over the characters heads.

So in Health, I added a new event

public event System.Action<float, float> OnHealthChanged;

and a new method:

        private void CallOnHealthChanged()
        {
            OnHealthChanged?.Invoke(healthPoints.value, GetMaxHealthPoints());
        }

Now in every method where healthPoints.value changes, I add a call to CallOnHealthChanged(). Itā€™s also in Start() after healthPoints.ForceInit(); (technically, healthPoints.ForceInit() is not needed because the call to CallOnHealthChanged will force an Init if needed.

Finally, HealthDisplay and HealthBar.cs are rewritten to accomodate an Event instead of an Update

using UnityEngine;
using UnityEngine.UI;

namespace RPG.Attributes
{
    public class HealthDisplay : MonoBehaviour
    {
        private Health health;
        private Text text;

        private void Awake()
        {
            text = GetComponent<Text>();
            health = GameObject.FindWithTag("Player").GetComponent<Health>();
            health.OnHealthChanged += UpdateDisplay;
        }
        
        private void UpdateDisplay(float healthPoints, float maxHealthPoints)
        {
            text.text = $"{healthPoints:F0}/{maxHealthPoints:F0}";
        }
    }
}
using UnityEngine;
using UnityEngine.UI;

namespace RPG.Attributes
{
    public class HealthDisplay : MonoBehaviour
    {
        private Health health;
        private Text text;

        private void Awake()
        {
            text = GetComponent<Text>();
            health = GameObject.FindWithTag("Player").GetComponent<Health>();
            health.OnHealthChanged += UpdateDisplay;
        }
        
        private void UpdateDisplay(float healthPoints, float maxHealthPoints)
        {
            text.text = $"{healthPoints:F0}/{maxHealthPoints:F0}";
        }
    }
}

To add to the complexity, MaxHealth could change at any time, if, for example, a change in Equipment has a stat boost to max health, or a Trait (shops and Abilities) increases the health stat.

What might work for this is an interface that can be put on any class that might affect stats
The best place for this is actually in GameDevTV.Utils, since we need Equipment

namespace GameDevTV.Utils
{
    public interface IAffectStatsEventRaiser
    {
        event System.Action AffectStatsEvent;
    }
}

Now in each component where a change could affect the outcome of stats, implement this interface. For a completed project, this is

  • Equipment.cs (not StatsEquipment, because the actual changes to equipment are in equipment, the StatsEquipment just enumerates the changes).
  • TraitStore.cs (Stats and Abilities, it needs updating when committing stats and when restoring)

But you may have other components you wish to add in the future that can affect stats (say a Buff Store that gives temporary stat boots).

Then in Health.cs, or any other class that depends on stats that could change at any time, say Mana.cs, you would subscribe to these IAffectStatsEventRaiser with a method that updates any cached variables and raises itā€™s own change event for the UI.

3 Likes

I believe that choosing to use ā€œeventsā€ is a more advantageous option compared to using the ā€œUpdateā€ function. I concur with your perspective.

Organizing events through an interface is an intelligent strategy. However, I cannot claim credit for the idea.

I appreciate you mentioning it. I put a lot of work and dedication into this inquiry.
Discovering that Linq may appear visually appealing but is actually slower can be quite frustrating. Sad timesā€¦

I am grateful for the detailed feedback you have provided. Your commitment and attention to detail in your reply are truly admirable. Your responses are always greatly appreciated and bring joy to my day.

Donā€™t get me wrong about Linq. I use Linq all the time. When paired with event driven programming techniques, thereā€™s nothing wrong with Linq at all. Itā€™s generally more readable for those of us with a background in SQL and databases in general.

My problem with it here is that itā€™s a dramatic step down in performance compared to the pre-cached Dictionary.

I have two other techniques to slash the time that Progression takes to deal with statsā€¦ The first is to wipe the CharacterClass off the map. Itā€™s unneeded. Your Progression becomes the CharacterClass. Even Sam agree with me on this the last time we spoke about the Turducken we created in the lookup.

Now with your progression as your Character Class, you have a ScriptableObject for each class, meaning it only has to contain the Stats and the Levels. Thatā€™s optimization #1.

Optimization #2 comes when itā€™s time to deal with the Levels. I think we all hate trying to type in all those values from level to level. You might have even noticed that when Rick showed us a spreadsheet of the values, they almost always followed a formulaā€¦

So my second optimization is that my Stats within the CharacterClass Scriptable Object contain just a Stat and the parameters of a formula. Iā€™ve actually experimented a lot with this, and have gone with various formulas like
result = (base + level * add) * (level * percent); //This one ramps up DRAMATICALLY if your percent is too high!
result = level1 + ((level100 - level1)/100) * level; //Two parameters are Level 1 value and Level 100 value, and it scales to level whatever!
Iā€™ve even tried using an AnimationCurve, and using the curve to evaluate and get the value. Sounds good on paper, but hard for users to edit.

My favorite tends to be the one in the middle

[System.Serializable]
public class StatFormula
{
    [SerializeField] float level1 = 10;
    [SerializeField] float level100 = 500;
    
    float deltaPerLevel;

    void Awake()
    {
         deltaPerLevel = (level100 - level1);
    }

    public float Evaluate(int level)
    {
        level--;
        if(level<0) level = 0;
        return level1 + deltaPerLevel * level;
     }
     public int EvaluateAsInt(int level) => Mathf.RoundToInt(Evaluate(level));
}

This is faster than even our Dictionary grabbing levels and evaluating, it never runs out (well, I suppose if the ramping from 1 to 100 was high enough it would crash at around 3.4x10^38, but most games would put a cap before that anyways.

2 Likes

Beautiful solution.

Wondered why Rick and Sam didnā€™t come up with that. Maybe they showed a more complex way for educational purposes.

So, when a function may be used in Update(), a better option would be to cache Dictionary, and when we use it with events - Linq will be fine. All right? (Sorry for the many questions :sweat_smile:)

I would say that sums it up nicely, yes.

1 Like

Seems like there you missed ā€œ/ 100ā€ at the end of the deltaPerLevel = (level100 - level1); part.

I changed it like this:

private const int LEVEL_AMOUNT = 100;
deltaPerLevel = (level100 - level1) / LEVEL_AMOUNT;

Oops, youā€™re right, I did, but change LEVEL_AMOUNT to 99 if you expect the function to yield the value of Level100 at level 100. :slight_smile:

1 Like

Thanks)

I seem to be having an issue where the Awake function isnā€™t triggering, causing deltaPerLevel to always be 0. It took me some time to realize this. I attempted to move the StatFormula class to a different file, but it didnā€™t solve the problem.
Is there a solution to avoid constructing something like this:

private void CalculateDelta()
{
    if(isCalculated) return;
    deltaPerLevel = (level100 - level1) / 100;
}

and make Awake work?

Hereā€™s a better solution:

[HideInInspector][SerializeField] float deltaPerLevel;

void OnValidate()
{
    deltaPerLevel = (level100 - level1) / 99f;
}

Maybe I miss something. That solution not working for me eitherā€¦
I have a UnityToolbox extension. And it does recognize this Awake or OnValidate as Unity methods.

Should be like this

Itā€™s possible that the issue is caused by ScriptableObject.

ScriptableObjects are supposed to fire their Awake() and OnEnable() methods when they are first accessed.
OnValidate() is called when when the Inspector is open.
Itā€™s likely that the writer of the UnityToolbox extension is not aware of this.
You can test this by putting some Debugs in Awake(), OnEnable() and OnValidate().
In the inspector, OnValidate() should fire every time you change something in the inspector of that SO.
When you play the game Awake() and OnEnable() should fire.

Note: If youā€™re using Unity 2022.3.3 or 3.4, who knowsā€¦ it may be a bug (Donā€™t use these versions, even Unity is calling them a dumpster fire on their discord).

Im using 2021.3.13f1.
I donā€™t know how, but the editor decided to not invoke awake and other Unity builtin functions from this file. Also, I have this error in vscode, when trying to use print():
The name 'print' does not exist in the current context [Assembly-CSharp]csharp(CS0103)
Something weird.

UPD:
In other files print and other stuff working fine

Hmmmā€¦ not sure why the Awake() and OnEnable() arenā€™t firing. Thatā€™s pretty significant.

The print not showing upā€¦ that is weirdā€¦ What about Debug.Log? Is your Studio Code package up to date in the Package Manager? This stuff is so much easier in Rider, but alas, it isnā€™t free.

Debug.Log() behaves normally.
And ya, I forgot to update packages. I updated it but the error is still there.

In addition to updating the package, go into Preferences/External Tools/Editor and there should be a button to refresh the solution. (Close VS Code first).

Even with all that said, just know that Visual Studio Code is a moving target, and the tricks we figure out to get it to work today donā€™t always work tomorrow. The joys of a fully open source project.

1 Like

I have a ā€œregenerate project filesā€ button. But when I tap on it nothing happens. Should I select something upward?

It wonā€™t always look like it did anything.
You can ā€œforceā€ the issue by clicking on one of the options, like Registry packages, and Regenerating, then uncheck it and regenerate it again.

I added all, then vice versa, but ā€œThe name ā€˜printā€™ does not exist in the current contextā€))

Thank you for trying to help. Iā€™ll search for a solution until I find it (hoping it will be less than a week), and share with you results.

Iā€™m dumb. Youā€™re not going to make print work in a ScriptableObject. print is a convenience method exclusive to MonoBehaviours.
This is within the MonoBehaviour.cs, and the only reason print works at all:

public static void print(object message) => Debug.Log(message);

If youā€™re absolutely hide bound to using print, there is a workaround, but it wonā€™t get you a simple print statement:
Create a new class ScriptableObjectHelpers. Replace the entire class created with this:

using UnityEngine;

namespace GameDevTV.Inventories
{
    public static class ScriptableObjectHelper
    {
        public static void print(this ScriptableObject so, object message)
        {
            Debug.Log(message);
        }
    }
}

Now, within a ScriptableObject, you can use

this.print("My Debug");

print by itself will not work because we canā€™t edit ScriptableObjectā€™s code directly, we have to use extension methods instead.
Personally, I prefer just using Debug.Log().

Privacy & Terms