Integrating TerraInk into a fresh Lyra project.
Every change a Lyra-based game needs to make to integrate TerraInk after a clean drop
of the plugin. Cloned Lyra, copied TerraInk into Plugins/,
ready to wire it up so a ranged weapon paints zones on hit. Build.cs entries,
Gameplay-Ability wiring, multiplayer authority, and a verification checklist.
Prerequisites
- UE 5.6 or newer (5.8 recommended, used during plugin development).
- Lyra Sample Game source available.
LyraGamemodule compiles cleanly before any TerraInk edits. - Engine-side plugins TerraInk depends on are enabled:
Niagara,PCG,PCGFastGeoInterop,FastGeoStreaming. The TerraInk.upluginalready declares them.
Plugin install
Copy the entire TerraInk folder to:
<YourProject>/Plugins/TerraInk/
The plugin ships six modules:
| Module | Type | Purpose |
|---|---|---|
TerraInk | Runtime | Subsystem, scatter manager, replicator, types. |
TerraInkEditor | Editor | Validators, Configuration Checklist tool. |
TerraInkPCGBridge | Runtime | PCG dispatch + custom PCG nodes. |
TerraInkFastGeoBridge | Runtime | FastGeo container interop. |
TerraInkFastGeoCompute | Runtime | FastGeo GPU compute path. |
TerraInkTests | DeveloperTool | Standalone tests, excluded from Shipping. |
<YourProject>.uproject does not need a manual edit; the plugin
registers itself when the engine scans Plugins/.
Module dependency in LyraGame
Open Source/LyraGame/LyraGame.Build.cs and add "TerraInk" to
PublicDependencyModuleNames:
PublicDependencyModuleNames.AddRange(
new string[] {
// ...existing entries...
"TerraInk",
}
);
This is the only Lyra-side build dependency required. The other TerraInk modules
(TerraInkPCGBridge, etc.) load themselves; LyraGame only needs
the runtime module.
Wire paint into the ranged weapon ability
ULyraGameplayAbility_RangedWeapon is where every bullet impact resolves
into an FHitResult – the natural place to call
PaintZoneAtHit.
4a. Header: add three authored fields
In Source/LyraGame/Weapons/LyraGameplayAbility_RangedWeapon.h:
#include <TerraInkTypes.h> // up with the other includes
// inside the class, in the public block
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category="Lyra|Net Paint")
float PaintRadius = 289.f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category="Lyra|Net Paint")
ETerraInkPaintLayer PaintLayer = ETerraInkPaintLayer::PaintLayer_R;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category="Lyra|Net Paint")
float PaintIntensity = .15f;
PaintLayer is the fallback layer used when the avatar is
not on a team (solo / no-team modes). Team-tagged avatars override it at runtime.
4b. Cpp: includes
Near the top of LyraGameplayAbility_RangedWeapon.cpp:
// TerraInk: weapon impact painting
#include <TerraInkSubsystem.h>
#include <TerraInkTypes.h>
#include "Teams/LyraTeamSubsystem.h"
4c. Cpp: paint at every blocking hit, server-authoritative
Inside OnTargetDataReadyCallback (or wherever
OnRangedWeaponTargetDataReady is invoked), gate on authority and call
PaintZoneAtHit for each blocking hit:
if (HasAuthority(&CurrentActivationInfo))
{
if (UTerraInkSubsystem* TerraInkSub = GetWorld()->GetSubsystem<UTerraInkSubsystem>())
{
// Resolve team -> layer once per shot. Falls back to the ability's authored
// PaintLayer for solo/no-team avatars
ETerraInkPaintLayer ResolvedLayer = PaintLayer;
if (const ULyraTeamSubsystem* Teams = GetWorld()->GetSubsystem<ULyraTeamSubsystem>())
{
const int32 TeamId = Teams->FindTeamFromObject(GetAvatarActorFromActorInfo());
const ETerraInkPaintLayer Mapped = TerraInk::GetPaintLayerForTeamIndex(TeamId);
if (Mapped != ETerraInkPaintLayer::None)
{
ResolvedLayer = Mapped;
}
}
for (int32 TargetIdx = 0; TargetIdx < LocalTargetDataHandle.Num(); ++TargetIdx)
{
if (const FGameplayAbilityTargetData_SingleTargetHit* HitData =
static_cast<const FGameplayAbilityTargetData_SingleTargetHit*>(LocalTargetDataHandle.Get(TargetIdx)))
{
if (HitData->HitResult.bBlockingHit)
{
TerraInkSub->PaintZoneAtHit(
HitData->HitResult,
PaintRadius,
ResolvedLayer,
PaintIntensity);
}
}
}
}
}
- The ranged-ability target-data callback fires on the predicting client and the server. Without an authority gate the paint multicasts twice and clients see double-stamped paint.
- The server's
PaintZoneAtHitroutes throughATerraInkReplicator::MulticastSurfacePaint, which fans the stroke to every machine, including the originating client. Same latency story as any other replicated effect.
4d. Optional: per-shot style override
If you want different scatter/decals/VFX per weapon, pass a FGameplayTag
as the 5th arg:
TerraInkSub->PaintZoneAtHit(HitData->HitResult, PaintRadius, ResolvedLayer, PaintIntensity, WeaponStyleTag);
WeaponStyleTag lives wherever the weapon stores it (PlayerState, weapon
instance, ability spec). Empty tag = use the layer's DefaultStyleTag.
Author the data assets
/Game/TerraInk — point your world setup actor at
those configs to skip the authoring pass below. Read on if you want to build your own.
For a 2-team layout (Splatoon-style) you need two UTerraInkPaintConfig assets:
| Asset | PaintLayer | SplatVisualColor | DefaultStyleTag |
|---|---|---|---|
DA_TeamA_PaintConfig |
PaintLayer_R |
red-ish | Paint.Style.Default |
DA_TeamB_PaintConfig |
PaintLayer_G |
blue-ish | Paint.Style.Default |
Each config carries a Styles map. One entry minimum (the
Paint.Style.Default you set above) with at least one tier. Example tier:
Styles
Paint.Style.Default
Tiers
[0]
MinIntensity = 0.0
Meshes.ScatterMeshes [optional]
VFX.Systems [optional]
Decals.Materials [optional]
PCG.Graphs [optional]
bInstantOnHit = true (for one-shot hit scatter)
For per-weapon variation add Paint.Style.Pistol,
Paint.Style.Rifle, etc. inside the same map. Both team configs share the
same tag scheme; each has its own visuals.
Initialize the paint system
You need one of these two paths. The actor is the recommended fast path; manual init is for projects that want game-mode control.
7a. Recommended: drop ATerraInkWorldSetup in the level
ATerraInkWorldSetup is an optional convenience actor. It
is not required, only the easiest entry point. Drop it into your gameplay map (or the
persistent level for World Partition setups) and configure:
| Property | Value |
|---|---|
ArenaBounds | World-space FBox covering the painted area. |
PaintLayerConfigs | Both team configs (TeamA + TeamB). |
GridResolution | 32 (default; raise for finer cells). |
RTResolution | 2048 (default). |
GroupSize | 4 (must divide GridResolution). |
bSpawnDebugPlane | true while iterating, off for ship. |
The actor's BeginPlay registers each config with the subsystem and calls
InitializePaintSystem. No further setup required.
7b. Alternative: manual init from a game mode / experience component
If you do not want a placed actor, do the same handful of calls yourself – for
example on your ALyraGameMode::InitGame or an experience-action
component's activation:
UTerraInkSubsystem* TerraInkSub = World->GetSubsystem<UTerraInkSubsystem>();
TerraInkSub->SetArenaBounds(MyArenaBounds);
TerraInkSub->RegisterPaintLayerConfig(TeamAConfig);
TerraInkSub->RegisterPaintLayerConfig(TeamBConfig);
TerraInkSub->InitializePaintSystem();
This is the path to take when arena bounds are computed from streamed-in level data, or when the game mode wants to swap configs based on selected map / mode.
Optional components
None of these are required for the basic shooting + scatter flow described in steps 1–7. Add them only when the listed feature is needed.
| Component | Status | What it adds |
|---|---|---|
UTerraInkPaintableSurfaceComponent |
Auto-attached on paint hit by default (bAutoAttachSurfaceOnPaint = true). |
Binds the live paint render targets into the actor's material slots so visible paint shows up on that mesh. Only needed manually if you turn the auto-attach off. |
UTerraInkDynamicPaintComponent |
Opt-in. | Per-actor splat ring buffer in local space, encoded into Custom Primitive Data. Use on movable actors that should carry paint when they move. Capped at ~6 splats per primitive by the 32-float CPD ceiling. See the Dynamic Paint section. |
UTerraInkPaintZoneRegistry (world subsystem) |
Optional, registered automatically when used. | Multicast Blueprint events for "actor entered/exited zone of layer X". Subscribe from gameplay BPs that need to react to paint coverage. |
UTerraInkDynamicPaintComponent to
specific actor BPs only when an artist asks "why is the enemy not getting painted when
it walks through red zones?"
Multiplayer notes
- Server authority on paint events. The authority gate in step 4c is required.
ATerraInkReplicator(auto-spawned by the subsystem) handles the multicast. - Late-joining clients. Existing paint state on the zone-map RTs replicates via the standard replication graph; the splat cell texture follows the same path. New clients see the current paint state when they join.
- Config swap mid-session.
ATerraInkWorldSetup::SwitchToConfig(Tag)is server-only authority and multicasts to all clients. Used for phase changes (Day → BloodMoon, Calm → Storm).
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.
Verification checklist
Run through these before declaring the integration done.
- LyraEditor compiles clean:
Engine/Build/BatchFiles/Build.bat LyraEditor Win64 Development -Project=<...>.uproject. - Right-click
DA_TeamA_PaintConfig> Validate Asset shows no errors. - PIE single-player: equipped weapon paints the ground at impacts; tier scatter spawns once intensity exceeds tier 0.
- PIE 2-client: each client's avatar paints in its own team color (Team 0 → R, Team 1 → G). Both clients see both teams' paint.
terraink.DumpStatsconsole command prints non-zero ISM / scatter counts after a few shots.- No log warnings from
LogTerraInkmatching*PaintZone called before InitializePaintSystem*(means the world setup actor never ran).
Common pitfalls
-
Forgot authority gate.
Symptom: double scatter, doubled paint intensity per shot. Fix: wrap the call in
if (HasAuthority(&CurrentActivationInfo))as shown in step 4c. -
PaintLayerConfigsempty on the world setup actor.Symptom: warnings on every paint hit "no config registered for layer N". Either wire the configs or accept the placeholder default config.
-
Team subsystem lookup returns
INDEX_NONE.Solo modes hit this; the fallback to authored
PaintLayerkeeps the weapon working. Verify your game mode actually assigns teams viaULyraTeamSubsystem::ChangeTeamForActor(or your own equivalent). -
DefaultStyleTagis unset on a config.Symptom: paint paints the RT but no scatter / VFX / decals spawn. Validator flags this as a Warning at edit time.
-
Tags missing from the gameplay tag manager.
Tag pickers show empty lists. Add the
Paint.Style.*entries toDefaultGameplayTags.inibefore authoring configs.