Object Pooling Patterns
Pooling reduces SpawnActor cost (CDO copy + component register + BeginPlay + replication channel open) by ~60% on community-measured projectile bursts. The wins are real but the bug surface is large — cached pointers, double-acquire, ProjectileMovement state retention, GAS attribute leakage. This tutorial covers actor pools, projectile pools, GAS pooling, and replication-aware patterns.
Why pooling on UE5
The SpawnActor cost breakdown:
- CDO copy — creating the actor instance from defaults.
- Component register — attaching components to the world.
- BeginPlay — running per-actor + per-component init logic.
- Replication channel open — if replicated, opens a network channel.
Community-reported numbers (Hadiosh, UE 5.3 — methodology not published): pooled spawn ~60% faster than SpawnActor; 100-projectile burst ~45 ms with hitch on SpawnActor vs ~6 ms stable when pooled. Treat as directional rather than authoritative — the magnitude is right (multi-x speedup), the precise percentage will vary heavily with what the actor's BeginPlay + components do. The orthogonal win is GC pressure: pooled objects don't enter GC, so a churn-heavy game stops paying for repeated mark/sweep cycles.
Subsystem choice (World vs GameInstance)
UWorldSubsystem— level-scoped, auto-tears-down. Right for projectile / VFX pools that don't need to survive level changes.UGameInstanceSubsystem— cross-level persistence. Right for AI pools, character pools, long-lived game objects.
UCLASS() class UProjectilePoolSubsystem : public UWorldSubsystem { GENERATED_BODY() public: virtual void Initialize(FSubsystemCollectionBase& Collection) override; virtual void Deinitialize() override; AProjectile* Acquire(TSubclassOf<AProjectile> Class, const FTransform& Transform); void Release(AProjectile* Projectile); private: TMap<UClass*, TArray<AProjectile*>> FreePool; void PrewarmPool(TSubclassOf<AProjectile> Class, int32 Count); };
The IPoolable interface contract
Per the BFObjectPooling pattern:
OnAcquiredFromPool()— called on pool reuse; reset state, activate components.OnReleasedToPool()— called on pool return; deactivate components, clear references.ResetForReuse()— explicit reset for predictable re-init.- Don't cache pooled handles outside lifetime — the same pointer can be re-acquired by another caller.
Use TWeakObjectPtr with a generation counter for any external reference to a pooled actor — or RAII-wrap with a FScopedPooledHandle that check()s the in-pool flag on access.
Lifecycle: hibernation pattern
Per Lyra's pooling pattern, the soft hibernate recipe on Release:
void AProjectile::OnReleasedToPool() { // Hide visually SetActorHiddenInGame(true); // Disable collision SetActorEnableCollision(false); // Stop ticking SetActorTickEnabled(false); // Detach from physics scene if simulating if (CollisionMesh->IsSimulatingPhysics()) { CollisionMesh->SetSimulatePhysics(false); } // Stop ProjectileMovement explicitly if (ProjectileMovement) { ProjectileMovement->StopMovementImmediately(); ProjectileMovement->HomingTargetComponent = nullptr; ProjectileMovement->SetActive(false); } }
Warmup at level load
Pre-spawn pools in the subsystem's Initialize, but spread across frames to avoid load-time hitches:
void UProjectilePoolSubsystem::PrewarmPool(TSubclassOf<AProjectile> Class, int32 Count) { // Spawn N actors across N frames using a Timer int32 Remaining = Count; GetWorld()->GetTimerManager().SetTimer(WarmupTimer, [this, Class, Remaining]() mutable { if (Remaining-- > 0) { AProjectile* New = GetWorld()->SpawnActor<AProjectile>(Class); New->OnReleasedToPool(); FreePool.FindOrAdd(Class).Add(New); } }, 0.001f, true); }
Per bennystarfighter's pool docs: pre-spawning >~200 actors hits a community-noted threshold where component auto-activate cost dominates warmup. Disable auto-activate on Niagara/audio components, activate in OnAcquiredFromPool.
Pool sizing strategy
- 95th-percentile concurrent count + headroom — measure during gameplay; size pool to handle worst realistic burst.
- Auto-grow vs fixed-cap — auto-grow handles unexpected bursts but never shrinks. Fixed-cap returns failures or stalls. Hybrid: warm size + bounded growth.
- ~200-actor warmup ceiling — per community guidance; above this, component activation cost dominates.
Replication-aware pooling
Server-authoritative pools must be on the server only. Client-side pools must not allocate replicated UObjects. The pattern:
- Use
SetNetDormancy(DORM_DormantAll)on Release rather thanDestroy(). The actor stays in the replication graph but doesn't replicate. FlushNetDormancy()on Acquire to wake the actor for replication.- Don't toggle
bHiddenfor replication — that flips relevancy churn. Dormancy is the correct primitive. - Channel-close cost — even pooled actors that survive a cycle keep their channel; clear any client-side caches keyed on
AActor*.
GAS / AbilitySystemComponent pooling
ASC is non-persistent unless the owning pawn is pooled. On pooled-pawn release:
- Reset attribute sets via
RemoveActiveEffectsWithGrantedTags(). - Clear active gameplay effects — without this, you get "ghost" buffs across pool cycles.
- Reseat Owner / Avatar — ASC retains old pointers.
Per Vorixo's GAS guide: ASCs accumulate ActiveGameplayEffects and don't auto-reset.
ProjectileMovement reset trap
UProjectileMovementComponent retains last velocity and homing target across pool cycles. Per the Epic forums thread:
ProjectileMovement->StopMovementImmediately(); ProjectileMovement->HomingTargetComponent = nullptr; ProjectileMovement->Velocity = LaunchVelocity; ProjectileMovement->SetActive(false); ProjectileMovement->SetActive(true, true); // reset=true; required for re-activation
The double SetActive call is from the community thread — Activate ignores re-activation if the component thinks it's already active. SetActive(false) first, then SetActive(true, /*bReset*/true).
Mass integration
For high-density actor pools (>500 entities), Mass Entity is often the better path:
IMassActorPoolableInterfaceon representation actors.UMassActorSpawnerSubsystemrecycle path.- Don't mix Mass with hand-rolled pools for the same content — Mass owns lifecycle.
UObject*
Per Epic forums, event handlers comparing pointers across a Mass pool cycle will incorrectly match. Use generational handles, not raw pointers.
Pre-submit checklist
- External references use
TWeakObjectPtr+ version, not raw pointers. - RAII handle wrapper with
check()on the in-pool flag. - ProjectileMovement explicitly reset on Release: stop, clear homing, deactivate.
- BehaviorTree / Blackboard cleared on Release.
StopLogic(), clear all keys. - AIController
UnPossessif AI-controlled. - GAS active effects swept on Release.
- Component auto-activate disabled for pooled-actor components; activate in
OnAcquiredFromPool. - Replicated pooled actors use dormancy, not visibility toggle.
- Pre-spawn count under ~200 per pool to avoid load-time hitch.
PerfGuard "pool-health scenario" warms pool at level start, fires N projectiles in fixed cadence, captures stat game SpawnActorTime, GC stall count, obj list UObject delta. CI catches three pool bug classes automatically: pool not warming (high SpawnActorTime on first burst), pool leak (UObject count drifts upward across cycles), double-acquire (collision/tick re-enable spikes inside a single hidden-state window).
- Gotcha #10: Garbage Collection Hitches — pooling is the GC mitigation.
- Networking & Replication — replication-aware pooling deep dive.
- Loading Time Optimization — complementary async-loading pattern (TSoftObjectPtr / FStreamableManager / UE-217968 PIE corruption callout folded in).