π 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.mainand 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.
(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 path | Fix |
|---|---|
GetComponent<T>() each frame | Cache it in a field in Awake/Start |
new WaitForSeconds(1f) in a loop | Cache the wait object in a field (Lesson 3.3) |
String building: "Score: " + n every frame | Update only when the value changes (event-driven, Lesson 4.2) |
LINQ (.Where, .Select) in Update | Use plain loops in hot paths; LINQ allocates iterators |
foreach over some collection types | Fine for List/arrays; watch older enumerators that box |
Boxing a value type to object | Use 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 toAwake/Start; move change-driven work behind events; keep only the truly per-frame math inUpdate.
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.
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).
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.
(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:
- Start from the "bad" script below. Open the Profiler, enter Play mode, and note the GC Alloc per frame.
- Cache the
Camera.mainandGetComponentlookups inAwake. - Replace
tag ==withCompareTag, and stop rebuilding the label string every frame (only update it when the value changes). - Convert the bullet spawning to an
ObjectPool<Bullet>; make bulletsReleaseback to the pool instead ofDestroy. - 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: cacheGetComponent/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 hiddenFind). Prefer smallstructs 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,Releaseinstead ofDestroy. - Watch hidden costs:
Find,tag ==(useCompareTag), emptyUpdates; 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
- Manual β Understanding the garbage collector
- Scripting Reference β ObjectPool<T>
- Manual β The Profiler window
- Unity β Optimize your game code (best practices)
π 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.