As the game becomes more complex, it is highly likely that certain tasks will become time-consuming and impact the overall gameplay experience. These tasks can range from causing slight discomfort to rendering the game completely unplayable and frozen. Our objective is to ensure that these operations do not hinder the game loop or user interactions in any way while they are being executed. Some examples of such actions include:

  • Making network requests
  • Performing file operations like reading and writing
  • Implementing patchfinding algorithms
  • Developing artificial intelligence for decision-making
  • Loading game resources

While the issues mentioned earlier are common in game development, another aspect that can arise is the presence of in-game mechanics that require the completion of specific tasks before proceeding to the next step. This article will primarily focus on addressing this aspect. To effectively understand and implement the solutions discussed here, it is essential to have knowledge of C# programming, the Unity game engine, and specifically Unity’s package manager.

When designing games, there are often scenarios where certain actions or conditions must be met before progressing further. For example, a puzzle game may require the player to solve a series of puzzles in order to unlock a new level. In these cases, it is crucial to handle these dependencies efficiently and ensure smooth gameplay transitions.

The Challenges

Introducing a scenario that illustrates an interactive procedural animation performed on a cube, we encounter the following steps:

  • Await the player’s spacebar press.
  • Gradually move the cube left by 3 units.
  • Await for 1 second.
  • Gradually move the cube up by 2 units.
  • Await the player’s spacebar press.
  • Gradually rotate the cube by 90 degrees over 3 seconds.
  • Await the player’s enter key press.
  • Simultaneously resize the cube to double its size and rotate it by another 90 degrees in 2 seconds.
  • Await the player’s spacebar press.
  • Destroy the cube within 1 second.

To address this problem in the Unity game engine, we will explore various approaches that leverage asynchronous programming:

Solution Description
Solution 1Utilizing the Update method without asynchronous features. This approach involves writing code within the Update method, where actions are executed synchronously.
Solution 2Leveraging coroutines, a built-in Unity feature that allows code execution to span multiple Update calls. Coroutines provide a way to write asynchronous code in Unity.
Solution 3Employing async/await with UniTask, a C# feature designed for writing asynchronous code. This solution combines the power of C#’s async/await syntax with UniTask library.

Solution 1: Leveraging the Update Method

The initial solution we will explore is the Update method. This approach is commonly adopted when starting the journey with the Unity game engine and lacking knowledge of asynchronous programming. Initially, it may seem like the easiest option since it does not involve grasping more intricate concepts. However, as we delve deeper, we will discover that it is, in fact, the most intricate solution among the three. It demands writing a considerable amount of code and necessitates creative thinking to establish smooth connections.

When using the Update method for procedural animation, we leverage Unity’s built-in Update loop. This loop runs continuously throughout the game and allows us to update the cube’s position, rotation, or other properties over time.

To commence, we will begin by defining an enum that aids in monitoring the animation’s current stage.

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

}

Considering our reliance on the ‘Update’ loop, we understand that it must not be blocked by executing the entire animation within a single update. Therefore, we must distribute the execution across multiple updates. To facilitate this, we require fields that monitor the progress of each step. Notably, the destruction of an object presents an intriguing circumstance. To prevent initiating the destruction multiple times if the ‘Update’ method is consecutively called by the engine, we divide it into two stages. This ensures the destruction is invoked 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;

With all the necessary preparations complete, we can now delve into the implementation of our ‘Update’ method. Within this method, we can examine the current step of the animation and respond accordingly. To maintain a clear and organized structure, each step is extracted into a separate method. This approach ensures that the main Update method retains a concise and 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 evident, the code has significantly expanded, primarily due to state management. However, the actual operations performed on the cube are yet to be incorporated. Additionally, the current code arrangement may imply a particular execution order, but it’s susceptible to breaking easily. Introducing a new step in-between would necessitate numerous changes. Without in-depth analysis and a step-by-step examination, the execution order remains uncertain.

Moving forward, we can now implement the animation and interaction steps. Some steps, such as key press checks, can be easily reused, resulting in code savings. However, given the stateful nature of the approach and the need to modify certain fields, the majority of the code becomes non-reusable.

The following method serves to check for space and enter key presses, proceeding to the specified step if the corresponding key is detected during an update call.

private void proceedToIfKeyPressed(Step step, KeyCode key)

{

     if(Input.GetKeyDown(key)) proceedTo(step);

}

Let’s begin with the first step of the animation, which involves moving the cube to the left. As we are distributing the execution across multiple updates, we need to perform some calculations to ensure that the cube moves proportionally to the time elapsed since the last update. This prevents overextending the cube’s movement beyond what is necessary. Additionally, we must update the remaining translation distance to be covered. Finally, once the cube approaches the destination closely enough, we can progress to the next step of the animation.

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);

}

Observing the subsequent method, it becomes apparent that there is similar code present, albeit operating on different variables. In this particular case, the method simply pauses for a specific duration before advancing to the next step.

private void waitAndProceedTo(Step step)

{

     var diff = Mathf.Min(Time.deltaTime, waitLeft);

     waitLeft -= diff;

     if (diff <= epsilon) proceedTo(step);

}

The movement upwards is essentially identical to the movement to the left, except for the variables and values involved. Unfortunately, this variance prevents us from creating a more generalized approach for both cases.

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 process differs slightly from movement, as it incorporates 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 step of the animation is undoubtedly the most intricate and captivating. We need to simultaneously rotate and resize the cube, ensuring both animations conclude before progressing to the next step. The code reveals a multitude of calculations taking place, and we are compelled to redefine the rotation logic due to the utilization of other variables.

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 subsequent method serves the purpose of setting the current step to the specified value. This approach prevents direct modification of the variable from multiple locations within the code, thereby enhancing its self-descriptive nature.

private void proceedTo(Step step)

{

     currentStep = step;

}

Indeed, the code provided was extensive. While we could divide it into multiple MonoBehaviours, doing so may further diminish the execution context unless we introduce another MonoBehaviour responsible for managing the attached MonoBehaviours on the cube. This raises the question: Can we improve the current approach?

programming code and keyboard

 

Solution 2: Coroutines

Introducing coroutines, a powerful feature built into the Unity game engine, that provides us with the ability to perform asynchronous programming. Coroutines enable the splitting of operations across multiple update calls, allowing for more flexible code execution. During each game tick, executing coroutines gain control over the code flow, enabling the performance of operations while relinquishing control to the next coroutine when ready. It’s important to note that coroutines still operate within the main game loop, so it’s essential to ensure that operations are performed in small chunks to avoid blocking the execution of the entire game. Nevertheless, coroutines provide a valuable tool for improving code readability by facilitating code separation.

However, a challenge arises with Step 8 of our scenario, which requires the simultaneous execution of two animations. While this could be achieved similarly to Solution 1, coroutines present an alternative approach that allows for code reuse. The trade-off is that we will maintain object-level statefulness with this solution. Notably, we can omit the explicit state variables for each step, as they can be contained within the scopes of the corresponding coroutine methods. By utilizing coroutines, we can execute entire loops within the coroutine, further enhancing our implementation.

private bool finishedResizing = false;

private bool finishedRotating = false;

private bool finishedLastStep => finishedResizing & finishedRotating;

private float epsilon = 0.001f;

In this approach, we can eliminate the use of the ‘Update’ method entirely. Instead, we will utilize the ‘Start’ method to initiate our coroutine. By invoking the ‘StartCoroutine’ method, we can effectively run the coroutine and commence its execution.

void Start()

{

     StartCoroutine(runScenario());

}

The ‘runScenario’ method will execute each step of our animation. Notably, it enhances readability and mitigates the risk of errors by providing immediate context regarding the order of step execution. It’s important to note that the coroutine method must have a return type of ‘IEnumerator’. Additionally, coroutines commonly incorporate yield return statements. These statements are utilized to relinquish control over the code flow to the main loop. Subsequent executions of the coroutine method will resume from the last yield instruction, commencing in the subsequent frame.

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);

}

One of the most common steps in the provided scenario, and typically the first one, involves checking if a key is being pressed. At first glance, the implementation may appear peculiar. We perform the check within a while loop. The clever aspect here is that if the key is not being pressed, the coroutine will relinquish control over code execution and resume with another check in the next frame, utilizing the yield instruction. However, when the key is pressed, the coroutine will exit the method without yielding control, allowing it to progress beyond the subsequent yield statement and break out of the loop altogether.

private IEnumerator checkKeyDown(KeyCode key)

{

     while(!Input.GetKeyDown(key)) yield return null;

}

Given that the animations do not modify any variables within the ‘MonoBehaviour’ that encompasses the entire script, we can extract the direction and distance parameters in the movement method. Consequently, we can utilize the same code for moving the cube both left and up. While the state remains present through the ‘distanceLeft’ variable, it is confined to the local execution of the method.

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 process bears similarities to the movement method, but it applies a distinct operation to the transform. Notably, the second parameter of the rotate method warrants attention. Its purpose will be explained shortly, but for now, it’s important to remember that it enables us to utilize the rotate method in various 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();

}

The resize method shares similarities with the rotate method. Notice the presence of the callback parameter in this case 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 the purpose of this callback parameter? In the 8th step, where we need to execute two operations simultaneously and leverage the reusable code for rotation and resizing, we employ new coroutines to accomplish this. However, we also want to determine when these coroutines have completed their respective transformations, allowing us to progress to the next step. To achieve this, we provide a callback in the form of a lambda expression, which executes after the transformation is performed. Within these callbacks, we modify the state of the MonoBehaviour to indicate the completion of each animation. Utilizing callbacks enables us to obtain the desired outcome from the coroutine.

At this stage, we have three coroutines in progress: the main coroutine overseeing the overall scenario, and the two coroutines we’ve spawned to execute smaller operations. Subsequently, we employ a while loop within the main coroutine to check if both small animation steps have concluded. If they have, we proceed to the next step. Otherwise, we relinquish control until the conditions are met.

private IEnumerator resizeAndRotate()

{

     StartCoroutine(rotate(2f, () => finishedRotating = true));

     StartCoroutine(resize(2f, () => finishedResizing = true));

     while(!finishedLastStep) yield return null;

}

As you can observe, the revised implementation provides a clearer understanding of the animation’s progression step by step. However, it still exhibits certain drawbacks, particularly the reliance on callbacks to retrieve values from the coroutines.

Solution 3: async/await and UniTask

Async/await is an incredibly powerful and widely adopted feature that exists in numerous programming languages. It serves as a valuable tool for writing asynchronous code using a syntax that is more intuitive and human-friendly. By simply applying the ‘async’ keyword to a function, method, or procedure, we indicate that it can be invoked asynchronously, thus enabling the use of ‘await’ to pause execution and wait for the completion of other asynchronous operations within its body. The ‘await’ keyword essentially halts the execution of the code, allowing it to patiently wait for the result of an asynchronous operation without impeding the progress of the remaining code. This functionality is not limited to just one language but is also available in C#, thereby granting developers the opportunity to harness its numerous advantages and streamline their programming processes. With async/await, handling asynchronous tasks becomes significantly more manageable and coherent, empowering developers to create efficient and responsive applications.

While async/await is not inherently well-suited for the Unity game engine, we can take advantage of a library called UniTask, which seamlessly integrates async/await into Unity. To incorporate UniTask into your project, please refer to the instructions provided on its GitHub repository.

Now, let’s delve into the solution. Similar to the coroutine approach, we won’t utilize the ‘Update’ method with async/await. Instead, we’ll set up everything within the ‘Start’ method. You’ll notice the usage of both the async and await keywords in their respective contexts. The main distinction in the method definition is that we’re returning UniTaskVoid instead of void from the ‘Start’ method. This is necessary to allow the ‘Start’ method to execute asynchronously.

async UniTaskVoid Start()

{

     await runScenario();

}

Upon examining the ‘runScenario’ method, one can easily notice the striking resemblance it bears to the coroutine approach. This similarity is beneficial as it enables us to grasp the step-by-step progression of the scenario in a comprehensible manner. Notably, the 8th step presents an interesting comparison. In the coroutine approach, we had to execute two additional coroutines and pass them a lambda function as an argument to track their results. However, with the async/await approach, we can simply await on two separate tasks simultaneously, resulting in even greater simplification and improved code readability. This approach eliminates the need for explicit lambda usage and provides a more straightforward syntax for handling multiple asynchronous operations concurrently.

Moreover, the utilization of ‘UniTask’ built-in methods further enhances the capabilities of async/await. These methods allow us to conveniently wait until a specific condition is met, incorporating the use of lambdas. This feature not only streamlines the code but also provides flexibility in handling complex asynchronous scenarios. By leveraging these built-in methods, developers can write more concise and expressive code, making asynchronous programming more manageable and efficient.

Overall, the async/await approach not only simplifies the handling of asynchronous operations but also offers enhancements over traditional coroutine approaches, improving code readability and maintainability. The ability to await multiple tasks concurrently and utilize built-in methods like ‘UniTask’ with lambdas adds another layer of flexibility and power to the async/await paradigm, empowering developers to create more robust asynchronous code.

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);

}

Similar to coroutines, the movement, rotation, and resize methods in this async/await approach involve calculations within a loop, which cannot be circumvented. These methods await the next frame to execute each individual operation. However, a notable improvement is that we no longer require any additional parameters to be defined for the rotate and resize methods, resulting in enhanced code simplicity.

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 significant advantage of the async/await approach, which distinguishes it from the other two solutions, lies in its seamless ability to delegate work to other threads using UniTask.SwitchToThreadPool. This powerful feature allows developers to offload computationally intensive tasks to separate threads, thereby enhancing overall performance and responsiveness.

In addition to thread delegation, async/await also offers the convenience of switching back to the main thread effortlessly using UniTask.SwitchToMainThread. This functionality proves invaluable when dealing with time-consuming operations that could potentially impact the Unity engine’s main thread. By seamlessly transitioning between threads, developers can ensure smoother execution of tasks, effectively preventing any negative impact on the user experience.

The combined power of thread delegation and the ability to switch between threads on demand makes async/await an incredibly versatile tool for optimizing performance in Unity applications. By utilizing UniTask.SwitchToThreadPool and UniTask.SwitchToMainThread effectively, developers can achieve a fine-tuned balance between background processing and main thread responsiveness, resulting in faster and more efficient application execution.

html code on the screen

Conclusion

Although asynchronous programming in the Unity game engine may initially seem daunting, we have come to recognize the numerous advantages associated with adopting this approach. By utilizing asynchronous programming, we can effectively delegate resource-intensive computational tasks and handle tasks with uncertain completion times. Furthermore, this programming paradigm significantly enhances code readability and provides a clearer understanding of the logical flow within our codebase. By enabling us to write code in a more linear manner while maintaining concise code structures, asynchronous programming proves to be a valuable tool in our development process.