Voltar para o Blog
Quest Log

FPS Controller na Unity: Câmera em Primeira Pessoa do Zero

Visão em primeira pessoa de um corredor de teste com piso quadriculado, uma mira no centro da tela e as mãos de um personagem segurando uma ferramenta

Unity FPS controller do zero em C#: mouse look com limite vertical, cursor travado, CharacterController, pulo e o erro do deltaTime no mouse, no Unity 6.

Ferramenta:UnityC#

FPS Controller na Unity: Câmera em Primeira Pessoa do Zero

Um unity FPS controller tem duas metades: a câmera que olha com o mouse, com limite para não dar cambalhota, e o corpo que anda com WASD sem atravessar parede. Aqui você escreve as duas em um único FirstPersonController.cs, com CharacterController, Input System e C# para o Unity 6.3 LTS, incluindo travar o cursor, pular com gravidade e configurar a sensibilidade do jeito certo.

O ponto que separa este controller da maioria dos que circulam por aí é o mouse. Muito tutorial multiplica o delta do mouse por Time.deltaTime, e o resultado é uma mira que muda de sensibilidade conforme o FPS do jogo. A seção de sensibilidade explica por que isso acontece e como tratar mouse e controle de forma diferente.

Unity fps controller: estrutura do objeto

Monte esta hierarquia:

Player  (CharacterController + FirstPersonController)
└── CameraRoot  (vazio, na altura dos olhos)
    └── Main Camera
  • Player: CharacterController com Height 1.8, Radius 0.4, Center (0, 0.9, 0). O pivô fica nos pés.
  • CameraRoot: posição local (0, 1.6, 0), a altura dos olhos de um personagem de 1,8 m.
  • Main Camera: filha do CameraRoot, posição e rotação zeradas. Field of View entre 70 e 90 é o comum em FPS no PC.

Por que dois objetos para a rotação? A rotação horizontal (yaw, olhar para os lados) gira o corpo inteiro, para que "frente" do movimento acompanhe a mira. A rotação vertical (pitch, olhar para cima e para baixo) gira só o CameraRoot. Se o pitch girasse o corpo, a cápsula inclinaria e o personagem andaria para dentro do chão ao olhar para baixo.

O CameraRoot também é onde você pendura coisas que acompanham a visão: a arma, as mãos, uma lanterna. Elas herdam o pitch automaticamente.

Travar e esconder o cursor

Num FPS o cursor do sistema precisa sumir e ficar preso no centro da janela, senão ele sai da tela no primeiro giro rápido e o próximo clique vai para a área de trabalho. Duas linhas:

Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;

Com CursorLockMode.Locked, a Unity recentraliza o cursor a cada frame e o delta continua chegando normalmente. O script abaixo trava no Start, solta com Esc e trava de novo com um clique na tela. Essa volta pelo clique importa em dois casos: no Editor, onde o primeiro clique no Game view é que dá foco à janela, e em builds para navegador, onde o travamento do mouse só é permitido depois de uma ação do usuário.

O script FirstPersonController completo

Projetos novos do Unity 6 trazem o asset InputSystem_Actions como project-wide actions, já habilitado, com as ações Move, Look e Jump no mapa Player. O Look lê o delta do mouse e o analógico direito do controle. Crie FirstPersonController.cs:

using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(CharacterController))]
public class FirstPersonController : MonoBehaviour
{
    [Header("Olhar")]
    [SerializeField] private Transform cameraRoot;
    [SerializeField] private float mouseSensitivity = 0.1f;
    [SerializeField] private float gamepadSensitivity = 180f;
    [SerializeField] private float minPitch = -85f;
    [SerializeField] private float maxPitch = 85f;

    [Header("Movimento")]
    [SerializeField] private float walkSpeed = 4.5f;
    [SerializeField] private float sprintSpeed = 7f;

    [Header("Pulo e gravidade")]
    [SerializeField] private float jumpHeight = 1.2f;
    [SerializeField] private float gravity = -20f;
    [SerializeField] private float groundedVerticalVelocity = -2f;

    [Header("Referências")]
    [SerializeField] private CharacterController controller;

    private InputAction moveAction;
    private InputAction lookAction;
    private InputAction jumpAction;
    private InputAction sprintAction;
    private float pitch;
    private float verticalVelocity;

    private void Awake()
    {
        if (controller == null)
        {
            controller = GetComponent<CharacterController>();
        }

        moveAction = InputSystem.actions.FindAction("Move");
        lookAction = InputSystem.actions.FindAction("Look");
        jumpAction = InputSystem.actions.FindAction("Jump");
        sprintAction = InputSystem.actions.FindAction("Sprint");
    }

    private void Start()
    {
        LockCursor(true);
    }

    private void Update()
    {
        HandleCursor();

        if (Cursor.lockState == CursorLockMode.Locked)
        {
            Look();
        }

        Move();
    }

    private void Look()
    {
        Vector2 lookInput = lookAction.ReadValue<Vector2>();
        bool fromMouse = lookAction.activeControl != null && lookAction.activeControl.device is Mouse;

        Vector2 rotation = fromMouse
            ? lookInput * mouseSensitivity
            : lookInput * gamepadSensitivity * Time.deltaTime;

        transform.Rotate(Vector3.up, rotation.x, Space.Self);

        pitch -= rotation.y;
        pitch = Mathf.Clamp(pitch, minPitch, maxPitch);
        cameraRoot.localRotation = Quaternion.Euler(pitch, 0f, 0f);
    }

    private void Move()
    {
        bool grounded = controller.isGrounded;
        if (grounded && verticalVelocity < 0f)
        {
            verticalVelocity = groundedVerticalVelocity;
        }

        if (grounded && jumpAction.WasPressedThisFrame())
        {
            verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
        }

        verticalVelocity += gravity * Time.deltaTime;

        Vector2 input = Vector2.ClampMagnitude(moveAction.ReadValue<Vector2>(), 1f);
        bool sprinting = sprintAction != null && sprintAction.IsPressed();
        float speed = sprinting ? sprintSpeed : walkSpeed;

        Vector3 horizontal = (transform.right * input.x + transform.forward * input.y) * speed;
        Vector3 motion = new Vector3(horizontal.x, verticalVelocity, horizontal.z) * Time.deltaTime;

        CollisionFlags flags = controller.Move(motion);
        if ((flags & CollisionFlags.Above) != 0 && verticalVelocity > 0f)
        {
            verticalVelocity = 0f;
        }
    }

    private void HandleCursor()
    {
        if (Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame)
        {
            LockCursor(false);
        }
        else if (Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame
                 && Cursor.lockState != CursorLockMode.Locked)
        {
            LockCursor(true);
        }
    }

    private void LockCursor(bool locked)
    {
        Cursor.lockState = locked ? CursorLockMode.Locked : CursorLockMode.None;
        Cursor.visible = !locked;
    }
}

No Inspector, arraste o CameraRoot no campo Camera Root. Aperte Play, clique no Game view e o controller está funcionando.

Olhar com o mouse e limitar o ângulo

O Look() separa os eixos:

  • Yaw (rotation.x) gira o Player em torno do eixo Y com transform.Rotate. Não há limite: o jogador pode dar quantas voltas quiser.
  • Pitch (rotation.y) é acumulado numa variável float pitch e aplicado ao CameraRoot como rotação local. O Mathf.Clamp entre -85 e 85 impede que a câmera passe da vertical e vire de cabeça para baixo.

Por que guardar o pitch numa variável em vez de ler cameraRoot.localEulerAngles.x? Porque a Unity devolve ângulos de Euler entre 0 e 360. Olhar 10 graus para cima vira 350, e o Clamp(350, -85, 85) trava a câmera em 85, que é olhar para baixo. A variável própria mantém o valor no intervalo que você controla.

O sinal de menos em pitch -= rotation.y existe porque mover o mouse para cima gera Y positivo, e rotação positiva no eixo X inclina a câmera para baixo. Para oferecer "inverter eixo Y" nas opções, basta trocar o sinal.

Limitar em 85 e não em 90 evita o ponto exato da vertical, onde frente e cima se confundem e alguns cálculos de direção ficam instáveis.

Andar com Character Controller

O movimento usa transform.right e transform.forward do próprio Player. Como o yaw gira o corpo, forward já é a direção para onde a câmera olha no plano horizontal, sem precisar achatar vetor. O pitch não interfere, porque mora no CameraRoot.

O ClampMagnitude impede a diagonal mais rápida caso o input chegue com comprimento maior que 1. A operação de somar right * x com forward * y é a mesma de qualquer movimento relativo, e o post de matemática para jogos: vetores mostra por que ela funciona.

Um único controller.Move por frame, com horizontal e vertical juntos, mantém o isGrounded confiável.

Um FPS controller que anda e olha é a parte fácil de um jogo em primeira pessoa. Arma com recuo, tiro por raycast, inimigos que reagem, portas, HUD, sons de passo e uma fase com objetivo transformam esse script em um jogo, e é nessa integração que a maioria dos projetos solo empaca. Um curso de Unity que leva você por um projeto inteiro, peça por peça, resolve exatamente essa parte.

Curso de Unity
Quer fazer seu primeiro jogo na Unity?

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

Pulo e gravidade

O CharacterController não tem gravidade própria. O script acumula verticalVelocity com gravity * Time.deltaTime e manda junto no Move. No chão, a velocidade fica em -2 em vez de zero, para a cápsula continuar encostada no piso e o isGrounded não piscar.

A velocidade do pulo vem da fórmula Mathf.Sqrt(jumpHeight * -2f * gravity): com 1,2 m de altura e gravidade -20, o personagem sobe 1,2 m exatos. A checagem de CollisionFlags.Above zera a subida quando ele bate a cabeça. Coyote time, jump buffer e o isGrounded instável estão explicados com calma no post de pulo e gravidade com Character Controller desta mesma série.

Sensibilidade e o erro do deltaTime no mouse

Este é o ponto mais mal explicado em tutoriais de FPS. A documentação do Input System descreve o delta dos dispositivos de ponteiro assim: movimento em pixels acumulado (somado) durante o frame, e que volta a (0,0) a cada frame.

Ou seja: o delta do mouse já é "quanto o mouse andou neste frame". Se o jogo roda a 30 FPS, cada frame junta mais movimento; a 144 FPS, cada frame junta menos. Somando os frames de um segundo, o total é o mesmo: o quanto a mão do jogador moveu. Por isso a rotação certa é delta * sensibilidade, sem tempo.

Multiplicar por Time.deltaTime aplica a compensação de tempo duas vezes. A 144 FPS, o delta de cada frame já é pequeno e ainda é multiplicado por um deltaTime pequeno; a 30 FPS, os dois são grandes. O efeito prático é que o mesmo movimento de mão gira a câmera menos quando o FPS sobe, e a mira fica inconsistente justamente nos momentos de queda de desempenho.

O analógico do controle é o oposto. Ele não diz "quanto andou", diz "quanto está inclinado agora", um valor de -1 a 1 que se mantém enquanto o polegar segura. Isso é uma taxa, e taxa precisa de Time.deltaTime para virar graus por frame. O script decide qual fórmula usar com lookAction.activeControl.device is Mouse. O código dos Starter Assets da Unity faz a mesma distinção: multiplica o look por deltaTime só quando o dispositivo atual não é mouse.

Valores de partida: mouseSensitivity = 0.1 (graus por pixel) e gamepadSensitivity = 180 (graus por segundo com o analógico no fim). Exponha os dois num menu de opções; sensibilidade é preferência pessoal.

Se a binding de Look no seu asset tiver processors de escala, eles se somam à sensibilidade do script. Abra o InputSystem_Actions e confira para não dobrar a escala.

Erros comuns

A câmera vira de cabeça para baixo. Pitch sem Clamp, ou clamp aplicado em localEulerAngles (que vai de 0 a 360). Use a variável pitch.

Olhar para baixo faz o personagem andar devagar ou afundar. O pitch está girando o Player em vez do CameraRoot.

Mira muda de sensibilidade com o FPS. Delta do mouse multiplicado por Time.deltaTime.

O mouse sai da janela. O cursor não está travado, ou foi destravado e não voltou. No Editor, clique no Game view.

Câmera treme ao andar. Algo está movendo o Player no FixedUpdate (um Rigidbody esquecido, por exemplo) enquanto a câmera roda no Update. Com CharacterController, tudo no Update.

Rigidbody junto com o CharacterController. Remova o Rigidbody. Os dois disputam a posição.

Próximo passo

Com o controller pronto, o passo natural de um FPS é atirar. O tutorial de tiro com raycast em FPS na Unity parte deste mesmo CameraRoot para disparar do centro da tela. E se quiser comparar com outra engine, o post de controle em primeira pessoa no Godot monta a mesma estrutura de yaw no corpo e pitch na cabeça, o que ajuda a separar o conceito da API.

Antes de seguir, teste o controller com Application.targetFrameRate = 30 e depois = 144 num script de teste, com o VSync do Game view desligado. Se o giro de um mesmo movimento de mouse ficar igual nos dois, a sensibilidade está certa.

Perguntas frequentes

Como travar e esconder o cursor na Unity?

Use Cursor.lockState = CursorLockMode.Locked e Cursor.visible = false, de preferência no Start ou quando o jogador clica na tela. Para soltar o mouse num menu de pausa, volte para CursorLockMode.None e Cursor.visible = true.

Devo multiplicar o delta do mouse por Time.deltaTime?

Não. No Input System, o delta do mouse já é o movimento em pixels acumulado durante o frame e volta a zero a cada frame. Multiplicar por deltaTime faz a sensibilidade mudar com o FPS. O analógico do controle é diferente: ele é uma taxa e precisa do deltaTime.

Existe um FPS controller pronto da Unity?

Sim. A Unity mantém de graça na Asset Store o pacote First Person + Third Person Character Controllers (os antigos Starter Assets). Em outubro de 2026 a página lista a versão 2.0.1 e compatibilidade com o Unity 6000.3 em URP. Vale estudar o código dele depois de entender o básico deste tutorial.

Como fazer FPS controller com o novo Input System?

Em projetos novos do Unity 6, o asset InputSystem_Actions já traz as ações Move, Look e Jump no mapa Player. Basta buscar cada uma com InputSystem.actions.FindAction e ler os valores no Update, como no script deste post.