Skip to main content

โ™ป๏ธ Lesson 12.2: Object Pooling & Optimization

In Lesson 12.1 you learned to spot GC spikes and per-frame allocations in the Profiler. This lesson delivers the fix. The number-one cause of gameplay hitches is Instantiate and Destroy churn โ€” spawning a bullet or an explosion, then throwing it away a moment later, hundreds of times a second. The cure is an object pool: create your objects once, then recycle them forever.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why Instantiate/Destroy churn causes GC hitches and frame stalls
  • Build a reusable, generic Queue-based object pool from scratch
  • Use Unity's built-in ObjectPool<T> with its create/get/release/destroy callbacks
  • Cache GetComponent references and eliminate per-frame allocations
  • Apply batching, atlasing, and Update discipline to shave CPU time
  • Know when LOD helps and how to reason about optimization trade-offs

Estimated Time: 45 minutes  ยท  Prerequisite: Lesson 12.1 (Profiling) โ€” you should be able to read the GC Alloc column

In This Lesson

Why Instantiate/Destroy Hurts

Calling Instantiate is not free. Unity has to allocate managed and native memory, clone the prefab's component data, register the new object with every relevant system (physics, rendering, hierarchy), and wake up its scripts. Destroy is the mirror image: the object is torn down and its managed memory becomes garbage waiting for the collector.

Do that once and nobody notices. Do it for every bullet in a machine-gun, every spark in an explosion, every floating damage number, and two things go wrong:

  • CPU cost per spawn. The allocation and setup work adds up on the main thread โ€” a burst of spawns can be the exact spike you saw in the Profiler.
  • GC pressure. All those destroyed objects pile up as garbage. Eventually the garbage collector runs, pauses the game for a few milliseconds, and the player feels a hitch โ€” usually at the worst moment, mid-firefight.

๐Ÿ“– Definition

Object pool: a pre-made collection of reusable objects. Instead of creating an object when you need one and destroying it when you're done, you borrow one from the pool (activating it) and return it when finished (deactivating it). The object count stays constant, so there's almost nothing to allocate and nothing to collect.

๐Ÿ’ก The mindset shift. Objects in a pool are never really "created" or "destroyed" during gameplay โ€” they're switched on and off. A dead enemy isn't deleted; it's parked, reset, and waiting to be the next enemy.

The Pool Idea

A pool has just two operations. Get hands you a ready-to-use object: if a spare is available it reactivates that one, otherwise it creates a fresh one to grow the pool. Release takes a finished object, deactivates it, and files it back for next time. Here's the full lifecycle:

flowchart LR N["Need an object
(spawn a bullet)"] --> G["Pool.Get()"] G --> Q{"Spare available
in the pool?"} Q -- "Yes" --> R["Reactivate a
parked object"] Q -- "No" --> C["Instantiate one
(pool grows)"] R --> U["Object is active
and in use"] C --> U U --> D["Done with it
(bullet hit / expired)"] D --> Rel["Pool.Release(obj)"] Rel --> P["Deactivate &
park in pool"] P -.->|reused next time| Q

Figure 1: The pool get/release lifecycle. After the pool warms up, the "Instantiate" branch stops firing โ€” every request is served by reactivating a parked object, so there's nothing to allocate or collect.

Notice the payoff: the first few requests may still instantiate, but once the pool holds enough objects for your peak demand, every future request just flips an object back on. That steady-state 0 B of GC allocation is exactly the flat line you were aiming for in the Profiler.

A Queue-Based Pool

The simplest pool is a Queue<T> of inactive objects. When you get, you dequeue one (or make a new one if the queue is empty) and activate it. When you release, you deactivate it and enqueue it. Here's a complete, generic pool you can drop into any project:

using System.Collections.Generic;
using UnityEngine;

public class SimplePool : MonoBehaviour
{
    [SerializeField] GameObject prefab;
    [SerializeField] int prewarm = 20;   // create this many up front

    readonly Queue<GameObject> pool = new Queue<GameObject>();

    void Awake()
    {
        // Warm the pool so the first burst of gameplay doesn't instantiate.
        for (int i = 0; i < prewarm; i++)
            pool.Enqueue(CreateOne());
    }

    GameObject CreateOne()
    {
        GameObject obj = Instantiate(prefab, transform);
        obj.SetActive(false);
        return obj;
    }

    // Borrow an object from the pool.
    public GameObject Get(Vector3 position, Quaternion rotation)
    {
        // Reuse a parked object, or grow the pool if none are free.
        GameObject obj = pool.Count > 0 ? pool.Dequeue() : CreateOne();

        obj.transform.SetPositionAndRotation(position, rotation);
        obj.SetActive(true);
        return obj;
    }

    // Return a finished object to the pool.
    public void Release(GameObject obj)
    {
        obj.SetActive(false);
        pool.Enqueue(obj);
    }
}

The object that borrows itself out is responsible for coming back. A bullet, for example, tells the pool to take it back after a lifetime or on impact โ€” never calls Destroy:

using UnityEngine;

public class PooledBullet : MonoBehaviour
{
    public SimplePool Owner { get; set; }   // set by the pool/spawner
    [SerializeField] float lifeTime = 3f;

    float timer;

    void OnEnable() => timer = lifeTime;    // reset state every time we're reused

    void Update()
    {
        timer -= Time.deltaTime;
        if (timer <= 0f)
            Owner.Release(gameObject);      // return to the pool, do NOT Destroy
    }
}

โš ๏ธ Reset state in OnEnable, not Awake

Awake runs once in an object's life; a pooled object is reused many times, so anything that must be fresh on each use โ€” health, timers, velocity, trail renderers โ€” belongs in OnEnable. Forgetting this is the classic pooling bug: the recycled bullet still carries the last one's leftover speed or a half-finished particle trail.

Unity's ObjectPool<T>

You don't always have to write the plumbing yourself. Unity ships a battle-tested generic pool in UnityEngine.Pool called ObjectPool<T>. You give it callbacks for the four moments in an object's pooled life โ€” create, get, release, and destroy โ€” and it manages the collection, capacity, and even safety checks for you:

using UnityEngine;
using UnityEngine.Pool;

public class BulletSpawner : MonoBehaviour
{
    [SerializeField] PooledBullet bulletPrefab;

    ObjectPool<PooledBullet> pool;

    void Awake()
    {
        pool = new ObjectPool<PooledBullet>(
            createFunc:     CreateBullet,                       // make a new one
            actionOnGet:    b => b.gameObject.SetActive(true),  // borrowed
            actionOnRelease: b => b.gameObject.SetActive(false), // returned
            actionOnDestroy: b => Destroy(b.gameObject),        // pool trimmed
            collectionCheck: true,   // warns if you release the same object twice
            defaultCapacity: 20,
            maxSize: 200);           // above this, extras are destroyed not stored
    }

    PooledBullet CreateBullet()
    {
        PooledBullet b = Instantiate(bulletPrefab);
        b.SetPool(pool);             // so the bullet can release itself
        return b;
    }

    public PooledBullet Fire(Vector3 pos, Quaternion rot)
    {
        PooledBullet b = pool.Get();                 // borrow
        b.transform.SetPositionAndRotation(pos, rot);
        return b;
    }
}

The bullet releases itself through the pool reference it was handed:

using UnityEngine;
using UnityEngine.Pool;

public class PooledBullet : MonoBehaviour
{
    IObjectPool<PooledBullet> pool;
    [SerializeField] float lifeTime = 3f;
    float timer;

    public void SetPool(IObjectPool<PooledBullet> p) => pool = p;

    void OnEnable() => timer = lifeTime;

    void Update()
    {
        timer -= Time.deltaTime;
        if (timer <= 0f)
            pool.Release(this);      // hand myself back
    }
}

โœ… Pro Tip: which pool should I use?

Reach for ObjectPool<T> first โ€” it's less code, and collectionCheck catches the double-release bug for free during development. Write your own Queue-based pool only when you need behaviour the built-in one doesn't offer (for example, pooling by prefab variant, or a shared multi-type pool). Both eliminate the churn; the built-in one just saves you the boilerplate.

Cache GetComponent

Pooling solves spawn churn; the next wins are about what your scripts do every frame. The most common offender is calling GetComponent inside Update. It performs a lookup every single frame for a reference that never changes. Grab it once and store it:

// โŒ Slow: looks up the Rigidbody 60+ times a second, forever.
void Update()
{
    GetComponent<Rigidbody>().AddForce(Vector3.forward);
}

// โœ… Fast: look it up once, reuse the cached reference.
Rigidbody rb;

void Awake()
{
    rb = GetComponent<Rigidbody>();
}

void Update()
{
    rb.AddForce(Vector3.forward);
}

The same rule applies to Camera.main (a hidden FindGameObjectWithTag under the hood), GameObject.Find, and any other search. Do the lookup once in Awake or Start, cache the result, and reference the field thereafter.

Kill Per-Frame Allocations

Every allocation inside a per-frame method feeds the garbage collector you're trying to keep quiet. The Profiler's GC Alloc column names them; here's how to remove the usual suspects:

  • String building. "Score: " + score allocates a new string each frame. Only update the text when the value actually changes, and prefer a StringBuilder or a preformatted approach for frequently changing HUD numbers.
  • Temporary collections. new List<>() or new T[] inside Update allocates every frame. Create the collection once as a field and Clear() it for reuse.
  • LINQ in hot loops. .Where(...).ToList() is readable but allocates iterators and lists. In per-frame code, write a plain for loop.
  • Physics that returns arrays. Physics.RaycastAll and OverlapSphere allocate a new array each call โ€” use the NonAlloc variants (RaycastNonAlloc, OverlapSphereNonAlloc) with a reused buffer.
// โœ… Reuse one buffer instead of allocating an array every frame.
readonly RaycastHit[] hits = new RaycastHit[16];

void Update()
{
    int count = Physics.RaycastNonAlloc(transform.position, transform.forward, hits, 50f);
    for (int i = 0; i < count; i++)
        Debug.Log(hits[i].collider.name);
}

โš ๏ธ Don't micro-optimize what isn't hot

These techniques trade a little readability for less garbage โ€” worth it in code that runs every frame or every physics step, and pointless everywhere else. A List allocated once at startup, or a string built when a menu opens, costs nothing meaningful. Let the Profiler tell you which allocations are actually per-frame before you rewrite anything.

Batching, Atlasing, LOD & Update Discipline

If profiling shows you're GPU-bound or drowning in draw calls, the wins move from code to content:

  • Batching & atlasing. Objects that share a single material can be drawn together in one batch. Combine many small textures into one texture atlas (or a Sprite Atlas in 2D) so more objects share a material โ€” the Frame Debugger from Lesson 12.1 shows exactly which draw calls this merges. Mark non-moving geometry as Static so Unity can batch it automatically.
  • LOD (Level of Detail). A LOD Group component swaps a mesh for cheaper versions as it gets further from the camera โ€” full detail up close, a simpler mesh in the distance, nothing at all beyond a threshold. It trades a little memory for far fewer triangles on screen, which matters most in large 3D scenes.
  • Update discipline. Every Update has a small fixed cost, and hundreds of them add up. Don't run an Update that does nothing most frames. Options: disable components you don't need, run periodic logic on a coroutine or timer instead of every frame, and have one manager tick many objects rather than each object ticking itself.
// Instead of a per-frame check that only matters occasionally,
// run it a few times a second with a coroutine.
System.Collections.IEnumerator ScanForTargets()
{
    var wait = new WaitForSeconds(0.25f);   // 4x per second is plenty for AI sensing
    while (enabled)
    {
        DoExpensiveScan();
        yield return wait;                  // yields control; no per-frame cost
    }
}

โœ… Pro Tip: the biggest win is doing less

Pooling, caching, and batching are all the same idea in different clothes: stop paying for work you don't need to repeat. Reuse the object, reuse the reference, reuse the draw call, reuse the result. "Fastest code is the code that doesn't run" is the whole of optimization in one sentence.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: Pool your projectiles

Objective: Turn a churning spawner into a pooled one and prove the difference in the Profiler.

  1. Make a shooter that fires a bullet prefab several times a second using plain Instantiate, with each bullet Destroy-ing itself after a few seconds. Play, open the Profiler, and note the periodic GC spikes.
  2. Add the BulletSpawner with ObjectPool<T> (or your own SimplePool) and switch the shooter to call Fire().
  3. Give the bullet a SetPool/Release path so it returns itself instead of destroying.
  4. Reset the bullet's timer (and any velocity/trails) in OnEnable.
  5. Play again with the Profiler open and compare the GC Alloc column against the pre-pool version.
๐Ÿ’ก Hint: bullets reappear at the wrong place or keep old speed

That's the reuse bug โ€” pooled objects keep their last state. Set position/rotation in the spawner's Fire/Get, and reset velocity, timers, and trail renderers in OnEnable. Also confirm each bullet is released exactly once (with collectionCheck: true the pool will warn you if not).

โœ… Success check

After the pool warms up (the first second or two), the GC Alloc for spawning drops to 0 B and the periodic GC spikes vanish from the CPU chart โ€” the same firing rate now runs smooth.

๐Ÿ‹๏ธ Exercise 2: Hunt one per-frame allocation

Open a scene of your own, sort the Profiler Hierarchy by GC Alloc, and fix one non-zero row: cache a GetComponent, stop rebuilding a string every frame, or swap a RaycastAll for RaycastNonAlloc. Measure before and after โ€” the row should read 0 B when you're done.

๐ŸŽฏ Quick Quiz

Question 1: What is the main reason object pooling reduces gameplay hitches?

Question 2: Where should you reset a pooled object's state (timers, health, velocity)?

Question 3: Which change removes a per-frame allocation?

Summary

๐ŸŽ‰ Key Takeaways

  • Instantiate/Destroy churn costs CPU to spawn and feeds the GC on destroy โ€” the top cause of gameplay hitches.
  • An object pool reuses objects: Get reactivates a parked one, Release deactivates and files it back. Steady state = 0 B allocated.
  • A Queue<T> pool is a few lines; Unity's ObjectPool<T> gives you create/get/release/destroy callbacks and a double-release safety check.
  • Reset reused state in OnEnable, never Awake.
  • Cache GetComponent and Camera.main once; kill per-frame allocations (strings, temp lists, LINK, RaycastAll).
  • Batching/atlasing and LOD cut GPU work; Update discipline cuts CPU work. The theme is always: do less, reuse more.

๐Ÿš€ What's Next?

You can now find bottlenecks and fix the most common ones. It's time to ship. In the module's mini-project, Lesson 12.3: Quality Settings, Builds & Shipping, you'll set up URP quality tiers and Player Settings, use Unity 6's new Build Profiles to produce a real build (including WebGL), trim its size, and run the full profile โ†’ optimize โ†’ build โ†’ test loop end to end. It's also the finale of the whole course.

โ™ป๏ธ You can stop the churn now

Borrow, use, return โ€” never create and destroy in the hot path. Pair that with cached references and fewer per-frame allocations, and the GC hitches you learned to spot simply stop happening.