Skip to main content

๐Ÿ› ๏ธ Lesson 2.5: Mini-Project โ€” Profiling & Fixing a Stress Scene

Four lessons of theory now meet one deliberately broken scene. You'll build a small "arena" that runs at a miserable frame rate on purpose, then do exactly what a professional does on a real project: open the Profiler, follow the evidence to three specific culprits โ€” per-frame allocations, hundreds of unbatched materials, and an expensive scene query every frame โ€” fix each one, and prove the fix with before/after numbers. By the end you'll have run the complete performance loop end to end, and turned a 40 ms frame into a smooth one.

๐ŸŽฏ What You'll Build & Learn

In this build-along you will:

  • Assemble a stress scene with three baked-in performance bugs, one per earlier lesson
  • Profile it and read the spike in the CPU Timeline and Hierarchy (Lesson 2.1)
  • Fix a per-frame allocation hot path (Lesson 2.3) and confirm GC Alloc drops to 0 B
  • Fix an expensive FindObjectsByType called every frame (Lessons 2.1 & 2.3)
  • Fix hundreds of unbatched materials and watch the SetPass count collapse (Lesson 2.4)
  • Re-measure with the Profile Analyzer and report a real before/after

Estimated Time: 90 minutes  ยท  Prerequisite: Lessons 2.1โ€“2.4 (this project uses all four)

In This Lesson

The Plan & the Baseline

The golden rule from Lesson 2.1 governs everything here: measure, then fix, then measure again. We won't "optimize" anything until the Profiler points at it, and we won't declare a fix successful until the numbers move. The scene we build has exactly three problems, each drawn from a previous lesson:

  1. Per-frame allocations โ€” a manager whose Update builds strings and lists every frame (Lesson 2.3).
  2. An expensive per-frame query โ€” each agent calls FindObjectsByType<Agent>() every frame to find its neighbours (Lessons 2.1 & 2.3).
  3. Hundreds of unbatched materials โ€” every agent clones its material to tint it, exploding the SetPass count (Lesson 2.4).

Together these will drag a few hundred agents down to a stuttering ~25 FPS. Here's the target of the whole exercise: the Profiler CPU Timeline before (a tall, ugly frame) and after (a flat one under budget).

Profiler CPU timeline before and after the fixes Two stacked Profiler CPU timelines for the same scene. The top one, labelled Before at 41 milliseconds per frame, shows a main-thread lane packed almost end to end with three large coloured blocks: a wide red block labelled FindObjectsByType times 300, a blue block labelled string and list allocations with a GC spike marker, and a green Rendering block labelled 900 SetPass calls. A dashed line marks the 16.7 millisecond sixty-frames-per-second budget, far to the left of where the frame ends, showing the frame is well over budget. The bottom timeline, labelled After at 9 milliseconds per frame, shows a much shorter main-thread lane with three small blocks: cached neighbour list, zero-B pooled update, and Rendering at 40 SetPass calls, all finishing comfortably to the left of the 16.7 millisecond budget line. BEFORE ยท 41.0 ms/frame ยท ~24 FPS 16.7 ms budget โ†’ (frame ends way past here) Main Thread FindObjectsByType<Agent> ร— 300 string + List alloc โ–ฒ GC spike Rendering 900 SetPass 0 41 ms three problems fill the whole frame AFTER ยท 9.0 ms/frame ยท ~110 FPS 16.7 ms budget Main Thread cached list 0 B update Render 40 SetPass 0 41 ms (same scale) same scene, same scale โ€” the frame now finishes well under budget
Figure 1: The goal, as a before/after Profiler CPU Timeline (reconstructed diagram, same time scale on both). Before: three problems โ€” 300 per-frame scene queries, string/list allocations with a GC spike, and 900 SetPass calls โ€” fill a 41 ms frame. After: a cached neighbour list, a zero-allocation update, and 40 SetPass calls finish in 9 ms, comfortably inside the 16.7 ms budget. The numbers are illustrative of the shape of the win; your exact values depend on your machine and agent count.

Step 1: Build the Stress Scene

Create a fresh URP scene. Add an empty ArenaSpawner GameObject and give it this script, which spawns a few hundred cube "agents." Each agent will (deliberately) misbehave in the next script.

using UnityEngine;

public class ArenaSpawner : MonoBehaviour
{
    [SerializeField] Agent _agentPrefab;   // a cube with the Agent script + a Renderer
    [SerializeField] int _count = 300;
    [SerializeField] float _area = 40f;

    void Start()
    {
        for (int i = 0; i < _count; i++)
        {
            Vector3 pos = new Vector3(
                Random.Range(-_area, _area), 0.5f, Random.Range(-_area, _area));
            Instantiate(_agentPrefab, pos, Quaternion.identity);
        }
    }
}

Now the deliberately-slow Agent. Every line of its Update commits one of our three sins: it re-scans the whole scene for neighbours, it allocates strings/lists, and it clones its material to tint itself. This is the "before."

using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class Agent : MonoBehaviour
{
    Renderer _renderer;

    void Awake() { _renderer = GetComponent<Renderer>(); }

    void Update()
    {
        // โŒ PROBLEM 2: scan the ENTIRE scene every frame, for every agent.
        //    300 agents ร— FindObjectsByType = 300 full-scene scans per frame.
        Agent[] all = Object.FindObjectsByType<Agent>(FindObjectsSortMode.None);

        // โŒ PROBLEM 1: LINQ + ToList + a captured closure allocate every frame.
        Vector3 self = transform.position;
        List<Agent> neighbours = all
            .Where(a => a != this &&
                        Vector3.Distance(a.transform.position, self) < 6f)
            .ToList();

        // โŒ PROBLEM 1 (again): string built every frame for a debug label.
        string label = "Agent " + name + " sees " + neighbours.Count + " neighbours";
        gameObject.name = label;   // touching name each frame is itself wasteful

        // โŒ PROBLEM 3: '.material' CLONES the material โ†’ a unique instance per
        //    agent โ†’ the SRP Batcher can't keep them together โ†’ SetPass explodes.
        float t = Mathf.Clamp01(neighbours.Count / 8f);
        _renderer.material.color = Color.Lerp(Color.cyan, Color.red, t);
    }
}

Wire a cube prefab with the Agent script into the spawner, press Play, and you should see the frame rate crater. Resist the urge to fix anything yet โ€” first we prove what's wrong.

Step 2: Profile & Read the Spike

Open Window โ†’ Analysis โ†’ Profiler, enter Play mode, and let it capture a few seconds. Then:

  1. Click a representative frame in the CPU Usage module chart to freeze it. Note the total frame time (well over 16.7 ms).
  2. In Timeline, find the fat blocks on the Main Thread โ€” you'll see a large slab of script time.
  3. Switch to Hierarchy and sort by Self ms. Agent.Update and, underneath it, FindObjectsByType should dominate.
  4. Now sort by GC Alloc. Agent.Update shows a large per-frame allocation (the LINQ chain + strings).
  5. Add the Rendering module and read the SetPass calls count โ€” it'll be in the high hundreds because every agent has a cloned material.

You now have three pieces of evidence, each pointing at one problem. This is the whole discipline: the Profiler named the culprits, so every fix below is targeted, not a guess.

โœ… Write the numbers down

Before touching code, record four baseline numbers: frame time (ms), Agent.Update Self ms, GC Alloc per frame, and SetPass calls. Without a written baseline you can't prove a fix worked โ€” and "I think it feels smoother" is not engineering. This is exactly what the Profile Analyzer automates in Step 6.

Step 3: Fix the Allocations

Tackle the garbage first (Lesson 2.3). Two allocation sources live in Update: the LINQ Whereโ€ฆToList, and the per-frame string. We replace LINQ with a reused list and a plain loop, and we drop the pointless per-frame string entirely.

// Reused across frames instead of a fresh List every frame.
readonly List<Agent> _neighbours = new List<Agent>(32);

void UpdateNeighbours(Agent[] all)
{
    _neighbours.Clear();               // reuse the same backing array
    Vector3 self = transform.position;
    for (int i = 0; i < all.Length; i++)
    {
        Agent a = all[i];
        if (a == this) continue;
        // sqrMagnitude avoids the sqrt in Distance and the closure in Where.
        if ((a.transform.position - self).sqrMagnitude < 36f) // 6 * 6
            _neighbours.Add(a);
    }
    // No per-frame string. If you need a label, update it only on change,
    // or show it in a dedicated debug UI โ€” not by rebuilding gameObject.name.
}

Re-profile just this change and the GC Alloc for Agent.Update drops toward 0 B (the scene query still allocates its array โ€” that's the next fix). The LINQ/iterator/closure/string bytes are gone, and the incremental GC has far less to do.

Step 4: Fix the Scene Query

The worst CPU offender is FindObjectsByType running once per agent, per frame โ€” 300 agents means 300 full-scene scans every frame, each also allocating an array. The fix is a registry: agents add themselves to one shared static list on enable and remove themselves on disable (Lessons 2.1 & 2.3). The whole scene is then known without any scanning.

using System.Collections.Generic;
using UnityEngine;

public class Agent : MonoBehaviour
{
    // ONE shared list of all live agents โ€” no scanning, no allocation.
    public static readonly List<Agent> All = new List<Agent>(512);

    readonly List<Agent> _neighbours = new List<Agent>(32);
    Renderer _renderer;
    MaterialPropertyBlock _mpb;        // set up in Step 5

    void Awake()
    {
        _renderer = GetComponent<Renderer>();
        _mpb = new MaterialPropertyBlock();
    }

    // Register / unregister โ€” mirror the += / -= discipline from Lesson 2.2.
    void OnEnable()  { All.Add(this); }
    void OnDisable() { All.Remove(this); }

    void Update()
    {
        _neighbours.Clear();
        Vector3 self = transform.position;
        // Iterate the shared registry โ€” no FindObjectsByType, no allocation.
        for (int i = 0; i < All.Count; i++)
        {
            Agent a = All[i];
            if (a == this) continue;
            if ((a.transform.position - self).sqrMagnitude < 36f)
                _neighbours.Add(a);
        }

        ApplyTint(_neighbours.Count);  // Step 5
    }
}

Re-profile: Agent.Update's Self ms collapses because the 300 full-scene scans are gone, replaced by iterating one in-memory list. This is usually the single biggest win in the whole project โ€” a classic "never search the scene every frame" fix.

โš ๏ธ Still O(nยฒ) โ€” but a cheap nยฒ

Each agent still loops over every other agent, so neighbour-finding is O(nยฒ). We removed the catastrophic cost (full-scene reflection scans + allocations), which is enough to hit budget here. If you needed to scale to thousands, the next step would be a spatial partition (a uniform grid or quadtree) so each agent only checks nearby cells โ€” or moving the whole thing into a Burst job (Module 3). Know when "good enough" is good enough: the Profiler says we're now under budget, so we stop.

Step 5: Fix the Unbatched Materials

The last problem is rendering (Lesson 2.4): _renderer.material.color = โ€ฆ reads the singular .material, which clones the material into a unique instance for every agent. 300 unique materials means the SRP Batcher can't keep them together and the SetPass count explodes. The fix is a MaterialPropertyBlock โ€” per-object colour with no material cloning, so all agents keep sharing one material and one shader:

static readonly int BaseColorId = Shader.PropertyToID("_BaseColor");

void ApplyTint(int neighbourCount)
{
    float t = Mathf.Clamp01(neighbourCount / 8f);
    Color c = Color.Lerp(Color.cyan, Color.red, t);

    // Write colour into a property block โ€” does NOT clone the material,
    // so every agent still shares one material/shader and batches together.
    _renderer.GetPropertyBlock(_mpb);
    _mpb.SetColor(BaseColorId, c);
    _renderer.SetPropertyBlock(_mpb);
}

Open the Frame Debugger (Lesson 2.4) before and after this change: beforehand it reports batches breaking on "different materials"; afterward the agents draw in far fewer batches. The SetPass calls count in the Rendering module drops from the high hundreds to a few dozen. If your agents all share one mesh too, you could go further with GPU Instancing โ€” but the property-block fix alone restores SRP batching and is enough here.

๐Ÿ”Ž One rule, remembered. Runtime per-object visual variation belongs in a MaterialPropertyBlock, never in renderer.material. That singular property is a material-cloning trap that silently multiplies your materials and wrecks batching โ€” one of the most common rendering-performance bugs in real projects.

Step 6: Re-measure Before/After

Now prove it. Capture a fresh profile of the fixed scene and compare all four numbers against the baseline you wrote down in Step 2:

๐Ÿ“Š A representative result (300 agents)

  • Frame time: ~41 ms โ†’ ~9 ms (over budget โ†’ comfortably under)
  • Agent.Update Self ms: dominated by 300 scene scans โ†’ a plain list iteration, a fraction of the cost
  • GC Alloc / frame: kilobytes of LINQ + strings + arrays โ†’ ~0 B
  • SetPass calls: ~900 โ†’ ~40

Your exact numbers depend on hardware and agent count, but the shape โ€” this is Figure 1 made real โ€” should match: a frame that no longer fills its budget, flat GC, and a low SetPass count.

For a rigorous before/after, use the Profile Analyzer package (Lesson 2.1). Capture a range of frames before your fixes and save it; capture another range after; load both into the Analyzer's Compare view. It diffs every marker and shows exactly which ones got faster and by how much โ€” turning "it feels better" into "Agent.Update median dropped from 0.11 ms to 0.004 ms." That is the deliverable a professional performance pass produces: not a vibe, a measured delta.

โœ… What you just did โ€” the whole loop

You measured (Profiler CPU/Memory/Rendering), diagnosed three specific causes from evidence, fixed each with the right technique (garbage-free code, a registry instead of scene scans, a property block instead of cloned materials), and re-measured to prove it. That loop โ€” not any single trick โ€” is the transferable skill of this entire module.

Extend It

๐Ÿ‹๏ธ Challenge 1: Scale to 2,000 agents

Crank the spawner to 2,000 and profile again. The O(nยฒ) neighbour loop (Step 4's warning) will now dominate. Add a simple uniform grid: divide the arena into cells, put each agent in its cell each frame, and check only the agent's own cell plus the eight around it. Re-profile and compare Agent.Update Self ms before and after the grid.

๐Ÿ’ก Hint

Key a Dictionary<Vector2Int, List<Agent>> (pool the inner lists with ListPool<T> from Lesson 2.3 to stay garbage-free) by (floor(x/cellSize), floor(z/cellSize)). Rebuild it once per frame in a single manager, then each agent reads only its 3ร—3 neighbourhood. You've converted O(nยฒ) into roughly O(n), and the Profiler will show a flat line where the quadratic curve used to be. For the ultimate version, this is precisely the kind of uniform, data-parallel work that Module 3's Job System and Burst were built for.

๐Ÿ‹๏ธ Challenge 2: Instance the agents

All agents share one cube mesh and (now) one material. Convert the rendering to true GPU Instancing with Graphics.RenderMeshInstanced (Lesson 2.4): stop using per-agent MeshRenderers, gather the transforms and colours into arrays each frame, and issue instanced draws. Compare draw-call count and CPU render time.

๐Ÿ’ก Hint

Have the manager collect a Matrix4x4[] of agent transforms and a per-instance colour array into a MaterialPropertyBlock, then call Graphics.RenderMeshInstanced in batches of up to 1,023. The SetPass and draw counts drop to a handful regardless of agent count โ€” the same mechanism (via BatchRendererGroup) that powers the GPU Resident Drawer and the ECS entity fields you'll meet in Module 4.

๐ŸŽฏ Quick Quiz

Question 1: Before writing any fix in this project, what did we do first?

Question 2: Why was calling FindObjectsByType<Agent>() in each agent's Update so damaging?

Question 3: Setting _renderer.material.color per agent exploded the SetPass count. Why?

Question 4: After the fixes, how do you prove the optimization worked rather than just claim it?

Summary

๐ŸŽ‰ What You Built

  • A stress scene with three deliberate, distinct performance bugs โ€” one each from Lessons 2.3, 2.1/2.3, and 2.4.
  • A profiled diagnosis that named each culprit from evidence: Timeline + Hierarchy Self ms, the GC Alloc column, and the Rendering SetPass count.
  • Three targeted fixes: a garbage-free update (reused list, no LINQ, no per-frame string), a shared registry replacing per-frame FindObjectsByType, and a MaterialPropertyBlock restoring SRP batching.
  • A measured before/after (โ‰ˆ41 ms โ†’ โ‰ˆ9 ms, ~900 โ†’ ~40 SetPass, kilobytes โ†’ ~0 B GC) verified with the Profile Analyzer.

Extend it: add the uniform-grid spatial partition to scale past 2,000 agents, convert rendering to true GPU Instancing, and โ€” the natural next step โ€” move the neighbour computation into a parallel Burst job. That last one is exactly where the course goes next.

๐Ÿš€ What's Next?

You've mastered the diagnostic half of performance: measure, find, fix, prove. Module 2 got the most out of a single main thread. Module 3 changes the game โ€” instead of making one thread do less, you make many threads share the work. In Lesson 3.1: Data-Oriented Thinking, we start the Job System & Burst track, and the O(nยฒ) neighbour loop you just wrestled becomes the perfect candidate for safe, Burst-compiled parallelism.

๐Ÿ You've closed the loop

Measure โ†’ diagnose โ†’ fix โ†’ re-measure, on a real scene with real numbers. That loop is Module 2 in one sentence โ€” and it's the professional habit that separates guessing from engineering.