paint-brush
How to Enhance Your Game Project Using the ObjectPool Pattern (A Unity Guide)by@jokeresdeu
900 reads
900 reads

How to Enhance Your Game Project Using the ObjectPool Pattern (A Unity Guide)

by Les DibrivniyMay 10th, 2023
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

Object Pool is the basis of 99% of game projects' optimization. It is based on an elementary principle: after completing its task, the object is not deleted but moved to a separate environment and can be retrieved and reused. Les Dibrivniy explains why this pattern is essential.
featured image - How to Enhance Your Game Project Using the ObjectPool Pattern (A Unity Guide)
Les Dibrivniy HackerNoon profile picture

Hi there, my name is Oles Dibrivniy, and I'm a Unity Developer at Keiki. We create products at the intersection of EdTech and GameDev — apps and web products for children's development. Now our products have more than four million users.

If someone asks you while job interview about the programming patterns you use in game development, one of the first things you should mention is ObjectPool. It is based on an elementary principle: after completing its task, the object is not deleted but moved to a separate environment and can be retrieved and reused.

This pattern directly affects the user's perception of the app, that's why it is crucial. It should be the basis of 99% of game projects' optimization.

This article may not be relevant for programming gurus but for beginners vice versa. Here I will use examples to explain why this pattern is essential.


How do things go without ObjectPool?

First, we'll analyze the project case without ObjectPool, starting with the setting. We have a relatively simple scene with the protagonist and two enemies throwing fireballs:

Let's consider what the fireball itself will look like. The fields

Rigidbody2D
and
_speed
will be responsible for the fireballs' movement; the
OnTriggerEnter2D
method will work after a collision — the object with the FireBall component will be destroyed:

public class FireBall : MonoBehaviour
2{
3    [SerializeField] private Rigidbody2D _rigidbody;
4    [SerializeField] private float _speed;
5    
6    public void FlyInDirection(Vector2 flyDirection)
7    {
8        _rigidbody.velocity = flyDirection * _speed;
9    }
10    
11    private void OnTriggerEnter2D(Collider2D other)
12    {
13        Destroy(gameObject);
14    }
15}

The enemy will also look quite simple:

public class Enemy : MonoBehaviour
2{
3    [SerializeField] private FireBall _fireBall;
4    [SerializeField] private Transform _castPoint;
5    
6    public void Cast()
7    {
8        FireBall fireBall = Instantiate(_fireBall, _castPoint.position, Quaternion.identity);
9        fireBall.FlyInDirection(transform.right);
10    }
11}

You can call the

Cast
method from any point in your project or animation events. We have the following flow: the enemy created a fireball and launched it in the direction of his gaze. The fireball is destroyed by reaching the first obstacle on the way.

This approach seems optimal, but it is one of the worst options. Let's analyze everything point by point:

1. Inefficient use of the CPU and memory of the device.

Instantiate
and
Destroy
are expensive operations: calling them every few seconds causes delays and lags. This is especially noticeable when working with gadgets like smartphones, where saving every byte of memory is necessary. You can open the Unity Profiler and type Instantiate in the search field of the Hierarchy window to see how "painful" these operations are for the game. There will be the following:

Sure, I’ve increased the number of fireballs created by each enemy at a time to 500 to maximize the dramatic effect. However, it's easy to imagine projects with even more constantly created and deleted objects — especially when dealing with UI elements or particles.

These processes occur constantly in runtime. The player may experience a noticeable drop in frames due to the constant memory allocation after you spawn hundreds of objects on the stage.

2. Everything you've created must be destroyed and cleaned. The

Destroy
method removes the game object from the scene, sending it to the garbage collector. You have no control over when or how the collector processes it. By the way,
Destroy
is very sneaky. It deletes the game object itself, but its components can continue to live separately. This may happen if another object is linked to this component. For example, it may be a subscription to a certain event.

3. Code control. Different classes are responsible for creating and destroying an object, and dozens of them can be in the project. Finding what and where is created or deleted isn't a trivial task sometimes — and I'm silent about controlling objects in the hierarchy now.

Let's integrate ObjectPool into the project!

After defining the problem, let's move on to its solution. As I mentioned earlier, the principle of the ObjectPool pattern operation is simple: after finishing work with the object, it is not deleted but hides in the "pool." The object can be retrieved and reused from it:

One entity will be responsible for creating, reusing, and destroying an object — we'll call it

ObjectPool
. We can make it look like this to work with a fireball:

public class ObjectPool : MonoBehaviour
2{
3    [SerializeField] private FireBall _fireBallPrefab;
4    
5    private readonly List<FireBall> _freeFireBalls = new List<FireBall>();
6    
7    public FireBall GetFireBall()
8    {
9        FireBall fireBall;
10        if (_freeFireBalls.Count > 0)
11        {
12            fireBall = _freeFireBalls[0];
13            _freeFireBalls.Remove(fireBall);
14        }
15        else
16        {
17            fireBall = Instantiate(_fireBallPrefab, transform);
18        }
19        return fireBall;
20    }
21    
22    private void ReturnFireBall(FireBall fireBall)
23    {
24        _freeFireBalls.Add(fireBall);
25    }
26}

The

_freeFireBalls
list appears in this code. We will store the created fireballs that have done their work there. The enemy will now look like this:

public class Enemy : MonoBehaviour
2{
3    [SerializeField] private ObjectPool _objectPool;
4    [SerializeField] private Transform _castPoint;
5    
6    public void Cast()
7    {
8        FireBall fireBall = _objectPool.GetFireBall();
9        fireBall.transform.position = _castPoint.position;
10        fireBall.FlyInDirection(transform.right);
11    }
12}

The root of our problem: how can we return the fireball to the pool? We can't rely on the enemy; he does not know when the fireball will be destroyed. We also don't want to give fireball knowledge about

ObjectPool
, because it will create unnecessary connections.

I hope you've noticed that I made the

ReturnFireBall
method private. Therefore, we will use one of the basic C# patterns — observer, and its implementation — events. The fireball will look like this now:

public class FireBall : MonoBehaviour
2{
3    [SerializeField] private Rigidbody2D _rigidbody;
4    [SerializeField] private float _speed;
5    
6    public event Action<FireBall> Destroyed;
7    
8    public void FlyInDirection(Vector2 flyDirection)
9    {
10        _rigidbody.velocity = flyDirection * _speed;
11    }
12    
13    private void OnTriggerEnter2D(Collider2D other)
14    {
15        Destroyed?.Invoke(this);
16    }
17}

ObjectPool
will subscribe to the
Destroyed
event after passing the object to the world:

public class ObjectPool : MonoBehaviour
2{
3    [SerializeField] private FireBall _fireBallPrefab;
4    private readonly List<FireBall> _freeFireBalls = new List<FireBall>();
5    
6    public FireBall GetFireBall()
7    {
8        FireBall fireBall;
9        if (_freeFireBalls.Count > 0)
10        {
11            fireBall = _freeFireBalls[0];
12            _freeFireBalls.Remove(fireBall);
13        }
14        else
15        {
16            fireBall = Instantiate(_fireBallPrefab, transform);
17        }
18        fireBall.Destroyed += ReturnFireBall;
19        return fireBall;
20    }
21    
22    private void ReturnFireBall(FireBall fireBall)
23    {
24        fireBall.Destroyed -= ReturnFireBall;
25        _freeFireBalls.Add(fireBall);
26    }
27}

It remains to hang the Object Pool on the object with the

Enemy
component — congratulations! The garbage collector will rest until you move to another scene or close the game. We have ObjectPool in its basic implementation, but we can improve it.

Interfaces and generics: the perfect match to ObjectPool

The

FireBall
isn't the only object type we'll run through the object pool. You must write a separate
ObjectPool
for each to work with other types. This will expand the code base and make the code less readable. So, let's use imitations and generics.

Each object we run through the

ObjectPool
must be bound to a specific type. They must be processed independently of the particular implementation. Keeping the basic inheritance hierarchy of the objects that the pool will process is essential. They will inherit from
MonoBehaviour
, at least. Let's use the IPoolable interface:

public interface IPoolable
2{
3    GameObject GameObject { get; }
4    event Action<IPoolable> Destroyed;
5    void Reset();
6}

We inherit

FireBall
from it:

public class FireBall : MonoBehaviour, IPoolable
2{
3    [SerializeField] private Rigidbody2D _rigidbody;
4    [SerializeField] private float _speed;
5    public GameObject GameObject => gameObject;
6    
7    public event Action<IPoolable> Destroyed;
8    
9    private void OnTriggerEnter2D(Collider2D other)
10    {
11        Reset();
12    }
13    public void Reset()
14    {
15        Destroyed?.Invoke(this);
16    }
17    
18    public void FlyInDirection(Vector2 flyDirection)
19    {
20        _rigidbody.velocity = flyDirection * _speed;
21    }
22}

The last task is to teach

ObjectPool
to work with any objects. IPoolable will not be enough because Instantiate can only create a copy of the object with the
gameObject
property. We will use
Component
; each such object inherits from this class.
MonoBehaviour
is also inherited from it. As a result, we will get the following
ObjectPool
:

public class ObjectPool<T> where T : Component, IPoolable 
2{
3    private readonly List<IPoolable> _freeObjects;
4    private readonly Transform _container;
5    private readonly T _prefab;
6    
7    public ObjectPool(T prefab)
8    {
9        _freeObjects = new List<IPoolable>();
10        _container = new GameObject().transform;
11        _container.name = prefab.GameObject.name;
12        _prefab = prefab;
13    }
14    
15    public IPoolable GetFreeObject()
16    {
17        IPoolable poolable;
18        if (_freeObjects.Count > 0)
19        {
20            poolable = _freeObjects[0] as T;
21            _freeObjects.RemoveAt(0);
22        }
23        else
24        {
25            poolable = Object.Instantiate(_prefab, _container);
26        }
27        poolable.GameObject.SetActive(true);
28        poolable.Destroyed += ReturnToPool;
29        return poolable;
30    }
31    
32    private void ReturnToPool(IPoolable poolable)
33    {
34        _freeObjects.Add(poolable);
35        poolable.Destroyed -= ReturnToPool;
36        poolable.GameObject.SetActive(false);
37        poolable.GameObject.transform.SetParent(_container);
38    }
39}

Now we can create an

ObjectPool
for any object that fulfills the conditions of inheritance from
Component
and IPoolable. I removed the
ObjectPool
inheritance from
MonoBehaviour
, thus reducing the number of components we will hang on objects.

There is a problem that needs to be solved. Several enemies can be on the stage at the same time, and each of them spawns the same fireballs. It would be great if they all went through the same

ObjectPool
— there can never be too much resource savings! Having one class ask for a specific object type is also quite convenient. Such a class will take over the object's generation, control, and eventual disposal. This option is the best choice as managing every
ObjectPool
individually in the project is challenging.

To complete the basic requirements of

ObjectPool
, we need to generate a set number of objects when the scene loads. To avoid any resource consumption issues, we will include a loading window. Let's start implementing this now.

We will create an additional entity

PoolTask
in the final implementation of
ObjectPool
. This class will control work with objects created from one prefab:

public class PoolTask
2{
3    private readonly List<IPoolable> _freeObjects;
4    private readonly List<IPoolable> _objectsInUse;
5    private readonly Transform _container;
6    
7    public PoolTask(Transform container)
8    {
9        _container = container;
10        _objectsInUse = new List<IPoolable>();
11        _freeObjects = new List<IPoolable>();
12    }
13    
14    public void CreateFreeObjects<T>(T prefab, int count) where T : Component, IPoolable
15    {
16        for (var i = 0; i < count; i++)
17        {
18            var poolable = Object.Instantiate(prefab, _container);
19            _freeObjects.Add(poolable);
20        }
21    }
22    
23    public T GetFreeObject<T>(T prefab) where T : Component, IPoolable
24    {
25        T poolable;
26        if (_freeObjects.Count > 0)
27        {
28            poolable = _freeObjects[0] as T;
29            _freeObjects.RemoveAt(0);
30        }
31        else
32        {
33            poolable = Object.Instantiate(prefab, _container);
34        }
35        poolable.Destroyed += ReturnToPool;
36        poolable.GameObject.SetActive(true);
37        _objectsInUse.Add(poolable);
38        return poolable;
39    }
40    
41    public void ReturnAllObjectsToPool()
42    {
43        foreach (var poolable in _objectsInUse)
44            poolable.Reset();
45    }
46    
47    public void Dispose()
48    {
49        foreach (var poolable in _objectsInUse)
50            Object.Destroy(poolable.GameObject);
51        
52        foreach (var poolable in _freeObjects)
53            Object.Destroy(poolable.GameObject);
54    }
55    
56    private void ReturnToPool(IPoolable poolable)
57    {
58        _objectsInUse.Remove(poolable);
59        _freeObjects.Add(poolable);
60        poolable.Destroyed -= ReturnToPool;
61        poolable.GameObject.SetActive(false);
62        poolable.GameObject.transform.SetParent(_container);
63    }
64}

PoolTask will have additional features:

1. Tracking the objects we have released into the world so that, if necessary, they can be destroyed or returned to the pool at a specific moment;

2. Generating a predetermined quantity of free objects.

Finally, let's create an ObjectPool that will meet all our needs and completely take over the control and generation of objects:

public class ObjectPool
2{
3    private static ObjectPool _instance;
4    public static ObjectPool Instance => _instance ??= new ObjectPool();
5    
6    private readonly Dictionary<Component, PoolTask> _activePoolTasks;
7    private readonly Transform _container;
8   
9    private ObjectPool()
10    {
11        _activePoolTasks = new Dictionary<Component, PoolTask>();
12        _container = new GameObject().transform;
13        _container.name = nameof(ObjectPool);
14    }
15    
16    public void CreateFreeObjects<T>(T prefab, int count) where T : Component, IPoolable
17    {
18        if(!_activePoolTasks.TryGetValue(prefab, out var poolTask))
19            AddTaskToPool(prefab, out poolTask);
20        
21        poolTask.CreateFreeObjects(prefab, count);
22    }
23    
24    public T GetObject<T>(T prefab) where T : Component, IPoolable
25    {
26        if(!_activePoolTasks.TryGetValue(prefab, out var poolTask))
27            AddTaskToPool(prefab, out poolTask);
28        return poolTask.GetFreeObject(prefab);
29    }
30    
31    public void Dispose()
32    {
33        foreach (var poolTask in _activePoolTasks.Values)
34            poolTask.Dispose();
35    }
36    
37    private void AddTaskToPool<T>(T prefab, out PoolTask poolTask) where T : Component, IPoolable
38    {
39        var taskContainer = new GameObject
40        {
41            name = $"{prefab.name}_pool",
42            transform =
43            {
44                parent = _container
45            }
46        };
47        poolTask = new PoolTask(taskContainer.transform);
48        _activePoolTasks.Add(prefab, poolTask);
49    }
50}

The use of Singleton may catch your eye — it is only an example. You can customize the usage in your project — it can be running

ObjectPool
through constructors or injection through Zenject.

We have the final version of our

Cast
method in
Enemy
:

public class Enemy : MonoBehaviour
2{
3    [SerializeField] private FireBall _prefab;
4    [SerializeField] private Transform _castPoint;
5    
6    public void Cast()
7    {
8        FireBall fireBall = ObjectPool.Instance.GetObject(_prefab);
9        fireBall.transform.position = _castPoint.position;
10        fireBall.FlyInDirection(transform.right);
11    }
12}

We get an object of the type we need for processing immediately because of using generic.

ObjectPool
will group internal tasks according to the prefab — if there are several with the
FireBall
component, the pool will process them correctly and give you the right one. This approach will help generate any object for the game scene.

However, be careful when working with UI elements: when moving an object between parent transforms with different

localScale
, the
localScale
of the object itself will change. If you have an adaptive UI in your project, transforms with the canvas component will change their
localScale
depending on the extension. I advise you to do this simple operation:

poolable.GameObject.transform.localScale = Vector2.one;

You can use 3 scripts in other options:

ObjectPool
,
PoolTask
, and
IPoolable
. So feel free to add them to your project and use the Object Pool pattern for 100%!