Prefab pooling

The previous pooling solution is useful for typical classes, but it won't work for special Unity objects, such as GameObject and MonoBehaviour. These objects tend to consume a large chunk of our runtime memory, can cost us a great deal of CPU usage when they're created and destroyed, and tend to risk a large amount of garbage collection at runtime. In other words, the main goal of Prefab pooling is to push the overwhelming majority of object instantiation to Scene initialization, rather than letting them get created at runtime. This can provide some big runtime CPU savings, and avoids a lot of spikes caused by object creation/destruction and garbage collection, at the expense of Scene loading times, and runtime memory consumption. As a result, there are quite a few pooling solutions available on the Asset Store for handling this task, with varying degrees of simplicity, quality, and feature sets.

Note

It is often recommended that pooling should be implemented in any game that intends to deploy on mobile devices, due to the greater overhead costs involved in the allocation and deallocation of memory compared to desktop applications.

However, creating a pooling solution is an interesting topic, and building one from scratch is a great way of getting to grips with a lot of important internal Unity Engine behavior. Also, knowing how such a system is built makes it easier to extend if we wish it to meet the needs of our particular game, rather than relying on a prebuilt solution.

The general idea of Prefab pooling is to create a system that contains lists of active and inactive GameObjects that were all instantiated from the same Prefab reference. The following diagram shows how the system might look after several spawns, despawns, and respawns of various objects derived from four different Prefabs (Orc, Troll, Ogre, and Dragon):

Prefab pooling

Note

Note that the Heap Memory area represents the objects as they exist in memory, while the Pooling System area represents references to those objects.

In this example, several instances of each Prefab were instantiated (11 Orcs, 8 Trolls, 5 Ogres, and 1 Dragon). Currently only eleven of these objects are active, while the other fourteen have previously been despawned, and are inactive. Note that the despawned objects still exist in memory, although they are not visible and cannot interact with the game world until they have been respawned. Naturally, this costs us a constant amount of heap memory at runtime in order to maintain the inactive objects, but when a new object is instantiated, we can reuse one of the existing inactive objects, rather than allocating more memory in order to satisfy the request. This saves significant runtime CPU costs during object creation and destruction, and avoids garbage collection.

The following diagram shows the chain of events that needs to occur when a new Orc is spawned:

Prefab pooling

The first object in the Inactive Orc pool (Orc7) is reactivated and moved into the Active pool. We now have 6 active Orcs, and 5 inactive Orcs.

The following figure shows the order of events when an Ogre object is despawned:

Prefab pooling

This time the object is deactivated and moved from the Active pool into the Inactive pool, leaving us with 1 active Ogre and 4 inactive Ogres.

Finally, the following diagram shows what happens when a new object is spawned, but there are no inactive objects to satisfy the request:

Prefab pooling

In this scenario, more memory must be allocated to instantiate the new Dragon object, since there are no Dragon objects in its Inactive pool to reuse. Therefore, in order to avoid runtime memory allocations for our GameObjects, it is critical that we know beforehand how many we will need. This will vary depending on the type of object in question, and requires occasional testing and debugging to ensure we have a sensible number of each Prefab instantiated at runtime.

With all of this in mind, let's create a pooling system for Prefabs!

Poolable Components

Let's first define an interface for a Poolable Component:

public interface IPoolableComponent {
    void Spawned();
    void Despawned();
}

The approach for IPoolableComponent will be very different from the approach taken for IPoolableObject. The objects being created this time are GameObjects, which are a lot trickier to work with than standard objects because of how much of their runtime behavior is already handled through the Unity Engine, and how little access we have to it.

GameObjects do not give us access to an equivalent New() method that we can invoke any time the object is created, and we cannot derive from the GameObject class in order to implement one. GameObjects are created either by placing them in a Scene, or by instantiating them at runtime through GameObject.Instantiate(), and the only inputs we can apply are an initial position and rotation. Of course, their Components have an Awake() method we can define, which is invoked the first time the Component is brought to life, but this is merely a compositional object—it's not the actual parent object we're spawning and despawning.

So, because we only have control over a GameObject class's Components, it is assumed that the IPoolableComponent interface is implemented by at least one of the Components that is attached to the GameObject we wish to pool.

The Spawned() method should be invoked on every implementing Component each time the pooled GameObject is respawned, while the Despawned() method gets invoked whenever it is despawned. This gives us entry points to control the data variables and behavior during the creation and destruction of the parent GameObject.

The act of despawning a GameObject is trivial; turn its active flag to false (through SetActive()). This disables the Collider and Rigidbody for physics calculations, removes it from the list of renderable objects, and essentially takes care of disabling interactions with all built-in Unity Engine subsystems in a single stroke. The only exception is any Coroutines that are currently invoking on the object, since as we learned earlier in Chapter 2, Scripting Strategies, Coroutines are invoked independently of Update() and GameObject activity. We will therefore need to call StopCoroutine(), or StopAllCoroutines() during the despawning of such objects.

In addition, Components typically hook into our own custom gameplay subsystems as well, and so the Despawn() method gives our Components the opportunity to take care of any custom cleanup before shutting down. For example, we would probably want to use Despawn() to deregister the Component from the Messaging System we defined back in Chapter 2, Scripting Strategies.

Unfortunately, successfully respawning the GameObject is a lot more complicated. When we respawn an object, there will be many settings that were left behind when the object was previously active, and these must be reset in order to avoid conflicting behaviors. A common problem with this is Rigidbody velocity. If this value is not explicitly reset before the object is reactivated, then the newly respawned object will continue moving with the same velocity the old version had when it was despawned.

This problem becomes further complicated by the fact that built-in Components are sealed, and therefore cannot be derived from. So, to avoid these issues, we can create a custom Component that resets the attached Rigidbody whenever the object is despawned:

public class ResetPooledRigidbodyComponent : MonoBehaviour, IPoolableComponent {
    Rigidbody _body;
    public void Spawned() {  }
    public void Despawned() {
        if (_body == null) {
            _body = GetComponent<Rigidbody>();
            if (_body == null) {
                // no Rigidbody!
                return;
            }
        }
        _body.velocity = Vector3.zero; 
        _body.angularVelocity = Vector3.zero;
    }
}

Note that the best place to perform this task is during despawning, because we cannot be certain in what order the GameObject class's IPoolableComponent interfaces will have their Spawned() methods invoked. It is unlikely that another IPoolableComponent will change the object's velocity during despawning, but it is possible that a different IPoolableComponent attached to the same object might want to set the Rigidbody's initial velocity to some important value during its own Spawned() method. Ergo, performing the velocity reset during the ResetPooledRigidbodyComponent class's Spawned() method could potentially conflict with other Components and cause some very confusing bugs.

Tip

In fact, creating Poolable Components that are not self-contained, and tend to tinker with other Components like this, is one of the biggest dangers of implementing a pooling system. We should minimize such implementations, and routinely verify them when we're trying to debug strange issues in our game.

For the sake of illustration, here is the definition of a simple Poolable Component that replaces the TestMessageListener class we defined back in Chapter 2, Scripting Strategies. This Component automatically handles some basic tasks every time the object is spawned and despawned:

public class PoolableTestMessageListener : MonoBehaviour, IPoolableComponent {
    public void Spawned() {
        MessagingSystem.Instance.AttachListener (typeof(MyCustomMessage), this.HandleMyCustomMessage);
    }

bool HandleMyCustomMessage(BaseMessage msg) {
        MyCustomMessage castMsg = msg as MyCustomMessage;
        Debug.Log (string.Format("Got the message! {0}, {1}", castMsg._intValue, castMsg._floatValue));
        return true;
    }

    public void Despawned() {
        if (MessagingSystem.IsAlive) {
MessagingSystem.Instance.DetachListener(typeof(MyCustomMessage), this.HandleMyCustomMessage);
        }
    }
}

The Prefab pooling system

Hopefully, we now have an understanding of what we need from our pooling system, so all that's left is to implement it. The requirements are as follows:

  • It must accept requests to spawn a GameObject from a Prefab, an initial position, and an initial rotation:
    • If a despawned version already exists, it should respawn the first available one
    • If it does not exist, then it should instantiate a new GameObject from the Prefab
    • In either case, the Spawned() method should be invoked on all IPoolableComponent interfaces attached to the GameObject
  • It must accept requests to despawn a specific GameObject:
    • If the object is managed by the pooling system, it should deactivate it and call the Despawned() method on all IPoolableComponent interfaces attached to the GameObject
    • If the object is not managed by the pooling system, it should send an error

The requirements are fairly straightforward, but the implementation requires some investigation if we wish to make the solution performance-friendly. Firstly, a typical Singleton would be a good choice for the main entry point, since we want this system to be globally accessible from anywhere:

public static class PrefabPoolingSystem {
}

The main task for object spawning involves accepting a Prefab reference, and figuring if we have any despawned GameObjects that were originally instantiated from the same reference. To do this, we will essentially want our pooling system to keep track of two different lists for any given Prefab reference: a list of active (spawned) GameObjects, and a list of inactive (despawned) objects that were instantiated from it. This data would be best abstracted into a separate class, which we will name PrefabPool.

In order to maximize the performance of this system (and hence make the largest gains possible, relative to just allocating and deallocating objects from memory all of the time), we will want to use some fast data structures in order to acquire the corresponding PrefabPool objects whenever a spawn or despawn request comes in.

Because spawning involves being given a Prefab, we will want one data structure that can quickly map Prefabs to the PrefabPool that manages them. And because despawning involves being given a GameObject, we will want another data structure that can quickly map spawned GameObjects to the PrefabPool that originally spawned them. A Dictionary is a good choice for both of these needs.

Let's define these maps in our pooling system:

public static class PrefabPoolingSystem {
    static Dictionary<GameObject,PrefabPool> _prefabToPoolMap = new Dictionary<GameObject,PrefabPool>();
    static Dictionary<GameObject,PrefabPool> _goToPoolMap = new Dictionary<GameObject,PrefabPool>();
}

Next we'll define what happens when we spawn an object:

public static GameObject Spawn(GameObject prefab, Vector3 position, Quaternion rotation) {
    if (!_prefabToPoolMap.ContainsKey (prefab)) {
        _prefabToPoolMap.Add (prefab, new PrefabPool());
    }
    PrefabPool pool = _prefabToPoolMap[prefab];
    GameObject go = pool.Spawn(prefab, position, rotation);
    _goToPoolMap.Add (go, pool);
    return go;
}

The Spawn() method will be given a Prefab reference, an initial position, and an initial rotation. We need to figure out which PrefabPool the Prefab belongs to (if any), ask it to spawn a new GameObject using the data provided, and then return the spawned object to the requestor. We first check our "Prefab-to-Pool" map, to see if a pool already exists for this Prefab. If not, we quickly create one. In either case, we then ask the PrefabPool to spawn us a new object. The PrefabPool will either end up respawning an object that was despawned earlier, or instantiate a new one (if there aren't any inactive instances left).

Either way, this class doesn't particularly care. It just wants the instance generated by the PrefabPool class so that it can be entered into the "GameObject-to-Pool" map and returned to the requestor.

For convenience, we can also define an overload which places the object at the world's center (useful for GameObjects that aren't visible, and just need to exist somewhere):

public static GameObject Spawn(GameObject prefab) {
    return Spawn (prefab, Vector3.zero, Quaternion.identity);
}

Note

Note that no actual spawning and despawning are taking place, yet. This task will eventually be handled within the PrefabPool class.

Despawning involves being given a GameObject, and then figuring out which PrefabPool is managing it. This could be achieved by iterating through our PrefabPool classes and checking if they contain the given GameObject. But if we end up generating a lot of PrefabPools, then this iterative process can take a while. We will always end up with as many PrefabPool classes as we have Prefabs (at least so long as we manage all of them through the pooling system). Most projects tend to have dozens, hundreds, if not thousands of different Prefabs.

So, the GameObject-to-Pool map is maintained to ensure that we always have rapid access to the PrefabPool that originally spawned the object. It can also be used to quickly verify if the given GameObject is even managed by the pooling system to begin with. Here is the method definition for the despawning method, which takes care of these tasks:

public static bool Despawn(GameObject obj) {
    if (!_goToPoolMap.ContainsKey(obj)) {
        Debug.LogError (string.Format ("Object {0} not managed by pool system!", obj.name));
        return false;
    }
    
    PrefabPool pool = _goToPoolMap[obj];
    if (pool.Despawn (obj)) {
        _goToPoolMap.Remove (obj);
        return true;
    }
    return false;
}

Tip

Note that the Despawn() method of both PrefabPoolingSystem and PrefabPool returns a Boolean that can be used to verify whether or not the object was successfully despawned.

As a result, thanks to the two maps we're maintaining, we can quickly access the PrefabPool that manages the given reference, and this solution will scale for any number of Prefab that the system manages.

Prefab pools

Now that we have a system that can handle multiple Prefab pools automatically, the only thing left is to define the behavior of the pools. As mentioned previously, we will want the PrefabPool class to maintain two data structures: one for active (spawned) objects that have been instantiated from the given Prefab and another for inactive (despawned) objects.

Technically, the PrefabPoolingSystem class already maintains a map of which Prefab is governed by which PrefabPool, so we can actually save a little memory by making the PrefabPool a slave to the PrefabPoolingSystem class, by not having it keep track of which Prefab it is managing. Consequently, the two data structures are the only member variables the PrefabPool needs to keep track of.

However, for each spawned GameObject, it must also maintain a list of all of its IPoolableComponent references in order to invoke the Spawned() and Despawned() methods on them. Acquiring these references can be a costly operation to perform at runtime, so it would be best to cache the data in a simple struct:

public struct PoolablePrefabData {
    public GameObject go;
    public IPoolableComponent[] poolableComponents;
}

This struct will contain a reference to the GameObject, and the precached list of its IPoolableComponents.

Now we can define the member data of our PrefabPool class:

public class PrefabPool {
    Dictionary<GameObject,PoolablePrefabData> _activeList = new Dictionary<GameObject,PoolablePrefabData>();
    Queue<PoolablePrefabData> _inactiveList = new Queue<PoolablePrefabData>();
}

The data structure for the active list should be a dictionary in order to do a quick lookup for the corresponding PoolablePrefabData from any given GameObject reference. This will be useful during object despawning.

Meanwhile, the inactive data structure is defined as a Queue, but it will work equally well as a List, a Stack, or really any data structure that needs to regularly expand or contract, and where we only need to pop items from one end of the list, since it does not matter which object it is. It only matters that we retrieve one of them. A Queue is useful in this case because we can both retrieve and remove the object from the data structure in a single call.

Object spawning

Let's define what it means to spawn a GameObject in the context of our pooling system: at some point, PrefabPool will get a request to spawn a GameObject from a given Prefab, at a particular position and rotation. The first thing we should check is whether or not we have any inactive instances of the Prefab. If so, then we can pop the next available one from the Queue and respawn it. If not, then we need to instantiate a new GameObject from the Prefab using GameObject.Instantiate(). At this moment, we should also create a PoolablePrefabData object to store the GameObject reference, and acquire the list of all IPoolableComponents that are attached to it.

Either way, we can now activate the GameObject, set its position and rotation, and call the Spawned() method on all of its IPoolableComponents. Once the object has been respawned, we can add it to the list of active objects and return it to the requestor.

Here is the definition of the Spawn() method that defines this behavior:

public GameObject Spawn(GameObject prefab, Vector3 position, Quaternion rotation) {
    PoolablePrefabData data;
    if (_inactiveList.Count > 0) {
         data = _inactiveList.Dequeue();
    } else {
        // instantiate a new object
        GameObject newGO = GameObject.Instantiate(prefab, position, rotation) as GameObject;
        data = new PoolablePrefabData();
        data.go = newGO;
        data.poolableComponents = newGO.GetComponents<IPoolableComponent>();
    }

    data.go.SetActive (true);
    data.go.transform.position = position;
    data.go.transform.rotation = rotation;
    for(int i = 0; i < data.poolableComponents.Length; ++i) {
        data.poolableComponents[i].Spawned ();
    }
    _activeList.Add (data.go, data);

    return data.go;
}

Instance prespawning

Because we are using GameObject.Instantiate() whenever the pool has run out of despawned instances, this system does not completely rid us of runtime object instantiation and hence, heap memory allocation. It's important to prespawn the expected number of instances that we will need during the lifetime of the current Scene, so that we don't need to instantiate more during runtime.

Tip

It would be wasteful to prespawn 100 explosion particle effects, if the most we will ever expect to see in the Scene at any given time is three or four. Conversely, spawning too few instances will cause excessive runtime memory allocations, and the goal of this system is to push the majority of allocation to the start of a Scene's lifetime. We need to be careful about how many instances we maintain in memory so that we don't waste more memory space than necessary.

Let's define a method in our PrefabPoolingSystem class that we can use to quickly prespawn a given number of objects from a Prefab. This essentially involves spawning N objects, and then immediately despawning them all:

public static void Prespawn(GameObject prefab, int numToSpawn) {
    List<GameObject> spawnedObjects = new List<GameObject>();

    for(int i = 0; i < numToSpawn; i++) {
        spawnedObjects.Add (Spawn (prefab));
    }

    for(int i = 0; i < numToSpawn; i++) {
        Despawn(spawnedObjects[i]);
    }

    spawnedObjects.Clear ();
}

We would use this method during Scene initialization, to prespawn a collection of objects to use in the level. For example:

public class OrcPreSpawner : MonoBehaviour
    [SerializeField] GameObject _orcPrefab; 
    [SerializeField] int _numToSpawn = 20;
    void Start() {
        PrefabPoolingSystem.Prespawn(_orcPrefab, _numToSpawn);
    }
}

Object despawning

Finally, there is the act of despawning the objects. As mentioned previously, this primarily involves deactivating the object, but we also need to take care of various bookkeeping tasks and invoking Despawned() on all of its IPoolableComponent references.

Here is the method definition for the PrefabPool class's Despawn() method:

public bool Despawn(GameObject objToDespawn) {
    if (!_activeList.ContainsKey(objToDespawn)) {
        Debug.LogError ("This Object is not managed by this object pool!");
        return false;
    }

    PoolablePrefabData data = _activeList[objToDespawn];

    for(int i = 0; i < data.poolableComponents.Length; ++i) {
        data.poolableComponents[i].Despawned ();
    }
    data.go.SetActive (false);

    _activeList.Remove (objToDespawn);
    _inactiveList.Enqueue(data);
    return true;
}

First we verify the object is being managed by the pool, and then we grab the corresponding PoolablePrefabData in order to access the list of IPoolableComponent references. Once Despawned() has been invoked on all of them, we deactivate the object, remove it from the active list, and push it into the inactive queue so that it can be respawned later.

Prefab pool testing

The following class definition allows us to perform a simple hands-on test with the PrefabPoolingSystem class. It will support three Prefabs, and prespawn five instances during application initialization. We can press the 1, 2, or 3 keys to spawn an instance of each type, and then press Q, W, or E to despawn a random instance of each type.

public class PoolTester : MonoBehaviour {

    [SerializeField] GameObject _prefab1;
    [SerializeField] GameObject _prefab2;
    [SerializeField] GameObject _prefab3;

    List<GameObject> _go1 = new List<GameObject>();
    List<GameObject> _go2 = new List<GameObject>();
    List<GameObject> _go3 = new List<GameObject>();

    void Start() {
        PrefabPoolSystem_AsSingleton.Prespawn(_prefab1, 5);
        PrefabPoolSystem_AsSingleton.Prespawn(_prefab2, 5);
        PrefabPoolSystem_AsSingleton.Prespawn(_prefab3, 5);
    }

    void Update () {
        if (Input.GetKeyDown(KeyCode.Alpha1)) {SpawnObject(_prefab1, _go1);}
        if (Input.GetKeyDown(KeyCode.Alpha2)) {SpawnObject(_prefab2, _go2);}
        if (Input.GetKeyDown(KeyCode.Alpha3)) {SpawnObject(_prefab3, _go3);}
        if (Input.GetKeyDown(KeyCode.Q)) { DespawnRandomObject (_go1); }
        if (Input.GetKeyDown(KeyCode.W)) { DespawnRandomObject (_go2); }
        if (Input.GetKeyDown(KeyCode.E)) { DespawnRandomObject (_go3); }
    }

    void SpawnObject(GameObject prefab, List<GameObject> list) {
        GameObject obj = PrefabPoolingSystem.Spawn (prefab, Random.insideUnitSphere * 8f, Quaternion.identity);
        list.Add (obj);
    }

    void DespawnRandomObject(List<GameObject> list) {
        if (list.Count == 0) {
            // Nothing to despawn
            return;
        }

        int i = Random.Range (0, list.Count);
        PrefabPoolingSystem.Despawn(list[i]);
        list.RemoveAt(i);
    }
}

Once we spawn more than five instances of any of the Prefabs, it will need to instantiate a new one in memory, costing us some memory allocation. But, if we observe the Memory Area in the Profiler, while we only spawn and despawn instances that already exist, then we will notice that absolutely no new allocations take place.

Prefab pooling and Scene loading

There is one subtle caveat to this system that has not yet been mentioned: the PrefabPoolingSystem class will outlast Scene lifetime since it is a static class. This means that, when a new Scene is loaded, the pooling system's dictionaries will attempt to maintain references to any pooled instances from the previous Scene, but Unity forcibly destroys these objects regardless of the fact that we are still keeping references to them (unless they were set to DontDestroyOnLoad()!), and so the dictionaries will be full of null references. This would cause some serious problems for the next Scene.

We should therefore create a method in PrefabPoolingSystem that resets the pooling system in preparation for this likely event. The following method should be called before a new Scene is loaded, so that it is ready for any early calls to Prespawn() in the next Scene:

public static void Reset() {
    _prefabToPoolMap.Clear ();
    _goToPoolMap.Clear ();
}

Note that, if we also invoke a garbage collection during Scene transitions, there's no need to explicitly empty the PrefabPools these dictionaries were referencing. Since these were the only references to the PrefabPool objects, they will be deallocated during the next garbage collection. If we aren't invoking garbage collection between Scenes, then the PrefabPool and PooledPrefabData objects will remain in memory until that time.

Prefab pooling summary

We have finally solved the problem of runtime memory allocations for GameObjects and Prefabs but, as a quick reminder, we need to be aware of the following caveats:

  • We need to be careful about properly resetting important data in respawned objects (such as Rigidbody velocity)
  • We must ensure we don't prespawn too few, or too many, instances of a Prefab
  • We should be careful of the order of execution of Spawned() and Despawned() methods on IPoolableComponents
  • We must call Reset() on PrefabPoolingSystem before Scene loading

There are several other features we could implement. These will be left as academic exercises if we wish to extend this system in the future:

  • Any IPoolableComponents added to the GameObject after initialization will not be invoked. We could fix this by changing PrefabPool to keep acquiring IPoolableComponents every time Spawned() and Despawned() are invoked, at the cost of additional overhead during spawning/despawning.
  • IPoolableComponents attached to children of the Prefab's root will not be counted. This could be fixed by changing PrefabPool to use GetComponentsInChildren<T>, at the cost of additional overhead if we're using Prefabs with deep hierarchies.
  • Prefab instances that already exist in the Scene will not be managed by the pooling system. We could create a Component that needs to be attached to such objects and that notifies the PrefabPoolingSystem class of its existence and passes the reference into the corresponding PrefabPool.
  • We could implement a way for IPoolableComponents to set a priority during acquisition, and directly control the order of execution for their Spawned() and Despawned() methods.
  • We could add counters that keep track of how long objects have been sitting in the Inactive list relative to total Scene lifetime, and print out the data during shutdown. This could tell us whether or not we're prespawning too many instances of a given Prefab.
  • This system will not interact kindly with Prefab instances that set themselves to DontDestroyOnLoad(). It might be wise to add a Boolean to every Spawn() call to say whether the object should persist or not, and keep them in a separate data structure that is not cleared out during Reset().
  • We could change Spawn() to accept an argument that allows the requestor to pass custom data to the Spawned() function of IPoolableObject for initialization purposes. This could use a system similar to how custom message objects were derived from the BaseMessage class for our Messaging System back in Chapter 2, Scripting Strategies.
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.191.165.62