15.1 Profiling (CPU / GPU / Memory)

Phase 15 · Optimization & Mobile · Study time: 25–45 h

Measure before you optimize — CPU and GPU profilers, frame captures, and finding the real bottleneck instead of guessing.

Two builds of the same game can run the exact same gameplay and look completely different in how smoothly they play. The difference is rarely luck — it comes down to whether someone actually measured where the time and memory went, instead of guessing. This chapter is about the tools Unity gives you to answer "why is this slow?" with real numbers instead of a hunch: the Profiler window, the Frame Debugger, the Memory Profiler, and the habit of always checking a real device instead of trusting the editor.

1. Why You Must Measure Before You Optimize

Guessing about performance is tempting because it feels efficient — you look at the code, decide what "seems" expensive, and go fix that. The problem is that modern games have a lot of moving parts running at once: your gameplay scripts, physics, animation, particle systems, UI, the rendering pipeline, and a garbage collector running in the background. Slowdowns very often come from a part of the system you were not even looking at.

Here is a concrete story of a wrong guess, the kind that happens on real teams.

A small team is finishing a boss fight for a mobile game. On their test phone the game stutters — a visible hitch — roughly once every two seconds during the fight. The programmer looks at the boss code, sees a pathfinding system that recalculates a path to the player every single frame, and thinks: "that's obviously expensive, recalculating a path 60 times a second is wasteful." They spend two days rewriting the pathfinding to only recalculate every 10 frames instead of every frame — a real piece of work, careful and correct.

They rebuild, run it on the same phone, and the stutter is still there, at the same rate, barely changed. The average frame time moved from 14.9 ms to 14.6 ms — not nothing, but nowhere near enough to explain a visible hitch every two seconds.

Only then do they open Unity's Profiler window and actually look. The pathfinding code, even running every frame, was only ever costing about 0.3 ms — a tiny slice of a 16.6 ms budget. The real cost, clearly visible the moment they looked, was a large spike lining up exactly with the stutter: a garbage collection pause caused by the boss's on-screen health bar rebuilding its text every frame, whether the health value had changed or not. That single bad habit was generating enough garbage to trigger a multi-millisecond pause roughly every two seconds. Section 9 of this chapter walks through that exact bug in code.

THE WRONG WAY THE RIGHT WAY -------------- ------------- "it's probably the AI, open the Profiler, pathfinding looks expensive" look at the timeline | | v v spend 2 days rewriting see one frame spike the pathfinding far above the rest | | v v stutter is still there click it: GC spike lines (barely any change) up with the health bar text | v fix the text code in about 10 minutes

The lesson is not "pathfinding is never expensive" or "always suspect the UI." The lesson is that you cannot tell where the time went by reading code and guessing — you have to measure. A profiler turns "I think this is slow" into "this exact function, on this exact frame, cost this many milliseconds," which is the only way to know you are fixing the right thing before you spend two days on it.

Tip A good habit: before you optimize anything, be able to point at a number from a tool that says "this is the biggest cost." If you can't point at that number yet, you don't know it's slow — you suspect it.

2. The Frame Budget: Milliseconds, Not "FPS Vibes"

"Frames per second" (fps, how many complete frames the game draws in one second) is the number players see, but it is not the number you should think in while optimizing. The number that actually matters is frame time: how many milliseconds it took to produce one frame. Fps is just 1000 divided by frame time in milliseconds.

Turn a target fps into a frame budget (the maximum time one frame is allowed to take) like this:

frame budget (ms) = 1000 / target fps

60 fps  ->  1000 / 60  =  16.666... ms   (round to 16.6 ms)
30 fps  ->  1000 / 30  =  33.333... ms   (round to 33.3 ms)

That number is the total time budget for everything that has to happen before the next frame can be shown: reading input, running every script's Update(), physics, animation, particle systems, audio, building the list of things to draw, and the GPU actually drawing them. If any single frame goes over budget, that frame takes longer to appear — a stutter, also called a hitch or a spike.

16.6 ms budget (60 fps target) [==== CPU: scripts, physics, animation 7 ms ====][== GPU: rendering 9 ms ==] | | 0 ms 16.6 ms 33.3 ms budget (30 fps target) [==== CPU: scripts, physics, animation 7 ms ====][== GPU: rendering 9 ms ==][............ spare room ............] | | 0 ms 33.3 ms

Notice the same 7 ms of CPU work and 9 ms of GPU work fits comfortably inside the 30 fps budget with room to spare, but only just barely fits inside the 60 fps budget with almost nothing left over. This is why "make it run at 60 fps" is a much harder target than "make it run at 30 fps" — you have roughly half the time to do the same amount of work.

Common mistake Treating "we're at 55 fps, close enough to 60" as fine. 55 fps is a frame time of about 18.2 ms — already over the 16.6 ms budget, meaning some frames are being delayed. With v-sync (waiting for the display's refresh signal before showing a frame) on, a frame that takes even 0.1 ms too long often does not get delayed by 0.1 ms — it gets delayed to the next whole refresh interval, so a 17 ms frame can end up actually taking a full 33.2 ms to appear. A small overage can cost a full extra frame's worth of time.

3. CPU-Bound vs GPU-Bound: What It Means and How to Tell

Your game's frame is produced by two different processors working together: the CPU (central processing unit — runs your C# scripts, physics, animation, and decides what needs to be drawn) and the GPU (graphics processing unit — actually draws the pixels). They do not simply run one after another; Unity overlaps them so the CPU can start preparing the next frame while the GPU is still drawing the current one. This overlap is called pipelining.

CPU: [ Frame 1 CPU ][ Frame 2 CPU ][ Frame 3 CPU ][ Frame 4 CPU ] GPU: [ Frame 1 GPU ][ Frame 2 GPU ][ Frame 3 GPU ] ^ GPU starts frame 1 only once CPU finished preparing it -- but CPU is already free to start frame 2 at the same time

Because of this overlap, your final frame time in steady state is roughly the larger of the two costs, not their sum. If the CPU takes 11 ms and the GPU takes 6 ms, your frame time is close to 11 ms, not 17 ms — the GPU finishes early and waits. This matters enormously for optimizing: if you are CPU-bound (CPU is the larger, limiting cost), making your shaders cheaper or lowering resolution will barely help, because the GPU was never the bottleneck. If you are GPU-bound (GPU is the larger, limiting cost), rewriting your gameplay scripts to be faster will barely help either.

CPU-BOUND (CPU is the bottleneck) GPU-BOUND (GPU is the bottleneck) CPU: [======== 11 ms ========] CPU: [== 4 ms ==] GPU: [== 6 ms ==]......idle... GPU: [======== 13 ms ========] ^ ^ GPU sits idle, waiting CPU sits idle, waiting for CPU to feed it work for GPU to finish drawing

How do you actually tell which one you are, instead of guessing? Two reliable ways:

This distinction decides everything about where you spend your time next: optimizing CPU-side code when you are GPU-bound (or the other way around) can eat days of work for almost no measured improvement — exactly the mistake from Section 1, just on the CPU/GPU axis instead of the AI/UI axis.

4. Reading the Unity Profiler Window

Unity's Profiler (a built-in window that records exactly how much time and memory every part of your game used, frame by frame) is opened from Window > Analysis > Profiler. It has two halves that work together.

The top half: the timeline

The top half shows a strip of stacked graphs, one per module (CPU Usage, Rendering, Memory, Audio, and others). Each module's graph is made of thin vertical columns — one column per frame — and the height of a column is how many milliseconds that frame took. Inside the CPU Usage module, each column is also split into colored bands showing roughly how that frame's time divided between categories like Scripts, Rendering, Physics, and Garbage Collection.

CPU Usage module (top half of Profiler window) ms 20 | 18 | 16 |------------------------------------------- 16.6 ms budget line 14 | [S][S][S][S][S][S][S][S][S][S][S][S][S] 12 | [R][R][R][R][R][R][R][R][R][R][R][R][R] 10 | [P][P][P][P][P][P][P][P][P][P][P][P][P] --------------------------------------------- f1 f2 f3 f4 f5 f6 f7 f8 f9 f10 f11 (each column = one frame; S=Scripts R=Rendering P=Physics)

Click any column to select that frame. Whatever you select in the top half drives what the bottom half shows.

The bottom half: Hierarchy or Timeline view

The bottom half breaks the selected frame down function by function. In Hierarchy view it looks like a table: a function name, how many milliseconds it took (Time ms), and how many bytes it allocated on the managed heap (GC Alloc). Rows nest — expanding PlayerLoop shows the systems Unity ran, expanding one of those shows the MonoBehaviour methods it called, and so on down to your own code.

Function Time ms GC Alloc PlayerLoop 14.2 ms 2.1 KB Update.ScriptRunBehaviourUpdate 9.8 ms 2.1 KB EnemyAI.Update() 0.3 ms 0 B HealthBarBad.Update() 8.9 ms 2.1 KB <- biggest cost Physics.Simulate 1.1 ms 0 B Rendering.OpaqueDraw 3.3 ms 0 B

Timeline view shows the same information laid out on an actual time axis, per thread, which makes gaps and waiting easier to see than the table does. Both views show the same underlying data; Hierarchy view is usually faster for finding "what's the single biggest number," Timeline view is better for seeing "what was everything doing at this exact moment."

Main thread vs render thread

Both views separate work by thread. The main thread is where your gameplay code runs — every MonoBehaviour method, physics step, and animation update, plus building the list of draw commands for the GPU. On many platforms Unity also uses a separate render thread, whose only job is taking that list of commands and actually submitting it to the graphics driver, so the main thread does not have to wait on the driver itself. A big gap on the render thread's row usually means it is waiting on the main thread to hand it work; a big gap on the main thread usually means it is waiting on the render thread or the GPU.

5. Spotting a Spike in the Timeline

A spike (also called a hitch or a stutter, the visible effect a player notices) is simply one frame's column standing out taller than the columns around it. You do not need to read exact numbers to spot one — a consistent row of similar-height columns with one column poking up well above the rest is a spike, and it is exactly the kind of frame you click on first.

ms 40 | # 36 | # 32 | # 28 | # <- one frame way over budget 24 | # 20 | # 16 |------------------------------------#-------------- 16.6 ms budget 14 | # # # # # # # # # # 12 | # # # # # # # # # # --------------------------------------------------- f1 f2 f3 f4 f5 f6 f7(SPIKE) f8 f9 f10

The frames on either side of a spike being normal is actually useful information: it tells you the cost is not constant (like "this scene always has too many objects"), it is event-driven — something specific happened on that one frame. Common causes of an isolated spike like this: a garbage collection pass (Section 8), loading an asset synchronously, a physics query that occasionally does much more work than usual, or a UI layout rebuild triggered by one specific change. Click the spike, look at the Hierarchy view underneath it, and the biggest Time ms or GC Alloc number in that one frame's breakdown is almost always your answer.

Tip Right-click the Profiler's Hierarchy view header row and make sure the GC Alloc column is visible, then click that column header to sort by it. A spike caused by garbage collection sorts straight to the top instead of you having to scroll and guess.

6. The Frame Debugger: Every Draw Call, In Order

The Profiler tells you a frame spent, say, 3.3 ms on rendering — but not which objects, or why. Unity's Frame Debugger (Window > Analysis > Frame Debugger) answers that: it freezes one frame and lets you step through every single draw call (one instruction telling the GPU "draw this mesh, with this material, right now") in the exact order Unity issued them, showing you the image build up on screen call by call.

Frame Debugger -- 214 draw calls total, stepping through in order [1] Skybox tris: 12 [2] Terrain tris: 40,201 [3] Player_Mesh (batched x1) tris: 3,004 [4] Enemy_Mesh (batched x1) tris: 3,004 [5] Enemy_Mesh (batched x1) tris: 3,004 ... [189] UI Canvas - HealthBarText tris: 8 [190] UI Canvas - HealthBarText tris: 8 <- drawn AGAIN?

For each draw call selected, a side panel shows exactly which GameObject and material it came from, the shader and pass used, the vertex and triangle count, and — importantly — whether Unity managed to batch it (combine it with other draw calls into one GPU submission to save overhead) or not, and if not, why not (a different material, a different shader keyword, and so on breaks batching). Seeing the same UI element listed as two separate draw calls, like row [190] above, is exactly the kind of clue that leads you toward a UI element being rebuilt more often than it should — the same family of bug as the health bar text problem from Section 1.

Common mistake Assuming "high draw call count" always means "GPU-bound." A high count of tiny, cheap draw calls is often a CPU cost — the CPU has to spend time on each one preparing and submitting it, even if the GPU draws it in almost no time. Confirm which side is actually the bottleneck (Section 3) before assuming reducing draw calls will help the number you care about.

7. The Memory Profiler: Finding What Is Holding RAM

The Memory Profiler (a separate package, com.unity.memoryprofiler, installed through the Package Manager) answers a different question than the Profiler window: not "how much time," but "what is using how much RAM, right now." You take a snapshot (a complete capture of memory at one instant) and Unity gives you a full breakdown.

The Summary view splits memory into two big categories you already have the background for: native memory (memory owned by Unity's C++ engine side — textures, meshes, audio clips, and other engine objects) and managed memory (the C# heap from your earlier memory chapter — every object, array, and string your scripts allocated, tracked by the garbage collector).

Snapshot: 412 MB total +-- Native (engine-owned) : 260 MB | +-- Textures : 190 MB <- biggest single category | +-- Meshes : 40 MB | +-- Audio Clips : 30 MB +-- Managed (C# heap) : 152 MB +-- byte[] : 80 MB <- unexpectedly big, why? +-- string : 12 MB +-- List<Enemy> : 4 MB +-- (everything else) : 56 MB

The Tree Map view draws this same breakdown as nested boxes sized by memory used, so one giant unused texture or one runaway array visually jumps out instead of hiding in a long list of numbers. The most powerful feature, though, is comparing two snapshots — take one right after a level loads, play for a while, take another, and ask Unity to diff them. Anything that grew and did not come back down (an array of textures that should have been unloaded, a list of enemies that keeps growing because dead ones are never removed) is a strong candidate for a memory leak — something being kept alive by a reference nobody meant to keep, often an event subscription that was never unsubscribed.

8. GC Allocations Per Frame: Why Garbage Causes a Stutter

From the earlier C# memory chapter you already know that objects, arrays, and strings live on the heap (managed memory tracked and eventually freed by the garbage collector, or GC — the system that finds heap memory nothing refers to anymore and reclaims it), while value types like int and float normally live on the stack and clean up automatically when a function returns. Every time your code creates a new object, array, or string with new (or implicitly, like string concatenation), it is an allocation — memory carved out of the heap that the GC will eventually have to deal with.

A single small allocation costs almost nothing. The problem is rate: if your game allocates repeatedly every frame — even small amounts — the heap fills up, and eventually the garbage collector has to run a collection pass to find and reclaim the garbage. Historically, Unity's default garbage collector is a stop-the-world collector: while it runs, every one of your threads pauses completely until it finishes. Since Unity 2019.1, an optional Incremental Garbage Collector can spread that work across many small slices over several frames instead of one big pause — this shrinks the visible stutter a lot, but it does not make allocation free; every allocation anywhere in your game still adds to the pile the collector eventually has to walk, incrementally or not.

Frame time (ms), stop-the-world GC pause on frame 6 40 | ############# 36 | # GC PAUSE # 32 | ############# 28 | ############# 24 | ############# 20 | ############# 16 -----------------------#############------------- 16.6 ms budget 12 | # # # # # ############# # # --------------------------------------------- f1 f2 f3 f4 f5 f6(collect) f7 f8

This is exactly the shape of spike from Section 5, and exactly what happened in the Section 1 story: small, repeated allocations from a UI text rebuild, every single frame, eventually crossed an internal threshold and triggered a collection — a multi-millisecond pause the player feels as a stutter, roughly on a schedule matching how fast the garbage piled up. The Profiler's GC Alloc column is how you catch this before a player does: any function showing a non-zero GC Alloc number on a frame that runs every frame (like Update()) is worth a second look, even if the number looks small — it is the rate, not the one-time size, that causes the eventual pause.

9. Allocating vs Non-Allocating C#: The Same Bug From the Story, Fixed

Here is the exact bug from Section 1's story, in code, along with the fix and what each version actually costs according to the Profiler.

The allocating version

using UnityEngine;
using UnityEngine.UI;

public class HealthBarBad : MonoBehaviour
{
    public Text label;
    public int currentHealth = 100;

    void Update()
    {
        // BAD: builds a brand new string every single frame,
        // even on frames where currentHealth did not change at all.
        label.text = "HP: " + currentHealth + " / 100";
    }
}

Strings in C# are immutable (once created, a string's contents can never be changed in place) — you learned this in the earlier C# chapter. That means "HP: " + currentHealth + " / 100" cannot edit an existing string; it has to build one or more brand new string objects on the heap and throw the old ones away. Doing that inside Update() means it happens 60 times a second, whether or not currentHealth ever changed.

Profiler Hierarchy view, one frame: Function Time ms GC Alloc HealthBarBad.Update() 0.02 ms 44 B <- every single frame

44 bytes does not sound like much. But 44 bytes times 60 frames a second is about 2.6 KB a second, and that adds up: after roughly two seconds the accumulated garbage crosses Unity's internal collection threshold, the garbage collector runs, and on a mid-range phone that stop-the-world pause can cost 20-30 ms — more than an entire frame budget at 60 fps, all at once. That is the exact spike from the Section 1 story.

The non-allocating version

using UnityEngine;
using UnityEngine.UI;

public class HealthBarGood : MonoBehaviour
{
    public Text label;
    public int currentHealth = 100;

    int lastShownHealth = -1; // an impossible value, forces the first update

    void Update()
    {
        // GOOD: only touch the string when the value actually changed.
        if (currentHealth == lastShownHealth) return;

        lastShownHealth = currentHealth;
        label.text = "HP: " + currentHealth + " / 100";
    }
}

This version still uses the exact same string concatenation — the fix is not a cleverer way to build the string, it is not building it unless the value actually changed. During a boss fight, health might change a few times a second at most, not 60 times a second.

Profiler Hierarchy view, across several frames: Function Time ms GC Alloc HealthBarGood.Update() 0.001 ms 0 B <- most frames (early return) HealthBarGood.Update() 0.001 ms 0 B HealthBarGood.Update() 0.02 ms 44 B <- only the frame health changed HealthBarGood.Update() 0.001 ms 0 B

Same visual result on screen, same string-building code even, but allocations drop from roughly 60 times a second to a small handful of times per fight — nowhere near enough to trigger a collection pause during gameplay. This is the general shape of most allocation fixes: you rarely need exotic tricks, you mostly need to stop doing the expensive thing on frames where nothing changed.

Tip This same "only rebuild when something changed" pattern fixes a huge fraction of real allocation bugs in UI code, not just health bars: score counters, timers, minimap labels, inventory counts — anything that redraws text or rebuilds a list every Update() regardless of whether its data changed.

10. Finding the Allocating Line

Section 9's example is small enough to spot by eye, but real game code is not. Three techniques narrow a spike down to one exact line.

Sort the Hierarchy view by GC Alloc

Click a spiking frame, then in the bottom Hierarchy view, sort by the GC Alloc column (largest first). Expand the top row, then the next, following the biggest number down through the call stack — PlayerLoop to Update.ScriptRunBehaviourUpdate to the exact MonoBehaviour's Update() method. This alone finds most problems, because Unity already tracks allocations per function by default.

Turn on Deep Profile

By default the Profiler only tracks Unity's own systems and functions marked with a profiler sample. If the allocation happens inside a private helper method several calls deep — code the default view will not break out separately — enable Deep Profile (a checkbox near the Profiler's record button) to track every single C# method call. It is much slower to run and adds noticeable overhead of its own, so use it briefly to find the exact line, then turn it back off.

Add your own markers

For code with several distinct chunks inside one Update(), wrap each chunk in a named marker so it shows up as its own row instead of being lumped into the whole method:

using UnityEngine;
using UnityEngine.Profiling;

void Update()
{
    Profiler.BeginSample("HealthBar.RebuildText");
    label.text = "HP: " + currentHealth + " / 100";
    Profiler.EndSample();

    Profiler.BeginSample("HealthBar.UpdateColor");
    label.color = Color.Lerp(Color.red, Color.green, currentHealth / 100f);
    Profiler.EndSample();
}

Now the Profiler shows HealthBar.RebuildText and HealthBar.UpdateColor as two separate rows with their own Time ms and GC Alloc numbers, instead of one combined number for the whole Update() that hides which half is the actual problem.

11. GPU Profiling at a Concept Level: RenderDoc and Xcode GPU Capture

Unity's own Frame Debugger shows you the order of draw calls, but not deep detail about what the GPU itself spent time doing inside one draw call. For that, dedicated GPU debugging tools capture a single frame and let you inspect it at the hardware level.

Both tools exist to answer one question at the concept level every beginner should know even before touching the tools themselves: is the GPU cost dominated by pixels or by vertices?

Fill-rate bound

Fill-rate is how many pixels the GPU can shade per second. A game is fill-rate bound when the cost comes from running the pixel/fragment shader too many times — usually caused by overdraw (the same screen pixel being drawn, and its shader run, multiple times because several objects overlap, especially common with stacked transparent effects like particles), an expensive full-screen post-processing effect, or simply rendering at a high resolution.

One column of screen pixels, 5 overlapping transparent particles: particle 5 ########## particle 4 ########## particle 3 ########## particle 2 ########## particle 1 ########## -------------------------------------------------- 1 pixel on screen ends up running the pixel shader 5 separate times -- this is "5x overdraw"

Vertex bound

A game is vertex bound when the cost instead comes from the vertex shader — running once per vertex, not per pixel — usually caused by very high-poly meshes, complex skinning (bone-driven character deformation) on many characters at once, or tessellation.

The cheap diagnostic test does not require RenderDoc or Xcode at all: temporarily lower the render resolution. If frame time drops sharply, you are fill-rate bound (fewer pixels to shade). If it barely moves, temporarily swap in lower-poly meshes or disable skinning instead — if that drops frame time sharply, you are vertex bound. Only once you know which one, is it worth opening RenderDoc or Xcode to find the exact expensive draw call or shader.

12. Profiling on the Real Device: Why Editor Numbers Lie

Every number this chapter has shown so far could come from the Unity Editor's own Play Mode — and that is exactly the trap. Editor numbers are close enough for rough comparisons ("is version A faster than version B") but are unreliable for the question that actually matters: "will this run fast enough on the device a player owns?" Four concrete reasons why:

The fix is to profile a real build, on the real (or worst-supported) hardware: enable Development Build in Build Settings (keeps the profiling hooks without shipping a full debug build), check Autoconnect Profiler so the Profiler window on your PC automatically connects to the running build, install it on the actual target device, and connect — ideally over a USB cable rather than WiFi, since WiFi profiling adds its own latency and can distort precise timing, especially for GPU numbers.

Same frame, two environments Editor Play Mode (desktop CPU/GPU, Editor overhead running alongside) [======== 8 ms ========] Development Build on a real mid-range phone (USB-connected Profiler) [================================== 38 ms ==================================]
Common mistake Optimizing entirely inside the Editor until frame time "looks good," then discovering on the first real device test that it is nowhere close to the target. Treat every editor number as a rough guide only, and check the real device early and often, not just once at the end.

13. The Repeatable Workflow: Measure, Find, Fix, Measure Again

Everything in this chapter combines into one repeatable loop. It is deliberately boring and mechanical, because that is what makes it reliable:

+---------------------+ | 1. MEASURE | | open the Profiler | | on a real device, | | find a spike | +----------+----------+ | v +---------------------+ | 2. FIND THE BIGGEST | | COST in that frame | | (Hierarchy view, | | Frame Debugger, | | Memory Profiler) | +----------+----------+ | v +---------------------+ | 3. FIX ONE THING | | (just the biggest | | cost -- nothing | | else yet) | +----------+----------+ | v +---------------------+ | 4. MEASURE AGAIN | +----------+----------+ | budget met? -- no --+ | | yes | | | v | done <-----+---- back to step 1

Two rules keep this loop from wasting time. First: fix only one thing before measuring again. Fixing several suspected problems at once and then measuring means you don't actually know which fix helped, or whether one fix quietly made something else worse. Second: always re-measure after a fix, even one you're confident about — fixing the biggest cost often reveals a second bottleneck that was hiding right behind it, and occasionally reveals that your "fix" made no measurable difference at all, which is exactly what happened with the pathfinding rewrite in Section 1.

Applying the full loop to the Section 1 story, with real numbers this time: measuring showed a 42 ms spike roughly every two seconds against a 16.6 ms budget; the Hierarchy view's GC Alloc column pointed straight at HealthBarBad.Update(); the fix was the one-line early-return from Section 9; re-measuring on the same phone showed the spike gone entirely, worst frame time down to about 15 ms. Total time to find and fix it with the Profiler: about ten minutes, against the two days spent guessing.

14. Glossary

15. Exercises

Exercise 1 — Frame Budget Math Your Profiler shows the main thread spending 11.4 ms on scripts and physics, and rendering taking 6.2 ms, for a measured total frame time of 11.6 ms (not 11.4 + 6.2, because of the CPU/GPU pipelining from Section 3). Answer: (a) roughly what fps does an 11.6 ms frame time correspond to; (b) is the game CPU-bound or GPU-bound, and why; (c) the team wants a stable 60 fps (16.6 ms budget) — do they currently have headroom, and if they needed to find more, which side should they optimize first?
Show answer
(a) fps = 1000 / frame time(ms) = 1000 / 11.6 ->  about 86 fps
    (uncapped -- with v-sync locked to 60 fps the game would just
    show a steady 60, with room to spare)

(b) CPU-bound. The CPU's own cost (11.4 ms) is larger than the
    GPU's cost (6.2 ms), and the measured total (11.6 ms) sits
    close to the CPU number, matching the pipelining rule from
    Section 3: total frame time tracks the larger of the two.

(c) Yes, headroom: 11.6 ms measured against a 16.6 ms budget
    leaves about 5 ms of room. If more headroom were needed,
    optimize the CPU side first -- it is the bottleneck (11.4 ms
    vs 6.2 ms), so cutting GPU/shader cost would barely move the
    total, the same mistake as Section 1's pathfinding rewrite.
Exercise 2 — Fix the Allocating Update The following script checks for nearby enemies every frame and alerts them. Identify every source of per-frame garbage allocation in it, then rewrite it so it allocates nothing during normal gameplay.
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class AlertNearbyEnemies : MonoBehaviour
{
    void Update()
    {
        List<Enemy> nearby = FindObjectsOfType<Enemy>()
            .Where(e => Vector3.Distance(e.transform.position, transform.position) < 10f)
            .ToList();

        foreach (var e in nearby)
        {
            e.Alert();
        }
    }
}
Show answer

Three separate allocations happen here, every single frame: FindObjectsOfType<Enemy>() allocates a brand new array by scanning the whole scene every call; .Where(...) allocates a closure (to capture transform.position) and an iterator object; .ToList() allocates a new List<Enemy> to hold the results. All three happen whether or not any enemy is actually nearby.

using System.Collections.Generic;
using UnityEngine;

public class AlertNearbyEnemies : MonoBehaviour
{
    List<Enemy> allEnemies; // filled once, not every Update

    void Start()
    {
        allEnemies = new List<Enemy>(FindObjectsOfType<Enemy>());
    }

    void Update()
    {
        for (int i = 0; i < allEnemies.Count; i++)
        {
            Enemy e = allEnemies[i];
            if (Vector3.Distance(e.transform.position, transform.position) < 10f)
            {
                e.Alert();
            }
        }
    }
}

The fix moves the expensive scene scan (FindObjectsOfType) into Start() so it runs once, not sixty times a second. It replaces the LINQ chain with a plain for loop over a list that already exists, so no closure, no iterator, and no new list gets built each frame. The distance check itself still runs every frame, but comparing numbers allocates nothing. A further real-world improvement, for very large enemy counts, would be Physics.OverlapSphereNonAlloc, a Unity API built specifically to reuse the same buffer array every call instead of allocating a new one.

Exercise 3 — Diagnose Fill-Rate vs CPU Cost On a real device your frame time is 30 ms (about 33 fps). You run two separate experiments from a clean state: (1) lowering the render resolution scale from 100% to 50% drops frame time to 14 ms; (2) putting resolution back to 100% and instead removing half the particle effects on screen only drops frame time to 28 ms. What do these two results tell you about where the cost is, and what would you check next?
Show answer

Experiment 1 shows a huge drop (30 ms to 14 ms) from lowering resolution alone, which is the diagnostic test from Section 11 for being fill-rate bound — the GPU cost is dominated by shading pixels, not by CPU work or vertex count, since fewer pixels made a massive difference.

Experiment 2 shows almost no drop (30 ms to 28 ms) from removing half the particles, which rules out particle overdraw specifically as the main fill-rate cost — the particles were not the expensive pixels, something else is. Since the game is confirmed fill-rate bound but not because of the particles, the next step is opening the Frame Debugger to look for a different pixel-heavy culprit — a large full-screen post-processing effect, an expensive UI panel covering most of the screen, or a high shadow resolution — and, once a specific draw call looks suspicious, using RenderDoc or Xcode's GPU Frame Capture to inspect that exact draw call's shader cost directly.

← Back to all chapters