wandres.dev
RAYCASTING · Detectar el clic en 3D

Capas, raycast propio, BVH y picking por GPU

Las cuatro formas de acelerar el picking en Three.js ordenadas por coste de implementación: layers, raycast personalizado, three-mesh-bvh y el picking por color desde la GPU.

⏱ 20 min

Cuando el picking se ha convertido en el cuello de botella, hay cuatro herramientas y conviene aplicarlas en orden de esfuerzo creciente. Las capas cuestan una línea y filtran antes de tocar geometría. Un raycast propio cuesta diez líneas y poda subárboles enteros. Un BVH cuesta una dependencia y convierte el coste lineal en logarítmico. El picking por GPU cuesta una arquitectura entera y es la única opción cuando la geometría solo existe dentro de un shader.

🎯 Al terminar esta lección sabrás
  • Filtrar objetos con layers y entender que es una máscara de 32 bits.
  • Sustituir el raycast de una clase para implementar una prueba propia.
  • Integrar three-mesh-bvh y activar firstHitOnly.
  • Decidir cuándo el picking por GPU es la única alternativa razonable.

Capas: el filtro que cuesta cero

Layers es una máscara de 32 bits. Cada Object3D tiene la suya y el raycaster también. Antes de llamar a raycast(), Three.js hace object.layers.test( raycaster.layers ), que es una operación AND entre dos enteros. Más barato imposible.

const CAPA_INTERACTIVA = 1;

// Los objetos con los que se puede interactuar se apuntan a la capa 1.
boton.layers.enable( CAPA_INTERACTIVA );
palanca.layers.enable( CAPA_INTERACTIVA );

// El raycaster solo mira esa capa.
raycaster.layers.set( CAPA_INTERACTIVA );

set( n ) borra todas las capas y activa solo la n. enable( n ) añade la n a las que ya había. disable( n ) la quita. toggle( n ) la invierte. Por defecto todo objeto está en la capa 0 y todo raycaster también, que es por lo que sin configurar nada todo interseca con todo.

La sutileza que hay que tener en la cabeza: las capas no se heredan. Poner un grupo en la capa 1 no pone a sus hijos en la capa 1, y como el Group no interseca nada, el efecto neto es que has filtrado el grupo pero sus hijos siguen visitándose con su capa original. Para aplicarlo a un subárbol hay que recorrerlo:

modelo.traverse( ( o ) => o.layers.enable( CAPA_INTERACTIVA ) );

La misma máscara la usan la cámara —para decidir qué se dibuja— y las luces —para decidir a qué ilumina—. Esa reutilización es útil y es una fuente de sorpresas: si usas capas para picking y también para separar lo que ve una cámara secundaria, puedes desactivar sin querer objetos del render. Reserva capas concretas para cada propósito y documéntalo.

Un raycast propio

Sustituir raycast en una clase o en una instancia concreta te da control total. Hay tres usos que valen la pena.

Podar un subárbol. Ya lo viste: devolver false corta la recursión hacia los hijos.

Intersecar un volumen en lugar de la geometría. Una malla compleja con una prueba de esfera es picking aproximado a coste constante:

const _esfera = new THREE.Sphere();
const _punto = new THREE.Vector3();

modelo.raycast = function ( raycaster, intersects ) {

	if ( this.geometry.boundingSphere === null ) this.geometry.computeBoundingSphere();

	_esfera.copy( this.geometry.boundingSphere ).applyMatrix4( this.matrixWorld );

	if ( raycaster.ray.intersectSphere( _esfera, _punto ) === null ) return;

	const d = raycaster.ray.origin.distanceTo( _punto );

	if ( d < raycaster.near || d > raycaster.far ) return;

	intersects.push( {
		distance: d,
		point: _punto.clone(),
		object: this
	} );

};

Fíjate en las variables temporales fuera de la función. Es el patrón que usa Three.js en todo su código y no es manía: crear un Vector3 dentro de una función que se llama miles de veces por frame genera presión de recolección de basura que aparece en el perfilador como picos aleatorios.

Ampliar el área efectiva de objetos pequeños. Un manipulador de tres ejes con flechas finas es imposible de clicar; un raycast que interseque cilindros generosos en lugar de la geometría real lo hace usable.

three-mesh-bvh

Cuando de verdad necesitas intersecar geometría densa, un bounding volume hierarchy convierte el bucle lineal en un descenso por un árbol. La librería de referencia es three-mesh-bvh y su integración son cuatro líneas.

import * as THREE from 'three';
import {
	computeBoundsTree,
	disposeBoundsTree,
	acceleratedRaycast
} from 'three-mesh-bvh';

// Parchear los prototipos una sola vez, al arrancar.
THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
THREE.Mesh.prototype.raycast = acceleratedRaycast;

// Construir el arbol de las geometrias que lo necesiten.
modelo.traverse( ( o ) => {

	if ( o.isMesh ) o.geometry.computeBoundsTree();

} );

// Y el interruptor que mas rinde:
raycaster.firstHitOnly = true;

acceleratedRaycast sustituye el raycast de Mesh: si la geometría tiene boundsTree, usa el árbol; si no, cae al comportamiento original. Por eso es seguro parchear el prototipo global aunque solo algunas geometrías tengan BVH.

firstHitOnly = true es la línea que más devuelve. Sin ella, el BVH recorre el árbol recogiendo todas las intersecciones, que es lo que exige la semántica de intersectObject. Con ella, usa raycastFirst, que puede podar ramas enteras en cuanto encuentra un impacto más cercano que el volumen que está a punto de visitar. Para picking —donde solo te interesa lo primero que toca el rayo— es la opción correcta siempre.

Lo que hay que saber antes de adoptarla:

  • Construir el árbol cuesta tiempo y memoria. Para una malla de medio millón de triángulos son unas décimas de segundo y varios megabytes. Hazlo al cargar, no en el primer clic, o usa GenerateMeshBVHWorker del subpath three-mesh-bvh/worker para no bloquear el hilo principal.
  • El árbol no es dinámico. Geometría con skinning o morph targets no vale. Si mueves vértices directamente, hay una función refit para reajustar los volúmenes sin reconstruir.
  • La construcción genera un índice como efecto secundario, salvo que uses la opción indirect.
  • Cada grupo de material crea una raíz aparte, así que una geometría con muchos grupos rinde peor de lo esperado.
  • Hay que liberar. geometry.disposeBoundsTree() al descartar el modelo.

Además del raycast, el BVH habilita consultas que Three.js no tiene: shapecast para intersecar volúmenes arbitrarios, intersectsSphere, closestPointToPoint. Es la base de las colisiones de personaje, del pintado sobre malla y de la generación de campos de distancia.

Picking por GPU

Hay un caso donde nada de lo anterior sirve: cuando la posición real de la geometría solo existe dentro de la GPU. Es exactamente la situación de un sistema de partículas simulado con compute shaders, o de una malla deformada en el vertex shader. La CPU no sabe dónde están las cosas, así que no puede intersecarlas.

La solución es preguntarle a quien sí lo sabe. Se renderiza la escena a un render target fuera de pantalla con un material que codifica el identificador de cada objeto como color, se lee el píxel bajo el cursor, y se decodifica.

const objetivo = new THREE.WebGLRenderTarget( 1, 1 );
const escenaId = new THREE.Scene();
const buffer = new Uint8Array( 4 );

function pickPorGPU( x, y ) {

	// Camara recortada a un solo pixel: el que esta bajo el cursor.
	camera.setViewOffset(
		renderer.domElement.width, renderer.domElement.height,
		x * window.devicePixelRatio | 0, y * window.devicePixelRatio | 0,
		1, 1
	);

	renderer.setRenderTarget( objetivo );
	renderer.render( escenaId, camera );
	renderer.setRenderTarget( null );

	camera.clearViewOffset();

	renderer.readRenderTargetPixels( objetivo, 0, 0, 1, 1, buffer );

	return buffer[ 0 ] + buffer[ 1 ] * 256 + buffer[ 2 ] * 65536;

}

setViewOffset es la pieza clave: hace que la cámara renderice solo la región de un píxel que te interesa, sobre un render target de un píxel. Sin él, tendrías que renderizar la escena completa a resolución completa para leer un solo píxel.

Las ventajas son reales: el coste es independiente del número de triángulos y independiente de la complejidad geométrica, y funciona con cualquier deformación de vertex shader porque lo que se lee es el resultado ya rasterizado. Los inconvenientes también: hay que mantener una segunda escena o un material de sustitución, se pierde el uv y la normal —solo obtienes qué objeto, no dónde— y readRenderTargetPixels es una lectura síncrona desde la GPU, que fuerza a esperar a que el pipeline se vacíe y puede costar milisegundos enteros.

Por eso el picking por GPU no es la opción por defecto: es la opción cuando el raycast en CPU no puede saber la respuesta.

El BVH que necesitas puede que no sea el de los triángulos

Casi todo el material que hay sobre three-mesh-bvh habla del árbol dentro de una malla: acelerar la intersección contra medio millón de triángulos. Y es el caso que la librería resolvió primero. Pero en muchísimas escenas web el problema no es ese, es el contrario: no tienes una malla enorme, tienes diez mil mallas pequeñas, y el coste está en recorrerlas todas antes de encontrar la que toca el rayo. Ahí el BVH de triángulos no ayuda nada, porque cada malla individual se despacha en microsegundos; lo que cuesta es el bucle sobre diez mil objetos, con su comprobación de capa, su copia de esfera envolvente y su transformación de matriz por cabeza. Ese es un problema de jerarquía de objetos, no de triángulos, y tiene dos respuestas. La librería incorpora un ObjectBVH precisamente para esto, construido sobre el grafo de escena en lugar de sobre la geometría, y es lo más directo. Pero antes de traerlo merece la pena la respuesta casera, que es la del raycast propio que devuelve false: agrupa tus objetos espacialmente —por celda, por región, por edificio— dale a cada grupo su esfera o su caja envolvente, y poda el subárbol cuando el rayo no la toca. Con veinte grupos de quinientos objetos, un rayo típico visita veinte volúmenes y baja a uno solo: quinientas comprobaciones en lugar de diez mil, sin dependencias y sin memoria adicional. La lección general, que vale más allá del raycasting: antes de acelerar una búsqueda, comprueba en qué nivel de la jerarquía está el coste. Optimizar el nivel equivocado da mejoras de un dígito porcentual y una sensación desagradable de haber trabajado para nada.

⚔️ Escala el picking hasta que se rompa
  1. Monta una escena con 10 000 cubos pequeños y mide el coste de un raycast.
  2. Agrúpalos en 25 grupos con esfera envolvente y raycast propio que devuelva false, y vuelve a medir.
  3. Sustituye la escena por una única malla de 800 000 triángulos y mide.
  4. Integra three-mesh-bvh con firstHitOnly y compara.
  5. Construye el BVH en un worker con GenerateMeshBVHWorker y comprueba que el hilo principal no se bloquea durante la carga.