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.
Un raycast contra una malla de doscientos mil triángulos comprueba doscientos mil triángulos. No hay estructura, no hay índice, no hay atajo: es un bucle lineal que llama a intersectTriangle una vez por cara. Ese hecho es la única cosa que hay que saber sobre el rendimiento del picking en Three.js, y explica tanto por qué la mayoría de las escenas van sobradas como por qué algunas se caen a diez frames por segundo en cuanto el usuario mueve el ratón.
- Describir la secuencia exacta de descartes que aplica
Mesh.raycast. - Identificar cuál de los volúmenes envolventes no se calcula automáticamente y activarlo.
- Medir el coste de un raycast con
performance.now()y con el perfilador. - Estimar el presupuesto de raycasts que cabe en un frame de 16 milisegundos.
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:
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.
// 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.
Ray.intersectTriangle recibe un flag de backface culling. Con material.side en FrontSide —el valor por defecto— los triángulos que dan la espalda al rayo se descartan con una comparación de signo, muy barata. Con DoubleSide no se descarta ninguno, lo que no solo duplica los aciertos sino que llena el array de resultados con las caras traseras de todo lo que el rayo atraviesa, y después las ordena. Si has puesto DoubleSide por un problema visual concreto y esa malla también recibe picking, plantéate separar el material de render del de colisión.
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.
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:
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.
Cuando las cuatro palancas no bastan, la respuesta instintiva es traer un BVH. A veces es lo correcto, y lo verás en la lección siguiente. Pero antes conviene plantearse una pregunta que en la industria del videojuego lleva treinta años resuelta y que en la web se olvida constantemente: ¿por qué estás intersecando la malla que dibujas? Un motor de juego no lo hace. El personaje que ves tiene sesenta mil triángulos y la cápsula contra la que se calculan colisiones tiene ocho. El edificio que ves tiene medio millón y su malla de colisión son doce cajas. Nadie interseca la geometría de render porque la geometría de render está optimizada para un objetivo completamente distinto: densidad de detalle uniforme, buenas normales, UVs sin costuras. Para responder “¿ha tocado el usuario este objeto?” no hace falta nada de eso. Traducido a Three.js: crea una malla invisible con una geometría burda —una caja, una cápsula, una versión simplificada del modelo— colócala en la misma jerarquía, ponla en la lista de picking y saca de ella la malla real. Con visible = false no basta, porque el raycaster ignora visible; lo que hay que hacer es simplemente no meter la malla de render en la lista. El coste de picking baja dos o tres órdenes de magnitud de golpe, sin librerías, sin construir árboles, sin memoria extra significativa. Y hay un efecto secundario que se agradece: la geometría de colisión es estable aunque cambies el modelo visual, lo que significa que un artista puede subdividir la malla sin romper la interacción. La regla que resume todo esto: un BVH acelera intersecar la malla equivocada; elegir la malla correcta hace que no haga falta acelerarla.
- Genera una esfera con 500 000 triángulos y mide el coste de un raycast que la acierta y otro que la falla.
- Añade
computeBoundingBox()y repite la medida con un rayo tangente a la esfera envolvente pero fuera de la caja. - Cambia el material a
DoubleSidey anota cuántas intersecciones aparecen en el array y cuánto sube el tiempo. - Sustituye la malla por una caja invisible del mismo tamaño en la lista de picking y vuelve a medir.
- Calcula cuántos raycasts por frame te caben en 5 ms en cada una de las cuatro configuraciones.