Otimização de Performance em Jogos: Como Garantir 60 FPS Estáveis

Guia completo de otimização de performance: profiling, técnicas avançadas, GPU/CPU optimization e estratégias para alcançar 60 FPS
Otimização de Performance em Jogos: Como Garantir 60 FPS Estáveis
Performance não é detalhe que você ajusta no fim do projeto. É decisão de arquitetura que você toma cedo e paga caro pra consertar depois. Eu já vi jogo bom morrer porque travava no celular do jogador, e jogo mediano vender bem porque rodava liso em qualquer máquina.
A meta de 60 FPS existe por um motivo concreto: a 60 quadros por segundo você tem 16,67 milissegundos pra fazer tudo que acontece em um frame. Calcular gameplay, física, IA, animação, mandar os comandos pra GPU desenhar a tela. Estourou esse orçamento, o frame atrasa, o jogador sente. Em jogo competitivo isso é a diferença entre ganhar e perder. Em qualquer jogo, é a diferença entre parecer profissional ou amador.
Antes de tudo: esse orçamento de 16,67ms é a sua régua. Toda otimização daqui pra frente é tentar caber dentro dele.
Profiling: meça antes de otimizar
A regra número um é não confiar no seu palpite. Você acha que o gargalo é a IA, mede, e descobre que era a sua UI redesenhando 200 elementos por frame. Acontece o tempo todo. Otimizar sem perfilar é apagar incêndio no cômodo errado.
A ferramenta principal no Unity é o Profiler embutido (Window > Analysis > Profiler). Ele te mostra, frame a frame, quanto tempo cada coisa consome: scripts, física, rendering, garbage collector. Para olhar o que a GPU está desenhando, use o Frame Debugger. Para casos mais sérios, RenderDoc (GPU, de graça) e o Profiler de release build, porque medir no editor mente: o editor tem overhead que o build final não tem.
Você também pode marcar trechos específicos do seu código pra aparecerem nomeados no Profiler. Isso ajuda a achar exatamente qual sistema seu está custando caro:
using Unity.Profiling;
using UnityEngine;
public class EnemyManager : MonoBehaviour
{
static readonly ProfilerMarker s_UpdateAI = new ProfilerMarker("EnemyManager.UpdateAI");
void Update()
{
using (s_UpdateAI.Auto())
{
UpdateAI();
}
}
void UpdateAI()
{
// sua lógica de IA aqui
}
}
Esse ProfilerMarker cria uma entrada chamada "EnemyManager.UpdateAI" na timeline do Profiler. Quando ela aparecer ocupando 8ms, você sabe exatamente onde ir mexer.
O contador de FPS é só o termômetro
Mostrar FPS na tela não otimiza nada, mas ajuda a sentir o que você está fazendo. Um detalhe que muita gente erra: não calcule FPS como 1 / Time.deltaTime e mostre direto, porque o número pisca demais e não dá pra ler. Suavize com uma média móvel.
using UnityEngine;
public class FpsCounter : MonoBehaviour
{
float smoothDeltaTime;
void Update()
{
// média móvel: cada frame puxa o valor 10% na direção do atual
smoothDeltaTime += (Time.unscaledDeltaTime - smoothDeltaTime) * 0.1f;
}
void OnGUI()
{
float ms = smoothDeltaTime * 1000f;
float fps = 1f / smoothDeltaTime;
GUI.Label(new Rect(10, 10, 200, 20), $"{fps:0.} FPS ({ms:0.0} ms)");
}
}
Atenção: OnGUI em si custa caro e o GC do OnGUI polui sua própria medição. Use isso só em build de debug, nunca no jogo que vai pro jogador. Em produção, prefira um Text de TextMeshPro atualizado de tempos em tempos.
Identificando o gargalo: CPU bound ou GPU bound?
Antes de otimizar, você precisa saber quem está segurando o frame. Existem dois suspeitos: a CPU (seu código, física, IA, lógica de jogo) e a GPU (o que está sendo desenhado). No Profiler do Unity, compare o tempo gasto em "CPU Usage" com o tempo de "GPU". Quem tiver o maior número é o seu gargalo naquele momento.
Um teste rápido de campo: baixe a resolução de renderização pela metade. Se o FPS subir bastante, você está GPU bound (a placa não dá conta de desenhar). Se o FPS quase não mexer, você está CPU bound (o problema é o processamento, não o desenho). Esse teste de 30 segundos te poupa horas otimizando o lado errado.
Quando o gargalo é GPU, os alvos costumam ser: sombras (caríssimas), resolução de render, complexidade de shaders, transparências sobrepostas (overdraw) e post-processing. Quando é CPU, olhe scripts rodando em todo objeto no Update, física com muitos corpos, picos do garbage collector e draw calls em excesso (porque montar a lista de comandos pra GPU também é trabalho de CPU).
Otimização de CPU
Object Pooling: pare de instanciar e destruir
Instantiate e Destroy são caros e, pior, Destroy gera lixo pro garbage collector. Se o seu jogo cria balas, inimigos ou partículas o tempo todo, isso vira pico de GC e o frame engasga. A solução é reaproveitar objetos em vez de criar e destruir: você cria um lote no começo, desativa, e vai ativando conforme precisa.
using System.Collections.Generic;
using UnityEngine;
public class ObjectPool : MonoBehaviour
{
[SerializeField] GameObject prefab;
[SerializeField] int initialSize = 20;
readonly Queue<GameObject> pool = new Queue<GameObject>();
void Awake()
{
for (int i = 0; i < initialSize; i++)
pool.Enqueue(CreateInstance());
}
GameObject CreateInstance()
{
GameObject obj = Instantiate(prefab, transform);
obj.SetActive(false);
return obj;
}
public GameObject Get()
{
// se o pool esvaziou, cresce em vez de quebrar
GameObject obj = pool.Count > 0 ? pool.Dequeue() : CreateInstance();
obj.SetActive(true);
return obj;
}
public void Return(GameObject obj)
{
obj.SetActive(false);
pool.Enqueue(obj);
}
}
O Unity tem um ObjectPool<T> embutido em UnityEngine.Pool desde a versão 2021, então em projeto novo vale usar a versão da própria engine. Mas entender o conceito acima é o que importa: o objeto nunca é destruído, só vai e volta entre ativo e inativo.
Cache de componentes e cuidado com o que roda no Update
GetComponent não é de graça. Chamar dentro do Update é desperdício, porque a resposta é sempre a mesma. Pegue a referência uma vez no Awake e reutilize.
public class Player : MonoBehaviour
{
Rigidbody rb;
void Awake()
{
rb = GetComponent<Rigidbody>(); // uma vez só
}
void FixedUpdate()
{
rb.AddForce(Vector3.forward); // sem GetComponent aqui
}
}
O mesmo raciocínio vale para Camera.main (ele faz um FindGameObjectWithTag por baixo, não use no Update) e para qualquer busca por objeto na cena. E a pergunta mais honesta que você pode fazer: esse script precisa mesmo de um Update rodando 60 vezes por segundo? Inimigo que só reage a cada meio segundo não precisa pensar todo frame. Tirar Update de 300 objetos e deixar uma corrotina ou um timer central cuidando deles costuma ser a otimização de CPU com melhor retorno.
Jobs System para trabalho pesado em paralelo
Quando você tem milhares de cálculos independentes (distância de muitos inimigos pra um alvo, simulação de partículas, flocking), o Jobs System do Unity com Burst Compiler distribui isso pelos núcleos da CPU e ainda compila pra código nativo otimizado. A diferença de performance é grande, mas só compensa em volume: pra 10 inimigos, não vale a complexidade.
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
[BurstCompile]
public struct DistanceJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float3> positions;
public float3 target;
public NativeArray<float> results;
public void Execute(int i)
{
results[i] = math.distance(positions[i], target);
}
}
public class DistanceCalculator : MonoBehaviour
{
NativeArray<float3> positions;
NativeArray<float> results;
void Start()
{
positions = new NativeArray<float3>(10000, Allocator.Persistent);
results = new NativeArray<float>(10000, Allocator.Persistent);
}
void Update()
{
var job = new DistanceJob
{
positions = positions,
target = float3.zero,
results = results
};
JobHandle handle = job.Schedule(positions.Length, 64);
handle.Complete(); // espera terminar antes de usar os resultados
}
void OnDestroy()
{
// NativeArray não é gerenciado pelo GC: você TEM que liberar na mão
positions.Dispose();
results.Dispose();
}
}
O detalhe que pega todo mundo: NativeArray não é coletado pelo garbage collector. Esqueceu o Dispose, vazou memória. O Unity reclama no console quando isso acontece, mas é bom já nascer sabendo.
Otimização de GPU
Draw calls e batching
Cada material distinto que você manda desenhar é, na prática, uma conversa separada entre CPU e GPU. Muitas dessas conversas (centenas, milhares) afundam o frame, e o custo aparece tanto na CPU (montar os comandos) quanto na GPU. Reduzir draw calls é uma das otimizações de maior impacto em jogo real.
As três ferramentas principais no Unity:
- Static Batching: para objetos que nunca se movem (cenário, prédios), marque como Static no Inspector. O Unity junta tudo que compartilha material em uma malha só.
- GPU Instancing: para muitas cópias do mesmo objeto (grama, árvores, inimigos iguais), ative "Enable GPU Instancing" no material. A GPU desenha milhares de cópias com pouquíssimos comandos.
- SRP Batcher: se você usa URP ou HDRP, ligue o SRP Batcher nas configurações do pipeline. Ele acelera o desenho de objetos que compartilham o mesmo shader, mesmo com materiais diferentes.
O ponto de partida prático: abra o Frame Debugger, veja quantos draw calls seu jogo está fazendo e o que está quebrando o batching. Geralmente é um objeto com material único no meio de um monte de objetos iguais.
Shaders e o custo do fragment
A regra mais útil sobre shader: o fragment shader (que roda por pixel) custa muito mais que o vertex shader (que roda por vértice), porque você tem milhões de pixels e relativamente poucos vértices. Então a otimização que mais rende é tirar conta do fragment. Pré-calcule no vertex o que der, evite pow, sin, divisões e branches dentro do fragment quando possível.
Em mobile, isso é ainda mais crítico. Transparência e overdraw (vários pixels transparentes desenhados um por cima do outro) derrubam o FPS rápido. Prefira shaders opacos, reduza camadas de partículas transparentes e teste em celular de verdade, não no editor.
LOD: menos detalhe ao longe
O jogador não enxerga os detalhes de um objeto a 100 metros, então não faz sentido a GPU desenhar a malha de alta resolução dele. O sistema de LOD (Level of Detail) troca o modelo por versões mais simples conforme o objeto fica longe da câmera. O Unity tem o componente LOD Group, que você configura no Inspector com as distâncias de troca. Não precisa escrever código pra isso, e o ganho em cenas com muita geometria é enorme.
Para terreno, ajuste o Pixel Error (quanto maior, menos detalhe e mais performance) e a distância de detalhes de vegetação. Árvores distantes podem virar billboards (um plano com a foto da árvore) em vez de geometria 3D.
Otimização de memória
Texturas costumam ser o maior vilão
Em muitos jogos, principalmente mobile, textura é o que mais come memória. Dois ajustes resolvem a maior parte:
Primeiro, compressão. Textura sem compressão ocupa um absurdo. Use os formatos comprimidos certos por plataforma: ASTC em mobile moderno (iOS e Android), BC7/DXT no PC e consoles. O Unity faz isso nas Import Settings de cada textura, com override por plataforma.
Segundo, resolução de import. Não importe uma textura 4096x4096 se ela aparece pequena na tela. Baixe o Max Size na importação. E desligue "Read/Write Enabled" se você não acessa os pixels da textura por código: isso mantém uma cópia duplicada na RAM à toa.
Vale lembrar que reduzir a resolução pela metade não corta a memória pela metade, corta por quatro, porque área cresce ao quadrado. Uma textura 1024 ocupa um quarto de uma 2048.
Garbage Collector: o inimigo invisível dos frames suaves
No Unity, quando você aloca objetos gerenciados (arrays novos, listas novas, concatenação de strings) durante o gameplay, mais cedo ou mais tarde o garbage collector roda pra limpar. E quando ele roda, pode travar o frame por alguns milissegundos. É a causa clássica daquele engasgo periódico em jogo que tecnicamente "roda a 60 FPS" na média.
A estratégia é não gerar lixo no caminho quente do código (o que roda todo frame). Na prática:
using System.Text;
using UnityEngine;
public class HudText : MonoBehaviour
{
int health = 100, score = 0;
readonly StringBuilder sb = new StringBuilder(64); // alocado uma vez
string BuildStatus()
{
// RUIM seria: "Health: " + health + " Score: " + score
// cada concatenação dessas cria strings temporárias = lixo todo frame
sb.Clear();
sb.Append("Health: ").Append(health).Append(" Score: ").Append(score);
return sb.ToString();
}
}
Outros geradores comuns de lixo: criar new List<>() ou new T[] dentro do Update (pré-aloque e reutilize com .Clear()), usar LINQ no gameplay (.Where(), .Select() alocam), e foreach em algumas coleções em versões antigas do Unity. O Profiler tem uma coluna "GC Alloc" que mostra exatamente quem está alocando: se ela não está zerada durante o gameplay, você tem trabalho ali.
Forçar GC.Collect() na mão raramente é boa ideia, com uma exceção: chamar de propósito durante uma tela de loading ou transição de nível, quando um engasgo não incomoda ninguém. Aí sim faz sentido limpar a casa num momento em que o jogador não está vendo.
Adaptive quality: ajustando à máquina do jogador
Seu jogo vai rodar em hardware que você nunca testou. Em vez de assumir, dá pra medir o frame time durante o jogo e baixar a qualidade automaticamente quando ele passar do orçamento. A ideia é simples: acompanhe a média de frame time numa janela de alguns frames e, se estiver consistentemente ruim, desça um degrau nas configurações.
using UnityEngine;
public class AdaptiveQuality : MonoBehaviour
{
const float TargetMs = 16.67f; // 60 FPS
readonly float[] samples = new float[30];
int index;
void Update()
{
samples[index] = Time.unscaledDeltaTime * 1000f;
index = (index + 1) % samples.Length;
if (index == 0)
Evaluate();
}
void Evaluate()
{
float avg = 0f;
foreach (float s in samples) avg += s;
avg /= samples.Length;
int level = QualitySettings.GetQualityLevel();
// frame time alto e ainda dá pra baixar: desce um nível
if (avg > TargetMs * 1.1f && level > 0)
QualitySettings.DecreaseLevel();
// sobrando folga e dá pra subir: sobe um nível
else if (avg < TargetMs * 0.8f && level < QualitySettings.names.Length - 1)
QualitySettings.IncreaseLevel();
}
}
Esse código usa os Quality Levels que você configura em Project Settings > Quality, onde define sombras, anti-aliasing, distância de LOD e afins pra cada nível. O AdaptiveQuality só decide quando subir ou descer. Cuidado pra não ficar oscilando entre níveis (o jogo fica "respirando"): por isso as margens de 80% e 110%, que criam uma zona morta no meio.
Ferramentas que valem conhecer
Para profiling e debug você vai usar, em ordem de frequência:
- Unity Profiler: seu primeiro e principal recurso. CPU, GPU, memória, GC, tudo num lugar.
- Frame Debugger: para entender draw calls e por que o batching não está funcionando.
- RenderDoc: debug profundo de GPU, captura de frame, inspeção de cada draw. Gratuito.
- Memory Profiler (pacote do Unity): para caçar o que está ocupando RAM e vazamentos.
Em todos os casos, a regra que mais importa: meça em build de release e em hardware real, principalmente no aparelho mais fraco que você pretende suportar. O editor mente, sua máquina de dev mente. O celular do jogador é a única verdade.
Fechando
Não existe bala de prata em performance. Existe método: estabeleça o orçamento de 16,67ms, perfile pra achar o gargalo real, descubra se é CPU ou GPU, ataque o que pesa de fato e meça de novo. Repete. A maior parte das vezes o problema não é onde você achava que era, e é por isso que medir vem antes de otimizar, sempre.
E o conselho que eu daria pra mim mesmo dez anos atrás: é muito mais fácil manter performance boa do que recuperar performance perdida. Object pooling, cache de componentes e cuidado com GC custam pouco se você os adota desde o começo. Vira pesadelo se você deixa pra resolver depois que o jogo inteiro já foi construído em cima de decisões ruins.


