This is a sort of stream-of-consciousness document explaining how world partition works under the hood.
This is not a general overview of world partition, nor is it an explanation of how to use world partition or terms used in the user-facing parts of it. See Epic's own docs for that - this is meant to cover the "undocumented" innards of world partition.
Top-Level Concepts
World partition at runtime is "just" normal level streaming, but with a ton of additional features built on top to make managing that streaming much easier. Being built on normal level streaming, actors are not loaded individually (that'd be slow) - instead actors are grouped based on the runtime grid and data layers they are placed into at edit-time and also based on what references they have to other actors.
Streaming
Runtime grids (defined in world settings) specify how actors get placed into grid cells, and each cell results in one or more streaming levels being created during cook (and PIE!) for use at runtime. Streaming levels are also generated for combinations of runtime data layers that overlap within a single cell, meaning multiple streaming levels may generate for one physical location if there are actors in different runtime grids or data layers. As such, having lots of "overlapping" data layers and grids is harmful to streaming performance. On the flip side, a space with no actors in a grid or data layer will not generate a streaming cell and has no overhead - grids and data layers for the most part only have streaming costs in locations that contain actors.
Actor clusters are groups of actors that have hard references to each other and thus must always be loaded within the same streaming level in order to guarantee those references stay intact.
Actors and actor clusters larger than a cell or too far across multiple cell boundaries may be "promoted" into a higher grid level, which is a cell that encompasses a larger area. As such, even within a single runtime grid you can have multiple streaming levels overlapping each other due to (among other reasons) grid promotion.
Soft references do not have this behavior since they do not require their target object to be loaded at the same time.
Runtime grids are defined as part of the world partition runtime hash, which is a class that decides the layout of the runtime grid through the use of a spatial hash. Spatial hashes are selected as part of world partition settings - the current default as of the time of writing is the LH Grid, which attempts to place actors into a regular grid but will expand/shrink the boundaries of cells if the cell would be slightly over- or under-sized.
The spatial hash is effectively a function that takes any location in the world and then outputs the name of the streaming cell that corresponds to that location. At cook time this is used to generate streaming levels, at runtime it is used to decide which levels to stream for a particular location.
The way that the engine decides what locations to feed into the streaming system at runtime is controlled by two main APIs: streaming sources and data layers. Streaming sources allow objects like the player controller to register themselves as a location that the world should stream from, including options such as the radius and target runtime grid(s) to have some control over what exactly gets streamed in.
Runtime data layers are a complimentary method which allow controlling groups of actors to load and unload as defined in the editor. Actors on a data layer still obey spatial loading settings and will only load if non-spatially-loaded or if a streaming source tells them to. Editor data layers only exist within the editor and are primarily an organizational tool, though it is possible to build other editor tools on top of them (such as actor filters,).
External data layers (EDLs) are data layers generally registered by game feature plugins. Content placed into an EDL is stored inside the owning plugin's content folder instead of in the owning level's content folder. This allows completely unregistering the content inside the EDL from the engine - you can simply disable/not cook the owning plugin and the EDL's content won't be packaged. This is particularly useful for DLCs or for live-service content like Fortnite uses (for example, a seasonal event that won't be going out in the same release as the map it takes place on, or one that will need to "inject" content for a limited period of time between client updates).
Content bundles are effectively an older implementation of EDLs with less features and a worse API and are considered legacy.
Level Instances
Level instances are levels that have been placed into a world like a normal actor - sort of like a sublevel, but you can have multiple copies of the same level instance scattered across the world. At runtime, only standalone level instances exist - embedded level instances are broken apart at cook time (and thus have no runtime representation or overhead, aside from the actors broken out of them), while standalone instances stream as a unit even at runtime. This can have some benefits, like being able to manually control the streaming of an entire level instance.
This is a pretty major limitation. It is possible to modify the the engine to support both of these scenarios (and I have successfully done so for a project), but it requires some very complex modifications in very easy to break locations. See the end of this post for an overview of what is necessary to fix this, though note that it is not an easy task.
Sub-world partitions are standalone level instances that point to a world partition level that has streaming enabled. This lets you define entirely separate streaming settings for that level instance and have it stream just like it would if you were loaded into it directly. Note that this can be more expensive, and that if the level instance itself is not streamed then nothing within it will be either. That includes non-spatially-loaded actors - they will only load if the outer level instance is loaded!
In the Editor
The editor does not operate on the same concepts as runtime does as the runtime representation of the world requires having cooked all actors into streaming levels. Instead, the editor needs to deal with each actor individually and has an entire data model for working with actors without having to actually load them.
The entire system is built on top of the asset registry, where metadata about each actor is serialized as part of asset registry tags. Asset registry tags do not require explicit loading and thus provide a fast way for the editor to learn important information about an actor such as its label, position, bounds, class, etc - everything you need to be able to display the actor in the outliner and decide whether to load it on the world partition minimap.
Note that this reliance on the asset registry means that sometimes world partition data in the editor can become stale - this usually results in "ghost" actors appearing in the outliner that can't actually be loaded. This isn't a big deal and can be ignored, but clearing your asset registry cache can sometimes help resolve it.
Descriptors
Actor descriptors (FWorldPartitionActorDesc) contain all data about an actor that is needed by the editor when that actor hasn't yet been loaded. These are serialized and then transformed into a base64-encoded string that is saved into an asset registry tag on the uasset that contains the actor it represents. The default descriptor type includes the actor label, runtime and editor bounds, data layer assignments, content bundles, and other data required by the editor.
It is possible to create your own custom actor descriptor subclass to allow storing out whatever editor-only data you desire to ingest in your own tools. This involves creating a subclass of FWorldPartitionActorDesc and then implementing CreateClassActorDesc on the actor to return your new actor descriptor.
Actor Descriptor Containers (UActorDescContainer) contain a list of actor descriptors that generally are fed to streaming generation as a unit - for example, all of the actors in a basic world partition level will be placed into a single top-level container that represents that level. Every external data layer in that level will then create an additional container to store all of the actors within that EDL, since those actors are stored and processed from a separate location.
Containers are not "saved" anywhere and cannot store any persistent data - they are created on the fly by the editor from each directory that world partition actors are stored in. You can generally think of a container as an editor representation of a single level's directory in the __ExternalActors__ folder of the project or a plugin's content directory.
It is possible to make a custom container subclass but this is unusual - currently the only additional implementation of a container in the engine is for property overrides for level instances (a feature that Epic has sadly abandoned because of SceneGraph... which is something that does not yet exist for anyone outside of Fortnite and is thus not particularly useful).
__ExternalActors__ is where one-file-per-actor (OFPA) assets get stored - every uasset in this folder is a single actor (plans for one-file-multiple-actors for PCG use notwithstanding). The initial directories under this folder are named based on the path to the level the actors belong to. Digging deep enough into these folders, you'll eventually encounter more folders with seemingly random letters and numbers, and the assets themselves will also have random names. These names are not significant and are randomly generated just to avoid version control conflicts - if the filenames were based on actor name or location then you'd hit a conflict just because two people placed actors in a similar way.The
__ExternalObjects__ directory is effectively the same, but it is used for objects referenced by a level that aren't actors.Additionally these folders can have certain "special" folders at the top level - for example, there will be an
EDL folder for the storage of all actors and objects belonging to an external data layer owned by that plugin.Actor descriptors represent an actor saved to disk, but they do not represent the actual placement of that actor into a level - for that we have actor instance descriptors (FWorldPartitionActorDescInstance). These are not saved to disk but instead are basically a handle to an actor descriptor along with information about the placement of that actor and what level owns it. This is important because embedded level instances can result in the same on-disk actor being instanced multiple times into the same level. Actor instance descriptors cannot be subclassed and do not carry any "custom" data.
Actor descriptor container instances (UActorDescContainerInstancecontain a list of actor instance descriptors, a reference to the container that the original descriptors are in, and a list of sub-container instances.
Container instances are used for a variety of reasons - a basic level may only have a single container instance and a single container, but every level instance will result in a new container instance. Furthermore, actor filters on a level instance may change which actor descriptor instances get created for a container instance.
Container instances are hierarchical, so one container instance can contain another container instance. This is how the hierarchy of nested level instances works - you have the outermost level instance parented to the level's root container instance, and then every level instance inside it is a sub-container, creating a hierarchy that mimics the placement of the level instances themselves.
Custom container instance subclasses are also possible by subclassing UActorDescContainerInstance, though this is again somewhat unusual. Container instances aside from the root are created through actor descriptors (see IsChildContainerInstance and related functions) - thus any actor descriptor can in theory provide an additional container instance to the level.
Streaming Generation
Streaming generation is the process in which the editor takes the above "unloaded" data model for the world and generates a list of streaming levels (and what goes in them) for use at runtime. This process is used not only during cooking for saving out the streaming level packages, but also to generate the world on the fly in PIE.
Streaming generation is an incredibly complex topic in and of itself, but it boils down to the following steps:
- Collect the list of container instances that will generate streaming levels
- Filter actors within each container (remove editor-only actors, for example)
- Cluster actors based on how big they are, what runtime grid they are in, what they hard-reference, and what data layers they belong to
- Feed the sets of actor clusters to the configured spatial hashes to decide on the list of streaming levels to generate and which actors fit in each.
This process can be tested in-editor with the command wp.Editor.DumpStreamingGenerationLog which will run streaming generation on the active level and output a log of the results to Saved/Logs/WorldPartition. Additionally, doing anything that requires streaming generation to run (such as entering PIE or cooking a world partition level) will also create a streaming generation log in that folder to aid in debugging.
Actors with Is Spatially Loaded disabled and which have no runtime data layers assigned will be saved into the outer level directly, just like an actor in a legacy sublevel-based world. This guarantees that these actors are loaded at all times and simplifies their loading process.
Non-spatially-loaded actors on runtime data layers will still get extracted to separate streaming levels and will no longer provide any guarantees about loading behavior since they stream in like everything else.
Runtime Cell Transformers are used to apply additional transformations to the generation of streaming levels. These are classes with simple "(Pre/Post)Transform" methods that can be added to world settings - every streaming level generated is fed into the transform methods and the transformer class can opt to modify the level or run any other operation you want. A "simple" example is provided in the engine in the form of an ISM transformer that finds all actors using the same mesh in the same streaming cell, and then replaces them with a single ISM component. The FastGeometryStreaming plugin also uses a transformer in order to replace actors with an optimized representation.
More about External Data Layers
Actors can be placed into a single EDL at a time but may be placed into other non-external-data-layers.
EDLs must be registered through the use of a game feature action (or custom code calling into the EDL subsystem) before they can be used. Actors placed into an EDL will be stored into the content directory of the plugin that registered the EDL as noted previously, and thus the generates streaming cells for the EDL will also be stored as part of that plugin to be injected at runtime when the EDL is registered.
EDLs work by creating a new container for each EDL on a map, with the container's path pointed at the content directory of the plugin that registered the EDL. The result is that streaming generation places any generated levels inside the plugin that owns the EDL instead of whatever owns the outer world partition level.
External Streaming Objects
The output of streaming generation for an EDL, aside from the streaming level packages, is also an external streaming object which stores metadata about each runtime streaming cell and the streaming levels that were created for them. At runtime, external streaming objects are injected into a world partition and are what allow the EDL's content to be loaded by the outer world partition.
Level Instance Limitations
As mentioned earlier, level instances are not compatible with EDLs. They can't be placed inside EDLs, and they can't contain EDLs (well they can, but those EDLs won't work). Also, content bundles have the same limitation and won't work either. This is a pretty massive problem if you need to ship any kind of live service/scheduled content on a common level over time. I'm not sure how Epic avoided implementing these features - my understanding is that they have pretty heavy uses of both level instances and EDLs/content bundles, though I think I've heard that the Fortnite branch of the engine has a heavily customized variant of the content bundle system that maybe addresses the issue.
Anyway, I've managed to fix both of these issues though the changes are fairly complex and deep in world partition. I can't post the full set of changes, but I can briefly describe how it works. This is NOT for the faint of heart - it involves a large number of changes to low level parts of world partition, and this is meant to be more of an explanation of how it works in theory rather than instructional on how to do it yourself.
First, a primer on how EDLs work. When you add an EDL to a level it first spawns a new AWorldDataLayers actor. When you make a world partition level you always get one of these actors by default and it is used to store and configure your list of data layers. This extra EDL actor is used to configure the EDL itself along with any sub-layers, and is stored within the EDL's external content path.
When an EDL is injected into a world, the data layer manager (UDataLayerManager) searches the EDL's content path for this actor (via its actor descriptor) and then attaches a container instance pointing to the rest of the EDL's content for that level. This results in the EDL's contained actors being made known to the level's world partition, and also segments the content so it can be cooked to the EDL's parent plugin instead of the level it was placed in.
(Container Instance) Root
|- AWorldDataLayers (default instance)
|- AWorldDataLayers: EDL_MyLayer
|-- (Container Instance) EDL_MyLayer
The first problem with level instances - not being able to place them inside EDLs - is I believe just a small oversight. When the data layer manager creates a container instance for an EDL, it does not ask world partition to create the full hierarchy of containers below that instance (done by setting UActorDescContainerInstance::FInitializeParams::bCreateContainerInstanceHierarchy to true when creating the container instance). I believe there are some other small checks/ensures that need to be removed or fixed up as a result of this change, but otherwise this is the fix.
The second problem is much more complicated to fix. It stems from two rules around how the hierarchy of container instances works:
- All container instances aside from the root must have a "parent" actor descriptor (this descriptor doesn't necessarily have to create the container instance, but the container instance must belong to the actor).
- A single actor can have at most one child container instance.
So a level instance actor descriptor creates a single child container instance that contains the referenced level's actor instance descriptors. But that level instance actor could not, for example, spawn a second level by itself since it would not be able to create another container instance.
Let's illustrate a small hierarchy of containers:
(Container Instance) Root
|- ALevelInstanceActor: MyLevel
|-- (Container Instance) MyLevel
Here's a simple hierarchy showing the root container, a level instance actor, and the container instance it created. This is all allowed. Now, if we wanted to have an EDL inside the level instance, we'd need to add a container to the level instance actor to store any actors inside that level that are in the EDL.
(Container Instance) Root
|- ALevelInstanceActor: MyLevel
|-- (Container Instance) MyLevel
|-- (Container Instance) EDL_MyLayer <--
Except this isn't allowed - now there are two container instances parented to a single actor!
The solution is to try to mimic what the external data layer manager already does - use the EDL's AWorldDataLayers as the parent of the container. Something like this:
(Container Instance) Root
|- ALevelInstanceActor: MyLevel
|-- (Container Instance) MyLevel
|--- AWorldDataLayers: EDL_MyLayer <--
|---- (Container Instance) EDL_MyLayer
Doing so is a bit complicated - the data layer manager needs to be updated to listen for any container instance being registered (via UWorldPartition::OnActorDescContainerInstanceRegistered), then iterate through injected EDLs and run asset registry searches to find an actor descriptor for the EDL's AWorldDataLayers for that container's level. If one doesn't exist, then ignore it. If one does, add the actor descriptor to the incoming container instance and then add a container instance pointing at the EDL's content to that AWorldDataLayers actor descriptor.
That's the gist, but there are also a whole list of other problems to fix that are all complex in their own right too:
- EDLs won't act properly in the data layer outliner if under a level instance. This is due to a level instance's external data layer manager not injecting data layers for that level - the fix is to let the manager inject EDLs for the level instance itself. Note that the EDL manager for level instances should not listen for further container instances being registered to inject EDL container instances into - only the outermost manager should handle that process to avoid multiple trying to register at once.
- Sequencer bindings will break if bindings reference actors in EDLs inside level instances. This can be fixed by adding a new container id to
FWorldPartitionResolveDataand filling it out inFWorldPartitionLevelHelper::LoadActorsInternal- this can then be used from withinFActorLocatorFragment::Resolve(viaFActorLocatorFragmentResolveParameterwhich also needs a new field to store the EDL container) to calculate the proper path to the actor. This also requires plumbing through the parent container id as part ofFWorldPartitionRuntimeCellObjectMappingto set the resolve data properly in the first place. - Soft references into/out of EDLs will end up mangled and not resolve properly. Similar to the sequencer issue but for any soft references rather than just sequencer bindings.
FWorldPartitionRuntimeContainerResolver::BuildContainerIDToEditorPathMapmust be updated to calculate the proper editor path if the given container instance is an instanced EDL. This requires plumbing through whether a container is an instanced EDL as part ofFWorldPartitionRuntimeContainerInstance, which can have its value set inFWorldPartitionStreamingGenerator::CreateContainerResolver
If somehow you manage to do all of the above, you'll have EDLs working inside of level instances and level instances working inside of EDLs.
The End
Bye!