Engineering

Introduction to asynchronous programming in Unity

| 15 min read

Introduction

Most likely, as the game grows in complexity, we will find that certain operations just take too much time to complete. The effect of this may vary from unpleasant game experience to a completely unplayable and frozen game. In fact, we don’t want to block a game loop and user interactions in any way as the operation is being performed. Such actions may include:

  • Performing network requests
  • Performing operations on files like read and write
  • Patchfinding
  • Artificial intelligence for decision making
  • Loading resources

These are some of the most typical problems, however, we might also have in-game mechanics that depend on something first to be completed, before proceeding to the next step and this is the topic this article will focus on. To move forward, you will have to have some knowledge of C# programming, Unity game engine itself and especially Unity’s package manager.

The problem

Let’s come up with a simple scenario of an interactive procedural animation that will be performed on the cube. So the scenario goes as follows:

  • Wait for the player to press a spacebar.
  • Gradually move the cube left by 3 units.
  • Wait for 1 second.
  • Gradually move the cube up by 2 units.
  • Wait for the player to press a spacebar.
  • Gradually rotate the cube by 90 degrees in 3 seconds.
  • Wait for the player to press enter.
  • Gradually resize the cube to double its size and rotate for another 90 degrees simultaneously (both in 2 seconds).
  • Wait for the player to press a spacebar.
  • Destroy the cube in 1 second of time.

Then, we will solve this problem in Unity game engine by applying a few different approaches revealing how we can benefit from asynchronous programming:

  • Solution 1: Update method - no asynchronous features.
  • Solution 2: Coroutine usage - Feature built-in Unity that allows us to split execution of the piece of code over several Update calls.
  • Solution 3: async/await using UniTask - C# feature for writing asynchronous code.

Solution 1: Update method

The first solution is probably the way we would go along if we knew nothing about asynchronous programming and we are starting our journey with Unity game engine. We may initially think that this approach is the easiest one because it does not require understanding of more complex concepts. However, the fact is that as you will soon see yourself, it is actually the most complex solution of all three of them. It requires the most code to be written and some creativity to tie everything together.

Let’s start by defining an enum that helps us track the current stage of the animation.

private enum Step
{
     WAIT_SPACEBAR_1,
     MOVE_LEFT,
     WAIT_1,
     MOVE_UP,
     WAIT_SPACEBAR_2,
     ROTATE,
     WAIT_ENTER,
     RESIZE_AND_ROTATE,
     WAIT_SPACEBAR_3,
     DESTROY,
     AWAITING_DESTROY
}

Since the assumption is that we only use Update loop, we know that we are not allowed to block it by performing the whole animation in a single update. We have to split the execution over several updates ourselves so we will need some fields to keep track of the progress of each step. The interesting fact is that there are two stages related to destroying an object. The reason for that is that we don’t want to initiate destruction more than once if the Update method is called subsequently by the engine, so we have to split this into two steps to ensure it is called only once.

private Step currentStep = Step.WAIT_SPACEBAR_1;
private float move1UnitsLeft = 3f;
private float waitLeft = 1f;
private float move2UnitsLeft = 2f;
private float rotate1DegreesLeft = 90f;
private float rotate1Speed = 90f/3f;
private float resizeLeft = 1f;
private float resizeSpeed = 1f/2f;
private float rotate2DegreesLeft = 90f;
private float rotate2Speed = 90f/2f;
private float epsilon = 0.001f;

Having prepared everything, we can proceed to our Update method itself. Here, we can check the current step of an animation and act accordingly. Every step is then extracted to a separate method so that the main one contains only the general skeleton of execution.

void Update()
{
     switch(currentStep)
     {
         case Step.WAIT_SPACEBAR_1:
             proceedToIfKeyPressed(Step.MOVE_LEFT, KeyCode.Space);
             break;
         case Step.MOVE_LEFT:
             moveLeftAndProceedIfNeeded();
             break;
         case Step.WAIT_1:
             waitAndProceedTo(Step.MOVE_UP);
             break;
         case Step.MOVE_UP:
             moveUpAndProceedIfNeeded();
             break;
         case Step.WAIT_SPACEBAR_2:
             proceedToIfKeyPressed(Step.ROTATE, KeyCode.Space);
             break;
         case Step.ROTATE:
             rotateAndProceedIfNeeded();
             break;
         case Step.WAIT_ENTER:
             proceedToIfKeyPressed(Step.RESIZE_AND_ROTATE, KeyCode.Return);
             break;
         case Step.RESIZE_AND_ROTATE:
             resizeRotateAndProceedIfNeeded();
             break;
         case Step.WAIT_SPACEBAR_3:
             proceedToIfKeyPressed(Step.DESTROY, KeyCode.Space);
             break;
         case Step.DESTROY:
             Destroy(gameObject, 1f);
             proceedTo(Step.AWAITING_DESTROY);
             break;
         case Step.AWAITING_DESTROY: break;
         default: break;
     }
}

As you can see, the code has grown considerably and this is just the state management. The actual operations performed on the cube are yet to be introduced. On top of that, by looking at this piece of code, we don’t really know in what order the things actually happen. We of course arranged it in a way that suggests it but you can imagine how easily this could be broken and how many changes would be required to add another step somewhere in-between. We cannot be sure of the execution order without really digging into it and step-by-step analysis.

Then, we can implement the actual animation and interaction steps. Some of these, like key press checks, can be actually easily reused so we can save some lines of code. In general, however, because the approach is stateful in nature and we have to modify some fields, the major parts of the code end up being non-reusable.

The following method will be used to check for both space and enter key presses and proceeds to a given step if the specific key is being pressed during update call.

private void proceedToIfKeyPressed(Step step, KeyCode key)
{
     if(Input.GetKeyDown(key)) proceedTo(step);
}

Here is a first step of the animation that allows us to move a cube to the left. Since we are splitting the execution over several updates, we are doing some math to make sure that the cube is moved proportionally to the time that has passed since the last update and that we don’t move the cube more than necessary. We also have to update how much of the translation is yet to be done and then, finally, when we move close enough to the destination, we can proceed to the next step.

private void moveLeftAndProceedIfNeeded()
{
     var diff = Mathf.Min(Time.deltaTime, move1UnitsLeft);
     move1UnitsLeft -= diff;
     transform.position = transform.position + Vector3.left * diff;
     if (diff <= epsilon) proceedTo(Step.WAIT_1);
}

As you can see by looking at the next method, there is some code that is very similar but acting on different variables. The following one just waits for some time before proceeding to the next step.

private void waitAndProceedTo(Step step)
{
     var diff = Mathf.Min(Time.deltaTime, waitLeft);
     waitLeft -= diff;
     if (diff <= epsilon) proceedTo(step);
}

Movement up is basically the same as movement to the left, but acting on different variables and values which prevents us from making it more generalized.

private void moveUpAndProceedIfNeeded()
{
     var diff = Mathf.Min(Time.deltaTime, move2UnitsLeft);
     move2UnitsLeft -= diff;
     transform.position = transform.position + Vector3.up * diff;
     if (diff <= epsilon) proceedTo(Step.WAIT_SPACEBAR_2);
}

The rotation is a little bit different than movement, because it includes the influence of rotation speed.

private void rotateAndProceedIfNeeded()
{
     var diff = Mathf.Min(Time.deltaTime * rotate1Speed, rotate1DegreesLeft);
     rotate1DegreesLeft -= diff;
     transform.Rotate(0f, diff, 0f, Space.Self);
     if (diff <= epsilon) proceedTo(Step.WAIT_ENTER);
}

This is by far the most complex and interesting step of the animation. We have to rotate and resize the cube simultaneously and then proceed to the next step, when both animations are finished. You can clearly see that there are a lot of calculations going on and we are forced to redefine rotation logic because of other variables being used.

private void resizeRotateAndProceedIfNeeded()
{
     var resizeDiff = Mathf.Min(Time.deltaTime * resizeSpeed, resizeLeft);
     var rotateDiff = Mathf.Min(Time.deltaTime * rotate2Speed, rotate2DegreesLeft);
     resizeLeft -= resizeDiff;
     rotate2DegreesLeft -= rotateDiff;
     transform.localScale = transform.localScale + new Vector3(resizeDiff, resizeDiff, resizeDiff);
     transform.Rotate(0f, rotateDiff, 0f, Space.Self);
     if (resizeDiff <= epsilon && rotateDiff <= epsilon)
     {
         proceedTo(Step.WAIT_SPACEBAR_3);
     }
}

The following is just a simple method for setting the current step to the given one so that we don’t modify the variable directly from multiple places of the code keeping it more self-describing.

private void proceedTo(Step step)
{
     currentStep = step;
}

That was quite a huge piece of code. We could split it into several MonoBehaviours, but in the end, we would lose context of execution even more, unless we create another one that manages the MonoBehaviours that are attached to the cube. The question that arises is as follows - can it be done better?

Solution 2: Coroutines

Welcome Coroutines. Coroutines are giving us the possibility of asynchronous programming and are build-in Unity game engine so that you can use them out of the box. These are used to split execution of an operation over multiple update calls. On every game tick, the coroutines that are being executed gain control over the code flow, so you are able to perform some operations and give up control for the next coroutine, when you decide that you’re ready to do so. The important thing to note is that they are still executed in the main game loop so it is your call to ensure that the performed operations are done in small chunks so that you won’t block the execution of a whole game. Nevertheless, these are a great tool to split up code execution so that you will end up with more readable code. The main problem which arises is that the step 8th of our scenario requires performing two animations simultaneously. This could be done the same way as in the solution 1, but coroutines opens up a new possibility, which will allow us to reuse some of the code. The downside is that we will remain stateful on the object level with our solution. But what we skip right now, are the states of each step as these can be held in the scopes of the corresponding methods. We will not need them as we will be able to execute the whole loops in the coroutine.

private bool finishedResizing = false;
private bool finishedRotating = false;
private bool finishedLastStep => finishedResizing & finishedRotating;
private float epsilon = 0.001f;

In this approach, we can entirely drop the Update method. Instead, we will use the Start method to spawn our coroutine. The coroutine can be run by calling the StartCoroutine method.

void Start()
{
     StartCoroutine(runScenario());
}

The runScenario method will perform every step of our animation. You can clearly see that it’s more readable and reduces the risk of an error by also giving an immediate context of the order in which the steps are executed. You can observe that the method used as coroutine has to have IEnumerator return type. What’s also typical of the coroutines is yield return statements. They are used to give up control over code-flow to the main loop. The next execution of the coroutine method will happen in the following frame, starting at the last yield instruction it stopped previously.

private IEnumerator runScenario()
{
     yield return checkKeyDown(KeyCode.Space);
     yield return move(Vector3.left, 3f);
     yield return new WaitForSeconds(1.0f);
     yield return move(Vector3.up, 2f);
     yield return checkKeyDown(KeyCode.Space);
     yield return rotate(3f);
     yield return checkKeyDown(KeyCode.Return);
     yield return resizeAndRotate();
     yield return checkKeyDown(KeyCode.Space);
     Destroy(gameObject, 1f);
}

The most common step in the given scenario and basically the first one would be checking if the key is being pressed. Take a look at this as it might seem odd at first glance. We are performing a check in the while loop. The trick here is that if the key is not being pressed, the coroutine will give up control over code execution and will continue with another check in the next frame by using yield instruction. When the key is pressed, however, it will simply get out of the method without giving up the control, so it will really stop on another yield and the loop itself will be broken.

private IEnumerator checkKeyDown(KeyCode key)
{
     while(!Input.GetKeyDown(key)) yield return null;
}

Since the animations are not modifying anything in the MonoBehaviour that contains the whole script, we can abstract out the direction and distance in the movement method so that we will be actually able to use the same code for moving the cube both left and up. You can see that the state is still here with the existence of distanceLeft variable but it’s local to the method execution.

private IEnumerator move(Vector3 direction, float distance)
{
     var distanceLeft = distance;
     while(distanceLeft > epsilon)
     {
         var diff = Mathf.Min(Time.deltaTime, distanceLeft);
         distanceLeft -= diff;
         transform.position = transform.position + direction * diff;
         yield return null;
     }
}

The rotation is very similar to the movement but it’s performing a different operation on the transform. What might interest you is the second parameter of the rotate method. It will be explained why it’s there in a while, but for now, just keep it in mind that it’s there to allow us to use the rotate in different contexts.

private IEnumerator rotate(float duration, Action callback = null)
{
     var rotateDegreesLeft = 90f;
     var rotationSpeed = rotateDegreesLeft/duration;
     while(rotateDegreesLeft > epsilon)
     {
         var diff = Mathf.Min(Time.deltaTime * rotationSpeed, rotateDegreesLeft);
         rotateDegreesLeft -= diff;
         transform.Rotate(0f, diff, 0f, Space.Self);
         yield return null;
     }
     if(callback != null) callback();
}

Resize is very similar to the rotate method. You can see the callback parameter here as well.

private IEnumerator resize(float duration, Action callback = null)
{
     var resizeLeft = 1f;
     var resizeSpeed = resizeLeft/duration;
     while(resizeLeft > epsilon)
     {
         var diff = Mathf.Min(Time.deltaTime * resizeSpeed, resizeLeft);
         resizeLeft -= diff;
         transform.localScale = transform.localScale + new Vector3(diff, diff, diff);
         yield return null;
     }
     if(callback != null) callback();
}

So what is this callback parameter for? Since in the 8th step we have to perform two operations simultaneously and we want to reuse rotation & resize code, we have to run them as new coroutines. But we somehow want to know when they are really finished so we can proceed to the next step. That’s why we are providing a callback in a form of lambda expression to be executed after the transformation is performed. In these callbacks, we’re modifying the MonoBehaviour state to indicate that each of the animations are done. Using callbacks is the way to get the outcome out of the coroutine. So at this point, we are having 3 coroutines running. The current one which supervises the overall scenario and the two we are just spawning that perform smaller operations. Then, we perform a while loop in the main coroutine that checks if both small animation steps are finished and, if so, we proceed to the next step or give up control otherwise.

private IEnumerator resizeAndRotate()
{
     StartCoroutine(rotate(2f, () => finishedRotating = true));
     StartCoroutine(resize(2f, () => finishedResizing = true));
     while(!finishedLastStep) yield return null;
}

As you can see, it’s much clearer to what’s actually happening in the whole animation step by step. But it still has some drawbacks such as using callbacks to get values out of the coroutines.

Solution 3: async/await and UniTask

Async/await is a feature that you can find in many programming languages that allows for the use of human friendly syntax that helps us write asynchronous code. The async keyword is applied to the function, method or procedure and indicates that it can be called asynchronously so that it can perform await on other asynchronous operations in its body. The await keyword is used on the call and allows us to wait on the result of the asynchronous operation without blocking the execution of the rest of the code. The async & await keywords are available in C# as well so we can benefit from that feature. The great advantage of this approach is that we can actually return values from asynchronous operations without using any callbacks. They are, however, not very well matched for Unity game engine by itself, but fortunately, we can include a library called UniTask that allows for asynchronous programming with the usage of async/await. In order to add it to your project, please follow the instructions on the git repository.

Let's move on to the solution. Similarly to coroutines, we will not use the Update method for async/await approach as well. Instead, we will set up everything in the Start method. You can see that both async and await keywords are being used in their contexts. The key difference in the method definition is that we are returning UniTaskVoid instead of void from the Start method. It is required so that the Start method can be executed asynchronously.

async UniTaskVoid Start()
{
     await runScenario();
}

You can see that the runScenario method is really very similar to the coroutines approach. It also allows us to gather context of what’s really happening step by step. What’s interesting here is the 8th step of the scenario. In the coroutine approach, we’ve had to run another two coroutines and pass them a lambda as an argument to keep track of the result. Here, we can simply await on two separate tasks simultaneously which simplifies the code readability even further. We can also see the use of some methods built in UniTask which allows us to wait until a certain condition is met, which can be easily used with lambdas.

private async UniTask runScenario()
{
     await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.Space));
     await move(Vector3.left, 3f);
     await UniTask.Delay(TimeSpan.FromSeconds(1));
     await move(Vector3.up, 1f);
     await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.Space));
     await rotate(3f);
     await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.Return));
     await (resize(2f), rotate(2f));
     await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.Space));
     Destroy(gameObject, 1f);
}

Much like with coroutines the movement rotation and resize methods really does some math in a loop which can’t be avoided and it awaits the next frame when the single operation is performed. What is important, though, is that we no longer need any second parameters to be defined for rotate and resize methods, so we’ve improved the code a little.

private async UniTask move(Vector3 direction, float distance)
{
     var distanceLeft = distance;
     while(distanceLeft > epsilon)
     {
         var diff = Mathf.Min(Time.deltaTime, distanceLeft);
         distanceLeft -= diff;
         transform.position = transform.position + direction * diff;
         await UniTask.NextFrame();
     }
}

private async UniTask rotate(float duration)
{
     var rotateDegreesLeft = 90f;
     var rotationSpeed = rotateDegreesLeft/duration;
     while(rotateDegreesLeft > epsilon)
     {
         var diff = Mathf.Min(Time.deltaTime * rotationSpeed, rotateDegreesLeft);
         rotateDegreesLeft -= diff;
         transform.Rotate(0f, diff, 0f, Space.Self);
         await UniTask.NextFrame();
     }
}

private async UniTask resize(float duration)
{
     var resizeLeft = 1f;
     var resizeSpeed = resizeLeft/duration;
     while(resizeLeft > epsilon)
     {
         var diff = Mathf.Min(Time.deltaTime * resizeSpeed, resizeLeft);
         resizeLeft -= diff;
         transform.localScale = transform.localScale + new Vector3(diff, diff, diff);
         await UniTask.NextFrame();
     }
}

Another benefit of the async/await approach that makes it different from the other two solutions is that we can actually delegate the work to another thread easily with UniTask.SwitchToThreadPool and back to the main thread with UniTask.SwitchToMainThread instructions. This might get in handy when performing time-consuming operations when we don’t want to affect Unity’s only thread.

Conclusions

If it was a new thing to us, we might be a little scared of asynchronous programming in Unity game engine in the beginning, but now we are aware of some benefits of taking this kind of approach. We can for example delegate heavy computational tasks, or the tasks that we are not sure how long it will take to complete them. It also clearly improves readability and gives us more context of what’s logically happening in our code by providing us a tool to write it in a more linear fashion while keeping code much more condensed.