Voltar para o Blog
Quest Log

Object Pooling na Unity com o ObjectPool Nativo

Projéteis brilhantes saindo de uma nave e voltando para uma caixa de reserva em loop

Unity object pooling com UnityEngine.Pool.ObjectPool: troque Instantiate e Destroy de balas, configure os 4 callbacks, meça no Profiler e evite o release duplo.

Ferramenta:UnityC#

Object Pooling na Unity com o ObjectPool Nativo

Object pooling na Unity é reaproveitar objetos em vez de criar e destruir: a bala que acertou a parede volta para um estoque desligada e é religada no próximo tiro. A Unity já traz isso pronto em UnityEngine.Pool.ObjectPool<T>, e você não precisa escrever a fila na mão. Este tutorial troca o Instantiate/Destroy de um sistema de tiro pelo pool nativo, explica os quatro callbacks, mostra como medir a diferença no Profiler e corrige o bug clássico de devolver a mesma bala duas vezes.

O código é do Unity 6.3 LTS. Se você quer a teoria do pooling independente de engine, com exemplo no Godot, o post sobre object pooling e otimização cobre a ideia geral; aqui o foco é a API da Unity.

Por que Instantiate e Destroy pesam

Cada Instantiate de um prefab faz bastante coisa: copia a hierarquia de objetos, cria cada componente, aloca memória gerenciada para os scripts, registra colliders na física e chama Awake e OnEnable. Cada Destroy desfaz tudo, e a memória gerenciada fica para o coletor de lixo (GC) recolher depois.

Uma vez ou outra, isso é imperceptível. O problema é frequência. Uma arma automática que dispara dez balas por segundo, com cada bala vivendo dois segundos, cria e destrói vinte objetos a cada dois segundos só para um jogador. Num jogo de nave com inimigos atirando, multiplique. O sintoma típico não é FPS baixo constante, é soluço: o jogo roda liso e trava por um instante quando o GC decide limpar o que acumulou.

Pooling ataca as duas pontas. Os objetos são criados uma vez (ou sob demanda, até um limite) e depois só ligados e desligados com SetActive, o que não aloca memória nova. É uma das técnicas do processo de otimização de performance para 60 FPS, e uma das poucas que dá para aplicar sem mudar o design do jogo.

Object pooling na Unity com ObjectPool nativo

O namespace UnityEngine.Pool tem:

  • ObjectPool<T>: pool baseado numa pilha, o que você vai usar na maioria dos casos.
  • LinkedPool<T>: mesma ideia com lista ligada, útil quando o tamanho varia muito.
  • ListPool<T>, HashSetPool<T>, DictionaryPool<TKey, TValue>, GenericPool<T>: pools de coleções e objetos C# comuns, para evitar alocar listas temporárias.

O construtor do ObjectPool<T> recebe sete parâmetros. Segundo a referência de script do Unity 6.3:

  • createFunc: cria uma instância nova quando o estoque está vazio.
  • actionOnGet: roda quando um objeto sai do estoque.
  • actionOnRelease: roda quando um objeto volta ao estoque.
  • actionOnDestroy: roda quando o pool descarta um objeto.
  • collectionCheck: verifica se o objeto devolvido já está no estoque (pega release duplo).
  • defaultCapacity: capacidade inicial da coleção interna.
  • maxSize: limite do estoque. Devolver com o estoque cheio faz o pool destruir o objeto em vez de guardar.

E quatro informações úteis em tempo de execução: CountActive (em uso), CountInactive (no estoque), CountAll (soma) e os métodos Get, Release e Clear.

Os callbacks: create, get, release e destroy

Cada callback tem uma responsabilidade clara, e misturar essas responsabilidades é a origem da maioria dos bugs:

  • create: só instanciar e ligar o objeto ao pool. Não posiciona, não ativa.
  • get: ativar o objeto (SetActive(true)). A posição e a direção ficam com quem pediu a bala, logo depois do Get.
  • release: desativar (SetActive(false)). É o lugar de garantir que nada continue rodando.
  • destroy: Destroy(obj.gameObject). Roda quando o estoque está cheio ou quando você chama Clear.

O reset do estado (velocidade zerada, timer de vida, rastro limpo) pode ficar no get ou no próprio objeto, num método chamado ao ser lançado. Eu prefiro no objeto, porque quem conhece o estado da bala é a bala.

Exemplo: pool de balas

Arquivo Bullet.cs. A bala conhece o pool de onde veio e se devolve:

using UnityEngine;
using UnityEngine.Pool;

[RequireComponent(typeof(Rigidbody2D))]
public class Bullet : MonoBehaviour
{
    [SerializeField] private float speed = 18f;
    [SerializeField] private float lifetime = 2f;
    [SerializeField] private TrailRenderer trail;

    private Rigidbody2D rb;
    private IObjectPool<Bullet> pool;
    private float timeLeft;
    private bool released;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
    }

    public void SetPool(IObjectPool<Bullet> owner)
    {
        pool = owner;
    }

    public void Launch(Vector2 position, Vector2 direction)
    {
        released = false;
        timeLeft = lifetime;
        transform.position = position;
        rb.position = position;
        rb.linearVelocity = direction.normalized * speed;
        rb.angularVelocity = 0f;

        if (trail != null)
        {
            trail.Clear();
        }
    }

    private void Update()
    {
        timeLeft -= Time.deltaTime;
        if (timeLeft <= 0f)
        {
            ReturnToPool();
        }
    }

    private void OnTriggerEnter2D(Collider2D other)
    {
        ReturnToPool();
    }

    private void ReturnToPool()
    {
        if (released)
        {
            return;
        }

        released = true;
        rb.linearVelocity = Vector2.zero;

        if (pool != null)
        {
            pool.Release(this);
        }
        else
        {
            Destroy(gameObject);
        }
    }
}

Arquivo Gun.cs, que cria o pool e atira:

using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Pool;

public class Gun : MonoBehaviour
{
    [SerializeField] private Bullet bulletPrefab;
    [SerializeField] private Transform muzzle;
    [SerializeField] private float fireInterval = 0.1f;
    [SerializeField] private int defaultCapacity = 30;
    [SerializeField] private int maxSize = 100;

    private ObjectPool<Bullet> pool;
    private InputAction attackAction;
    private float nextFireTime;

    private void Awake()
    {
        pool = new ObjectPool<Bullet>(
            createFunc: CreateBullet,
            actionOnGet: bullet => bullet.gameObject.SetActive(true),
            actionOnRelease: bullet => bullet.gameObject.SetActive(false),
            actionOnDestroy: bullet => Destroy(bullet.gameObject),
            collectionCheck: true,
            defaultCapacity: defaultCapacity,
            maxSize: maxSize);

        attackAction = InputSystem.actions.FindAction("Player/Attack");
    }

    private Bullet CreateBullet()
    {
        Bullet bullet = Instantiate(bulletPrefab);
        bullet.SetPool(pool);
        bullet.gameObject.SetActive(false);
        return bullet;
    }

    private void Update()
    {
        if (attackAction.IsPressed() && Time.time >= nextFireTime)
        {
            nextFireTime = Time.time + fireInterval;
            Bullet bullet = pool.Get();
            bullet.Launch(muzzle.position, muzzle.right);
        }
    }

    private void OnDestroy()
    {
        pool?.Clear();
    }
}

Pontos que valem explicação:

  • O prefab da bala precisa de um Rigidbody2D com Gravity Scale 0 e um collider marcado como Is Trigger.
  • Player/Attack é uma ação que já vem no asset de input padrão dos projetos novos do Unity 6.
  • O Get ativa a bala e o Launch posiciona e dá velocidade, nessa ordem. Se você ativar depois de posicionar, ela aparece por um frame na posição onde morreu.
  • maxSize em 100 significa que, se em algum momento houver mais de 100 balas no estoque, as excedentes são destruídas ao voltar. As balas em uso não contam para esse limite, só as guardadas.
  • O pool começa vazio: o defaultCapacity reserva espaço na coleção, não cria objetos. Se quiser evitar o custo de criação no primeiro tiro, pré-aqueça no Start com um laço que faz Get de N balas e depois Release de todas.

O pool resolve uma parte da performance. Num jogo completo, você vai cruzar isso com física, animação, UI e áudio, e decidir onde vale otimizar e onde não. Essa visão de conjunto, saber o que medir e o que deixar quieto num projeto inteiro, é o que mais falta para quem aprende técnica por técnica sem um jogo de verdade para amarrar tudo.

Curso de Unity
Quer fazer seu primeiro jogo na Unity?

No curso de Unity 6 da CursoGame.Dev você vai do zero ao primeiro jogo, com um desafio prático em cada level e suporte individual no WhatsApp quando travar.

Conhecer o curso de Unity
Suporte no WhatsAppAcesso vitalícioGarantia de 7 dias

Medindo no Profiler: GC Alloc antes e depois

Não confie em sensação: meça. Abra Window > Analysis > Profiler, deixe o módulo CPU Usage selecionado e entre em Play Mode segurando o tiro.

  1. No painel de baixo, troque a visualização da timeline para Hierarchy.
  2. Clique num frame em que houve disparo.
  3. Ordene pela coluna GC Alloc. Ela mostra quanta memória gerenciada cada chamada alocou naquele frame.

Faça o teste duas vezes: uma com a versão antiga (Instantiate e Destroy direto) e outra com o pool. Na versão antiga, os frames de disparo mostram alocação vinda da criação do objeto e dos componentes. Na versão com pool, depois que o estoque encheu, os frames de disparo devem mostrar zero ou quase zero de GC Alloc nessa linha. O número exato depende do seu prefab (quantos componentes, se tem script com listas), por isso o que importa é comparar o seu antes e depois, não um valor de referência.

Para ver o efeito no tempo de frame, olhe também a coluna Time ms e os picos no gráfico do topo. O soluço que some quando você troca para pool é o sinal de que o GC estava trabalhando por causa das balas. Se o Profiler ainda é território desconhecido, o post sobre o Profiler da Unity mostra os módulos e como ler cada um.

Dica de método: meça numa build de desenvolvimento, não só no editor. O editor tem alocações próprias que poluem a leitura. Marque Development Build e Autoconnect Profiler nas opções de build e o Profiler conecta ao jogo rodando.

Erros comuns: release duplo e objeto com estado antigo

Release duplo. A bala bate num inimigo (OnTriggerEnter2D) e, no mesmo frame, o tempo de vida acaba (Update). Os dois chamam Release. Com collectionCheck ligado, o pool lança uma exceção avisando que o objeto já foi devolvido. Com ele desligado, a mesma bala entra duas vezes no estoque e, mais tarde, dois Get devolvem o mesmo objeto: dois tiros viram uma bala só, que pula de um lugar para outro. A flag released no Bullet existe exatamente para isso. Deixe collectionCheck ligado enquanto desenvolve.

Estado antigo. A bala volta do estoque ainda com a velocidade de antes, com o rastro desenhando uma linha do lugar onde morreu até a arma, ou com um timer zerado que a devolve no primeiro frame. Tudo que a bala carrega precisa ser redefinido no Launch: posição, velocidade, timer, rastro, cor, dano.

Coroutine rodando depois do release. Se a bala tem uma coroutine (piscar antes de sumir, por exemplo), SetActive(false) interrompe as coroutines desse objeto. Quando ela voltar, nada é retomado sozinho: reinicie o que precisar no Launch.

Pool destruído com balas em uso. Se a arma é destruída, as balas em voo ainda apontam para um pool que não existe mais. Nesse exemplo, o Clear destrói só as balas do estoque; as que estão em voo terminam e chamam Release num pool sem dono. Para armas que podem sumir no meio da partida, mantenha o pool num objeto que vive mais (um gerenciador de projéteis da cena).

Quando NÃO precisa de pooling

  • Objetos raros. Baú, porta, chefe da fase. Instanciar uma vez não aparece no Profiler.
  • Objetos que vivem a fase inteira. Se nascem no início e morrem no fim, o pooling não economiza nada.
  • Antes de medir. Pooling acrescenta complexidade (estado para resetar, bugs de release). Se o Profiler não mostra pico ligado à criação de objetos, gaste o tempo com outra coisa.
  • Partículas do Particle System. O sistema de partículas já reaproveita as partículas internamente. O que você pode colocar num pool é o GameObject do efeito inteiro (a explosão), se ele for criado com frequência.

Próximo passo

Pegue o objeto que mais nasce e morre no seu jogo, troque por um ObjectPool<T> usando a estrutura deste post e faça a medição no Profiler antes e depois, de preferência numa Development Build. Anote o GC Alloc dos frames de disparo nos dois casos: esse número é o argumento para aplicar, ou não, a mesma técnica em inimigos e efeitos.

Perguntas frequentes

A Unity tem object pooling nativo?

Tem. O namespace UnityEngine.Pool traz ObjectPool<T>, LinkedPool<T> e pools de coleções como ListPool<T>. O ObjectPool<T> recebe funções para criar, pegar, devolver e destruir objetos e cuida do estoque para você, sem precisar escrever a estrutura do zero.

Object pooling é sempre melhor que Instantiate?

Não. Pooling compensa quando o mesmo tipo de objeto nasce e morre muitas vezes por segundo, como balas, partículas e inimigos de horda. Para um baú que aparece uma vez por fase, Instantiate é mais simples e o ganho do pool é invisível.

O que faz o parâmetro collectionCheck do ObjectPool?

Quando ligado, o pool verifica se o objeto devolvido já está no estoque e lança uma exceção se estiver. Isso pega o bug de devolver o mesmo objeto duas vezes. A verificação tem custo, então é comum deixar ligada durante o desenvolvimento.

Qual a diferença entre defaultCapacity e maxSize?

defaultCapacity é o tamanho inicial da coleção interna que guarda os objetos inativos. maxSize é o limite de objetos guardados no estoque: se você devolver um objeto com o pool cheio, ele é destruído pelo callback de destroy em vez de ser guardado.