James Kingston Clarke

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

#c# #godot #dev

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 async loadLevel 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);
    }
}

References