Skip to content
PRODUCT GITHUB  ↗

Spatial Streaming

For worlds too large to keep resident at once — procedural terrain, voxel landscapes, endless grids — you want to stream mesh cells in and out around the player. The RealtimeMeshSpatial module provides exactly that: a tickable world subsystem tracks streaming sources, and a per-actor manager runs a quadtree/LOD state machine that fires four lifecycle delegates as cells load, become visible, hide, and unload.

You provide the geometry; the module decides which cells should exist right now and when. It pairs naturally with custom providers — a single shared generator produces the geometry for every cell, with per-cell variation carried in the descriptor.

Add the module to your Build.cs:

PrivateDependencyModuleNames.AddRange(new string[] {
"RealtimeMeshComponent",
"RealtimeMeshExt", // generator + URealtimeMeshConstructed
"RealtimeMeshSpatial", // streaming manager + subsystem
});

The shipped worked example is ARealtimeMeshSpatialStreamingActor in Source/RealtimeMeshExamples/Public/RealtimeMeshSpatialStreamingActor.h (implementation in Source/RealtimeMeshExamples/Private/RealtimeMeshSpatialStreamingActor.cpp). It streams a plane field around the player using the plane generator. We’ll follow it throughout.


  • URealtimeMeshSpatialStreamingSubsystem — a UTickableWorldSubsystem, one per world. It holds the registered streaming sources and managers and ticks every manager each frame. You don’t create it; the engine does. It ticks in editor and when paused, so streaming keeps working in PIE and the editor viewport.

  • URealtimeMeshSpatialStreamingManager — a UObject you create and own (typically on your streaming actor). It runs the quadtree/LOD traversal and calls your lifecycle delegates. You initialize it once and it registers itself with the subsystem.

  • Streaming sources — points the streaming radiates from (usually the player). The subsystem auto-adds a source for the player Character in PIE; for a custom pawn you add a URealtimeMeshSpatialStreamingSourceComponent.

  • A structure provider — your implementation of IRealtimeMeshSpatialStreamingStructureProvider, which defines the grid: its transform, the maximum LOD, whether streaming is 2D or 3D, and which cells are valid.


Cell identity: FRealtimeMeshSpatialComponentLocation

Section titled “Cell identity: FRealtimeMeshSpatialComponentLocation”

Every delegate is keyed by a cell location — the unit of streaming:

Source/RealtimeMeshSpatial/Public/RealtimeMeshSpatialComponentLocation.h
struct FRealtimeMeshSpatialComponentLocation
{
FInt64Vector Location; // integer grid coordinates
int32 LOD; // quadtree level (0 = finest)
};

It hashes and compares by (Location, LOD), so it works directly as a TMap key — which is how the example tracks the component it created for each live cell.


You drive everything through four delegates on FRealtimeMeshSpatialStreamingStateInitParams:

Source/RealtimeMeshSpatial/Public/RealtimeMeshSpatialStreamingState.h
struct FRealtimeMeshSpatialStreamingStateInitParams
{
TSharedPtr<RealtimeMesh::IRealtimeMeshSpatialStreamingStructureProvider> ChunkProvider;
TDelegate<void(const FRealtimeMeshSpatialComponentLocation&)> OnActivateCell;
TDelegate<void(const FRealtimeMeshSpatialComponentLocation&)> OnDeactivateCell;
TDelegate<TFuture<bool>(const FRealtimeMeshSpatialComponentLocation&,
RealtimeMesh::FRealtimeMeshCancellationToken)> OnLoadCell;
TDelegate<void(const FRealtimeMeshSpatialComponentLocation&)> OnUnloadCell;
};

The lifecycle for a cell is:

  1. OnLoadCell — the cell entered streaming range. This is the only async delegate: it returns TFuture<bool> so you can build the geometry off the game thread and report success/failure when done. It also receives a FRealtimeMeshCancellationToken — if the cell leaves range before the load finishes, honor the token and abandon the work. Create the mesh here, but keep it hidden.
  2. OnActivateCell — the loaded cell is now close enough to be visible. Show it.
  3. OnDeactivateCell — the cell moved out of the visible band but is still loaded. Hide it (don’t destroy — it may re-activate cheaply).
  4. OnUnloadCell — the cell left streaming range entirely. Destroy its resources.

This load-then-activate split lets the manager pre-load geometry slightly outside the visible radius so cells pop into view already built.


The manager needs to know the shape of your world. Implement IRealtimeMeshSpatialStreamingStructureProvider:

Source/RealtimeMeshSpatial/Public/Interface/Spatial/RealtimeMeshSpatialValidChunkProvider.h
class IRealtimeMeshSpatialStreamingStructureProvider
{
protected:
int32 MaxLOD; // coarsest LOD the grid streams to
uint32 bShouldUse3dStreaming : 1; // false = 2D quadtree, true = 3D octree
public:
int32 GetMaxLOD() const;
bool ShouldUse3dStreaming() const;
virtual bool IsCellValid(const FRealtimeMeshSpatialComponentLocation& CellLocation) const = 0;
virtual FTransform GetGridTransform() const = 0;
};

MaxLOD and bShouldUse3dStreaming are set in your constructor; IsCellValid lets you carve holes or bound the world (return false for cells that shouldn’t exist); GetGridTransform anchors and orients the grid in world space. The example’s minimal provider is an unbounded 2D grid anchored at the actor:

class FExampleChunkProvider : public IRealtimeMeshSpatialStreamingStructureProvider
{
public:
FExampleChunkProvider(int32 InMaxLOD, const FTransform& InGridTransform)
: GridTransform(InGridTransform)
{
MaxLOD = InMaxLOD;
bShouldUse3dStreaming = false;
}
virtual bool IsCellValid(const FRealtimeMeshSpatialComponentLocation& Cell) const override { return true; }
virtual FTransform GetGridTransform() const override { return GridTransform; }
private:
FTransform GridTransform;
};

Grid vs. cell sizing. The manager’s base chunk size (64 units) and your generator’s per-cell size must agree so a generated patch exactly fills its streaming cell. The example pins both to a shared GBaseChunkSize = 64 constant; a cell at LOD n covers BaseChunkSize << n units. Getting these out of sync leaves visible seams or overlaps.


Wiring it up: Initialize / Start / Stop / Reset

Section titled “Wiring it up: Initialize / Start / Stop / Reset”

The manager’s control surface:

// URealtimeMeshSpatialStreamingManager
void Initialize(const FRealtimeMeshSpatialStreamingStateInitParams& InitParams, bool bAutoStart = true);
void Start();
void Stop();
void Reset();
bool IsStreamingStable() const; // no pending loads/unloads and nothing changed last frame

Initialize takes your init params and, when bAutoStart is true, registers with the subsystem so it begins ticking immediately. Start/Stop toggle that registration; Reset tears everything down. IsStreamingStable() returns true once every pending load/unload has settled — useful to gate teleports, screenshots, or “world ready” logic.

From the example’s BeginPlay:

void ARealtimeMeshSpatialStreamingActor::BeginPlay()
{
Super::BeginPlay();
// Resolve the shared generator from the (editor-picked) provider config.
Generator = FRealtimeMeshProviderRegistry::Get().CreateGenerator(ProviderConfig);
if (!Generator.IsValid())
{
return;
}
ChunkProvider = MakeShared<FExampleChunkProvider>(MaxLOD, GetActorTransform());
Manager = NewObject<URealtimeMeshSpatialStreamingManager>(this);
FRealtimeMeshSpatialStreamingStateInitParams Init;
Init.ChunkProvider = ChunkProvider;
Init.OnLoadCell.BindUObject(this, &ARealtimeMeshSpatialStreamingActor::OnLoadCell);
Init.OnActivateCell.BindUObject(this, &ARealtimeMeshSpatialStreamingActor::OnActivateCell);
Init.OnDeactivateCell.BindUObject(this, &ARealtimeMeshSpatialStreamingActor::OnDeactivateCell);
Init.OnUnloadCell.BindUObject(this, &ARealtimeMeshSpatialStreamingActor::OnUnloadCell);
// bAutoStart registers with the streaming subsystem, which then ticks and drives us.
Manager->Initialize(Init, /*bAutoStart*/ true);
}

And clean teardown in EndPlay — Reset() the manager, then destroy any components still alive:

void ARealtimeMeshSpatialStreamingActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
if (Manager)
{
Manager->Reset();
Manager = nullptr;
}
for (auto& Pair : CellComponents)
{
if (Pair.Value) { Pair.Value->DestroyComponent(); }
}
CellComponents.Empty();
Super::EndPlay(EndPlayReason);
}

OnLoadCell creates a URealtimeMeshComponent, places it at the cell corner, initializes a URealtimeMeshConstructed, binds the shared generator with a per-cell descriptor, and kicks off Regenerate. It returns the regenerate future mapped to bool. The component starts hidden — activation makes it visible.

TFuture<bool> ARealtimeMeshSpatialStreamingActor::OnLoadCell(
const FRealtimeMeshSpatialComponentLocation& Cell, FRealtimeMeshCancellationToken CancellationToken)
{
const int64 ChunkSize = GBaseChunkSize << Cell.LOD;
const FVector Corner(double(Cell.Location.X) * ChunkSize, double(Cell.Location.Y) * ChunkSize, 0.0);
URealtimeMeshComponent* Component = NewObject<URealtimeMeshComponent>(this);
Component->RegisterComponent();
Component->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepRelativeTransform);
Component->SetRelativeLocation(Corner);
Component->SetVisibility(false); // shown later, in OnActivateCell
CellComponents.Add(Cell, Component);
URealtimeMeshConstructed* Mesh = Component->InitializeRealtimeMesh<URealtimeMeshConstructed>();
if (!Mesh)
{
return MakeFulfilledPromise<bool>(false).GetFuture();
}
// Per-cell descriptor: same generator, different cell.
FRealtimeMeshPlaneCellDescriptor Descriptor;
Descriptor.CellLocation = FIntPoint(int32(Cell.Location.X), int32(Cell.Location.Y));
Descriptor.LOD = Cell.LOD;
Descriptor.CellSize = FVector2f(float(GBaseChunkSize), float(GBaseChunkSize));
Descriptor.CellHeight = CellHeight;
Mesh->SetGenerator(Generator.ToSharedRef(), FInstancedStruct::Make(Descriptor));
return Mesh->Regenerate(FRealtimeMeshDirtyFlags::AllDirty())
.Next([](ERealtimeMeshProxyUpdateStatus) { return true; });
}

Note the shape: one Generator, many descriptors. Every cell shares the generator resolved in BeginPlay; only the descriptor differs. That is what makes streaming hundreds of cells concurrently cheap.

Activate / deactivate — toggle visibility

Section titled “Activate / deactivate — toggle visibility”
void ARealtimeMeshSpatialStreamingActor::OnActivateCell(const FRealtimeMeshSpatialComponentLocation& Cell)
{
if (URealtimeMeshComponent* Component = CellComponents.FindRef(Cell))
{
Component->SetVisibility(true, true);
}
}
void ARealtimeMeshSpatialStreamingActor::OnDeactivateCell(const FRealtimeMeshSpatialComponentLocation& Cell)
{
if (URealtimeMeshComponent* Component = CellComponents.FindRef(Cell))
{
Component->SetVisibility(false, true);
}
}
void ARealtimeMeshSpatialStreamingActor::OnUnloadCell(const FRealtimeMeshSpatialComponentLocation& Cell)
{
if (URealtimeMeshComponent* Component = CellComponents.FindRef(Cell))
{
Component->DestroyComponent();
CellComponents.Remove(Cell);
}
}

Streaming radiates from sources. In PIE the subsystem auto-adds a source for the player Character, so the plane field simply follows the player with no extra setup. If your pawn is not a Character, attach a URealtimeMeshSpatialStreamingSourceComponent to it:

Source/RealtimeMeshSpatial/Public/RealtimeMeshSpatialStreamingSourceComponent.h
UCLASS(Meta = (BlueprintSpawnableComponent))
class URealtimeMeshSpatialStreamingSourceComponent : public UActorComponent,
public IRealtimeMeshSpatialStreamingSourceProvider
{
public:
UFUNCTION(BlueprintCallable, Category = "Streaming") void EnableStreamingSource();
UFUNCTION(BlueprintCallable, Category = "Streaming") void DisableStreamingSource();
UFUNCTION(BlueprintPure, Category = "Streaming") bool IsStreamingSourceEnabled() const;
// True once all managers in this world have settled their loads/unloads.
UFUNCTION(BlueprintCallable, Category = "Streaming") bool IsStreamingStable() const;
};

The component registers itself with the subsystem on OnRegister and reports the pawn’s position as a streaming source each frame. It exposes enable/disable and an IsStreamingStable() you can call from Blueprint to gate gameplay until the streamed world is stable.

Under the hood a source carries tuning knobs (FRealtimeMeshSpatialStreamingSource): MaxVisibilityDistance, LODFalloff, LODVisibilityThreshold, and StreamingDistanceMultiplier control how far cells stream and how aggressively LOD falls off with distance.