๐ ๏ธ 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
FindObjectsByTypecalled 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:
- Per-frame allocations โ a manager whose
Updatebuilds strings and lists every frame (Lesson 2.3). - An expensive per-frame query โ each agent calls
FindObjectsByType<Agent>()every frame to find its neighbours (Lessons 2.1 & 2.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).
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:
- Click a representative frame in the CPU Usage module chart to freeze it. Note the total frame time (well over 16.7 ms).
- In Timeline, find the fat blocks on the Main Thread โ you'll see a large slab of script time.
- Switch to Hierarchy and sort by Self ms.
Agent.Updateand, underneath it,FindObjectsByTypeshould dominate. - Now sort by GC Alloc.
Agent.Updateshows a large per-frame allocation (the LINQ chain + strings). - 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 aMaterialPropertyBlock, never inrenderer.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.