Saltar al contenido

BatchedMesh: geometrías distintas en un solo lote

La API en dos pasos de BatchedMesh, el culling y la ordenación por instancia que InstancedMesh no tiene, cómo cambiar la geometría de una instancia en caliente, y sus restricciones reales.

El hueco que llena

Entre una malla por objeto y un InstancedMesh hay un caso muy común que ninguna de las dos cubre bien: una escena con cincuenta modelos distintos repetidos muchas veces, todos con el mismo material. Con mallas separadas son miles de llamadas; con InstancedMesh hacen falta cincuenta objetos, uno por geometría, y cada uno con su culling de todo o nada.

BatchedMesh mete las cincuenta geometrías en un solo par de buffers —vértices e índices— y guarda por cada una el rango que ocupa. Luego, por cada instancia, guarda a qué geometría corresponde y su matriz. Al dibujar, construye la lista de rangos de las instancias visibles y emite un solo dibujo múltiple con todos ellos.

La consecuencia interesante es que, como cada instancia tiene su propio rango, el motor puede decidir por instancia: incluirla o no según el frustum, y ordenarlas por profundidad antes de emitir. Eso es exactamente lo que InstancedMesh no puede hacer.

Construir y poblar

JS
new THREE.BatchedMesh( maxInstanceCount, maxVertexCount, maxIndexCount = maxVertexCount * 2, material )

Los buffers se reservan en el constructor y no crecen solos, así que hay que dimensionarlos. Y aquí está el detalle que la gente calcula mal la primera vez: el presupuesto de vértices es para las geometrías distintas, no para las instancias. Cada geometría se almacena una sola vez, aunque tenga mil instancias.

JS
import * as THREE from 'three';const MAX_INSTANCIAS = 5000;const MAX_VERTICES = 4096;    // suma de las geometrías distintas, con holguraconst MAX_INDICES = 8192;const material = new THREE.MeshStandardMaterial( { roughness: 0.4 } );const lote = new THREE.BatchedMesh( MAX_INSTANCIAS, MAX_VERTICES, MAX_INDICES, material );// Paso 1: registrar las geometrías. Devuelve un identificador de geometría.const idCubo   = lote.addGeometry( new THREE.BoxGeometry( 0.4, 0.4, 0.4 ) );const idEsfera = lote.addGeometry( new THREE.SphereGeometry( 0.25, 16, 12 ) );const idCono   = lote.addGeometry( new THREE.ConeGeometry( 0.25, 0.5, 16 ) );const formas = [ idCubo, idEsfera, idCono ];const aux = new THREE.Object3D();const color = new THREE.Color();// Paso 2: crear instancias. Devuelve un identificador de instancia.for ( let i = 0; i < MAX_INSTANCIAS; i ++ ) {  const id = lote.addInstance( formas[ i % formas.length ] );  aux.position.set(    ( Math.random() - 0.5 ) * 40,    ( Math.random() - 0.5 ) * 20,    ( Math.random() - 0.5 ) * 40  );  aux.rotation.set( Math.random() * 6.28, Math.random() * 6.28, 0 );  aux.updateMatrix();  lote.setMatrixAt( id, aux.matrix );  lote.setColorAt( id, color.setHSL( ( i % 360 ) / 360, 0.6, 0.55 ) );}scene.add( lote );console.log( lote.instanceCount, lote.unusedVertexCount, lote.unusedIndexCount );

Las tres geometrías del ejemplo suman poco más de trescientos vértices y unos mil doscientos índices entre las tres. Cuatro mil y ocho mil dan holgura de sobra para añadir más adelante.

Los dos pasos son separados y ambos obligatorios. addGeometry copia los datos a los buffers y devuelve un identificador de geometría; no dibuja nada por sí solo. addInstance( idGeometria ) crea una instancia que sí se dibuja y devuelve el identificador que usan setMatrixAt, setColorAt y setVisibleAt. Confundir los dos identificadores es el error de arranque más frecuente, y los métodos de validación lanzan errores explícitos cuando ocurre.

addGeometry acepta dos parámetros más, reservedVertexCount y reservedIndexCount, que reservan más espacio del que la geometría necesita ahora. Sirven para poder sustituirla después por una versión más pesada con setGeometryAt. Con el valor por defecto de menos uno se reserva justo lo que ocupa.

Los cuatro contadores de solo lectura te dicen cómo va el presupuesto: maxInstanceCount, instanceCount, unusedVertexCount y unusedIndexCount. Y si te quedas corto, setInstanceCount y setGeometrySize redimensionan, con la advertencia de que encoger por debajo de lo usado lanza un error.

Una diferencia notable respecto a InstancedMesh: setColorAt acepta un Color o un Vector4, así que aquí sí hay opacidad por instancia sin escribir shaders.

Culling, orden y visibilidad por instancia

Estas son las dos propiedades que justifican la clase, y las dos vienen activadas:

JS
lote.perObjectFrustumCulled = true;   // por defectolote.sortObjects = true;              // por defecto

Con la primera, antes de cada dibujo el objeto construye el frustum en su propio espacio local —multiplicando la proyección por la inversa de la matriz de vista y por su matriz de mundo— y prueba la esfera envolvente de cada instancia. Las que no pasan no entran en la lista de rangos. Es el culling por instancia que había que implementar a mano con InstancedMesh.

Con la segunda, las instancias visibles se ordenan antes de emitir: de atrás hacia adelante si el material es transparente, de delante hacia atrás si es opaco. Eso significa que las transparencias instanciadas se mezclan bien, cosa que con InstancedMesh simplemente no ocurre. Y si tu criterio de orden es otro, se sustituye:

JS
lote.setCustomSort( ( lista, camara ) => {  // lista es un array de { start, count, z } que puedes reordenar in situ  lista.sort( ( a, b ) => b.z - a.z );} );

La visibilidad individual es una llamada y no toca ningún buffer de matrices:

JS
lote.setVisibleAt( id, false );console.log( lote.getVisibleAt( id ) );

Y aquí está la capacidad que más rendimiento da y que casi nadie usa: cambiar la geometría de una instancia en caliente.

JS
lote.setGeometryIdAt( id, idBajoDetalle );

Con eso, los niveles de detalle por instancia dejan de ser un problema. Registras dos o tres versiones de cada modelo, y en cada frame —o mejor, cada pocos frames— asignas a cada instancia la versión que corresponda a su distancia:

JS
const posicion = new THREE.Vector3();const matriz = new THREE.Matrix4();function actualizarDetalle( lote, camara, instancias ) {  for ( const { id, alto, medio, bajo } of instancias ) {    lote.getMatrixAt( id, matriz );    posicion.setFromMatrixPosition( matriz );    const d = posicion.distanceTo( camara.position );    lote.setGeometryIdAt( id, d < 20 ? alto : d < 60 ? medio : bajo );  }}

Es la respuesta directa a una de las decisiones que el instancing hacía perder, y hacerlo con InstancedMesh obligaría a mantener un objeto por nivel y mover instancias entre ellos a mano.

Restricciones y comparación

Lo que hay que aceptar para usarlo.

Un solo material. Todas las geometrías comparten el mismo, porque comparten llamada de dibujo. La variación se consigue con color por instancia o con atributos propios, no con materiales distintos.

Un mismo juego de atributos. Todas las geometrías se copian a los mismos buffers, así que tienen que tener los mismos atributos con los mismos tamaños. Mezclar una con UVs y otra sin ellas no funciona.

Nada de escalas negativas. La documentación del método es explícita: las matrices con escala negativa no están soportadas. Para espejar, hay que registrar la geometría espejada como una geometría más.

Presupuesto fijo por delante. Los tres máximos se fijan al construir. Redimensionar es posible pero reasigna buffers y texturas.

Borrar deja huecos. deleteGeometry y deleteInstance liberan lógicamente, pero el espacio de vértices queda fragmentado hasta que llames a optimize(), que repacka los rangos.

La comparación final entre las tres formas de dibujar:

Mesh InstancedMesh BatchedMesh
Geometrías distintas una por objeto no sí
Materiales distintos sí no no
Llamadas una por objeto una un dibujo múltiple
Culling por objeto por lote entero por instancia
Orden por profundidad sí no sí
Color por instancia no aplica sí, sin alfa sí, con alfa
Visibilidad individual visible bajar count setVisibleAt
Nivel de detalle por objeto no setGeometryIdAt
Añadir y quitar en caliente sí reasignar sí

El criterio, resumido: una geometría repetida muchas veces, InstancedMesh, porque es más simple, más barato en memoria y no necesita presupuesto por adelantado. Varias geometrías con un material, BatchedMesh, especialmente si hay transparencias, si el mundo es grande y necesitas culling fino, o si quieres niveles de detalle. Varios materiales, ninguno de los dos: primero unifica los materiales con un atlas o con datos por instancia, y luego vuelve a plantear la pregunta.