One Node to Spawn Them All: Stutter-Free Scene Streaming in Godot

Antony Qin, Two Robots Studios
One Node to Spawn Them All: Stutter-Free Scene Streaming in Godot

🥚 One Node to Spawn Them All

By Antony Qin, Lead Gameplay Engineer @ Two Robots

What started as a small testing efficiency has become the very foundation of the project.


Part 1: Help me test

Let's start with this Scuttler scene. It looks great and all, but kind of lonely. Is it too big? Is it too small? No idea, unless I add the Rig scene to the scene.

The Scuttler scene by itself — no sense of scale

With the Rig in, I can properly scale Scuttler to fit. But then I've got to remember to delete the Rig from this Scuttler model scene. It wouldn't do to have it also spawn in when spawning the Scuttler in game. Now, doing this every once in a while is alright, but there are so many warbots, each with their own shapes, sizes, and animations. Having to remember to add and delete some temporary scene every time gets tedious. (And if I just toggled visibility on it instead, well, then the parent scene got bigger and less efficient, due to having to load the model scene every time.)

The Rig scene added in — now Scuttler can be scaled to fit

My solution: the spawner scene.

The spawner node — a lightweight Node3D with a scene path and tool buttons

Doesn't look like much, right? But that's the point. It's a Node3D. Lightweight, basic. But it does contain a little extra: a scene path, configured in the inspector. With this, on a @tool script, the spawner scene is able to spawn a specific scene right there in the editor — I added an @export_tool_button so I can press it to spawn a particular scene only when needed. Best of all, it doesn't save to the scene.

To do this, after adding a node in the editor, simply do NOT set its owner:

self.add_child(spawned_scene)
# do NOT add this line after: spawned_scene.owner = get_tree().edited_scene_root

You can spawn something temporarily, forget about it, and the next time the scene is open, whatever scene got spawned is no longer there. Only the lightweight spawner node remains — no models, textures, or resources to pull in, so the cost of loading it is minimal.

Now I can spawn whatever scene I want temporarily!


Part 2: Replace EVERYTHING

I used spawner nodes for a while just like this, until I started feeling like parent scenes were taking too long to load. You see, the Scuttler model above used to be just a model amongst many, all underneath a "Warbot" scene. The Warbot scene was designed to handle spawning a specific warbot depending on what was happening in game. That meant every warbot model was loaded with the Warbot scene. I had set everything as an "InstancePlaceholder" in the inspector, which means that every warbot model is not instantiated during game.

Load as Placeholder — Godot's way of deferring instantiation

However, this still means that each warbot model is preloaded along with the main Warbot scene when it loads into game. A bit of a waste of resources if, during a game, some types of warbot models are never ever spawned. I also noticed a clear pattern of code used across multiple scripts that were spawning children InstancePlaceholder:

## Request a background load
ResourceLoader.load_threaded_request(scene_path)

## In _process, continually check progress
func _process(_delta : float) -> void:
    if ResourceLoader.load_threaded_get_status(scene_path) == ResourceLoader.ThreadLoadStatus.THREAD_LOAD_LOADED:
        instantiate_the_scene()

## Then finally instantiate the scene
func instantiate_the_scene():
    scene = InstancePlaceholder.create_instance(false, ResourceLoader.load_threaded_get(scene_path))

Of course, I updated the parent Warbot so that all the children warbot models are held in spawner nodes (no warbot models are preloaded along with the main Warbot, so only those used ever get loaded). But I also extended the spawner nodes. Now it's no longer just an editor-only helper node. It's used all over the project, and handles spawning things in the background, so specific scripts no longer need to implement this duplicated functionality.

Spawner nodes loading and despawning scenes in the background

Part 3: Wait your turn

Then came the time to update the ship experience. In the past, each room the player entered was bookended by a fade-to-black screen. A load screen, to hide the stutters as a large room (and many objects) were instantiated and added to the SceneTree. A new task was assigned: design wanted the player to roam between rooms seamlessly, WITHOUT a load screen in between. So how come the spawner nodes weren't working? Everything was loading in the background, and only instantiated once ready. See, that was the problem: the spawners were all fine and dandy, but too isolated. Each would fire and load in the background and instantiate and add itself to the SceneTree all at once, concurrently. Which, if all stacked on the same frame, would produce a hitch — a stutter. The fix? A "throttler".

The spawner nodes were extended so that they no longer load, instantiate, and add to the SceneTree themselves. Instead they submit their work in pieces to a global "throttler" auto-load. The "throttler" would then, one at a time, load scenes in the background, instantiate them, and add them to the SceneTree. It allots a fraction of each frame to doing so — so multiple things can potentially still spawn on the same frame — and goes to sleep if its work would go over the frame budget (minimize stutter), until the next fresh frame where it continues.

In essence, the "throttler" spreads out the work of all the spawners for a smoother gameplay experience. It's like, instead of shoving a cake in your face, you'd slice it up and shove each slice in your face instead. Yummy.


This post started life as a Slack message that ended with "not sure how long these blog posts should be, so this could be longer or shorter." It got longer.

Recent Posts