We are currently developing a dynamic level loading system in Unreal Engine and are facing a few technical challenges. Our system works by triggering level loading upon interaction with specific objects. Rather than loading in a binary fashion, the system gradually loads the environment. This is visually represented as a sphere contracting toward the player. This is accompanied by a post-process shader that we use to shift the visual tone.
While we’ve implemented a working version internally, we’re encountering two key issues:
Synchronisation Issues: The shader does not always stay in sync with the level loading progress.
Performance Bottlenecks: The system causes significant frame stutters, especially on lower-end systems.
Attached is a gif to showcase the functionality
Current Implementation
We have a persistent level and sub levels. We stream the level as soon as the scene transition is triggered. However everything in the sublevel is hidden. We have a sphere collider. When the scene transition is triggered, we start reducing the radius of this collider via a timeline. If the component is from the current level, it is hidden and its collision profile is changed (with the original stored). If it’s from a different level, it’s made visible. This is the loading process. For unloading if the component is from the current level, it is shown and its original collision profile is restored. If it’s from a different level, it is hidden and its collision is disabled.
The transition in the shader uses a sphere mask as the alpha input in a linear interpolation between two different shaders. Initially, the mask is extended beyond the level. When the player interacts with the trigger, the mask begins to shrink, revealing the second shader. The radius of the sphere mask is controlled by a Material Parameter Collection (MPC), allowing us to dynamically adjust the radius over time using a timeline when the trigger is activated.
Disclaimer: While I probably won’t remember the exact implementation specifics we had regarding this (the game released a few years ago), our project specifics probably wouldn’t help you much anyway, so I’ll stick to general ideas.
Approximating streaming speed (which you’re currently relying on) is basically impossible, so “does not always stay in sync” sounds like a pretty good result for what you currently have. You never know what else is going on on the client’s machine during streaming, it could take literally 10x as long as you expect.
For reference, our “seamless transitions” (for anything that required more than a second of loading) were “hubs” of sorts (even if the players wouldn’t exactly call them “hubs”) and we always avoided using the new level until it was completely loaded.
Lucky for us developers (in general, including you), the players don’t actually need the level to be streamed in on their request and shown as soon as it’s available, they just need it shown when they expect it! I’d strongly suggest adding an invisible collider around these interactable objects and triggering sublevel loading as soon as the collider is triggered. I’d also consider making them (the colliders) as big as you can afford, so if the entire room has only one “trigger” item → fill the room with the collider to minimize the chance the sublevel isn’t loaded in time. The tuning obviously depends on the requirements of your sublevels, but if tuned well you would also solve the majority of issue 2 by splitting/staggering loading into many smaller phases.
This is very project-specific, and I’m sure you already have Unreal Insights traces and/or hitch dumps giving you clues as to what is happening. Some general things to look out for (which you just have to catch and fix on a case-by-case basis):
your sublevels should aggressively avoid hard-referencing any heavy assets
initialization of any kind (be it BeginPlay or similar stacks) should be kept very lightweight, and deferred/staggered as much as you can in cases where initialization relies on heavy assets (as per the previous point)
unloading can also cause stutters! watch out for GC, which you can’t really control all that much