wandres.dev
RAYCASTING · Detectar el clic en 3D

El objeto de intersección, campo a campo

Qué devuelve intersectObject, qué significa cada propiedad del resultado, cuáles pueden faltar y por qué, y cómo se usan uv, face, barycoord e instanceId en casos reales.

⏱ 18 min

El array que devuelve intersectObjects está ordenado por distancia y su primer elemento es el impacto más cercano. Hasta ahí, todo el mundo. Lo que casi nadie explota es el resto del objeto de intersección: la coordenada de textura exacta del impacto, la normal interpolada, las coordenadas baricéntricas, el índice de la instancia. Con esos campos se resuelven problemas que de otro modo requieren shaders enteros —pintar sobre una malla, colocar un decal, saber qué instancia has tocado— y con dos o tres líneas.

🎯 Al terminar esta lección sabrás
  • Enumerar los campos del objeto de intersección y saber cuáles pueden estar ausentes.
  • Usar uv para muestrear una textura desde JavaScript en el punto exacto del impacto.
  • Distinguir face.normal de normal y saber cuál usar en cada caso.
  • Identificar la instancia tocada dentro de un InstancedMesh y actuar sobre ella.

Los dos métodos y su forma

// Un objeto y, por defecto, todos sus descendientes.
const hits = raycaster.intersectObject( modelo, true );

// Varios objetos. Aqui casi siempre quieres recursive en false.
const hits = raycaster.intersectObjects( mallas, false );

// Tercer argumento: array reutilizable para no generar basura.
const buffer = [];

function consultar() {

	buffer.length = 0;
	raycaster.intersectObjects( mallas, false, buffer );
	return buffer;

}

Los dos ordenan el resultado por distance ascendente antes de devolverlo. El tercer argumento existe precisamente para el caso de consultar cada frame: reutilizar el array evita crear uno nuevo sesenta veces por segundo. No elimina toda la basura —cada intersección crea objetos Vector3 y Vector2 nuevos— pero quita la parte más gruesa.

intersectObjects con una lista no ordena los objetos entre sí antes de intersecar: recorre la lista completa y ordena al final. No hay early-out por proximidad. Si te basta con el primer impacto y tienes muchos objetos, ese es exactamente el trabajo que ahorra un BVH, que verás en la lección siguiente.

Campo a campo

Campo Tipo Siempre presente
distance number
point Vector3 Sí, en coordenadas de mundo
object Object3D
face objeto Solo en Mesh
faceIndex number Solo en Mesh
barycoord Vector3 Solo en Mesh
uv Vector2 Solo si la geometría tiene atributo uv
uv1 Vector2 Solo si la geometría tiene atributo uv1
normal Vector3 Solo si la geometría tiene atributo normal
instanceId number Solo en InstancedMesh
distanceToRay number Solo en Points

point está en coordenadas de mundo. Es el error más frecuente al colocar algo en el punto de impacto: si el objeto tocado es hijo de un grupo transformado y añades el marcador a ese mismo grupo, aparecerá desplazado. O añades el marcador a la escena raíz, o conviertes el punto al espacio local del padre:

const marcador = new THREE.Mesh( geoMarcador, matMarcador );
grupo.add( marcador );

// Convertir de mundo a local del padre.
marcador.position.copy( grupo.worldToLocal( hit.point.clone() ) );

face describe el triángulo tocado: a, b, c son los índices de sus tres vértices en la geometría, normal es la normal geométrica del triángulo —calculada con el producto vectorial de sus lados, no interpolada— y materialIndex indica el grupo de material al que pertenece cuando la malla tiene varios.

normal, sin más, es la normal interpolada a partir del atributo normal de los tres vértices usando las coordenadas baricéntricas. Además, Three.js la voltea si apunta en el mismo sentido que el rayo, para que siempre mire hacia quien dispara.

La diferencia entre las dos importa mucho:

// Colocar un objeto pegado a la superficie con orientacion suave.
if ( hit.normal ) {

	marcador.position.copy( hit.point ).addScaledVector( hit.normal, 0.01 );
	marcador.lookAt( hit.point.clone().add( hit.normal ) );

}

// Rebotar una particula: aqui interesa la normal plana del triangulo,
// pero hay que pasarla a mundo, porque face.normal esta en espacio local.
const normalMundo = hit.face.normal
	.clone()
	.transformDirection( hit.object.matrixWorld );

velocidad.reflect( normalMundo );

Ese detalle —face.normal está en espacio local de la malla y hit.normal también— es responsable de decals que se orientan mal en cuanto el objeto está rotado. transformDirection aplica solo la parte rotacional de la matriz y renormaliza, que es exactamente lo que necesita un vector de dirección.

barycoord son los pesos del punto de impacto respecto a los tres vértices del triángulo. Suman uno y sirven para interpolar cualquier atributo de vértice, no solo los que Three.js interpola por ti:

// Interpolar un atributo propio, por ejemplo un peso de terreno.
const attr = hit.object.geometry.getAttribute( 'humedad' );
const { a, b, c } = hit.face;
const { x, y, z } = hit.barycoord;

const humedad =
	attr.getX( a ) * x +
	attr.getX( b ) * y +
	attr.getX( c ) * z;

uv es la coordenada de textura del punto exacto, ya interpolada. Es la puerta a un conjunto de técnicas que sorprende lo poco que se usan:

// Leer el color del mapa de la malla en el punto tocado, desde JavaScript.
const textura = hit.object.material.map;
const img = textura.image;

const lienzo = document.createElement( 'canvas' );
lienzo.width = img.width;
lienzo.height = img.height;

const ctx = lienzo.getContext( '2d', { willReadFrequently: true } );
ctx.drawImage( img, 0, 0 );

const px = Math.floor( hit.uv.x * img.width );
const py = Math.floor( ( 1 - hit.uv.y ) * img.height );   // ojo al eje V

const [ r, g, b ] = ctx.getImageData( px, py, 1, 1 ).data;

El mismo uv permite pintar sobre una textura de datos y marcar needsUpdate, que es el esqueleto de cualquier herramienta de pintado 3D en el navegador.

instanceId y el picking sobre instancias

Con InstancedMesh hay un único objeto y una única geometría, pero miles de copias. Mesh.raycast no serviría, así que InstancedMesh implementa el suyo: transforma el rayo al espacio de cada instancia, interseca, y añade instanceId al resultado.

const malla = new THREE.InstancedMesh( geometria, material, 5000 );

// ... rellenar matrices con setMatrixAt ...

const hits = raycaster.intersectObject( malla, false );

if ( hits.length > 0 ) {

	const id = hits[ 0 ].instanceId;

	// Cambiar el color solo de esa instancia.
	malla.setColorAt( id, new THREE.Color( 0xff4444 ) );
	malla.instanceColor.needsUpdate = true;

	// O leer y modificar su matriz.
	const m = new THREE.Matrix4();
	malla.getMatrixAt( id, m );

}

El coste, eso sí, es lineal en el número de instancias: el raycast prueba todas. Con cinco mil cubos son cinco mil transformaciones de rayo más la intersección con doce triángulos cada una. Es una de las situaciones donde más se nota la aceleración espacial, y three-mesh-bvh tiene soporte específico para ello.

hits[0].object en este caso es la propia InstancedMesh, no una malla individual. La instancia se identifica solo por instanceId, así que si necesitas asociar datos a cada instancia, mantén un array paralelo indexado por ese número.

El orden por distancia es más caro de lo que parece, y a veces miente

Dos cosas del ordenamiento final que conviene interiorizar. La primera es de coste: intersects.sort( ascSort ) se ejecuta sobre todas las intersecciones encontradas, no sobre las que te interesan. Si lanzas un rayo contra un modelo de doscientos mil triángulos con side: DoubleSide y el rayo lo atraviesa longitudinalmente, puedes acabar con cientos de impactos en el array, todos calculados con su point, su uv, su normal y su barycoord —cada uno de ellos con varios objetos nuevos recién creados— para después ordenarlos y quedarte con el primero. El trabajo desperdiciado es enorme y es invisible en el perfilador porque aparece repartido entre checkGeometryIntersection y el recolector de basura. Si solo necesitas el impacto más cercano, la solución no es leer hits[0], es usar una estructura que sepa parar en el primero, que es literalmente lo que hace raycaster.firstHitOnly con un BVH. La segunda cosa es de corrección: el orden por distancia es el orden geométrico, no el visual. Un objeto con depthTest: false, o con renderOrder manipulado, o transparente y dibujado encima de todo, sigue estando geométricamente donde está. Un botón de interfaz flotante que se dibuja siempre encima puede tener detrás, en el sentido del rayo, un objeto de la escena que hits[0] devolverá como ganador. Cuando mezcles capas visuales que no respetan la profundidad —overlays, gizmos, interfaz en 3D— no confíes en el primer elemento: separa el picking en pasadas por capas, de la más superficial a la más profunda, y para en la primera que devuelva algo. Es más código y es el único que se comporta como espera el usuario.

⚔️ Explota los campos que nadie usa
  1. Coloca una esfera pequeña en el punto de impacto separada un milímetro por la normal interpolada.
  2. Compara visualmente el resultado usando hit.normal frente a hit.face.normal sobre una esfera de pocos segmentos.
  3. Lee el color de la textura difusa en hit.uv y muéstralo en un cuadrado de la interfaz.
  4. Monta un InstancedMesh de 5000 cubos y cambia el color de la instancia tocada con setColorAt.
  5. Añade un atributo propio a una geometría e interpólalo con barycoord en el punto de impacto.