Skip to content
PRODUCT GITHUB  ↗

Updating Mesh Data

Once a section group exists, there are several ways to change its geometry, spanning a spectrum from “throw away the buffers and re-upload everything” to “poke a few bytes of an existing GPU buffer in place.” Picking the right one is the single biggest lever on update performance. This page walks that spectrum, all grounded in URealtimeMeshSimple example actors.

The rule of thumb:

  • Changing topology (vertex/triangle counts) → full re-push with UpdateBufferSet.
  • Changing values only (positions, colors, normals) on a Dynamic group → in-place EditMeshInPlace / EditMeshInPlaceRanged.

The simplest update rebuilds the StreamSet and hands it back. This works for any group, handles topology changes, and is the right default when the mesh isn’t changing every frame:

FRealtimeMeshStreamSet StreamSet;
// ...rebuild the whole grid into StreamSet with a builder...
const FRealtimeMeshBufferSetKey GroupKey =
FRealtimeMeshBufferSetKey::Create(0, FName("DynamicMesh"));
// Create the group on the first call, then just update it afterward.
if (RealtimeMesh->GetBufferSets(FRealtimeMeshLODKey(0)).Contains(GroupKey))
{
RealtimeMesh->UpdateBufferSet(GroupKey, StreamSet);
}
else
{
RealtimeMesh->CreateBufferSet(GroupKey, StreamSet);
}

UpdateBufferSet has const-ref and rvalue (MoveTemp(StreamSet)) overloads. For a Static group this republishes by recreating the proxy; that’s fine for occasional changes but is why frame-by-frame animation wants either a Dynamic group or the in-place path below.

This is exactly what ARealtimeMeshExample_Simple_DynamicUpdate (Source/RealtimeMeshExamples/Private/Simple/RealtimeMeshExample_Simple_DynamicUpdate.cpp) does — it rebuilds and re-pushes a rippling grid every frame.


When the topology is fixed and you only change vertex values, you can edit the group’s CPU streams in place and upload just the changed streams — no reallocation and no new proxy version. This requires a Dynamic draw-type group (see Section Groups & Sections):

// At creation: Dynamic draw type -> BUF_Dynamic buffers -> in-place path eligible.
RealtimeMesh->CreateBufferSet(
GroupKey, StreamSet,
FRealtimeMeshBufferSetConfig(ERealtimeMeshSectionDrawType::Dynamic));

EditMeshInPlace takes a callback that mutates the FRealtimeMeshStreamSet and returns the set of stream keys it changed:

RealtimeMesh->EditMeshInPlace(GroupKey,
[Time, Width](FRealtimeMeshStreamSet& Streams) -> TSet<FRealtimeMeshStreamKey>
{
FRealtimeMeshStream* Positions = Streams.Find(FRealtimeMeshStreams::Position);
if (!Positions)
{
return {};
}
TRealtimeMeshStreamBuilder<FVector3f, void> Position(*Positions);
for (int32 Index = 0; Index < Position.Num(); ++Index)
{
FVector3f V = Position.GetValue(Index);
V.Z = /* ...new height... */;
Position.Set(Index, V);
}
// Tell RMC which streams changed so only those upload.
return { FRealtimeMeshStreams::Position };
});

Eligible vertex streams on a Dynamic group are uploaded via a buffer lock with no realloc and no new proxy version; anything not eligible falls back to a full update.

EditMeshInPlaceRanged — upload only a sub-range

Section titled “EditMeshInPlaceRanged — upload only a sub-range”

If you touch only part of a stream, EditMeshInPlaceRanged lets you return, per changed stream, the half-open element range [lower, upper) you actually modified — and only that GPU sub-region uploads:

RealtimeMesh->EditMeshInPlaceRanged(GroupKey,
[/* captures */](FRealtimeMeshStreamSet& Streams) -> TMap<FRealtimeMeshStreamKey, FInt32Range>
{
TMap<FRealtimeMeshStreamKey, FInt32Range> Updated;
FRealtimeMeshStream* Positions = Streams.Find(FRealtimeMeshStreams::Position);
if (!Positions) { return Updated; }
TRealtimeMeshStreamBuilder<FVector3f, void> Position(*Positions);
for (int32 Index = VertexStart; Index < VertexEnd; ++Index)
{
FVector3f V = Position.GetValue(Index);
V.Z = /* ...new height... */;
Position.Set(Index, V);
}
Updated.Add(FRealtimeMeshStreams::Position, FInt32Range(VertexStart, VertexEnd));
return Updated;
});

Only the returned range is written to the GPU, so untouched regions stay exactly as they were. Streams/ranges that aren’t eligible fall back to a full update.

Both patterns come straight from ARealtimeMeshExample_Simple_FastUpdate (Source/RealtimeMeshExamples/Private/Simple/RealtimeMeshExample_Simple_FastUpdate.cpp), which animates a Dynamic grid — the full-grid mode uses EditMeshInPlace, the middle-band mode uses EditMeshInPlaceRanged so the top and bottom bands visibly stay flat.


To read a group’s current CPU data without copying it out, use ProcessMesh. It invokes your callback with the FRealtimeMeshStreamSet while the accessor lock is held, so the reference never escapes the lock:

RealtimeMesh->ProcessMesh(GroupKey, [](const FRealtimeMeshStreamSet& Streams)
{
// Inspect Streams here. Do NOT retain the reference past this callback.
});

Don’t store the reference beyond the callback, and don’t call a write/edit API on the same mesh from inside it. (URealtimeMeshSimple also offers ProcessBufferSet, which hands you the buffer set under the same lock discipline.)


Building off-thread, committing on the game thread

Section titled “Building off-thread, committing on the game thread”

Heavy procedural builds shouldn’t stall the game thread. A FRealtimeMeshStreamSet is a pure CPU container with no UObject access, so it’s safe to build on a worker thread and then hop back to commit it. RMC ships async helpers for exactly this (Core/RealtimeMeshFuture.h, in namespace RealtimeMesh):

using namespace RealtimeMesh;
TWeakObjectPtr<URealtimeMeshSimple> WeakMesh(RealtimeMesh);
// 1) Build the heavy StreamSet off the game thread.
TFuture<FRealtimeMeshStreamSet> BuildFuture = DoOnAsyncThread([]()
{
return BuildGridStreamSet(); // pure CPU, worker-thread safe
});
// 2) Hop back to the game thread to create/update the section group.
ContinueOnGameThread(MoveTemp(BuildFuture),
[WeakMesh](TFuture<FRealtimeMeshStreamSet>&& Result)
{
const FRealtimeMeshStreamSet& StreamSet = Result.Get();
if (URealtimeMeshSimple* Mesh = WeakMesh.Get())
{
Mesh->CreateBufferSet(
FRealtimeMeshBufferSetKey::Create(0, FName("AsyncMesh")), StreamSet);
}
});

This is the recommended pattern for expensive geometry, and it’s what ARealtimeMeshExample_Simple_AsyncBuild (Source/RealtimeMeshExamples/Private/Simple/RealtimeMeshExample_Simple_AsyncBuild.cpp) demonstrates — it runs the build in BeginPlay (not OnConstruction) so it isn’t re-triggered while editing. Use TWeakObjectPtr captures so the callback is safe if the mesh or actor is destroyed before the build finishes. See Async & Threading for more on these helpers.


Every geometry-changing call — CreateBufferSet, UpdateBufferSet, EditMeshInPlace(Ranged), SetSectionVisibility, and so on — returns a TFuture<ERealtimeMeshProxyUpdateStatus> that completes once the render proxy has caught up. The status is one of:

  • Updated — the proxy was updated.
  • NoUpdate — nothing needed updating.
  • NoProxy — there was no render proxy to update (e.g. the mesh isn’t currently rendered).

You can ignore the future entirely (the update still happens), or await it to chain follow-up work:

RealtimeMesh->UpdateBufferSet(GroupKey, MoveTemp(StreamSet))
.Next([](ERealtimeMeshProxyUpdateStatus Status) { /* ...after the proxy updates... */ });

From Blueprint, the same operations take an OnComplete completion delegate (FRealtimeMeshManagedCompletionCallback) that fires with the ERealtimeMeshProxyUpdateStatus when the update lands, instead of returning a future.