Unity Performance: Using Coroutines To Prevent Ui Freezes

Why UI Freezes Happen in Unity

A common frustration when developing Unity games is UI freezes and hitches. This happens because Unity’s game loop is single-threaded – all of the game logic, UI updates, scene rendering, and more happen on one core thread. When this thread is blocked by intensive processing, the entire application freezes until the task finishes. The results are jarring halts that disrupt gameplay and break immersion.

For example, let’s say on player input you trigger a explosion visual effect that has complex particle and physics calculations. These computations could take several seconds, during which the game loop is unable to update the UI or render any new frames. The UI will be completely frozen as far as the player can tell, even though the game logic is still processing in the background.

Explanation of Unity’s Single-Threaded Nature

Unlike a web browser which uses multiple threads and processes, Unity games rely on a single core thread advancing the game state over time. Each frame this thread processes input, runs game logic, renders scenes, calculates physics, updates objects, and handles all other tasks needed to advance the game simulation. If any particular task monopolizes computation time, Unity cannot break out of this loop until it completes.

Ideally every frame should run fast enough to hit the target frame rate for smooth gameplay. If a frame takes too long, animation and control response suffers. Excessive delays also manifest as UI freezes where the application is unresponsive while background work finishes.

Understanding this single-threaded limitation is key to architecting performant Unity games. The goal is to structure game code to avoid blocking expensive operations that tie up the core thread for too long. By carefully scheduling this work, we can keep each frame snappy and prevent UI freezes.

Using Coroutines to Offload Work

Definition and Brief Overview of Coroutines

Coroutines are a Unity feature which provides an elegant solution to perform heavy processing without blocking the main thread. Structurally they work like regular C# functions, but with special yield statements allowing them to pause execution and continue on the next frame.

This means you can break up intensive work over multiple frames. From the perspective of the main thread, the coroutine yields control back each frame to keep the game loop moving. But the coroutine continues where it left off in the next available frame, spreading out the processing over time.

Example Code of a Coroutine to Perform Heavy Work

Here is a simple coroutine example to illustrate the syntax:

IEnumerator HeavyWorkCoroutine() {

    // Do some initial setup
    SetupLongCalculation();

    // Pause this coroutine and let the game loop continue
    yield return null;

    // Continue executing from here in the next frame
    DoMoreLongCalculation();
    
    yield return null;

    FinalizeLongCalculation();
}

To start running this coroutine, instantiate the IEnumerator object and pass it to StartCoroutine().

StartCoroutine(HeavyWorkCoroutine()); 

Explanation of How Coroutines Prevent UI Freeze by Offloading Work

Without coroutines, if DoMoreLongCalculation() took 5 seconds to complete the application would be completely frozen during that work. With coroutines, each yield return null; allows Unity to skip out of the function to refresh and respond normally. From the player’s perspective the game remains smooth rather than seizing up.

By splitting heavy processing using yields, you avoid monopolizing CPU time in a single frame. This prevents the main thread from getting blocked. UI loops, rendering, physics, etc can all interleave frames normally around the incremental work of the coroutine.

Essentially coroutines emulate a form of lightweight multi-threading in Unity. They allow you to implement long asynchronous operations for visual smoothness, while keeping Unity’s single threaded paradigm.

Best Practices for Coroutines

Let’s explore some tips for structuring coroutine logic efficiently…

Tips for Structuring Coroutine Logic

  • Make coroutines self-contained – they should handle setup, yield scheduling, and cleanup
  • Use descriptive names like SetupEnemySpawnsCoroutine() rather than LaunchNewCoroutine()
  • Split coroutines into multiple small routines focused on specific tasks
  • Always yield back to the main thread while waiting, loading, or doing gradual work

Examples of Inefficient Coroutine Usage to Avoid

Some coroutine antipatterns to avoid:

  • Yielding too infrequently while doing complex iterative logic
  • Starting too many concurrent coroutines instead of chaining logically
  • Failing to stop endless coroutines when no longer needed

As a simple improvement, for repetitive work iterate over a large list using yields every N iterations. This ensures small continual frame deadlines rather than processing the whole list in one huge frame stall.

Using WaitForEndOfFrame and WaitForSecondsRealtime

Two useful yields to structure smooth coroutines are WaitForEndOfFrame and WaitForSecondsRealtime.

WaitForEndOfFrame ensures a coroutine finishes the current frame of work before continuing. This stops halfway-processed visual artifacts. WaitForSecondsRealtime explicitly pauses a real-world duration in wall clock time (rather than tying pauses to Time.deltaTime scaling). Use this to implement consistent delays for server requests, timed spawn events, etc.

Coroutine Alternatives

While coroutines are ideally suited for avoiding UI hitches without multi-threading, Unity provides alternatives for more advanced use cases:

Brief Discussion of Other Options Like Threads and Jobs

Threads – For maximal parallelism you can directly leverage threads with the Thread class. However this requires carefully marshaling data back to the main Unity thread and avoiding race conditions from asynchronous work.

Jobs – The Job System uses threads implicitly via its job queue and handles thread safety for you. Jobs work well for advanced high performance use cases like physics, animation, or other highly parallel systems.

Comparison of Pros and Cons vs Coroutines

Coroutines strike a balance between simplicity and flexibility:

  • Pros – Easy to use, lightweight, handle complexity of spreading work over frames
  • Cons – Not implicitly threaded, somewhat slower than raw threads/jobs

Whereas threads and jobs require more programming effort but enable advanced performance gains:

  • Pros – Maximal CPU utilization, very high performance
  • Cons – Complex, error prone, hard to debug, GCs must be carefully managed

For the majority of small to medium sized games, coroutines should cover offloading needs. Pick threads/jobs only if you really need uncompromising CPU speed and have the programming expertise.

Example: Smooth Data Loading with Coroutines

Let’s walk through a practical example of preventing UI hitches…

Walkthrough of Coroutine to Load Data without Hitches

A common case of hitches happens when loading large chunks of data synchronously. For example, when the player enters a new area you might load all related textures, models, audio clips, etc to use in the local zone. Doing one bulk load leads to an unpleasant freeze.

We can use a coroutine to incrementally stream content over several frames instead. Here is concept code showing the approach:

IEnumerator LoadNewAreaDataCoroutine() {

    // Set progress bar to display 0% 
    progressBar.value = 0;   

    // Load header metadata first 
    var headerData = LoadHeaderFromDatabase();

    // Preallocate dicts to later load section data into
    var textureMap = new Dictionary();
    var audioMap = new Dictionary();

    // Load textures in 10 batches 
    int numTextures = headerData.numTextures;
    int batchSize = numTextures / 10;
    
    for(int i = 0; i < numTextures; i += batchSize) {
       
        // Figure out next batch of textures to load
        int batchEnd = i + batchSize; 
        var nextTextures = GetTextureList(i, batchEnd);

        yield return null; 

        // Load next batch of textures asynchronously
        var loadedTextures = Resources.LoadAll(nextTextures);

        // Add successfully loaded textures to dict
        textureMap.AddRange(loadedTextures);
       
        // Update progress bar to next 10% amount 
        progressBar.value += 0.1f;
    }

    // Repeat for audio clips, models, etc

    yield return null;

    // Finalize setup now all data loaded
    FinalizeNewArea(textureMap, audioMap);  

    // Hide progress bar
    progressBar.SetActive(false);
}

The key idea here is to break content loading into small incremental batches. By yielding every batch, we ensure the UI and other systems get frame time to process rather than stalling. The end result is no player-facing freezes during area transitions, despite loading substantial data.

Code Sample to Demonstrate Approach

To leverage this coroutine to handle new area loading without hitches:

void OnPlayerEnterNewArea(AreaData area) {

    StartCoroutine(LoadNewAreaDataCoroutine(area));

}

The coroutine begins executing, loading a bit more content each frame until complete. Meanwhile the game thread remains responsive as content streams in. Structuring heavy workflows as coroutines is crucial for performant real-time games.

Key Takeaways and Next Steps

Let’s recap the core techniques covered to round out our discussion of coroutines…

Summary of Main Points

  • Use coroutines for asynchronous behavior without multithreading pains
  • Yield often while doing gradual/iterative work
  • Break huge tasks into smaller routine chunks
  • Load content incrementally via coroutine batches

Resources for Learning More about Optimization

For more info check out:

  • Unity’s Coroutine documentation – https://docs.unity3d.com/Manual/Coroutines.html
  • Unity Performance Optimization Guide – https://unity3d.com/learn/tutorials/topics/performance-optimization
  • GDC Talk on Optimizing the Unity Engine – https://www.youtube.com/watch?v=j4YAY36xjwE

Hopefully you now understand techniques to structure asynchronous game logic with coroutines. Applying these patterns will keep your projects smooth and responsive!

Leave a Reply

Your email address will not be published. Required fields are marked *