Adopting Unreal Engine Asset Manager for Asynchronous Asset Loading: An Architecture Decision
A concise architecture note on adopting Unreal Engine's Asset Manager for non-blocking asset streaming, covering requirements, minimal implementation, trust boundaries, operational checks, failure modes, and redesign triggers.
13 Oct 2026, 20:04 UTC

Requirements
Our gameplay team needed to eliminate frame-rate stalls that occurred when large textures, meshes, or audio banks were loaded on the Game Thread during level transitions. The constraints were:
- Zero blocking on the Game Thread for any asset larger than 4 MiB.
- Deterministic load ordering so that critical assets (player character, UI) appear before optional ones (background props).
- Automatic cleanup when a level is unloaded to avoid memory growth across sessions.
- Minimal code churn – the existing codebase already uses
TSoftObjectPtrfor asset references.
Smallest Suitable Design
The Asset Manager, available in UE4 and UE5, satisfies these constraints with a thin wrapper around the engine's FStreamableManager. The design consists of three pieces:
- Primary asset registration – each loadable asset is assigned to a primary asset type (e.g.,
GameplayTexture) via Project Settings → Asset Manager → Primary Asset Types. This centralises metadata and enables soft-pointer resolution without hard-coded paths. - Load request helper – a single C++ function that takes a
TSoftObjectPtr<UObject>, a priority value, and stores the resultingTSharedPtr<FStreamableHandle>. The helper forwards the request toUAssetManager::LoadPrimaryAsset. - Unload policy – when a level ends, the subsystem releases the stored handles or calls
UAssetManager::UnloadPrimaryAssetfor the relevant IDs. The engine's garbage collector then reclaims memory once no other references exist.
No new manager classes, custom thread pools, or changes to the asset pipeline are required.
Concrete Example – Texture Loader
// MyTextureLoader.h
#pragma once
#include "CoreMinimal.h"
#include "Engine/AssetManager.h"
#include "UObject/SoftObjectPtr.h"
#include "MyTextureLoader.generated.h"
UCLASS()
class MYGAME_API UMyTextureLoader : public UObject
{
GENERATED_BODY()
public:
// Request a texture asynchronously.
// @param TexturePtr Soft reference set in the editor.
// @param Priority Higher values are loaded first.
UFUNCTION(BlueprintCallable, Category="AssetLoading")
void RequestTexture(TSoftObjectPtr<UTexture2D> TexturePtr, int32 Priority);
// Release the handle so the asset can be garbage collected.
UFUNCTION(BlueprintCallable, Category="AssetLoading")
void ReleaseTexture(const FPrimaryAssetId& AssetId);
private:
void OnLoadComplete(TSoftObjectPtr<UTexture2D> TexturePtr);
TMap<FPrimaryAssetId, TSharedPtr<FStreamableHandle>> ActiveHandles;
};
// MyTextureLoader.cpp
#include "MyTextureLoader.h"
void UMyTextureLoader::RequestTexture(TSoftObjectPtr<UTexture2D> TexturePtr, int32 Priority)
{
if (TexturePtr.IsNull())
{
return;
}
// The asset must be assigned to a Primary Asset Type in the Editor.
FPrimaryAssetId AssetId(TEXT("GameplayTexture"), TexturePtr.ToSoftObjectPath().GetAssetName());
if (!AssetId.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("Invalid primary asset id for %s"), *TexturePtr.ToString());
return;
}
TSharedPtr<FStreamableHandle> Handle = UAssetManager::Get().LoadPrimaryAsset(
AssetId,
FStreamableDelegate::CreateUObject(this, &UMyTextureLoader::OnLoadComplete, TexturePtr),
Priority);
if (Handle.IsValid())
{
ActiveHandles.Add(AssetId, Handle);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("Failed to start async load for %s"), *TexturePtr.ToString());
}
}
void UMyTextureLoader::OnLoadComplete(TSoftObjectPtr<UTexture2D> TexturePtr)
{
UTexture2D* LoadedTexture = TexturePtr.Get();
if (LoadedTexture)
{
UE_LOG(LogTemp, Log, TEXT("Async load complete: %s"), *LoadedTexture->GetName());
}
else
{
UE_LOG(LogTemp, Warning, TEXT("Async load returned null for %s"), *TexturePtr.ToString());
}
}
void UMyTextureLoader::ReleaseTexture(const FPrimaryAssetId& AssetId)
{
if (TSharedPtr<FStreamableHandle>* HandlePtr = ActiveHandles.Find(AssetId))
{
HandlePtr->Reset();
ActiveHandles.Remove(AssetId);
}
}
Place the class in a gameplay module, add the GameplayTexture primary asset type in Project Settings → Asset Manager, and assign each texture to that type via the Asset Manager UI. The loader can now be called from Blueprints or C++ without blocking the Game Thread.
Trust / Data Boundaries
- Editor ↔ Runtime: Asset Manager reads the Asset Registry built by the Editor. If the registry is stale (for example, after a content-only cook without a full rebuild), soft pointers can resolve to
nullptrsilently. The boundary is theAssetRegistrymodule; runtime code must never assume a path exists without checkingIsValid()orIsNull(). - Game Thread ↔ Load Thread:
LoadPrimaryAssetperforms I/O on a background thread. The completion delegate runs on the Game Thread, but the loadedUObjectis only safe to use after the delegate fires. Accessing the object earlier is a data race. - Ownership: The caller owns the
TSharedPtr<FStreamableHandle>returned byLoadPrimaryAsset. Releasing the handle (or callingUnloadPrimaryAsset) transfers ownership back to the garbage collector. Keeping handles alive longer than needed defeats the unload policy.
Operational Checks
- Load-time profiling – Run
stat streamingin a packaged build. Verify that async loading time stays low and does not spike on the Game Thread when high-priority assets are requested. - Memory accounting – Use
memreport -fullafter a level transition. Confirm that theTexture2Dpool drops by the size of the unloaded assets. - Regression test – Add an automated test that requests a known texture, waits for the delegate, then releases the handle and asserts the object is no longer referenced after a garbage-collection tick.
Failure Modes
| Symptom | Root Cause | Mitigation |
|---|---|---|
| Delegate never fires | Asset not registered as a primary asset, or the primary asset type name is misspelled. | Validate registration in the Editor; log the resolved FPrimaryAssetId at startup. |
| Frame hitch despite async load | Too many concurrent high-priority requests saturate the streaming thread. | Limit concurrent high-priority loads and demote optional assets to lower priority values. |
| Memory grows across level loads | Handles retained in a manager singleton; unload logic never runs. | Enforce handle cleanup in level-shutdown code; add a watchdog that logs dangling handles. |
| Soft pointer resolves to null | Asset Registry is stale after a content-only cook. | Force a full asset registry rebuild in the cook pipeline before packaging. |
Conditions That Would Change the Design
- Deterministic frame budget – If the project adopts a fixed-time-step simulation that cannot tolerate any variance, replace async loads with synchronous pre-load during a loading screen.
- Streaming from network – For cloud-based asset delivery, the Asset Manager's local-only registry is insufficient; a custom asset provider would be required.
- Heavy use of user-generated content – When assets are created at runtime, they cannot be pre-registered. Switch to
FStreamableManager::RequestAsyncLoadwith raw package paths. - Platform-specific memory limits – On consoles with tight GPU memory, add a budget manager that cancels low-priority loads when the texture pool exceeds a threshold.
Verification Checklist
- Open the project in Unreal Editor (UE4 or UE5).
- Add a new Primary Asset Type named
GameplayTexturein Project Settings → Asset Manager. - Assign a few large textures (greater than 4 MiB) to that type via the Asset Manager UI.
- Compile the
UMyTextureLoadermodule. - Create a test Blueprint that calls
RequestTexturewith a high priority value and prints the returned texture name. - Launch a Standalone Game. Observe the output log – the texture name should appear after a few frames without a visible hitch.
- Run
stat streamingand confirm async loading time stays low. - Trigger a level transition that calls
ReleaseTexturefor the same handles. Runmemreport -fulland verify the texture memory is released.
If any step fails, consult the Failure Modes table above before expanding the design.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.