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.
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
GenerateMeshBVHWorkerdel subpaththree-mesh-bvh/workerpara 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
refitpara 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.