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.
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.
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.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}
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%!