Saltar al contenido

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.

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:

JS
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 rendergeometria.setAttribute( 'position', atributo );geometria.setDrawRange( 0, 0 );                // de momento, nadaconst linea = new THREE.Line( geometria, new THREE.LineBasicMaterial( { color: 0x89b4fa } ) );linea.frustumCulled = false;   // la esfera envolvente no describe lo que se dibujascene.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:

JS
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 origenmodelo.scale.setScalar( 2 / Math.max( ...tamano.toArray() ) );   // normalizado a 2 unidades

El recálculo es responsabilidad tuya en cuanto muevas vértices:

JS
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:

JS
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:

JS
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:

JS
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:

JS
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.