Combat with two hundred-odd projectiles took us from 60 FPS to 15. My first instinct was rendering. It was wrong. The cost was spawning: allocation, construction, component registration, several times a second. The profiler settled it in about ten minutes, which is roughly a week less than I would have spent optimising draw calls on a hunch.
So I wrote a pool. Generic over actor class, thread-safe, with a warm-up pass so the first encounter doesn't pay for the whole fight. It worked. The framerate did not move.
The node they had
Retrieving from the pool was a C++ template. The designers building the projectile systems were, reasonably, still calling SpawnActor, because that is the node that exists in the palette, and nobody had told them there was now a rule about it. The performance win sat in the repository for two weeks doing nothing.
Custom K2 nodes fixed it. `Spawn Actor From Pool` sits next to `Spawn Actor From Class`, takes the same pins, and costs the same effort to type. Within a fortnight around 90% of projectile systems had moved over, and the framerate finally did what the profiler said it should.
A system's real interface is whatever the least-invested user can reach. Everything above that line is a system only you use.
Two bugs that only pooling produces
A pooled actor is not a new actor, and everything you forget to reset is a bug that appears only after the pool wraps around. We got projectiles spawning with a previous owner's velocity and enemies reactivating with someone else's health. Nondeterministic, hard to reproduce, and utterly baffling until you notice they always follow a busy fight.
Teaching the pool how to clean every actor type would have made it not-generic, so cleanup went behind an interface each actor implements for itself. The pool calls it, the actor decides what reset means.
The nastier bug is use-after-return. You hold a pointer to a projectile, it finishes and goes back to the pool, and your pointer is still valid: it points at a live actor. It is just somebody else's actor now. `IsValid` returns true and you damage the wrong target.
A raw pointer cannot express that, so pooled actors are handed out as a handle that knows about the pool and returns null the moment the actor is recycled. The failure becomes a null check instead of a silent gameplay bug.
Build the inspector at the same time
Optimisation is invisible until it breaks, and an invisible system is one nobody trusts enough to configure. The plugin registers its own stat group, so `stat ObjectPool` shows retrievals, returns, growths, cache hit rate and per-pool health live in the running game.
Cache hit rate is the number that matters: a miss means the pool had to grow, which is the allocation you were trying to avoid. Once that was on screen, prewarm sizes stopped being guesses. Somebody would run the stress encounter, watch the miss counter, and set the number. That took an afternoon to build and it is the reason the pool got tuned properly instead of being left at whatever I had typed first.
Final numbers on the same encounter: 15–30 FPS to a stable 60, garbage collection every 3–4 seconds down to negligible, allocations down roughly 85%, and stress-tested to 500+ simultaneous projectiles with stable memory. None of which happened until the thing was reachable from a Blueprint.