InstancedMesh y la matriz por instancia
Cómo se construye un InstancedMesh, el patrón del objeto auxiliar, por qué hay que cambiar la pista de uso a mano, y cómo dibujar solo una parte de las instancias.
El constructor y la matriz por instancia
new THREE.InstancedMesh( geometry, material, count )Extiende Mesh, así que es un Object3D normal: se añade a la escena, se mueve, se rota. Lo que añade es esto:
instanceMatrix // InstancedBufferAttribute con Float32Array(count * 16), itemSize 16instanceColor // null hasta el primer setColorAtmorphTexture // null hasta el primer setMorphAtcount // cuántas instancias se dibujanboundingBox // null, se computa bajo demandaboundingSphere // null, se computa bajo demandaEl constructor inicializa las count matrices a la identidad con un bucle. Eso significa que un InstancedMesh recién creado dibuja count copias superpuestas en el origen, no nada.
La matriz de cada instancia son dieciséis flotantes en orden por columnas, el mismo que Matrix4.elements. En el vertex shader se aplica antes que la matriz del objeto:
posicion_mundo = matrixWorld · instanceMatrix · positionDe ahí sale una propiedad muy útil: las matrices de instancia están en el espacio local del InstancedMesh. Mover, rotar o escalar el objeto mueve todas las instancias a la vez, sin tocar ni un byte del buffer. Un bosque instanciado se puede desplazar entero con bosque.position.x += 1.
El patrón del objeto auxiliar
Componer una matriz de transformación a mano es tedioso y fácil de equivocar. El patrón universal es usar un Object3D desechable como calculadora:
import * as THREE from 'three';const N = 5000;const malla = new THREE.InstancedMesh( new THREE.BoxGeometry( 0.2, 0.2, 0.2 ), new THREE.MeshStandardMaterial( { color: 0x89b4fa, roughness: 0.4 } ), N);const aux = new THREE.Object3D(); // uno solo, reutilizadofor ( let i = 0; i < N; i ++ ) { aux.position.set( ( Math.random() - 0.5 ) * 30, ( Math.random() - 0.5 ) * 30, ( Math.random() - 0.5 ) * 30 ); aux.rotation.set( Math.random() * 6.28, Math.random() * 6.28, 0 ); aux.scale.setScalar( 0.5 + Math.random() ); aux.updateMatrix(); // compone posición, rotación y escala malla.setMatrixAt( i, aux.matrix );}malla.instanceMatrix.needsUpdate = true; // una vez, fuera del buclescene.add( malla );Tres cosas del patrón importan. El objeto auxiliar es uno solo, reutilizado: crear cinco mil Object3D para tirarlos genera basura innecesaria. updateMatrix() es obligatorio, porque setMatrixAt copia aux.matrix y esa matriz no se recompone sola. Y needsUpdate va fuera del bucle, porque es un setter que incrementa un contador de versión: llamarlo cinco mil veces no rompe nada pero es trabajo tirado y confunde a quien lee el código.
Si prefieres saltarte el Object3D, Matrix4 tiene el método directo:
const m = new THREE.Matrix4();const p = new THREE.Vector3();const q = new THREE.Quaternion();const s = new THREE.Vector3( 1, 1, 1 );m.compose( p, q, s );malla.setMatrixAt( i, m );Y para leer de vuelta:
const m = new THREE.Matrix4();malla.getMatrixAt( 37, m );m.decompose( p, q, s );Actualizar cada frame: usage y needsUpdate
Aquí está la trampa que cuesta rendimiento sin dar la cara. InstancedMesh crea su instanceMatrix sin tocar la propiedad usage, así que hereda el valor por defecto de BufferAttribute, que es StaticDrawUsage. Si vas a reescribir las matrices cada frame, hay que decirlo, y hay que decirlo antes del primer render:
malla.instanceMatrix.setUsage( THREE.DynamicDrawUsage );La pista viaja al driver al crear el buffer y a partir de ahí no se puede cambiar. Sin ella, el driver aloja el buffer en la memoria optimizada para lectura y cada actualización cuesta más de lo necesario.
El ejemplo completo con animación:
import * as THREE from 'three';const N = 5000;const malla = new THREE.InstancedMesh( new THREE.BoxGeometry( 0.2, 0.2, 0.2 ), new THREE.MeshStandardMaterial( { color: 0x89b4fa, roughness: 0.4 } ), N);malla.instanceMatrix.setUsage( THREE.DynamicDrawUsage );scene.add( malla );// Las posiciones base, calculadas una vez.const base = new Float32Array( N * 3 );for ( let i = 0; i < N * 3; i ++ ) base[ i ] = ( Math.random() - 0.5 ) * 30;const aux = new THREE.Object3D();const reloj = new THREE.Timer();reloj.connect( document );renderer.setAnimationLoop( ( tiempo ) => { reloj.update( tiempo ); const t = reloj.getElapsed(); for ( let i = 0; i < N; i ++ ) { const x = base[ i * 3 + 0 ]; const y = base[ i * 3 + 1 ]; const z = base[ i * 3 + 2 ]; aux.position.set( x, y + Math.sin( t + x * 0.3 ) * 0.6, z ); aux.rotation.set( t * 0.3 + x, t * 0.2 + z, 0 ); aux.updateMatrix(); malla.setMatrixAt( i, aux.matrix ); } malla.instanceMatrix.needsUpdate = true; renderer.render( scene, camera );} );Y ahora la advertencia honesta sobre este código: recomponer cinco mil matrices por frame en JavaScript cuesta entre uno y tres milisegundos, más la subida de trescientos veinte kilobytes de matrices. Has cambiado cinco mil llamadas de dibujo por un bucle de cinco mil composiciones de matriz. Es una mejora enorme —la composición es mucho más barata que la llamada— pero no es gratis, y con cincuenta mil instancias vuelve a ser un problema.
La solución completa es dejar las matrices estáticas y hacer la animación en el vertex shader, leyendo un atributo propio por instancia. Es el tema de la lección siguiente, y es la diferencia entre cinco mil instancias animadas y quinientas mil.
count, raycast y liberación
La propiedad count es el número de instancias que se dibujan, y se puede bajar en caliente sin reasignar nada:
malla.count = 1200; // dibuja solo las 1200 primeras del bufferEs el equivalente de setDrawRange para instancias y es la base de dos patrones muy usados. El de depósito: reservas el máximo, mantienes las instancias activas al principio del array y ajustas count según cuántas haya vivas. Y el de nivel de detalle: ordenas las instancias por importancia y bajas count cuando la escena va justa.
Lo que no se puede hacer es subir count por encima del valor del constructor: el buffer tiene el tamaño con el que se creó y no se puede redimensionar. Reserva el máximo desde el principio.
El raycasting funciona y merece una nota. InstancedMesh.raycast prueba primero la esfera envolvente global y, si pasa, itera todas las count instancias comprobando la geometría contra cada una. Las intersecciones traen el índice:
const impactos = raycaster.intersectObject( malla );if ( impactos.length > 0 ) { console.log( 'instancia', impactos[ 0 ].instanceId );}Ese bucle es lineal en el número de instancias y en el de triángulos de la geometría. Con cinco mil instancias de un cubo es tolerable; con cincuenta mil de una geometría de mil triángulos, es inviable por frame y hay que filtrar antes con una estructura espacial propia.
La liberación tiene un detalle que conviene conocer. InstancedMesh.dispose() despacha el evento de liberación y libera la textura de morph si existe. No libera la geometría, ni el material, ni las texturas, exactamente igual que cualquier otra malla. Y la comprobación de fugas es la de siempre:
malla.geometry.dispose();malla.material.dispose();malla.dispose();malla.removeFromParent();console.log( renderer.info.memory.geometries ); // tiene que volver a su valor previo