Saltar al contenido

El coste real de un raycast

Por qué el raycast es lineal en triángulos, qué descartes tempranos aplica Three.js y cuál de ellos no calcula por ti, y cómo medir el coste real antes de decidir qué optimizar.

La secuencia de descartes

Mesh.raycast no salta directamente al bucle de triángulos. Aplica dos filtros antes, y merece la pena verlos en orden porque el segundo tiene una trampa.

Primer filtro: la esfera envolvente, en espacio de mundo. La geometría tiene una boundingSphere, y si no la tiene, Three.js la calcula ahí mismo con computeBoundingSphere(). Se copia, se transforma por la matrixWorld del objeto, y se comprueba si el rayo la toca. Si el origen del rayo está dentro de la esfera, se salta la prueba —ya está tocando—. Si no, se interseca; si no hay impacto, se sale. Y si lo hay, se comprueba además que la distancia hasta la esfera no supere el rango entre near y far.

Segundo filtro: la caja envolvente, en espacio local. El rayo se transforma al espacio local invirtiendo la matrixWorld, y entonces:

JS
if ( geometry.boundingBox !== null ) {	if ( _ray.intersectsBox( geometry.boundingBox ) === false ) return;}

Ahí está la trampa: la caja solo se prueba si ya existe. A diferencia de la esfera, Three.js no la calcula por ti. Y boundingBox es null por defecto en cualquier BufferGeometry recién creada. El resultado es que, en la inmensa mayoría de las escenas, ese segundo descarte —que es mucho más ajustado que la esfera para geometrías alargadas— nunca se ejecuta.

JS
// Una linea que cambia el coste de todos los raycasts contra esta malla.malla.geometry.computeBoundingBox();

Para una esfera, caja y esfera envolvente son casi equivalentes y no ganas nada. Para un pasillo, una escalera, un coche, un árbol, un personaje de pie —cualquier cosa con una dimensión mucho mayor que las otras— la esfera envolvente es enorme y la caja es ajustada. Un rayo que pasa por la esquina de la esfera pero fuera de la caja se ahorra el bucle completo de triángulos.

Tercer paso: el bucle. Sin más filtros. Por cada triángulo se reconstruyen las tres posiciones de vértice con getVertexPosition —que aplica morph targets si los hay—, se llama a Ray.intersectTriangle, y si acierta se calculan las coordenadas baricéntricas, se interpolan uv, uv1 y normal, se construye el objeto face y se hace push. Todo eso, por cada acierto.

Medir en lugar de suponer

El coste real depende de tantas cosas —número de triángulos, número de objetos, profundidad del grafo, si hay morph targets, si el material es de doble cara— que la única respuesta útil es la que mides en tu escena.

JS
function medirRaycast( raycaster, objetos, repeticiones = 200 ) {	const buffer = [];	// Calentamiento: evita medir la compilacion JIT.	for ( let i = 0; i < 20; i ++ ) {		buffer.length = 0;		raycaster.intersectObjects( objetos, false, buffer );	}	const t0 = performance.now();	for ( let i = 0; i < repeticiones; i ++ ) {		buffer.length = 0;		raycaster.intersectObjects( objetos, false, buffer );	}	const t1 = performance.now();	return ( t1 - t0 ) / repeticiones;}console.log( medirRaycast( raycaster, mallas ).toFixed( 3 ), 'ms por raycast' );

El calentamiento importa: las primeras ejecuciones de una función caliente en JavaScript corren interpretadas y pueden ser un orden de magnitud más lentas que las siguientes. Sin él, medirás el compilador y no el algoritmo.

Los órdenes de magnitud que salen en un portátil moderno, para hacerte una idea antes de medir:

Escena Coste aproximado por raycast
20 cubos, geometrías de 12 triángulos menos de 0,02 ms
1 malla de 50 000 triángulos, rayo que la toca 0,5 a 2 ms
1 malla de 500 000 triángulos, rayo que la toca 5 a 25 ms
1 malla de 500 000 triángulos, rayo que falla la esfera menos de 0,01 ms

La última fila es la más informativa: fallar es gratis y acertar es caro. Un raycast que no toca nada cuesta lo que cuesta comprobar unas cuantas esferas. Por eso el picking sobre una escena grande suele ir bien hasta que el usuario apunta al modelo pesado, y ahí se cae.

El presupuesto de un frame a 60 Hz es de 16,6 milisegundos, y de esos, el raycast compite con el resto de tu lógica. Un raycast de 5 ms consume casi un tercio del frame. Dos raycasts —uno para hover y otro para el cursor de colocación— consumen dos tercios. Ese es el punto donde hace falta una estructura de aceleración.

Las cuatro palancas antes de traer una librería

Antes de instalar nada, hay cuatro cosas que se hacen en minutos y que a menudo bastan.

Reducir la lista. intersectObjects( soloLoQueEsClicable, false ) en lugar de intersectObject( escena, true ). Mantén un array de objetos interactivos y actualízalo al añadir y quitar; casi nunca es más del 5 % de la escena.

Calcular las cajas. Un computeBoundingBox() por geometría al cargar. Con modelos glTF:

JS
modelo.traverse( ( o ) => {	if ( o.isMesh ) o.geometry.computeBoundingBox();} );

Recortar el rango. Si tu interacción solo tiene sentido hasta veinte unidades, pon raycaster.far = 20. No acelera la intersección con una malla concreta, pero evita que el resultado incluya objetos lejanos que después descartarías, y ahorra la construcción de sus objetos de intersección.

No lanzar el rayo cada evento. Un raycast por frame, no uno por pointermove. Ya lo viste en la lección de coordenadas, y es la palanca que más devuelve en relación con el esfuerzo.