//rp.wtf

Lazy loading Godot scenes with InstancePlaceholder

Tue Aug 27 2024

Huh?

So imagine you have a monster of a level your player has to be loaded into. It has all kinds of cool stuff in it for the player to experience and discover; there's just one problem: it takes ages to load, and waiting sucks. Well, there's one way to make it faster—by not loading everything at once but loading parts only when we need them!

There are things that are most likely out of the players view from the start that we don't need to load right away, like enemies, foliage, or buildings.

You can probably imagine some ways you could manage that with Godot. Instead of having your optional thing in the scene from the start, when a certain event happens, you just load() it, then instantiate() it, then call add_child() on the level node with your new instance. But what if there was a less "manual" way?

Enter InstancePlaceholder

Godot comes with this nifty thing called InstancePlaceholder. What it does is, instead of loading a child scene right away, it loads a "marker" node in its place, and then all you need to do is call create_instance on it, and it will spawn that child scene instance right where it was supposed to go originally.

Using InstancePlaceholder has multiple advantages: the initial load of the scene goes considerably faster since you don't have to load everything at once, so we get to render things to the player faster. We load stuff dynamically, but in the editor we can see and work with the complete level, which makes it easy to iterate, and using InstancePlaceholder doesn't force you to change your workflow; you can design the level as you would normally and then save separate parts as child scene instances when you are closer to being done.

OK, how about a practical example?

Practical Placeholdery

Let's start a new Godot project, and we'll do something dead-simple and useless, but the main idea is to just show how it works. I'll post the project, so you can download it and don't have to follow along.

I made a simple setup with a Control node as the root, then added a VBoxContainer containing some text and a public domain image of a cute little rabbit. Saved all of that as main.tscn and set it as the main scene.

A screenshot of a simple godot 4 project with a control node hierarchy of a button, and a vboxcontainer with a label and a texturerect.

Bonifatius is a fluffy ball of burning fury!

The goal is to make the VBoxContainer part of the scene load only when we press the button. In an actual game, you'd use different logic to load a placeholder, like checking for a condition, e.g., "the player is about to go into this room; load it", user input, or maybe even something like a VisibleOnScreenNotifier2D signal.

First of all, we will save the VBoxContainer as a separate child scene. We can do so by just right-clicking it and clicking "Save Branch as Scene." Call the scene whatever you like; it literally does not matter as we won't be using the file path anywhere.

A screenshot of the context-menu of a Node with the Save Branch as Scene option highlighted.

Now we need to mark the VBoxContainer child scene as a placeholder. When you right-click it again, you will notice some new menu items, one of them being a "Load as Placeholder" checkbox. Tick it:

A screenshot of the context-menu of a child scene instance with the Load as Placeholder option highlighted.

After these steps, when you launch the main scene, you will notice that the text and the image no longer appear, even though you can see them in the editor. If the VBoxContainer node contained more than just a label and an image as its children, you'd also notice a decrease in the load time. That is because instead of the VBoxContainer scene, Godot loaded an InstancePlaceholder.

A screenshot of the remote debugger showing 3 nodes, a Control node, a button and an InstancePlaceholder node

When you check the remote scene tree view, you can see the InstancePlacholder in place of the VBoxContainer

And now we need to write a script for the button so it loads the actual VBoxContainer scene. Add a script to the root Control scene:

A screenshot of a selected Control node with a green circle around the Attach Script button

And replace it with the following contents:

extends Control

var v_box_container: VBoxContainer


func _ready() -> void:
    $Button.pressed.connect(_on_button_pressed)


func _on_button_pressed() -> void:
    v_box_container = $VBoxContainer.create_instance()

Now, when you launch the project and press the button, the text and the bunny show up! We've successfully deferred loading a part of our main scene.

The only line that really matters here is the create_instance() call. Which tells the InstancePlaceholder to load the child scene, the place of which it's put instead, and add it as a sibling next to itself. You can also tell it to replace itself by passing true as the first argument to create_instance(), but then you are robbing yourself of another nice upside of using instance placeholders: being able to delete the child scene and call create_instance() again.

Basically, you can save RAM by deleting a child scene created from a placeholder when it's not needed (e.g., it goes out of view) and instantiating it again when you need it again. There is no reason to keep it around once it is loaded if it is not used.

Using our example, it would be as simple as:

v_box_container.queue_free()
v_box_container = null

# some time later, by pressing the button again, we execute the same again ...

v_box_container = $VBoxContainer.create_instance()

Keep in mind that calls to create_instance() aren't indempotent, as in, every time you perform this call, an instance of the child scene will be added as a sibling to the InstancePlaceholder, so you need to take care not creating more instances than you need, but that can also work as a way to implement a mob spawner!

Basically, InstancePlaceholder has multiple applications, and I've shown you the most useless one, but hopefully that will spark some ideas on how you can improve the load times of your levels or other types of complex scenes.

You can download the project from here, but we are not done with this yet!

Background loading

Well, you might notice that if you start putting large child scenes as placeholders and load them later, you will notice the game choke up. That's quite understandable since, under the hood, Godot does something like load()-ing the child scene, instantiate()-ing it, and adding it to the active scene tree, and that all takes plenty of CPU cycles, especially if there's a lot to load.

So we're back to our original issue. Waiting still sucks. Now we're just doing some more of it a bit later instead of waiting a whole lot right away.

Threads to the rescue. So, how about we do the waiting without the game being locked up? All we need to do is let the main thread draw all the pretty things for us while we employ other threads to do background loading.

Thankfully, we don't need to do anything special to do threaded background loading. Godot provides the ResourceLoader singleton, which makes loading PackedScene files in the background pretty straightforward, we'll just need to perform a few adjustments to our InstancePlaceholder example. Open main.gd in the editor and replace its contents with the following:

extends Control

var v_box_container: VBoxContainer
var path: String

@onready var placeholder: InstancePlaceholder = $VBoxContainer


func _ready() -> void:
    set_process(false)
    path = placeholder.get_instance_path()
    $Button.pressed.connect(_on_button_pressed, CONNECT_ONE_SHOT)


func _process(_delta: float) -> void:
    var status: int = ResourceLoader.load_threaded_get_status(path)
    if status != ResourceLoader.ThreadLoadStatus.THREAD_LOAD_LOADED:
        return

    set_process(false)
    _create_instance()


func _on_button_pressed() -> void:
    ResourceLoader.load_threaded_request(path, "", true)
    set_process(true)


func _create_instance() -> void:
    v_box_container = placeholder.create_instance()

As you see, the script has grown a little bit. First of all, we have added a bit of state since we'll need to use that in a couple places—a reference to the InstancePlaceholder as well as a string path. In the _ready() function, we tell the node not to run its _process() function by calling set_process(false), and we store the path of the PackedScene replaced by the InstancePlaceholder in the path variable.

Now onto the interesting bits: when the button is pressed, instead of asking the placeholder to place an instance right away, which would result in the main thread being blocked for larger scenes, we call load_threaded_request of the ResourceLoader singleton. The first argument is the scene path, which we got from the InstancePlaceholder. It can be anything that you would ordinarily pass to load() or preload(). Then the type hint argument, which we just leave as empty—the default. And the third argument is the usage of subthreads for loading the scene, which is beneficial for scenes with more dependencies as those can be loaded in parallel, therefore reducing the loading time. The documentation mentions that using subthreads can cause slowdowns on the main thread, but in my personal experience I haven't really noticed a negative impact. But in case you do, just flip the third argument to false or just remove it, since false is the default.

Pressing the button also enables processing. So now onto the _process() function. In every tick of the engine, we ask ResourceLoader if our scene is loaded. We're sort of being like a child on a car ride, saying, "Are we there yet?" only really, really fast. And since this call isn't heavy on the CPU, the game just continues running while the scene is loaded in the background. As soon as the ResourceLoader tells us that the scene is loaded, we stop processing and call _create_instance().

In a usual scenario, we would just use ResourceLoader.load_threaded_get() to get our PackedScene so we can instantiate() it and add it to the scene tree, but we have the InstancePlaceholder for that, so all we do is, just like before, ask InstancePlaceholder to do all that for us by calling create_instance(). Only now this call returns faster since we have already put the PackedScene in the cache of Godot by using ResourceLoader. Basically, the benefit of "preloading" the scene is that the instance placeholder needs less time on the main thread for the instance creation since it doesn't need to load and parse the scene from disk; we did that part for it. This results in a smoother experience for the player as the main thread can just continue with regular game input processing and rendering instead of spending time on loading.

Conclusion

There are multiple things that we can improve here; for example, we aren't doing any error checking during the _process() function, and we could also use another "worker" thread that could monitor the status of the loading process instead of the _process() function.

Using threaded loading before instantiating an InstancePlaceholder is absolute overkill for displaying a picture after clicking a button, but I wanted to use the simplest possible example I could think of to just show you how to get this going. Now we rely on a hardcoded placeholder reference, but you can use your newfound knowledge to create a more dynamic system that can work with multiple instance placeholders when your levels grow in complexity, and you could benefit from loading only a small bit ahead of time and loading other bits on demand. What I'm trying to say is—build something cool! And when you do, tag me on X or mastodon!