Scripting Lua

Raycast no Roblox: a direção É o comprimento, e o streaming pode esconder o alvo

Raycast manda um raio invisível de um ponto numa direção com comprimento definido. Duas coisas derrubam quem está começando: o vetor de direção carrega o comprimento, e com streaming ligado o cliente pode não ter a peça que você quer acertar.

por Gustavo Cantino8 min de leituraIntermediário

Produzido com IA a partir das fontes citadas abaixo, e conferido por checagens automáticas contra essas mesmas fontes. Nenhuma pessoa leu este texto antes da publicação. Política Editorial

Placa tipográfica com a categoria Scripting Lua em destaque, o nível intermediário abaixo, e um campo de barras verticais verde-limão sobre fundo escuro.

Raycast é a ferramenta que responde "o que está naquela direção?" — e é a base de tiro, de mira, de detecção de chão, de linha de visão e de seleção por clique. A API é pequena, e mesmo assim tem dois detalhes que derrubam quase todo mundo na primeira semana.

O primeiro é que não existe parâmetro de alcance. O segundo é que, com streaming ligado, o raio pode atravessar uma parede que existe. Este guia cobre a API inteira e termina nos dois.

O problema

A definição oficial é precisa e já contém a armadilha: raycast é mandar um raio invisível de um ponto Vector3 numa direção específica com comprimento definido. Depois de lançado, dá para detectar se ele acertou uma BasePart ou uma célula de Terrain.

Repare que "direção" e "comprimento" aparecem juntos. O lançamento é pelo método Raycast do WorldRoot — na prática, workspace:Raycast(origem, direcao) — e a assinatura tem dois parâmetros de geometria, não três. Não há onde colocar o alcance.

A resposta está escrita: o parâmetro de direção precisa conter o comprimento desejado do raio. E o exemplo é literal: se a magnitude da direção é 10, o raio também terá comprimento 10.

Isso muda como se pensa o vetor. Ele não é uma seta apontando para onde olhar; é uma seta que chega até onde o raio termina. Vector3.new(0, -1, 0) não desce até o chão — desce um stud. Para descer cem, é Vector3.new(0, -100, 0), ou a direção unitária multiplicada pelo alcance.

Há um teto: o comprimento máximo é de quinze mil studs.

Passo a passo

1. Lance o raio. Origem, direção com o comprimento embutido. É o caso mais simples e cobre detecção de chão, que é direção para baixo vezes a profundidade que interessa.

2. Quando você tem dois pontos, subtraia. A direção é o destino menos a origem — e a magnitude dela já é exatamente a distância entre os dois, o que faz o raio terminar no destino sem sobrar nem faltar.

A documentação destaca que isso é útil quando os dois pontos mudam, como entre um personagem e outro. É o caso de linha de visão, de mira automática, de "este inimigo me enxerga?". E aqui não se normaliza o vetor: normalizar jogaria fora justamente a distância que se quer.

3. Filtre. O Raycast aceita um RaycastParams opcional, que manda incluir ou excluir certas BaseParts, ignorar água no Terrain ou usar um grupo de colisão. Quatro campos:

  • FilterDescendantsInstances — o array de objetos cujos descendentes entram na filtragem. Note a palavra: basta passar o modelo do personagem para todas as partes dele entrarem.
  • FilterType — Exclude, onde toda BasePart é considerada menos as descendentes dos objetos do array; ou Include, onde só as descendentes são consideradas.
  • IgnoreWater — decide se o material Água entra na conta contra o Terrain.
  • CollisionGroup — o nome do grupo de colisão usado na operação.

A escolha entre Exclude e Include costuma ser decidida pelo tamanho da lista. Tiro que ignora quem atirou é Exclude. Seleção que só aceita objetos de uma pasta é Include — e é mais seguro, porque um objeto novo no cenário não vira alvo por acidente.

4. Leia o resultado. Acertando um alvo elegível, um objeto RaycastResult é devolvido. Para testar se houve acerto, confirma-se que o resultado não é nil.

Quatro propriedades, e todas as quatro têm uso real:

  • Instance — a BasePart ou célula de Terrain que o raio interceptou.
  • Position — a posição Vector3 da interseção.
  • Distance — a distância entre a origem e o ponto de interseção.
  • Normal — o vetor normal da face interceptada.

A Normal é a mais subaproveitada. É ela que permite alinhar um decalque, uma marca de tiro ou um efeito de poeira à superfície atingida, em vez de colar tudo virado para o mesmo lado.

A Distance tem um uso que também passa batido: ela evita uma conta. Como o raio já mediu a distância até o ponto de impacto, não é preciso calcular a magnitude entre origem e Position depois — e é exatamente essa conta redundante que aparece em quase todo código de tiro. Usar a Distance direto é mais curto e não corre o risco de discordar do resultado.

E há o Material, que a documentação também expõe no resultado: é o Enum.Material da peça ou do terreno no ponto de interseção. Ele é o caminho mais barato para som de passo por superfície, partícula por tipo de chão e marca de impacto diferente em metal e em madeira — tudo isso sai de uma propriedade que o raio já devolveu, sem nenhuma estrutura de dados paralela dizendo de que cada peça é feita.

Artefato

O módulo abaixo embrulha as três operações que aparecem em quase todo jogo: lançar com alcance explícito, lançar entre dois pontos e achar o chão.

Duas decisões dele valem explicar. A primeira é a separação entre lancar e entre: uma normaliza a direção e aplica o alcance, a outra não normaliza, porque a distância entre os pontos já é o comprimento certo. Misturar as duas é a origem de metade dos raios com tamanho errado.

A segunda é o RaycastParams criado uma vez e reaproveitado. Num loop de tiro, criar um objeto por disparo é lixo por quadro sem motivo nenhum — e o único campo que muda de verdade entre chamadas é a lista de ignorados.

Erro comum

O erro campeão é o do vetor unitário. Alguém escreve direcao.Unit para "normalizar direito", e o raio passa a ter um stud de comprimento. Ele não acerta nada, o resultado é sempre nil, e não há erro nenhum — parece que o raycast não funciona.

O inverso também acontece, e é mais difícil de ver: usar destino - origem e depois multiplicar por um alcance. Aí o raio fica muito mais longo que a distância entre os pontos e acerta coisas atrás do alvo. A linha de visão passa a dizer que o inimigo está bloqueado por uma parede que está do outro lado dele.

A regra que resolve: pergunte se você já sabe onde o raio termina. Se sabe (há um destino), use a subtração e não toque no vetor. Se não sabe (é uma direção e um alcance), normalize e multiplique.

O segundo erro é o do streaming, e este é sério porque produz um jogo que funciona no Studio e falha no jogador. A documentação avisa: com streaming de instâncias ligado, o cliente pode não ter recebido peças distantes, e o raycast pode não detectar acerto.

O detalhe que piora é o seguinte: o cliente vê alguma coisa lá. O sistema de streaming gera Terrain e modelos de baixo detalhe para o horizonte — e eles são puramente visuais, não são alvos elegíveis de raycast. Ou seja, o jogador mira numa montanha que está na tela dele e o raio atravessa.

A consequência prática é uma regra de arquitetura: raycast que decide alguma coisa roda no servidor. O cliente pode lançar raios para retorno imediato — traçado da bala, mira, destaque do objeto sob o cursor. Quem decide se acertou é o servidor, que tem o mundo inteiro.

O terceiro erro é mais fácil de consertar: peças que não deveriam ser alvo e são. Efeito visual, moldura, aura, hitbox de gatilho — tudo isso bloqueia raio. Dá para isentar qualquer BasePart da detecção pondo o CanQuery dela em falso, e isso é mais limpo e mais barato que acrescentar a peça na lista de filtro de todo raycast do jogo.

Próximo passo

Com o básico no lugar, vale fazer uma varredura no cenário atrás de candidatos a CanQuery desligado. Objeto decorativo que nunca deveria ser atingido resolvido na propriedade é um objeto que nunca mais vai aparecer em lista de filtro nenhuma — e listas de filtro são exatamente o tipo de coisa que alguém esquece de atualizar quando acrescenta um efeito novo.

Depois disso, o assunto natural é o grupo de colisão. Quando o jogo tem várias categorias de raio — tiro que atravessa vidro, mira que não atravessa, seleção que só pega item —, o CollisionGroup do RaycastParams organiza isso de uma vez, em vez de cada chamada montar a própria lista.

E vale testar cedo com streaming ligado, se o seu jogo usa. É o tipo de diferença que não aparece no Studio com o mapa inteiro carregado, e que todo jogador encontra.

Fontes

Como corrigimos erros

Artefato pronto pra usar

Módulo de raycast com params reaproveitado, alcance explícito e o caso do streaming (ReplicatedStorage)
lua
-- ReplicatedStorage > ModuleScript "Mira"
--
-- Três coisas que este módulo existe para acertar:
--
--   1. O VETOR DE DIREÇÃO CARREGA O COMPRIMENTO. Não existe parâmetro
--      separado de alcance. Direção unitária vezes alcance.
--   2. O RaycastParams é REAPROVEITADO. Criar um por disparo num loop
--      de tiro é lixo por quadro sem motivo.
--   3. Com streaming ligado, um raio lançado NO CLIENTE pode não achar
--      peça distante. Quem decide acerto é o servidor.

local Mira = {}

local ALCANCE_MAXIMO = 15000 -- teto da plataforma, em studs

-- Um params por "tipo de consulta", criado uma vez.
local paramsDeTiro = RaycastParams.new()
paramsDeTiro.FilterType = Enum.RaycastFilterType.Exclude
paramsDeTiro.IgnoreWater = true

-- Lança de `origem` NA DIREÇÃO de `direcao`, por `alcance` studs.
-- `direcao` não precisa vir normalizada: a função normaliza e aplica o
-- alcance, que é justamente o passo que se esquece.
function Mira.lancar(origem, direcao, alcance, ignorar)
	if direcao.Magnitude == 0 then
		return nil
	end

	alcance = math.clamp(alcance or 100, 0, ALCANCE_MAXIMO)
	paramsDeTiro.FilterDescendantsInstances = ignorar or {}

	return workspace:Raycast(origem, direcao.Unit * alcance, paramsDeTiro)
end

-- Entre DOIS PONTOS: a direção é destino menos origem, e a magnitude
-- dela já é a distância entre eles. Não normalize aqui.
function Mira.entre(origem, destino, ignorar)
	local direcao = destino - origem
	if direcao.Magnitude == 0 or direcao.Magnitude > ALCANCE_MAXIMO then
		return nil
	end

	paramsDeTiro.FilterDescendantsInstances = ignorar or {}
	return workspace:Raycast(origem, direcao, paramsDeTiro)
end

-- Linha de visão entre dois personagens, ignorando os dois.
function Mira.enxerga(deCharacter, paraCharacter)
	local a = deCharacter and deCharacter:FindFirstChild("HumanoidRootPart")
	local b = paraCharacter and paraCharacter:FindFirstChild("HumanoidRootPart")
	if not a or not b then
		return false
	end

	local r = Mira.entre(a.Position, b.Position, { deCharacter, paraCharacter })
	-- nil = nada no caminho = enxerga.
	-- ATENÇÃO: no CLIENTE com streaming, nil também pode significar
	-- "a parede existe e não chegou aqui". Esta função só vale no
	-- servidor para decidir alguma coisa.
	return r == nil
end

-- Chão sob um ponto. Devolve posição e normal, que é o que se usa para
-- alinhar decalque, marca de tiro ou poeira à superfície.
function Mira.chaoSob(posicao, profundidade, ignorar)
	local r = Mira.lancar(posicao, Vector3.new(0, -1, 0), profundidade or 100, ignorar)
	if not r then
		return nil
	end
	return r.Position, r.Normal, r.Instance, r.Material
end

return Mira
Neste artigo

Continue por aqui