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.
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 imagineEntityManager
has a powerful database which is the core power of ECS. - Could contains systems. (
ComponentSystem
,JobComponentSystem
,EntityCommandBufferSystem
,ComponentSystemGroup
) Systems contains logic that do work onEntity
because it knowsEntityManager
from its world. Creating a system is gate keeped byworld.GetOrCreateSystem<T>
, it is this point that theWorld
inject itself to the system. - Your system code has
protected
propertiesWorld
andEntityManager
to refer back to its owning world.
Problems
- Systems usually has work logic on their
OnUpdate
override. This could be triggered withsystem.Update()
, but it is inconvenient to useworld.GetOrCreateSystem<T>
then call.Update()
sequentially. Also, sequentially in what order? World
has no method that couldUpdate
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 oldMonoBehaviour
'sUpdate()
. 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 inSimulationSystemGroup
and one another inPresentationSystemGroup
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 doSortSystemUpdateList
. 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 yourWorld.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 inSimulationSystemGroup
. - 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 callDefaultWorldInitialization.Initialize
on your own- Or even avoid
DefaultWorldInitialization.Initialize
and setup the world manually that leads to calling a validScriptBehaviourUpdateOrder.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()
withworld.GetOrCreateSystem<T>()
. - Using
ScriptBehaviourUpdateOrder.UpdatePlayerLoop(world);
on your manual world does nothing because it only cares to get theUpdate()
delegate of the 3 top groups. - Even if you
GetOrCreateSystem
those groups into theWorld
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 :
- 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. - You get a chance to modify these to-be-added systems in your own
ICustomBootstrap
declared anywhere. Return aList<Type>
with less or more elements as you like. - 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
- An amount of declared systems in an assembly. This could not be tested without modifying the source code between each test.
- 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 itsGetEntityQuery
) 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)