Tiro Hitscan com Raycast na Unity (FPS)
Tiro com raycast na Unity (o unity raycast shooting de quase todo FPS) funciona assim: a cada disparo, você traça um raio do centro da câmera para frente com Physics.Raycast, e o primeiro collider atingido leva o dano no mesmo frame. Aqui você monta uma arma hitscan completa em C# do Unity 6.3 LTS: raio pela mira, LayerMask, dano e força no alvo, cadência, munição, recarga, muzzle flash, marca de impacto e linha traçante.
E no começo você decide se hitscan é mesmo o que o seu jogo precisa, porque a resposta muda o código inteiro.
Hitscan vs projétil: quando cada um faz sentido
Hitscan é instantâneo. Não existe bala na cena, existe uma pergunta à física: "o que está nesta linha?". Vantagens: barato (uma consulta por tiro), nunca atravessa alvo fino, precisão total. Ideal para pistola, rifle, metralhadora, laser.
Projétil físico é um objeto com Rigidbody que viaja. Vantagens: tempo de voo, queda, ricochete, o inimigo pode desviar, o jogador vê o tiro. Ideal para foguete, granada, flecha, plasma lento.
O critério prático: se o jogador precisa antecipar o alvo (mirar na frente de quem corre), é projétil. Se mirar e clicar resolve, é hitscan. Muitos jogos misturam: rifle hitscan e lança foguetes por projétil, na mesma arma base. Se o seu caso é o de projétil, o post de sistema de tiro e projéteis monta essa versão.
Tiro com raycast na Unity a partir da câmera
O alvo precisa de um contrato para receber dano. Em IDamageable.cs:
using UnityEngine;
public interface IDamageable
{
void TakeDamage(int amount, Vector3 hitPoint);
}
Em 3D, o segundo parâmetro é o ponto exato do acerto, útil para efeitos de sangue, faísca ou dano localizado (cabeça vale mais).
Agora a arma. Crie HitscanGun.cs e coloque no objeto da arma, filho da câmera do jogador:
using System.Collections;
using UnityEngine;
using UnityEngine.InputSystem;
public class HitscanGun : MonoBehaviour
{
[Header("Referências")]
[SerializeField] private Camera cam;
[SerializeField] private Transform muzzle;
[Header("Tiro")]
[SerializeField] private LayerMask hitMask = ~0;
[SerializeField] private float range = 100f;
[SerializeField] private int damage = 10;
[SerializeField] private float impactForce = 4f;
[SerializeField] private float shotsPerSecond = 10f;
[SerializeField] private bool automatic = true;
[Header("Munição")]
[SerializeField] private int magazineSize = 30;
[SerializeField] private float reloadTime = 1.6f;
[Header("Efeitos")]
[SerializeField] private ParticleSystem muzzleFlash;
[SerializeField] private GameObject impactPrefab;
[SerializeField] private LineRenderer tracer;
[SerializeField] private float tracerDuration = 0.03f;
private InputAction attackAction;
private float nextFireTime;
private int ammo;
private bool isReloading;
public int Ammo => ammo;
public bool IsReloading => isReloading;
private void Awake()
{
attackAction = InputSystem.actions.FindAction("Attack");
ammo = magazineSize;
if (tracer != null)
{
tracer.enabled = false;
}
}
private void OnDisable()
{
isReloading = false;
if (tracer != null)
{
tracer.enabled = false;
}
}
private void Update()
{
if (isReloading || attackAction == null)
{
return;
}
bool reloadPressed = Keyboard.current != null && Keyboard.current.rKey.wasPressedThisFrame;
if (reloadPressed && ammo < magazineSize)
{
StartCoroutine(Reload());
return;
}
bool wantsToFire = automatic ? attackAction.IsPressed() : attackAction.WasPressedThisFrame();
if (!wantsToFire || Time.time < nextFireTime)
{
return;
}
if (ammo <= 0)
{
StartCoroutine(Reload());
return;
}
nextFireTime = Time.time + 1f / shotsPerSecond;
Shoot();
}
private void Shoot()
{
ammo--;
if (muzzleFlash != null)
{
muzzleFlash.Play();
}
Ray ray = cam.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f));
Vector3 endPoint = ray.origin + ray.direction * range;
if (Physics.Raycast(ray, out RaycastHit hit, range, hitMask, QueryTriggerInteraction.Ignore))
{
endPoint = hit.point;
IDamageable target = hit.collider.GetComponentInParent<IDamageable>();
if (target != null)
{
target.TakeDamage(damage, hit.point);
}
if (hit.rigidbody != null)
{
hit.rigidbody.AddForceAtPosition(ray.direction * impactForce, hit.point, ForceMode.Impulse);
}
SpawnImpact(hit);
}
if (tracer != null)
{
StartCoroutine(ShowTracer(muzzle.position, endPoint));
}
}
private void SpawnImpact(RaycastHit hit)
{
if (impactPrefab == null)
{
return;
}
Quaternion rotation = Quaternion.LookRotation(hit.normal);
GameObject impact = Instantiate(impactPrefab, hit.point + hit.normal * 0.01f, rotation);
impact.transform.SetParent(hit.collider.transform, true);
Destroy(impact, 8f);
}
private IEnumerator ShowTracer(Vector3 start, Vector3 end)
{
tracer.SetPosition(0, start);
tracer.SetPosition(1, end);
tracer.enabled = true;
yield return new WaitForSeconds(tracerDuration);
tracer.enabled = false;
}
private IEnumerator Reload()
{
isReloading = true;
yield return new WaitForSeconds(reloadTime);
ammo = magazineSize;
isReloading = false;
}
}
O coração está em duas linhas. cam.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f)) cria um raio que sai da câmera passando pelo centro exato da tela, onde está a crosshair. Viewport vai de 0 a 1 nos dois eixos, então 0,5 e 0,5 é o meio, em qualquer resolução. E Physics.Raycast(ray, out RaycastHit hit, range, hitMask, QueryTriggerInteraction.Ignore) devolve true se acertou algo, preenchendo hit com o ponto (hit.point), a normal da superfície (hit.normal), o collider (hit.collider), o Rigidbody se houver (hit.rigidbody) e a distância (hit.distance).
LayerMask e distância máxima
Sem LayerMask, o raio acerta a primeira coisa no caminho, e a primeira coisa costuma ser um collider do próprio jogador (a arma, um braço, um filho com collider na frente da câmera) ou um trigger de zona invisível. A cápsula de onde o raio parte não conta: a física 3D ignora o collider em que a origem do raio está dentro.
Configure assim:
- Em Edit > Project Settings > Tags and Layers, crie as layers
PlayereEnemy. - Coloque o jogador (e os filhos dele) na layer
Player. - No Inspector da
HitscanGun, em Hit Mask, marque tudo menosPlayer.
O QueryTriggerInteraction.Ignore faz o raio passar direto por triggers (zonas de checkpoint, áreas de som, detectores de inimigo). Sem ele, o comportamento segue a configuração global Queries Hit Triggers em Physics, e é comum um tiro sumir num trigger que ninguém lembra que existe.
A distância (range) define até onde o tiro vale. Para pistola, 50 a 100 metros. Para sniper, 500 ou mais. Distância infinita (Mathf.Infinity) funciona, mas um teto deixa o jogo previsível e o raycast mais barato.
Aplicar dano e força no alvo
O dano vai pela interface: GetComponentInParent<IDamageable>() procura no collider atingido e nos pais, porque em personagem 3D o collider costuma estar num osso filho (cabeça, tronco). Para headshot, coloque um componente Hitbox no collider da cabeça com um multiplicador e leia esse valor antes de aplicar o dano.
A força usa AddForceAtPosition com ForceMode.Impulse no ponto do acerto, então um barril atingido na borda gira, e não só desliza. Isso vale para qualquer objeto com Rigidbody, inclusive ragdoll de inimigo morto. Mantenha a força baixa (2 a 6): física exagerada vira comédia.
Cadência, munição e recarga
A cadência segue a mesma lógica de qualquer arma: nextFireTime guarda o instante do próximo tiro permitido e avança 1 / shotsPerSecond a cada disparo. Com 10 tiros por segundo, 0,1 s entre tiros. O booleano automatic troca entre segurar (IsPressed) e clicar (WasPressedThisFrame), ou seja, entre metralhadora e pistola com o mesmo script.
A recarga é a única coroutine do sistema, e faz sentido ser: é um estado com duração que bloqueia o tiro. O OnDisable zera isReloading porque, se o jogador trocar de arma no meio da recarga, a coroutine morre junto com o objeto desativado e a arma voltaria travada para sempre. Esse bug aparece no dia em que você adiciona troca de armas.
Recarga automática ao tentar atirar sem munição é uma escolha de design. Jogos táticos preferem o "clique seco" e obrigam o R. Troque StartCoroutine(Reload()) por um som de clique se for o seu caso.
Neste ponto a arma está completa sozinha. O que costuma travar o projeto é tudo em volta: o FPS controller, a câmera com recuo, a HUD de munição ouvindo a arma certa, inimigo com vida, animação de recarga sincronizada com o tempo do código, troca de armas sem quebrar estado. Montar um jogo inteiro com essas peças conversando, em vez de cada uma num vídeo isolado, é o que um curso de Unity com projeto completo faz você praticar.
Efeitos: muzzle flash, impacto e tracer
O tiro hitscan não tem bala visível, então os efeitos são a bala para o jogador. Três camadas:
Muzzle flash. Um ParticleSystem filho do muzzle, com Duration curta (0,05 s), Looping desligado, Start Lifetime 0,05, Emission em Burst de 1 a 3 partículas e Simulation Space Local. Play() a cada tiro. Uma Point Light que pisca junto vende muito mais o disparo.
Impacto. O prefab de impacto nasce em hit.point, levemente afastado pela normal (hit.normal * 0.01f) para não brigar em profundidade com a superfície (o famoso z-fighting). Para partículas de faísca, a rotação Quaternion.LookRotation(hit.normal) faz elas espirrarem para fora da parede. O impacto vira filho do objeto atingido para acompanhar porta que abre ou inimigo que anda. Cuidado com objetos de escala não uniforme: o filho herda a deformação.
Para a marca de tiro na URP, use um Decal Projector. Ele só aparece se o renderer tiver a feature de decal: selecione o asset do URP Renderer usado pelo projeto (no template 3D da URP, os assets ficam em Assets/Settings), clique em Add Renderer Feature e escolha Decal. O Decal Projector projeta ao longo do eixo Z local dele, então a rotação da marca é Quaternion.LookRotation(-hit.normal), apontando para dentro da superfície. É o oposto da partícula, e é por isso que tanta marca de tiro some ou aparece do lado errado.
Tracer. Um LineRenderer com 2 posições, largura de 0,02 a 0,04 e material com shader unlit aditivo. Ele vai do cano até o ponto de acerto (ou até o fim do alcance, se errou), aparece por 2 frames e some. Mesmo sem bala física, o jogador lê a trajetória. Para o tiro ganhar peso de verdade (recuo de câmera, hit stop, som em camadas), veja game feel e juice.
Erros comuns: tiro saindo do cano errado, acertando o próprio jogador
O tiro não acerta onde a mira aponta. O raio está saindo do cano (muzzle.position com muzzle.forward). O cano fica deslocado da câmera, e o erro cresce em alvos próximos. Regra: a lógica sai da câmera, o visual sai do cano.
O tiro acerta o próprio jogador. A layer Player está marcada no Hit Mask, ou só o objeto raiz foi para a layer e os filhos (com colliders) ficaram em Default. Ao trocar a layer do raiz, a Unity pergunta se quer aplicar aos filhos: responda que sim.
O tracer atravessa a parede quando o jogador encosta nela. O cano entrou na parede, mas o raio da câmera bate antes. O resultado lógico está certo. Para o visual, se hit.distance for menor que a distância da câmera ao cano, não desenhe o tracer.
A marca de tiro não aparece. Falta a Renderer Feature de Decal, ou a rotação está com hit.normal em vez de -hit.normal.
A arma trava depois de trocar de arma. É a recarga interrompida sem resetar o estado. O OnDisable do script resolve.
Próximo passo
Com a arma hitscan pronta, o passo natural é o resto do FPS: movimento, pulo e câmera com mouse look, que é o tema de FPS controller em primeira pessoa na Unity. Depois, transforme os números da arma (dano, cadência, pente, recarga) num ScriptableObject para criar pistola, rifle e escopeta como assets diferentes usando o mesmo script.
Perguntas frequentes
O que é tiro hitscan?
É o tiro que acerta no mesmo frame em que é disparado, sem um objeto de bala viajando pela cena. A engine traça uma linha (raycast) da câmera para frente e o primeiro objeto atingido leva o dano. É o modelo de rifles e pistolas na maioria dos FPS.
Por que o raycast deve sair da câmera e não do cano da arma?
Porque a mira (crosshair) está no centro da câmera. Se o raio sair do cano, que fica deslocado para o lado e para baixo, o tiro não acerta exatamente onde o jogador está mirando, principalmente em alvos próximos. O raio sai da câmera e só o efeito visual sai do cano.
Como fazer o raycast não acertar o próprio jogador na Unity?
Coloque o jogador numa layer própria (Player) e deixe essa layer de fora da LayerMask usada no Physics.Raycast. Passe também QueryTriggerInteraction.Ignore para o tiro não parar em triggers invisíveis.
Quando usar projétil físico em vez de hitscan?
Use projétil quando o tempo de voo faz parte da jogabilidade: foguete, granada, flecha, sniper com queda de bala, ou tiro lento que o inimigo pode desviar. Para armas rápidas em distâncias curtas e médias, hitscan é mais simples e mais barato.



