Caching GameObjects


This article will describe the strategy used to create a GameObject Cache in EVAX and GOBSMACKED.

Concept

Smooth gameplay is critical to every game. If the user experiences stuttering it can be extremely frustrating and ruin the game experience.


In Unity, creating a new instance of a prefab GameObject can require a significant amount of loading time. We could attempt to deal with this by creating every object we need during a loading period before the scene starts. That strategy might work for smaller scenes; however, if we have a scene where hundreds of enemies appear, get killed, and appear again we cannot load a distinct object for each of them when the scene starts.

A better strategy is to load a fraction of the GameObject instances we will need in our scene. Those instances can be stored in a pool. When the scene needs a new enemy, we activate (not load or create) an instance from the pool. When that enemy dies, we deactivate it and return it to the pool.

This allows the developer to have better control over the number of resources that the game consumes. For instance, this pattern makes it very easy to code a game where there are always a specific number of enemies on the screen. We could set the size of our GameObject cache based on the system resources. So a powerful system could show 50 enemies on screen, while a weaker system only shows 10. The rest of the game code does not have to worry about these specifics.

The GameObject Cache class

The first choice we have to make is the type of data structure we want to use to hold our cached instances. A queue works well here because we usually want requests for new objects to take the oldest instance in the pool. This avoids contention and avoids bugs that could come from performing cleanup on instances that were just released back to the pool.

We also need to create a variable to hold our template. The template is the mold that our pool will use to stamp out new instances. The pool does not need to populate itself. You could use another strategy to populate it; however, our pool will populate itself for simplicity.

So far, our code looks like this:

					
public class GameObjectCache : MonoBehaviour
{					
	public GameObject entityTemplate;
	protected Queue<GameObject> pool;
}
				
				

Creating Instances

Now we will add some object instances to the pool. Our pool will expose an initial size to the user.

					
public int initialSize;

private void InitializePool ()
{
	if (entityTemplate == null) {
		Debug.LogError ("Cannot load the pool: no entity template was set.");
		return;
	}
	AddInstances (initialSize);
}

//Give the user the ability to mark instances with a special name prefix
public string spawnName = "Spawn";
protected void AddInstances (int count)
{
	for (int i = 0; i < count; i++) {
		//Instantiate a new instance at the cache's position
		GameObject spawn = (GameObject)Instantiate (entityTemplate, transform.position, Quaternion.identity);
		//Apply the instances's name
		spawn.name = spawnName + i + " (" + gameObject.name + ")";
		//This is mostly for debug purposes - when we make the spawn a child of the pool, it is easy to locate all instances in the Editor
		spawn.transform.parent = transform;
		
		//We use our retire logic here for deactivating the instance and adding it to the pool
		Retire (spawn);
	}	
}

private void Retire (GameObject spawn)
{
	//Move the instance out of the scene, and to the pool
	spawn.transform.position = transform.position;
	//Turn off all renderer's in the instance (This is likely not necessary, it was included as part of a special case when I was using it)
	Renderer[] renderers = spawn.GetComponentsInChildren<Renderer> ();
	for (int i = 0; i < renderers.Length; i++) {
		renderers [i].enabled = false;
	}
	//Deactivate the instance
	spawn.SetActive (false);

	//Add the instance to our queue so it can be used again
	mPool.Enqueue (spawn);
}
				
				

Our pool is full of instances and ready for use. No game code should try to access these instances directly. All other components and game logic should request instances from the GameObjectCache using the appropriate method.

					
public virtual GameObject GetNextAvailable ()
{
	if (mPool.Count < 1) {
		Debug.LogError ("(" + gameObject.name + ")Failed to create instance of " + entityTemplate.name + "! Cache pool empty");
		return null;
	}
	GameObject nextSpawn = mPool.Dequeue ();
	
	//This should not happen, but this check can help to catch bugs in your code.
	if (nextSpawn == null) {
		Debug.LogError ("(" + gameObject.name + ")Failed to create instance of " + entityTemplate.name + "! Dequeued null");
		return null;
	}
	
	//This should not happen, but this check can help to catch bugs in your code.
	//Maybe another component is accessing the instances or they are activating themselves?
	if (nextSpawn.activeSelf) {
		Debug.LogError ("(" + gameObject.name + ")Failed to create instance of " + entityTemplate.name + "! Next one is already active");
		return null;
	}
	
	//Make the instance ready for use
	Activate (nextSpawn);
	
	return nextSpawn;
}			

protected void Activate (GameObject spawn)
{
	
	spawn.SetActive (true);
	//Again, this is probably not necessary, but it is included from a special case in game I created
	Renderer[] renderers = spawn.GetComponentsInChildren<Renderer> ();
	for (int i = 0; i < renderers.Length; i++) {
		renderers [i].enabled = true;
	}
	//This message can be useful if the object instance needs to do any internal cleanup before reuse
	spawn.SendMessage("Reset", SendMessageOptions.DontRequireReceiver);
}		
				
				

Our cache is halfway functional now. It creates an initial pool of objects, and provides a method for other components to request those objects. The final step is providing a way for object instances to be returned to the pool.

Returning Instances to the Cache

It is critical that instances get returned to our cache. If no instances are returned, our cache will simply return null after handing out the initial allocation.

We could require components to explicitly return the object to the cache after they are done with it. That will certainly work - and it may be the best solution for your project - but it will require special coding everywhere the object is used. Another method is to make the object aware that it is cached. Then, when the object is no longer needed, it returns itself to the instance pool.

					
public abstract class CachedBehavior : MonoBehaviour
{
	protected GameObjectCache cache;
	
	public virtual void SetCache(GameObjectCache c)
	{
		cache = c;
	}
	
	public virtual void Remove ()
	{
		cache.Retire (gameObject);
	}
}
				
				

The code above creates a simple component that allows the object to return itself to the cache. We can use this object in conjuction with the logic that controls when our object instance needs to be removed. A common example of this is when an enemy dies:

					
public class DeathMonitor : CachedBehavior
{
	public float baseHealth = 10;
	protected float health;
	
	public DeathMonitor ()
	{
		Reset ();
	}
	
	void OnEnable ()
	{
		Reset ();
	}
	
	public virtual bool IsDead ()
	{
		return health <= 0;	
	}
	
	public virtual void Reset ()
	{
		health = baseHealth;	
	}

	public virtual void OnHit (float damage)
	{
		if (IsDead()) {
			return;
		}
		health -= damage;
		if (health <= 0) {
			Die ();
		}
	}
	
	public virtual void Die ()
	{
		Remove();
	}
}
				
				

There are two important parts to our DeathMonitor class.

  • First, it checks when the entity should die (be removed). When the entity dies, it calls the CachedBehavior.Remove method which returns the instance to the cache pool.
  • Second, it implements a Reset method. This is the method that our cache calls to instruct the entity to reset to an initial state. Here our entity returns its health to the base. If it did not do this, it would already be dead the second time we tried to use it!

The last step is making sure that we set the cache reference in each object instance. This is best done in the GameObjectCache class. Here is an example of that:

					
CachedBehavior[] childObjects = spawn.GetComponents<CachedBehavior> ();
for (int k = 0; k < childObjects.Length; k++) {
	childObjects [k].SetCache (this);	
}
					
				

We add the code above to our AddInstances method (after Instantiate and before Retire). Now the cache will set itself as the cache for each instance that it contains.