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, Radius0.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 oPlayerem torno do eixo Y comtransform.Rotate. Não há limite: o jogador pode dar quantas voltas quiser. - Pitch (
rotation.y) é acumulado numa variávelfloat pitche aplicado aoCameraRootcomo rotação local. OMathf.Clampentre -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.
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.



