Pooling for any actor class, with warm-up config and a runtime performance debugger.
Solved a 60-to-15 FPS collapse in combat scenarios with 200+ actors by replacing spawn churn with pooling.
Role: Performance engineer. Team: solo. Duration: 2 weeks. Platform: Unreal Engine 5. Tools: C++ · Blueprints.
Problem: Combat with 200+ active projectiles fell from 60 FPS to 15. My first instinct was rendering and it was wrong. The profiler put the cost in spawning: allocation, construction and component registration, several times a second.
Approach: A generic pooling subsystem that amortises allocation, then Blueprint nodes and a debugger, because a pool nobody can use or inspect does not actually fix anything.
Result: Stable 60 FPS in the same encounter, roughly 85% fewer runtime allocations, and headroom tested to 500+ simultaneous projectiles.
Two modules, no editor code in shipping. Runtime holds the pooling logic with zero editor dependencies. The editor module carries prewarm configuration and the debug window. The split is boring and it is the reason the plugin can be dropped into a shipping project without an audit.
Thread safety decided up front. Pool access is guarded by an FCriticalSection so async loading and background tasks can pool without racing. I made that call before writing the first pool rather than after the first heisenbug, which is the only reason it is one lock in one place instead of a retrofit spread across every call site.
The dirty pool problem. Early testing threw bugs that made no sense: a projectile spawning with the previous owner's velocity, an enemy returning to life with someone else's health. A pooled actor is not a new actor, and every field you forget to reset is a bug that only appears after the pool wraps around. Teaching the pool about every actor type would have made it useless as a generic system, so cleanup moved behind an interface each actor implements for itself.
Use-after-return, solved by a handle. The nastier version of the same class of bug is holding a pointer to an actor that has since been recycled. Your reference is still valid: it points at a live actor. It is just someone else's actor now. FPooledActorHandle wraps the weak pointer and the subsystem together and returns nullptr the moment that actor goes back in the pool, so the mistake becomes a null check instead of a projectile that damages the wrong target.
Adoption was the real problem. For the first two weeks the pool made almost no difference, because using it needed C++ and the designers kept calling SpawnActor, which is the node they had. The performance win was sitting in the repo doing nothing. Custom K2 nodes made pooling as cheap to type as spawning, and within a fortnight around 90% of projectile systems had moved across. The lesson stuck: a system's real interface is whatever the least-invested user can reach.
Optimisation you can watch. Pooling is invisible until it breaks, and an invisible system is one nobody trusts enough to size properly. The plugin registers its own stat group, so stat ObjectPool shows retrievals, returns, growths, cache hit rate and per-pool health live. Prewarm sizes stopped being guesses and started being read off the screen during a stress test.
Measured. Same combat scenario, before and after.