Easy Learn C#

Unity Optimization

Introduction to Unity Optimization

Optimization is crucial for creating games that run smoothly across various platforms and devices. Unity provides numerous tools and techniques to help improve performance and ensure your game delivers the best possible experience to players.

In this guide, you'll learn:

  • Profiling and measuring performance
  • CPU optimization techniques
  • GPU and rendering optimization
  • Memory management
  • Mobile-specific optimizations
  • Physics optimizations
  • Asset optimization strategies

Using the Unity Profiler

The Unity Profiler is your primary tool for identifying performance bottlenecks. It provides real-time data about CPU usage, memory allocation, rendering costs, and more.

Profiling Basics:

  1. Open the Profiler (Window → Analysis → Profiler)
  2. Click "Record" to start capturing performance data
  3. Play your game to generate profiling information
  4. Analyze the data to identify performance issues

Key Profiler modules:

  • CPU Usage: Shows what scripts and systems are using processing time
  • GPU Usage: Displays rendering costs and bottlenecks
  • Memory: Shows memory allocation and potential leaks
  • Audio: Displays audio processing costs
  • Physics: Shows physics simulation costs
  • UI: Displays UI rendering and layout costs

Profiling Tips

  • Always profile on your target platform (especially for mobile games)
  • Use the Deep Profile option to get detailed script information (but beware of overhead)
  • Utilize the Frame Debugger for rendering optimizations
  • Set realistic performance targets based on target hardware
  • Performance-test regularly throughout development

CPU Optimization Techniques

Most performance problems in Unity games stem from inefficient CPU usage. Here are key strategies to optimize your code:

Optimizing Update Calls:

using UnityEngine;
using System.Collections;

public class OptimizedUpdates : MonoBehaviour
{
    // Cache component references to avoid GetComponent calls every frame
    private Transform myTransform;
    private Rigidbody myRigidbody;
    
    // Cache frequently accessed values
    private Vector3 targetPosition;
    private float moveSpeed = 5f;
    
    // Use for initialization
    private void Awake()
    {
        // Cache component references in Awake - only done once
        myTransform = transform;
        myRigidbody = GetComponent<Rigidbody>();
        
        // Initialize target position
        targetPosition = new Vector3(0, 0, 10);
    }
    
    // Example of a custom timer system for spreading logic across frames
    private float timer = 0f;
    private float checkPathInterval = 0.5f; // Check path every 0.5 seconds instead of every frame
    
    private void Update()
    {
        // Example 1: Optimized movement calculation - cache calculations that don't need to be repeated
        Vector3 direction = (targetPosition - myTransform.position).normalized;
        
        // Example 2: Use Time.deltaTime to ensure frame-rate independent movement
        myTransform.position += direction * moveSpeed * Time.deltaTime;
        
        // Example 3: Throttle expensive operations with a timer
        timer += Time.deltaTime;
        if (timer >= checkPathInterval)
        {
            timer = 0f;
            // Run expensive path calculation or other heavy operations here
            RecalculatePath();
        }
    }
    
    // Expensive operation that doesn't need to run every frame
    private void RecalculatePath()
    {
        // Simulate an expensive operation
        Debug.Log("Recalculating path - this would be an expensive operation");
        
        // Update target based on new path calculation
        // targetPosition = newCalculatedPosition;
    }
    
    // Example 4: Use coroutines for delayed or spread-out operations
    private IEnumerator UpdateAICoroutine()
    {
        while (true)
        {
            // Complex AI logic here
            
            // Wait for specified seconds before updating AI again
            yield return new WaitForSeconds(0.1f);
        }
    }
    
    // Example 5: Object pooling for frequently created/destroyed objects
    private void InitializeObjectPool()
    {
        // Set up object pool instead of using Instantiate/Destroy frequently
        // This avoids garbage collection hitches
    }
    
    // Example 6: Using FixedUpdate correctly
    private void FixedUpdate()
    {
        // Put physics-related code here, not in Update
        if (myRigidbody != null)
        {
            // Physics calculations
        }
    }
    
    // Example 7: Efficient collision detection
    private void OnTriggerEnter(Collider other)
    {
        // Use CompareTag instead of tag == for better performance
        if (other.CompareTag("Enemy"))
        {
            // Handle collision with enemy
        }
    }
    
    // Example 8: Use InvokeRepeating for simple repeating tasks
    private void StartRepeatingTasks()
    {
        // Invoke a method every 2 seconds, starting after 1 second
        InvokeRepeating("RegenerateHealth", 1.0f, 2.0f);
    }
    
    private void RegenerateHealth()
    {
        // Simple repeating logic
        // health += 1;
    }
}

Key CPU optimization principles:

  • Cache references: Avoid GetComponent and Find calls during gameplay
  • Throttle updates: Not every system needs to update every frame
  • Use coroutines: Spread computational costs across frames
  • Object pooling: Reuse objects instead of instantiating/destroying
  • Optimize loops: Minimize work inside loops and avoid nested loops
  • Use appropriate message functions: FixedUpdate for physics, Update for inputs

Object Pooling Implementation:

using System.Collections.Generic;
using UnityEngine;

public class ObjectPool : MonoBehaviour
{
    // Singleton instance
    public static ObjectPool Instance;
    
    // Object prefab to pool
    public GameObject prefab;
    
    // Initial pool size
    public int initialPoolSize = 20;
    
    // Maximum pool size (to prevent unlimited growth)
    public int maxPoolSize = 100;
    
    // List of inactive pooled objects
    private List<GameObject> pooledObjects;
    
    private void Awake()
    {
        // Set up singleton
        if (Instance == null)
        {
            Instance = this;
        }
        else
        {
            Destroy(gameObject);
            return;
        }
        
        // Initialize the pool
        pooledObjects = new List<GameObject>(initialPoolSize);
        
        // Pre-instantiate objects
        for (int i = 0; i < initialPoolSize; i++)
        {
            CreateNewPooledObject();
        }
    }
    
    // Create a new pooled object and add it to the inactive list
    private GameObject CreateNewPooledObject()
    {
        GameObject obj = Instantiate(prefab);
        obj.SetActive(false);
        pooledObjects.Add(obj);
        
        // Parent to this object for hierarchy organization
        obj.transform.SetParent(transform);
        
        return obj;
    }
    
    // Get an object from the pool
    public GameObject GetPooledObject()
    {
        // First, try to find an inactive object in the pool
        for (int i = 0; i < pooledObjects.Count; i++)
        {
            if (!pooledObjects[i].activeInHierarchy)
            {
                return pooledObjects[i];
            }
        }
        
        // If we didn't find an inactive object and haven't reached max size,
        // create a new one
        if (pooledObjects.Count < maxPoolSize)
        {
            return CreateNewPooledObject();
        }
        
        // If we reached max size, either return null or reuse the oldest object
        // Here we'll return null, but you could implement an LRU strategy
        Debug.LogWarning("Object pool reached maximum size. Consider increasing maxPoolSize.");
        return null;
    }
    
    // Example usage method
    public void SpawnPooledObject(Vector3 position, Quaternion rotation)
    {
        // Get an object from the pool
        GameObject obj = GetPooledObject();
        
        if (obj != null)
        {
            // Position and enable the object
            obj.transform.position = position;
            obj.transform.rotation = rotation;
            obj.SetActive(true);
            
            // If the object has a PooledObject component, initialize it
            PooledObject pooledObj = obj.GetComponent<PooledObject>();
            if (pooledObj != null)
            {
                pooledObj.OnObjectSpawn();
            }
        }
    }
}

// Optional component for pooled objects to handle initialization/cleanup
public class PooledObject : MonoBehaviour
{
    // Time after which the object returns to pool automatically
    // (Set to 0 or negative to disable auto-return)
    public float autoReturnTime = 3f;
    
    private void OnEnable()
    {
        // If auto-return is enabled, schedule it
        if (autoReturnTime > 0)
        {
            Invoke("ReturnToPool", autoReturnTime);
        }
    }
    
    // Called when object is taken from the pool
    public virtual void OnObjectSpawn()
    {
        // Reset object state here
    }
    
    // Return this object to the pool
    public void ReturnToPool()
    {
        gameObject.SetActive(false);
        
        // Cancel any pending auto-return
        CancelInvoke("ReturnToPool");
    }
}

Using the object pool:

// Instead of:
// Instantiate(bulletPrefab, firePoint.position, firePoint.rotation);

// Use:
ObjectPool.Instance.SpawnPooledObject(firePoint.position, firePoint.rotation);

// And to return an object to the pool:
GetComponent<PooledObject>().ReturnToPool();

GPU and Rendering Optimization

Rendering is often a major performance bottleneck, especially on mobile devices. Optimizing your graphics pipeline can lead to significant performance improvements.

Key Rendering Optimizations:

  • Draw Call Batching: Combine multiple mesh renderers into single draw calls
  • LOD (Level of Detail): Use simpler meshes at greater distances
  • Occlusion Culling: Don't render objects that aren't visible
  • Texture Atlasing: Combine multiple textures into a single larger texture
  • Shaders: Use optimized shaders for your target platforms
  • Lighting: Choose appropriate lighting techniques for your game

Setting up LOD (Level of Detail):

using UnityEngine;

public class LODExample : MonoBehaviour
{
    // Reference to LOD Group component
    private LODGroup lodGroup;
    
    // Camera reference for distance checks
    private Camera mainCamera;
    
    void Start()
    {
        // Get or add the LOD Group component
        lodGroup = GetComponent<LODGroup>();
        if (lodGroup == null)
        {
            lodGroup = gameObject.AddComponent<LODGroup>();
            SetupLODs();
        }
        
        mainCamera = Camera.main;
    }
    
    // Manual LOD setup example
    void SetupLODs()
    {
        // Create LOD levels
        LOD[] lods = new LOD[3];
        
        // High detail LOD (used when close to camera)
        Renderer[] highLODRenderers = transform.Find("HighDetail").GetComponentsInChildren<Renderer>();
        lods[0] = new LOD(0.6f, highLODRenderers);
        
        // Medium detail LOD (used at medium distance)
        Renderer[] mediumLODRenderers = transform.Find("MediumDetail").GetComponentsInChildren<Renderer>();
        lods[1] = new LOD(0.3f, mediumLODRenderers);
        
        // Low detail LOD (used at far distance)
        Renderer[] lowLODRenderers = transform.Find("LowDetail").GetComponentsInChildren<Renderer>();
        lods[2] = new LOD(0.1f, lowLODRenderers);
        
        // Set LODs on the LODGroup
        lodGroup.SetLODs(lods);
        
        // Set the LOD group's size (adjusts the transition distances)
        lodGroup.RecalculateBounds();
    }
    
    // Custom LOD implementation for objects without meshes
    // or for more control over LOD switching
    void CustomLODCheck()
    {
        if (mainCamera == null) return;
        
        // Calculate distance to camera
        float distanceToCamera = Vector3.Distance(transform.position, mainCamera.transform.position);
        
        // Switch behavior based on distance
        if (distanceToCamera < 10f)
        {
            // Close distance - high detail
            // Enable detailed particle effects, high-frequency updates
            InvokeRepeating("HighDetailUpdate", 0f, 0.02f);
        }
        else if (distanceToCamera < 50f)
        {
            // Medium distance - medium detail
            // Reduce particle count, lower update frequency
            InvokeRepeating("MediumDetailUpdate", 0f, 0.1f);
        }
        else
        {
            // Far distance - low detail
            // Minimal visual effects, very infrequent updates
            InvokeRepeating("LowDetailUpdate", 0f, 0.5f);
        }
    }
    
    // Debugging and visualization
    void OnDrawGizmosSelected()
    {
        if (lodGroup != null)
        {
            LOD[] lods = lodGroup.GetLODs();
            
            // Draw spheres representing LOD transition distances
            for (int i = 0; i < lods.Length; i++)
            {
                // Calculate radius based on LOD percentage
                float radius = lodGroup.size.magnitude * (1 - lods[i].screenRelativeTransitionHeight);
                
                // Draw sphere with different colors for each LOD level
                switch (i)
                {
                    case 0: Gizmos.color = Color.green; break;   // High detail
                    case 1: Gizmos.color = Color.yellow; break;  // Medium detail
                    case 2: Gizmos.color = Color.red; break;     // Low detail
                    default: Gizmos.color = Color.gray; break;
                }
                
                Gizmos.DrawWireSphere(transform.position, radius);
            }
        }
    }
}

Memory Optimization

Efficient memory management is critical, especially for mobile games or games with large open worlds.

Asset Loading and Unloading:

using UnityEngine;
using UnityEngine.SceneManagement;
using System.Collections;

public class AssetManager : MonoBehaviour
{
    // Singleton instance
    public static AssetManager Instance;
    
    // References to common assets to prevent reloading
    private Texture2D[] commonTextures;
    private AudioClip[] commonSounds;
    
    // Track loaded asset bundles
    private Dictionary<string, AssetBundle> loadedBundles = new Dictionary<string, AssetBundle>();
    
    private void Awake()
    {
        // Set up singleton
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
            return;
        }
        
        // Subscribe to scene change events
        SceneManager.sceneLoaded += OnSceneLoaded;
        SceneManager.sceneUnloaded += OnSceneUnloaded;
    }
    
    private void OnDestroy()
    {
        // Unsubscribe from scene events
        SceneManager.sceneLoaded -= OnSceneLoaded;
        SceneManager.sceneUnloaded -= OnSceneUnloaded;
    }
    
    // Load level-specific assets when a new scene is loaded
    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        string sceneName = scene.name;
        
        // Unload unused assets when changing scenes
        if (mode == LoadSceneMode.Single)
        {
            StartCoroutine(UnloadUnusedAssetsDelayed());
        }
        
        // Load scene-specific assets
        StartCoroutine(LoadLevelSpecificAssets(sceneName));
    }
    
    // Clean up when scenes are unloaded
    private void OnSceneUnloaded(Scene scene)
    {
        // Consider unloading specific assets for this scene
        string sceneName = scene.name;
        UnloadLevelSpecificAssets(sceneName);
    }
    
    // Delayed asset unloading to avoid hitches during scene transitions
    private IEnumerator UnloadUnusedAssetsDelayed()
    {
        // Wait for new scene to be fully loaded
        yield return new WaitForSeconds(1.0f);
        
        // Unload unused assets
        AsyncOperation asyncUnload = Resources.UnloadUnusedAssets();
        yield return asyncUnload;
        
        // Force garbage collection after unloading
        System.GC.Collect();
    }
    
    // Load assets specific to a level/scene
    private IEnumerator LoadLevelSpecificAssets(string levelName)
    {
        // Example: Load an asset bundle for the level
        string bundlePath = Application.streamingAssetsPath + "/bundles/" + levelName.ToLower();
        
        if (System.IO.File.Exists(bundlePath))
        {
            AssetBundleCreateRequest bundleRequest = AssetBundle.LoadFromFileAsync(bundlePath);
            yield return bundleRequest;
            
            if (bundleRequest.assetBundle != null)
            {
                // Store the loaded bundle
                loadedBundles[levelName] = bundleRequest.assetBundle;
                
                // Example: Load specific assets from the bundle
                string[] assetNames = bundleRequest.assetBundle.GetAllAssetNames();
                foreach (string assetName in assetNames)
                {
                    // Only load assets we need immediately
                    if (assetName.Contains("required"))
                    {
                        AssetBundleRequest assetRequest = bundleRequest.assetBundle.LoadAssetAsync<GameObject>(assetName);
                        yield return assetRequest;
                        
                        // Use the loaded asset
                        GameObject prefab = assetRequest.asset as GameObject;
                        if (prefab != null)
                        {
                            // Add to a prefab cache or instantiate as needed
                        }
                    }
                }
            }
        }
    }
    
    // Unload assets specific to a level/scene
    private void UnloadLevelSpecificAssets(string levelName)
    {
        // Unload the bundle if it exists
        if (loadedBundles.TryGetValue(levelName, out AssetBundle bundle))
        {
            bundle.Unload(true); // true = unload all loaded objects
            loadedBundles.Remove(levelName);
        }
    }
    
    // Example method to load assets on demand
    public IEnumerator LoadAssetOnDemand<T>(string assetPath, System.Action<T> callback) where T : Object
    {
        // First check if asset is already loaded in a bundle
        foreach (var bundle in loadedBundles.Values)
        {
            string[] assetNames = bundle.GetAllAssetNames();
            foreach (string name in assetNames)
            {
                if (name.EndsWith(assetPath))
                {
                    AssetBundleRequest request = bundle.LoadAssetAsync<T>(name);
                    yield return request;
                    
                    if (request.asset != null)
                    {
                        callback(request.asset as T);
                        yield break;
                    }
                }
            }
        }
        
        // If not in a bundle, try loading from Resources
        ResourceRequest request = Resources.LoadAsync<T>(assetPath);
        yield return request;
        
        if (request.asset != null)
        {
            callback(request.asset as T);
        }
        else
        {
            Debug.LogWarning("Failed to load asset: " + assetPath);
        }
    }
    
    // Example: Resources.Load is simple but doesn't allow for good memory management
    // Avoid using this pattern for large assets or frequent loading/unloading
    public T LoadFromResources<T>(string path) where T : Object
    {
        return Resources.Load<T>(path);
    }
}

Memory optimization strategies:

  • Asset Bundles: Load and unload assets in groups
  • Addressables: Modern Unity system for asset management
  • Streaming: Load assets as needed and unload when not needed
  • Texture Compression: Use appropriate formats for each platform
  • Asset References: Be careful with static references that prevent garbage collection

Mobile Optimization

Mobile platforms require special consideration due to their hardware limitations and battery concerns.

Mobile-Specific Optimizations:

  • Reduce draw calls: Mobile GPUs are particularly sensitive to draw call count
  • Lower poly models: Use significantly simplified geometry
  • Texture sizes: Keep textures small (512x512 or less when possible)
  • Avoid overdraw: Minimize transparent objects that cause pixels to be drawn multiple times
  • Reduce shader complexity: Use mobile-optimized shaders with fewer instructions
  • Disable unnecessary features: Turn off shadows, reflection probes, etc. when not critical
  • Application size: Compress assets to reduce download size
  • Battery usage: Lower frame rate targets, reduce physics updates

Adaptive Quality Settings:

using UnityEngine;

public class AdaptiveQuality : MonoBehaviour
{
    // Target framerate ranges
    public int targetFrameRate = 60;
    public int minimumFrameRate = 30;
    
    // Current quality level
    private int currentQualityLevel;
    
    // Performance tracking
    private float[] frameTimeHistory = new float[30]; // Track last 30 frames
    private int frameTimeIndex = 0;
    private float averageFrameTime = 0.0f;
    
    // Cooldown to avoid rapid quality changes
    private float qualityChangeCooldown = 0f;
    private const float COOLDOWN_DURATION = 5.0f;
    
    void Start()
    {
        // Initialize with the current quality setting
        currentQualityLevel = QualitySettings.GetQualityLevel();
        
        // Set initial target framerate
        Application.targetFrameRate = targetFrameRate;
        
        // Initialize frame time history
        for (int i = 0; i < frameTimeHistory.Length; i++)
        {
            frameTimeHistory[i] = 1.0f / targetFrameRate;
        }
    }
    
    void Update()
    {
        // Update frame time history
        frameTimeHistory[frameTimeIndex] = Time.unscaledDeltaTime;
        frameTimeIndex = (frameTimeIndex + 1) % frameTimeHistory.Length;
        
        // Calculate average frame time
        averageFrameTime = 0;
        foreach (float frameTime in frameTimeHistory)
        {
            averageFrameTime += frameTime;
        }
        averageFrameTime /= frameTimeHistory.Length;
        
        // Reduce cooldown timer
        qualityChangeCooldown -= Time.deltaTime;
        
        // Only adjust quality if cooldown is expired
        if (qualityChangeCooldown <= 0)
        {
            AdjustQuality();
        }
    }
    
    void AdjustQuality()
    {
        // Calculate current FPS
        float currentFPS = 1.0f / averageFrameTime;
        
        // Number of quality levels available
        int qualityLevelCount = QualitySettings.names.Length;
        
        // If FPS is too low, reduce quality
        if (currentFPS < minimumFrameRate && currentQualityLevel > 0)
        {
            currentQualityLevel--;
            QualitySettings.SetQualityLevel(currentQualityLevel, true);
            ApplyQualitySpecificSettings();
            
            // Set cooldown to allow adjustment to take effect
            qualityChangeCooldown = COOLDOWN_DURATION;
            
            Debug.Log("Performance low - reducing quality to level " + 
                      currentQualityLevel + " (" + QualitySettings.names[currentQualityLevel] + ")");
        }
        // If FPS is well above target, we can try increasing quality
        else if (currentFPS > targetFrameRate * 1.2f && currentQualityLevel < qualityLevelCount - 1)
        {
            currentQualityLevel++;
            QualitySettings.SetQualityLevel(currentQualityLevel, true);
            ApplyQualitySpecificSettings();
            
            // Set cooldown to allow adjustment to take effect
            qualityChangeCooldown = COOLDOWN_DURATION;
            
            Debug.Log("Performance good - increasing quality to level " + 
                      currentQualityLevel + " (" + QualitySettings.names[currentQualityLevel] + ")");
        }
    }
    
    // Apply additional settings based on quality level
    void ApplyQualitySpecificSettings()
    {
        switch (currentQualityLevel)
        {
            case 0: // Lowest quality
                // Reduce view distance dramatically
                Camera.main.farClipPlane = 50f;
                
                // Disable post-processing
                MonoBehaviour[] postProcessing = GetComponentsInChildren<MonoBehaviour>();
                foreach (MonoBehaviour pp in postProcessing)
                {
                    if (pp.GetType().Name.Contains("PostProcess"))
                    {
                        pp.enabled = false;
                    }
                }
                
                // Lower resolution on mobile
                if (Application.isMobilePlatform)
                {
                    Screen.SetResolution(
                        Screen.width / 2, 
                        Screen.height / 2, 
                        Screen.fullScreen
                    );
                }
                break;
                
            case 1: // Low quality
                Camera.main.farClipPlane = 100f;
                
                // Maybe enable basic post-processing
                
                // Use native resolution but with reduced render texture sizes
                if (Application.isMobilePlatform)
                {
                    Screen.SetResolution(
                        Screen.width, 
                        Screen.height, 
                        Screen.fullScreen
                    );
                }
                break;
                
            case 2: // Medium quality
                Camera.main.farClipPlane = 300f;
                
                // Enable post-processing
                MonoBehaviour[] mediumPostProcessing = GetComponentsInChildren<MonoBehaviour>();
                foreach (MonoBehaviour pp in mediumPostProcessing)
                {
                    if (pp.GetType().Name.Contains("PostProcess"))
                    {
                        pp.enabled = true;
                    }
                }
                break;
                
            default: // High quality or better
                Camera.main.farClipPlane = 1000f;
                break;
        }
    }
    
    // Draw debug UI to show current performance
    void OnGUI()
    {
        // Only display in development or when debug mode is enabled
        #if UNITY_EDITOR || DEVELOPMENT_BUILD
        GUI.Label(new Rect(10, 10, 200, 20), "FPS: " + (1.0f / averageFrameTime).ToString("F1"));
        GUI.Label(new Rect(10, 30, 200, 20), "Quality: " + QualitySettings.names[currentQualityLevel]);
        
        // Show cooldown timer
        if (qualityChangeCooldown > 0)
        {
            GUI.Label(new Rect(10, 50, 200, 20), 
                     "Quality cooldown: " + qualityChangeCooldown.ToString("F1"));
        }
        #endif
    }
}

Best Practices for Unity Optimization

  1. Profile first, optimize second: Always identify the actual bottleneck before optimizing
  2. Focus on the biggest issues: Tackle the areas that will give the most performance gain
  3. Test on target hardware: Performance characteristics can vary dramatically between devices
  4. Batch optimizations: Group related optimizations together to better measure their impact
  5. Balance quality and performance: Provide graphical options for different hardware capabilities
  6. Build size matters: Remove unused assets and compress files for faster downloads
  7. Use Unity's built-in tools: Profiler, Frame Debugger, Memory Profiler, etc.
  8. Update Unity: Newer versions often include performance improvements
  9. Optimize continuously: Don't wait until the end of development to address performance
  10. Know your platform: Each platform has specific optimization requirements and limitations