Mesma linguagem, outras regras

Apostila de C# para Unity

Quem sabe C# e começa em Unity descobre, aos poucos, que várias coisas não funcionam como esperava: construtores que não se usam, propriedades que o Inspector ignora, um null que não é bem null, ?. que falha, alocações que fazem o jogo engasgar. Quem aprendeu C# dentro do Unity costuma ter o problema inverso. Esta apostila é a ponte: o C# que o Unity usa de maneira própria, e porquê.

10 módulosCiclo de vida · serializaçãoNull falso · DestroyCorrotinas · AwaitableGC · poolingJobs · Burst · IL2CPPExercícios com gabarito
📍 Onde esta apostila se encaixa

A linguagem em si — tipos, orientação a objetos, genéricos, LINQ, async — está em C# do Básico ao Avançado. O editor e os conceitos de Unity estão em Unity Essencial e Unity do zero ao mercado. A arquitetura de projetos maiores está em Unity para Aplicações. Aqui, só o que muda quando o C# roda dentro do Unity. Referência: Unity 6.

MÓDULO 01 · BÁSICO

O C# do Unity não é o C# do .NET atual

Objetivo: saber que versão da linguagem e que bibliotecas estão disponíveis, e por que o ambiente de execução é diferente do de uma aplicação .NET comum.

1.1 Linguagem, bibliotecas e runtime

Aplicação .NET modernaUnity 6
Versão da linguagemA mais recenteC# 9, com algumas exceções documentadas
Bibliotecas base.NET atual.NET Standard 2.1 ou .NET Framework (perfil de compatibilidade escolhido nas Player Settings)
Runtime no editorCoreCLRMono
Runtime nos buildsCoreCLR (JIT)Mono ou IL2CPP (C# convertido em C++ e compilado antes — módulo 9)
Garbage collectorGeracional, compactadorNão geracional, com modo incremental (módulo 7)

A Unity anunciou a migração para o runtime moderno do .NET (CoreCLR); até lá, confira sempre se um recurso da linguagem ou uma biblioteca NuGet funciona no Unity antes de construir em cima dele. Alguns recursos do C# 9 que dependem de tipos do runtime novo (como init e record) exigem declarar um tipo auxiliar ou não funcionam como esperado.

1.2 O que mais surpreende quem vem do .NET

💼 Mercado de trabalho

Entrevistas para vagas de Unity quase sempre incluem perguntas sobre o que é específico do motor: ciclo de vida, serialização, null falso, GC, corrotinas. Saber C# "do .NET" não basta, e é exatamente aí que muitos candidatos vindos de back-end tropeçam. O módulo 10 reúne as perguntas mais comuns.

✏️ Exercício 1 — Porta a biblioteca?

Você quer usar no Unity uma biblioteca NuGet que depende de System.Text.Json e de reflexão para gerar código em tempo de execução. Que dois riscos verifica primeiro?

Gabarito: (1) Compatibilidade: se a biblioteca e as suas dependências têm uma versão para .NET Standard 2.1 compatível com o perfil do projeto. (2) IL2CPP: bibliotecas que geram código em tempo de execução (Reflection.Emit, expressões compiladas) não funcionam em plataformas AOT como iOS, consolas e WebGL (módulo 9). É por isso que em Unity se usa tanto o pacote Newtonsoft JSON da própria Unity.

MÓDULO 02 · BÁSICO

MonoBehaviour e o ciclo de vida

Objetivo: saber quando o motor chama cada método, em que ordem, e onde pôr cada tipo de código.

2.1 Os métodos principais

MétodoQuandoPara quê
AwakeUma vez, quando o objeto é carregado (mesmo com o componente desativado)Inicializar o próprio estado e obter referências internas
OnEnableSempre que o componente é ativadoInscrever-se em eventos
StartUma vez, antes do primeiro Update, se ativoInicialização que depende de outros objetos já terem feito o Awake
FixedUpdateEm passos fixos de física (0 a várias vezes por quadro)Física: forças, Rigidbody
UpdateUma vez por quadroInput, lógica de jogo
LateUpdateDepois de todos os UpdateCâmaras que seguem objetos
OnDisableSempre que é desativado ou destruídoDesinscrever-se de eventos
OnDestroyQuando o objeto é destruídoLibertar recursos

2.2 A regra de ouro da inicialização

Em Awake, prepare-se a si próprio; em Start, fale com os outros. A ordem de Awake entre objetos diferentes não é garantida. Se o objeto A lê em Awake um valor que B só define no seu Awake, o resultado depende da sorte — e muda entre o editor e o build. A ordem pode ser forçada em Script Execution Order, mas é melhor não depender dela.

2.3 Tempo

void Update()
{
    // independente do frame rate: unidades por segundo × segundos deste quadro
    transform.position += direcao * velocidade * Time.deltaTime;
}

void FixedUpdate()
{
    // física: dentro do passo fixo; não multiplique forças por deltaTime
    corpo.AddForce(empurrao);
}
⚠️ Input em FixedUpdate

Ler "o botão foi carregado neste quadro" em FixedUpdate perde toques: há quadros sem nenhum passo de física e quadros com vários. Leia o input em Update, guarde a intenção num campo, e aplique-a em FixedUpdate.

✏️ Exercício 2 — Onde vai cada linha?

Distribua pelos métodos: (a) rb = GetComponent<Rigidbody>(); (b) GameManager.Instance.Registrar(this); (c) if (Input.GetKeyDown(KeyCode.Space)) querPular = true;; (d) rb.AddForce(Vector3.up * forca) quando querPular; (e) jogador.Morreu += AoMorrer e o seu oposto.

Gabarito: (a) Awake — referência interna. (b) Start — depende de outro objeto já inicializado. (c) Update. (d) FixedUpdate, voltando querPular a falso. (e) Inscrever em OnEnable, desinscrever em OnDisable.

MÓDULO 03 · BÁSICO

Serialização: o que o Inspector guarda

Objetivo: saber exatamente que campos o Unity guarda nas cenas e prefabs, porque é que uma propriedade não aparece no Inspector, e como não perder dados ao mudar o código.

3.1 As regras

O Unity guarda (serializa) um campo se ele for público ou marcado com [SerializeField], não estático, não const, não readonly, e de um tipo serializável: tipos primitivos, string, enums, tipos do Unity (Vector3, Color, AnimationCurve…), referências a UnityEngine.Object (GameObject, componentes, assets), classes e structs marcadas com [Serializable], e arrays ou List<T> desses tipos.

Não são serializados: propriedades, dicionários, arrays multidimensionais, listas de listas, campos estáticos, e referências a interfaces ou classes abstratas (a não ser com [SerializeReference]).

🔍 Isto é serializado?
Aparece no Inspector e é guardado?—

3.2 Três armadilhas

public class Inimigo : MonoBehaviour
{
    [SerializeField] private float velocidade = 5f;           // no Inspector, privado para o código
    [field: SerializeField] public int Vida { get; private set; } // serializa o campo por trás da propriedade
    [FormerlySerializedAs("dano")]
    [SerializeField] private int danoPorGolpe;                // renomeado sem perder os valores
}
✏️ Exercício 3 — O inventário que não aparece

Um programador escreve public Dictionary<string, int> inventario; e o campo não aparece no Inspector. Proponha duas soluções.

Gabarito: (1) Uma lista de pares serializáveis — [Serializable] struct Item { public string id; public int qtd; } e List<Item> — convertida para dicionário em Awake se precisar de busca rápida. (2) Implementar ISerializationCallbackReceiver, guardando em duas listas antes de serializar e reconstruindo o dicionário depois de desserializar.

MÓDULO 04 · INTERMEDIÁRIO

O "null falso" e a vida dos objetos

Objetivo: entender por que um objeto destruído "é null" para o == e não é para o ?., e evitar os bugs que isso causa.

4.1 Dois objetos em um

Cada GameObject, componente ou asset tem um lado C# (o objeto gerenciado) e um lado nativo (em C++, dentro do motor). Quando se chama Destroy, o lado nativo desaparece, mas o objeto C# continua a existir enquanto houver referências a ele. Para esconder isso, UnityEngine.Object sobrecarrega o operador ==: um objeto destruído compara como igual a null, embora a referência C# não seja nula.

4.2 Onde isso morde

CódigoCom objeto destruído
if (alvo == null)✓ verdadeiro — usa a sobrecarga do Unity
if (alvo)✓ falso — a conversão para bool também usa o Unity
alvo?.Atacar()✗ chama o método num objeto destruído — o ?. não usa a sobrecarga
var x = alvo ?? reserva;✗ fica com o objeto destruído — o ?? também não usa
if (alvo is null)✗ falso — o is testa a referência C# pura
⚠️ Regra prática

Para qualquer tipo que herda de UnityEngine.Object, não use ?., ??, ??= nem is null. Use == null, != null ou if (obj). Os analisadores de código do Unity avisam sobre isto — mantenha-os ativos. Para tipos C# comuns (os seus modelos de domínio), os operadores funcionam normalmente.

4.3 Destroy não é imediato

Destroy(obj) marca o objeto e só o destrói no fim do quadro. No resto do quadro atual, ele ainda existe e responde. Código que destrói e, na linha seguinte, conta os objetos na cena, conta também o destruído. DestroyImmediate é para scripts de editor, não para o jogo.

4.4 Procurar componentes

// TryGetComponent: não aloca quando o componente não existe (GetComponent aloca no editor)
if (outro.TryGetComponent(out Vida vida))
    vida.ReceberDano(10);

// guarde as referências; não procure em cada Update
Rigidbody _rb;
void Awake() => _rb = GetComponent<Rigidbody>();
✏️ Exercício 4 — O míssil teimoso

Um míssil guarda uma referência ao alvo e, em Update, faz transform.LookAt(alvo?.transform.position ?? Vector3.zero). Quando o alvo é destruído, surge MissingReferenceException. Porquê, e como corrigir?

Gabarito: O ?. não vê o objeto destruído como nulo; tenta ler .transform num componente cujo lado nativo já não existe. Correção: if (alvo == null) { Explodir(); return; } transform.LookAt(alvo.position); — decidindo explicitamente o que fazer quando o alvo desaparece.

MÓDULO 05 · INTERMEDIÁRIO

Corrotinas, async e Awaitable

Objetivo: saber escrever código que se espalha por vários quadros ou espera por algo, escolhendo entre corrotinas e async, sem deixar operações órfãs.

5.1 Corrotinas

IEnumerator Piscar(int vezes)
{
    for (int i = 0; i < vezes; i++)
    {
        render.enabled = !render.enabled;
        yield return new WaitForSeconds(0.1f);   // continua no quadro certo, na thread principal
    }
    render.enabled = true;
}
// StartCoroutine(Piscar(6));

5.2 async/await com Awaitable

No Unity 6, o tipo Awaitable é a forma recomendada de usar async no motor: é integrado ao laço do Unity, tem métodos para esperar o próximo quadro, segundos, o próximo passo de física, e para mudar de thread.

async Awaitable AbrirPortaAsync()
{
    var ct = destroyCancellationToken;               // cancela se o objeto for destruído
    animador.SetTrigger("abrir");
    await Awaitable.WaitForSecondsAsync(1.2f, ct);
    colisor.enabled = false;

    await Awaitable.BackgroundThreadAsync();         // trabalho pesado fora da thread principal
    var caminho = CalcularRota(mapa);               // só C# puro aqui, nada de API do Unity
    await Awaitable.MainThreadAsync();                // volta antes de tocar na cena
    guia.Mostrar(caminho);
}
⚠️ Task e o Play Mode

Um async Task comum não para quando se sai do Play Mode no editor nem quando o objeto é destruído — continua a correr e a tocar em objetos que já não existem. Passe sempre um CancellationToken (o destroyCancellationToken do componente, ou o Application.exitCancellationToken). A biblioteca comunitária UniTask é outra opção popular, com zero alocações.

5.3 Qual usar

SituaçãoEscolha
Sequência visual curta ligada a um objeto (piscar, esperar uma animação)Corrotina — para sozinha com o objeto
Esperar um resultado (rede, carregamento) e usá-loasync Awaitable com cancelamento
Cálculo pesado sem API do motorAwaitable.BackgroundThreadAsync — ou Jobs, se for muito (módulo 8)
✏️ Exercício 5 — A requisição órfã

Um painel faz uma requisição de 3 segundos em async void Start() e, no fim, escreve o resultado num texto. Se o jogador fecha o painel antes, aparece um erro. Corrija.

Gabarito: Trocar async void por async Awaitable (exceções em async void são difíceis de apanhar), passar destroyCancellationToken à espera/requisição, e tratar OperationCanceledException em silêncio. Assim, fechar o painel cancela a continuação em vez de a deixar escrever num texto destruído.

MÓDULO 06 · INTERMEDIÁRIO

Eventos e comunicação entre objetos

Objetivo: escolher a forma certa de um objeto avisar outros, sem acoplar tudo a tudo nem vazar memória.

6.1 As opções

MecanismoVantagemCusto / risco
Evento C# (event Action<T>)Rápido, tipado, claro no códigoNão aparece no Inspector; esquecer de desinscrever vaza e causa erros
UnityEventLigável no Inspector por designers, sem códigoMais lento, ligações invisíveis no código (difícil de rastrear)
Canal em ScriptableObjectDesacopla emissor e ouvintes, que só conhecem o asset do canalMais assets para gerir; fluxo menos explícito
Interface + TryGetComponentÓtimo para interações pontuais (IDanificavel)Exige uma referência ao objeto
SendMessage—Por nome de método em string, lento, sem verificação — evitar

6.2 O par OnEnable / OnDisable

void OnEnable()  => jogador.VidaMudou += AtualizarBarra;
void OnDisable() => jogador.VidaMudou -= AtualizarBarra;

Inscrever em OnEnable e desinscrever em OnDisable cobre ativação, desativação e destruição. Um evento que continua a apontar para um componente destruído é uma das fontes mais comuns de MissingReferenceException intermitente e de vazamento de memória.

✏️ Exercício 6 — Escolha o mecanismo

(a) Um botão de UI que um designer quer ligar a várias ações sem programador; (b) a vida do jogador que atualiza a barra, o som e a câmara; (c) um projétil que causa dano em qualquer coisa que o possa receber.

Gabarito: (a) UnityEvent (o próprio Button.onClick já é um). (b) Evento C# no componente de vida, com os três ouvintes a inscrever-se em OnEnable. (c) Interface IDanificavel e TryGetComponent no objeto atingido.

MÓDULO 07 · AVANÇADO

Memória, alocações e o GC

Objetivo: entender por que alocar memória por quadro causa engasgos, reconhecer as alocações escondidas mais comuns, e eliminá-las.

7.1 Por que o GC incomoda mais no Unity

O coletor de lixo do Unity não é geracional: quando corre, examina todo o heap gerenciado. Com o GC incremental (ativo por padrão nas versões recentes), o trabalho é dividido por vários quadros, o que reduz os picos — mas não elimina o custo. A regra de ouro continua: no código que corre a cada quadro, alocar zero.

7.2 Alocações escondidas

CódigoAloca porque…Alternativa
texto.text = "Vida: " + vida; em UpdateCria uma string nova a cada quadroAtualizar só quando a vida muda
LINQ em Update (inimigos.Where(...).ToList())Enumeradores, delegates e a listaLaço for numa lista reutilizada
Lambda que captura variáveis locaisCria um objeto de fecho (closure)Método normal ou lambda sem capturas
Passar um struct como object ou interfaceBoxingGenéricos
Physics.RaycastAll, GetComponentsDevolvem arrays novosVersões NonAlloc / que recebem uma lista
Instantiate/Destroy de balas, partículasCriação e destruição constantesPooling

7.3 Pooling

using UnityEngine.Pool;

ObjectPool<Bala> _balas;
void Awake() => _balas = new ObjectPool<Bala>(
    createFunc:      () => Instantiate(prefab),
    actionOnGet:     b => b.gameObject.SetActive(true),
    actionOnRelease: b => b.gameObject.SetActive(false),
    defaultCapacity: 32);

// disparar: var b = _balas.Get();   ao acertar: _balas.Release(b);

Para medir, use a coluna GC Alloc do Profiler (em build de desenvolvimento no aparelho): o objetivo é 0 B por quadro durante a jogabilidade. Performance em aparelhos móveis e XR está em Meta Quest 3, módulos 6 e 7.

✏️ Exercício 7 — Caça às alocações

Encontre as quatro alocações neste Update: var perto = FindObjectsOfType<Inimigo>().Where(e => Vector3.Distance(e.transform.position, transform.position) < raio).ToList(); placar.text = "Perto: " + perto.Count;

Gabarito: (1) FindObjectsOfType devolve um array novo (e é lento — mantenha uma lista registada de inimigos). (2) A lambda captura this/raio → fecho. (3) Where/ToList → enumerador e lista nova. (4) Concatenação de string. Reescrita: laço for sobre uma lista estática de inimigos, contando num int, comparando sqrMagnitude com raio*raio, e atualizar o texto só quando a contagem muda.

MÓDULO 08 · AVANÇADO

C# de alto desempenho: Jobs e Burst

Objetivo: conhecer o sistema de Jobs e o compilador Burst, o subconjunto de C# que eles aceitam, e quando o esforço compensa.

8.1 O problema e a resposta

O código de jogo típico corre numa só thread, com objetos espalhados pela memória. Para processar dezenas de milhares de elementos (partículas próprias, multidões, simulações, geração procedural), a Unity oferece duas peças:

8.2 Um job em miniatura

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

[BurstCompile]
struct MoverJob : IJobParallelFor
{
    public NativeArray<float3> posicoes;
    [ReadOnly] public NativeArray<float3> velocidades;
    public float dt;

    public void Execute(int i) => posicoes[i] += velocidades[i] * dt;
}

// agendar:  var h = new MoverJob { posicoes = p, velocidades = v, dt = Time.deltaTime }.Schedule(p.Length, 64);
//           h.Complete();   // antes de ler os resultados
// NativeArray não é gerido pelo GC: faça Dispose() quando não precisar mais.

8.3 O C# que o Burst aceita

Burst compila um subconjunto chamado HPC# (High Performance C#): structs e tipos não gerenciados, NativeArray e outras coleções nativas, e o pacote Mathematics. Não aceita classes, strings gerenciadas, referências a GameObject ou componentes, nem exceções no caminho normal. É outro estilo de programar — orientado a dados.

8.4 ECS / DOTS

O pacote Entities (a parte "ECS" da pilha orientada a dados, DOTS) leva a ideia ao extremo: entidades sem GameObject, componentes como dados puros, sistemas que processam tudo em lote. Compensa em jogos com enormes quantidades de elementos; para a maioria dos projetos, Jobs + Burst em pontos quentes específicos dão a maior parte do ganho com muito menos mudança.

✏️ Exercício 8 — Vale a pena?

Para cada caso, diga se Jobs/Burst compensam: (a) um menu com 20 botões; (b) 30 000 peixes num cardume com regras de vizinhança; (c) a IA de 5 inimigos com árvores de decisão complexas; (d) gerar um terreno procedural de 1024×1024 ao carregar o nível.

Gabarito: (a) Não. (b) Sim — é o caso clássico: muitos elementos, dados simples, a mesma operação. (c) Normalmente não — poucos elementos e lógica cheia de ramificações e referências. (d) Sim — cálculo massivo e independente por ponto, ideal para IJobParallelFor com Burst.

MÓDULO 09 · MUITO AVANÇADO

IL2CPP, stripping, editor e domain reload

Objetivo: entender o que acontece ao seu C# no build e no editor, e os bugs que só aparecem num dos dois.

9.1 IL2CPP e AOT

Com IL2CPP, o código C# é convertido em C++ e compilado antes da execução (AOT). É obrigatório em plataformas como iOS, WebGL e consolas, e costuma ser mais rápido. Consequência: não há geração de código em tempo de execução. Reflection.Emit e expressões compiladas não funcionam, e alguns usos de genéricos só descobertos em execução podem falhar se o compilador não os tiver previsto.

9.2 Managed code stripping

Para reduzir o tamanho do build, o Unity remove código que parece não ser usado. Código chamado só por reflexão (serializadores, injeção de dependências, alguns plugins) parece não usado — e desaparece. O sintoma: funciona no editor, falha no build com erros de tipo ou método em falta. Soluções: [Preserve] nos tipos e métodos, ou um arquivo link.xml listando o que manter, e um nível de stripping menos agressivo enquanto se investiga.

9.3 Código de editor

9.4 Domain reload e campos estáticos

Ao entrar no Play Mode, o Unity recarrega por padrão todo o código (domain reload), o que repõe os campos estáticos. Muitas equipas desativam esse recarregamento (Enter Play Mode Options) para entrar em Play em segundos em vez de dezenas de segundos. Consequência: campos estáticos e eventos estáticos mantêm os valores da sessão anterior. Um singleton que "já existe" ou uma contagem que começa em 37 são os sintomas. A solução é repor o estado explicitamente:

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Repor() { _instancia = null; TotalInimigos = 0; }
✏️ Exercício 9 — Funciona no editor

Um sistema de save usa uma biblioteca que encontra, por reflexão, todas as classes que implementam ISalvavel. No editor tudo funciona; no build Android com IL2CPP, nada é salvo e não há erro. Hipótese e correção?

Gabarito: Stripping: as classes ISalvavel só são referenciadas por reflexão, e o linker removeu-as (ou removeu os seus construtores). Correção: marcar as classes com [Preserve] ou incluí-las num link.xml; melhor ainda, registar as classes explicitamente num ponto do código em vez de depender de descoberta por reflexão.

MÓDULO 10 · CARREIRA

Entrevistas, portfólio e fontes

Objetivo: preparar as perguntas técnicas mais comuns sobre C# em Unity e mostrar essas competências num portfólio.

10.1 Perguntas frequentes

"Qual a diferença entre Awake e Start?"

Awake corre quando o objeto é carregado, mesmo com o componente desativado, e serve para inicializar o próprio estado; Start corre antes do primeiro Update, só se ativo, e é onde é seguro depender de outros objetos. A ordem de Awake entre objetos não é garantida.

"Por que não usar ?. com componentes?"

Porque UnityEngine.Object sobrecarrega == para objetos destruídos, mas ?. e ?? não usam essa sobrecarga: um objeto destruído passa pelo ?. e causa MissingReferenceException.

"O jogo engasga a cada poucos segundos. O que investiga?"

Coleta de lixo: Profiler no aparelho, coluna GC Alloc e marcador GC.Collect. Procuro alocações por quadro — strings, LINQ, closures, boxing, Instantiate/Destroy constantes — e corrijo com cache, laços simples, NonAlloc e pooling.

"Corrotina ou async?"

Corrotina para sequências curtas ligadas a um objeto (param com ele); async Awaitable para esperar resultados, sempre com CancellationToken. Nenhuma das duas é paralelismo — para isso, trabalho em background sem API do motor, ou Jobs.

"Funciona no editor mas não no build. Por onde começa?"

Ordem de Awake diferente, stripping de código usado só por reflexão, código de editor que não existe no build, geração de código em runtime em IL2CPP, e estado estático preso com domain reload desativado (esse é o inverso: afeta o editor).

10.2 Portfólio

  1. Um projeto com código limpo e comentado no GitHub: [SerializeField] private, eventos desinscritos, sem alocações por quadro (com captura do Profiler a mostrar 0 B).
  2. Uma demonstração de Jobs + Burst: a mesma simulação em MonoBehaviour e em jobs, com os tempos medidos.
  3. Uma ferramenta de editor que poupa trabalho real (validador de cenas, gerador de níveis) — mostra que você sabe estender o motor.
  4. Testes automatizados com o Unity Test Framework na lógica do jogo.

10.3 Fontes para continuar

🏁 Síntese final da apostila

Quatro ideias sustentam o C# no Unity: (1) o motor manda no tempo — ciclo de vida, quadros e passos de física decidem quando o seu código corre; (2) o Inspector tem regras próprias — serialização por campos, não por propriedades, e o valor guardado vence o do código; (3) objetos do motor têm dois lados — use == null, não ?., e lembre-se de que Destroy espera pelo fim do quadro; (4) memória é desempenho — zero alocações por quadro, pooling, e Jobs/Burst onde o volume justifica. E teste no build, não só no editor: IL2CPP, stripping e ordem de inicialização só se revelam lá.