Creating an asyncronous resource loading system in Godot
Learning how to create an asyncronous resource loading system in Godot, and all the nuances that come with it
Written by James Clarke on
The problem
When developing a game, you will often need to load resources and display some sort of loading icon to the user. This loading icon is usually a spinner, or a loading bar etc.
An example scenario: loading up a level
Say you have a button and when the user presses that, you want to start a particular level. In this trivial example below we can break this down into 3 steps.
- Add the player node to the scene tree
- Load up the level from disk
- Add the level node to the scene tree
Example code below
void _Ready(){
// when the "start" button is pressed, load the level
GetNode<Button>("start").Connect("pressed", new Callable(this, MethodName.loadLevel));
}
void loadLevel(){
AddChild(new Player());
var level = new LevelLoader().LoadLevelFromJSONFile("level.json");
AddChild(level);
}
If for example level.json
is fairly large, then this could take a few seconds;
thus we want to show some sort of loading spinner to the player whilst
loadLevel
is being called.
Adding a loading spinner… the niave way
Lets create a basic spinner node which, when added to the scene tree, just rotates around.
partial class Spinner : TextureRect {
void _Process(delta double){
Rotate(delta*SPEED);
}
}
Now when the user presses start
, we can start spinning!
void _Ready(){
// when the "start" button is pressed, load the level
GetNode<Button>("start").Connect("pressed", new Callable(this, MethodName.loadLevel));
}
void loadLevel(){
// start spinning
var spinner = new Spinner();
AddChild(spinner);
// do a load of work
AddChild(new Player());
var level = new LevelLoader().LoadLevelFromJSONFile("level.json");
AddChild(level);
// remove the spinner
spinner.QueueFree();
}
However… if you run this you’ll find that the spinner will suddenly stop spinning for a second, and then resume. Why is that? Well its to do with… blocking code and threads.
Blocking code, and threads in Godot
In the above example, LoadLevelFromJSONFile
is blocking; we have to wait for
it to complete before we carry on executing code in our function. A particularly
heavy task like disk IO can be noticable to a human. So, we have some code that
takes a while to run, why does this stop the spinner though?
You would think that, because the spinner is a seperate node, it would just update independently. But thats not the case.
Every single update to any node in the scene tree in Godot is all done on the main thread, one after the other.
This means when we call LoadLevelFromJSONFile
, we are blocking any other updates to every single node
in the scene tree from running; hence the spinner doesn’t spin.
Introducing async await
Okay… other games have loading spinners that don’t stop (well most of the time), so how can we do it?
We can use async await!
If you don’t know how async await works in C# under the hood (don’t worry, it’s fiddly), then this is how it works in a nutshell.
When you declare a method as async
, it essentially means that the function can
be called & resumed later on. This means you can call the method, go and do some
other work, and then come back and resume right where you left off.
The below example shows the await
keyword yielding control back to the callFoo
function whilst its waiting for Task.Delay(1000)
to complete.
async Task foo(){
while(true){
await Task.Delay(1000);
GD.Print("Foo is running...");
}
}
void callFoo(){
foo();
GD.Print("Foo is now running in the background and will continue running");
}
However, if we instead await
foo, then we will block and wait forever until its done to carry on.
Note that we have to mark callFoo
as async
to use the await
keyword.
async Task foo(){
while(true){
await Task.Delay(1000);
GD.Print("Foo is running...");
}
}
async Task callFoo(){
await foo();
// the below code will never run
GD.Print("Foo is now running in the background and will continue running");
}
A nuance here is that the CLR (Common Language Runtime) doesn’t nessescarily start the async work it on another thread, the CLR will decide to do so or not, what matters is that we can start & resume functions.
Using Task.Run & async await to load the level
So, using our new async await knowledge, we can finally do some magic here. What we can now do is
- Load up the spinner into the scene tree & start spinning
- Spawn a task on another thread to do all the level loading logic
await
this task, meaning the Godot engine is free to carry on doing other stuff and resume this asyncloadLevel
function later- Once the level is loaded, destroy the spinner
async Task loadLevel(){
var spinner = new Spinner();
AddChild(spinner);
await Task.Run(()=>{
AddChild(new Player());
var level = new LevelLoader().LoadLevelFromJSONFile("level.json");
AddChild(level);
});
spinner.QueueFree();
}
A thing to note here is Task.Run
does run on another thread,
meaning our reading from disk is truly parralel here from an application perspective.
However… if you run the above you may encounter an error.
Adding children to a node inside the SceneTree is only allowed from the main thread. Use call_deferred
Using async to do non scene tree work
Remember, we cant modify the scene tree from outside the main thread.
To solve this, we need to move all of our AddChild
calls out of the task and
back into the blocking code. Unfortunately this is just a sacrifice that you
have to make here - scene tree modifications will block.
async Task loadLevel(){
var spinner = new Spinner();
AddChild(spinner);
AddChild(new Player());
var level = await Task.Run(()=>{
return new LevelLoader().LoadLevelFromJSONFile("level.json");
});
AddChild(level);
spinner.QueueFree();
}
Now we have a working loading spinner!
As a side note: to get around this small performance issue you can call
CallDeffered
to run scene tree modiciations concurrently, but you sacrifice
order. In this case, we want to guarantee order here when loading up the level.
Could we improve our spinner code?
So after building that working spinner I wanted to try to see if I could use a pattern in Python, called context managers.
Essentially with these you can run code at the start of some procedure, and at the end, and encapsulate all that logic within an object. They are designed for resource management, i.e. you can open and close a file and within that, do some operations on the file safely.
For example, the following will load a file, print the file object, and then implicitly close the file.
with open("foo.txt", r) as file:
print(file)
All the file opening & closing logic is hidden to the developer.
… What if we could do the same with spinners in our game?
Custom context manager
We can use the IDisposable
type! The point of IDisposable
is
to manage resources, a bit like the file example above. However, we can use this
to create a spinner context manager to automatically create/destroy the spinner.
public class LoadingSpinnerContext : IDisposable
{
private bool isDisposed = false;
private Node sceneTree;
private Node loadingSpinner;
public LoadingSpinnerContext(Node sceneTree)
{
this.sceneTree = sceneTree;
loadingSpinner = (LoadingSpinnerController)GD.Load<PackedScene>("res://prefabs/ui/Loading.tscn").Instantiate();
sceneTree.GetNode("/CanvasLayer").AddChild(loadingSpinner);
}
public void Dispose()
{
Dispose(true);
}
protected virtual void Dispose(bool disposing)
{
if (isDisposed)
{
return;
}
sceneTree.GetNode<CanvasLayer>("/CanvasLayer").RemoveChild(loadingSpinner);
isDisposed = true;
}
}
We can then use this like below!
async Task loadLevel(){
// the spinner is created here
using (new LoadingSpinnerContext(this)){
AddChild(new Player());
// we yield control back to Godot to process the scene tree
// whilst the level is being loaded from disk
var level = await Task.Run(()=>{
return new LevelLoader().LoadLevelFromJSONFile("level.json");
});
AddChild(level);
}
// the spinner is implicitly destroyed here
}
Now we can easily add a loading spinner to our game which works properly & doesn’t get blocked by the main thread!
Bonus: Adding a status update to the spinner
I wanted to try adding a little label next to the spinner which tells the user
what is actually going on… so I added a Label to the spinner node and added
a method on the LoadingSpinnerContext
class to print the status update.
public void UpdateStatus(string message){
GD.Print($"LoadingSpinnerContext: {message}");
loadingSpinner.GetNode<Label>("StatusLabel").Text = message;
}
And then you can use it like below
async Task loadLevel(){
using (var spinner = new LoadingSpinnerContext(this)){
spinner.UpdateStatus("Adding player...");
AddChild(new Player());
spinner.UpdateStatus("Loading level from disk...");
var level = await Task.Run(()=>{
return new LevelLoader().LoadLevelFromJSONFile("level.json");
});
spinner.UpdateStatus("Adding level to game...");
AddChild(level);
}
}