When running a game with thousands of active entities—such as enemies moving around, projectiles flying, or rotating environment items—defining a standard void Update() method on each of their individual scripts can severely degrade overall performance.
Intriguingly, even if these Update methods are completely empty, having thousands of them in your active scene can still trigger massive frame-rate drops. The culprit behind this phenomenon is an internal Unity mechanism known as 'Managed-to-Native' overhead.
This article explores why this overhead occurs by diving into the Unity Profiler, and walks you through a step-by-step implementation of a Centralized Update Pattern that invokes all update operations through a fast C# loop managed by a single manager class, complete with a clean auto-registration system.
1. The Root Cause: The High Cost of Crossing the "Managed-to-Native" Boundary
Why do individual Update calls cost so much?
Unity's core engine logic (handling physics, rendering, GameObject hierarchy, etc.) is written in high-performance C++ (native code). On the other hand, the custom gameplay logic we write runs in C# (managed code).
When a GameObject becomes active, Unity checks if its MonoBehaviour defines magic methods like Update() and adds them to an internal list. Every frame, the native C++ engine loops through this list and calls the C# method for each script.
[Unity Native (C++) Engine] ──(Heavy Boundary Crossing)──> [MonoBehaviour (C#) Update()]
Figure: Costly managed-to-native transitions happening thousands of times every frame
Every time the native engine invokes a managed C# method (similar to P/Invoke transitions), it must perform security checks, sync state data, and handle exception management. This transition boundary has a fixed performance cost.
Calling a method within C# (e.g., from one class method to another) is extremely fast, taking only a fraction of a nanosecond. However, crossing the Managed-to-Native boundary is tens of times more expensive.
If you only have 100 active objects, this boundary cost is negligible. But when you scale up to 1,000 or 5,000 objects, the cumulative transition overhead wastes several milliseconds of CPU time, creating a major performance bottleneck.
2. The Solution: Centralized Update Pattern
The Centralized Update Pattern is a design architecture designed to bypass this transition overhead.
The concept is simple:
- Remove standard
void Update()methods from individual scripts. - Make these scripts implement a common update interface, such as
IUpdateable. - Introduce a single MonoBehaviour manager class,
UpdateManager, into the scene. - The
UpdateManageracts as the single recipient of Unity's nativeUpdate()call, and propagates the frame update to all registeredIUpdateableinstances using a standard C#forloop.
[Unity Native (C++) Engine]
│
(Single Call)
▼
[UpdateManager (C#)]
│
(Fast C# Loop)
├─> [Enemy 1 (IUpdateable.OnUpdate)]
├─> [Enemy 2 (IUpdateable.OnUpdate)]
└─> [Projectile (IUpdateable.OnUpdate)]
Figure: Reducing the native transition count to once per frame and looping purely in C#
With this architecture, the Managed-to-Native transition happens exactly once per frame instead of thousands of times.
3. Implementation Guide: Step-by-Step Code
Let us implement a production-ready, safe UpdateManager.
A critical challenge in managing update lists is preventing collection-modified exceptions (Concurrent Modification) when scripts register, unregister, or destroy themselves while the update loop is actively running. We solve this by buffering registration requests and applying them outside the main loop.
1. The Interface (IUpdateable.cs)
Define a basic interface for objects that need centralized frame updates.
public interface IUpdateable
{
void OnUpdate(float deltaTime);
}
2. The Manager Script (UpdateManager.cs)
This singleton class manages registrations and processes the core update loop. Additions and deletions are buffered during execution to avoid crashing the iterator.
using System.Collections.Generic;
using UnityEngine;
public class UpdateManager : MonoBehaviour
{
private static UpdateManager _instance;
public static UpdateManager Instance
{
get
{
if (_instance == null)
{
var go = new GameObject("UpdateManager");
_instance = go.AddComponent<UpdateManager>();
DontDestroyOnLoad(go);
}
return _instance;
}
}
private readonly List<IUpdateable> _updateables = new List<IUpdateable>();
private readonly List<IUpdateable> _toAdd = new List<IUpdateable>();
private readonly List<IUpdateable> _toRemove = new List<IUpdateable>();
private bool _isUpdating;
private void Awake()
{
if (_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = this;
DontDestroyOnLoad(gameObject);
}
// Register objects to the update loop
public void Register(IUpdateable updateable)
{
if (updateable == null) return;
if (_isUpdating)
{
if (!_toAdd.Contains(updateable) && !_updateables.Contains(updateable))
{
_toAdd.Add(updateable);
_toRemove.Remove(updateable);
}
}
else
{
if (!_updateables.Contains(updateable))
{
_updateables.Add(updateable);
}
}
}
// Unregister objects
public void Unregister(IUpdateable updateable)
{
if (updateable == null) return;
if (_isUpdating)
{
if (!_toRemove.Contains(updateable))
{
_toRemove.Add(updateable);
_toAdd.Remove(updateable);
}
}
else
{
_updateables.Remove(updateable);
}
}
private void Update()
{
_isUpdating = true;
float deltaTime = Time.deltaTime;
int count = _updateables.Count;
// Use a standard for-loop to scan items to avoid GC Allocations
for (int i = 0; i < count; i++)
{
// Null check in case an object was destroyed during the frame
if (_updateables[i] != null)
{
_updateables[i].OnUpdate(deltaTime);
}
}
_isUpdating = false;
// Apply buffered add/remove operations after the loop completes
ProcessPendingRequests();
}
private void ProcessPendingRequests()
{
if (_toRemove.Count > 0)
{
for (int i = 0; i < _toRemove.Count; i++)
{
_updateables.Remove(_toRemove[i]);
}
_toRemove.Clear();
}
if (_toAdd.Count > 0)
{
for (int i = 0; i < _toAdd.Count; i++)
{
if (!_updateables.Contains(_toAdd[i]))
{
_updateables.Add(_toAdd[i]);
}
}
_toAdd.Clear();
}
}
}
3. Auto-Registration Base Class (ManagedMonoBehaviour.cs)
To avoid writing manual registration code for every script, implement a base class that wraps registration events automatically within the Unity lifecycle.
using UnityEngine;
public abstract class ManagedMonoBehaviour : MonoBehaviour, IUpdateable
{
protected virtual void OnEnable()
{
// Automatically registers when active in scene
UpdateManager.Instance.Register(this);
}
protected virtual void OnDisable()
{
// Automatically unregisters when deactivated or destroyed
if (UpdateManager.Instance != null)
{
UpdateManager.Instance.Unregister(this);
}
}
// Derived classes override this instead of using void Update()
public abstract void OnUpdate(float deltaTime);
}
4. Usage Example
Inheriting from our base class integrates a script into the centralized manager automatically.
using UnityEngine;
public class Projectile : ManagedMonoBehaviour
{
[SerializeField] private float speed = 10f;
private Vector3 _direction = Vector3.forward;
public override void OnUpdate(float deltaTime)
{
// Movement calculation run centralized
transform.Translate(_direction * (speed * deltaTime));
}
}
4. Performance Metrics: Benchmark Results
We measured the CPU overhead of individual MonoBehaviours vs. the centralized `UpdateManager` using 10,000 active GameObjects executing an empty update call.
- Test Environment: PC (Core i7-12700 / Windows 11 / Unity 2022.3 LTS)
- Item Count: 10,000 objects
| Execution Method | Profiler Metric (CPU Time) | GC Alloc |
|---|---|---|
| Standard Method (Individual Update x 10,000) | BehaviourUpdate: 8.2ms |
0 B |
| Centralized Update Method | UpdateManager.Update: 1.4ms |
0 B |
Result: For a load of 10,000 elements executing the exact same logic, switching to a centralized model reduces the update overhead by 83% (8.2ms to 1.4ms).
Saving 7 milliseconds per frame frees up a massive amount of CPU budget that can be used for game graphics, AI systems, or physics simulations.
5. Summary & Best Practices for Production
While the centralized update pattern is highly effective, consider these factors when implementing it in production:
- Controlling Execution Order:
If certain systems need to update before or after others, you must sort the active list or split update pipelines by priority. You can extend the register logic (e.g.,
UpdateManager.Register(IUpdateable, int priority)) to handle sorting. - Extending to FixedUpdate and LateUpdate:
You can apply the same logic to physics loops and camera-follow systems by defining
IFixedUpdateableandILateUpdateableinterfaces alongside separate list runners. - Inspector Enable/Disable Support:
Because
ManagedMonoBehaviourregisters and unregisters withinOnEnableandOnDisable, checking or unchecking the component in the Unity Inspector behaves exactly as expected. However, toggling components frequently can cause list insertions and deletions, which introduce slight overhead. For frequently toggled entities, combine this pattern with an Object Pooler.
As games grow in complexity, small framework overheads accumulate quickly. Understanding internal structures like native-to-managed boundaries allows you to design high-performance architectures, keeping your framerates fluid even under heavy entity counts.