Maintaining head tracking in Unity

Ensuring immersive user experience quality

This guide shows you how to ensure that your Unity apps meet the following quality guidelines:

Background

In Unity, app code runs on the main thread. When your app performs a CPU intensive task, such as loading a complicated scene or initializing app state for a large number of game objects, the main thread can become blocked. This can result in full or partial loss of head tracking.

Users experience this as the world appearing to be locked to head movement or as black borders intruding from the edge of the display. These black borders are caused by the asynchronous reprojection feature having to apply a significant translation to a very old image frame generated when the user was previously looking in a different direction.

Unblocking the main thread

There are a number of techniques you can use to help avoid blocking the main thread for a long time and help ensure that your app maintains head tracking.

Identify specific performance issues

Use the Unity CPU Profiler to identify performance issues in your code.

Once you've identified a problematic section in your code, use the techniques below to avoid blocking the main thread.

Defer initialization work

Identify work that it done in Awake() or OnEnable() that can be deferred to a later time, using a Coroutine, or a refactored Start() method.

Refactor expensive Start() methods

Replace an expensive void Start() { … } script block with one that makes use of the IEnumerator pattern in order to defer some or all of the work to subsequent frames. Here is an example:

IEnumerator Start() {
  // Lets say we need to create 1,000 game objects, but we've determined that
  // we can only create approximately 100 at a time while maintaining framerate.
  for (int i=0; i < 10; i++) {
    // Wait until the next frame before continuing the loop. By starting
    // with a yield statement, we don't start any work until the next frame.
    yield return null;

    // Set up 100 game objects (1/10th of the total work).
    SetupOneHundredGameObjects();
  }
}

Refactor expensive game logic

Similarly, you might be able to replace an expensive section in your script with a Coroutine. This lets you perform a small amount of work in each frame before yielding control back to the game engine.

Use a loading scene

If your initial scene takes too long to load, consider adding a loading scene that provides feedback to the user. You can display a VR splash screen and/or provide audio feedback while your main scene loads.

Use asynchronous scene loading

When loading a new scene, start by using LoadSceneAsync and experiment with different values for Application.backgroundLoadingPriority. You can use the returned AsyncOperation to monitor or control the loading of the scene.

If your app still freezes or loses head tracking, use the Unity CPU Profiler to find expensive script operations, including any Awake() or Start() functions in the new scene and OnDestroy() methods in the old scene. There are a few more tips in this Unity post.

Use a background thread

Move an expensive task off the main thread onto a background thread. However, note that Unity APIs may only be called from the main thread.

Hiding effects of a blocked main thread

Even with other optimizations, you might still find it difficult to maintain head tracking and frame rate at certain points in your app.

You can work around these issues by hiding the effects of a blocked main thread. For example, when transitioning between two levels in a game, you can fade the camera to black, spend a few hundred milliseconds on expensive computational work, and then fade back into the new scene.

Fade to black

To create a fade in and out effect, attach the ScreenFade script (and the material and shader by the same name) in daydream-elements to the main camera.

See the LevelSelectButton script for an example of how to use screen fade.

  • If you fade to black while loading a scene, try using the synchronous LoadScene() method, as this allows the main thread to load the new scene slightly faster.

  • Alternatively, you may find that you can start loading the next scene asynchronously using LoadSceneAsync() before fading to black and then delay the fade slightly using a Coroutine.

    This lets your app:

    • Provide feedback to the user for a longer time, meeting performance requirement PS-P1.

    • Maintain head tracking, meeting design requirement UX-D2.

Provide audio feedback

Consider providing audio feedback (music or other sound) while the screen is faded to black to let users know that the app is still running. This is especially important if screen remains black for a long time, for example while loading and initializing a level.