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

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.
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.
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.
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).
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.
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.
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.


