Voltar para o Blog
Quest Log

Tiro Hitscan com Raycast na Unity (FPS)

Visão em primeira pessoa de uma arma disparando, com linha traçante até faíscas de impacto numa parede de concreto

Tiro com raycast na Unity para FPS: hitscan do centro da câmera, LayerMask, dano, cadência, munição, recarga, impacto e tracer, em C# do Unity 6.3 LTS.

Ferramenta:UnityC#

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:

  1. Em Edit > Project Settings > Tags and Layers, crie as layers Player e Enemy.
  2. Coloque o jogador (e os filhos dele) na layer Player.
  3. No Inspector da HitscanGun, em Hit Mask, marque tudo menos Player.

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.

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

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.