Scene Management in Unity

We're back with another Code Review, in this series we look at actual code I write for actual video game productions. And today, we're looking at Scene Management. I made a cool system to help manage scenes in Unity, and I think it's pretty neat we're going to check it out.

General Architecture

Unity relies heavily on the concept of scenes, whether you use them as stages, or regions in a game, you will switch between scenes using the Unity SceneManager, you can also load multiple scenes at once using Additive mode, this way you can have the UI in a separate scene for instance, because it's common to all scenes.

Our game has a pretty standard setup, we have multiple scenes for multiple regions, the home of the player, the village, the docks, other areas of the world, and we have a Common scene with all the UI, important systems, audio stuff, whatever is common to all scenes. However, since we have gorgeous 2D graphics, every scene is heavy, and when switching scenes, loading times can be quite long. Which, as a player, is annoying not gonna lie, so we'll want to try and avoid that as much as possible.

The way we do this is we load every scene in Additive mode. This way, once a scene is loaded and we exit the area, we simply disable the contents of the scene, and when we reenter it later we enable it again. As a result the initialization logic of a scene happens in the OnEnable method, as the behavior must be the same whether you're enabling a scene or loading it fresh.

To keep track of scenes, our own SceneManager takes care of loading the scenes (calling the Unity manager under the hood) and keeps track of what scene has been loaded. This way when we want to Switch scenes, the manager can choose to either load the scene, or simply enable it if it was already loaded.

Focus on the scene asset

Scenes are referenced using a handy dandy ScriptableObject called SceneAsset. A scene asset represents a scene, and that's how we reference it when we want to, say, teleport the player to a scene.

So what's in the scene asset? Well first of all, a reference to the actual Unity scene, I use this very useful script that creates a scene reference field and tells you whether it's in the build or not. The one I use isn't maintained anymore but it still works, and there are plenty others on GitHub as well.

Along with that we have a Weight amount, we'll get to that in a bit, and a list of Neighbours scenes, for instance from the docks you can go to the village, from the village you can go to the forest or the player's home, etc etc. And we'll get to see how that's useful in a sec as well.

Basically, think of the SceneAsset as the metadata of the scene, it's useful to handle scenes and their metadata without actually having to load them to know what's inside. In the future more data will be added, such as for instance the sound banks used by the scene.

Focus on the loading process

So in order to enable or disable an entire scene at once, every scene has a single root object. This object is always active but when enabling or disabling a scene we will toggle its children.

And this object has a SceneRoot component which helps with various things, such as loading the Common scene if it's not loaded, this way I can start the game in the editor from any scene without worrying about loading the Common scene.

It also keep tracks of which of its children is actually enabled: let's say the player unlocks a door and we want to keep this door object disabled. When entering the scene, if the SceneRoot enables every of its children it will re-enable the door, which is not what we want. So upon disabling the scene, the SceneRoot will make a list of already disabled objects, to make sure not to enable them next time we enter the scene.

public IEnumerator Load(Ref<SceneRoot> rootWrapper)
{
    // Wait for potential pre-loading to be finished
    while (IsPreloading)
    {
        yield return null;
    }

    if (m_loadedRoot == null) // Check if already loaded
    {
        Debug.Log($"[Scene] Loading {name}");
        m_loadingOperation = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync(m_scene, UnityEngine.SceneManagement.LoadSceneMode.Additive);

        while (!m_loadingOperation.isDone)
        {
            yield return null;
        }

        m_loadedRoot = FindRoot(); // once loaded, find the root of the scene to link it to the asset
        m_loadingOperation = null;
    }

    rootWrapper.value = m_loadedRoot; // Return the root of the scene

    yield break;
}

So how does loading happen, well when we want to switch to a new scene, we will load the target scene first, obviously. The loading function is in the SceneAsset, which first checks if the scene is preloading, we'll get to that later, and then checks if the scene is already loaded by checking if it is aware of any scene root object being present for this scene, if it isn't loaded we'll call the Unity LoadScene function and wait for loading to complete, then return the scene root.

Back to our SceneManager we just have to disable the scene we just exited, and enable the new scene, whether it was just loaded or was already in cache.

public IEnumerator Switch(SceneAsset target)
{
    Debug.Assert(!m_switching, "[Scene] Switch is already in progress.");
    if (m_switching) yield break;
    m_switching = true;

    // Load target scene
    Ref<SceneRoot> rootWrapper = new Ref<SceneRoot>();
    yield return LoadScene(target, rootWrapper, false);
    var newSceneRoot = rootWrapper.value;

    // Proper switch
    CurrentScene.SetSceneActive(false);
    newSceneRoot.SetSceneActive(true);

    CurrentScene = newSceneRoot;

    UnloadUnusedScenes();

    StartCoroutine(PreLoadNeighbours());

    m_switching = false;
    yield break;
}

Budget

Now as I said we load scenes and disable them when we don't need them, this way they are already loaded next time we want to visit them. But obviously, if we never unload scenes, at some point we will run out of memory, especially on lower end hardware.

That's where the Budget system kicks in.

I mentioned every scène has a Weight field, this is actually a rough estimate of the weight of the scene in RAM, in megabytes. In the SceneManager we set a total budget for scenes, and we will keep loading scenes additively as long as we are within budget. Once we reach the limit, if we want to load a new scene, we will first unload as many scenes as needed to free up enough space for the new scene to load.

The simple way to know which scene to unload is to maintain a history of what scenes have been used. Every time we enable a scene, we move it to the front of the queue, and when unloading we remove the oldest scenes starting from the back the queue.

This heuristic is not optimal, imagine a case where the player leaves home, travels through multiple regions, then wants to teleport back home, by then the Home scene might have been unloaded. What would be better is to define a sort of priority or rating based on frequency of use, to make sure we keep the most frequently visited scenes in cache.

But anyway that's how the Budget system works, and it has additional benefits, for instance the total budget limit can be set to different values based on the platform, as there isn't the same amount of RAM on, say, the Nintendo Switch, than on a more recent console. We could even imagine setting the budget dynamically on PC based on the player's hardware.

Another thing we can do is to pre-load scenes. As I said, every SceneAsset has a list of neighbouring scenes, so we could pre-load them as long as we stay within budget. This way by the time we actually get to the "door" to the neighbour scene, it might already be loaded or almost loaded.

Preloading tangent

Pre-loading is very cool, or rather it would be, but this feature is disabled right now. Let's go on a little tangent to explain why.

Profiling Scene Pre-loading

So when loading scenes there's a few spikes that freeze the game, like maybe 3 or 4 frames that take around 50ms. And it's not a problem while switching scenes, at worse the loading spinning icon thing just stutters. But preloading happens during gameplay and that's unacceptable.

Investigating that lead to discovering the CPU is waiting on the GPU to... render the frame (CPU is locked in Gfx.WaitForPresentOnGfxThread). Which is weird, because there's nothing to render yet as the new scene is disabled. So I checked the render thread, and it's actually uploading textures to the GPU, using Gfx.UploadTextureData. Since we have huge background textures and they haven't been optimized at all that's no surprise.

There is a feature in Unity to avoid this, the solution is to set the "Async Asset Upload" settings in the Quality settings. You can set the size of the upload buffer asyncUploadBufferSize but really you should set it to the max texture size because if Unity encounters a texture larger than the config it will reallocate the buffer. You can also set asyncUploadTimeSlice which is the number of milliseconds we allow the uploading of textures to take per frame. So that's cool right? Wrong.

Setting it to a low value (the default is 2ms) did not change anything.

My theory is this setting is here to allow multiple uploads per frame, like after a first upload we'll check if we still have time and start a second upload. But if a single texture takes 50ms to load, it will take 50ms it's not going to be split over multiple frames. We're kinda stuck actually, if we can't split the upload of a single texture and we have very large textures, they will each create a spike.

And that sucks.

The only thing we can do is optimize the texture. Right now since we're in development and I don't really care about stutters or loading times, all images are uncompressed and don't have a max size. In the future I will need to bring them down in size while compromising the least on quality, and for this we have a bunch of ways to optimize a texture, but that is planned for another episode of Code Review

End of tangent

All that remains is a way to measure the weight of a scene, the way I do this is by building a project with just an empty scene and the scene we want to measure. In the empty scene I take a snapshot of memory usage using the memory profiler, then switch to the target scene and take a second snapshot. The difference between the two is the weight of the scene.

In the future I hope I can automate this process by having a CI pipeline that loops on every scene, builds the measuring build, launches it, takes the snapshots, reads the difference and sets the value of the SceneAsset weight automatically, but that's a very low priority feature considering the other stuff that needs to be done for this game.

Now of course there is more to RAM than just loading scenes, and the total budget mustn't be equal to the total amount of RAM available, to account for dynamic objects that will be created or instanced.

Additional features

So during loading the game fades to black, right, and it fades back in when the scene is loaded. But a scene being loaded on the Unity side doesn't necessarily mean it's initialized on our side, there is some additional stuff we want to do like runtime navmesh generation, or making sure other resources are ready before proceeding.

So in the Awake function, objects in the scene can register themselves in the scene root, as being objects that take a while to initialize. The scene root keeps a list of those IInitializationObservers and during a scene switch, we will wait for all of them to be ready before removing the loading screen.

public bool Initialized
{
    get
    {
        bool initialized = true;
        foreach (var item in InitializationObservers)
        {
            initialized &= item.Initialized;
        }
        return initialized;
    }

}

Future and Conclusion

That's it I think for scene management, let me know what you think in the comments section of the video and how you handle scenes and loading times!

In the future I will make a similar system for Addressable Assets, with a budget of its own, as a way to manually keep track of AAs and to load a bundle exactly once, with a reference counter to know when it's safe to unload it.

You can grab the source code for this Scene Management tool on the Patreon and use it in your project, there's also another neat little tool for navigating scenes as a Patreon exclusive.

See you soon-ish and have a good one!

Music Credits