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.
InstancedMesh resuelve el problema de mil copias del mismo objeto. BatchedMesh resuelve el siguiente: mil objetos distintos que comparten material. Mete todas las geometrías en un par de buffers grandes, guarda dónde empieza y acaba cada una, y emite el conjunto con un dibujo múltiple. Y por el camino recupera dos cosas que el instancing había perdido: el descarte por frustum de cada instancia y la ordenación por profundidad.
- Dimensionar y construir un
BatchedMeshcon la API en dos pasos de r184. - Aprovechar el culling y la ordenación por instancia que trae activados por defecto.
- Cambiar la geometría de una instancia en caliente para implementar niveles de detalle.
- Enumerar las restricciones reales y elegir entre las tres formas de dibujar.
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
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.
import * as THREE from 'three';
const MAX_INSTANCIAS = 5000;
const MAX_VERTICES = 4096; // suma de las geometrías distintas, con holgura
const 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:
lote.perObjectFrustumCulled = true; // por defecto
lote.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:
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:
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.
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:
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.
El método copy de BatchedMesh en r184 comprueba this._colorsTexture !== null en lugar de source._colorsTexture antes de copiar la textura de colores. Como un BatchedMesh recién construido siempre tiene esa textura a null, la condición nunca se cumple y los colores por instancia no se copian al clonar. Si necesitas duplicar un lote, vuelve a aplicar los colores a mano sobre la copia.
BatchedMesh es interesante por lo que hace y más interesante todavía por hacia dónde apunta. Fíjate en lo que ocurre en su onBeforeRender: recorre las instancias en JavaScript, prueba cada esfera contra el frustum, las ordena, construye un array de rangos y lo entrega a una única orden de dibujo múltiple. Es decir, la CPU sigue decidiendo qué se dibuja, pero ya no lo dice objeto a objeto: lo dice una vez, en forma de datos. Ese es el penúltimo peldaño de una escalera que lleva subiendo treinta años. El último es el dibujo indirecto, donde ni siquiera la lista de rangos la construye la CPU: vive en un buffer que un shader de cómputo rellena, y la CPU emite una orden que dice “dibuja lo que ponga en ese buffer” sin saber cuántos objetos hay ni dónde están. Con eso, el culling, la selección de nivel de detalle y la ordenación se hacen en la GPU sobre decenas de miles de objetos en microsegundos, y el hilo principal deja de participar en el renderizado por completo. WebGL no llega ahí: no tiene shaders de cómputo ni dibujo indirecto. WebGPU sí, y por eso WebGPURenderer y el sistema de nodos no son un cambio de sintaxis sino la puerta a una arquitectura distinta. Mientras tanto, la lección que sí se puede aplicar hoy con cualquier renderer es la que atraviesa las cinco lecciones de este nivel: cada vez que la CPU tome una decisión por objeto y por frame, pregúntate si esa decisión se puede convertir en un dato. Una matriz en un buffer en lugar de un uniform. Una fase en un atributo en lugar de un bucle. Un rango en una lista en lugar de una llamada. Es la misma jugada repetida a escalas distintas, y es prácticamente toda la optimización gráfica que existe.
- Construye un
BatchedMeshcon tres geometrías y cinco mil instancias, y compruebaunusedVertexCount. - Desactiva
perObjectFrustumCulledy mide la diferencia al mirar al vacío. - Pon el material en transparente y comprueba que la ordenación hace que se mezclen bien.
- Implementa niveles de detalle con
setGeometryIdAty mide cuántos triángulos ahorras. - Borra un tercio de las instancias, llama a
optimize()y observa cómo cambian los contadores.