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:
- Open the Profiler (Window → Analysis → Profiler)
- Click "Record" to start capturing performance data
- Play your game to generate profiling information
- 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
- Profile first, optimize second: Always identify the actual bottleneck before optimizing
- Focus on the biggest issues: Tackle the areas that will give the most performance gain
- Test on target hardware: Performance characteristics can vary dramatically between devices
- Batch optimizations: Group related optimizations together to better measure their impact
- Balance quality and performance: Provide graphical options for different hardware capabilities
- Build size matters: Remove unused assets and compress files for faster downloads
- Use Unity's built-in tools: Profiler, Frame Debugger, Memory Profiler, etc.
- Update Unity: Newer versions often include performance improvements
- Optimize continuously: Don't wait until the end of development to address performance
- Know your platform: Each platform has specific optimization requirements and limitations