跳转至

11 Asynchronous Functions, Coroutines & Object Pooling

Introduction

The veggie gladiators are pouring through the gates! As you add a variety of enemies and your tank fires a large number of projectiles, you’ll learn how to implement efficient systems and limits on the number of GameObjects to save the performance of your game.

img

Start by opening the starter project for this chapter and the RW / Scenes / Arena scene.

Play the game and fire several projectiles. You’ll see the accumulation of GameObjects in the Hierarchy.

img

Timed destruction of GameObjects

You need mechanisms to clean up the Hierarchy from the projectiles and enemies after they expire. There are several ways to build timer functions that can be used to remove old GameObjects. You’ll explore two patterns for writing asynchronous timer functions: coroutines and async/await.

Synchronous and asynchronous functions

When you write code, you’re giving your game a set of instructions to run. It will complete those instructions in the order they’re written. This is called synchronous programming. It works well because you can determine what your game will do next.

However, it’s not so good when you want something to happen over a period of time or frames. For example, if you were to write something like this:

while (transform.position != someTargetPosition)
{
    MoveTowards(someTargetPosition);
}

The code in the while loop would be called multiple times in the same frame — or “tick” — until your object had moved to where you wanted it to be. But to the user, it would appear as if it happened instantly.

Asynchronous methods are run on their own independent paths — so they don’t block the main flow of your game. You can even run multiple asynchronous tasks at the same time.

Understanding coroutines

In Unity, you can write coroutine methods for your asynchronous code. A coroutine must be called using StartCoroutine(MyCoroutine);, and the coroutine needs to be an IEnumerator method. At the end of each frame, Unity will return back to the MonoBehaviour and continue execution at the statement immediately after the statement beginning with yield return.

img

It’s time to write your first coroutine method to remove an enemy GameObject whenever an enemy is killed. Open the EnemyController script from the RW / Scripts folder. Within the class, add the following method:

IEnumerator DieCoroutine()
{
    // 1. Delay for 5 seconds.
    yield return new WaitForSeconds(5);
    // 2. Destroy the GameObject
    Destroy(gameObject);
}

Here’s what you’re doing:

  1. When you use a yield instruction, you’re saying to the IEnumerator, “OK, we’re done for now. Come back later.” You can specify when to return using built-in methods such as WaitForSeconds or WaitForEndOfFrame.
  2. In this case, after the wait provided by WaitForSeconds(5) has occurred, execution is returned back to the next instruction after the yield, where you destroy the GameObject.

Calling this coroutine when an enemy is defeated will therefore allow a delay for the enemy death animation before the enemy GameObject is destroyed.

To do that, find the method OnTriggerEnter, and at the start of the block if (state != States.Dead), add the following:

StartCoroutine(DieCoroutine());

When a character is killed by a projectile or tank, the coroutine will now be called and enemies will be removed from the scene after a delay of 5 seconds after their death. The coroutine provides the asynchronous behavior of handling the timing while the gameplay can proceed in parallel.

img

Save your changes, head back to the Unity Editor and play the scene.

A new alternative: Async/Await

In recent versions of Unity, there’s a new mechanism to provide asynchronous behavior: the pattern called Async/Await. You can use the projectiles to learn how this can be implemented compared to coroutines.

With the Async/Await pattern, you create an async method that includes an instruction — Task.Yield(). These are quite similar to the IEnumerator and yield returnstatements from the coroutine pattern.

img

The keyword async defines a method that can run asynchronously. Put the asyncbetween the access modifier (public, private, etc.) and the return type.

Open the RW / Scripts / ProjectileBehaviour script and add the following method to the class:

public async void DieAsync()
{
    // 1.
    await Task.Delay(2000);
    // 2.
    Destroy(gameObject);
}

Compare this with the coroutine function above:

  1. Instead of the yield, you have an await instruction that acts on a Task, which in this case provides a delay for 2000 milliseconds (2 seconds). The Task is a class that represents a single operation that can run asynchronously.
  2. After the Task asynchronously completes, execution continues to the Destroy call to remove this projectile GameObject.

You need to start your async function after the projectile is instantiated, so open the RW / Scripts / PlayerController script and find the OnFire() method. Towards the bottom of this method, add the following to the bottom of the if (projectile) block:

projectile.GetComponent<ProjectileBehaviour>().DieAsync();

Save all your changes in your scripts and run the scene. Fire some projectiles and after 2 seconds, the timed calls to Destroy clean up the used projectiles.

Note: Which pattern should you use? If you prefer the style of the coroutine, feel free to use it, but often the async/await pattern is a better option. In many ways, coroutines were a precursor to async/await. The biggest advantage of async/await is its overall simplicity and common approach to asynchronous code in C# outside of Unity. Either mechanism in Unity will asynchronously run your code on the main thread of the game avoiding conflicts that could occur with asynchronous multithreading. One of the advantages of async/await is that it allows you to return a value — which you can’t do with an IEnumerator method. If you need to stop your asynchronous methods from outside of the method, there are built-in functions for coroutines such as StopCoroutine, however, there aren’t any easy built-in methods for async/await. To read more about coroutines, check out the Unity Official Documentation. And, to learn more about Asynchronous Programming in Unity, check out this great tutorial on raywenderlich.com.

Object pooling

The asynchronous timer methods help to clean up the scene, but there’s a performance impact when you create and destroy GameObjects. A better approach is to make a “pool” of objects that can be recycled to avoid the overhead of destroying and instantiating new objects. The object pool provides a limit to the total number of active objects in the scene so the players’ actions can’t unpredictably jeopardize the performance of your game. Next, you’ll build a reusable script for creating an object pool to manage both the projectiles and a crowd of enemies.

How to implement object pooling

You can construct an object pool by completing the RW / Scripts / ObjectPool script. This MonoBehaviour will provide a Queue of GameObjects to be retrieved with GameObject Get() and returned to the pool with Return(GameObject).

img

Before you can create a generic ObjectPool class, there are certain rules that any poolable GameObject must consider. If you open the RW / Scripts / IPoolable interface, you see it defines the methods any poolable GameObject should implement in a component script:

public interface IPoolable
{
    // 1.
    void Reset();

    // 2.
    void Deactivate();

    // 3.
    void SetPool(ObjectPool pool);
}

A C# interface like this sets the basic rules that scripts must follow to:

  1. Implement a method that returns it back to a ready state in Reset().
  2. Implement a method that defines what happens when it goes back to the pool in Deactivate().
  3. Provide a helper method to track what pool it belongs to.

To use the IPoolable interface, your class must inherit it. For example, if you look at ProjectileBehviour, you’ll see that it does just that:

public class ProjectileBehaviour : MonoBehaviour, IPoolable

Once you state this, the class must implement the interface’s methods. So in this case, it needs to have methods called Reset(), Deactivate(), and SetPool(ObjectPool pool)defined. Stubs for these methods have already been added to ProjectileBehaviour and EnemyController.

Later, you’ll update these methods to follow the rules above so that the ProjectileBehaviour and EnemyController are ready for action.

Complete the ObjectPool

First, create the methods that will provide a reusable object pool for either type of GameObject.

Open the RW / Scripts / ObjectPool. The class has pre-defined variable called pool:

private Queue<GameObject> pool = new Queue<GameObject>();

This is a Queue of GameObjects that contains a “first-in, first-out” queue of any reusable GameObject.

To add objects to the pool queue, add the following to the Add(GameObject anObject)method:

// 1.
IPoolable poolable = anObject.GetComponent<IPoolable>();
if (poolable != null)
{
    // 2.
    pool.Enqueue(anObject);
    poolable.SetPool(this);
}

Going through this:

  1. First check if the supplied GameObject has the necessary IPoolable component script.
  2. If it does, put it into the pool queue to be used later.

Now to request a poolable GameObject from the queue. Add the following to the Get()method stub, above the return null; statement:

// 1.
if (pool.Count > 0)
{
    // 2.
    GameObject toReturn = pool.Dequeue();
    toReturn.GetComponent<IPoolable>().Reset();
    return toReturn;
}
  1. Check if the pool has any members.
  2. If so, reset the state of the pool member so it’s ready to be used, and return it.

You now need a way to put a GameObject back in the pool, Add the following to Return(GameObject anObject):

// 1.
IPoolable poolable = anObject.GetComponent<IPoolable>();
if (poolable != null)
{
    // 2.
    poolable.Deactivate();
    Add(anObject);
}
  1. First check if this the supplied object is an IPoolable member.
  2. If so, deactivate the poolable object, then add it back to the pool.

The last method you need to write is a helper to construct a pool from a set of prefabs. You’ll use this to create a pool of projectiles or various enemies. Add the following to the Awake()method:

// 1.
for (int i = 0; i < PoolSize; i++)
{
    // 2.
    GameObject poolMember = Instantiate(Prefabs[i % Prefabs.Length],
                                        transform);
    // 3.
    poolMember.SetActive(false);
    Add(poolMember);
}

This method will build a pool by instantiating one or more example prefabs provided by the array of Prefabs.

Going through this:

  1. The number of objects in the pool will be determined by the PoolSize variable. So loop around for that number of times.
  2. Instantiate the next prefab from the Prefabs array. This uses modular arithmetic; just as you can count 24 hours on a clock face without using a number beyond 12, this allows you to count the full pool size without extending beyond the length of your Prefabs array.
  3. Make the pool member inactive in the scene before adding it to the pool.

Save your changes and head back to the Unity editor.

Creating an object pool for projectiles

Find and select the empty Actors / Projectiles GameObject in the Hierarchy and add the component script Object Pool. Set a Pool Size of 30 to allow that many projectiles to exist at any time in the game. Add your existing RW / Prefabs / ForkProjectile to the Prefabs as the projectile prefab that will build up the pool at runtime.

img

The ForkProjectile already has a component ProjectileBehaviour, but you need to define what happens when a projectile is reset and when it’s deactivated upon returning to a pool.

Open the RW / Scripts / ProjectileBehaviour script.

In DieAsync(), replace Destroy(gameObject); with:

if (ProjectilePool)
{
    ProjectilePool.Return(gameObject);
}

This will now return the GameObject to the pool instead of destroying it.

The Reset() method needs to provide a ready-to-use projectile and is called automatically when requesting a GameObject with ObjectPool.Get(). This method needs to clear the velocity of the projectile and keep it active for a couple of seconds before it’s returned to the pool automatically.

Add the following to the Reset() method:

// 1.
gameObject.SetActive(true);
gameObject.GetComponent<Rigidbody>().velocity = new Vector3(0, 0, 0);
// 2.
DieAsync();

Going through this:

  1. To reactivate a projectile, make the GameObject active and clear its velocity.
  2. Start the async method to return it back to the pool.

Finally, you need to implement the Deactivate() method that will be called by ObjectPool.Return(GameObject) when returning the projectile to its pool:

Add the following to Deactivate():

gameObject.SetActive(false);

This simply sets the GameObject inactive to hide it from view and stop all motion.

Save your changes. You’ve now completed the major logic for the projectile pool — ready now to reuse, reduce and recycle those projectiles!

Updating the projectile launch

The next change needed for ProjectilePool is to pull GameObjects from the ProjectilePoolinstead of instantiating them.

Open the RW / Scripts / PlayerController script and replace the line public GameObject Projectile; with the following:

public ObjectPool ProjectilePool;

Next, in OnFire() replace GameObject projectile = GameObject.Instantiate(Projectile); with:

GameObject projectile = ProjectilePool.Get();

This retrieves a ready-to-use ForkProjectile that can fire from the tank.

Since the projectile now starts the DieAsync() coroutine as part of the Reset() pool method, you can also remove projectile.GetComponent<ProjectileBehaviour>().DieAsync(); from the if (projectile) block of OnFire().

Save your changes and head back to the Unity editor. Select Actors / Tank in the Hierarchy, and in the Player Controller component in the Inspector, assign the Projectiles GameObject to the Projectile Pool field:

img

Save your scene. Play it and observe that ForkProjectiles now fill the Projectiles GameObject and become activated and inactivated as they’re fired by the tank and returned back to the pool.

img

Creating an object pool for the enemies

Creating a pool of different varieties of enemy gladiators is even easier now that the ObjectPool is defined.

Open the RW / Scripts / EnemyController script and in DieCoroutine(), change Destroy(gameObject) with:

if (EnemyPool)
{
    EnemyPool.Return(gameObject);
}

This is the same as what you did for the projectiles earlier.

It’s critical to reset the enemy state when obtaining an enemy from the pool when calling ObjectPool.Get(), so add the following to the Reset() method:

state = States.Ready;
characterAnimator.SetBool("Death", false);

This resets the enemy to be ready for battle.

Now, add the following to Deactivate():

agent.isStopped = true;
gameObject.SetActive(false);
gameObject.GetComponent<NavMeshAgent>().enabled = false;

Remember that when a GameObject is returned to the pool with ObjectPool.Return(GameObject) the Deactivate() method is called. So this is where you hide the enemy by inactivating the character and disable its navigation.

Save the script and go back to the Unity editor.

Find the empty Actors / Enemies GameObject in the Hierarchy and add the Object Poolcomponent to it. Set a Pool Size of 50, and assign the Warrior_Carrot Variant, Warrior_Pepper Variant and Warrior_Potato Variant prefabs to the pool by dragging them from the RW / Prefabs folder.

img

Lastly, open the RW / Scripts / GateSpawner script. Here, you again need to replace the GameObject variable with an ObjectPool variable.

At the top of GateSpawner, replace public GameObject Enemy; with:

public ObjectPool EnemyPool;

Now, in SpawnEnemies(), use your new variable by replacing GameObject enemy = Instantiate(Enemy, Gate.transform.parent); with:

GameObject enemy = EnemyPool.Get();

Save your changes, and in the Unity editor, select all four of the GateWall VariantGameObjects in the Hierarchy, find their Gate Spawner component, and assign all to have the Enemies GameObject in the Hierarchy to the Enemy Pool field in the component.

Now, play the scene and you’ll see a varied attack force of slow potatoes, carrots and spicy peppers!

img

Within the Enemies container, the same enemies are being reactivated and recycled to use in the scene.

Congratulations! You’ve made the game more complex with new enemies, and you’ve mastered efficiency with asynchronous operations and object pooling!

Key points

  1. Coroutines and async/await are two ways you can implement asynchronous operations for timed delays to actions.
  2. Object Pools are a reusable mechanism for managing large numbers of GameObjects in a scene — such as Projectiles, Enemies and other spawnable elements. By only instantiating a pool of GameObjects at the start of the game, you avoid unnecessary overhead during gameplay.
  3. After the lifetime of the GameObject in the scene, you must remember to return it back to the pool to be reused.
  4. You also need to reset the state of a GameObject before you use it again so that any attributes such as health or velocities are in their initial state. Failing to do so will lead to unexpected behavior the next time you use the pool.

Continue to the next chapter to see what becomes of all the defeated veggie gladiators and what’s cooking down below!