Custom Providers
A generator is RMC’s answer to a single question: “given this descriptor, produce the requested aspects of a mesh.” It owns no per-mesh state and no UObject references, so one configured generator can be shared (TSharedRef) across many meshes and called concurrently on worker threads. This is what makes generators the backbone of both one-off procedural meshes and the spatial streaming system, where hundreds of cells share a single generator.
Generators live in the RealtimeMeshExt module. Add it to your PrivateDependencyModuleNames in your module’s Build.cs:
PrivateDependencyModuleNames.AddRange(new string[] { "RealtimeMeshComponent", "RealtimeMeshExt", // FRealtimeMeshGenerator, URealtimeMeshConstructed});Everything here is in the RealtimeMesh:: namespace. The shipped worked example is FRealtimeMeshPlaneGenerator in Source/RealtimeMeshExt/Public/Factory/RealtimeMeshGeneratorPlane.h (implementation in the matching Private/Factory/RealtimeMeshGeneratorPlane.cpp) — a procedural sine-height plane. We’ll walk through it phase by phase.
The two sides of a generator
Section titled “The two sides of a generator”A generator separates static configuration from per-mesh identity:
- Config — set once per configured generator and shared across every mesh it drives (grid resolution, UV scale, material choices). This is also the editor surface: a designer picks and edits it through an
FInstancedStructdropdown. - Descriptor — the per-mesh identity supplied at generation time (cell coordinates, LOD, seed). It travels in the request, not in a map inside the generator. This is what replaces the old provider-tree’s
TMap<MeshID, FState>.
Because neither the generator nor the config holds mutable shared state, the generator is safe to run off the game thread. Keep it that way: do not cache per-mesh state on the generator between calls.
The base class and its phases
Section titled “The base class and its phases”Every generator derives from FRealtimeMeshGenerator. You override only the aspects you produce; the rest default to “nothing”. GetStructure() and GetSectionGroup() are pure virtual because a generator that produces no geometry is meaningless — bounds, collision, and Nanite are all optional.
class FRealtimeMeshGenerator : public TSharedFromThis<FRealtimeMeshGenerator>{public: // Thread(s) the generation phases may run on. Async by default. virtual ERealtimeMeshThreadType AllowedThread() const { return ERealtimeMeshThreadType::AsyncThread; }
// Compute shared data once per update for the phases below to read. Optional. virtual void PreUpdate(const FRealtimeMeshGenerationRequest& Request, FRealtimeMeshGenerationScratch& OutScratch) const {}
// The LOD / section-group layout for this mesh. (required) virtual FRealtimeMeshStructure GetStructure(const FRealtimeMeshGenerationRequest& Request, const FRealtimeMeshGenerationScratch& Scratch) const = 0;
// Fill the streams/sections for one section group. Called once per dirty group. (required) virtual void GetSectionGroup(const FRealtimeMeshGenerationRequest& Request, const FRealtimeMeshGenerationScratch& Scratch, const FRealtimeMeshBufferSetKey& SectionGroupKey, FRealtimeMeshSectionGroupData& OutData) const = 0;
// Mesh bounds. Default: unset (existing bounds left untouched). virtual TOptional<FBoxSphereBounds3f> GetBounds(const FRealtimeMeshGenerationRequest& Request, const FRealtimeMeshGenerationScratch& Scratch) const;
// Collision geometry. Default: none. virtual void GetCollision(const FRealtimeMeshGenerationRequest& Request, const FRealtimeMeshGenerationScratch& Scratch, FRealtimeMeshCollisionInfo& OutCollision) const {}
// Nanite resources. Default: none. virtual FRealtimeMeshNaniteResourcesPtr GetNanite(const FRealtimeMeshGenerationRequest& Request, const FRealtimeMeshGenerationScratch& Scratch) const { return nullptr; }};The orchestrator (FRealtimeMeshConstructed::Construct) calls only the phases whose dirty bit is set, so partial updates stay cheap — regenerating only bounds does not re-run geometry.
What’s in a request
Section titled “What’s in a request”struct FRealtimeMeshGenerationRequest{ FInstancedStruct Descriptor; // per-mesh identity (your descriptor struct) FRealtimeMeshDirtyFlags DirtyFlags; // which aspects to (re)generate FRealtimeMeshCancellationToken CancellationToken;
template <typename T> const T& Get() const; // Request.Get<FMyDescriptor>() template <typename T> bool IsDescriptorOfType() const;};Get<T>() unwraps the descriptor to your concrete struct. PreUpdate() can stash cross-aspect shared data (say, a sampled density field) into the FRealtimeMeshGenerationScratch, which every per-aspect phase then reads — so a full update computes it once rather than per aspect.
Threading expectations
Section titled “Threading expectations”AllowedThread() controls where the phases run. The default is ERealtimeMeshThreadType::AsyncThread, meaning all phase methods (PreUpdate, GetStructure, GetSectionGroup, GetBounds, GetCollision, GetNanite) run on a worker thread. That is why the phase methods are const and why the generator must be free of mutable shared state — one instance serves many meshes concurrently.
If a phase must touch game-thread-only state, override AllowedThread() to return ERealtimeMeshThreadType::GameThread. You give up the concurrency win, so prefer keeping everything async and passing what you need through the descriptor. The values are flags (RenderThread, GameThread, AsyncThread, Any) defined in Core/RealtimeMeshFuture.h.
Regardless of AllowedThread(), the final commit of the generated data onto the mesh always happens on the game thread — the orchestrator handles that for you.
The convenience base: TRealtimeMeshProvider<TConfig>
Section titled “The convenience base: TRealtimeMeshProvider<TConfig>”Most providers don’t want to unwrap the config FInstancedStruct by hand. TRealtimeMeshProvider<TConfigType> stores a typed, value-copied config and exposes GetConfig():
template <typename TConfigType>class TRealtimeMeshProvider : public FRealtimeMeshGenerator{public: using FConfig = TConfigType; explicit TRealtimeMeshProvider(const TConfigType& InConfig) : Config(InConfig) {} const TConfigType& GetConfig() const { return Config; }protected: TConfigType Config;};The value copy is exactly what makes the generator safe off the game thread — no shared-mutable reference back to a UObject. Your config struct must derive from FRealtimeMeshProviderConfig (a USTRUCT), which marks it as a provider config and lets the editor filter its type dropdown to valid choices.
Worked example: the plane generator
Section titled “Worked example: the plane generator”1. The config and descriptor structs
Section titled “1. The config and descriptor structs”From RealtimeMeshGeneratorPlane.h. The config is the editor surface; the descriptor is the per-cell identity the streaming layer (or a one-off caller) supplies.
USTRUCT(BlueprintType)struct FRealtimeMeshPlaneProviderConfig : public FRealtimeMeshProviderConfig{ GENERATED_BODY()
// Number of quads per side of the grid. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "RealtimeMesh|Plane") FIntPoint GridSize = FIntPoint(128, 128);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "RealtimeMesh|Plane") FVector2f UVScale = FVector2f(1.0f / 1.28f, 1.0f / 1.28f);};
USTRUCT(BlueprintType)struct FRealtimeMeshPlaneCellDescriptor{ GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "RealtimeMesh|Plane") FIntPoint CellLocation = FIntPoint(0, 0);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "RealtimeMesh|Plane") int32 LOD = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "RealtimeMesh|Plane") FVector2f CellSize = FVector2f(64.0f, 64.0f);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "RealtimeMesh|Plane") float CellHeight = 256.0f;};Both are USTRUCTs — a hard requirement, because they travel as FInstancedStruct payloads across the worker boundary.
2. The generator class
Section titled “2. The generator class”namespace RealtimeMesh{ class FRealtimeMeshPlaneGenerator : public TRealtimeMeshProvider<FRealtimeMeshPlaneProviderConfig> { static const FRealtimeMeshBufferSetKey MainSectionGroup; static const FRealtimeMeshSectionKey MainSection;
public: using TRealtimeMeshProvider::TRealtimeMeshProvider; // inherit the (const FConfig&) ctor
virtual FRealtimeMeshStructure GetStructure(const FRealtimeMeshGenerationRequest& Request, const FRealtimeMeshGenerationScratch& Scratch) const override; virtual void GetSectionGroup(const FRealtimeMeshGenerationRequest& Request, const FRealtimeMeshGenerationScratch& Scratch, const FRealtimeMeshBufferSetKey& SectionGroupKey, FRealtimeMeshSectionGroupData& OutData) const override; virtual TOptional<FBoxSphereBounds3f> GetBounds(const FRealtimeMeshGenerationRequest& Request, const FRealtimeMeshGenerationScratch& Scratch) const override; };}It overrides only the three phases it needs. The plane keys everything to one section group in one LOD, declared as static members.
3. GetStructure — the layout
Section titled “3. GetStructure — the layout”Return the LOD / section-group skeleton. The plane uses a single LOD with a single section group:
FRealtimeMeshStructure FRealtimeMeshPlaneGenerator::GetStructure( const FRealtimeMeshGenerationRequest& Request, const FRealtimeMeshGenerationScratch& Scratch) const{ FRealtimeMeshStructure Structure; Structure.LODs.SetNum(1); Structure.LODs[0].SectionGroups.Add(MainSectionGroup); return Structure;}4. GetSectionGroup — the geometry
Section titled “4. GetSectionGroup — the geometry”This is where the mesh is actually built, into OutData. It runs once per dirty section group. You unwrap the descriptor with Request.Get<T>(), then build streams with the usual stream builders — here TRealtimeMeshBuilderLocal writing directly into OutData.Streams:
void FRealtimeMeshPlaneGenerator::GetSectionGroup( const FRealtimeMeshGenerationRequest& Request, const FRealtimeMeshGenerationScratch& Scratch, const FRealtimeMeshBufferSetKey& SectionGroupKey, FRealtimeMeshSectionGroupData& OutData) const{ const FRealtimeMeshPlaneCellDescriptor& Cell = Request.Get<FRealtimeMeshPlaneCellDescriptor>(); const FIntPoint GridSize = Config.GridSize; // from the config const FVector2f Size = Cell.CellSize * FMath::Pow(2.0f, static_cast<float>(Cell.LOD));
OutData.DrawType = ERealtimeMeshSectionDrawType::Static; OutData.bReplaceAllExistingStreams = true; OutData.bAutoCreatePolygroupSections = true; OutData.SectionConfigs.Add(MainSection, FRealtimeMeshSectionConfig());
TRealtimeMeshBuilderLocal Builder(OutData.Streams); Builder.EnableTangents(); Builder.EnablePolyGroups(); Builder.EnableTexCoords();
// ... sample a sine-height field over the (GridSize+1)^2 grid, adding a vertex per sample ... Builder.AddVertex(FVector3f(X * GridSpacing.X, Y * GridSpacing.Y, Height)) .SetTexCoord(UVOffset + FVector2f(X, Y) * UVStepSize) .SetTangents(Tangent, Binormal, Normal);
// ... then two triangles per quad ... Builder.AddTriangle(BaseIndex, BaseIndexNextRow + 1, BaseIndex + 1); Builder.AddTriangle(BaseIndex, BaseIndexNextRow, BaseIndexNextRow + 1);}Note how config (GridSize, UVScale) and descriptor (CellLocation, LOD, CellSize, CellHeight) combine: the config sets the resolution, the descriptor places and sizes this particular cell. The FRealtimeMeshSectionGroupData fields — DrawType, bReplaceAllExistingStreams, bAutoCreatePolygroupSections, SectionConfigs — control how the built streams merge into the mesh.
5. GetBounds — cheap and separate
Section titled “5. GetBounds — cheap and separate”Bounds are their own phase so they can be recomputed without rebuilding geometry. Return an unset TOptional to leave existing bounds untouched:
TOptional<FBoxSphereBounds3f> FRealtimeMeshPlaneGenerator::GetBounds( const FRealtimeMeshGenerationRequest& Request, const FRealtimeMeshGenerationScratch& Scratch) const{ const FRealtimeMeshPlaneCellDescriptor& Cell = Request.Get<FRealtimeMeshPlaneCellDescriptor>(); const FVector2f Size = Cell.CellSize * FMath::Pow(2.0f, static_cast<float>(Cell.LOD)); const FVector3f Extent = FVector3f(Size.X, Size.Y, Cell.CellHeight); return FBoxSphereBounds3f(FBox3f(FVector3f::ZeroVector, Extent));}Registering a provider
Section titled “Registering a provider”Registration is the data-driven seam: it maps a config struct type to a factory that builds the matching generator. Once registered, picking that config struct in the editor resolves to your generator with no extra glue. Add one line at file scope inside namespace RealtimeMesh in the generator’s .cpp:
// In RealtimeMeshGeneratorPlane.cppnamespace RealtimeMesh{ RMC_REGISTER_PROVIDER(FRealtimeMeshPlaneGenerator, FRealtimeMeshPlaneProviderConfig) // ... phase implementations ...}RMC_REGISTER_PROVIDER(GeneratorType, ConfigType) constructs a static registration that registers with the FRealtimeMeshProviderRegistry when the object system is ready (registration is deferred past static init so StaticStruct() is safe to call). GeneratorType must have a constructor taking const ConfigType& — which TRealtimeMeshProvider<ConfigType> provides.
To build a generator from a config at runtime, ask the registry:
FInstancedStruct ProviderConfig = /* picked in the editor, or FInstancedStruct::Make(MyConfig) */;FRealtimeMeshGeneratorPtr Generator = FRealtimeMeshProviderRegistry::Get().CreateGenerator(ProviderConfig);if (!Generator.IsValid()){ // config type wasn't registered}CreateGenerator returns null if the config’s type isn’t registered or the struct is invalid, so always check.
Exposing the config in the editor
Section titled “Exposing the config in the editor”Declare an FInstancedStruct property with the base-struct meta so the type dropdown only shows registered provider configs:
UPROPERTY(EditAnywhere, Category = "RealtimeMesh", meta = (BaseStruct = "/Script/RealtimeMeshExt.RealtimeMeshProviderConfig", ExcludeBaseStruct))FInstancedStruct ProviderConfig;Consuming a generator from a Constructed mesh
Section titled “Consuming a generator from a Constructed mesh”URealtimeMeshConstructed is the mesh leaf that holds a generator plus a per-mesh descriptor and regenerates itself on demand — no factory tree involved. Bind the pair with SetGenerator, then call Regenerate with the dirty aspects you want:
URealtimeMeshConstructed* Mesh = Component->InitializeRealtimeMesh<URealtimeMeshConstructed>();
FRealtimeMeshPlaneCellDescriptor Descriptor;Descriptor.CellLocation = FIntPoint(0, 0);Descriptor.LOD = 0;Descriptor.CellSize = FVector2f(64.0f, 64.0f);Descriptor.CellHeight = 256.0f;
// Bind one shared generator + this mesh's descriptor.Mesh->SetGenerator(Generator.ToSharedRef(), FInstancedStruct::Make(Descriptor));
// (Re)generate. AllDirty() marks structure, Nanite, bounds and (optionally) collision dirty.Mesh->Regenerate(RealtimeMesh::FRealtimeMeshDirtyFlags::AllDirty()) .Next([](ERealtimeMeshProxyUpdateStatus Status) { /* done on the game thread */ });Regenerate returns a TFuture<ERealtimeMeshProxyUpdateStatus>; the future completes once the proxy update is applied. To regenerate only part of the mesh later — say, after the descriptor changes — build a narrower FRealtimeMeshDirtyFlags (e.g. MarkBoundsDirty() or MarkSectionGroupDirty(Key)) instead of AllDirty(), and only those phases run. Change the descriptor first with SetDescriptor(FInstancedStruct::Make(NewDescriptor)).
For a complete, running use of this pattern across many meshes, see the spatial streaming page, whose example actor creates one URealtimeMeshConstructed per streamed cell and drives them all from a single shared generator.