wandres.dev
RAYCASTING · Detectar el clic en 3D

El Raycaster y su modelo

Qué es realmente un Raycaster en Three.js, cómo se construye un rayo, qué controlan near y far, y por qué la clase delega en cada objeto en lugar de intersecar ella misma.

⏱ 17 min

Raycaster es una de las clases más pequeñas de Three.js y una de las peor entendidas. No es un sistema de picking, no mantiene ningún índice espacial y no sabe nada de tu escena: es un rayo con dos límites y un mecanismo de delegación. Todo lo que hace de verdad ocurre dentro del método raycast() de cada objeto, y entender esa división de responsabilidades explica de golpe por qué el coste es el que es y dónde se puede intervenir.

🎯 Al terminar esta lección sabrás
  • Construir un Raycaster a mano con origen, dirección y límites, sin pasar por la cámara.
  • Explicar qué recortan near y far y por qué se miden desde el origen del rayo.
  • Configurar params para líneas y puntos y entender qué significa su umbral.
  • Describir el mecanismo de delegación y qué implica devolver false desde raycast().

Un rayo, dos límites, una capa

El constructor acepta cuatro argumentos y todos tienen un valor por defecto razonable:

import * as THREE from 'three';

const origen = new THREE.Vector3( 0, 5, 0 );
const direccion = new THREE.Vector3( 0, - 1, 0 );  // debe estar normalizada

const raycaster = new THREE.Raycaster( origen, direccion, 0, 50 );

La dirección tiene que estar normalizada. No es una recomendación: el cálculo de distancia asume módulo uno, y con un vector sin normalizar todas las distancias que devuelve el raycaster quedan escaladas por su módulo. Si construyes la dirección restando dos puntos, no olvides el .normalize().

near y far recortan el segmento útil del rayo, y se miden desde el origen, no desde la cámara ni desde el plano near de la proyección. Una intersección más cercana que near o más lejana que far se descarta después de haberse calculado, no antes: el filtro está al final de la comprobación de cada triángulo. Eso significa que reducir far no acelera el raycast contra una malla concreta, aunque sí evita que aparezcan resultados irrelevantes. near no puede ser negativo y far no puede ser menor que near.

Puedes cambiar el rayo en cualquier momento sin recrear el objeto:

raycaster.set( nuevoOrigen, nuevaDireccion );

// O tocar el Ray directamente.
raycaster.ray.origin.copy( jugador.position );
raycaster.ray.direction.set( 0, - 1, 0 );

raycaster.ray es un Ray, la clase de matemáticas que hace el trabajo geométrico real: intersectSphere, intersectBox, intersectTriangle, intersectPlane, closestPointToPoint. Si lo que necesitas es una comprobación geométrica pura y no tocar la escena, usa Ray directamente y ahórrate la ceremonia del raycaster.

La tercera propiedad de estado es layers, un Layers que se compara con el de cada objeto antes de intentar la intersección. Es el filtro más barato que existe porque es una operación de bits y ocurre antes de tocar geometría; lo verás a fondo en la lección de optimización.

params: el umbral de las cosas sin volumen

Un triángulo tiene área y un rayo lo atraviesa o no. Una línea y un punto no tienen área: la probabilidad de que un rayo infinitamente fino los toque exactamente es cero. Por eso Raycaster lleva un objeto de parámetros con un umbral en unidades de mundo para esos casos:

raycaster.params = {
	Mesh: {},
	Line: { threshold: 1 },
	LOD: {},
	Points: { threshold: 1 },
	Sprite: {}
};

threshold es la distancia máxima entre el rayo y la línea —o el punto— para considerar que hay impacto. El valor por defecto de uno es enorme para casi cualquier escena: en una escena en metros significa que aciertas cualquier punto que pase a menos de un metro del rayo. Si tu sistema de puntos tiene partículas de dos centímetros, el umbral debería estar en el orden de 0.02, no en uno.

// Puntos pequeños: umbral proporcional al tamaño visual.
raycaster.params.Points.threshold = 0.05;

// Lineas gruesas dibujadas con LineSegments: un poco mas que su ancho aparente.
raycaster.params.Line.threshold = 0.1;

El detalle que muerde es que el umbral está en unidades de mundo y es constante con la distancia, mientras que el tamaño aparente de un punto en pantalla depende de la perspectiva. Un umbral que funciona bien para puntos cercanos es demasiado pequeño para los lejanos y viceversa. Si necesitas precisión de picking constante en pantalla, la solución no es ajustar el umbral sino escalar el umbral por la distancia a la cámara antes de cada consulta, o pasarse directamente a picking por GPU.

Las entradas Mesh, LOD y Sprite están vacías porque esos objetos no necesitan tolerancia: tienen superficie. Existen en el objeto para que puedas añadirles propiedades desde un raycast personalizado.

Delegación: quién hace el trabajo

Este es el punto que cambia el modelo mental. intersectObject no interseca nada. Lo que hace es esto:

// Version simplificada de lo que ocurre en Raycaster.
function intersect( object, raycaster, intersects, recursive ) {

	let propagar = true;

	if ( object.layers.test( raycaster.layers ) ) {

		const resultado = object.raycast( raycaster, intersects );

		if ( resultado === false ) propagar = false;

	}

	if ( propagar && recursive ) {

		for ( const hijo of object.children ) {

			intersect( hijo, raycaster, intersects, true );

		}

	}

}

Tres consecuencias, todas útiles.

La primera: el raycaster recorre el grafo de escena, no una lista plana. Con recursive activado visita cada descendiente, comprueba sus capas y llama a su raycast. Ese recorrido tiene coste propio, proporcional al número de nodos, independientemente de que ninguno acierte.

La segunda: cada tipo de objeto implementa su propia geometría de intersección. Mesh.raycast compara contra triángulos, Points.raycast contra puntos con umbral, Line.raycast contra segmentos, LOD.raycast elige el nivel de detalle según la distancia y delega en él, y Object3D.raycast está vacío —un Group nunca interseca, solo propaga—. Si escribes tu propia clase derivada de Object3D, definir raycast es cómo la integras en el sistema.

La tercera, y la menos conocida: si raycast() devuelve false, el recorrido no baja a los hijos de ese objeto. Es un mecanismo de poda explícito, y es el gancho para implementar una jerarquía de volúmenes envolventes a mano:

class GrupoConVolumen extends THREE.Group {

	constructor() {

		super();
		this.volumen = new THREE.Sphere();

	}

	raycast( raycaster ) {

		// Si el rayo no toca la esfera del grupo, no hace falta mirar dentro.
		if ( raycaster.ray.intersectsSphere( this.volumen ) === false ) {

			return false;

		}

	}

}

Ese return false corta de raíz la exploración de un subárbol entero. Con una escena de mil objetos agrupados en veinte grupos con su esfera envolvente, un rayo que solo toca un grupo pasa de comprobar mil objetos a comprobar veinte esferas y cincuenta objetos. Es la optimización estructural más barata que existe y no requiere ninguna librería.

ℹ️
setFromXRController

Además de setFromCamera, el raycaster sabe construirse desde un controlador de WebXR con setFromXRController( controller ). Toma la posición y la orientación del controlador de su matrixWorld y lanza el rayo hacia su eje Z negativo, que es la convención de apuntado en XR. Es la base de cualquier puntero láser en realidad virtual y evita reconstruir la matriz a mano.

El recursive que cambió de valor por defecto

Hay un cambio de API que sigue rompiendo código portado y que casi nadie tiene presente: en las versiones actuales de Three.js, recursive vale true por defecto. Durante años la firma fue intersectObject( object, recursive = false ), y una barbaridad de tutoriales, respuestas de foro y código de producción se escribieron asumiendo ese valor. La firma actual es intersectObject( object, recursive = true, intersects = [] ). El síntoma cuando arrastras código antiguo es doblemente traicionero porque no falla, funciona de más: un raycaster.intersectObject( escena ) que antes solo comparaba contra la escena vacía ahora recorre el árbol completo, y de repente tu clic acierta cosas que no debería, o el frame se dispara de dos milisegundos a treinta porque estás intersecando la ciudad entera en lugar de un cubo. Al revés también duele: código nuevo escrito por alguien que aprendió con la firma vieja pasa false explícitamente “por si acaso”, y entonces el picking deja de funcionar sobre modelos glTF, porque un glTF cargado es siempre un Group con las mallas colgando como hijos y el Group no interseca nada. La regla que evita las dos trampas es no depender nunca del valor por defecto: escribe siempre el segundo argumento. Y cuando pases una lista a intersectObjects, plantéate si esa lista debería contener el objeto raíz con recursive activo o las mallas hoja con recursive desactivado; la segunda opción es casi siempre más rápida y siempre más predecible, porque te obliga a saber qué estás intersecando.

⚔️ Rayos sin cámara
  1. Lanza un rayo vertical hacia abajo desde la posición de un objeto y usa la primera intersección para apoyarlo sobre el suelo.
  2. Ajusta far para que el rayo solo detecte suelo a menos de tres unidades y comprueba qué pasa cuando el objeto cae de un precipicio.
  3. Crea un sistema de Points con partículas de dos centímetros y calibra params.Points.threshold hasta que el picking sea preciso.
  4. Escribe una subclase de Group con esfera envolvente que devuelva false desde raycast y verifica con un contador que sus hijos dejan de visitarse.
  5. Compara el tiempo de intersectObject( escena, true ) frente a intersectObjects( listaDeMallas, false ) sobre la misma escena.