Object Pooling
Object pooling means reusing objects instead of creating and destroying them. When a bullet "dies", you switch it off and put it back in a pool; the next shot takes it out and switches it on again. It removes the cost of Instantiate/Destroy and the garbage-collection stutters they cause.
Shoes at a bowling alley. The alley doesn't make a new pair for every customer and throw them away afterwards. It keeps a shelf of shoes: hand a pair out, get it back, spray it clean (reset), and put it back on the shelf. When it's busy and the shelf is empty, it buys a few more pairs.
When to pool
| Pool it | Don't bother |
|---|---|
| Bullets, shell casings, projectiles | The player |
| Hit effects, particles, decals | A boss spawned once per level |
| Damage numbers, floating text | UI screens opened occasionally |
| Enemies in wave-based games | Anything created a few times per minute |
| Audio one-shot sources |
Rule of thumb: pool things created many times per second, or in bursts large enough to show a spike in the Profiler.
The lifecycle
Get and Release just switch objects on and off. Instantiate and Destroy (dashed) only happen at the edges, when the pool runs dry or overflows.Unity's built-in ObjectPool<T>
Namespace UnityEngine.Pool (Unity 2021.1+).
| Constructor parameter | Meaning |
|---|---|
createFunc | How to make a new one (usually Instantiate) |
actionOnGet | Runs when taken out (usually SetActive(true)) |
actionOnRelease | Runs when returned (usually SetActive(false)) |
actionOnDestroy | Runs when the pool is full and throws one away, or on Clear() |
collectionCheck | If true, throws an error if you release the same object twice (checked in the Editor). Keep it on while developing. |
defaultCapacity | Starting size of the internal storage |
maxSize | Max objects kept inactive. Extras released beyond this are destroyed. |
| Member | Description |
|---|---|
Get() | Take one out (creates one if the pool is empty) |
Release(obj) | Give one back |
CountActive / CountInactive / CountAll | How many are out / waiting / total |
Clear() | Destroy all inactive ones |
Full example: pooled bullets
using UnityEngine;
using UnityEngine.Pool;
public class Gun : MonoBehaviour
{
[SerializeField] private Bullet bulletPrefab;
[SerializeField] private Transform muzzle;
[SerializeField] private int prewarm = 30;
private ObjectPool<Bullet> pool;
void Awake()
{
pool = new ObjectPool<Bullet>(
createFunc: () =>
{
Bullet b = Instantiate(bulletPrefab);
b.SetPool(pool); // the bullet needs to know where to return
return b;
},
actionOnGet: b => b.gameObject.SetActive(true),
actionOnRelease: b => b.gameObject.SetActive(false),
actionOnDestroy: b => Destroy(b.gameObject),
collectionCheck: true,
defaultCapacity: prewarm,
maxSize: 200);
Prewarm();
}
// Create objects during loading, not in the middle of a fight
void Prewarm()
{
var temp = new Bullet[prewarm];
for (int i = 0; i < prewarm; i++) temp[i] = pool.Get();
for (int i = 0; i < prewarm; i++) pool.Release(temp[i]);
}
public void Fire()
{
Bullet b = pool.Get();
b.Launch(muzzle.position, muzzle.rotation);
}
}
using UnityEngine;
using UnityEngine.Pool;
[RequireComponent(typeof(Rigidbody))]
public class Bullet : MonoBehaviour
{
[SerializeField] private float speed = 40f;
[SerializeField] private float lifetime = 3f;
[SerializeField] private TrailRenderer trail;
private IObjectPool<Bullet> pool;
private Rigidbody rb;
private float dieAt;
private bool released;
void Awake() { rb = GetComponent<Rigidbody>(); }
public void SetPool(IObjectPool<Bullet> p) { pool = p; }
// Everything that must be "fresh" is reset here, NOT in Start
public void Launch(Vector3 pos, Quaternion rot)
{
released = false;
transform.SetPositionAndRotation(pos, rot);
rb.position = pos; // keep physics in sync with the teleport
rb.linearVelocity = transform.forward * speed;
rb.angularVelocity = Vector3.zero;
if (trail) trail.Clear(); // otherwise a streak is drawn from the old position
dieAt = Time.time + lifetime;
}
void Update()
{
if (Time.time >= dieAt) ReturnToPool();
}
void OnCollisionEnter(Collision c)
{
if (c.collider.TryGetComponent(out IDamageable d)) d.TakeDamage(10f);
ReturnToPool();
}
private void ReturnToPool()
{
if (released) return; // guard: hit something AND timed out on the same frame
released = true;
pool.Release(this); // instead of Destroy(gameObject)
}
}
What happens at runtime
- At load,
Prewarmcreates 30 bullets (running theirAwake), then deactivates them. All the expensive work is done up front. Fire()takes an inactive bullet from the pool's internal stack. NoInstantiate, no memory allocation.SetActive(true)runsOnEnable.- On hit or timeout, the bullet deactivates itself (
OnDisableruns) and goes back on the stack. - If 31 bullets are needed at once, the pool creates one more. Over time the pool grows to the peak you actually need and then stops allocating.
Pooling particle effects
Set the ParticleSystem's Stop Action to Callback (Main module). Unity then calls OnParticleSystemStopped on scripts on the same GameObject when it finishes.
using UnityEngine;
using UnityEngine.Pool;
[RequireComponent(typeof(ParticleSystem))]
public class PooledEffect : MonoBehaviour
{
public IObjectPool<PooledEffect> Pool { get; set; }
private ParticleSystem ps;
void Awake()
{
ps = GetComponent<ParticleSystem>();
var main = ps.main;
main.stopAction = ParticleSystemStopAction.Callback;
}
public void PlayAt(Vector3 pos)
{
transform.position = pos;
ps.Play();
}
void OnParticleSystemStopped() { Pool.Release(this); }
}
One pool per prefab
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Pool;
public class PoolManager : MonoBehaviour
{
public static PoolManager Instance { get; private set; }
private readonly Dictionary<GameObject, ObjectPool<GameObject>> pools = new();
private readonly Dictionary<GameObject, GameObject> instanceToPrefab = new();
void Awake() { Instance = this; }
public GameObject Spawn(GameObject prefab, Vector3 pos, Quaternion rot)
{
if (!pools.TryGetValue(prefab, out var pool))
{
pool = new ObjectPool<GameObject>(
() => Instantiate(prefab, transform), // parent under the manager: tidy Hierarchy
go => go.SetActive(true),
go => go.SetActive(false),
go => Destroy(go));
pools[prefab] = pool;
}
GameObject obj = pool.Get();
obj.transform.SetPositionAndRotation(pos, rot);
instanceToPrefab[obj] = prefab;
return obj;
}
public void Despawn(GameObject obj)
{
if (instanceToPrefab.TryGetValue(obj, out var prefab)) pools[prefab].Release(obj);
else Destroy(obj); // wasn't pooled
}
}
Pooling temporary collections
UnityEngine.Pool also pools lists and sets, to avoid garbage from temporary collections:
using (ListPool<Collider>.Get(out List<Collider> nearby))
{
GetComponentsInChildren(nearby);
// use the list...
} // automatically returned to the pool here
Also available: HashSetPool<T>, DictionaryPool<K,V>, GenericPool<T> for plain C# classes.
Common Mistakes
- Not resetting state. A reused enemy comes back with 0 health; a bullet keeps its old velocity. Reset in your "launch"/
OnEnablecode.AwakeandStartonly run the first time. - Releasing twice. The same object ends up in the pool twice and gets handed out to two users. Guard with a flag (see
releasedabove) and keepcollectionCheckon. - Calling
Destroyon a pooled object. The pool still holds a reference to a destroyed object. AlwaysRelease. - Stale references. An enemy still targeting a bullet that has been recycled into a new bullet. Clear references on release, or check an ID.
- Coroutines on pooled objects stop when deactivated. Restart them in
OnEnableif needed. - Pools that grow forever. Set
maxSizeso a one-off burst doesn't keep hundreds of objects in memory.
See also
This page is a simplified, AI-assisted interpretation. For the complete and authoritative reference, see the official Unity documentation.