Projectile Optimization and FPS Improvement in Unreal Engine
I’ve been struggling with this issue for quite some time — the FPS drop that happens as soon as the number of projectiles increases beyond 100. I’m sure many of you have faced the same issue, so I wanted to share the approach while developing my Turret Plugin Mortar Pro Turret Creator Plugin
There may be more efficient solutions out there, but this is what worked for me. I’d love to hear thoughts or suggestions for improvement .
Problem: Having Separate Actors as Projectiles
Setting up projectiles as actors is often the quickest and easiest approach.
A player or AI spawns a projectile actor with configured speed, collision, and damage. On hit, it spawns Niagara effects and applies damage and this was the same approach I followed initially for my plugin
However, having hundreds of ticking actors quickly becomes a bottleneck — especially when aiming for massive projectile counts. Each actor ticking independently adds up fast.
AActor* ProjectileObj = nullptr;
FActorSpawnParameters ActorSpawnParams;
ActorSpawnParams.Owner = OwnerActor;
ActorSpawnParams.Instigator = OwnerActor->GetInstigator();
ActorSpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
ProjectileObj = GetWorld()->SpawnActor<AActor>(SpawnClass,SpawnLocation,SpawnRotation, ActorSpawnParams);
Optimization 1: Disable Individual Tick
The first optimization was simple — disable tick on individual projectile actors.
This prevents hundreds of tick calls per frame. I
Optimization 2: Aggregate Projectile Movement Tick
The ProjectileMovementComponent
is powerful, but when hundreds of them tick simultaneously it will affect the performance which it did in my plugin.
To fix this:
- I created an Aggregate Manager (could be a subsystem).
- All projectile movement updates were processed in a single tick loop inside this manager.
//This will tick all the actor components registered
void AggregateSubSystem::ExecuteTick(ETickingGroup TickGroup, float DeltaTime, ELevelTick TickType, ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
{
for (FActorComponentTickFunction* Func : TickableComponents)
{
Func->ExecuteTick(DeltaTime, TickType, CurrentThread, MyCompletionGraphEvent);
}
// Cleanup after ticking all components
//Like for actors that are dead we want to remove the tick
for (FActorComponentTickFunction* Func : CleanupQueue)
{
TickableComponents.Remove(Func);
}
CleanupQueue.Empty();
}
Optimization 3: Use Significance Manager
Epic provides the Significance Manager to prioritize objects relative to others (usually the player).
In my plugin, MortarProPlugin, I used this to dynamically adjust tick rates based on distance from the player.
Far-away projectiles update less frequently
Optimization 4: No Separate Actors (Manager-Based System)
This was the major improvement — about a 16 - 20% FPS improvement.
Instead of spawning individual actors:
- I created a manager class (can be an actor or subsystem) that stores all projectile data in arrays of structs.
Example data per projectile:CurrentPosition
CurrentVelocity
Lifetime
- The manager loops through and updates all projectiles in its tick.
Sample snippet
void BulletHellManager::Tick(float DeltaTime)
{
//Code omitted
Super::Tick(DeltaTime);
for (int32& ActiveIndex : ProjectilesIndex)
{
// Get Updated Rotation,Velocity and Position
FVector OldPosition = ProjectileInstance[ActiveIndex].Position;
ProjectileInstance.Rotation = GetUpdatedRotation(ProjectileInstance[ActiveIndex], DeltaTime);
ProjectileInstance.Velocity = GetUpdatedVelocity(ProjectileInstance[ActiveIndex], DeltaTime);
ProjectileInstance.Position = GetUpdatedPosition(ProjectileInstance[ActiveIndex], DeltaTime);
FHitResult Hit;
if (DoLineTrace(Hit, ProjectileInstance[ActiveIndex]))
{
ExplosionIndex.Add(ActiveIndex);
}
}
UpdatePositions(ProjectileInstance,ActiveIndex);
//Report Collision to Blueprint
BPExplosion(ProjectileInstance, ExplosionIndex);
}
Handling Collision
In the manager class now each projectile performs a simple line trace instead of relying on complex per-actor collision.
This keeps the logic lightweight and fast.
FX Handling
Spawning a Niagara effect per projectile or having per-projectile Niagara components is expensive.
Instead:
- I Used a global Niagara system.
- and Feed projectile hit data via Niagara Data Channels to trigger effects efficiently.
Static Mesh Rendering
Using multiple static meshes per projectile adds rendering overhead.
Instead of adding static meshes per projectile, which I initially did in my plugin, I used Instanced Static Mesh (ISM) components. I avoided Hierarchical ISM (HISM) due to warnings about instability in dynamic updates (as mentioned by Epic). Instanced Static Mesh Component in Unreal Engine | 虚幻引擎 5.6 文档 | Epic Developer Community
Bonus Tips
Further improvements can be explored, like Async Line Traces and Parallel For Loops for projectile updates.
For now, I kept it simple as I found a post related to thread safety while doing line trace in a thread.
Line Trace
Would like to thank Unreal Engine forums and Discord channel for helping me to get to this solution.