Search Unity

  1. Unity 6 Preview is now available. To find out what's new, have a look at our Unity 6 Preview blog post.
    Dismiss Notice
  2. Unity is excited to announce that we will be collaborating with TheXPlace for a summer game jam from June 13 - June 19. Learn more.
    Dismiss Notice

ScriptableObjects and Coroutines

Discussion in 'General Discussion' started by michaelfelleisen, May 19, 2021.

Thread Status:
Not open for further replies.
  1. michaelfelleisen

    michaelfelleisen

    Joined:
    Nov 4, 2019
    Posts:
    6
    A coworker and I recently fell into the ScriptableObject rabbit hole and thought it would be awesome if you could start Coroutines on ScriptableObjects. At the moment this is not possible and I do not see the Unity team changing this in the near future.

    So, why do we even need this? In our case we wanted to have an authentication ScriptableObject which sends a webrequests to authenticate a user. There are a few workarounds to this but we did not like them a lot, but I will list them anyways.

    1. Have some GameObject with a script attached in the scene. Find the script instance from the Scriptable Object with FindObjectOfType<T>() or other ways to search your scene and trigger a public function on said script.
    2. Have a singleton GameObject in your scene to skip the scene searching step by accessing the static T Instance attribute and call the public function from there.
    3. Don't use ScriptableObjects at all, be forever stuck in MonoBehaviour/Singleton-land and lose your will to live after trying to merge multiple git branches with overlapping Scene/ Prefab/ Nested-Prefab changes and hard-linked MonoBehaviours
    So we came up with another option. The basic idea is that a ScriptableObject which wants to run a Coroutine spawns a GameObject in the Scene which will hold the Coroutine until it is finished and destroys itself afterwards.

    Inherit your custom ScriptableObject from SO_Base instead of ScriptableObject and use StartCoroutine the same way you would in a normal MonoBehaviour.

    Code (CSharp):
    1. using System.Collections;
    2. using UnityEngine;
    3.  
    4. public class SO_Custom : SO_Base
    5. {
    6.     public void SomeFunction(int _a, int _b)
    7.     {
    8.         StartCoroutine(SomeHeavyCalculations(_a, _b));
    9.     }
    10.  
    11.     private IEnumerator SomeHeavyCalculations(int _a, int _b)
    12.     {
    13.         yield return new WaitForSeconds(5);
    14.  
    15.         Debug.Log(_a + _b);
    16.     }
    17. }
    Code (CSharp):
    1. using System.Collections;
    2. using UnityEngine;
    3.  
    4. public class SO_Base : ScriptableObject
    5. {
    6.     protected void StartCoroutine(IEnumerator _task)
    7.     {
    8.         if (!Application.isPlaying)
    9.         {
    10.             Debug.LogError("Can not run coroutine outside of play mode.");
    11.             return;
    12.         }
    13.  
    14.         CoWorker coworker = new GameObject("CoWorker_" + _task.ToString()).AddComponent<CoWorker>();
    15.         coworker.Work(_task);
    16.     }
    17. }
    18.  
    Code (CSharp):
    1. using System.Collections;
    2. using UnityEngine;
    3.  
    4. public class CoWorker : MonoBehaviour
    5. {
    6.     public void Work(IEnumerator _coroutine)
    7.     {
    8.         StartCoroutine(WorkCoroutine(_coroutine));
    9.     }
    10.  
    11.     private IEnumerator WorkCoroutine(IEnumerator _coroutine)
    12.     {
    13.         yield return StartCoroutine(_coroutine);
    14.         Destroy(this.gameObject);
    15.     }
    16. }
    17.  
    What do you guys think?
     
  2. Murgilod

    Murgilod

    Joined:
    Nov 12, 2013
    Posts:
    10,255
    Honestly, you're dramatically overblowing the downsides of all of these things compared to your own solution. It's fine to use MonoBehaviours and even Singleton patterns if you're responsible with them, and in this context it's actually a little difficult to get irresponsible. This feels a bit like busting out a saw and sandpaper because you can't fit a square peg in a round hole instead of just putting it in the square hole.
     
  3. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    We did something similar for our in game tutorial

    Code (CSharp):
    1.     public abstract class TutorialStep : ScriptableObject
    2.     {
    3.         public abstract IEnumerator Execute();
    4.     }
    5.  
    6.     [CreateAssetMenu(menuName = "Tutorial/PlaceBombStep")]
    7.     public class PlaceBombStep : TutorialStep
    8.     {
    9.         public Transform PlacementPrefab;
    10.         public override IEnumerator Execute()
    11.         {
    12.             var bomb = Get<BombCase>();
    13.             var placeMentGameObject = Instantiate(PlacementPrefab);
    14.             var placement = placeMentGameObject.GetComponentInChildren<BombCasePlacement>();
    15.             ShowPopup(placement.transform, "Place the bomb inside the marker.");
    16.             while (bomb.State != BombCase.BombState.Placed)
    17.                 yield return null;
    18.             Destroy(placeMentGameObject.gameObject);
    19.         }
    20.     }
    21.  
    22.  
    Its up to the caller to execute it. Scripable assets are just serializable data containers. I see no wrong in letting a outer worker handle the execution of the Coroutine as it deem fit
     
  4. neginfinity

    neginfinity

    Joined:
    Jan 27, 2013
    Posts:
    13,623
    It is overcompliated.

    You can create a global singleton object that will spawn itself on the first request, and add a static "spawnCoroutine" method to it. Instead you spawn multiple ones and enforce inheritance from a specific base which is more hassle.
     
  5. michaelfelleisen

    michaelfelleisen

    Joined:
    Nov 4, 2019
    Posts:
    6
    To create a ScriptableObject you have to inherit from ScriptableObject anyways, so its not that much of an inconvenience to inherit from a custom ScriptableObject base class. I would even recommend it to be honest. A simple description string you can fill out in the editor to describe what your ScriptableObject does is pretty neat. Stole that idea from unity-atoms. So our ScriptableObject base class actually looks like this:

    Code (CSharp):
    1. using UnityEngine;
    2.  
    3. public class SO_Base : ScriptableObject
    4. {
    5.     [TextArea(5, 25), SerializeField]
    6.     private string description;
    7.  
    8.     protected void StartCoroutine(IEnumerator _task)
    9.     {
    10.         if (!Application.isPlaying)
    11.         {
    12.             Debug.LogError("Can not run coroutine outside of play mode.");
    13.             return;
    14.         }
    15.  
    16.         CoWorker coworker = new GameObject("CoWorker_" + _task.ToString()).AddComponent<CoWorker>();
    17.         coworker.Work(_task);
    18.     }
    19. }
    And we will probably add more stuff to it in the future. Another advantage would be, that you dont have to split up your logic into ScriptableObject and MonoBehaviour code snippets.
    If you are even using ScriptableObjects for that stuff. Something I really like is that your scene is very clean in the editor because your code does not have to exist on a GameObject in the scene permanently. But that's just me.

    The actual discussion should probably be: Is it a good idea to use ScriptableObjects for stuff like this.

    The only 'official' source from Unity I could find to not use ScriptableObjects only as data containers is this:

    He is doing something very similar here at [46:40]. This is where the basic idea for this came from.
     
    JonathanBartel likes this.
  6. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    In the video they are doing exactly how I do it. They execute the method that returns the enumerator and runs the Coroutine from a scene aware place.
     
  7. MadeFromPolygons

    MadeFromPolygons

    Joined:
    Oct 5, 2013
    Posts:
    3,997
    Yeah but you are not doing what they do in the video. Creating a new gameobject each time to handle coroutine execution is a bad way to approach this and creates unnecessary garbage. Empty gameobjects are not "free" performance wise btw, far from it. What if I fire off 10 of these, I now have 10 dead gameobjects floating around? And for what exactly?

    You should have an existing MB handle executing the coroutine, the SO should just contain a way to call it from outside but not handle its execution directly. This makes total sense because a SO is a scriptable serializable data container, it is absolutely not a "script". It exists at asset level, not scene level.
     
  8. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    Correction, a empty gameobject with managed wrapper is not free. A empty game object in scene is pretty much free. Unity loads its managed wrappers lazy so there for the footprint of empty scene game objects is negligible
     
  9. MadeFromPolygons

    MadeFromPolygons

    Joined:
    Oct 5, 2013
    Posts:
    3,997
    Its not entirely free, thats why entities in DOTS are so much lighter weight than GameObjects when comparing the two :) You will definately see some (small) difference if you spawn 10000 empty gameobjects vs none in a scene

    But yeah I know what you mean and are saying :) I also dont think the perf hit of gameobjects matters that much in this context, but I do think its important to know they are not actually "empty" underneath
     
  10. MadeFromPolygons

    MadeFromPolygons

    Joined:
    Oct 5, 2013
    Posts:
    3,997
    Although I know how deeply things are nested matters too, a flat hierarchy is super cheap vs deep nested one
     
    michaelfelleisen likes this.
  11. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    A world is built on thousands of gameobjects that are just containers for mesh renders, colliders etc. These only reside in native memory, the cost of these gameobjects are close to free. That's my point :)

    Edit: 1000 empty gameobjects is probably not measurable.
     
    MadeFromPolygons likes this.
  12. MadeFromPolygons

    MadeFromPolygons

    Joined:
    Oct 5, 2013
    Posts:
    3,997
    Yeah I missed the word "managed" in your original post :D I thought you were saying "they take up no memory anywhere!" and I was like wtf how? But this makes way more sense lol
     
    MDADigital likes this.
  13. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    :p
     
  14. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    Btw, the rumored switch from Mono to CoreClr (Net 6) will shift alot of this. It will probably make il2cpp obsolete for example. In my testas Net and more so Net 6 beats il2cpp and even handwritten cpp in many cases.
     
  15. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    I'm myself do scene aware stuff in none scene types sometimes. Here is an exexample

    Code (CSharp):
    1.     public class InstanceCountPool
    2.     {
    3.         private static InstanceCountPool instance;
    4.         private readonly Dictionary<string, InstanceCountPoolNamed> pools = new Dictionary<string, InstanceCountPoolNamed>();
    5.  
    6.         static InstanceCountPool()
    7.         {
    8.             SceneManager.sceneUnloaded += SceneUnloaded;
    9.         }
    10.  
    11.         private static void SceneUnloaded(Scene scene)
    12.         {
    13.               instance = null;          
    14.         }
    15.  
    16.         public static InstanceCountPoolNamed Get(string name, int maxCount)
    17.         {
    18.             if (instance == null)
    19.                 instance = new InstanceCountPool();
    20.  
    21.             if(!instance.pools.ContainsKey(name))
    22.                 instance.pools[name] = new InstanceCountPoolNamed(maxCount);
    23.  
    24.             return instance.pools[name];
    25.         }
    26.     }
    27.  
    28.  
    29.  
    30.     public class InstanceCountPoolNamed
    31.     {
    32.         private readonly int maxCount;
    33.         private readonly Queue<PrefabRel> active = new Queue<PrefabRel>();
    34.         private readonly Dictionary<Component, Queue<Component>> pool = new Dictionary<Component, Queue<Component>>();
    35.  
    36.         public InstanceCountPoolNamed(int maxCount)
    37.         {
    38.             this.maxCount = maxCount;
    39.         }
    40.  
    41.         public T Get<T>(T prefab) where T : Component
    42.         {
    43.             if (active.Count > maxCount)
    44.             {
    45.                 var stale = active.Dequeue();
    46.                 pool[stale.Prefab].Enqueue(stale.Instance);
    47.                 stale.Instance.gameObject.SetActive(false);
    48.             }
    49.  
    50.             if (!pool.ContainsKey(prefab))
    51.                 pool[prefab] = new Queue<Component>();
    52.  
    53.             var queue = pool[prefab];
    54.             T instance;
    55.             if (queue.Count == 0)
    56.                 instance = GameObject.Instantiate(prefab);
    57.             else
    58.             {
    59.                 instance = (T) queue.Dequeue();
    60.                 instance.gameObject.SetActive(true);
    61.             }
    62.  
    63.             active.Enqueue(new PrefabRel{ Prefab = prefab, Instance = instance});
    64.             return instance;
    65.         }
    66.  
    67.         private struct PrefabRel
    68.         {
    69.             public Component Prefab { get; set; }
    70.             public Component Instance { get; set; }
    71.         }
    72.     }
    Used like

    Code (CSharp):
    1.         public void EjectCasing() {
    2.             if (Bullet == null) return;
    3.  
    4.             var bulletPrefab = Bullet.Spent ? Bullet.Type.BulletEmptyPrefab : Bullet.Type.BulletPrefab;
    5.             var casing = InstanceCountPool
    6.                 .Get("Casings", 360) //12 players and 30 shots per mag ca
    7.                 .Get(bulletPrefab);
    8.  
    9.             casing.transform.position = CasingSpawnPoint.position;
    10.             casing.transform.rotation = CasingSpawnPoint.rotation;
    11.  
    12.             var body = casing.Body;
    13.  
    14.             casing.Init(IgnoredColliders);
    15.  
    16.             body.velocity = Velocity;
    17.             body.maxAngularVelocity = body.maxAngularVelocity * 10;
    18.             AddForceToCasing(body);
    19.  
    20.             Bullet = null;          
    21.         }
    Somehow this feels more ok :p
     
    michaelfelleisen likes this.
  16. michaelfelleisen

    michaelfelleisen

    Joined:
    Nov 4, 2019
    Posts:
    6
    While I do not necessarily agree with your second point here, your first point got me thinking. What if someone starts a lot of Coroutines every frame. That would create a lot of CoWorker GameObjects every frame, that are floating around in your scene until the Coroutine is finished. And after that the GC would have to clean up a lot of stuff.
    So we tried to enforce only one instance of our CoWorker class.

    Code (CSharp):
    1. using System.Collections;
    2. using UnityEngine;
    3.  
    4. public class CoWorker : MonoBehaviour
    5. {
    6.     private static CoWorker _instance;
    7.  
    8.     public static Coroutine Work(IEnumerator _task)
    9.     {
    10.         if (!Application.isPlaying)
    11.         {
    12.             Debug.LogError("Can not run coroutine outside of play mode.");
    13.             return null;
    14.         }
    15.  
    16.         if (!_instance) //edit old: if(_instance == null)
    17.         {
    18.             _instance = new GameObject("CoroutineWorker").AddComponent<CoWorker>();
    19.             DontDestroyOnLoad(_instance.gameObject); //edit2, probably a good idea
    20.         }
    21.  
    22.         Coroutine coroutine = _instance.StartCoroutine(_task);
    23.         return coroutine;
    24.     }
    25. }
    And our ScriptableObject base class now looks like this, but you really dont have to use it anymore, because all it does is call CoWorker.Work(). Which is pretty cool because the inheritance is not mandatory anymore but you can still use it if you want to use normal Unity syntax to start your Coroutines.

    Code (CSharp):
    1. using System.Collections;
    2. using UnityEngine;
    3.  
    4. public class SO_Base : ScriptableObject
    5. {
    6.     protected Coroutine StartCoroutine(IEnumerator _task)
    7.     {
    8.         return CoWorker.Work(_task);
    9.     }
    10. }
    Another thing we did not like about our old version was that our StartCoroutine() / CoWorker.Work() did not return a Coroutine. But not it does and you can do fancy stuff like waiting for a Coroutine inside of a Coroutine. So we fixed that and found another problem.

    I really wanted the CoWorker do destroy itself after it was not used anymore tho! Maybe after a second of no SO Coroutines or something..
    Unfortunately I could not get it to work. You might have done something like this inside of a Coroutine before:

    Code (CSharp):
    1. yield return StartCoroutine(A_Coroutine());
    In our case we would have to wait for the Coroutine from the CoWorker and the ScriptableObject.
    Which gets us to this cool new Unity message:
    Only one coroutine can wait for another coroutine

    :(


    I then tried to save a list of Coroutines running on the CoWorker to check them for null at certain intervals, but they never get nulled. I know you can save Coroutines in variables to check if there is already a Coroutine running, but this does not work with Coroutine lists. Really dont know why.
    After that I got some horrible ideas about writing my own Coroutines and that is when I decided it was time to drop the idea of a self destructing CoWorker.


    If you have some ideas on how it could be possible to destroy the CoWorker instance after all Coroutines are done please let me know. Otherwise I think my ScriptableObject / Coroutine journey ends here.
     
    Last edited: May 21, 2021
  17. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    Your code will break if you unload current scene and load a new one. That will kill the GO and next time you use it its destroyed

    Change if(instance == null) to if(!instance) it will fix that
     
  18. neginfinity

    neginfinity

    Joined:
    Jan 27, 2013
    Posts:
    13,623
    Just like I said it earlier.
    https://forum.unity.com/threads/scriptableobjects-and-coroutines.1112239/#post-7156648

    A coroutine is not a thread, and you can roll your own, if you really want to.

    All a coroutine does is returning IEnumerator and when it calls a subroutine, it yield returns another Enumerator. Unity seems to be storing this expression internally. They're a hack based on generator expressions.

    For example, following expression, should be able to "unroll" a coroutine instantly.
    Code (csharp):
    1.  
    2.     static void unrollCoroutine(IEnumerator enumerator){
    3.         if (enumerator == null)
    4.             return;
    5.         while(enumerator.MoveNext()){
    6.             var nested = enumerator.Current as IEnumerator;
    7.             if (nested != null)
    8.                 unrollCoroutine(nested);
    9.         }
    10.     }
    11.  
    12.  
     
  19. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    Thats dangerous though :D
    Potentielly hangup render thread.

    edit: well it will stack overflow in this case since its recursive.
     
  20. neginfinity

    neginfinity

    Joined:
    Jan 27, 2013
    Posts:
    13,623
    Nope, you didn't pay enough attention.

    It goes without saying that you should use common sense when trying to unroll an infinite loop. And should only do so when necessary.
     
  21. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    Take my code above

    Code (CSharp):
    1.             while (bomb.State != BombCase.BombState.Placed)
    2.                 yield return null;
    That code will hang thread.

    I dont see a point doing this at all to be honest.
     
  22. neginfinity

    neginfinity

    Joined:
    Jan 27, 2013
    Posts:
    13,623
    ...don't pass it into unrollCoroutine, maybe?
     
  23. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    Why would anyone ever want to "unroll" a Coroutine? Whats the use case?
     
  24. neginfinity

    neginfinity

    Joined:
    Jan 27, 2013
    Posts:
    13,623
    The code was written to demonstrate how coroutines WORK. Should be fairly obvious from the context, no?
     
  25. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    If a developer doesn't understand IEnumerators they need to learn C# first Unity later :p

    edit: Though I have seen alot of devs think Coroutines are not executing on the main render thrad which is a strange notion to me
     
  26. neginfinity

    neginfinity

    Joined:
    Jan 27, 2013
    Posts:
    13,623
    And if a flock of pink elephants fly over rainbow while singing hail mary that probably means something too.

    Please stop derailing the thread.
     
    angrypenguin likes this.
  27. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    Your initial response could be interpreted as Your solution is over complicated, so roll your own Coroutine handler instead :p
     
  28. MDADigital

    MDADigital

    Joined:
    Apr 18, 2020
    Posts:
    2,198
    Anyway to get back, yeah, coroutines are just enumerators. We have used this in our game.

    Code (CSharp):
    1.         protected IEnumerator Execute(params Func<IEnumerator>[] routines)
    2.         {
    3.             var sequence = new LinkedList<Func<IEnumerator>>(routines);
    4.             var current = sequence.First;
    5.  
    6.             do
    7.             {
    8.                 var routine = current.Value();
    9.                 var handPicked = false;
    10.                 do
    11.                 {
    12.                     if (routine.Current is SequenceState state)
    13.                     {
    14.                         handPicked = true;
    15.  
    16.                         switch (state)
    17.                         {
    18.                             case SequenceState.Start:
    19.                                 current = sequence.First;
    20.                                 break;
    21.                             case SequenceState.Previous:
    22.                                 current = current.Previous ?? current;
    23.                                 break;
    24.                         }
    25.  
    26.                         break;
    27.                     }
    28.  
    29.                     yield return routine.Current;
    30.                 } while (routine.MoveNext());
    31.  
    32.                 if (!handPicked) current = current.Next;
    33.             } while (current != null);
    34.         }
    Usage

    Code (CSharp):
    1.     [CreateAssetMenu(menuName = "Tutorial/AttachAttachmentStep")]
    2.     public class AttachAttachmentStep : InteractStep
    3.     {
    4.         public RailSystemAttachment Prefab;
    5.         public int RailIndex;
    6.  
    7.         private RailSystemAttachment attachment;
    8.         private RailSystem.RailSystem railSystem;
    9.  
    10.  
    11.         public override IEnumerator Execute()
    12.         {
    13.             var firearm = Get<Firearm>();
    14.  
    15.             topItem = GetAll().Select(r => r.GetComponent<SimpleNetworkedMonoBehavior>()).First(r => r.Prefab.name == Prefab.name).GetComponent<NVRInteractable>();
    16.             railSystem = firearm.GetComponent<RailSystemContainer>().RailSystems[RailIndex];
    17.             attachment = topItem.GetComponent<RailSystemAttachment>();
    18.  
    19.             yield return Execute(WaitForAttachmentGrab, WaitForAttachmentHoveringOverRail, WaitForPlacementOnRail);
    20.         }
    21.  
    22.         private IEnumerator WaitForAttachmentGrab()
    23.         {
    24.             if(attachment.AttachedSystem == null)
    25.             {
    26.                 ItemType = "Attachment";
    27.                 title = "Attach attachment";
    28.  
    29.                 yield return base.Execute();
    30.             }
    31.         }
    32.  
    33.         private IEnumerator WaitForAttachmentHoveringOverRail()
    34.         {
    35.             ShowPopup(railSystem.transform, "Hover over the rail with the attachment.");
    36.  
    37.             while (attachment.AttachedSystem != railSystem)
    38.             {
    39.                 if (attachment.IsCompletelyAttached)
    40.                 {
    41.                     yield return Execute<DetachAttachmentStep>(step => { step.Attachment = attachment; step.CorrectAttachmentPlacement = true; });
    42.                     yield return SequenceState.RestartCurrent;
    43.                 }
    44.                 else
    45.                     yield return null;
    46.             }
    47.         }
    48.  
    49.         private IEnumerator WaitForPlacementOnRail()
    50.         {
    51.             ShowPopup(railSystem.transform, "Slide the attachment over the rail until you find a good position and let go.");
    52.  
    53.             while (!attachment.IsCompletelyAttached)
    54.             {
    55.                 if (attachment.AttachedSystem != railSystem)
    56.                     yield return SequenceState.Previous;
    57.                 else
    58.                     yield return null;
    59.             }
    60.         }
    61.     }
     
  29. angrypenguin

    angrypenguin

    Joined:
    Dec 29, 2011
    Posts:
    15,633
    Why? This makes more work, both to destroy it and to recreate it when it's needed again. Not to mention the time taken to write and test that code. What benefit does it gain you which is worth it?
     
Thread Status:
Not open for further replies.