World, system groups, update order, and the player loop

If you have started using ECS, you might have heard of the "default world". You may notice that your defined systems are somehow updating magically. In this article, we will learn about that in reversed order.

World, system groups, update order, and the player loop

If you have started using ECS already, you might have heard of the "default world". You may notice that your defined systems in C# are somehow updating magically after you pressed play mode button.

In this article, we will learn about that in reversed order. I suggest you forget about everything for a moment. We will start without magic and when I lead you up to the "default world" the explanation will be shorter.

World

  • Get one EntityManager class object. You can imagine EntityManager has a powerful database which is the core power of ECS.
  • Could contains systems. (ComponentSystem, JobComponentSystem, EntityCommandBufferSystem, ComponentSystemGroup) Systems contains logic that do work on Entity because it knows EntityManager from its world. Creating a system is gate keeped by world.GetOrCreateSystem<T>, it is this point that the World inject itself to the system.
  • Your system code has protected properties World and EntityManager to refer back to its owning world.

Problems

  • Systems usually has work logic on their OnUpdate override. This could be triggered with system.Update(), but it is inconvenient to use world.GetOrCreateSystem<T> then call .Update() sequentially. Also, sequentially in what order?
  • World has no method that could Update on all their systems. (!)

ComponentSystemGroup

The solution to "mass update with ordering" of World. ComponentSystemGroup is also considered a "system" and so also contains .Update() to call, and OnUpdate to override, and able to be added to a World.

However it could contains any amount of systems (including an another ComponentSystemGroup) and it could do what World couldn't : .Update() it and it updates all of their systems in order. Then all we have to do is to run these ComponentSystemGroup manually no matter how many systems we have!

To add systems to the group, just call .AddSystemToUpdateList. When you call .Update() to the group to recursive update, it is ordered like how you add them to the group.

Sorting

With systemGroup.SortSystemUpdateList(), every systems in the group will be sorted by :

  • According to its [UpdateBefore/After(typeof(MySystem))] attribute. If the system type coming into this attribute couldn't be found in the same group by the time you are sorting, it has no effect, and it logs you a warning.
  • If no [UpdateBefore/After], it will be wherever it wants but still ensuring those with [UpdateBefore/After] are still on its correct "specs".
  • If the group contains an another group, it will be sorted recursively.
  • It could detect circular dependency on your [UpdateBefore/After] and logs error.

Custom update

If you are not content how the group just iterates all systems and update each one (that's good enough for me), it is possible to override OnUpdate ( .Update() calls it) of ComponentSystemGroup to do all kinds of work like a regular system.

But unlike regular systems you shouldn't override it in most case, just let it handle the sequential update work and let the real work in your systems in the group. Unless you got some exotic plans that updates in strange order. I had seen someone on the forum said he wanted to update some system again if some later system did something, I imagine this would be the right place to do.

Custom sort

It is also possible to override SortSystemUpdateList to make a custom sort. Maybe you could call base.SortSystemUpdateList for [UpdateBefore/After] sorting first then do your own business afterwards.

To modify the sorted systems or even do your own sorting from the start, manipulates the List<ComponentSystemBase> m_systemsToUpdate which is a protected list. I imagine Linq would be quite useful here.

How to use [UpdateBefore/After]

Many had expressed they want a concrete ordering for their system update, which they could by manually adding systems to the group and just don't sort it. Or make your own [ConcreteOrdering(x)] and just implement a custom sort. (good luck!)

However [UpdateBefore/After]'s power is actually that they do not say any concrete ordering but just sort so that it ended up like what you said. How to think about [UpdateBefore/After] is "What is the minimum requirement to make this system work properly? I want that correct, I don't care about anything else."

  • In a way, you will have an ordering that "just works" even if you yourself don't know where they ended up. It just works. (If you get your own [UpdateBefore/After] wrong it's your fault!) You can check the resulting ordering in the entity debugger.
  • It is future proof. When you add more systems, your new system maybe added somewhere, but existing systems with old update order will still "just works".
  • If no ordering at all, it means the system could work independently of anything else, you are opening up flexibility for the sorting.
  • It is not necessary that this ordering is the most optimal performance wise. For example, you may want some jobs from your unrelated system to be kicked off earlier or later, and because they are unrelated it is kinda not appropriate to put [UpdateBefore/After] to express relationship.
  • I suggest just use ordering and [UpdateBefore/After] for the correctness rather than performance, though. Tune your performance on your update condition and in your Bursted job code instead.

UnityEngine.Experimental.PlayerLoop

Imagine you don't know anything about ECS yet and read up to this point. You are now ready to rock the ECS database by creating a World, create some ComponentSystemGroup, add some systems (ComponentSystem, JobComponentSystem, EntityCommandBufferSystem, or even nested ComponentSystemGroup) into groups, sort them, then finally call .Update() on all top level groups you have. And you are now doing work on ECS database of that World's EntityManager.

But wouldn't it be much more convenient if all those group's .Update() got continuously called by an "invisible hand" in the same fashion as MonoBehaviour's Update()?

You could with this using UnityEngine.Experimental.PlayerLoop; namespace. By first get the static PlayerLoop.GetDefaultPlayerLoop();, you can pry open the classic Unity "invisible hand" (subsystems) consist of various phases like Update, FixedUpdate, PreLateUpdate, Initialization, etc.

These subsystem then in turn contains multiple subsystems, each containing something called updateDelegate. Assign it any method that returns void, it would invoke that in an appropriate phase. (From closed source, black box C++ land.)

Then, you could give the .Update() of the top most ComponentSystemGroup as a new subsystem's updateDelegate added to appropriate phase. Now you don't have to update by yourself. But you could see this is a pain to both select the correct phase from player loop, then "insert" a new update equal to an amount of your top level ComponentSystemGroup.

ScriptBehaviourUpdateOrder.UpdatePlayerLoop(world)

Luckily Unity prepared something like this. By just telling this static method your World (not the system group, throw in an entire world), it would add that World's top level groups to the player loop for you!

Where in the player loop? This add is opinionated and it only looks for specific groups inside the world to add to a specific phase in the player loop. Let's find out how to make a World that is "compatible" with this method.

(Use ScriptBehaviourUpdateOrder.UpdatePlayerLoop(null) to reset the player loop to the default classic state.)

Top-level 3 special component system groups

Unity defined 3 special ComponentSystemGroup. They are :

  • InitializationSystemGroup
  • SimulationSystemGroup
  • PresentationSystemGroup

These groups are special!

  • ScriptBehaviourUpdateOrder.UpdatePlayerLoop(world); looks for only these 3 groups. After the method call, your player loop will do .Update() on these 3 groups in addition to the old MonoBehaviour's Update(). Your things (including nested system group) inside these groups then get updated recursively.
  • The correct player loop phase will be chosen for each groups, which they said might be changing in between versions. I suggest you shouldn't care about that and try to go by the literal meaning of "initialization", "simulation", and "presentation".
    If you started fearing where one system in SimulationSystemGroup and one another in PresentationSystemGroup may ended up relative to each other, it is likely a symptom that you got your concept wrong, since presentation shouldn't be related to simulation in that "strict" way. The meaning of "presentation" should be "present the state after ALL simulation systems finished updating".
  • They are also created with several useful EntityCommandBufferSystem (barriers) that will not budge from its proper place in the group no matter how many times you do SortSystemUpdateList. This is because they came with a custom sort logic I have already talked about.
* InitializationSystemGroup
    * BeginInitializationEntityCommandBufferSystem
    - YOUR SORTABLE SYSTEMS -
    - YOUR SORTABLE SYSTEMS -
    - YOUR SORTABLE SYSTEMS -
    * EndInitializationEntityCommandBufferSystem
* SimulationSystemGroup
    * BeginSimulationEntityCommandBufferSystem
    - YOUR SORTABLE SYSTEMS -
    - YOUR SORTABLE SYSTEMS -
    - YOUR SORTABLE SYSTEMS -
    * LateSimulationSystemGroup
    * EndSimulationEntityCommandBufferSystem
* PresentationSystemGroup
    * BeginPresentationEntityCommandBufferSystem
    - YOUR SORTABLE SYSTEMS -
    - YOUR SORTABLE SYSTEMS -
    - YOUR SORTABLE SYSTEMS -
    * EndPresentationEntityCommandBufferSystem

(You see there is one more nested group LateSimulationSystemGroup for you to use as well. For those "2-pass" systems. This sub-group is also on a fixed position unaffected by sorting, after all your systems in simulation, and just before the ECBS at the end.)

If you want to use ScriptBehaviourUpdateOrder.UpdatePlayerLoop(world); rather than hack in your group's .Update() into the player loop (PITA), it is a given that you have to ensure the world has these 3 groups or nothing would be inserted to the player loop.

Default world

By now you may could already guess what is the default world. It is a World that :

  • Created automatically on entering play mode with [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]. This attribute existed on Unity from long time ago.
  • Added 3 groups : InitializationSystemGroup, SimulationSystemGroup, PresentationSystemGroup.
  • Those three groups aren't empty. All your assemblies are scanned and all system found will be added to SimulationSystemGroup, unless you got [UpdateInGroup] to explicitly say something else. This is quite costly by the way because it involves reflection to check for type that is a system.
  • After all that, all 3 groups are sorted. The sort is recursive if you have your own group to put in the top-level 3 groups.
  • Then it will do ScriptBehaviourUpdateOrder.UpdatePlayerLoop(world).
  • This World became your World.Active.

The first step could be prevented by UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP define. But you can still manually trigger the first step (and everything else) by calling DefaultWorldInitialization.Initialize(string worldName, bool editorWorld) static method.

[UpdateInGroup]

This attribute is useless if you are not using a default world. But if you do, it affects the "assembly scan" step :

  • No [UpdateInGroup] : It is now in SimulationSystemGroup.
  • With [UpdateInGroup] as one of the top-level trio : It is now in the specified group.
  • With [UpdateInGroup] as one of your self-defined group : The group is created to the world first if not exist yet, (put into a group yet again according to the same rule, yes your own group must somehow be in 1 of the 3 top-level groups.) then add the system in that self-defined group.

[UpdateInGroup] is valid on both regular systems and ComponentSystemGroup. It is quite common that you won't be pouring all your systems into SimulationSystemGroup but in your own group, that in turn has an [UpdateInGroup] to be in one of the top 3 base groups. (Or in yet another your own ComponentSystemGroup.

Now you know why the "magic" happen on vanilla ECS! To help learning these things, you may try handicap yourself with :

  • UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP then call DefaultWorldInitialization.Initialize on your own
  • Or even avoid DefaultWorldInitialization.Initialize and setup the world manually that leads to calling a valid ScriptBehaviourUpdateOrder.UpdatePlayerLoop(world).
  • You are not recommended to go one step further and modify the player loop... it's very annoying. Just live with the 3 top level systems that make the ScriptBehaviourUpdateOrder.UpdatePlayerLoop(world) work.
  • However one case maybe for example, you want to get your systems to run on FixedUpdate. In this case you have to do it because non of 3 top level groups right now is in that phase.

What would happen if you defy the default world

That is, you just new World on your own and add groups/systems...

  • You don't even get the trio elementary system groups : InitializationSystemGroup SimulationSystemGroup PresentationSystemGroup in the world. Your world is absolutely empty.
  • You must dig up a group or some systems to .Update() with world.GetOrCreateSystem<T>().
  • Using ScriptBehaviourUpdateOrder.UpdatePlayerLoop(world); on your manual world does nothing because it only cares to get the Update() delegate of the 3 top groups.
  • Even if you GetOrCreateSystem those groups into the World on your own, they are empty. You will now have to add systems into each group, which is a hassle since [UpdateInGroup] you put on now has no effect.
  • If you do add back those 3 top-level groups, you can still rely on them to have the EntityCommandBufferSystem at the correct place. They are created as a part of group's definition.
  • You now have to do SortSystemUpdateList manually on the top 3 groups (if you decided to add them back), which make your [UpdateBefore/After] take effect.
  • Your own EntityCommandBufferSystem (if defined) do not have the same "fixed place" property like those that came with 3 base groups. You must place [UpdateBefore/After] like usual to position them or they will be wherever.
  • You will miss Unity's own systems, such as ALL the transform/rendering systems and systems that make "sub scene" work, etc.

Default world initialization cost

This is happening on default world initialization :

  1. ALL assemblies are reflected and iterated for class that is a valid ECS system. You cannot stop this if you want to use the defaut world.
  2. You get a chance to modify these to-be-added systems in your own ICustomBootstrap declared anywhere. Return a List<Type> with less or more elements as you like.
  3. The remaining systems will be iterated through and added to a proper group.

So we would like to monitor the cost of starting up the default world by varying

  1. An amount of declared systems in an assembly. This could not be tested without modifying the source code between each test.
  2. An amount of system left after filter out some of them in ICustomBootstrap. You can imagine even if I filter to 0 system, you will still pay the reflection cost just because they are defined.

This is how I did it to vary declared systems... code generation.

seq  1 100 | sed -e 's/^/public class System/' -e 's/$/ : TestSystem { }/' | pbcopy

All TestSystem will update if it found entities with TC component.

public class TestSystem : ComponentSystem { 
        protected override void OnCreate() { GetEntityQuery(ComponentType.ReadOnly<TC>()); } 
        protected override void OnUpdate() { } 
}

public struct TC : IComponentData { }

In a test, even in play mode test where the default world may already got initialized, we can get an another default world by DefaultWorldInitialization.Initialize command. It will be set as World.Active too. But anyways I will put on UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP so that our DefaultWorldInitialization.Initialize in the test is as "pure" as possible.

[Test]
[Repeat(100)]
public void AmountOfSystemsInitializationTest([Values(10, 50, 100, 200, 300, 400, 500, 1000)] int systemAmount)
{
    NewTestScript.systemAmount = systemAmount;
    System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
    sw.Start();
    DefaultWorldInitialization.Initialize("Test World", editorWorld: false);
    sw.Stop();

    Debug.Log($"Initialization, {systemAmount}, {sw.ElapsedTicks}");
    ScriptBehaviourUpdateOrder.SetPlayerLoop(PlayerLoop.GetDefaultPlayerLoop());
    World.Active.Dispose();
}

The NewTestScript.systemAmount static assingment affects the filtering at ICustomBootstrap right here :

public static int systemAmount;
public struct TestBoot : ICustomBootstrap
{
    public List<Type> Initialize(List<Type> systems)
    {
        var filtered = new List<Type>(systemAmount);
        foreach(var system in systems)
        {
            if (typeof(TestSystem).IsAssignableFrom(system))
            {
                filtered.Add(system);
            }
            if(filtered.Count >= systemAmount)
            {
                break;
            }
        }
        if(filtered.Count != systemAmount)
        {
            if(World.Active != null)
            {
                World.Active.Dispose();
                World.Active = null;
            }
            Assert.Pass("This test is invalid as we defined less classes than required by the test.");
        }
        return filtered;
    }
}

The test is running on Xiaomi Mi A2 Android phone. The test is repeated 100 times and averages the ticks. (Outliers removed)

Defined systems Remaining systems Total initialization cost (ticks) Total initialization cost (s)
10 10 493823.59 4.94
50 10 528296.93 5.28
50 50 621304.41 6.21
100 10 564478.78 5.64
100 50 645459.38 6.45
100 100 747794.86 7.48
200 10 648822.97 6.49
200 50 732537.24 7.33
200 100 844753.73 8.45
200 200 1091579.17 10.92
300 10 740930.61 7.41
300 50 829417.68 8.29
300 100 945809.27 9.46
300 200 1145776.95 11.46
300 300 1342335.25 13.42
400 10 824768.29 8.25
400 50 921969.26 9.22
400 100 1048810.23 10.49
400 200 1245280.25 12.45
400 300 1422800.08 14.23
400 400 1635431.64 16.35
500 10 908057.04 9.08
500 50 977986.83 9.78
500 100 1089456.62 10.89
500 200 1328351.97 13.28
500 300 1574790.02 15.75
500 400 1848887.58 18.49
500 500 2130857.5 21.31
1000 10 1342344.59 13.42
1000 50 1457683.14 14.58
1000 100 1592836.44 15.93
1000 200 1847939.04 18.48
1000 300 2111582.52 21.12
1000 400 2145663.17 21.46
1000 500 2392359.81 23.92
1000 1000 3573001.95 35.73

Evaluation

I think most game will ended up with 4~7 seconds startup time to get all the systems ready. (currently, preview.30) Should not be that serious considering you would do this only on start up.

But for Unity "app" if that is possible to build in the future, maybe I desire more instant start time especially that 4 seconds on low number of systems.. and this 4s is a big blocker if you want to unit test the entire default world.

On the row with 10 remaining systems you could estimate the assembly reflection cost without grouping/sorting cost.

*All generated systems has no [UpdateBefore/After], this may make the sorting cheaper than the real use.

World running cost

This applies to non-default world also, but the test is similar. After initialized, how long it takes to update on all systems :

  • Assuming that all of them do not qualify for update.
  • Assuming that all of them qualified for update (found matching Entity for its GetEntityQuery) but that update ended up doing nothing.

Specifically I would like to know how much could I go nuts in defining systems and have the all littered to the default world, assuming that I have neatly design the update requirement and ignoring the world initialization cost. I will benchmark only the update cost for each round.

The benchmark is by stopwatching on the simulationGroup.Update() where all test systems live in.

The two questions will be addressed with this 2 similar tests :

[Test]
[Repeat(100)]
public void AmountOfSystemsEmptyUpdateTest([Values(10, 50, 100, 200, 300, 400, 500, 1000)] int systemAmount)
{
    NewTestScript.systemAmount = systemAmount;
    System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
    DefaultWorldInitialization.Initialize("Test World", editorWorld: false);

    var simulationGroup = World.Active.GetOrCreateSystem<SimulationSystemGroup>();
    sw.Start();
    simulationGroup.Update();
    sw.Stop();

    Debug.Log($"EmptyUpdate, {systemAmount}, {sw.ElapsedTicks}");
    ScriptBehaviourUpdateOrder.SetPlayerLoop(PlayerLoop.GetDefaultPlayerLoop());
    World.Active.Dispose();
}

[Test]
[Repeat(100)]
public void AmountOfSystemsRealUpdateTest([Values(10, 50, 100, 200, 300, 400, 500, 1000)] int systemAmount)
{
    NewTestScript.systemAmount = systemAmount;
    System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
    DefaultWorldInitialization.Initialize("Test World", editorWorld: false);

    World.Active.EntityManager.CreateEntity(ComponentType.ReadOnly<TC>()); // <-----

    var simulationGroup = World.Active.GetOrCreateSystem<SimulationSystemGroup>();
    sw.Start();
    simulationGroup.Update();
    sw.Stop();

    Debug.Log($"RealUpdate, {systemAmount}, {sw.ElapsedTicks}");
    ScriptBehaviourUpdateOrder.SetPlayerLoop(PlayerLoop.GetDefaultPlayerLoop());
    World.Active.Dispose();
}

The 2nd test satisfies all systems update requirement by creating a single Entity with the TC component.

Systems in the group Average empty update cost (ticks) Average empty update cost (ms) Average real update cost (ticks) Average real update cost (ms)
10 379.35 0.038 480.47 0.048
50 479.71 0.048 1391.99 0.139
100 671.46 0.067 2598.27 0.26
200 997.13 0.1 4899.2 0.49
300 1253.3 0.125 7312.21 0.731
400 1529.81 0.153 9788.16 0.979
500 1801.01 0.18 13188.35 1.319
1000 3129.96 0.313 36782.81 3.678

Evaluation

  • Most game may have 50-200 systems. As long as you have properly design the update condition I think you can have as much as 1000 systems with no worries.
  • Even the empty update / early out case is not that expensive. (3.6ms is expensive but you should not have 1000 empty updating systems in the first place)