Skip to main content

πŸ“ Lesson 5.1: Unity Performance and Memory

Throughout this course you've seen warnings: "don't do this every frame," "cache that," "we'll cover pooling in 5.1." This is 5.1. You'll learn why those matter β€” how the garbage collector causes frame hitches β€” and the core techniques for fast Unity C#: caching, avoiding allocations, pooling, and profiling before you optimize.

🎯 Learning Objectives

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

  • Explain how garbage collection causes frame spikes in Unity
  • Identify and eliminate common allocations in Update
  • Cache component references and reusable objects instead of recreating them
  • Implement object pooling by hand and with Unity's ObjectPool<T>
  • Avoid hidden costs like Camera.main and string building in hot paths
  • Use the Profiler to measure first and optimize what actually matters

Estimated Time: 75 minutes

Project: Refactor a spawn-heavy script to remove per-frame allocations and pool its objects, verified in the Profiler.

In This Lesson

The GC and Frame Spikes

C# is a managed language: you allocate objects and the garbage collector (GC) frees them later. On a server that's fine. In a game rendering a frame every ~16ms, it's a trap β€” when the GC runs to reclaim memory, it can pause your game for several milliseconds, causing a visible stutter.

πŸ“– Definition

Garbage is heap memory from objects you allocated (with new, or hidden allocations) that are no longer referenced. Periodically the GC stops execution to collect it. The more garbage you generate per frame, the more often β€” and longer β€” these collection pauses hit. In games this shows up as frame spikes or hitching.

graph LR A["Allocate every frame
(new objects, strings…)"] --> B["Heap fills with garbage"] B --> C["GC runs to reclaim"] C --> D["Game pauses a few ms"] D --> E["Visible stutter / dropped frame"] style A fill:#fdecea,stroke:#c0392b,stroke-width:2px style E fill:#fdecea,stroke:#c0392b,stroke-width:2px

βœ… The core principle

The fastest garbage is the garbage you never create. Most Unity performance work is about not allocating in code that runs every frame. Allocating once at startup is free of this cost; allocating 60 times a second is what hurts. Everything in this lesson flows from that idea.

Allocations in Update

Update, FixedUpdate, and LateUpdate are the hot paths β€” they run constantly. An allocation that's harmless once becomes garbage-per-frame here. The usual culprits:

Allocation in a hot pathFix
GetComponent<T>() each frameCache it in a field in Awake/Start
new WaitForSeconds(1f) in a loopCache the wait object in a field (Lesson 3.3)
String building: "Score: " + n every frameUpdate only when the value changes (event-driven, Lesson 4.2)
LINQ (.Where, .Select) in UpdateUse plain loops in hot paths; LINQ allocates iterators
foreach over some collection typesFine for List/arrays; watch older enumerators that box
Boxing a value type to objectUse generics/typed APIs; avoid object params in hot paths
// ❌ Allocates every frame: a GetComponent lookup + a new string.
void Update()
{
    GetComponent<Renderer>().material.color = Color.red;   // lookup each frame
    label.text = "Time: " + Time.time;                     // new string each frame
}
// βœ… Cache the reference; only touch the string when it needs to change.
private Renderer cachedRenderer;

void Awake() => cachedRenderer = GetComponent<Renderer>();

void Start() => cachedRenderer.material.color = Color.red;   // set once, not per frame
πŸ’‘ The mindset shift: in a hot path, ask of every line "does this allocate, and does it need to run this frame?" Move one-time work to Awake/Start; move change-driven work behind events; keep only the truly per-frame math in Update.

Cache Everything Reusable

Caching is the theme that has run through the whole course β€” now here's the complete rationale. Anything you look up or build repeatedly should be computed once and stored.

public class Enemy : MonoBehaviour
{
    // Cached component references (Lesson 1.3) β€” looked up once.
    private Rigidbody body;
    private Transform playerTarget;

    // Cached reusable objects (Lesson 3.3) β€” allocated once, reused forever.
    private readonly WaitForSeconds attackCooldown = new(1.5f);

    void Awake()
    {
        body = GetComponent<Rigidbody>();
        // Cache the player reference instead of finding it every frame.
        playerTarget = GameObject.FindWithTag("Player").transform;
    }

    void FixedUpdate()
    {
        // Use the cached refs β€” no lookups, no allocations here.
        Vector3 toPlayer = playerTarget.position - transform.position;
        body.AddForce(toPlayer.normalized * 5f);
    }
}

⚠️ Camera.main is a hidden Find

Camera.main looks tidy but internally searches the scene for a camera tagged "MainCamera" β€” effectively a FindWithTag (Lesson 1.3). Calling it every frame is a real cost. Cache it once: private Camera cam; void Awake() => cam = Camera.main;. The same goes for any Find/GetComponent hiding inside a per-frame call.

βœ… struct vs class for tiny data

Small, short-lived data (a 2D coordinate, an RGBA color) as a struct lives on the stack and generates no garbage, unlike a class which heap-allocates. Unity's own Vector3, Color, and Quaternion are structs for exactly this reason. For your own tiny value types in hot paths, prefer struct β€” but keep them small and immutable to avoid copy surprises.

Object Pooling

Lesson 2.3 flagged this: constantly Instantiate-ing and Destroy-ing objects (bullets, enemies, effects) allocates and creates garbage. Object pooling fixes it β€” reuse a fixed set of objects instead of creating and destroying them.

graph LR GET["Get()"] --> ACT["Activate object
from pool"] ACT --> USE["Object in use
(flying, alive)"] USE --> REL["Release()"] REL --> DEACT["Deactivate &
return to pool"] DEACT --> GET style ACT fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style DEACT fill:#eff6ff,stroke:#3b82f6,stroke-width:2px

Unity ships a ready-made pool: ObjectPool<T> in UnityEngine.Pool. You give it callbacks for create / get / release / destroy, and it manages the rest:

using UnityEngine;
using UnityEngine.Pool;

public class BulletSpawner : MonoBehaviour
{
    [SerializeField] private Bullet bulletPrefab;
    private ObjectPool<Bullet> pool;

    void Awake()
    {
        pool = new ObjectPool<Bullet>(
            createFunc: () => Instantiate(bulletPrefab),        // make one when the pool is empty
            actionOnGet: b => b.gameObject.SetActive(true),    // taken from pool
            actionOnRelease: b => b.gameObject.SetActive(false), // returned to pool
            actionOnDestroy: b => Destroy(b.gameObject),        // pool trimmed
            defaultCapacity: 20);
    }

    public Bullet Fire(Vector3 position, Quaternion rotation)
    {
        Bullet bullet = pool.Get();          // reuse instead of Instantiate
        bullet.transform.SetPositionAndRotation(position, rotation);
        return bullet;
    }

    // The bullet calls this instead of Destroy(gameObject):
    public void Return(Bullet bullet) => pool.Release(bullet);
}

πŸ’‘ Pooled objects "return," they don't destroy

The key change: a pooled bullet that expires or hits something calls pool.Release(this) (which deactivates it) instead of Destroy(gameObject). Nothing is allocated or garbage-collected β€” the same 20 bullets cycle forever. Reset each object's state in actionOnGet so a reused bullet doesn't carry stale velocity or health.

⚠️ Pool only when it pays

Pooling adds complexity (state resets, returning objects, sizing). It's worth it for high-frequency spawns β€” bullet-hell, particles, projectiles. For a handful of objects created occasionally, plain Instantiate/Destroy is clearer and fine. Optimize the hot spots, not everything (see the Profiler section).

Hidden Costs

Beyond allocations, a few Unity-specific calls are pricier than they look. In hot paths, watch for:

Looks cheapActuallyDo instead
Camera.mainA tagged Find each callCache in Awake
GameObject.Find(...)Searches every object by nameCache, or wire a reference (Lesson 1.3)
gameObject.tag == "X"Allocates a stringCompareTag("X") (Lesson 2.2)
Instantiate/Destroy in bulkAllocation + GCObject pooling
Heavy work in UpdateRuns every frame for every instanceDo it on change/event, or less often
Empty Update() methodsStill invoked each frame (Lesson 1.2)Delete event functions you don't use

βœ… Run expensive things less often

Not everything needs to run every frame. AI decisions, distance checks against far objects, or UI refreshes can run a few times per second instead of 60. A coroutine (Lesson 3.3) with yield return new WaitForSeconds(0.2f), or an accumulating timer in Update, spreads the cost. And when comparing distances, use sqrMagnitude instead of Vector3.Distance to skip a square root (Lesson 2.1).

Measure First: the Profiler

The most important rule comes last, because it governs all the rest: profile before you optimize. Guessing at bottlenecks wastes time and adds complexity where it isn't needed. Unity's Profiler (Window β†’ Analysis β†’ Profiler) shows exactly where each frame's time and memory go.

graph LR M["Measure
(Profiler)"] --> F["Find the real
bottleneck"] F --> O["Optimize just that"] O --> V["Verify it improved
(measure again)"] V --> M style M fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style O fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

What to look at:

  • CPU Usage β€” which methods eat the frame; look for spikes.
  • GC Alloc column β€” bytes allocated per frame. Your target is 0 B in steady-state gameplay. Any per-frame number here is garbage you can hunt down.
  • The spikes β€” regular tall bars often mean a GC collection; trace them to the allocations feeding it.

πŸ’‘ Profiler workflow

Enter Play mode, open the Profiler, and watch the GC Alloc column while gameplay runs. Sort by it, find the method allocating each frame, and apply this lesson's fixes (cache, pool, event-drive). Then re-measure to confirm. Also use Debug.Log timing or the ProfilerMarker API to bracket suspicious code. Profile in a build when possible β€” the Editor adds overhead that skews numbers.

⚠️ Don't optimize blind

"Premature optimization" β€” rewriting code you assume is slow β€” usually just makes it harder to read for no gain. Write clear code first; when the Profiler shows a real hot spot, optimize that. Readability matters more than micro-optimizing code that runs twice.

Exercise & Quiz

πŸ‹οΈ Exercise: Kill the Per-Frame Garbage

Objective: Take an allocation-heavy script to 0 B/frame, then pool its spawns β€” and confirm both in the Profiler.

Instructions:

  1. Start from the "bad" script below. Open the Profiler, enter Play mode, and note the GC Alloc per frame.
  2. Cache the Camera.main and GetComponent lookups in Awake.
  3. Replace tag == with CompareTag, and stop rebuilding the label string every frame (only update it when the value changes).
  4. Convert the bullet spawning to an ObjectPool<Bullet>; make bullets Release back to the pool instead of Destroy.
  5. Re-check the Profiler: steady-state GC Alloc should drop toward 0 B. Note the before/after.

Starter Code (the "before"):

using UnityEngine;
using TMPro;

public class BadPerf : MonoBehaviour
{
    [SerializeField] private TMP_Text label;
    [SerializeField] private Bullet bulletPrefab;

    void Update()
    {
        // 1) Camera.main every frame (hidden Find)
        Vector3 screen = Camera.main.WorldToScreenPoint(transform.position);

        // 2) GetComponent every frame
        GetComponent<Renderer>().enabled = true;

        // 3) string allocation every frame
        label.text = "Pos: " + transform.position;

        // 4) tag compare allocates
        if (gameObject.tag == "Player") { /* ... */ }

        // 5) Instantiate + Destroy churn
        if (Input.GetKeyDown(KeyCode.Space))
        {
            Bullet b = Instantiate(bulletPrefab);
            Destroy(b.gameObject, 2f);
        }
    }
}
πŸ’‘ Hint

Cache Camera cam; and Renderer rend; in Awake. Use CompareTag("Player"). Only set label.text when the position actually changes (store the last value). For pooling, build an ObjectPool<Bullet> (Section 4) and have the bullet call spawner.Return(this) after its lifetime instead of Destroy.

βœ… Solution (the "after")
using UnityEngine;
using UnityEngine.Pool;
using TMPro;

public class GoodPerf : MonoBehaviour
{
    [SerializeField] private TMP_Text label;
    [SerializeField] private Bullet bulletPrefab;

    private Camera cam;
    private Renderer rend;
    private Vector3 lastPos;
    private ObjectPool<Bullet> pool;

    void Awake()
    {
        cam = Camera.main;                      // cached once
        rend = GetComponent<Renderer>();         // cached once
        rend.enabled = true;                    // set once, not per frame

        pool = new ObjectPool<Bullet>(
            () => Instantiate(bulletPrefab),
            b => b.gameObject.SetActive(true),
            b => b.gameObject.SetActive(false),
            b => Destroy(b.gameObject),
            defaultCapacity: 20);
    }

    void Update()
    {
        // Only rebuild the string when the position actually changed.
        if (transform.position != lastPos)
        {
            label.text = $"Pos: {transform.position}";
            lastPos = transform.position;
        }

        if (CompareTag("Player")) { /* ... */ }   // no string alloc

        if (Input.GetKeyDown(KeyCode.Space))
        {
            Bullet b = pool.Get();                // reused, no GC
            b.transform.position = transform.position;
        }
    }

    public void ReturnBullet(Bullet b) => pool.Release(b);
}

Every per-frame allocation is gone: references cached, the string built only on change, CompareTag instead of tag ==, and bullets pooled instead of churned. The Profiler's GC Alloc should now read ~0 B during steady play.

🎯 Quick Quiz

Question 1: Why do per-frame allocations hurt performance in Unity specifically?

Question 2: When is object pooling the right choice over Instantiate/Destroy?

Question 3: What should you do before optimizing a script you think is slow?

Summary

πŸŽ‰ Key Takeaways

  • The GC reclaims unreferenced heap memory by pausing the game β€” per-frame garbage means frequent frame spikes. The fastest garbage is what you never create.
  • Eliminate allocations in Update: cache GetComponent/WaitForSeconds, avoid per-frame strings/LINQ/boxing, update on change (events) not every frame.
  • Cache anything reused β€” references, reusable objects, and especially Camera.main (a hidden Find). Prefer small structs for tiny hot-path data.
  • Object pooling (hand-rolled or UnityEngine.Pool.ObjectPool<T>) reuses objects instead of churning them β€” for high-frequency spawns; reset state on get, Release instead of Destroy.
  • Watch hidden costs: Find, tag == (use CompareTag), empty Updates; run expensive work less often.
  • Profile first. Use the Profiler's GC Alloc and CPU views to find real bottlenecks, optimize those, and re-measure. Don't optimize blind.

πŸ“š Additional Resources

πŸš€ What's Next?

Fast code still has to be correct. Lesson 5.2, Debugging and the Unity Test Framework, covers finding and fixing bugs β€” Debug tools, breakpoints, Gizmos β€” and writing automated tests with the Unity Test Framework so your systems stay reliable as the project grows.

πŸŽ‰ Smooth and efficient!

You now know why those "don't do this every frame" warnings mattered β€” and how to keep your game running at a steady frame rate. That's the mark of a polished Unity developer.