The Unity game loop
You can check the final proof of concept project on github
The Unity Engine provides us with event functions on MonoBehaviours that get executed automatically, some of those methods are part of the game loop. The game loop is the core part of Unity that is responsible for repeating endlessly so that our game runs, some parts of this game loop system are easily accessible to us, like the Update and FixedUpdate methods, other parts are not accessible to us, because their responsibilities are fixed in the Engine.
Unity gives us some limited access to that game loop system via the UnityEngine.LowLevel namespace, by providing three static methods and the PlayerLoopSystem struct with its properties that can help us add/remove or change/replace some of the systems in the game loop. Although removing some systems may have negative consequences to our game, like the physics not being calculated and changing some systems gets tricky, because we will have to deal with unsafe code that is marshalling into the C++ space, adding our own systems into the mix is relatively harmless and pretty easy.
The following post shows a way to add an EarlyUpdate, that gets executed before any Update method has run and a SuperLateUpdate that gets executed after all LateUpdates have executed. You can use it as a guideline to add your own systems wherever you feel necessary, or to remove any systems that you may feel that you don’t need, although i wouldn’t recommend that because it would be easy to inadvertently break something.
Changing a system by adding some functionality at the beginning or the end is also possible, but it gets trickier as we will need to use unsafe code and the compatibility of it, is dependent on the hardware and the compilation method we will use and for that reason it is out of the scope of this post.
The player loop system
The PlayerLoop class provides us with three static methods: GetCurrentPlayerLoop
, GetDefaultPlayerLoop
and SetPlayerLoop
. The first two return the current and default update order of all engine systems in Unity and the third sets a new custom order.
Let’s start by taking a look at the default update order of Unity’s engine systems:
public static class DefaultUpdateOrder
{
[RuntimeInitializeOnLoadMethod]
private static void Init()
{
StringBuilder sb = new();
ShowPlayerLoop(PlayerLoop.GetDefaultPlayerLoop(), sb, 0);
Debug.Log(sb);
}
private static void ShowPlayerLoop(PlayerLoopSystem playerLoopSystem, StringBuilder text, int inline)
{
if (playerLoopSystem.type != null)
{
for (var i = 0; i < inline; i++)
{
text.Append("\t");
}
text.AppendLine(playerLoopSystem.type.Name);
}
if (playerLoopSystem.subSystemList != null)
{
inline++;
foreach (var s in playerLoopSystem.subSystemList)
{
ShowPlayerLoop(s, text, inline);
}
}
}
}
by running this code the output will be:
TimeUpdate
WaitForLastPresentationAndUpdateTime
Initialization
ProfilerStartFrame
UpdateCameraMotionVectors
DirectorSampleTime
AsyncUploadTimeSlicedUpdate
SynchronizeInputs
SynchronizeState
XREarlyUpdate
EarlyUpdate
PollPlayerConnection
GpuTimestamp
AnalyticsCoreStatsUpdate
UnityWebRequestUpdate
ExecuteMainThreadJobs
ProcessMouseInWindow
ClearIntermediateRenderers
ClearLines
PresentBeforeUpdate
ResetFrameStatsAfterPresent
UpdateAsyncReadbackManager
UpdateStreamingManager
UpdateTextureStreamingManager
UpdatePreloading
UpdateContentLoading
RendererNotifyInvisible
PlayerCleanupCachedData
UpdateMainGameViewRect
UpdateCanvasRectTransform
XRUpdate
UpdateInputManager
ProcessRemoteInput
ScriptRunDelayedStartupFrame
UpdateKinect
DeliverIosPlatformEvents
ARCoreUpdate
DispatchEventQueueEvents
Physics2DEarlyUpdate
PhysicsResetInterpolatedTransformPosition
SpriteAtlasManagerUpdate
PerformanceAnalyticsUpdate
FixedUpdate
ClearLines
NewInputFixedUpdate
DirectorFixedSampleTime
AudioFixedUpdate
ScriptRunBehaviourFixedUpdate
DirectorFixedUpdate
LegacyFixedAnimationUpdate
XRFixedUpdate
PhysicsFixedUpdate
Physics2DFixedUpdate
PhysicsClothFixedUpdate
DirectorFixedUpdatePostPhysics
ScriptRunDelayedFixedFrameRate
PreUpdate
PhysicsUpdate
Physics2DUpdate
PhysicsClothUpdate
CheckTexFieldInput
IMGUISendQueuedEvents
NewInputUpdate
SendMouseEvents
AIUpdate
WindUpdate
UpdateVideo
Update
ScriptRunBehaviourUpdate
ScriptRunDelayedDynamicFrameRate
ScriptRunDelayedTasks
DirectorUpdate
PreLateUpdate
AIUpdatePostScript
DirectorUpdateAnimationBegin
LegacyAnimationUpdate
DirectorUpdateAnimationEnd
DirectorDeferredEvaluate
UIElementsUpdatePanels
EndGraphicsJobsAfterScriptUpdate
ConstraintManagerUpdate
ParticleSystemBeginUpdateAll
Physics2DLateUpdate
PhysicsLateUpdate
ScriptRunBehaviourLateUpdate
PostLateUpdate
PlayerSendFrameStarted
DirectorLateUpdate
ScriptRunDelayedDynamicFrameRate
PhysicsSkinnedClothBeginUpdate
UpdateRectTransform
PlayerUpdateCanvases
UpdateAudio
VFXUpdate
ParticleSystemEndUpdateAll
EndGraphicsJobsAfterScriptLateUpdate
UpdateCustomRenderTextures
XRPostLateUpdate
UpdateAllRenderers
UpdateLightProbeProxyVolumes
EnlightenRuntimeUpdate
UpdateAllSkinnedMeshes
ProcessWebSendMessages
SortingGroupsUpdate
UpdateVideoTextures
UpdateVideo
DirectorRenderImage
PlayerEmitCanvasGeometry
PhysicsSkinnedClothFinishUpdate
FinishFrameRendering
BatchModeUpdate
PlayerSendFrameComplete
UpdateCaptureScreenshot
PresentAfterDraw
ClearImmediateRenderers
PlayerSendFramePostPresent
UpdateResolution
InputEndFrame
TriggerEndOfFrameCallbacks
GUIClearEvents
ShaderHandleErrors
ResetInputAxis
ThreadedLoadingDebug
ProfilerSynchronizeStats
MemoryFrameMaintenance
ExecuteGameCenterCallbacks
XRPreEndFrame
ProfilerEndFrame
GraphicsWarmupPreloadedShaders
WOW!, that’s a lot. Some of those systems though are known to people that have used Unity even for 5 minutes. What is important to notice here is that each system is composed by subsystems that are responsible for certain parts of the core game loop, that’s why it is important not to remove/replace something unless we are pretty sure we know what we are doing.
Adding something though is relatively harmless as i mentioned and can be pretty useful depending on our situation.
The PlayerLoopSystem struct properties
The PlayerLoopSystem struct, exposes 5 properties that we can use to achieve our goals:
- loopConditionFunction: The loop condition for a native engine system
- subSystemList: We used this property to get the subsystems of each system in our previous code. Each PlayerLoopSystem struct contains a list of PlayerLoopSystem subsystems which in turn can contain their own etc.
- type: A type that acts as an ID for a subsystem and shows where this subsystem belongs, for custom systems this is only useful as an ID for the profiler.
- updateDelegate: A delegate that acts as an entry point to the game loop, useful for calling our own method when is the time for our own system to run.
- updateFunction: A native engine system, the only valid values are values that get returned by a PlayerLoopSystem that exists in the default game loop. This returns a pointer to the C++ native methods.
First let’s create our first system, our EarlyUpdate:
var myEarlyUpdate = new PlayerLoopSystem
{
subSystemList = null,
updateDelegate = OnEarlyUpdate,
type = typeof(MyEarlyUpdate)
};
the MyEarlyUpdate
is an empty class that its only purpose is to act as an ID:
public class MyEarlyUpdate { }
the OnEarlyUpdate
will be the method that is called when it is time for our system to run.
Adding our own system to the loop
Now we need to create a method that adds our system to the loop, we will do that by creating a new PlayerLoopSystem and adding to that each subsystem of an already existing loop. We will be searching for a type of subsystem after which we will add our own and then keep adding the remaining subsystems.
private static PlayerLoopSystem AddSystem<T>(in PlayerLoopSystem loopSystem, PlayerLoopSystem systemToAdd) where T : struct
{
PlayerLoopSystem newPlayerLoop = new()
{
loopConditionFunction = loopSystem.loopConditionFunction,
type = loopSystem.type,
updateDelegate = loopSystem.updateDelegate,
updateFunction = loopSystem.updateFunction
};
List<PlayerLoopSystem> newSubSystemList = new();
foreach (var subSystem in loopSystem.subSystemList)
{
newSubSystemList.Add(subSystem);
if (subSystem.type == typeof(T))
newSubSystemList.Add(systemToAdd);
}
newPlayerLoop.subSystemList = newSubSystemList.ToArray();
return newPlayerLoop;
}
the generic T here is the system that runs exactly before our own, the loopSystem
parameter is the loopSystem we are iterating and the systemToAdd
parameter is the system we have created. Obviously the code can be modified so that we can add our system before the T system. In the end we return the new system and we can set it with the statement:
PlayerLoop.SetPlayerLoop(ourSystem);
after that, everything that is in code inside our methods that we have provided in the Delegate will run at the appropriate time.
Here is the full code for the Early and LateUpdate systems:
public class MySuperLateUpdate { } // Empty class that acts as an identifier for our SuperLateUpdate system
public class MyEarlyUpdate { } // Empty class that acts as an identifier for our EarlyUpdate system
public static class MySystem
{
public static event Action AddSuperLateUpdate;
public static event Action AddEarlyUpdate;
[RuntimeInitializeOnLoadMethod]
private static void Init()
{
var defaultSystems = PlayerLoop.GetDefaultPlayerLoop();
var mySuperLateUpdate = new PlayerLoopSystem
{
subSystemList = null,
updateDelegate = OnSuperLateUpdate,
type = typeof(MySuperLateUpdate)
};
var myEarlyUpdate = new PlayerLoopSystem
{
subSystemList = null,
updateDelegate = OnEarlyUpdate,
type = typeof(MyEarlyUpdate)
};
var loopWithSuperLateUpdate = AddSystem<PreLateUpdate>(in defaultSystems, mySuperLateUpdate);
var loopWithEarlyAndSuperLateUpdate = AddSystem<PreUpdate>(in loopWithSuperLateUpdate, myEarlyUpdate);
PlayerLoop.SetPlayerLoop(loopWithEarlyAndSuperLateUpdate);
}
private static PlayerLoopSystem AddSystem<T>(in PlayerLoopSystem loopSystem, PlayerLoopSystem systemToAdd) where T : struct
{
PlayerLoopSystem newPlayerLoop = new()
{
loopConditionFunction = loopSystem.loopConditionFunction,
type = loopSystem.type,
updateDelegate = loopSystem.updateDelegate,
updateFunction = loopSystem.updateFunction
};
List<PlayerLoopSystem> newSubSystemList = new();
foreach (var subSystem in loopSystem.subSystemList)
{
newSubSystemList.Add(subSystem);
if (subSystem.type == typeof(T))
newSubSystemList.Add(systemToAdd);
}
newPlayerLoop.subSystemList = newSubSystemList.ToArray();
return newPlayerLoop;
}
private static void OnEarlyUpdate() => AddEarlyUpdate?.Invoke();
private static void OnSuperLateUpdate() => AddSuperLateUpdate?.Invoke();
}
By subscribing to the AddEarlyUpdate
and AddSuperLateUpdate
from any script, we can have functionality that runs on those systems. Like this:
public class Test : MonoBehaviour
{
private void OnEnable()
{
MySystem.AddEarlyUpdate += MyEarlyUpdate;
MySystem.AddSuperLateUpdate += MySuperLateUpdate;
}
private void Update()
{
Debug.Log("In Update");
}
private void LateUpdate()
{
Debug.Log("In late Update");
}
private void OnDisable()
{
MySystem.AddEarlyUpdate -= MyEarlyUpdate;
MySystem.AddSuperLateUpdate -= MySuperLateUpdate;
}
private void MySuperLateUpdate()
{
Debug.Log("Super Late Update Running!");
}
private void MyEarlyUpdate()
{
Debug.Log("Early Update Running!");
}
}
if we try now to see our player loop by calling
ShowPlayerLoop(PlayerLoop.GetCurrentPlayerLoop(), sb2, 0);
we can see that our new systems are in place:
TimeUpdate
WaitForLastPresentationAndUpdateTime
Initialization
ProfilerStartFrame
UpdateCameraMotionVectors
DirectorSampleTime
AsyncUploadTimeSlicedUpdate
SynchronizeInputs
SynchronizeState
XREarlyUpdate
EarlyUpdate
PollPlayerConnection
GpuTimestamp
AnalyticsCoreStatsUpdate
UnityWebRequestUpdate
ExecuteMainThreadJobs
ProcessMouseInWindow
ClearIntermediateRenderers
ClearLines
PresentBeforeUpdate
ResetFrameStatsAfterPresent
UpdateAsyncReadbackManager
UpdateStreamingManager
UpdateTextureStreamingManager
UpdatePreloading
UpdateContentLoading
RendererNotifyInvisible
PlayerCleanupCachedData
UpdateMainGameViewRect
UpdateCanvasRectTransform
XRUpdate
UpdateInputManager
ProcessRemoteInput
ScriptRunDelayedStartupFrame
UpdateKinect
DeliverIosPlatformEvents
ARCoreUpdate
DispatchEventQueueEvents
Physics2DEarlyUpdate
PhysicsResetInterpolatedTransformPosition
SpriteAtlasManagerUpdate
PerformanceAnalyticsUpdate
FixedUpdate
ClearLines
NewInputFixedUpdate
DirectorFixedSampleTime
AudioFixedUpdate
ScriptRunBehaviourFixedUpdate
DirectorFixedUpdate
LegacyFixedAnimationUpdate
XRFixedUpdate
PhysicsFixedUpdate
Physics2DFixedUpdate
PhysicsClothFixedUpdate
DirectorFixedUpdatePostPhysics
ScriptRunDelayedFixedFrameRate
PreUpdate
PhysicsUpdate
Physics2DUpdate
PhysicsClothUpdate
CheckTexFieldInput
IMGUISendQueuedEvents
NewInputUpdate
SendMouseEvents
AIUpdate
WindUpdate
UpdateVideo
MyEarlyUpdate
Update
ScriptRunBehaviourUpdate
ScriptRunDelayedDynamicFrameRate
ScriptRunDelayedTasks
DirectorUpdate
PreLateUpdate
AIUpdatePostScript
DirectorUpdateAnimationBegin
LegacyAnimationUpdate
DirectorUpdateAnimationEnd
DirectorDeferredEvaluate
UIElementsUpdatePanels
EndGraphicsJobsAfterScriptUpdate
ConstraintManagerUpdate
ParticleSystemBeginUpdateAll
Physics2DLateUpdate
PhysicsLateUpdate
ScriptRunBehaviourLateUpdate
MySuperLateUpdate
PostLateUpdate
PlayerSendFrameStarted
DirectorLateUpdate
ScriptRunDelayedDynamicFrameRate
PhysicsSkinnedClothBeginUpdate
UpdateRectTransform
PlayerUpdateCanvases
UpdateAudio
VFXUpdate
ParticleSystemEndUpdateAll
EndGraphicsJobsAfterScriptLateUpdate
UpdateCustomRenderTextures
XRPostLateUpdate
UpdateAllRenderers
UpdateLightProbeProxyVolumes
EnlightenRuntimeUpdate
UpdateAllSkinnedMeshes
ProcessWebSendMessages
SortingGroupsUpdate
UpdateVideoTextures
UpdateVideo
DirectorRenderImage
PlayerEmitCanvasGeometry
PhysicsSkinnedClothFinishUpdate
FinishFrameRendering
BatchModeUpdate
PlayerSendFrameComplete
UpdateCaptureScreenshot
PresentAfterDraw
ClearImmediateRenderers
PlayerSendFramePostPresent
UpdateResolution
InputEndFrame
TriggerEndOfFrameCallbacks
GUIClearEvents
ShaderHandleErrors
ResetInputAxis
ThreadedLoadingDebug
ProfilerSynchronizeStats
MemoryFrameMaintenance
ExecuteGameCenterCallbacks
XRPreEndFrame
ProfilerEndFrame
GraphicsWarmupPreloadedShaders
Making it cleaner
Using events is not very performant, a better approach would be to create a publisher/subscriber pattern through interfaces. Every time that we want one of our scripts to have the EarlyUpdate
or the SuperLateUpdate
we will make it implement the corresponding interface and then subscribe to our static manger class, like this:
public class Square : MonoBehaviour, IEarlyUpdate
{
private void OnEnable()
{
UpdateManager.RegisterEarlyUpdate(this);
}
private void OnDisable()
{
UpdateManager.UnregisterEarlyUpdate(this);
}
private void Update()
{
Debug.Log("Inside square Update");
}
void IEarlyUpdate.EarlyUpdate()
{
Debug.Log("Inside Square Early Update");
}
}
Our static UpdateManager class then would look like this:
public static class UpdateManager
{
private static readonly HashSet<IEarlyUpdate> _earlyUpdates = new();
private static readonly HashSet<ISuperLateUpdate> _superLateUpdates = new();
public static void RegisterEarlyUpdate(IEarlyUpdate earlyUpdate) => _earlyUpdates.Add(earlyUpdate);
public static void RegisterSuperLateUpdate(ISuperLateUpdate superLateUpdate) => _superLateUpdates.Add(superLateUpdate);
public static void UnregisterEarlyUpdate(IEarlyUpdate earlyUpdate) => _earlyUpdates.Remove(earlyUpdate);
public static void UnregisterSuperLateUpdate(ISuperLateUpdate superLateUpdate) => _superLateUpdates.Remove(superLateUpdate);
[RuntimeInitializeOnLoadMethod]
private static void Init()
{
var defaultSystems = PlayerLoop.GetDefaultPlayerLoop();
var mySuperLateUpdate = new PlayerLoopSystem
{
subSystemList = null,
updateDelegate = OnSuperLateUpdate,
type = typeof(MySuperLateUpdate)
};
var myEarlyUpdate = new PlayerLoopSystem
{
subSystemList = null,
updateDelegate = OnEarlyUpdate,
type = typeof(MyEarlyUpdate)
};
var loopWithSuperLateUpdate = AddSystem<PreLateUpdate>(in defaultSystems, mySuperLateUpdate);
var loopWithEarlyAndSuperLateUpdate = AddSystem<PreUpdate>(in loopWithSuperLateUpdate, myEarlyUpdate);
PlayerLoop.SetPlayerLoop(loopWithEarlyAndSuperLateUpdate);
static PlayerLoopSystem AddSystem<T>(in PlayerLoopSystem loopSystem, PlayerLoopSystem systemToAdd) where T : struct
{
PlayerLoopSystem newPlayerLoop = new()
{
loopConditionFunction = loopSystem.loopConditionFunction,
type = loopSystem.type,
updateDelegate = loopSystem.updateDelegate,
updateFunction = loopSystem.updateFunction
};
List<PlayerLoopSystem> newSubSystemList = new();
foreach (var subSystem in loopSystem.subSystemList)
{
newSubSystemList.Add(subSystem);
if (subSystem.type == typeof(T))
newSubSystemList.Add(systemToAdd);
}
newPlayerLoop.subSystemList = newSubSystemList.ToArray();
return newPlayerLoop;
}
}
private static void OnEarlyUpdate()
{
using var e = _earlyUpdates.GetEnumerator();
while (e.MoveNext())
{
e.Current?.EarlyUpdate();
}
}
private static void OnSuperLateUpdate()
{
using var e = _superLateUpdates.GetEnumerator();
while (e.MoveNext())
{
e.Current?.SuperLateUpdate();
}
}
}
for a full proof of concept, you can check the code on github
Conclusion
Having our own systems in the player loop is not only useful but can actually be more performant. Unity has to move between C# and C++ space for every event method that we use in our scripts. By using our own system, for example the EarlyUpdate instead of the Unity’s Update event method, is like having a manager class that has an Update that is calling MyUpdate methods from our scripts instead of having each script implement its own Update method.
Besides that, now, we are not constrained by MonoBehaviours. A normal C# class can implement the proper interface and after registering a method, can have that method executed every time inside the game loop. Again the project on github has the NonMonobehaviour class that is subscribed to the new systems.
Thank you for reading and as always if you have any questions or comments you can use the comments section or contact me directly via the contact form or email, also if you don’t want to miss any of the new articles, you can always subscribe to my newsletter or the RSS feed.