Voltar para o Blog
Quest Log

Object Pooling: Reaproveite Objetos e Ganhe Performance

Projéteis de um jogo 2D sendo reciclados em um pool de objetos reutilizáveis

Object pooling no seu jogo: aprenda a reaproveitar projéteis e inimigos com pool em GDScript no Godot 4 e pare de pagar o custo de instanciar sem parar.

Ferramenta:Godot

Object Pooling: Como Reaproveitar Objetos e Ganhar Performance no Seu Jogo

Seu jogo roda liso até o jogador segurar o botão de tiro. Aí o frame time dá um soluço, o movimento engasga e você não sabe por quê. Em boa parte dos casos, o culpado é o ciclo de criar e destruir objetos sem parar: cada tiro nasce com instantiate() e morre com queue_free(), dezenas de vezes por segundo. Object pooling é a técnica que resolve isso no seu jogo, e a ideia cabe numa frase: em vez de criar e destruir, você cria uma vez e reaproveita.

Neste tutorial eu monto um pool genérico em GDScript (Godot 4.x), aplico em projéteis e em inimigos, e mostro o bug clássico que pega todo mundo na primeira implementação. Também vou ser honesto sobre quando pooling não vale a pena, porque essa técnica é frequentemente aplicada onde não precisa. Um caso em que costuma valer é um enxame grande de inimigos com movimento de bando (boids), quando dezenas de agentes vivem na tela ao mesmo tempo.

O que é object pooling e quando seu jogo precisa

Criar um node no Godot não é só alocar memória. Quando você chama instantiate() e add_child(), a engine monta a sub-árvore de nodes inteira, roda o _ready() de cada um, registra shapes no servidor de física e conecta sinais. Quando você chama queue_free(), ela desmonta tudo isso na ordem inversa.

Pra um objeto de vez em quando, esse custo é irrelevante. O problema é frequência. Um jogo de nave com cadência de tiro alta cria e destrói dezenas de objetos por segundo. Um survivor-like com hordas chega às centenas. Cada criação dessas acontece no meio do frame, disputando os mesmos 16,6 ms que você tem pra renderizar a 60 FPS. O resultado típico não é FPS baixo constante: são picos de frame time, aqueles engasgos que aparecem justamente na hora mais intensa do jogo, que é quando o jogador menos perdoa. Se você ainda está montando a base pra medir e atacar esses gargalos, o guia de otimização de performance para 60 FPS cobre o processo completo; aqui o foco é uma técnica específica desse arsenal.

Object pooling inverte a lógica. Você instancia um lote de objetos uma vez, no carregamento da cena, e os mantém desligados num "estoque". Quando precisa de um projétil, pega um do estoque, liga e posiciona. Quando o projétil acerta algo ou sai da tela, em vez de destruir, você desliga e devolve pro estoque. Nada é criado nem destruído durante o gameplay. O custo de instanciar foi pago todo de uma vez, num momento em que ninguém percebe (a tela de carregamento).

Sinais de que o seu jogo é candidato:

  • Projéteis com cadência alta (o caso clássico)
  • Spawner contínuo de inimigos, moedas, drops
  • Efeitos repetitivos: número de dano flutuante, partícula de impacto, casquinha de bala
  • Plataforma mobile, onde o orçamento de CPU por frame é bem menor

Se o seu jogo cria três objetos por minuto, fecha este artigo e vai fazer outra coisa. Falo sério: pooling tem custo de complexidade, e complexidade sem retorno é dívida.

Um pool genérico em GDScript

A estrutura é um node que guarda uma cena e uma pilha de instâncias disponíveis. Dois métodos importam: acquire() entrega um objeto pronto pra uso, release() recebe de volta.

class_name ObjectPool
extends Node

@export var scene: PackedScene
@export var initial_size := 30

var _available: Array[Node2D] = []

func _ready() -> void:
    for i in initial_size:
        _available.append(_create_instance())

func acquire() -> Node2D:
    var instance: Node2D
    if _available.is_empty():
        # Estoque acabou: cresce sob demanda em vez de quebrar.
        instance = _create_instance()
    else:
        instance = _available.pop_back()
    instance.visible = true
    instance.process_mode = Node.PROCESS_MODE_INHERIT
    return instance

func release(instance: Node2D) -> void:
    instance.visible = false
    instance.process_mode = Node.PROCESS_MODE_DISABLED
    _available.append(instance)

func _create_instance() -> Node2D:
    var instance := scene.instantiate() as Node2D
    add_child(instance)
    instance.visible = false
    instance.process_mode = Node.PROCESS_MODE_DISABLED
    return instance

Três decisões aqui merecem explicação.

Desligar em vez de remover da árvore. Eu mantenho as instâncias como filhas do pool o tempo todo e uso process_mode = PROCESS_MODE_DISABLED pra pausar _process, _physics_process e input do objeto inteiro. Tirar e recolocar nodes da árvore com remove_child()/add_child() também funciona, mas reentrar na árvore tem custo próprio e complica referências. Desligar no lugar é mais simples e mais barato.

O pool cresce sob demanda. Se o estoque esvazia, acquire() cria uma instância nova em vez de retornar null. Você paga o custo de instanciar naquele frame, mas o objeto fica no pool pra sempre. Na prática o pool se ajusta sozinho ao pico real do seu jogo. A alternativa (reciclar o objeto ativo mais antigo) faz sentido em bullet hell purista, onde sumir com o tiro mais velho da tela é aceitável.

Tamanho inicial é chute educado. Comece com uma estimativa do pico (quantos projéteis existem na tela no momento mais caótico?) e deixe o crescimento sob demanda corrigir o resto. Depois dá pra logar o tamanho final do pool numa sessão de teste e ajustar o initial_size pra esse valor.

Pool de projéteis na prática

A cena do projétil é uma Area2D com sprite, shape e um notifier pra detectar saída de tela:

Bullet (Area2D)
├── Sprite2D
├── CollisionShape2D
└── VisibleOnScreenNotifier2D

O script do projétil sabe se mover, causar dano e se devolver ao pool:

extends Area2D

const SPEED = 900.0

var direction := Vector2.RIGHT
var pool: ObjectPool

@onready var shape: CollisionShape2D = $CollisionShape2D

func _physics_process(delta: float) -> void:
    position += direction * SPEED * delta

func fire(from: Vector2, dir: Vector2) -> void:
    global_position = from
    direction = dir.normalized()
    rotation = direction.angle()
    # Religa a colisão. set_deferred porque mexer em shape
    # no meio do passo de física não é permitido.
    shape.set_deferred("disabled", false)

func _on_body_entered(body: Node2D) -> void:
    if body.has_method("take_damage"):
        body.take_damage(1)
    _despawn()

func _on_visible_on_screen_notifier_2d_screen_exited() -> void:
    _despawn()

func _despawn() -> void:
    shape.set_deferred("disabled", true)
    pool.release(self)

Conecte os sinais body_entered e screen_exited pelo editor, como em qualquer Area2D.

Repare no detalhe da CollisionShape2D. O PROCESS_MODE_DISABLED pausa o processamento do node, mas a shape continua registrada no mundo da física. Se você só desligar o processamento, um projétil "guardado" no pool ainda pode ser detectado por outras áreas paradas sobre ele. Desligar a shape junto (disabled = true) tira o projétil do jogo de verdade. Esse é o tipo de bug que custa uma noite de debug quando você não sabe que existe.

Quem atira só precisa pegar do pool:

extends Node2D

@onready var bullet_pool: ObjectPool = $BulletPool

func shoot(from: Vector2, dir: Vector2) -> void:
    var bullet := bullet_pool.acquire()
    bullet.pool = bullet_pool
    bullet.fire(from, dir)

No editor, o BulletPool é um node ObjectPool com a cena do projétil arrastada no campo scene. Pronto: o jogador pode segurar o botão de tiro o quanto quiser que nenhum instantiate() roda durante o gameplay (depois que o pool atinge o tamanho de pico).

Curso de Godot
Quer fazer seu primeiro jogo na Godot?

No curso de Godot 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 Godot
Suporte no WhatsAppAcesso vitalícioGarantia de 7 dias

O bug clássico: estado sujo

Todo mundo que implementa pooling pela primeira vez esbarra nisso. Com queue_free(), cada objeto nasce zerado, porque é literalmente um objeto novo. Com pooling, o objeto que você pega do estoque viveu uma vida antes: ele carrega a velocidade, os timers, a animação e as flags da última vez que foi usado.

Os sintomas são bizarros de propósito: projétil que nasce já virado pra direção errada, inimigo que aparece com metade da vida, efeito que toca a animação a partir do meio. E o pior: só acontece depois que o pool recicla, ou seja, nunca nos primeiros segundos de teste.

A regra que evita tudo isso: todo estado mutável é resetado no momento da ativação, sem exceção. No projétil acima, o fire() cumpre esse papel: reposiciona, redefine direção e rotação, religa a colisão. Se amanhã o projétil ganhar um timer de vida útil ou um trail, o reset deles entra no fire() no mesmo commit. Tratar a função de ativação como o _ready() do objeto reciclado é o hábito que mantém o pool confiável.

Pool de inimigos: mais estado, mesma receita

Inimigo é o mesmo padrão com mais coisa pra resetar. Vida, máquina de estados, alvo, velocidade, animação: tudo precisa voltar ao zero na ativação.

extends CharacterBody2D

const MAX_HEALTH = 3

enum State { PATROL, CHASE, DEAD }

var health := MAX_HEALTH
var state := State.PATROL
var pool: ObjectPool

func reset(spawn_position: Vector2) -> void:
    global_position = spawn_position
    health = MAX_HEALTH
    state = State.PATROL
    velocity = Vector2.ZERO
    $AnimationPlayer.play("idle")
    $CollisionShape2D.set_deferred("disabled", false)

func take_damage(amount: int) -> void:
    health -= amount
    if health <= 0:
        die()

func die() -> void:
    state = State.DEAD
    $CollisionShape2D.set_deferred("disabled", true)
    pool.release(self)

O spawner chama acquire() e depois reset(posicao), nessa ordem. Posicionar antes de religar a colisão importa: se o inimigo for reativado na posição antiga e teleportado depois, ele pode colidir com algo no caminho durante um frame.

Um efeito colateral bom do pooling em inimigos: sinais conectados continuam conectados. Aquele setup de conectar body_entered, registrar no sistema de score, vincular à barra de vida? Roda uma vez na criação e nunca mais. Com instanciar/destruir, esse trabalho se repete a cada spawn.

Quando não usar pooling

Experiência real: já vi (e já escrevi) pool pra coisa que spawna uma vez por fase. Isso é otimização prematura clássica, e o preço é concreto:

  • Complexidade. Todo objeto poolado precisa do contrato de reset, e todo ponto que destruía agora precisa devolver ao pool. É mais código pra errar.
  • Memória parada. Um pool de 200 inimigos ocupa memória de 200 inimigos o tempo todo, mesmo com 3 na tela.
  • Bugs novos. Estado sujo e referência pendurada (outro sistema guardando referência pra um objeto que foi reciclado e agora é "outra coisa") não existem no mundo do queue_free().

Meu critério prático: só vale pra objetos de vida curta criados em alta frequência. Projétil, partícula de impacto, número de dano, inimigo de horda. Pra todo o resto, instantiate() e queue_free() são mais simples, e simples que funciona ganha de esperto que quebra.

Vale separar dois problemas parecidos: pooling reaproveita nós para evitar o custo de criar e destruir; quando o gargalo é desenhar milhares de cópias iguais na tela, a ferramenta é o MultiMesh, que renderiza tudo numa fração das draw calls. Grama e um enxame de moedas estáticas pedem MultiMesh, não pool.

E meça antes de decidir. Roda o jogo pelo editor, abre a aba Debugger > Monitors e acompanha Frame Time e a contagem de objetos durante o momento mais caótico do gameplay. Se o frame time fica estável no pico de spawn, você não tem o problema que pooling resolve. Se aparecem picos sincronizados com as rajadas de criação, agora você tem um caso de verdade, e dá pra comparar o antes e o depois com números em vez de impressão.

Fechando

Object pooling é uma troca consciente: você paga o custo de instanciar tudo de uma vez no carregamento, mais um pouco de complexidade de código, e em troca o gameplay fica livre de criação e destruição de objetos. Pra projétil e horda, é das otimizações com melhor retorno por esforço que existem em jogo de ação. Vale lembrar que pooling é uma otimização no nível do GDScript; quando o custo é cálculo pesado em loop, o próximo degrau é mover esse trecho para C++ com GDExtension.

O caminho que recomendo: implemente o ObjectPool genérico uma vez, aplique no sistema de tiro do seu projeto atual e compare o frame time no Monitors. Vendo a diferença (ou a ausência dela) no seu próprio jogo, você ganha o critério pra decidir onde a técnica entra no próximo. Conhecimento de otimização sem medição é só superstição com sintaxe bonita.