Estructuras espaciales: rejilla, octree y qué elegir
Cómo se organiza el espacio para responder consultas rápidas, por qué una rejilla uniforme gana casi siempre, cuándo compensa un octree, y el addon Octree de Three.js.
Todas las técnicas de un mundo grande —culling, streaming, colisiones, búsqueda de vecinos, consultas de proximidad— acaban planteando la misma pregunta: qué hay cerca de aquí. Responderla recorriendo todos los objetos es lineal, y lineal por frame y por consulta es exactamente lo que no puedes permitirte. Las estructuras espaciales convierten esa pregunta en una consulta de índice, y la elección entre ellas es menos misteriosa de lo que parece: casi siempre gana la más simple.
- Implementar una rejilla espacial con hash y consultar el vecindario de un punto.
- Explicar cuándo un octree supera a una rejilla y cuándo la complica sin ganancia.
- Usar el addon
Octreede Three.js para colisiones contra geometría estática. - Elegir la estructura a partir de la distribución de los datos y del tipo de consulta.
La rejilla uniforme
Divide el espacio en celdas de lado fijo, guarda cada objeto en la celda de su centro, y para consultar mira las celdas que intersecan la región de interés. Sin árbol, sin balanceo, sin recursión.
class Rejilla {
constructor( tamCelda ) {
this.tam = tamCelda;
this.celdas = new Map();
}
clave( x, y, z ) {
const cx = Math.floor( x / this.tam );
const cy = Math.floor( y / this.tam );
const cz = Math.floor( z / this.tam );
return cx + ':' + cy + ':' + cz;
}
insertar( objeto, posicion ) {
const k = this.clave( posicion.x, posicion.y, posicion.z );
let celda = this.celdas.get( k );
if ( celda === undefined ) {
celda = [];
this.celdas.set( k, celda );
}
celda.push( objeto );
objeto.userData.celda = k;
}
mover( objeto, posicion ) {
const nueva = this.clave( posicion.x, posicion.y, posicion.z );
const vieja = objeto.userData.celda;
if ( nueva === vieja ) return; // el caso comun: no hacer nada
const celdaVieja = this.celdas.get( vieja );
if ( celdaVieja ) {
const i = celdaVieja.indexOf( objeto );
if ( i !== - 1 ) celdaVieja.splice( i, 1 );
}
this.insertar( objeto, posicion );
}
enRadio( centro, radio, salida = [] ) {
const r = Math.ceil( radio / this.tam );
const cx = Math.floor( centro.x / this.tam );
const cy = Math.floor( centro.y / this.tam );
const cz = Math.floor( centro.z / this.tam );
const r2 = radio * radio;
for ( let z = cz - r; z <= cz + r; z ++ ) {
for ( let y = cy - r; y <= cy + r; y ++ ) {
for ( let x = cx - r; x <= cx + r; x ++ ) {
const celda = this.celdas.get( x + ':' + y + ':' + z );
if ( celda === undefined ) continue;
for ( const o of celda ) {
if ( o.position.distanceToSquared( centro ) <= r2 ) salida.push( o );
}
}
}
}
return salida;
}
}
Tres decisiones de diseño están en ese código y las tres importan.
El Map con clave de cadena en lugar de un array tridimensional. Un array de un mundo de mil por mil por mil celdas ocuparía mil millones de entradas casi todas vacías. Con un Map, solo existen las celdas ocupadas: la estructura es dispersa y su memoria es proporcional al número de objetos, no al volumen del mundo. El coste es el hash de la cadena, que en un motor de JavaScript moderno es rápido pero no gratis; si el rendimiento aprieta, se puede empaquetar las tres coordenadas en un único entero con desplazamientos de bits.
El corte temprano en mover. La mayoría de los objetos, la mayoría de los frames, no cambian de celda. Comprobarlo antes de tocar nada convierte la actualización de miles de objetos en miles de comparaciones de cadena, que es despreciable.
La comprobación de distancia real dentro del bucle. Las celdas son cubos y la consulta es una esfera: las celdas de las esquinas contienen objetos que están fuera del radio. Sin el filtro final devolverías falsos positivos.
El parámetro que decide todo es el tamaño de celda. Demasiado pequeña y una consulta de radio moderado tiene que visitar cientos de celdas vacías. Demasiado grande y cada celda contiene tantos objetos que vuelves a hacer una búsqueda lineal. La regla que funciona: el lado de la celda igual al radio de consulta típico, lo que hace que cualquier consulta toque como mucho ocho celdas en tres dimensiones. Si tus objetos tienen tamaño, usa el diámetro del mayor.
El octree
Un octree subdivide recursivamente el espacio en ocho, y solo subdivide donde hay contenido. Su ventaja aparece cuando la distribución es muy desigual: si el 90 % de tus objetos está en el 1 % del volumen, una rejilla uniforme acaba con unas pocas celdas saturadas y millones vacías, mientras que el octree se profundiza donde hace falta y se queda superficial donde no.
Sus desventajas son reales y suelen olvidarse. El recorrido es recursivo, con saltos de puntero y mala localidad de caché. Insertar y borrar puede provocar subdivisiones y fusiones. Y los objetos que cruzan el límite entre dos hijos hay que meterlos en el padre, o duplicarlos, o subdividirlos: ninguna de las tres opciones es limpia.
En la práctica, la regla que se sostiene: si tus objetos son dinámicos, empieza por la rejilla; si son estáticos y la distribución es muy desigual, considera el octree. Los motores de juego usan rejillas o BVH para objetos móviles y árboles para geometría estática, y esa división no es casual.
Three.js incluye un octree en sus addons, especializado en triángulos para colisiones contra geometría estática:
import { Octree } from 'three/addons/math/Octree.js';
import { Capsule } from 'three/addons/math/Capsule.js';
const octree = new Octree();
// Recorre la jerarquia, convierte cada malla en triangulos en espacio de mundo
// y construye el arbol. Hazlo una vez, tras cargar el nivel.
octree.fromGraphNode( escenario );
// Colision de un personaje modelado como capsula.
const colisionador = new Capsule(
new THREE.Vector3( 0, 0.35, 0 ),
new THREE.Vector3( 0, 1.65, 0 ),
0.35
);
const resultado = octree.capsuleIntersect( colisionador );
if ( resultado ) {
// resultado.normal y resultado.depth describen como salir del solido.
colisionador.translate( resultado.normal.multiplyScalar( resultado.depth ) );
}
Su configuración está en dos propiedades: trianglesPerLeaf, que por defecto es 8 y define cuántos triángulos caben en una hoja antes de subdividir, y maxLevel, que por defecto es 16 y limita la profundidad. También tiene un layers para filtrar qué mallas entran al construirlo, lo que permite tener geometría visual que no colisiona sin tener que separarla en el grafo.
Además de capsuleIntersect, ofrece sphereIntersect, boxIntersect y rayIntersect. Es la base del ejemplo oficial de movimiento de personaje de Three.js y funciona sorprendentemente bien para lo compacto que es.
Sus límites: es estático. fromGraphNode transforma los triángulos a coordenadas de mundo en el momento de construir; si mueves el escenario, hay que reconstruirlo entero. Y guarda todos los triángulos como objetos Triangle de JavaScript, lo que para geometría muy densa consume bastante memoria.
Con menos de mil objetos, un bucle lineal con distanceToSquared cuesta menos de un milisegundo y no tiene ningún coste de mantenimiento. Con menos de cien, ni te lo plantees. La estructura espacial empieza a compensar por encima de unos pocos miles de elementos, o cuando la consulta se hace muchas veces por frame. Introducirla antes de medir es la forma más habitual de añadir complejidad sin ganar nada, y encima con bugs de actualización que no existirían.
Elegir según la consulta
La estructura correcta depende menos del número de objetos que del tipo de pregunta que haces. Esta tabla resume lo que se sostiene en la práctica:
| Consulta | Objetos dinámicos | Objetos estáticos |
|---|---|---|
| Vecinos en un radio | rejilla uniforme | rejilla uniforme |
| Rayo contra triángulos | recalcular BVH o rejilla gruesa | BVH | octree |
| Objetos dentro del frustum | rejilla de regiones | octree | jerarquía de cajas |
| Colisión personaje-escenario | consultas del motor de física | octree de triángulos |
| El más cercano a un punto | rejilla en anillos crecientes | k-d tree | BVH |
| Carga por proximidad | rejilla de trozos | rejilla de trozos |
Un par de matices sobre la tabla. Para “el más cercano”, la rejilla se consulta en anillos crecientes alrededor de la celda del punto, parando en cuanto se encuentra un candidato y se comprueba que ninguna celda del anillo siguiente puede contener nada más cercano. Y para el frustum, agrupar objetos en regiones de una rejilla gruesa y comprobar la región completa —tal como viste en la lección de culling— captura casi todo el beneficio de un octree con una décima parte del código.
Cuando se comparan estructuras espaciales, casi siempre se comparan sus consultas: cuánto cuesta buscar vecinos en una rejilla frente a un octree, la complejidad asintótica, los factores constantes. Y esa comparación es la menos relevante, porque en una escena real con objetos que se mueven el coste dominante no es consultar, es mantener la estructura actualizada. Ahí la rejilla gana por goleada y por una razón que no aparece en ningún análisis asintótico: mover un objeto de celda es quitarlo de un array y meterlo en otro, sin reequilibrar nada, sin subdividir, sin fusionar, sin invalidar ancestros. Un octree, con la misma operación, puede tener que fusionar el nodo que se queda vacío y subdividir el que se llena, y esas dos operaciones tocan memoria dispersa. Con mil objetos moviéndose cada frame, la diferencia es de un orden de magnitud a favor de la rejilla aunque sus consultas sean algo peores. De ahí sale el criterio que de verdad decide, y que conviene aplicarlo antes que cualquier otro: mide la proporción entre consultas y actualizaciones. Si haces cien consultas por cada actualización —geometría estática que se interroga muchas veces— invierte en una estructura de consulta rápida y no te preocupes de lo que cueste construirla: un BVH o un octree. Si haces una consulta por cada actualización —partículas, agentes, todo lo que se mueve— la estructura tiene que ser barata de mantener aunque sus consultas sean mediocres: rejilla, siempre. Y si estás en el medio, hay una tercera vía que se olvida constantemente: dos estructuras. Un octree para el escenario, que se construye una vez, y una rejilla para los agentes, que se actualiza cada frame. Los motores de juego llevan décadas haciéndolo exactamente así, y no porque no se les ocurriera unificar, sino porque los dos problemas son distintos y una única estructura los resuelve los dos mal.
- Genera 5000 agentes en movimiento y busca los vecinos en radio 5 con un doble bucle. Mide.
- Implementa la rejilla y repite la medida con el lado de celda igual al radio.
- Prueba tamaños de celda de radio partido por cuatro, radio, y radio por cuatro, y anota el óptimo.
- Instrumenta cuántos objetos cambian de celda por frame y comprueba que es una fracción pequeña.
- Construye un
Octreesobre el escenario y mueve un personaje concapsuleIntersect.