Runtime zone painting for Unreal Engine

Gameplay impacts become persistent paint, replicated splats, and procedural scatter.

TerraInk is a runtime painting and scatter system for Unreal Engine. It lets player actions permanently mark world surfaces, then uses paint intensity to promote cells into procedural content using ISM, HISM, PCG, or FastGeo.

Paint + HISM on UE 5.6+ FastGeo runtime on UE 5.8+ PCG runtime-gen bridge Server-authoritative splat replication Triplanar material-side sampling

Install

Drop the plugin into your project, enable it, regenerate project files, and build.

The expected folder layout is:

<YourProject>/Plugins/TerraInk/
  TerraInk.uplugin
  Source/
  Content/
  Shaders/
  Docs/

Enable the plugin in the project file:

{
  "Plugins": [
    { "Name": "TerraInk", "Enabled": true }
  ]
}

Required engine plugins

  • Niagara – always required.
  • PCG – always required.
  • PCGFastGeoInterop – UE 5.7+ optional, UE 5.8+ required for PCG-routed FastGeo. Remove from TerraInk.uplugin on UE 5.6.
  • FastGeoStreaming – UE 5.7+ optional, UE 5.8+ required for the direct bridge.

These are listed in TerraInk.uplugin and enable automatically on engines that ship them.

Build

Regenerate project files, then build your editor target:

Engine/Build/BatchFiles/Build.bat YourProjectEditor Win64 Development -Project=YourProject.uproject

The first build compiles all TerraInk modules. On UE 5.6 / 5.7 the FastGeo modules are excluded automatically through Build.cs gates.

Verify the install

Open the editor, run terraink.DumpStats in the console:

> terraink.DumpStats
TerraInk: Subsystem ready. World [Editor] Cells=0 Splats=0 Path=Standard ISM

If you see that line, the runtime module loaded correctly. Move on to Quick Start.

Building on Lyra? See the dedicated Lyra Integration Guide for the exact Build.cs entry, ranged-weapon ability wiring, gameplay tags, team-driven layer routing, multiplayer authority gate, and a verification checklist.

Quick Start

The smallest viable path from plugin enabled to first runtime paint marks.

1
Create a Paint Config
Add one Layer (e.g. "Grass") with a color and intensity range. Add one Style entry under the Styles map keyed by a tag (e.g. Paint.Style.Default). Inside that style, add one Tier with a static mesh and ProxyClass = Auto. Set DefaultStyleTag on the config to the tag you just used.
2
Trigger paint from gameplay (pick one)
You need some code path that calls into UTerraInkSubsystem. Two common shapes:
  • Direct subsystem call (recommended for projects). From C++ or Blueprint, call PaintZoneAtHit(Hit, Radius, Layer, Intensity) on UTerraInkSubsystem. This is what Lyra weapons do; the Lyra integration guide shows the exact wiring inside ULyraGameplayAbility_RangedWeapon.
  • UTerraInkPainterComponent (optional convenience). A thin Pawn / PlayerController component that wraps the subsystem call. Attach it and fire its Paint(...) method from gameplay events. Use this when prototyping or when the actor naturally owns the painting logic; skip it if you already paint from an ability or projectile.
3
Mark surfaces as paintable (optional, on by default)
UTerraInkProjectSettings::bAutoAttachSurfaceOnPaint = true is the default; the subsystem attaches UTerraInkPaintableSurfaceComponent to every actor that takes a paint hit, on every machine, automatically. You do not need to add the component manually unless:
  • You turned the auto-attach off and want explicit opt-in per actor.
  • You want the actor pre-bound at level start so its material reads correct paint state from frame zero (use ScanWorldForPaintableSurfaces on the subsystem instead of manual component placement).
The plugin ships two material functions in TerraInk Content / Materials that you drop into your environment materials: MF_TerraInk_ApplyPaint_SDF_Cluster for static surfaces (samples the global paint RTs); MF_TerraInk_ApplyPaint_Local for movable / skinned actors (per-actor CPD storage via UTerraInkDynamicPaintComponent). For the minimum walkthrough, leave auto-attach on and skip this step.
4
Press Play
Paint accumulates where the painter fires. Once cell intensity crosses a tier threshold, scatter instances appear.
5
Verify with DumpStats
Check cells, splats, backend path, and instance counts.
> terraink.DumpStats
TerraInk: World [PIE] Cells=42 Splats=1284 ISM=200 HISM=4500 FastGeo=0 Path=Standard ISM
UV paint (optional): Default sampling is triplanar (world XY projection). For genuine UV-mapped paint on an environment material, route the material's BaseColor through MF_TerraInk_ApplyPaint_SDF_Cluster (in TerraInk Content / Materials). The shortest route: open the shipped M_BasicBlendApplyPaint. The apply function is already wired in, so plug your BaseColor into it, then assign that material to your mesh slot. The UV path additionally requires UPhysicsSettings::bSupportUVFromHitResults to be ON (the project-settings validator emits an error if it isn't).

Runtime Architecture

TerraInk separates runtime painting, visual sampling, scatter evaluation, replication, PCG routing, and FastGeo construction.

TerraInk runtime

Owns the subsystem, painter component, paintable surface component, scatter manager, replicator, project settings, and public hooks.

PCG bridge

Subscribes to TerraInk hooks, exposes splat and paint data to PCG graphs, and triggers regeneration when tiers promote.

FastGeo bridge

UE 5.8+ only. Owns per-cell cluster handles and calls the compute pipeline to build runtime FastGeo containers.

Gameplay event
  -> TerraInkPainterComponent trace
  -> paint filter
  -> FTerraInkSplat
  -> cell-strip splat texture
  -> zone maps for scatter triggers
  -> scatter tier promotion
  -> ISM / HISM / PCG / FastGeo
  -> replicated client reconstruction
Replication model: TerraInk replicates splats as gameplay data. It does not replicate render-target pixels. Clients rebuild the same splat texture and scatter state locally.

Splats

A splat is one accepted paint impact: position, normal, radius, intensity, and layer.

Lifecycle

1
Trace
The painter component fires a physics trace from a gameplay event.
2
Filter
Surface tags, slope cutoffs, team gates, and component filters accept or reject hits.
3
Write to cells (closest-N)
The splat is packed into each acceleration-grid cell overlapped by its AABB. When a cell is full, the new splat replaces the existing slot whose center is farthest from the cell center, but only if the new splat is closer than that worst entry. Otherwise the cell skips the write and keeps its current splats.
4
Mirror on CPU
The splat is appended to ActiveSplats for scatter, FastGeo, replication, and persistence.
5
Replicate
The server publishes splats. Clients rerun the same cell write locally.

Cell-strip splat texture

The visual path uses one RGBA32F splat texture. Each slot uses two texels.

Row Channels Encoding
0 RGB / A pos.xyz / radius
1 RGB / A splatColor.rgb / intensity

Zone maps

Render target Format Role
ZoneMapRT1 RGBA8 Four primary paint layers in R, G, B, and A channels.
ZoneMapRT2 R8 One secondary paint layer.
Color caveat: SplatVisualColor is baked into each splat when written. Changing it later does not recolor existing paint. Paint fresh splats over the area to refresh; closest-N eviction will displace older entries when new splats land closer to their cells' centers.
Multi-layer overpaint: Painting layer B over an area previously painted with layer R progressively replaces R-splats with B-splats per cell as B's splats land closer to cell centers than R's. The visible color transitions gradually, governed by paint density and splat radius relative to cell size.

Filtering

Per-layer paint filter (UTerraInkPaintFilter) runs on every accepted hit. Common filter shapes:

  • Surface tag allow-list (e.g., only Floor.Stone accepts paint).
  • Slope cutoff (reject hits on near-vertical surfaces).
  • Owner-team gate (Red team paints red layer, Blue paints blue).
  • Component class filter (e.g., reject hits on water meshes).

Filters are pure C++ predicates with no UObject overhead. They run on every paint event without allocations.

Replication

ATerraInkReplicator is a server-side actor that:

  • Batches splats per layer and per replication frequency tier.
  • Publishes via reliable RPC to the relevant clients.
  • Reconstructs the splat array on the client side, then re-runs WriteSplatToCells locally.

Clients see the same paint, the same scatter, and the same tier promotions as the server, with no replicated RT data on the wire.

Persistence

Splats serialize on the UTerraInkSubsystem per world. Save the world to persist; load to restore. The SplatCellsTexture is rebuilt on load by replaying the splats through WriteSplatToCells.

On dedicated server, replication and persistence share the same ActiveSplats array, so clients joining mid-session catch up via the standard replication path.

Memory at default settings

The visual splat texture is the only TerraInk allocation that scales with cell count. Tunable in UTerraInkProjectSettings or via the startup CVars terraink.SplatGridResolution and terraink.MaxSplatsPerCell (sampled in InitializePaintSystem; restart needed to change).

GridResolution = 32          ->  32³ = 32768 cells
MaxSplatsPerCell = 16        ->  524288 slots
2 RGBA32F texels per slot    ->  ~16 MB

Common tuning moves:

  • Drop SplatGridResolution to 16 → 4096 cells, 2 MB texture.
  • Drop MaxSplatsPerCell to 8 → halves the height, 8 MB at 32³.

Dynamic Paint (per-actor)

The world-space SDF in Splats handles everything that doesn't move. This section covers the companion path: paint that follows movable surfaces.

When to reach for it

Scenario Path Component Material function
Static walls, floors, props World-space SDF UTerraInkPaintableSurfaceComponent MF_TerraInk_ApplyPaint_SDF_Cluster
Movable crates, vehicles, characters Per-actor UTerraInkDynamicPaintComponent MF_TerraInk_ApplyPaint_Local

Splats live in a global cell-strip texture sampled per-pixel against world position; a movable actor walking through painted areas would not "carry" paint with it. The dynamic path exists exactly to fix that case – paint stays glued to the surface as the actor moves.

Three roles in the paint pipeline

Component Role Typical owner
UTerraInkPainterComponent Emitter. Calls UTerraInkSubsystem::PaintZone / PaintAtHitResult. Projectiles, abilities, brushes
UTerraInkPaintableSurfaceComponent Receiver: world-space binding. Binds global RTs and SplatCellsTexture to the mesh material. Static surfaces
UTerraInkDynamicPaintComponent Receiver: per-actor storage. Listens to OnPaintHitDynamic, accumulates actor-local splats, uploads to Custom Primitive Data. Movable actors

Mental model: Painter is the brush; PaintableSurface is a static canvas reading from the shared world paint; DynamicPaint is a portable canvas with its own splat memory.

Storage: Custom Primitive Data

The component packs splats into the host primitive's CPD slots:

Slot offset Encoding
Base + 0..2 LocalPos.xyz
Base + 3 Radius (0 marks empty)
Base + 4 LayerIdx + clamp(Intensity, 0, 0.9999)floor() = layer, frac() = intensity

5 floats per splat. UE 5.9 ships NumCustomPrimitiveDataFloat4s = 9 (36 floats), so the hard ceiling is 7 splats per primitive at this packing. Default MaxLocalSplats = 6 leaves headroom. The CustomDataSlotBase UPROPERTY (default 0) lets designers shift the block when other systems already own slot 0.

Subsystem hook

UTerraInkSubsystem::OnPaintHitDynamic (FiveParams delegate) fires from ApplyBrushAndSplatLocal after PushSplat. Both AOE (PaintZone, +Z normal) and surface (PaintZoneAtHit, real normal) paths reach it. Per-machine fire – replication rides the existing MulticastSurfacePaint, so dynamic-paint state converges on every machine without any new RPC.

Receive-time pipeline

broadcast(WorldPos, WorldNormal, Radius, Layer, Intensity)
  ↓
HandlePaintHitDynamic
  ↓
filter by AcceptedLayers
  ↓
proximity gate (any TargetPrimitive's Bounds.SphereRadius overlaps splat sphere)
  ↓
store WorldPos in FLocalSplat (closest-N eviction at MaxLocalSplats cap)
  ↓
UploadCustomPrimitiveData per TargetPrimitive

WorldPos, not actor-local. Splats live in world space; the per-primitive WorldToLocal conversion happens at upload time. This handles actors with multiple TargetPrimitives at different relative locations (e.g. Lyra Mesh with RelativeLocation = (0, 0, -90)).

Per-primitive radius clamp. MaxRadiusBoundsFraction (UPROPERTY, default 0.7) scales EffectiveRadius = min(InputRadius, Bounds.SphereRadius * fraction) per primitive. A splat with a 300 cm radius from a global PaintZone gets clamped to ~70% of each TargetPrimitive's bounds, so a 100 cm cube doesn't wash out. Set to 0 to disable.

HLSL sampler (MF_TerraInk_ApplyPaint_Local)

Single Custom HLSL node. LocalPosition node with Origin = Pre-Skinned Instance feeds LocalPixelPos. Reads CPD via GetPrimitiveData(Parameters).CustomPrimitiveData[N >> 2][N & 3] for each of 6 splats × 5 floats. Branchless layer routing avoids unrolled-loop undef paths in DXIL strict mode.

The Pre-Skinned Instance mode resolves to:

  • Skeletal mesh – bind-pose component-local position (pre-skinning, invariant to bone animation).
  • Static mesh – instance position (= component-local).

The C++ UploadCustomPrimitiveData dispatches to match: skeletal meshes get bind-pose via Mesh->GetComposedRefPoseMatrix; static meshes get component-local. Both ends of the per-pixel distance compare end up in the same coordinate space, so the same material function works for both mesh types and splats track bones through animation on skeletal characters.

Full graph + paste-ready HLSL block: Plugins/TerraInk/Docs/BrushRecipes/MF_TerraInk_ApplyPaint_Local.txt.

Auto-attach knobs

UTerraInkProjectSettings » Project Settings > Plugins > TerraInk > Paintable Surface:

Knob Default Effect
bAutoAttachSurfaceOnPaint OFF Per-hit auto-attach of UTerraInkPaintableSurfaceComponent to actors with TerraInk-aware materials.
bAutoBindOnInit OFF Init-time scan that pre-binds all matching actors.
AutoBindTagQuery empty Gameplay-tag filter for the init scan.

OFF by default = zero overhead. With both off, no per-hit re-trace cost, no per-actor MID creation cost, no init-time actor walk. Designer opts in by either flipping the toggles or dropping the component manually.

When auto-attach is on, the runtime short-circuits actors whose materials don't declare the SplatCellsTexture parameter – pawns, weapons, FX, anything that wouldn't sample paint anyway gets no useless component.

Scatter suppression on dynamic surfaces

The world-space scatter pipeline assumes static geometry. When a paint hit lands on a Movable actor, the splat goes into world ActiveSplats, the scatter manager spawns instances at the world position, and the actor moves out from under them – leaving meshes floating in mid-air.

UTerraInkProjectSettings::bSpawnScatterOnDynamicSurfaces is the toggle:

Value Behavior on Movable hits
false (default) World pipeline suppressed: no brush stroke, no PushSplat, no instant ISM, no decal. Only the OnPaintHitDynamic broadcast fires. Per-actor CPD listeners still capture the hit.
true Original behavior: full world-space spawn + scatter. Floating-mesh artifact accepted as a project tradeoff.
Tradeoff to know about: when paint hits a movable cube next to a static wall, the wall does not show paint via the world-space SDF either – the splat never enters ActiveSplats so neither cube nor wall sees it. The cube renders its local-CPD paint via UTerraInkDynamicPaintComponent; the wall stays clean. If a project needs the world-space "spillover" visual on nearby static surfaces, a per-splat bIsFromDynamicSurface flag the scatter manager filters on is the cleanest follow-up.

Lyra characters: ChildActorComponent walk

Lyra characters spawn cosmetic body / accessory meshes as separate actors via UChildActorComponent (see ULyraPawnComponent_CharacterParts::SpawnActorForEntry). The renderable mesh is on the child actor, not the parent character.

EnsureSurfacePaintBinding walks one level of UChildActorComponent on the hit actor and binds each child actor too. Adding UTerraInkDynamicPaintComponent to (e.g.) B_Manny.uasset – Lyra's male mannequin cosmetic part BP at Content/Characters/Cosmetics/B_Manny – is enough; paint hits on the parent character will route into the child actor's binding.

To add the component:

  1. Open the cosmetic part BP (B_Manny, B_Quinn, B_TinplateUE4, or your project's equivalent).
  2. Components panel → Add Component → "Terra Ink Dynamic Paint".
  3. Leave TargetPrimitives empty (auto-discovers UMeshComponent at BeginPlay).
  4. Make sure the body's master material uses MF_TerraInk_ApplyPaint_Local.
  5. Compile + Save.

Skeletal mesh: pre-skinned anchoring

For skeletal meshes the splat is stored in bind-pose component-local space, not in the animated frame. Per upload:

  1. USkeletalMeshComponent::FindClosestBone(WorldHit) – returns the bone the hit is closest to.
  2. BoneCurrentTransform.InverseTransformPosition(WorldHit) – point in the bone's animated-frame local coords.
  3. Mesh->GetComposedRefPoseMatrix(BoneIndex).TransformPosition(BoneLocal) – same point in bind-pose component-local.

Stored in the splat's CPD slot. The shader's LocalPosition (Pre-Skinned Instance) returns bind-pose at the pixel level. Both sides of the distance compare are in bind-pose, so the splat tracks the bone through any subsequent animation.

Skin cache compatibility: Pre-Skinned Instance is documented "incompatible with GPU skin cache". If a skeletal mesh has skin cache enabled, the LocalPosition (Pre-Skinned) reads can desync from actual skinning. Workaround: disable skin cache on the cosmetic-part skeletal mesh component (SetSkinCacheUsage(ESkinCacheUsage::Disabled)) or per-asset on the SkeletalMesh. Lyra's B_Manny etc. typically run with skin cache enabled by default; flip it off on dynamic-paint cosmetic parts if you see jittering or wrong-position splats.

Limitations

  • ISM / HISM PerInstanceSMCustomData not wired. Auto-discovery scope is UMeshComponent per-component CPD. ISMs ignore per-component CPD and read per-instance data instead. If you need scatter on dynamic instances, that's a separate path.
  • Hard cap of 7 splats per primitive on UE 5.9. Set by NumCustomPrimitiveDataFloat4s = 9 = 36 floats / 5 per splat. For more than 7 splats per actor, the pragmatic answer is a per-actor render target, not denser CPD packing.
  • Closest-bone single-influence approximation on skeletal. The bind-pose conversion uses the closest bone's transform; real skinning blends multiple bones. Fine for paint-decal scale; insufficient for high-precision joint anchoring.

Replication

The dynamic path inherits the world-space replication. ATerraInkReplicator::MulticastSurfacePaint already fans every paint event to every machine; OnPaintHitDynamic fires inside ApplyBrushAndSplatLocal on each machine independently. Each machine's local component instance accumulates its own splats and writes its own CPD. No new replication channel.

Gameplay events

Two surfaces for gameplay code to react to paint: direct queries on the subsystem and a per-world subscription registry for enter / exit / tier-change events.

Components are all optional. The canonical API is the subsystem's PaintZone / PaintZoneAtHit methods, callable from any C++ or Blueprint. The components below are convenience wrappers; UTerraInkPaintableSurfaceComponent auto-attaches by default. Use the components when they shorten wiring; skip them when paint is driven from an ability, projectile, or game-mode hook.

Painting from gameplay (canonical subsystem API)

// AOE paint at a world point (+Z normal). StyleTag is optional; empty falls
// back to the config's DefaultStyleTag.
UFUNCTION(BlueprintCallable, meta = (AdvancedDisplay = "StyleTag"))
void PaintZone(const FVector& WorldLocation, float Radius,
               ETerraInkPaintLayer Layer, float Intensity,
               FGameplayTag StyleTag = FGameplayTag());

// Surface paint at a hit point (uses the real hit normal). Same StyleTag rule.
UFUNCTION(BlueprintCallable, meta = (AdvancedDisplay = "StyleTag"))
void PaintZoneAtHit(const FHitResult& Hit, float Radius,
                    ETerraInkPaintLayer Layer, float Intensity,
                    FGameplayTag StyleTag = FGameplayTag());

StyleTag is the per-hit override for which style ladder runs for this paint event. Explicit tag wins over the config's DefaultStyleTag; an empty tag resolves to the default; if neither resolves, the layer paints into the RT but no scatter / VFX / decal / PCG runs for that hit.

Known networking limitation: AOE multicast (ATerraInkReplicator::MulticastPaintZone) currently does not carry StyleTag; clients fall back to the layer's DefaultStyleTag for replicated paint. Server-local paint and dynamic-paint per-machine hits resolve the explicit tag correctly.

Direct queries on UTerraInkSubsystem

// How much paint at this position for layer L? Returns [0, 1].
float SamplePaintAt(const FVector& WorldPos, ETerraInkPaintLayer Layer) const;

// Which tier is met? INDEX_NONE if below tier 0.
int32 GetCellTierForLayer(const FVector& WorldPos, ETerraInkPaintLayer Layer) const;

// Resolve the active style for a layer. Explicit tag → config's DefaultStyleTag
// → nullptr.
const FTerraInkPaintStyle* ResolveStyleForLayer(
    ETerraInkPaintLayer Layer, FGameplayTag StyleTag) const;

// Pre-bind every actor matching a gameplay-tag query (alternative to per-hit auto-attach).
void ScanWorldForPaintableSurfaces(
    const FGameplayTagQuery& TagQuery, bool RequireMeshComponent = true);

SamplePaintAt and GetCellTierForLayer are BlueprintPure. Use SamplePaintAt for continuous gameplay (per-tick effect strength); use GetCellTierForLayer for discrete state (am I in tier 1 vs tier 2 of this zone). ScanWorldForPaintableSurfaces is the explicit-opt-in alternative when you've turned the project-wide auto-attach off but still want surfaces pre-bound at level start.

Team-paint helper: TerraInk::GetPaintLayerForTeamIndex(int32 TeamIndex) maps team idx 0…3 to PaintLayer_R/G/B/A; out-of-range returns PaintLayer_None. Useful for territory-paint shooter setups.

UTerraInkPainterComponent (optional convenience)

Thin component that wraps the subsystem's PaintZone call so you can drive paint from a Pawn / PlayerController without writing the subsystem lookup yourself. Use it when prototyping or when the actor naturally owns the painting logic; skip it when paint is already triggered from an ability, projectile, or game-mode hook.

Member Signature / type Notes
DefaultPaintLayer ETerraInkPaintLayer (default PaintLayer_R) EditAnywhere, BlueprintReadWrite. Default layer for parameter-less paint calls.
DefaultRadius float (default 150.0, min 10.0) Brush radius in cm.
DefaultIntensity float (default 1.0, range 0–1) Per-hit intensity.
PaintAt void PaintAt(const FVector& WorldLocation) Paint at a world location using the defaults above.
PaintAtWithParams void PaintAtWithParams(const FVector& WorldLocation, float Radius, ETerraInkPaintLayer PaintLayer, float Intensity) Paint at a location with explicit parameters; ignores the defaults.
PaintAtOwnerLocation void PaintAtOwnerLocation() Paint at the owning actor's current location.
PaintAtHitResult void PaintAtHitResult(const FHitResult& HitResult) Paint at the impact point of a hit result (e.g., from a projectile trace).

All four PaintAt* methods are UFUNCTION(BlueprintCallable) in category "TerraInk", so they appear in Blueprint graphs once the component is attached.

UTerraInkPaintableSurfaceComponent (auto-attached by default)

Binds the splat-cells texture and triplanar parameters to the actor's material slots so the surface material function can sample the right per-pixel cell. The subsystem auto-attaches it on every paint hit when UTerraInkProjectSettings::bAutoAttachSurfaceOnPaint = true (the default), so you usually do not need to add it manually. Manual attachment is only useful when:

  • The actor must be pre-bound at level start – alternatively call UTerraInkSubsystem::ScanWorldForPaintableSurfaces on init.
  • bAutoAttachSurfaceOnPaint was turned off project-wide for explicit opt-in workflows.

Key methods (Blueprint-callable):

  • SetTargetMeshOverride(UMeshComponent*) – pick which mesh on the owner gets bound. If unset, picks the first UMeshComponent found at BeginPlay.
  • RebindPaintRTs() – re-run the bind step. Called automatically by the subsystem when the paint config changes; call manually after swapping the actor's mesh at runtime.

UTerraInkDynamicPaintComponent (opt-in)

Per-actor splat ring buffer encoded into Custom Primitive Data (~6 splats per primitive ceiling). Attach to movable actors that should carry paint when they move; the world-space RT path doesn't follow movement. Most projects don't need this; add it only when an artist asks "why doesn't paint follow the moving cube?". See Dynamic Paint for the full storage / pipeline writeup.

See Paint Config and Dynamic Paint for the two storage paths the receivers feed into.

UTerraInkPaintZoneRegistry

Per-world subsystem. Subscribes actors to a paint layer and fires Blueprint multicast events on transitions. One ticker walks all subscribers at terraink.PaintZonePollHz (default 10 Hz). Auto-cleans dead actors via weak pointers.

UTerraInkPaintZoneRegistry* R = World->GetSubsystem<UTerraInkPaintZoneRegistry>();

R->RegisterListener(MyPawn, ETerraInkPaintLayer::PaintLayer_R, /*MinIntensity=*/0.1f);
R->OnEnterPaintZone   .AddDynamic(this, &ThisClass::HandleEnter);
R->OnExitPaintZone    .AddDynamic(this, &ThisClass::HandleExit);
R->OnPaintTierChanged .AddDynamic(this, &ThisClass::HandleTierChanged);
Delegate Args Fires when
OnEnterPaintZone Actor, Layer, Tier, Intensity Actor crosses MinIntensity from below; carries the tier on entry.
OnExitPaintZone Actor, Layer Actor falls below MinIntensity, or no tier is met.
OnPaintTierChanged Actor, Layer, OldTier, NewTier, Intensity Actor remains inside the zone but its resolved tier changed.

Cleanup

R->UnregisterListener(MyPawn, ETerraInkPaintLayer::PaintLayer_R);
R->UnregisterAllForActor(MyPawn);   // covers every layer this actor watches

Subscribe from a Blueprint

The same registry is fully Blueprint-callable. From a Level BP or a Pawn BP:

1
Get the registry
On Event BeginPlay, drop a Get World Subsystem node and set its Class to TerraInkPaintZoneRegistry. Promote the return pin to a variable named Registry so you can reuse it for cleanup.
2
Register the listener
From Registry, call Register Listener. Set Actor to the tracked actor (Get Player Pawn (0) from Level BP, or Self from a Pawn BP), Layer to the paint layer, and Min Intensity to the entry threshold (e.g., 0.1).
3
Bind the events
From Registry, drop Bind Event to On Enter Paint Zone. Right-click the red event pin and choose Add Custom Event. Blueprint creates a matching event with args (Actor, Layer, Tier, Intensity). Put your gameplay reaction inside that event. Repeat for On Exit Paint Zone and On Paint Tier Changed.
4
Clean up on EndPlay
On Event EndPlay, call Unregister All For Actor on Registry, passing the same actor. The registry also auto-cleans dead actors via weak pointers, so this is belt-and-suspenders for explicit "stop watching" mid-game.
[Event BeginPlay]
    -> [Get World Subsystem (Class=TerraInkPaintZoneRegistry)] -> Registry (variable)
        -> [Register Listener]   Actor=Get Player Pawn(0), Layer=PaintLayer_R, MinIntensity=0.1
        -> [Bind Event to On Enter Paint Zone]   -> Custom Event HandleEnter (Actor, Layer, Tier, Intensity)
        -> [Bind Event to On Exit Paint Zone]    -> Custom Event HandleExit (Actor, Layer)
        -> [Bind Event to On Paint Tier Changed] -> Custom Event HandleTierChanged (Actor, Layer, OldTier, NewTier, Intensity)

[Event EndPlay]
    -> [Registry . Unregister All For Actor] (Self / Get Player Pawn(0))
Pawn BP vs Level BP: prefer a Pawn BP when the tracked actor is the pawn. Level BP's BeginPlay runs before pawn possession in some game modes, so Get Player Pawn may not return a valid reference yet. From a Pawn BP, use Self instead.
Multicasts fire for every registered actor: the delegate args include Actor for filtering. If multiple actors register against the same registry, branch on Actor == Self in your custom event so you don't react to other actors' transitions.
Multiplayer model: server fires events for authoritative gameplay (damage, scoring); client fires for responsive VFX, audio, UI. Both sides arrive at the same logical state because splats replicate.
Caveats: The registration's MinIntensity is independent of the paint config's tier MinIntensity. Set them equal to align gameplay edges with visual tier promotion. OnPaintTierChanged fires only while inside the zone; first-entry tier is delivered through OnEnterPaintZone instead.

Paint Config

UTerraInkPaintConfig is the data asset that drives layer behavior, visual color, brush writes, filters, and scatter tiers.

UTerraInkPaintConfig
  PaintLayer        // ETerraInkPaintLayer this config drives (R/G/B/A/Secondary)
  ChannelMask       // RT channel mask for the layer
  SplatVisualColor  // baked into each splat at write time
  BrushMaterial     // stamps into the zone-map RT
  LayerFilter       // UTerraInkPaintFilter subclass
  Styles            // TMap<FGameplayTag, FTerraInkPaintStyle>
  DefaultStyleTag   // FGameplayTag (meta = "Paint.Style")

The config used to hold a flat Tiers array. As of 5.8 those tiers live inside FTerraInkPaintStyle entries in the Styles map, keyed by a FGameplayTag (e.g. Paint.Style.Default). One paint config can carry many styles; a paint hit selects which style applies via its StyleTag argument.

FTerraInkPaintStyle
  Tiers              // TArray<FTerraInkScatterTier>
  TierExitMargin     // 0.05 default; hysteresis on tier demotion

  const FTerraInkPaintStyle* ResolveActiveTier(
      float Intensity, int32 PrevTier,
      ETerraInkQualityLevel MaxQuality = Cinematic) const;

Paint layers

Five layers are available: R, G, B, A, and Secondary. The first four map to RGBA channels; the secondary layer maps to RT2.

Splat visual color

SplatVisualColor sets the visible color of this layer's paint splats. Designer-tunable per layer, baked into each splat when written.

Brush material

The brush material stamps into the render target and must expose ChannelMask, Amount, and SubtractRate.

Layer filter

Each layer can add hit predicates on top of the global filter via a UTerraInkPaintFilter subclass: surface tags, slope rejection, team ownership, or component class checks.

Styles & DefaultStyleTag

Styles maps a FGameplayTag to a FTerraInkPaintStyle (a tier ladder plus TierExitMargin). DefaultStyleTag is the fallback when a paint hit arrives with an empty tag.

Validation

The editor checklist validates mesh references, tier ordering per style, decal domains, PCG graph references, FastGeo readiness, splat-storage memory, and three new checks: Styles map empty, DefaultStyleTag unset, and DefaultStyleTag not present in Styles.

Tier structure (inside FTerraInkPaintStyle)

A tier is a scatter rule keyed off layer intensity. Full breakdown lives in Scatter Tiers; the field shape is:

FTerraInkScatterTier
  MinIntensity        // threshold at which this tier promotes
  MinQualityLevel     // gate by scalability quality
  Meshes              // FTerraInkTierMeshes (mesh slots + ProxyClass)
  VFX                 // FTerraInkTierVFX (Niagara on promote)
  Decals              // FTerraInkTierDecals (deferred-decal stamps)
  PCG                 // FTerraInkTierPCG (graph references)
  UVPaint             // optional per-tier brush override

MinIntensity should be authored ascending. ResolveActiveTier walks Tiers in order and now gates each entry by MinQualityLevel <= MaxQuality, so a scalability-driven runtime can suppress tiers above a budget. The asset validator catches ordering breaks per style.

SubtractRate (brush material): The brush material's SubtractRate scalar (default 0.5) governs how aggressively this layer's brush subtracts from competing layers when it stamps. Without subtraction, painting layer B over layer A doesn't reduce A's intensity in the zone-map RT, so A's PCG content stays alive even after B's paint covers it visually. Honor this in custom brush materials, or accept that cross-layer overrides will leak.
Legacy migration: The config's PostLoad() override lifts an old flat Tiers array into Styles[DefaultStyleTag] automatically. Once you re-save the asset with real Styles entries, the legacy field is overwritten and migration becomes a no-op.
Authoring tip: One paint config per layer. BrushMaterial is the RT-write side (used to stamp into the scatter-trigger zone maps); the visible color on surfaces comes from SplatVisualColor, not from the brush material's color.

Scatter Tiers

A scatter tier defines what appears when paint intensity crosses a threshold. Tiers are authored inside a FTerraInkPaintStyle – one paint config can carry many styles (see Paint Config), and paint hits pick which style applies via StyleTag.

Mesh slots

Each tier's mesh array is a list of FTerraInkScatterMesh entries. Each entry pairs a static mesh with an optional slot-0 material override:

FTerraInkScatterMesh
  Mesh:             TSoftObjectPtr<UStaticMesh>
  MaterialOverride: TSoftObjectPtr<UMaterialInterface>

The override flows through to ISM and HISM via SetMaterial(0, ...), and to FastGeo via FProceduralISMComponentDescriptor::OverrideMaterials. The asset validator warns when an explicit FastGeo tier carries a WPO material override.

ProxyClass

ProxyClass Backend Use case
Auto Resolver chooses ISM, HISM, or FastGeo. Recommended default for artist-authored tiers.
ISM UInstancedStaticMeshComponent Small counts and simple placement.
HISM UHierarchicalInstancedStaticMeshComponent Larger scatter sets, especially with shadows.
FastGeoProceduralISM FastGeo direct bridge. UE 5.8+ high-density runtime construction.
Instant-on-hit ignores ProxyClass: a tier with bInstantOnHit = true spawns through the immediate CPU line-trace path (UTerraInkSubsystem::SpawnDirectOnSurface), which always uses HISM/ISM — it does not honor ProxyClass, even FastGeoProceduralISM. Only the deferred cell-promotion scatter route respects ProxyClass and can use the FastGeo bridge. The two are independent and can both fire on the same hit.

Auto resolver order

The free function UTerraInkScatterManager_ResolveProxyClass walks these rules in order when ProxyClass = Auto:

  1. If FastGeo is enabled, instance count is high enough, WPO is absent, and collision rules allow it, choose FastGeo.
  2. If the tier casts shadow and instance count is above the shadow threshold, choose HISM.
  3. If instance count is above the no-shadow threshold, choose HISM.
  4. Otherwise, choose ISM.

On UE 5.6 / 5.7 the function early-exits its FastGeo branch (the runtime API does not exist there) so the explicit FastGeoProceduralISM path falls through to HISM as well.

Older engine behavior: On UE 5.6 and UE 5.7, explicit FastGeo tiers silently downgrade to HISM at runtime. Asset authoring intent stays intact. The runtime module emits a single Display log at subsystem init explaining that FastGeo requires 5.8+; no per-tier warnings, no fallback spam.

CVars

CVar Default Purpose
terraink.FastGeo.Enabled 1 Master switch for the direct FastGeo path.
terraink.FastGeo.MinInstanceCount 2000 Auto-rule threshold for picking FastGeo.
terraink.FastGeo.PreviewBackend -1 Editor override: -1 = Auto, 0 = HISM, 1 = FastGeo.
terraink.FastGeo.MaxHandlesPerCell 4 Max FastGeo containers retained per (cell, layer, tier). As paint intensity grows the bridge appends a new container instead of rebuilding; the oldest is evicted when the cap is hit. Clamped to [1, 32].
terraink.UseHISM 0 Engineer force-HISM override (highest priority).
terraink.Debug.ShowProxyClass 0 Encode resolved class into PerInstanceSMCustomData[1] for visualization.

CVars override the cached project-settings snapshot when not at their sentinel value.

Tuning the auto resolver

Three knobs:

  1. terraink.FastGeo.MinInstanceCount – lower this to push more tiers onto FastGeo when you have memory pressure on ActorComponent or PrimitiveComponent LLM tags.
  2. terraink.FastGeo.Enabled 0 – global escape hatch back to HISM for the whole session.
  3. Tier authoring – setting bGenerateSurrogateComponents = true unlocks FastGeo for tiers with collision.

Use terraink.DumpStats to see the actual per-cell distribution.

PCG Bridge

TerraInkPCGBridge connects TerraInk runtime paint state to PCG runtime-gen graphs.

Subscribes to hooks

Uses FTerraInkPCGHooks to respond to build, cleanup, and cleanup-all events from the runtime module.

Exposes paint data

PCG graphs can read splat data, paint intensity, paint configs, and attributes for runtime scatter generation.

Per-world state

State is keyed by TWeakObjectPtr<UWorld> to prevent multi-PIE client worlds from corrupting each other.

Typical graph shapes

  • Density-driven scatter using paint intensity curves.
  • Layer-routed meshes for different biome or team ownership layers.
  • Filter chains combining TerraInk filters with PCG point filters.

See PCG Recipes for the artist-facing recipe book, including the two custom nodes (TerraInk Sample Paint, TerraInk Sample All Layers) and the raw RT user-parameter path.

Hooks

FTerraInkPCGHooks (in TerraInk/Public/TerraInkPCGHooks.h) declares the public delegates the bridge subscribes to. The runtime module fires; the bridge consumes. No hard dependency from runtime to bridge.

Delegate Fires when Payload
Build cluster Cell tier promotes Per cell, per layer, per tier (FTerraInkPCGDispatchContext).
Cleanup Cell tier demotes / exits Per cell, per layer, per tier (takes UWorld* first).
Cleanup all World teardown Per world (nullptr means all worlds).

Engine version

The PCG bridge module compiles on UE 5.6, 5.7, and 5.8+. Whether the resulting PCG graph can route output through FastGeo depends on the engine's PCG runtime-gen pipeline:

  • UE 5.6 / 5.7 – PCG graphs work, but FastGeo output requires the 5.8+ runtime API.
  • UE 5.8+ – full PCG → FastGeo routing via PCGFastGeoInterop and pcg.RuntimeGeneration.ISM.ComponentlessPrimitives.

DumpStats

> terraink.DumpStats
TerraInk: World [PIE] PCGBridge bound=true regenerations=42 last_cell=376

FastGeo Paths

TerraInk supports two independent FastGeo routes on UE 5.8+: direct bridge and PCG-routed.

Path 1: Direct bridge

The scatter manager calls the FastGeo bridge directly. The compute pipeline performs splat-centric placement, optionally gates candidates by Lumen global SDF, and builds a runtime FastGeo container.

UTerraInkScatterManager
  -> TerraInkFastGeoBridge
  -> TerraInkFastGeoCompute
  -> UFastGeoContainer::CreateRuntime

Path 2: PCG-routed

PCG graphs read TerraInk paint data, generate points, then route through PCGFastGeoInterop for FastGeo instancing.

UTerraInkPCGBridge
  -> PCG runtime-gen graph
  -> PCGFastGeoInterop
  -> UFastGeoContainer::CreateRuntime
UE 5.8+ requirement: Both FastGeo paths rely on UFastGeoContainer::CreateRuntime, which is only available at runtime in UE 5.8+.

Lumen SDF gate

The direct path can use the Lumen global SDF to reject candidate instances that would appear in air near platform edges, walls, or finite surfaces.

const bool bSDFAvailable =
  DoesProjectSupportDistanceFields() &&
  IConsoleManager::Get()
    .FindConsoleVariable(TEXT("r.DynamicGlobalIlluminationMethod"))
    ->GetInt() == 1;
Do not use: There is no r.DynamicGlobalDistanceField CVar; don't trust autocomplete here. Use the engine-blessed DoesProjectSupportDistanceFields() function plus the r.DynamicGlobalIlluminationMethod integer check.

Why both paths?

Different latency and authoring trade-offs. The direct bridge is per-paint-event and bypasses PCG graph evaluation; the PCG path lets you compose scatter rules with arbitrary point ops in a graph. Both write into the same FastGeo container abstraction; only the construction route differs.

PCG runtime-gen also re-runs the whole graph for any change, while the direct bridge updates only the affected cell – relevant when paint events arrive rapidly. PCG-routed FastGeo is also GPU-runtime-gen-only: the CPU UPCGComponent::Generate() path does not use componentless primitives even when the engine CVar is set.

FastGeo Bridge API

Public types and free functions for the direct FastGeo path. UE 5.8+ only. For the high-level overview see FastGeo Paths.

What it does

  • Owns per-cell FastGeo cluster handles.
  • Calls BuildTerraInkFastGeoCluster (in TerraInkFastGeoCompute) when the resolver picks the FastGeo backend.
  • Tears clusters down on cell exit, world teardown, or scatter manager shutdown.

Public API

struct FTerraInkBuildClusterRequest
{
    UWorld*                    World;
    UStaticMesh*               Mesh;
    UTextureRenderTarget2D*    PaintRT;
    FVector4f                  ChannelMask;
    FBox                       CellWorldBounds;
    FBox                       ArenaWorldBounds;
    int32                      MaxInstances;
    uint32                     Seed;
    float                      MeshScaleMin;
    float                      MeshScaleMax;
    float                      PaintZ;
    float                      PaintZJitter;
    FVector                    SurfaceNormal;
    bool                       bCastShadow;
    bool                       bEnableCollision;
    TArray<FVector4f>          SplatPackedData;
    /* ... */
};

struct FTerraInkFastGeoClusterHandle
{
    TStrongObjectPtr<UFastGeoContainer> Container;
    bool IsValid() const;
};

FTerraInkFastGeoClusterHandle BuildTerraInkFastGeoCluster(const FTerraInkBuildClusterRequest&);
void DestroyTerraInkFastGeoCluster(FTerraInkFastGeoClusterHandle&);

The handle uses TStrongObjectPtr to keep the runtime container GC-anchored for its lifetime. A weak ref would let GC reclaim the container, leaving the renderer scene with a stale primitive scene data pointer and crashing in the next relevance pass.

Per-world state

Same pattern as the PCG bridge: state keyed by TWeakObjectPtr<UWorld>, cleanup on OnWorldBeginTearDown. This avoids cross-world primitive ID contamination in multi-PIE-client sessions.

Hooks

FTerraInkFastGeoHooks (in TerraInk/Public/TerraInkFastGeoHooks.h) declares the build / cleanup delegates:

Delegate Fires Payload
FTerraInkFastGeoBuildDelegate Resolver picks FastGeo for a (cell, layer, tier, mesh) tuple Request a cluster build.
FTerraInkFastGeoCleanupDelegate Cell exit / tier demote Cleanup specific cluster. Takes UWorld* first.
FTerraInkFastGeoCleanupAllDelegate World teardown Cleanup all clusters. nullptr world means all worlds.

The bridge subscribes on startup; the runtime module fires.

Engine gate

TerraInkFastGeoBridge.Build.cs and TerraInkFastGeoCompute.Build.cs throw a BuildException on UE < 5.8. Drop the modules from your .uplugin on 5.6 / 5.7 to skip them entirely:

"Modules": [
    { "Name": "TerraInk",          "Type": "Runtime", "LoadingPhase": "Default" },
    { "Name": "TerraInkPCGBridge", "Type": "Runtime", "LoadingPhase": "Default" }
    // TerraInkFastGeoBridge and TerraInkFastGeoCompute removed for 5.6 / 5.7
]

Why a direct bridge instead of PCG-routed?

Two reasons:

  1. Per-paint-event updates fit poorly in a PCG graph evaluation model. PCG re-runs the whole graph for any change, while the direct bridge updates only the affected cell. When paint events arrive rapidly (gunfire, ability spam, footstep trails), per-cell update beats whole-graph re-eval by a wide margin.
  2. PCG runtime-gen → FastGeo is GPU-runtime-gen-only. The CPU UPCGComponent::Generate() path does not use componentless primitives even when pcg.RuntimeGeneration.ISM.ComponentlessPrimitives is set; the direct bridge sidesteps that constraint entirely.

See FastGeo Paths for the higher-level comparison between the two routes.

Compute pipeline

The bridge talks to TerraInkFastGeoCompute, which hosts:

  • TerraInkFastGeoPlacement.cpp – splat-centric placement compute shader. Reads the splat array, generates candidate transforms, gates by Lumen global SDF.
  • TerraInkSDFSampler.cpp – view extension that drains a build queue per render thread tick and dispatches the placement plus PCG scene-writer compute shaders.
  • TerraInkFastGeoCluster.cpp – the BuildTerraInkFastGeoCluster and DestroyTerraInkFastGeoCluster entry points that wrap UFastGeoContainer::CreateRuntime and DestroyRuntime.

Failure modes

  • Bridge returns invalid handle. Usually CreateRuntime failed (check log for "UFastGeoContainer::CreateRuntime returned null") or the engine doesn't support runtime FastGeo. Resolver falls back to HISM.
  • Container leaks across cell exits. Ensure FWorldDelegates::OnWorldBeginTearDown is hooked. If containers survive multiple promote / exit cycles, the cleanup delegate isn't firing.
  • Render-thread crash on scene destruction. The cluster destruction path drives the container's tick to completion before returning. A Primitives.Num() == 0 assert suggests a missing FlushRenderingCommands call.

Engine Compatibility

TerraInk supports UE 5.6, UE 5.7, and UE 5.8+, but FastGeo runtime construction is gated behind UE 5.8+.

Feature UE 5.6 UE 5.7 UE 5.8+
Runtime paint Yes Yes Yes
HISM scatter Yes Yes Yes
PCG bridge Yes Yes Yes
FastGeo direct bridge Excluded Excluded Yes
PCG to FastGeo Falls back to HISM Falls back to HISM Yes

Plugin dependencies by engine

Plugin UE 5.6 UE 5.7 UE 5.8+
Niagara Required Required Required
PCG Required Required Required
FastGeoStreaming Optional Optional Required for direct bridge
PCGFastGeoInterop Not present Optional Required for PCG-to-FastGeo

Behavior summary

  • UE 5.6 and 5.7 – HISM path only. FastGeo authoring is preserved (assets stay valid) but routes to HISM at runtime.
  • UE 5.8+ – full FastGeo direct bridge plus PCG-routed FastGeo via PCGFastGeoInterop.

Why no FastGeo runtime on 5.7

UE 5.7 ships UFastGeoContainer and FFastGeoProceduralISMComponent, but only as edit-time / cooked entities. There is no public runtime construction API. PCG's own runtime path goes through the same 5.8 entry point, so even PCG-routed FastGeo is 5.8+. Backporting CreateRuntime from 5.8 into a 5.7 fork is feasible (a few files in Engine/Plugins/Experimental/FastGeoStreaming/) but requires engine modification.

Known UE 5.7 build issue

UE 5.7's Components/InstancedSkinnedMeshComponent.h declares the class with UE_EXPERIMENTAL(5.6, "..."), which expands to a [[deprecated]] attribute MSVC rejects with C3837. Fixed in 5.8. The TerraInkSDFSampler.cpp source ships a 5.7-only macro-suppression block to dodge the parser bug; see Troubleshooting for the pattern if you hit it elsewhere.

Choosing a target

If you... Use
Want maximum compatibility UE 5.6 or 5.7, HISM path only
Need high-density GPU-scene scatter UE 5.8+
Author scatter through PCG runtime-gen graphs UE 5.8+ for FastGeo output; UE 5.6+ for HISM-output graphs

Console Commands and CVars

Common runtime controls for debugging, backend selection, and splat storage.

CVar / Command Purpose
terraink.DumpStats Print per-world cell, splat, bridge, and instance counts.
terraink.UseHISM Engineer override to force HISM.
terraink.FastGeo.Enabled Master switch for the direct FastGeo path.
terraink.FastGeo.MinInstanceCount Auto-resolver threshold for choosing FastGeo.
terraink.FastGeo.PreviewBackend Editor preview override: Auto, HISM, or FastGeo.
terraink.Debug.ShowProxyClass Encodes resolved backend into per-instance custom data for visualization.
terraink.MaxSplats Hard cap on the total number of live splats in the CPU mirror. Older entries evict when the cap is hit.
terraink.SplatGridResolution Acceleration grid resolution per axis. Sampled at paint system initialization.
terraink.MaxSplatsPerCell Per-cell splat slot count in the cell-strip texture.
terraink.PaintZonePollHz UTerraInkPaintZoneRegistry tick rate. Default 10. Sampled at world begin.
pcg.RuntimeGeneration.ISM.ComponentlessPrimitives Engine CVar affecting PCG GPU runtime-gen componentless primitive output.
Debug tip: terraink.Debug.ShowProxyClass 1 encodes proxy class values into PerInstanceSMCustomData[1]: ISM is 0.0, HISM is 0.5, and FastGeo is 0.75.

Troubleshooting

Common failure modes and where to look first. Grouped by area.

Visuals

Changed color did not update

Existing splats keep baked color. Paint fresh splats over the area; closest-N eviction will replace older entries when new splats land closer to cell centers. Restart PIE for a clean reset.

Splat colors look like noise / wrong tints

The splat-sampling material function may be reading row1.rgb as a normal vector instead of a color. Verify the shader treats Row 1 RGB as splatColor.rgb. Surface normals are no longer stored on the GPU after the cell-strip refactor.

Holes in the painted area when re-shooting

Two probable causes: splat radius much smaller than cell size (cells without any splat in their AABB get no paint – raise radius relative to your SplatGridResolution); or MaxSplatsPerCell too low (dense clusters push out edge coverage – bump it, memory grows linearly).

Paint replicates but is invisible on clients

UTerraInkPaintableSurfaceComponent is auto-attached on first paint hit by default (bAutoAttachSurfaceOnPaint = true). It only fails to bind when one of three things is true:

  • bAutoAttachSurfaceOnPaint was turned off project-wide and the actor was never explicitly opted in.
  • The hit didn't replicate to the client (replication path broken, server-only paint).
  • The mesh material doesn't include the TerraInk surface material function and has nowhere to bind paint into.

If terraink.DumpStats shows matching splat counts across machines but visuals still differ, it's a binding issue, not replication.

Multiplayer / PIE

Listen-server PIE: paint shows on client, not server

Two PIE worlds collide on the literal "TerraInkSplatCells" name in the transient package. Pass a unique name via MakeUniqueObjectName when calling UTexture2D::CreateTransient. ZoneMapRT1 / ZoneMapRT2 are unaffected because they're created with World as outer.

Multi-PIE crashes

Symptoms: BitArray OOB in FSceneCullingBuilder or VSM cache invalidation crashes with two PIE clients. Root cause: module-static state keyed only by gameplay coordinates collides across worlds. Fix: key all bridge state by TWeakObjectPtr<UWorld>; hook FWorldDelegates::OnWorldBeginTearDown for early teardown.

Dynamic paint / movable actors

Scatter left floating after a movable actor moves

World-space scatter anchors instances at world coordinates with no concept of which surface generated them. UTerraInkProjectSettings::bSpawnScatterOnDynamicSurfaces defaults to false in current builds – hits on Movable actors skip the world-space spawn pipeline and only fire OnPaintHitDynamic. Flip it back if you've explicitly enabled it.

Lyra characters don't show dynamic paint

Lyra spawns cosmetic body / accessory meshes as separate actors via UChildActorComponent. Add UTerraInkDynamicPaintComponent to the cosmetic-part BP (B_Manny, B_Quinn, ...), and confirm the body's master material uses MF_TerraInk_ApplyPaint_Local. EnsureSurfacePaintBinding walks one level of child actors automatically.

FastGeo

FastGeo shows as HISM in DumpStats

You may be on UE 5.6 / 5.7 (silent downgrade), terraink.FastGeo.Enabled 0 may be set, instance count may be below terraink.FastGeo.MinInstanceCount, or the tier may use a WPO material or have collision without bGenerateSurrogateComponents. Run terraink.Debug.ShowProxyClass 1 to see the resolved class encoded into PerInstanceSMCustomData[1].

FastGeo fallback warning spam

HandleBuild: BuildTerraInkFastGeoCluster failed ... Falling back to HISM. The FastGeo bridge was called on an engine without runtime FastGeo. Make sure the resolver downgrades FastGeoProceduralISM to HISM before the bridge is consulted on UE < 5.8, and that the FastGeo modules' Build.cs files throw BuildException on older engines.

Runtime crash on cell exit

BitArray SetRange index OOB inside FSceneCullingBuilder::MarkForRemove points at the placement buffer publishing garbage transforms. Verify TerraInkFastGeoPlacement.cpp zero-clears the instance buffer before placement runs – the AddClearUAVPass call in DispatchTerraInkPlacement must execute before the placement compute, not after.

stat memory shows no FastGeo wins

The resolver fell back to HISM. Check terraink.DumpStats – if Path reads Standard ISM, FastGeo isn't engaged. Lower terraink.FastGeo.MinInstanceCount for testing; verify the tier doesn't use a WPO material; confirm terraink.FastGeo.Enabled 1.

Lumen SDF check returns false despite Lumen on

Use the engine-blessed function, not a fictional CVar:

const bool bSDFAvailable =
  DoesProjectSupportDistanceFields() &&
  IConsoleManager::Get()
    .FindConsoleVariable(TEXT("r.DynamicGlobalIlluminationMethod"))
    ->GetInt() == 1;

r.DynamicGlobalDistanceField does not exist; don't trust autocomplete.

Editor / build

C3837 in InstancedSkinnedMeshComponent.h on UE 5.7

Engine-side bug: the class declaration uses UE_EXPERIMENTAL(5.6, "...") which expands to a [[deprecated]] attribute MSVC rejects with C3837. Fixed in 5.8. Workaround in TerraInkSDFSampler.cpp (and any other file that transitively pulls in <Components/InstancedSkinnedMeshComponent.h> on 5.7):

#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION == 7
    #include <Misc/Build.h>
    #pragma push_macro("UE_EXPERIMENTAL")
    #undef UE_EXPERIMENTAL
    #define UE_EXPERIMENTAL(Version, Message)
#endif
#include <FastGeoContainer.h>
#include <FastGeoProceduralISMComponent.h>
#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION == 7
    #pragma pop_macro("UE_EXPERIMENTAL")
#endif

#pragma push_macro / pop_macro is portable across MSVC, Clang, and GCC.

UE_EXPERIMENTAL redefinition warning

If you copied the C3837 workaround into a file that doesn't need it, or applied it inside a header, MSVC may emit a redefinition warning. Use the workaround only on .cpp files that transitively pull in InstancedSkinnedMeshComponent.h on 5.7, and gate strictly to ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION == 7.

Editor checklist shows wrong status

The TerraInk editor checklist (Window > TerraInk Checklist) verifies project state. On UE 5.6 / 5.7 the FastGeo section short-circuits to a single info row by design (no FastGeo runtime to validate). On 5.8+, ensure DoesProjectSupportDistanceFields() returns true; if false, Distance Field shadows or generation may be disabled in project settings.

FAQ

Recurring questions and design rationale, collected from the issue tracker and Slack.

Why doesn't changing SplatVisualColor recolor my existing paint?

Splat color is baked into the cell-strip splat texture at write time, not looked up via material parameters. Each splat carries its own color in Row 1 of its texture slot, captured from UTerraInkPaintConfig::SplatVisualColor when the splat was created. Changing the config field after the fact doesn't propagate backwards.

The trade-off was simplicity vs. flexibility: baking the color is one CPU write per splat, no shader-side lookup table, no extra texel per slot, no extra material parameter. Cost: existing paint keeps its old color until you paint fresh splats over it. If you want live-tunable layer colors at the cost of +50% texture memory and a bit more shader work, switch to a 3-texel layout with the layer index in Row 2 and LayerColor0..LayerColorN VectorParameters on the MID.

Does painting one layer over another wipe the previous layer's color?

It transitions, not wipes. The cell-strip storage doesn't know layer identity at the GPU level – each slot holds a color, position, radius, intensity. When layer B paints over an R-painted area, B-splats compete with R-splats via closest-N eviction. B-splats geometrically closer to a cell's center replace the worst R-splat; B-splats farther than every R-splat are dropped for that cell.

Result: the surface progressively transitions from R-color to B-color. Persistent R-splats near cell centers may resist replacement; bump MaxSplatsPerCell or shrink SplatGridResolution (larger cells accept more splats) for sharper transitions. For a hard "B wipes R" override you'd need a layer-priority eviction strategy – not implemented today.

Why both a direct bridge and a PCG path?

Different latency and authoring trade-offs. The direct bridge is per-paint-event and bypasses PCG graph eval; the PCG path lets you compose scatter rules with arbitrary point ops in a graph. Both write into the same FastGeo container abstraction; only the construction route differs.

Why per-world keyed state for the bridges?

Multi-PIE-client sessions run two UWorlds simultaneously. Module-static state keyed by gameplay coords alone collides between the two worlds and corrupts scene-culling bookkeeping (manifesting as BitArray OOB or VSM cache crashes). Keying by TWeakObjectPtr<UWorld> separates the lanes, and plumbing UWorld* through cleanup delegates ensures the right state map gets cleared on world teardown.

Why is FastGeoProceduralISM showing as HISM in terraink.DumpStats?

Three possibilities:

  1. You're on UE 5.6 / 5.7. The resolver silently downgrades on those versions (#if UE_VERSION_OLDER_THAN(5, 8, 0) block in UTerraInkScatterManager_ResolveProxyClass).
  2. terraink.FastGeo.Enabled 0 was set somewhere.
  3. The auto-rule's predicates failed (e.g., your tier uses a WPO material, or has collision without surrogate components).

Run terraink.Debug.ShowProxyClass 1 to see the resolved class encoded in PerInstanceSMCustomData[1] (0.0 = ISM, 0.5 = HISM, 0.75 = FastGeo).

Does TerraInk work without Lumen?

Yes for paint and HISM scatter. The Lumen global SDF gate is only used by the FastGeo direct bridge to reject "in air" placements; without Lumen, the gate is disabled and placement runs without surface validation. The HISM path doesn't use SDF at all.

Can I add custom paint filters?

Yes. Subclass UTerraInkPaintFilter (or implement the filter interface) and reference it from your layer's filter slot. Filters run on every accepted hit before the splat is created; they're pure C++ predicates with no UObject overhead in the hot path.

How do I bake paint into a level?

Splats serialize on the UTerraInkSubsystem per world. Save the world to persist; load to restore. Replicated splats follow the same path on dedicated server, so clients joining mid-session catch up via the standard replication path.

Does FastGeo support shadow casting?

Yes. The bCastShadow field on FTerraInkBuildClusterRequest propagates to the procedural ISM component descriptor. The auto-resolver also factors shadow casting into its decision: tiers that cast shadow get HISM at lower instance counts because shadow culling overhead matters more than instance memory.

Can I disable replication for testing?

Yes, but it requires editing the layer's replication settings, not a CVar. Set the layer's replication frequency to a sentinel value, or disable the layer's replication flag entirely.

What's the minimum Unreal version?

UE 5.6 for HISM scatter. UE 5.8 for FastGeo runtime construction. The plugin was originally developed against UE 5.8+ and ports backward via Build.cs and #if UE_VERSION_OLDER_THAN gates.

Why does the editor checklist show "FastGeo requires UE 5.8+" on my 5.7 build?

By design. The validator early-exits on UE < 5.8 with a single info row instead of running through every FastGeo readiness check. None of those checks are actionable on 5.7 (you can't enable a runtime API that doesn't exist), so showing them as Fail rows would be misleading.

How do I know which CVars affect what?

CVar Affects
terraink.FastGeo.EnabledDirect bridge master switch
terraink.FastGeo.MinInstanceCountAuto-rule FastGeo threshold
terraink.FastGeo.PreviewBackendEditor backend override
terraink.UseHISMGlobal force-HISM (highest priority)
terraink.Debug.ShowProxyClassDebug visualization mode
pcg.RuntimeGeneration.ISM.ComponentlessPrimitivesPCG path (engine CVar, GPU runtime-gen only)

The plugin is huge. Can I trim modules I don't use?

Yes, on a per-engine basis through the .uplugin Modules array:

  • Always requiredTerraInk. The runtime module owns the subsystem, scatter manager, replicator, paintable surface component, and dynamic paint component. Painting and HISM scatter run with this module alone.
  • Editor-onlyTerraInkEditor, TerraInkTests.
  • Optional (PCG)TerraInkPCGBridge. Required only if any tier in any paint config uses PCG.Graphs. HISM / ISM / FastGeo paths run without it. The runtime fires its dispatch delegate unconditionally; if the bridge isn't loaded the call is a no-op.
  • Optional (UE 5.8+ only)TerraInkFastGeoBridge, TerraInkFastGeoCompute. Required only for the direct FastGeo path (ProxyClass = FastGeoProceduralISM or Auto resolving to FastGeo). Without these, scatter falls back to HISM.

Drop any module you don't need from the .uplugin for that engine version. The runtime is decoupled enough that missing optional modules just mean the corresponding delegates have no subscribers.