setDrawRange, volúmenes envolventes, grupos y dispose
Cómo dibujar solo una parte de una geometría sin reasignar buffers, para qué se usan de verdad la caja y la esfera envolventes, cómo funcionan los grupos de material, y qué libera exactamente dispose.
Los buffers de la GPU no se pueden redimensionar. Esa restricción, que parece una molestia, define el patrón correcto para toda la geometría dinámica: reservar el máximo desde el principio y dibujar solo la parte que interesa. Alrededor de esa idea giran setDrawRange y los grupos, que comparten unidades y mecanismo. Y al final del ciclo de vida está dispose, que hace menos de lo que la gente cree y por eso las fugas de memoria de vídeo son tan comunes.
- Usar
setDrawRangepara geometría que crece sin reasignar buffers. - Distinguir cuándo hace falta la caja envolvente y cuándo la esfera, y cuándo recalcularlas.
- Repartir una geometría en grupos con materiales distintos.
- Escribir una función de liberación que no deje fugas de memoria de vídeo.
setDrawRange: dibujar una parte
drawRange es un objeto con dos campos que arranca en { start: 0, count: Infinity }, es decir, dibujarlo todo. setDrawRange( start, count ) lo cambia y devuelve undefined, así que no encadena.
Las unidades dependen de si la geometría está indexada: si hay índice, son entradas del índice; si no, son vértices. Confundirlas es el error clásico y produce que se dibuje un tercio de lo esperado.
El caso de uso canónico es una estela que crece. Reservas el máximo, empiezas con cero y vas subiendo:
import * as THREE from 'three';
const MAXIMO = 5000;
const posiciones = new Float32Array( MAXIMO * 3 );
const geometria = new THREE.BufferGeometry();
const atributo = new THREE.BufferAttribute( posiciones, 3 );
atributo.setUsage( THREE.DynamicDrawUsage ); // antes del primer render
geometria.setAttribute( 'position', atributo );
geometria.setDrawRange( 0, 0 ); // de momento, nada
const linea = new THREE.Line( geometria, new THREE.LineBasicMaterial( { color: 0x89b4fa } ) );
linea.frustumCulled = false; // la esfera envolvente no describe lo que se dibuja
scene.add( linea );
let n = 0;
function anadirPunto( x, y, z ) {
if ( n >= MAXIMO ) return;
posiciones[ n * 3 + 0 ] = x;
posiciones[ n * 3 + 1 ] = y;
posiciones[ n * 3 + 2 ] = z;
n ++;
geometria.setDrawRange( 0, n );
atributo.addUpdateRange( ( n - 1 ) * 3, 3 ); // solo el punto nuevo
atributo.needsUpdate = true;
}
Tres detalles del ejemplo no son accesorios. El setUsage antes del primer render, porque después ya no se puede cambiar. El frustumCulled = false, porque la esfera envolvente se calcularía sobre el array entero, incluidos los ceros que aún no has escrito, y describiría una región equivocada. Y el rango de actualización, que evita subir cinco mil puntos cada vez que añades uno.
El mismo patrón sirve para un depósito de partículas: reservas diez mil, subes y bajas count según cuántas estén vivas, y compactas las muertas al final del array. Y para revelar geometría progresivamente, que es la técnica más barata que existe para una animación de dibujado de contorno.
Lo que setDrawRange no hace es saltarse el coste de la memoria: el buffer entero está reservado en la GPU aunque dibujes tres vértices. Lo que ahorra es el procesamiento y, sobre todo, la reasignación.
Los volúmenes envolventes
boundingBox y boundingSphere empiezan valiendo null y se calculan bajo demanda, no al construir la geometría. Quién los pide es lo que determina cuál necesitas.
La esfera la usa el frustum culling y la primera fase del raycasting. Es barata de probar —una resta y un producto escalar por plano— y por eso es la que el renderer consulta en cada frame por cada objeto.
La caja la usa el raycasting en su segunda fase, y la usas tú para todo lo que sea encuadrar, alinear o medir. Box3.setFromObject recorre un subárbol entero y devuelve la caja del conjunto en espacio de mundo, que es la herramienta correcta para centrar un modelo recién cargado:
import * as THREE from 'three';
const caja = new THREE.Box3().setFromObject( modelo );
const centro = caja.getCenter( new THREE.Vector3() );
const tamano = caja.getSize( new THREE.Vector3() );
modelo.position.sub( centro ); // centrado en el origen
modelo.scale.setScalar( 2 / Math.max( ...tamano.toArray() ) ); // normalizado a 2 unidades
El recálculo es responsabilidad tuya en cuanto muevas vértices:
geometria.computeBoundingSphere();
geometria.computeBoundingBox();
Y ambos métodos comparten un aviso que conviene reconocer: si el atributo de posición contiene algún NaN, el radio calculado sale NaN y Three.js lo dice por consola. Ese mensaje es, en la práctica, el mejor detector de división por cero en tu generador de geometría, porque un NaN en las posiciones hace que el objeto desaparezca por completo sin ningún otro síntoma. Si un objeto no se ve y no entiendes por qué, mira ese aviso antes que nada.
Una nota sobre el coste: ambos métodos recorren todos los vértices, y computeBoundingSphere los recorre dos veces —una para hallar el centro a partir de la caja y otra para el radio máximo—. Para cien mil vértices son unos pocos milisegundos. No es algo que se llame cada frame sin pensarlo.
Grupos y varios materiales
Un grupo es un tramo del buffer al que se le asigna un índice de material. Se declaran con addGroup y las unidades son las mismas que las de drawRange:
geometria.addGroup( inicio, cantidad, indiceDeMaterial );
geometria.clearGroups();
Cuando una malla tiene grupos y su material es un array, Three.js emite una llamada de dibujo por grupo, cada una con el material correspondiente y con el rango acotado. Es el mecanismo que hace que un cubo pueda tener seis caras distintas:
import * as THREE from 'three';
// BoxGeometry genera seis grupos, uno por cara, con índices de material 0 a 5.
const materiales = [
new THREE.MeshStandardMaterial( { color: 0xf38ba8 } ), // +X
new THREE.MeshStandardMaterial( { color: 0xa6e3a1 } ), // -X
new THREE.MeshStandardMaterial( { color: 0x89b4fa } ), // +Y
new THREE.MeshStandardMaterial( { color: 0xf9e2af } ), // -Y
new THREE.MeshStandardMaterial( { color: 0xcba6f7 } ), // +Z
new THREE.MeshStandardMaterial( { color: 0xfab387 } ) // -Z
];
const cubo = new THREE.Mesh( new THREE.BoxGeometry( 1, 1, 1 ), materiales );
Varias geometrías integradas traen grupos de fábrica: BoxGeometry los seis de las caras, CylinderGeometry tres —lateral, tapa superior, tapa inferior— y ExtrudeGeometry dos, tapas y laterales. Puedes verlos con geometria.groups.
El coste es exactamente el que sugiere: un objeto con seis grupos cuesta seis llamadas de dibujo, no una. Para un cubo da igual; para una malla combinada de cien piezas con cien materiales, has convertido una geometría en cien draw calls y no has ganado nada respecto a tener cien mallas. La función mergeGroups de three/addons/utils/BufferGeometryUtils.js fusiona grupos consecutivos con el mismo índice de material, que es el arreglo obvio cuando el problema viene de una combinación mal ordenada.
dispose y la limpieza de una escena
BufferGeometry.dispose() hace una sola cosa: despachar un evento de tipo dispose. Es el renderer quien lo escucha y libera los buffers de GL asociados. Por eso funciona aunque el renderer no aparezca por ningún lado en tu llamada.
Y por eso mismo, lo que no libera es todo lo demás. Quitar un objeto de la escena con remove no libera nada. Poner la variable a null tampoco: el recolector de basura de JavaScript no sabe nada de la memoria de vídeo. Materiales, texturas, render targets y programas de shader se liberan cada uno por su cuenta.
La función completa, que merece la pena tener en cualquier proyecto que cargue y descargue modelos:
function liberar( raiz ) {
raiz.traverse( ( objeto ) => {
if ( objeto.geometry ) objeto.geometry.dispose();
const mat = objeto.material;
if ( ! mat ) return;
for ( const material of Array.isArray( mat ) ? mat : [ mat ] ) {
for ( const clave of Object.keys( material ) ) {
const valor = material[ clave ];
if ( valor && valor.isTexture ) valor.dispose();
}
material.dispose();
}
} );
raiz.removeFromParent();
}
El recorrido por las claves del material buscando texturas es feo y es la forma correcta: los mapas de un material no están en una lista, son propiedades sueltas —map, normalMap, roughnessMap, aoMap y una docena más—, y comprobar isTexture cubre todas sin enumerarlas.
Dos cautelas. Si varias mallas comparten geometría o material, esta función los libera al procesar la primera y las demás quedan rotas: para escenas con recursos compartidos hay que llevar cuenta de referencias. Y las texturas cargadas por un TextureLoader con caché siguen en la caché de Three.js aunque las liberes; THREE.Cache.clear() la vacía.
Para verificar que no hay fugas, la fuente de verdad es el propio renderer:
console.log( renderer.info.memory.geometries, renderer.info.memory.textures );
Esos dos números tienen que volver a su valor de partida después de descargar una escena. Si no vuelven, hay algo que no has liberado, y el ciclo de cargar y descargar cien veces lo hará evidente antes de que lo haga un usuario.
En JavaScript vives sin pensar en la propiedad de los objetos: creas, dejas de referenciar, y el recolector se encarga. Ese lujo se acaba en el borde de la GPU, y la razón es estructural. El recolector de basura funciona porque puede rastrear todas las referencias dentro del montón de JavaScript; un buffer de OpenGL no está en ese montón, es un número entero que identifica un recurso del driver, y ninguna cantidad de análisis de alcanzabilidad va a descubrir que ya no lo necesitas. Three.js hace lo que puede con WeakMap internos que asocian tus objetos a recursos de GL, pero eso solo garantiza que la tabla no crezca, no que el recurso se libere: para eso hace falta una llamada explícita. La consecuencia práctica es que cualquier aplicación que cargue contenido dinámicamente necesita un modelo de propiedad explícito, y no tenerlo no produce un fallo, produce una degradación. La escena carga bien, el usuario navega, la memoria de vídeo sube unos megabytes en cada transición, y a la vigésima el navegador pierde el contexto de WebGL y todo se pone negro. El síntoma aparece a los veinte minutos de uso, en el móvil de otra persona, sin ningún error en consola, y es prácticamente imposible de reproducir en desarrollo porque tú recargas la página cada dos minutos. La disciplina que lo evita es aburrida y funciona: renderer.info.memory en un rincón de la interfaz durante todo el desarrollo, y un ciclo automático de carga y descarga en las pruebas. Si los dos números vuelven al valor inicial, no hay fuga. Si no vuelven, la tienes, y sabes exactamente en qué transición se te ha ido.
- Monta la estela con
setDrawRangey comprueba qué pasa si olvidasfrustumCulled = false. - Indexa esa geometría y observa cómo cambian las unidades de
setDrawRange. - Introduce un
NaNen una posición y localiza el aviso exacto que aparece en consola. - Monta un cubo con seis materiales y cuenta las llamadas de dibujo con
renderer.info.render.calls. - Carga y descarga una escena cien veces con la función
liberary verifica querenderer.info.memoryvuelve a su valor inicial.