paint-brush
Como aprimorar seu projeto de jogo usando o padrão ObjectPool? (Um Guia de Unidade)por@jokeresdeu
906 leituras
906 leituras

Como aprimorar seu projeto de jogo usando o padrão ObjectPool? (Um Guia de Unidade)

por Les Dibrivniy16m2023/05/10
Read on Terminal Reader

Muito longo; Para ler

O Object Pool é a base de 99% da otimização dos projetos de jogos. Baseia-se em um princípio elementar: após completar sua tarefa, o objeto não é excluído, mas movido para um ambiente separado e pode ser recuperado e reutilizado. Les Dibrivniy explica por que esse padrão é essencial.
featured image - Como aprimorar seu projeto de jogo usando o padrão ObjectPool? (Um Guia de Unidade)
Les Dibrivniy HackerNoon profile picture
0-item
1-item

Olá, meu nome é Oles Dibrivniy e sou desenvolvedor Unity na Keiki . Criamos produtos na interseção de EdTech e GameDev — aplicativos e produtos da web para o desenvolvimento infantil. Agora nossos produtos têm mais de quatro milhões de usuários.

Se alguém lhe perguntar durante uma entrevista de emprego sobre os padrões de programação que você usa no desenvolvimento de jogos, uma das primeiras coisas que você deve mencionar é o ObjectPool. Baseia-se em um princípio elementar: após completar sua tarefa, o objeto não é excluído, mas movido para um ambiente separado e pode ser recuperado e reutilizado.

Esse padrão afeta diretamente a percepção do usuário sobre o aplicativo, por isso é crucial. Deve ser a base de 99% da otimização dos projetos de jogos.

Este artigo pode não ser relevante para gurus de programação, mas vice-versa para iniciantes. Aqui usarei exemplos para explicar por que esse padrão é essencial.


Como as coisas vão sem o ObjectPool?

Primeiramente, analisaremos o caso do projeto sem ObjectPool, começando pela configuração. Temos uma cena relativamente simples com o protagonista e dois inimigos jogando bolas de fogo:

Vamos considerar como será a própria bola de fogo. Os campos

 Rigidbody2D
e
 _speed
será responsável pelo movimento das bolas de fogo; o
 OnTriggerEnter2D
funcionará após uma colisão — o objeto com o componente FireBall será destruído:

 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 }

O inimigo também parecerá bastante simples:

 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 }

Você pode ligar para o

 Cast
método de qualquer ponto em seu projeto ou eventos de animação. Temos o seguinte fluxo: o inimigo criou uma bola de fogo e a lançou na direção de seu olhar. A bola de fogo é destruída ao atingir o primeiro obstáculo no caminho.

Essa abordagem parece ótima, mas é uma das piores opções. Vamos analisar tudo ponto a ponto:

1. Uso ineficiente da CPU e da memória do dispositivo.

 Instantiate
e
 Destroy
são operações caras: chamá-las a cada poucos segundos causa atrasos e atrasos. Isso é especialmente perceptível ao trabalhar com gadgets como smartphones, onde é necessário salvar cada byte de memória. Você pode abrir o Unity Profiler e digitar Instantiate no campo de pesquisa da janela Hierarchy para ver como essas operações são "dolorosas" para o jogo. Haverá o seguinte:

Claro, eu aumentei o número de bolas de fogo criadas por cada inimigo por vez para 500 para maximizar o efeito dramático. No entanto, é fácil imaginar projetos com objetos criados e excluídos ainda mais constantemente — especialmente ao lidar com elementos ou partículas de interface do usuário.

Esses processos ocorrem constantemente em tempo de execução. O jogador pode experimentar uma queda perceptível nos quadros devido à alocação de memória constante depois de gerar centenas de objetos no palco.

2. Tudo o que você criou deve ser destruído e limpo. O

 Destroy
O método remove o objeto do jogo da cena, enviando-o para o coletor de lixo. Você não tem controle sobre quando ou como o coletor o processa. Por falar nisso,
 Destroy
é muito sorrateiro. Ele exclui o próprio objeto do jogo, mas seus componentes podem continuar a viver separadamente. Isso pode acontecer se outro objeto estiver vinculado a este componente. Por exemplo, pode ser uma assinatura para um determinado evento.

3. Controle de código. Diferentes classes são responsáveis por criar e destruir um objeto, e dezenas delas podem estar no projeto. Descobrir o que e onde foi criado ou excluído às vezes não é uma tarefa trivial — e não falo sobre como controlar objetos na hierarquia agora.

Vamos integrar o ObjectPool ao projeto!

Definido o problema, passemos à sua solução. Como mencionei anteriormente, o princípio da operação do padrão ObjectPool é simples: depois de terminar o trabalho com o objeto, ele não é excluído, mas se esconde no "pool". O objeto pode ser recuperado e reutilizado a partir dele:

Uma entidade será responsável por criar, reutilizar e destruir um objeto — vamos chamá-lo de

 ObjectPool
. Podemos fazer com que pareça assim para trabalhar com uma bola de fogo:

 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 }

O

 _freeFireBalls
lista aparece neste código. Vamos armazenar as bolas de fogo criadas que fizeram seu trabalho lá. O inimigo agora ficará assim:

 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 }

A raiz do nosso problema: como podemos devolver a bola de fogo à piscina? Não podemos confiar no inimigo; ele não sabe quando a bola de fogo será destruída. Também não queremos dar conhecimento de bola de fogo sobre

 ObjectPool
, porque criará conexões desnecessárias.

Espero que você tenha notado que eu fiz o

 ReturnFireBall
método privado. Portanto, usaremos um dos padrões básicos do C# — observer e sua implementação — events . A bola de fogo ficará assim agora:

 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
vai se inscrever no
 Destroyed
evento depois de passar o objeto para o mundo:

 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 }

Resta pendurar o Object Pool no objeto com o

 Enemy
componente — parabéns! O coletor de lixo ficará parado até você passar para outra cena ou fechar o jogo. Temos o ObjectPool em sua implementação básica, mas podemos melhorá-lo.

Interfaces e genéricos: a combinação perfeita para ObjectPool

O

 FireBall
não é o único tipo de objeto que executaremos no pool de objetos. Você deve escrever um separado
 ObjectPool
para cada um trabalhar com outros tipos. Isso expandirá a base de código e tornará o código menos legível. Então, vamos usar imitações e genéricos.

Cada objeto que percorremos o

 ObjectPool
deve estar vinculado a um tipo específico. Eles devem ser processados independentemente da implementação específica. Manter a hierarquia básica de herança dos objetos que o pool processará é essencial. Eles herdarão de
 MonoBehaviour
, pelo menos. Vamos usar a interface IPoolable:

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

nós herdamos

 FireBall
a partir dele:

 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 }

A última tarefa é ensinar

 ObjectPool
para trabalhar com quaisquer objetos. IPoolable não será suficiente porque o Instantiate só pode criar uma cópia do objeto com o
 gameObject
propriedade. Nós vamos usar
 Component
; cada um desses objetos herda dessa classe.
 MonoBehaviour
também é herdado dele. Como resultado, obteremos o seguinte
 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 }

Agora podemos criar um

 ObjectPool
para qualquer objeto que preencha as condições de herança de
 Component
e IPoolable. eu removi o
 ObjectPool
herança de
 MonoBehaviour
, reduzindo assim o número de componentes que penduraremos nos objetos.

Há um problema que precisa ser resolvido. Vários inimigos podem estar no palco ao mesmo tempo, e cada um deles gera as mesmas bolas de fogo. Seria ótimo se todos passassem pelo mesmo

 ObjectPool
— nunca é demais economizar recursos! Ter uma classe solicitando um tipo de objeto específico também é bastante conveniente. Essa classe assumirá a geração, o controle e o eventual descarte do objeto. Esta opção é a melhor escolha para gerenciar cada
 ObjectPool
individualmente no projeto é desafiador.

Para completar os requisitos básicos de

 ObjectPool
, precisamos gerar um número definido de objetos quando a cena for carregada. Para evitar problemas de consumo de recursos, incluiremos uma janela de carregamento. Vamos começar a implementar isso agora.

Vamos criar uma entidade adicional

 PoolTask
na implementação final de
 ObjectPool
. Esta classe controlará o trabalho com objetos criados a partir de um 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 terá recursos adicionais:

1. Rastrear os objetos que lançamos no mundo para que, se necessário, possam ser destruídos ou devolvidos à piscina em um momento específico;

2. Gerando uma quantidade predeterminada de objetos livres.

Por fim, vamos criar um ObjectPool que atenda todas as nossas necessidades e assuma completamente o controle e geração de objetos:

 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 }

O uso de Singleton pode chamar sua atenção - é apenas um exemplo. Você pode personalizar o uso em seu projeto — pode estar em execução

 ObjectPool
através de construtores ou injeção através do Zenject.

Temos a versão final do nosso

 Cast
método em
 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 }

Obtemos um objeto do tipo que precisamos para processar imediatamente devido ao uso de genérico.

 ObjectPool
irá agrupar tarefas internas de acordo com o prefab — se houver vários com o mesmo
 FireBall
componente, o pool irá processá-los corretamente e fornecer o correto. Essa abordagem ajudará a gerar qualquer objeto para a cena do jogo.

No entanto, tenha cuidado ao trabalhar com elementos de interface do usuário: ao mover um objeto entre transformações pai com diferentes

 localScale
, o
 localScale
do próprio objeto mudará. Se você tiver uma IU adaptável em seu projeto, as transformações com o componente de tela mudarão
 localScale
dependendo da extensão. Eu aconselho você a fazer esta operação simples:

 poolable.GameObject.transform.localScale = Vector2. on e ;

Você pode usar 3 scripts em outras opções:

 ObjectPool
,
 PoolTask
, e
 IPoolable
. Portanto, sinta-se à vontade para adicioná-los ao seu projeto e usar o padrão Object Pool em 100%!