BaseMono - VirtueSky/sunflower GitHub Wiki

After inheriting from BaseMono

  • Tick() Same Update()
  • LateTick() Same LateUpdate
  • FixedTick() Same FixedUpdate

All class that inherit from BaseMono when called Tick (LateTick or FixedTick) will be updated at only one MonoGlobal (inherited from Monobehaviour).

  • What is the benefit of one single MonoBehavior class?

  • Note: Because BaseMono register Tick() at OnEnable(), classes that inherit from BaseMono when needing to use OnEnable() and update with Tick() are required to inherit OnEnable() from BaseMono.

  • Demo correct usage (Tick() will work)


public class Test : BaseMono
{
    public GameObject popup;

    public override void OnEnable()
    {
        base.OnEnable();
        popup.SetActive(true);
    }

    public override void Tick()
    {
        base.Tick();
        Debug.Log("Tick Update");
    }
}

  • Demo incorrect usage (Tick() will not work)

public class Test : BaseMono
{
    public GameObject popup;

    public void OnEnable()
    {
        popup.SetActive(true);
    }

    public override void Tick()
    {
        base.Tick();
        Debug.Log("Tick Update");
    }
}

  • If you do not inherit from BaseMono but still want to perform updates on MonoGlobal, you can register according to the example below
public class Player : MonoBehaviour
{
    private void Awake()
    {
        App.SubTick(CustomUpdate);
        App.SubFixedTick(CustomFixedUpdate);
        App.SubLateTick(CustomLateUpdate);
    }

    private void OnDestroy()
    {
        App.UnSubTick(CustomUpdate);
        App.UnSubFixedTick(CustomFixedUpdate);
        App.UnSubLateTick(CustomLateUpdate);
    }

    private void CustomUpdate()
    {
        Debug.Log("Update");
    }

    private void CustomFixedUpdate()
    {
        Debug.Log("Fixed Update");
    }

    private void CustomLateUpdate()
    {
        Debug.Log("Late Update");
    }
}

What is the benefit of one single MonoBehavior class?

The Unity engine has a Messaging system which allows the developers to define methods that will be called by an internal system based on their functionalities. One of the most commonly used Messages is the Update message. Unity is inspecting every MonoBehaviour the first time the type is accessed (independently from the scripting backend (mono, il2cpp)) and checks if any of the Message methods are defined. If a Message method is defined then the engine will cache this information. Then if an instance of this type is instantiated then the engine will add it to the appropriate list and will call the method whenever it should. This is also the key reason why Unity does not care about the visibility of our Message method, and that they are not called in a deterministic order.


public class Example1 : MonoBehaviour
{
    private void Update() { }
}
public class Example2: MonoBehaviour
{
    public void Update() { }
}

Both of the above achieves the same results but god knows which will be called first.

One of the main problem with this approach is that every time the engine calls a Message method an interop call (a call from c/c++ side to the managed c# side) has to happen. In case of Update luckily no marshaling is needed so this overhead is a bit smaller. However, if our game handles thousand or tens of thousands of objects which all have a script requiring a Message call then this overhead can be significant. A solution to this is to avoid interop calls. A good approach to this is behavior grouping. If we have a MonoBehaviour that is attached to a huge number of GameObjects we can cut the number of interop calls to just one by introducing an update manager. Since the update manager is also a managed object running managed code the only interop call will happen between the update manager’s Update Message and the Unity engine’s internal Message handler. We have to note the fact that this optimization technique is only relevant in large scale projects, and the frame time saved via this technique is more impactful when using the Mono scripting backend. (Remember IL2CPP transpiles to C++).

flowchart TB
    Engine["Unity Engine / Internal Message Handler"]

    Engine -->|"Interop call: Update()"| MB1["PlayerController : MonoBehaviour"]
    Engine -->|"Interop call: Update()"| MB2["EnemyAI : MonoBehaviour"]
    Engine -->|"Interop call: Update()"| MB3["Bullet : MonoBehaviour"]
    Engine -->|"Interop call: Update()"| MB4["ItemRotator : MonoBehaviour"]
    Engine -->|"Interop call: Update()"| MB5["UIAnimation : MonoBehaviour"]

    MB1 -->|"Update logic"| L1["Move player"]
    MB2 -->|"Update logic"| L2["Think / chase target"]
    MB3 -->|"Update logic"| L3["Move bullet"]
    MB4 -->|"Update logic"| L4["Rotate item"]
    MB5 -->|"Update logic"| L5["Animate UI"]
flowchart TB
    Engine["Unity Engine / Internal Message Handler"]

    Engine -->|"Only one interop call: Update()"| UpdateManager["UpdateManager : MonoBehaviour"]

    UpdateManager -->|"Managed call: Tick()"| U1["PlayerController"]
    UpdateManager -->|"Managed call: Tick()"| U2["EnemyAI"]
    UpdateManager -->|"Managed call: Tick()"| U3["Bullet"]
    UpdateManager -->|"Managed call: Tick()"| U4["ItemRotator"]
    UpdateManager -->|"Managed call: Tick()"| U5["UIAnimation"]

    U1 -->|"Update logic"| L1["Move player"]
    U2 -->|"Update logic"| L2["Think / chase target"]
    U3 -->|"Update logic"| L3["Move bullet"]
    U4 -->|"Update logic"| L4["Rotate item"]
    U5 -->|"Update logic"| L5["Animate UI"]

The above picture illustrates the difference between the two methods.

Let's do a benchmark with Unity's performance tools. The benchmark will spawn 10 000 gameobjects each with a mover script which moves these cubes up and down.

Now let's see the results of the bechmarks.

Not surprisingly IL2CPP leading the competition by far however it’s still interesting that the Update Manager is still twice as fast as the traditional way. If we profiled the execution of the Traditional method’s IL2CPP build we would find many Unity specific calls like check if the GameObject exists before invoking a component method etc. and these would explain the longer execution time. We can also make the conclusion that IL2CPP in this case is far faster than Mono, usually around twice as fast. The benchmark ran for one minute prior to a 5 seconds warmup and both scripting backends had the ideal compiler setting.

My benchmark results.

Samsung A54 5G device, IL2CPP

Update 10000 in MonoBehaviour's Update method.

https://github.com/user-attachments/assets/60c691b2-fd49-4611-868f-06fb027ad801

Update 10000 on the Update method of a single MonoBehaviour via the Update Manager system.

https://github.com/user-attachments/assets/9e6fefe9-83a3-432f-affa-0ebaa8e08571

The total cost of the update is cheaper than the traditional method by approximately 2.5ms.