Chapter 24

Performance & Optimization

Profile your game, identify bottlenecks, reduce draw calls, eliminate garbage collection spikes, and ensure a silky-smooth 60 FPS on every target platform.

Why Optimization Matters

Your game might run perfectly on your development PC. But most infinite runners are played on mobile phones — devices with limited CPU, GPU, and memory. A game that stutters, drops frames, or heats up the phone will be uninstalled in seconds. Players expect a consistent 60 frames per second (or 30 FPS minimum on low-end devices).

Optimization is not about making code "fast" in the abstract — it is about finding the specific things that are slow and fixing them. That is why we profile first, optimize second. Never guess where the bottleneck is.

The Golden Rule

Measure, do not guess. Developers waste enormous amounts of time optimizing code that was never the bottleneck. Always use the Unity Profiler to identify the actual problem before changing anything. If the Profiler says rendering takes 90% of your frame time, optimizing your C# scripts will not help.

Unity Profiler Walkthrough

The Unity Profiler is a built-in tool that shows you exactly where your game spends its time each frame. It is your most important optimization tool.

Opening the Profiler

  1. In Unity, go to Window > Analysis > Profiler (or press Ctrl+7 on Windows, Cmd+7 on Mac).
  2. Press Play in the Editor. The Profiler immediately starts recording data.
  3. Play your game for 10–30 seconds to capture a representative sample.
  4. Click anywhere on the timeline at the top to inspect a specific frame.

CPU Usage Panel

This is the most important panel. It shows how much time each system takes per frame.

  • PlayerLoop — The total frame time. At 60 FPS, each frame has a budget of 16.67 milliseconds. If any frame exceeds this, you drop below 60 FPS.
  • Scripts (green) — Time spent in your C# code (Update, FixedUpdate, coroutines).
  • Rendering (dark blue) — Time the GPU spends drawing everything.
  • Physics (orange) — Time spent on physics calculations (collisions, rigidbodies).
  • GC.Alloc — The column that shows how many bytes of garbage were allocated this frame. This is crucial — any allocation will eventually trigger the garbage collector, causing a stutter.

Click on a frame that shows a spike (a tall bar). In the bottom panel, you will see a hierarchy of every method that ran. Sort by Time ms to find the slowest methods, or sort by GC Alloc to find the worst allocators.

Deep Profile Mode

Enable Deep Profile in the Profiler toolbar for a complete call stack of every method. This is much slower but shows you exactly which line of code is responsible for each cost. Use it for investigation, then disable it for normal testing.

Memory Panel

Switch to the Memory module in the Profiler to see overall memory usage:

  • Total Reserved — Memory Unity has allocated from the OS.
  • Total Used — Memory actually in use.
  • GC Reserved — Memory managed by the garbage collector (C# objects, strings, arrays).
  • Texture Memory — Often the biggest category on mobile. Large textures consume massive amounts of memory.

Rendering Panel

The Rendering module shows GPU-related statistics:

  • Batches (Draw Calls) — How many separate draw commands are sent to the GPU. Each draw call has overhead. Fewer is better. A typical mobile game aims for under 100 draw calls.
  • Triangles — Total triangle count being rendered. More triangles = more GPU work.
  • SetPass Calls — How many times the GPU switches shaders/materials. Very expensive. Minimize material variety in your scene.

Draw Call Optimization

Every time Unity tells the GPU to draw something, that is a draw call (also called a "batch"). Each draw call has fixed overhead — it is like sending a letter through the postal system. Sending 100 tiny letters (100 draw calls for 100 small objects) is far slower than sending one big package (1 draw call for 100 objects batched together).

Static Batching

For objects that never move (scenery, decorations, buildings), mark them as Static in the Inspector (the checkbox at the top right of the Inspector). Unity will combine their meshes at build time into a single draw call.

  1. Select all non-moving environment objects (fences, trees, ground decorations).
  2. In the Inspector, check the Static checkbox (or specifically Batching Static in the dropdown).
  3. Play the game and check the Profiler's Rendering panel — you should see fewer Batches.
Do NOT Mark Moving Objects as Static

Only static (non-moving) objects should be marked Static. If a moving object is marked Static, Unity will not update its position after the initial batch, causing it to appear frozen or to render in the wrong place. Obstacles and collectibles that move with the world should NOT be static.

Dynamic Batching

Unity can automatically batch small meshes (under 300 vertices) that share the same material, even if they move. This is enabled by default in URP (Universal Render Pipeline). To benefit:

  • Keep obstacle and collectible meshes simple (low vertex count).
  • Use the same material for as many objects as possible. Ten different colored cubes with ten different materials = 10 draw calls. Ten cubes with one material using vertex colors = 1 draw call.

GPU Instancing

If you have many copies of the same mesh with the same material (e.g., hundreds of coins), enable GPU Instancing on the material:

  1. Select the material in the Project window.
  2. In the Inspector, check Enable GPU Instancing.
  3. Unity will now render all instances in a single draw call (or very few).

SRP Batcher (URP)

If you are using the Universal Render Pipeline (recommended), the SRP Batcher is active by default. It does not reduce draw calls but makes each draw call faster by caching shader properties. You do not need to do anything — just make sure your materials use SRP-compatible shaders (any shader from the "Universal Render Pipeline" category).

Object Pooling Review

We built an object pooling system in Chapter 12. Let us review why it is one of the most important optimizations:

Without Pooling (BAD)C#
// Every frame, this creates a new obstacle and destroys old ones.
// Each Instantiate() allocates memory. Each Destroy() generates garbage.
// The garbage collector runs periodically to clean up, causing stutters.

void SpawnObstacle()
{
    GameObject obstacle = Instantiate(obstaclePrefab);  // ALLOCATION!
    // ... set position, etc. ...
}

void RemoveObstacle(GameObject obstacle)
{
    Destroy(obstacle);  // GARBAGE! GC will stutter later.
}
With Pooling (GOOD)C#
// Objects are created once at startup and recycled.
// No runtime allocations, no garbage, no GC stutters.

void SpawnObstacle()
{
    GameObject obstacle = pool.Get();    // Just activates an existing object
    obstacle.SetActive(true);
    // ... set position, etc. ...
}

void RemoveObstacle(GameObject obstacle)
{
    obstacle.SetActive(false);
    pool.Return(obstacle);               // Returns to pool for reuse
}

If you followed Chapter 12, your obstacles, collectibles, and particles are already pooled. If anything is still using Instantiate/Destroy at runtime, fix it now.

LOD (Level of Detail)

LOD is a technique where objects far from the camera use simpler (fewer triangles) meshes. As the camera gets closer, higher-detail meshes swap in. This reduces the GPU workload for distant objects.

  1. Create 2–3 versions of your obstacle/environment mesh at different detail levels (e.g., 1000 tris, 300 tris, 50 tris).
  2. Create an empty GameObject and add a LOD Group component.
  3. In the LOD Group Inspector, you will see LOD levels (LOD 0, LOD 1, LOD 2, Culled). Drag each mesh version into its corresponding LOD slot.
  4. Adjust the distance thresholds by dragging the LOD bars.
  5. The "Culled" level means the object is invisible beyond a certain distance — free performance!
LOD for Our Runner

In an infinite runner, LOD is especially valuable because the camera always faces forward. Objects far down the track can use very simple meshes (or be culled entirely), and the player will never notice because they are so far away. Environment decorations on the sides of the track are great candidates for aggressive LOD.

Texture Atlasing and Compression

Textures are often the biggest memory consumer, especially on mobile. Here are the key optimization strategies:

Texture Compression

  • Select a texture in the Project window and look at the Inspector.
  • Under Platform-specific overrides, set compression for each platform:
    • Android: Use ASTC (best quality-to-size ratio) or ETC2.
    • iOS: Use ASTC.
    • PC: Use DXT (BC) compression.
  • Set Max Size to the smallest resolution that still looks good. A 2048x2048 texture on a coin that is 20 pixels on screen is wasteful — use 128x128 or 256x256.
  • Disable Generate Mip Maps for UI textures and sprites (they are always at a fixed distance).

Texture Atlasing

A texture atlas combines multiple small textures into one large texture. Instead of 20 draw calls for 20 objects with 20 different textures, you get 1 draw call because they all share one atlas material.

  • For 2D sprites, Unity's Sprite Atlas system does this automatically: right-click in the Project > Create > 2D > Sprite Atlas, then drag your sprites into it.
  • For 3D objects, you can manually create atlases in an image editor or use an asset like TexturePacker.

Camera Culling and Occlusion

The camera's Clipping Planes determine the visible range:

  • Near Clip: How close objects can be before they are not rendered. Default is 0.3. Keep it as high as possible without clipping visible geometry.
  • Far Clip: How far objects can be before they are not rendered. Set this to only slightly beyond the furthest visible part of your track. If your track is visible 100 units ahead, set Far Clip to 120 — not the default 1000.

Reducing the Far Clip plane means fewer objects are sent to the GPU each frame. Combined with LOD culling, objects beyond the visible range cost zero performance.

Fog as a Visual Trick

Add distance fog to your scene (Lighting > Environment > Other Settings > Fog). Set the fog end distance to match your Far Clip plane. This hides the popping that occurs when objects suddenly appear at the edge of the camera's range, making the culling invisible to the player.

Reducing Garbage Collection Allocations

The C# garbage collector (GC) periodically scans memory for objects that are no longer referenced and frees them. This scan causes a frame stutter — sometimes 10–50 milliseconds, which is a visible hitch at 60 FPS. The solution is to avoid creating garbage in the first place.

Common Allocation Traps and Fixes

TrapFix
Instantiate / Destroy every frame Use object pooling (Chapter 12)
GetComponent<T>() every frame Cache the result in Awake() or Start()
String concatenation in Update (e.g., "Score: " + score) Use StringBuilder or TextMeshPro.SetText("{0}", score)
LINQ queries (e.g., .Where(), .Select()) Use plain for/foreach loops — LINQ allocates iterators
Lambda closures that capture variables Use cached delegates or avoid closures in hot paths
new arrays/lists every frame Allocate once in Awake() and reuse
Boxing value types (e.g., passing int to object parameter) Use generic methods to avoid boxing

Detailed Examples

GC Traps vs. FixesC#
// ═══════════════════════════════════════════════════════
// TRAP 1: GetComponent every frame
// ═══════════════════════════════════════════════════════

// BAD — allocates and searches every frame
void Update()
{
    Rigidbody rb = GetComponent<Rigidbody>(); // SLOW + possible GC
    rb.AddForce(Vector3.forward);
}

// GOOD — cache it once
private Rigidbody _rb;

void Awake()
{
    _rb = GetComponent<Rigidbody>(); // Called once
}

void Update()
{
    _rb.AddForce(Vector3.forward); // Fast, no allocation
}

// ═══════════════════════════════════════════════════════
// TRAP 2: String concatenation in Update
// ═══════════════════════════════════════════════════════

// BAD — creates new strings every frame (strings are immutable in C#)
void Update()
{
    scoreText.text = "Score: " + score.ToString(); // 2 allocations!
}

// GOOD — use TextMeshPro's SetText with format specifiers
// (no allocation because it uses an internal char buffer)
void Update()
{
    scoreText.SetText("Score: {0}", score); // ZERO allocations!
}

// ALSO GOOD — only update when the value changes
private int _lastDisplayedScore = -1;

void Update()
{
    if (score != _lastDisplayedScore)
    {
        _lastDisplayedScore = score;
        scoreText.SetText("Score: {0}", score);
    }
}

// ═══════════════════════════════════════════════════════
// TRAP 3: Using structs vs. classes for small data
// ═══════════════════════════════════════════════════════

// BAD — class (heap allocated, creates garbage when no longer referenced)
public class DamageInfo
{
    public int amount;
    public Vector3 hitPoint;
}

// GOOD — struct (stack allocated, no garbage collection)
public struct DamageInfo
{
    public int amount;
    public Vector3 hitPoint;
}

// ═══════════════════════════════════════════════════════
// TRAP 4: Creating arrays every frame
// ═══════════════════════════════════════════════════════

// BAD — creates a new array every frame
void Update()
{
    Collider[] hits = Physics.OverlapSphere(pos, radius); // ALLOCATION!
}

// GOOD — pre-allocate and use the NonAlloc version
private Collider[] _hitBuffer = new Collider[20];

void Update()
{
    int hitCount = Physics.OverlapSphereNonAlloc(pos, radius, _hitBuffer);
    for (int i = 0; i < hitCount; i++)
    {
        // Process _hitBuffer[i]
    }
}
Finding GC Allocations

In the Profiler's CPU panel, look at the GC Alloc column. Sort by it (click the column header). Any method showing allocations in Update, FixedUpdate, or LateUpdate is a problem. The Profiler tells you exactly which methods allocate and how many bytes. Fix the biggest offenders first.

Mobile-Specific Optimization Tips

Mobile devices have much less processing power than a desktop PC. Here are platform-specific tips:

Target Frame Rate

Setting Target Frame RateC#
// Set this in your GameManager's Awake() or Start()
// 60 FPS is ideal, but 30 FPS is acceptable on low-end devices.
void Start()
{
    // Disable VSync so our target frame rate takes effect
    QualitySettings.vSyncCount = 0;

    // Set target to 60 FPS
    Application.targetFrameRate = 60;
}

// For low-end device detection, you could check SystemInfo:
void SetQualityBasedOnDevice()
{
    int memoryMB = SystemInfo.systemMemorySize;

    if (memoryMB < 2048) // Less than 2 GB RAM
    {
        // Low-end device
        Application.targetFrameRate = 30;
        QualitySettings.SetQualityLevel(0); // Lowest quality
    }
    else if (memoryMB < 4096) // 2-4 GB RAM
    {
        // Mid-range device
        Application.targetFrameRate = 60;
        QualitySettings.SetQualityLevel(1); // Medium quality
    }
    else
    {
        // High-end device
        Application.targetFrameRate = 60;
        QualitySettings.SetQualityLevel(2); // High quality
    }
}

Quality Settings

  • Open Edit > Project Settings > Quality.
  • Create 2–3 quality levels: Low, Medium, High.
  • For each level, adjust:
    • Shadows: Disable on Low, Soft Shadows on Medium, Hard/Soft on High.
    • Anti-Aliasing: Off on Low, 2x on Medium, 4x on High.
    • Texture Quality: Half Res on Low, Full Res on Medium/High.
    • Particle Raycast Budget: Lower on Low quality.
  • Set the default quality level for each platform (Android defaults to Low, PC defaults to High).

Reduce Post-Processing

Post-processing effects (bloom, vignette, color grading, ambient occlusion) look beautiful but are expensive on mobile GPUs. On the Low quality level:

  • Disable Bloom and Ambient Occlusion entirely.
  • Reduce Color Grading to a simple LUT (Look-Up Table).
  • Disable Motion Blur (it is not useful in an endless runner anyway since the camera does not rotate much).

Test on Actual Devices

Do Not Trust the Editor

The Unity Editor runs much slower than a real build because of all the editor overhead (Inspector, Scene view, Profiler itself). A game that runs at 30 FPS in the Editor might run at 60 FPS on a real device — or vice versa. You MUST test on actual target devices. Build an APK, install it on a real Android phone, and check performance there. The Profiler can connect to a running device over USB for remote profiling.

Using the Frame Debugger

The Frame Debugger shows you every single draw call in a single frame, one at a time. It is invaluable for understanding why you have so many draw calls and which objects are responsible.

  1. Go to Window > Analysis > Frame Debugger.
  2. Press Play, then click Enable in the Frame Debugger window.
  3. The game pauses and the Frame Debugger shows a numbered list of draw events.
  4. Click through each event to see what it draws. The Scene view highlights the affected geometry.
  5. Look for patterns:
    • Many separate draw calls for objects that share a material? They are not being batched — check if they can be marked Static or use GPU Instancing.
    • Redundant draw calls from UI elements? Combine UI elements into fewer canvases.
    • Unexpected draw calls from invisible objects? Something off-screen is still being rendered — check your culling settings.
Batching Broken?

The Frame Debugger tells you why batching failed for a specific draw call. Select a draw call and look at the "Why this draw call can't be batched with the previous one" message. Common reasons: different materials, different lightmaps, different render queues, or exceeding the vertex limit for dynamic batching.

Optimization Checklist

Use this checklist before building your final release:

CategoryCheckStatus
RenderingDraw calls under 100 on mobile
RenderingGPU Instancing enabled on repeated-object materials
RenderingStatic batching on non-moving environment objects
RenderingCamera Far Clip set to minimum necessary distance
RenderingLOD Groups on complex environment meshes
MemoryAll runtime spawning uses object pools (no Instantiate/Destroy)
MemoryTextures compressed and appropriately sized
MemoryNo GC allocations in Update/FixedUpdate/LateUpdate
CodeGetComponent results cached in Awake/Start
CodeNo string concatenation in hot paths
CodeUsing NonAlloc physics methods
CodeNo LINQ in Update loops
MobileApplication.targetFrameRate set appropriately
MobileQuality levels configured for low/mid/high devices
MobilePost-processing reduced or disabled on low quality
MobileTested on at least one real target device

What We Covered

In this chapter, we learned how to find and fix performance problems:

  • The Unity Profiler — CPU, Memory, and Rendering panels for identifying bottlenecks.
  • Draw call reduction — Static batching, dynamic batching, GPU Instancing, and the SRP Batcher.
  • Object pooling (review) — Eliminating Instantiate/Destroy overhead.
  • LOD Groups — Reducing geometry for distant objects.
  • Texture optimization — Compression, max size limits, and atlasing.
  • GC allocation elimination — Caching GetComponent, avoiding string concatenation, using structs, pre-allocating buffers, and using NonAlloc physics methods.
  • Mobile-specific tips — Target frame rate, quality levels, reduced post-processing, and testing on real devices.
  • The Frame Debugger — Stepping through draw calls to understand rendering costs.

In the final chapter, we will build and publish our game — creating builds for PC, Android, WebGL, and iOS, configuring player settings, and shipping to platforms like itch.io and the Google Play Store.

Save Your Progress

Optimization knowledge is locked in. Commit.

git add .
git commit -m "Apply performance optimizations and profiling"
git push