wandres.dev
INSTANCING · Miles de objetos en un draw call

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.

⏱ 18 min

InstancedMesh es engañosamente simple: una geometría, un material, un número, y un array de matrices de cuatro por cuatro que vive en la GPU. Lo que no es simple son los tres detalles que decide su implementación y que no aparecen en ningún ejemplo introductorio: la pista de uso del buffer se queda en estática aunque lo actualices cada frame, marcar la actualización dentro del bucle multiplica el trabajo por el número de instancias, y actualizar todas las matrices en JavaScript devuelve a la CPU exactamente el coste que habías eliminado.

🎯 Al terminar esta lección sabrás
  • Construir un InstancedMesh y poblar sus matrices con el patrón del objeto auxiliar.
  • Cambiar la pista de uso del buffer de matrices y explicar por qué es necesario.
  • Reducir el número de instancias dibujadas sin reasignar buffers.
  • Liberar correctamente un InstancedMesh y saber qué libera dispose.

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 16
instanceColor    // null hasta el primer setColorAt
morphTexture     // null hasta el primer setMorphAt
count            // cuántas instancias se dibujan
boundingBox      // null, se computa bajo demanda
boundingSphere   // null, se computa bajo demanda

El 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 · position

De 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, reutilizado

for ( 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 bucle
scene.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 buffer

Es 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
La matriz por instancia es un caso particular de una idea más grande: cualquier dato puede ser por instancia

InstancedMesh presenta la matriz de transformación como si fuera algo especial, y por eso mucha gente se queda ahí: instancia posiciones y rotaciones, y cuando necesita variar cualquier otra cosa concluye que el instancing no le sirve y vuelve a las mallas separadas. Es un malentendido caro. Lo que el hardware ofrece no es “matrices por instancia”: es un divisor de atributo, un número que dice cada cuántas instancias avanza el puntero de lectura de un atributo. Con divisor cero, el atributo avanza por vértice, que es lo normal; con divisor uno, avanza por instancia. La matriz de instancia no es más que un atributo de dieciséis componentes con divisor uno, y Three.js lo empaqueta así porque es el caso que todo el mundo necesita. Pero cualquier atributo puede tener divisor uno, y eso significa que cualquier dato puede variar por instancia: un color, una fase de animación, un índice de textura dentro de un atlas, un estado, un identificador, un tiempo de nacimiento, un peso de mezcla. En cuanto interiorizas esa generalidad, el instancing deja de ser una técnica para repetir el mismo objeto mil veces y pasa a ser el mecanismo con el que se dibujan mil objetos distintos que comparten forma: un bosque donde cada árbol tiene su altura, su tono y su balanceo propios; una multitud donde cada figura tiene su ropa y su animación; un sistema de partículas donde cada una tiene su vida. Todo eso es una sola llamada de dibujo, y la diferencia entre saberlo y no saberlo es la diferencia entre escenas de mil objetos y escenas de cien mil.

⚔️ Instancia de verdad
  1. Monta cinco mil cubos instanciados y comprueba con renderer.info.render.calls que solo hay una llamada.
  2. Quita setUsage y mide si notas diferencia al animar las matrices.
  3. Anima las matrices en el bucle y mide cuánto cuesta el bucle con performance.now().
  4. Baja count a la mitad en caliente y comprueba que se dibujan la mitad.
  5. Raycastea contra el InstancedMesh y usa instanceId para resaltar la instancia golpeada.