Skip to main content
Iris Replication System: The Forest of Ten Thousand Trees

Iris Replication System: The Forest of Ten Thousand Trees

·1023 words·5 mins
Iris - This article is part of a series.
Part 2: This Article
Let’s grow a dense forest and see how the replication systems handle it.

Introduction
#

adding more replicated entities and making the scenario closer to a real gameplay situation.

When I said that last time, I was being a bit disingenuous — this has nothing to do with a real gameplay situation. However, the number of entities will indeed increase.
I decided to fill the game level with a forest, where each tree is a replicated AActor.
Every tree has a health parameter and loses one point of health per second.
Simply replicating a float or an int felt too boring, so I added a UAbilitySystemComponent with a UHealthAttrSet to each tree.

In a real project, this is not a recommended approach — there are much more efficient ways to generate a forest.

Preparation
#

First, we need a tree actor that visually reflects its health state. The lower the health value, the redder the tree becomes.
To achieve this, we’ll adjust the HealthRatio parameter in the MaterialInstance.

void ATreeActor::BeginPlay()
{
	// Create a MaterialInstance to control the tree color based on its health
	ForEachComponent(false,
		[this](UActorComponent* Component)
		{
			if (auto Primitive = Cast<UPrimitiveComponent>(Component))
				if (auto Instance = Primitive->CreateAndSetMaterialInstanceDynamic(0))
					MaterialInstances.Add(Instance);
		});

	// Subscribe to health change events
	AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(HealthAttrSet->GetHealthAttribute())
		.AddUObject(this, &ThisClass::OnHealthValueChanged);
}

void ATreeActor::OnHealthValueChanged(const FOnAttributeChangeData& Data)
{
	auto HealthRatio = Data.NewValue / FMath::Max(HealthAttrSet->GetHealthMax(), UE_KINDA_SMALL_NUMBER);
	for (auto Instance : MaterialInstances)
		Instance->SetScalarParameterValue(TEXT("HealthRatio"), HealthRatio);
}
Fun fact: UAbilitySystemComponent doesn’t require ticking, so you can safely disable ticking on ATreeActor.

Here’s what the UHealthAttrSet class looks like:

UCLASS()
class THEGAME_API UHealthAttrSet : public UAttributeSet
{
	GENERATED_BODY()
public:
	UPROPERTY(EditAnywhere, BlueprintReadOnly, ReplicatedUsing = OnRep_Health)
	FGameplayAttributeData Health;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, ReplicatedUsing = OnRep_HealthMax)
	FGameplayAttributeData HealthMax;

	ATTRIBUTE_ACCESSORS(UHealthAttrSet, Health)
	ATTRIBUTE_ACCESSORS(UHealthAttrSet, HealthMax)
};

To handle health reduction, I created GE_DamageOverTime, which decreases health by 1 point every second. This effect is applied to every tree 30 seconds after the server starts. Technically, this isn’t the best approach — since all trees begin losing health simultaneously, it causes noticeable frame drops.

And of course, we need the tree mesh itself. Because there will be so many trees, I went with the simplest possible mesh — minimal polygons, nothing fancy. Additionally, collisions should be disabled so clients don’t run into the trees, and shadows should be turned off to improve FPS during testing and development.

Low-poly tree
My gorgeous tree: 33 vertices, 25 polygons

To populate the world with trees, I use Procedural Content Generation (PCG) with a simple graph that places around 10,000 trees over a 2 km² area. At a density of 0.0026 trees per square meter, the result is 10,201 trees.

Testing
#

Everything is set up roughly the same as in the previous test.

Iris Replication System
#

We start the test and connect to the server. The first impression isn’t great — there are noticeable lags and network corrections.
When a strong desynchronization occurs between the player’s position on the client and the server, the server forcibly “snaps” the client back to the correct position.
This manifests as sharp jerks of both the camera and the character.

The log is flooded with warnings indicating that the server is struggling to handle incoming packets:

LogNetPlayerMovement: Warning: ServerMove: TimeStamp expired: 53.433105, CurrentTimeStamp: 54.618511, Character: BP_PlayerCharacter_C_2147471470
LogNetPlayerMovement: Warning: ServerMove: TimeStamp expired: 53.433105, CurrentTimeStamp: 54.643517, Character: BP_PlayerCharacter_C_2147471470
LogNetPlayerMovement: Warning: ServerMove: TimeStamp expired: 53.433105, CurrentTimeStamp: 54.668522, Character: BP_PlayerCharacter_C_2147471470

The result is, of course, disappointing…
But if you look at it from another angle — quite inspiring!

The default replication system only sends data about actors located within a 150-meter radius around each player.
Iris, on the other hand, applies this rule only to APawn, while all other actors are marked as AlwaysRelevant by default — meaning they are replicated to all players, all the time.

; BaseEngine.ini from Engine

[/Script/IrisCore.ObjectReplicationBridgeConfig]
RequiredNetDriverChannelClassName=/Script/Engine.DataStreamChannel
; Filters
DefaultSpatialFilterName=Spatial
; Filter configs should typically be configured per game
; Set the class-based dynamic filters configs here. Using None means the objects of this class are always relevant.
!FilterConfigs=ClearArray
; NotRouted means that this type will not be replicated
;+FilterConfigs=(ClassName=/Script/Engine.LevelScriptActor, DynamicFilterName=NotRouted) 
; By default classes derived from AActor are always relevant (because not filtered dynamically)
+FilterConfigs=(ClassName=/Script/Engine.Actor, DynamicFilterName=None)) 
; Info types aren't supposed to have physical representation and rely on a static priority
+FilterConfigs=(ClassName=/Script/Engine.Info, DynamicFilterName=None)
+FilterConfigs=(ClassName=/Script/Engine.PlayerState, DynamicFilterName=None)
; Pawns can be spatially filtered...
+FilterConfigs=(ClassName=/Script/Engine.Pawn, DynamicFilterName=Spatial))

By default classes derived from AActor are always relevant (because not filtered dynamically)

All Iris settings are configured through the DefaultEngine.ini file.
Let’s add our ATreeActor to the Spatial filter.

; DefaultEngine.ini
[/Script/IrisCore.ObjectReplicationBridgeConfig]
+FilterConfigs=(ClassName=/Script/TheGame.TreeActor, DynamicFilterName=Spatial))

Now that’s a result — Iris delivers impressive performance.

No noticeable network corrections can be seen — everything runs smoothly.
When the player approaches the green trees (they appear green because they are outside the replication radius), they immediately turn red as soon as they enter the visibility range, replicating their current health values.
It’s time to compare it with the standard replication system.

Default Replication System
#

The situation here is, to put it mildly, far from ideal. The default replication system barely maintains stability, running at around 7 frames per second. There’s almost no time left for gameplay logic — the server is overloaded with network data processing.

This experiment clearly demonstrates how much Iris outperforms the standard system.
All that’s left is to wait for Iris to move from Experimental to Beta status and become a fully production-ready solution.

Conclusions
#

In this scenario, Iris delivers an impressive result — the system efficiently filters and processes a massive number of replicated entities without significant performance loss.


About the Charts
#

As you’ve probably noticed, the graphs in this article look smooth — that’s because I apply frame time smoothing for better readability and visual clarity. In this analysis, I’m primarily interested in the area under the curve, not individual spikes. Sharp frame time jumps that occur when all 10,000 trees lose health simultaneously (sometimes reaching 200 ms) don’t significantly affect the overall picture.

Here’s an example:

As you can see, the smoothed graph shifts slightly upward, moving above the 60 FPS budget line, while the raw, unsmoothed graph has several points that fall below this threshold.

Iris - This article is part of a series.
Part 2: This Article