Voltar para o Blog
Quest Log

Como Otimizar o FPS na Godot 4 (Profiler e Boas Práticas)

Aba Debugger com o Profiler aberto na Godot 4 mostrando gargalos de performance de um jogo

Guia prático para otimizar FPS na Godot 4: medir com o Profiler, achar gargalos de CPU e GPU, object pooling, MultiMesh, culling e GDScript tipado.

Seu jogo na Godot 4 começou fluido e agora trava, engasga ou perde frames quando a cena enche. A primeira reação de quase todo mundo é sair mexendo em configuração, trocar de linguagem ou desligar efeitos no chute. Não faça isso. A parte mais importante de otimizar FPS na Godot é medir antes de mexer, porque na maioria dos casos o gargalo não é onde você acha que é. Neste guia a gente vai usar o Profiler e os Monitors da própria engine para achar o culpado, e só depois atacar os gargalos mais comuns de CPU e de render, com código GDScript real e tipado.

A ideia central é simples: otimização sem medição é achismo. Você pode passar uma tarde inteira reescrevendo um sistema que custa quase nada e ignorar a função boba que come metade do seu frame. Vamos evitar isso.

Meça antes de otimizar (Profiler, Monitors e FPS)

Antes de tocar em qualquer linha de código, você precisa saber quanto tempo cada coisa está custando. A Godot 4 traz tudo isso embutido.

O jeito mais rápido de saber o FPS atual dentro do jogo é ler direto pela engine:

func _process(_delta: float) -> void:
    var fps: float = Engine.get_frames_per_second()
    $FPSLabel.text = "FPS: %d" % fps

Mas o número de FPS sozinho só diz que tem problema, não diz onde. Para isso serve o Profiler. Rode o jogo pelo editor, vá na aba Debugger (embaixo, ao lado do Output), e abra a sub-aba Profiler. Clique em iniciar a gravação, jogue até o momento em que trava, e pare. Ele mostra uma lista de funções ordenadas por tempo gasto no frame. A função que aparece no topo é o seu gargalo. Ponto. Não adianta otimizar a segunda ou a terceira da lista antes de resolver a primeira.

Ao lado, na sub-aba Monitors, você acompanha em tempo real vários contadores enquanto joga: FPS, tempo de processo, número de nós na cena, número de draw calls, memória de vídeo. Esses gráficos são ouro para descobrir se o problema é CPU (tempo de processo subindo) ou GPU (draw calls e memória de vídeo altos).

Você também consegue ler esses mesmos valores por código, o que é útil para criar um HUD de debug próprio:

func _process(_delta: float) -> void:
    var nos: int = Performance.get_monitor(Performance.OBJECT_NODE_COUNT)
    var draw_calls: int = Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)
    var tempo_processo: float = Performance.get_monitor(Performance.TIME_PROCESS)
    print("Nós: %d | Draw calls: %d | Process: %.2f ms" % [nos, draw_calls, tempo_processo * 1000.0])

Com esses números na mão, você já sabe para que lado olhar. Muita gente vê "tempo de processo" alto e assume que é a placa de vídeo, gasta horas mexendo em shader, e o problema era um _process rodando em quinhentos nós. Meça primeiro.

Gargalos de CPU: nós demais, _process pesado e instanciar em loop

A CPU na Godot sofre principalmente com três coisas: nós demais na cena, lógica pesada rodando todo frame, e criação e destruição de objetos em loop.

Nós demais

Cada nó tem um custo. Ele entra na árvore, participa de notificações, e se tiver _process ou _physics_process ativo, roda toda hora. Uma cena com milhares de nós pesa só de existir, mesmo que cada um faça pouco. Se o seu OBJECT_NODE_COUNT está na casa dos milhares e o jogo não justifica isso, é sinal de que dá para simplificar a estrutura, agrupar coisas ou desenhar em vez de instanciar nó para cada elemento.

_process pesado e a diferença para _physics_process

Um erro clássico é jogar tudo no _process. Ele roda uma vez por frame renderizado, então quanto mais frames, mais ele roda. Se você tem lógica de física, movimento ou colisão, ela deve ir no _physics_process, que roda numa taxa fixa (independente do FPS) e é mais estável entre máquinas diferentes.

E dentro do _process, evite recalcular coisa que não muda todo frame. Este padrão é caríssimo quando repetido em muitos nós:

# Custa caro: procura o nó por caminho toda vez que roda
func _process(_delta: float) -> void:
    var player: Node2D = get_node("/root/Mundo/Player")
    if global_position.distance_to(player.global_position) < 100.0:
        perseguir(player)

Guarde a referência uma vez com @onready e pare de buscar:

@onready var player: Node2D = get_node("/root/Mundo/Player")

func _process(_delta: float) -> void:
    if global_position.distance_to(player.global_position) < 100.0:
        perseguir(player)

Sinais em vez de polling

Ligado a isso: pare de perguntar toda hora se algo aconteceu. Verificar uma condição em todo frame (polling) é desperdício quando a mudança acontece de vez em quando. Use sinais para ser avisado só quando o evento ocorre.

# Ruim: checa a vida em todo frame só para reagir uma vez
func _process(_delta: float) -> void:
    if vida <= 0:
        morrer()
# Bom: emite um sinal no momento certo, reage uma vez
signal morreu

func receber_dano(quantidade: int) -> void:
    vida -= quantidade
    if vida <= 0:
        morreu.emit()

O nó interessado conecta no sinal e só é chamado quando precisa, em vez de gastar CPU checando à toa.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

Instanciar em loop e object pooling

Instanciar e liberar nós é uma das operações mais caras que você pode repetir. Num jogo de tiro que cria um nó de bala a cada disparo e chama queue_free() quando ela some, você paga esse custo o tempo todo, e ainda gera trabalho para o coletor de lixo. A solução é object pooling: em vez de criar e destruir, você mantém uma fila de nós prontos e reaproveita.

extends Node

@export var cena_bala: PackedScene
var disponiveis: Array[Node2D] = []

func _ready() -> void:
    # Pré-cria um estoque de balas desligadas
    for i in 30:
        var bala: Node2D = cena_bala.instantiate() as Node2D
        bala.set_process(false)
        bala.visible = false
        add_child(bala)
        disponiveis.append(bala)

func pegar_bala() -> Node2D:
    if disponiveis.is_empty():
        # Estoque acabou, cria uma nova como exceção
        return cena_bala.instantiate() as Node2D
    var bala: Node2D = disponiveis.pop_back()
    bala.visible = true
    bala.set_process(true)
    return bala

func devolver_bala(bala: Node2D) -> void:
    # Em vez de queue_free, desliga e devolve para a fila
    bala.visible = false
    bala.set_process(false)
    disponiveis.append(bala)

Quando a bala sai da tela, você chama devolver_bala em vez de queue_free(). O nó não é destruído, só volta para a fila esperando o próximo disparo. Isso corta a criação e destruição constantes e deixa o frame muito mais previsível.

Vale a mesma lógica para física desnecessária: se um objeto não precisa colidir num dado momento, desligue o processamento dele (set_physics_process(false)) ou desative a colisão em vez de deixar tudo ativo o tempo todo. Corpos de física custam CPU mesmo parados.

Gargalos de GPU e render: draw calls, MultiMesh e texturas

Se os Monitors mostram draw calls altas e memória de vídeo cheia, o gargalo é de render, e mexer no script não vai resolver.

Draw calls e MultiMesh

Cada objeto desenhado separado é, grosso modo, uma ordem para a placa de vídeo. Muitos objetos iguais desenhados um a um geram muitas draw calls, e isso pesa. Quando você tem centenas ou milhares de instâncias do mesmo objeto (grama, tijolos, moedas, uma nuvem de asteroides), use MultiMeshInstance2D (ou MultiMeshInstance3D no 3D). Ele desenha todas as cópias de uma vez só, numa fração das draw calls.

extends MultiMeshInstance2D

func _ready() -> void:
    var mm: MultiMesh = MultiMesh.new()
    mm.transform_format = MultiMesh.TRANSFORM_2D
    mm.mesh = $QuadMesh.mesh
    mm.instance_count = 500
    for i in mm.instance_count:
        var t: Transform2D = Transform2D()
        t.origin = Vector2(randf() * 1000.0, randf() * 600.0)
        mm.set_instance_transform_2d(i, t)
    multimesh = mm

Quinhentas cópias, uma leva de desenho só. A diferença para quinhentos nós separados é enorme.

Para efeitos com muitas partículas, a mesma lógica de deixar a GPU fazer o trabalho pesado se aplica. Se você ainda desenha efeito por efeito com nós, vale ler nosso guia de partículas com GPUParticles2D, que joga a simulação para a placa de vídeo. E se o gargalo aparece num cenário grande de blocos, dá uma olhada em como montar um tilemap na Godot 4 direito, porque um TileMap bem feito rende muito melhor que centenas de Sprites soltos.

Texturas grandes

Texturas gigantes ocupam memória de vídeo e podem estourar seu orçamento de VRAM à toa. Uma imagem 4096x4096 para um ícone de 64 pixels na tela é desperdício puro. Ajuste o tamanho real das texturas ao que aparece no jogo, ative compressão para as que precisam, e reutilize atlas quando fizer sentido. Cheque RENDER_VIDEO_MEM_USED nos Monitors para saber se está apertado.

Culling com VisibleOnScreenNotifier

Não faz sentido processar o que ninguém está vendo. O VisibleOnScreenNotifier2D (e a versão 3D) avisa por sinal quando um objeto entra ou sai da tela, e você usa isso para desligar a lógica de quem está fora do quadro.

@onready var notifier: VisibleOnScreenNotifier2D = $VisibleOnScreenNotifier2D

func _ready() -> void:
    notifier.screen_entered.connect(_ao_entrar_na_tela)
    notifier.screen_exited.connect(_ao_sair_da_tela)

func _ao_entrar_na_tela() -> void:
    set_process(true)

func _ao_sair_da_tela() -> void:
    # Inimigo saiu da tela, para de gastar CPU com ele
    set_process(false)

O inimigo lá no canto do mapa que o jogador nem vê para de rodar lógica, e você recupera frames de graça.

GDScript tipado ajuda no desempenho

Aqui tem um ganho que muita gente ignora: tipar suas variáveis e funções deixa o GDScript mais rápido. Quando você declara o tipo, a engine sabe com o que está lidando e pula parte da verificação que faria em tempo de execução com código sem tipo.

# Sem tipagem: a engine descobre o tipo em tempo de execução
func somar_distancias(a, b):
    var total = 0
    for valor in a:
        total += valor.distance_to(b)
    return total
# Tipado: mais rápido e ainda pega erro cedo no editor
func somar_distancias(pontos: Array[Vector2], alvo: Vector2) -> float:
    var total: float = 0.0
    for valor: Vector2 in pontos:
        total += valor.distance_to(alvo)
    return total

Além de rodar melhor, o código tipado quebra no editor quando você passa o argumento errado, em vez de falhar silenciosamente no meio do jogo. É ganho de performance e de segurança de uma vez, sem custo nenhum. Por isso a gente insiste em tipar tudo em todos os exemplos.

Uma dúvida que aparece muito nesse ponto é se não seria melhor trocar de linguagem para ganhar velocidade. Depende do gargalo. Se o custo está no render, trocar de linguagem não muda nada. Se está em lógica pesada de CPU, aí a conversa muda, e a gente comparou isso a fundo em C# vs GDScript na Godot. O resumo é que GDScript tipado resolve a maior parte dos casos, e você só precisa pensar em C# quando o Profiler apontar mesmo para lógica de script como o gargalo.

Próximo passo: meça, ataque um gargalo por vez

Otimizar não é reescrever o jogo inteiro no susto. É um ciclo curto: rode o Profiler, ache a função no topo, entenda se é CPU ou render pelos Monitors, aplique a correção certa (pooling, sinal, MultiMesh, culling, tipagem), e meça de novo para confirmar que melhorou. Um gargalo por vez.

Comece hoje pelo mais fácil: abra a aba Debugger, grave um Profiler no momento em que o jogo trava, e olhe quem está no topo. Você vai se surpreender com quantas vezes o culpado é uma bobagem que dá para arrumar em cinco minutos. E se o seu jogo ainda está nascendo, começar já tipando o GDScript e usando pooling desde cedo evita boa parte dessas dores lá na frente.

Bons frames, e continue medindo. Aqui no CursoGame.Dev a gente sempre volta na mesma ideia: número na mão antes de decisão no chute.

Perguntas frequentes

Por que meu jogo na Godot trava só em algumas partes?

Quase sempre é um pico de trabalho num frame específico: muitos nós sendo instanciados de uma vez, um _process pesado que roda em todo mundo ou um efeito de partículas exagerado. Abra o Profiler, grave o momento da travada e veja qual função aparece no topo. O gargalo costuma estar concentrado em uma única chamada.

Uso _process ou _physics_process para lógica de jogo?

Use _physics_process para qualquer coisa que envolva movimento com física, colisão ou que precise ser estável em máquinas diferentes, porque ele roda numa taxa fixa. Use _process para lógica visual, interface e efeitos que só precisam acompanhar a taxa de frames. Colocar tudo no _process quando não precisa é desperdício de CPU.

Trocar GDScript por C# resolve problema de FPS?

Nem sempre. Se o gargalo é draw call, texturas grandes ou nós demais, mudar de linguagem não muda nada, porque o problema está no render, não no script. C# ajuda em lógica pesada de CPU, mas GDScript com tipagem estática já fecha boa parte dessa diferença. Meça primeiro para saber onde está o custo.