Performance Optimization and Profiling

Welcome to the essential guide for making your Unity games run smoothly! In this chapter, you'll learn how to identify performance bottlenecks, optimize your code and assets, and use Unity's powerful profiling tools to create games that run at 60 FPS on target devices.

What You'll Learn

By the end of this chapter, you'll know how to:

  • Profile your game using Unity's built-in tools
  • Identify performance bottlenecks in CPU, GPU, and memory
  • Optimize scripts for better performance
  • Optimize assets like textures, models, and audio
  • Use best practices for mobile and desktop optimization

Why Performance Matters

Performance optimization is crucial for player experience. A game that runs smoothly feels professional and keeps players engaged, while a laggy game will frustrate users and hurt your reputation.

Pro Tip: Start optimizing early in development. It's much easier to build performance-friendly code from the start than to fix performance issues later.

Step 1: Understanding Unity's Profiler

Unity's Profiler is your best friend for identifying performance issues. Let's explore how to use it effectively.

Opening the Profiler

  1. Window → Analysis → Profiler (or press Ctrl+7)
  2. Play your game to start profiling
  3. Click "Record" to begin collecting data

Key Profiler Modules

CPU Usage

  • Rendering: Time spent drawing objects
  • Scripts: Your C# code execution time
  • Physics: Physics calculations and collisions
  • Garbage Collection: Memory allocation and cleanup

Memory Usage

  • Used Memory: Current memory consumption
  • GC Alloc: Garbage collection allocations
  • Texture Memory: VRAM usage for textures
  • Mesh Memory: 3D model data in RAM

GPU Usage

  • Draw Calls: Number of objects drawn per frame
  • Batches: Grouped draw calls for efficiency
  • SetPass Calls: Shader state changes

Step 2: CPU Optimization Techniques

Script Optimization

Avoid Update() when possible:

// ❌ BAD: Called every frame
void Update()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        Jump();
    }
}

// ✅ GOOD: Use event-driven approach
void Start()
{
    InputManager.OnJumpPressed += Jump;
}

Cache frequently accessed components:

public class PlayerController : MonoBehaviour
{
    // ❌ BAD: Gets component every frame
    void Update()
    {
        GetComponent<Rigidbody>().velocity = new Vector3(0, 0, 0);
    }

    // ✅ GOOD: Cache the component
    private Rigidbody rb;

    void Start()
    {
        rb = GetComponent<Rigidbody>();
    }

    void Update()
    {
        rb.velocity = new Vector3(0, 0, 0);
    }
}

Use object pooling for frequently created/destroyed objects:

public class ObjectPool : MonoBehaviour
{
    [SerializeField] private GameObject prefab;
    [SerializeField] private int poolSize = 50;

    private Queue<GameObject> pool = new Queue<GameObject>();

    void Start()
    {
        // Pre-instantiate objects
        for (int i = 0; i < poolSize; i++)
        {
            GameObject obj = Instantiate(prefab);
            obj.SetActive(false);
            pool.Enqueue(obj);
        }
    }

    public GameObject GetObject()
    {
        if (pool.Count > 0)
        {
            GameObject obj = pool.Dequeue();
            obj.SetActive(true);
            return obj;
        }
        return Instantiate(prefab);
    }

    public void ReturnObject(GameObject obj)
    {
        obj.SetActive(false);
        pool.Enqueue(obj);
    }
}

Coroutine Optimization

Use coroutines instead of Update for timed events:

// ❌ BAD: Using Update with timer
public class BadTimer : MonoBehaviour
{
    private float timer = 0f;
    private float interval = 1f;

    void Update()
    {
        timer += Time.deltaTime;
        if (timer >= interval)
        {
            DoSomething();
            timer = 0f;
        }
    }
}

// ✅ GOOD: Using coroutine
public class GoodTimer : MonoBehaviour
{
    void Start()
    {
        StartCoroutine(DoSomethingRepeatedly());
    }

    IEnumerator DoSomethingRepeatedly()
    {
        while (true)
        {
            yield return new WaitForSeconds(1f);
            DoSomething();
        }
    }
}

Step 3: Memory Optimization

Garbage Collection Best Practices

Avoid creating garbage in Update():

// ❌ BAD: Creates garbage every frame
void Update()
{
    Vector3 position = transform.position;
    position.y += 1f;
    transform.position = position;
}

// ✅ GOOD: Reuse variables
private Vector3 position;
void Update()
{
    position = transform.position;
    position.y += 1f;
    transform.position = position;
}

Use StringBuilder for string concatenation:

// ❌ BAD: Creates many string objects
string result = "";
for (int i = 0; i < 1000; i++)
{
    result += i.ToString();
}

// ✅ GOOD: Use StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
    sb.Append(i.ToString());
}
string result = sb.ToString();

Cache frequently used values:

public class OptimizedEnemy : MonoBehaviour
{
    private Transform player;
    private float updateInterval = 0.1f; // Update every 0.1 seconds
    private float lastUpdateTime;

    void Start()
    {
        player = GameObject.FindWithTag("Player").transform;
    }

    void Update()
    {
        if (Time.time - lastUpdateTime >= updateInterval)
        {
            UpdateAI();
            lastUpdateTime = Time.time;
        }
    }
}

Step 4: Rendering Optimization

Draw Call Optimization

Use batching to reduce draw calls:

// Enable static batching for non-moving objects
[MenuItem("GameObject/Static Batching")]
static void EnableStaticBatching()
{
    GameObject[] objects = Selection.gameObjects;
    foreach (GameObject obj in objects)
    {
        obj.isStatic = true;
    }
}

Use GPU Instancing for similar objects:

public class InstancedRendering : MonoBehaviour
{
    public Mesh mesh;
    public Material material;
    public int instanceCount = 1000;

    private Matrix4x4[] matrices;
    private MaterialPropertyBlock propertyBlock;

    void Start()
    {
        matrices = new Matrix4x4[instanceCount];
        propertyBlock = new MaterialPropertyBlock();

        // Generate random positions
        for (int i = 0; i < instanceCount; i++)
        {
            matrices[i] = Matrix4x4.TRS(
                Random.insideUnitSphere * 10f,
                Quaternion.identity,
                Vector3.one
            );
        }
    }

    void Update()
    {
        Graphics.DrawMeshInstanced(mesh, 0, material, matrices, propertyBlock);
    }
}

LOD (Level of Detail) System

Implement LOD for distant objects:

public class LODController : MonoBehaviour
{
    [SerializeField] private GameObject[] lodLevels;
    [SerializeField] private float[] distances;
    [SerializeField] private Transform player;

    private int currentLOD = 0;

    void Update()
    {
        float distance = Vector3.Distance(transform.position, player.position);

        for (int i = 0; i < distances.Length; i++)
        {
            if (distance <= distances[i])
            {
                SetLOD(i);
                break;
            }
        }
    }

    void SetLOD(int lodLevel)
    {
        if (currentLOD != lodLevel)
        {
            // Deactivate all LOD levels
            foreach (GameObject lod in lodLevels)
            {
                lod.SetActive(false);
            }

            // Activate current LOD level
            if (lodLevel < lodLevels.Length)
            {
                lodLevels[lodLevel].SetActive(true);
            }

            currentLOD = lodLevel;
        }
    }
}

Step 5: Asset Optimization

Texture Optimization

Use appropriate texture formats:

// Set texture import settings
[MenuItem("Assets/Optimize Textures")]
static void OptimizeTextures()
{
    Texture2D[] textures = Selection.GetFiltered<Texture2D>(SelectionMode.Assets);

    foreach (Texture2D texture in textures)
    {
        TextureImporter importer = AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(texture)) as TextureImporter;

        if (importer != null)
        {
            importer.textureCompression = TextureImporterCompression.Compressed;
            importer.maxTextureSize = 1024; // Adjust based on usage
            importer.mipmapEnabled = true;
            importer.wrapMode = TextureWrapMode.Clamp;

            AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(texture));
        }
    }
}

Audio Optimization

Use compressed audio formats:

public class AudioOptimizer : MonoBehaviour
{
    [SerializeField] private AudioClip[] audioClips;

    void Start()
    {
        // Set audio compression settings
        foreach (AudioClip clip in audioClips)
        {
            AudioImporter importer = AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(clip)) as AudioImporter;

            if (importer != null)
            {
                importer.format = AudioImporterFormat.Compressed;
                importer.compressionBitrate = 128; // 128 kbps for music, 64 for SFX
                AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(clip));
            }
        }
    }
}

Step 6: Mobile Optimization

Mobile-Specific Optimizations

Optimize for mobile devices:

public class MobileOptimizer : MonoBehaviour
{
    void Start()
    {
        // Set target frame rate
        Application.targetFrameRate = 60;

        // Optimize for mobile
        if (Application.platform == RuntimePlatform.Android || 
            Application.platform == RuntimePlatform.IPhonePlayer)
        {
            OptimizeForMobile();
        }
    }

    void OptimizeForMobile()
    {
        // Reduce quality settings
        QualitySettings.SetQualityLevel(1); // Medium quality

        // Optimize physics
        Physics.defaultSolverIterations = 4;
        Physics.defaultSolverVelocityIterations = 1;

        // Optimize rendering
        QualitySettings.pixelLightCount = 1;
        QualitySettings.shadowResolution = ShadowResolution.Low;
        QualitySettings.shadowDistance = 20f;
    }
}

Use occlusion culling for large scenes:

public class OcclusionCulling : MonoBehaviour
{
    void Start()
    {
        // Enable occlusion culling
        Camera.main.useOcclusionCulling = true;

        // Set up occlusion culling data
        if (OcclusionCullingSettings.occlusionCullingData == null)
        {
            Debug.LogWarning("Occlusion culling data not found. Please bake occlusion culling data.");
        }
    }
}

Step 7: Performance Monitoring

Runtime Performance Monitoring

Create a performance monitor:

public class PerformanceMonitor : MonoBehaviour
{
    [SerializeField] private Text fpsText;
    [SerializeField] private Text memoryText;

    private float fps;
    private float updateInterval = 0.5f;
    private float lastUpdateTime;

    void Update()
    {
        if (Time.time - lastUpdateTime >= updateInterval)
        {
            UpdatePerformanceStats();
            lastUpdateTime = Time.time;
        }
    }

    void UpdatePerformanceStats()
    {
        // Calculate FPS
        fps = 1.0f / Time.deltaTime;

        // Update UI
        if (fpsText != null)
        {
            fpsText.text = $"FPS: {fps:F1}";
        }

        if (memoryText != null)
        {
            long memory = System.GC.GetTotalMemory(false);
            memoryText.text = $"Memory: {memory / 1024 / 1024} MB";
        }
    }
}

Automated Performance Testing

Create performance tests:

public class PerformanceTest : MonoBehaviour
{
    [SerializeField] private float targetFPS = 60f;
    [SerializeField] private float testDuration = 10f;

    private float startTime;
    private List<float> fpsSamples = new List<float>();

    void Start()
    {
        startTime = Time.time;
    }

    void Update()
    {
        if (Time.time - startTime < testDuration)
        {
            fpsSamples.Add(1.0f / Time.deltaTime);
        }
        else
        {
            AnalyzePerformance();
        }
    }

    void AnalyzePerformance()
    {
        float averageFPS = fpsSamples.Average();
        float minFPS = fpsSamples.Min();

        Debug.Log($"Average FPS: {averageFPS:F1}");
        Debug.Log($"Minimum FPS: {minFPS:F1}");

        if (averageFPS < targetFPS)
        {
            Debug.LogWarning($"Performance below target! Average: {averageFPS:F1}, Target: {targetFPS}");
        }
    }
}

Step 8: Mini Challenge - Optimize Your Game

Your Task: Apply performance optimization techniques to your Unity project with these requirements:

  1. Profile your game using Unity's Profiler
  2. Identify bottlenecks in CPU, GPU, and memory usage
  3. Implement optimizations for scripts, assets, and rendering
  4. Test performance on target devices
  5. Achieve target FPS of 60 FPS on your target platform

Success Criteria:

  • Game runs at 60 FPS on target hardware
  • Memory usage is stable (no memory leaks)
  • Draw calls are optimized (under 1000 for mobile, 2000 for desktop)
  • Loading times are under 5 seconds

Pro Tips:

  • Start with the biggest performance bottlenecks first
  • Use Unity's built-in optimization tools
  • Test on actual target devices, not just the editor
  • Profile both in the editor and in builds

Troubleshooting Common Performance Issues

Issue 1: Low FPS

Problem: Game runs below target frame rate Solution:

  • Check CPU usage in Profiler
  • Optimize scripts and reduce Update() calls
  • Use object pooling for frequently created objects
  • Implement LOD system for distant objects

Issue 2: High Memory Usage

Problem: Game consumes too much RAM Solution:

  • Check for memory leaks in Profiler
  • Use object pooling instead of Instantiate/Destroy
  • Optimize texture sizes and formats
  • Unload unused assets

Issue 3: Long Loading Times

Problem: Game takes too long to load Solution:

  • Use Addressables for asset loading
  • Implement progressive loading
  • Optimize asset sizes
  • Use compressed formats

Issue 4: Stuttering and Frame Drops

Problem: Game stutters or has frame drops Solution:

  • Check for garbage collection spikes
  • Optimize physics calculations
  • Use coroutines instead of Update for timed events
  • Implement frame rate limiting

Pro Tips for Professional Optimization

  1. Profile Early and Often

    • Use the Profiler throughout development
    • Don't wait until the end to optimize
    • Set performance targets from the start
  2. Optimize for Your Target Platform

    • Mobile games need different optimizations than desktop
    • Test on actual hardware, not just the editor
    • Consider different quality settings for different devices
  3. Use Unity's Built-in Tools

    • Frame Debugger for rendering issues
    • Memory Profiler for memory optimization
    • Build Report for asset analysis
  4. Monitor Performance in Production

    • Implement runtime performance monitoring
    • Collect performance data from players
    • Use analytics to identify performance issues

What's Next?

Congratulations! You've learned how to optimize Unity games for peak performance. Your games will now run smoothly and provide an excellent player experience.

In the next chapter, Building and Publishing Your Game, you'll learn how to create builds for different platforms, optimize for distribution, and publish your game to app stores and game platforms.

Key Takeaways

  • Use Unity's Profiler to identify performance bottlenecks
  • Optimize scripts by caching components and avoiding garbage collection
  • Implement object pooling for frequently created/destroyed objects
  • Use LOD systems for distant objects
  • Optimize assets with appropriate formats and compression
  • Test on target hardware for accurate performance measurements

Your Unity games will now run at optimal performance, providing smooth gameplay experiences for players across all platforms!