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

Color y datos propios por instancia

Cómo funciona setColorAt y por qué no admite alfa, cómo añadir tus propios atributos por instancia con InstancedBufferAttribute, y cómo animar cien mil objetos con un solo uniform.

⏱ 19 min

Instanciar mil copias idénticas es una demo. Instanciar mil objetos que se parecen pero no son iguales es lo que hace falta en la práctica, y para eso hace falta variar datos por instancia. Three.js trae el color resuelto con un método y deja el resto abierto: cualquier atributo con divisor de instancia sirve, y consumirlo desde un material PBR cuesta un parche de shader. Con eso se llega al punto donde las matrices dejan de tocarse y la animación entera vive en la GPU.

🎯 Al terminar esta lección sabrás
  • Usar setColorAt sabiendo qué buffer crea y por qué no tiene canal alfa.
  • Declarar atributos propios por instancia con InstancedBufferAttribute.
  • Animar cien mil instancias subiendo un solo flotante por frame.
  • Entender meshPerAttribute y cuándo hace falta InstancedBufferGeometry.

setColorAt y lo que crea por debajo

const color = new THREE.Color();
malla.setColorAt( i, color.setHSL( i / N, 0.6, 0.55 ) );
malla.instanceColor.needsUpdate = true;

La primera llamada crea el buffer de forma perezosa, y su forma exacta importa:

// Lo que hace InstancedMesh la primera vez que llamas a setColorAt:
this.instanceColor = new THREE.InstancedBufferAttribute(
  new Float32Array( this.instanceMatrix.count * 3 ).fill( 1 ), 3
);

Tres componentes, inicializado a blanco. No hay canal alfa, y eso no es un descuido que se pueda rodear pasando un Vector4: el atributo del shader está declarado como vec3 y el color se aplica multiplicando las tres componentes del color difuso. Si necesitas opacidad por instancia, hace falta un atributo propio.

Un detalle que ahorra una búsqueda: no hay que activar material.vertexColors. El renderer detecta que el objeto tiene instanceColor y activa por su cuenta la ruta correspondiente en el shader, que declara la varying de color en el vertex aunque USE_COLOR no esté definido, y la fuerza en el fragment para que se aplique al color difuso. Es una asimetría deliberada entre las dos etapas y funciona sola.

// color_vertex.glsl.js, la parte que importa
#ifdef USE_INSTANCING_COLOR
  vColor.rgb *= instanceColor.rgb;
#endif

Fíjate en que multiplica. El color de instancia no sustituye al color del material: lo modula. Un material con color a 0x808080 y una instancia con color blanco sale gris. Lo habitual es dejar el material en blanco y controlar todo desde las instancias.

Y sobre el espacio de color: setColorAt recibe un THREE.Color, que con la gestión de color activada ya está en el espacio de trabajo lineal. Construir el color con setHex, setStyle o setHSL hace la conversión desde sRGB por ti. Rellenar el array de instanceColor a mano con valores sacados de un selector de la interfaz salta esa conversión y produce colores lavados.

getColorAt( i, color ) lee de vuelta, y si instanceColor todavía es null devuelve blanco en lugar de fallar.

Datos propios por instancia

Lo que hace posible el color por instancia es un mecanismo general: un atributo con divisor de instancia. Un atributo normal avanza un elemento por vértice; uno con divisor uno avanza un elemento por instancia. En Three.js eso es InstancedBufferAttribute:

new THREE.InstancedBufferAttribute( array, itemSize, normalized, meshPerAttribute = 1 )

Se pone en la geometría, como cualquier otro atributo, y el renderer detecta la bandera isInstancedBufferAttribute para configurar el divisor:

const fases = new Float32Array( N );
for ( let i = 0; i < N; i ++ ) fases[ i ] = Math.random() * Math.PI * 2;

geometria.setAttribute( 'aFase', new THREE.InstancedBufferAttribute( fases, 1 ) );

Una consecuencia de que viva en la geometría: esa geometría deja de ser compartible con una malla no instanciada. Si la usas en un Mesh normal, el atributo con divisor sigue configurado y el resultado es incorrecto. Clona la geometría si necesitas las dos cosas.

Para leerlo hace falta un shader. Con ShaderMaterial lo declaras y ya está; con un material PBR, se inyecta:

material.onBeforeCompile = ( shader ) => {
  shader.vertexShader = shader.vertexShader.replace( '#include <common>', `
    #include <common>
    attribute float aFase;
  ` );
  // ...y se usa donde haga falta
};

Animar en el shader en lugar de en el bucle

Esta es la culminación. En la lección anterior, animar cinco mil instancias costaba recomponer cinco mil matrices por frame en JavaScript. Ahora las matrices se escriben una vez y no se tocan más, y toda la variación por instancia vive en atributos que la GPU lee sola.

import * as THREE from 'three';

const N = 100000;

const geometria = new THREE.BoxGeometry( 0.15, 0.15, 0.15 );

// Un flotante por instancia: la fase de su oscilación.
const fases = new Float32Array( N );
for ( let i = 0; i < N; i ++ ) fases[ i ] = Math.random() * Math.PI * 2;
geometria.setAttribute( 'aFase', new THREE.InstancedBufferAttribute( fases, 1 ) );

const uniforms = { uTiempo: { value: 0 } };
const material = new THREE.MeshStandardMaterial( { roughness: 0.4 } );

material.onBeforeCompile = ( shader ) => {
  shader.uniforms.uTiempo = uniforms.uTiempo;

  shader.vertexShader = shader.vertexShader
    .replace( '#include <common>', `
      #include <common>
      attribute float aFase;
      uniform float uTiempo;
    ` )
    .replace( '#include <begin_vertex>', `
      #include <begin_vertex>
      transformed.y += sin( uTiempo * 1.5 + aFase ) * 0.5;
    ` );
};
material.customProgramCacheKey = () => 'instancias-onda-v1';

const malla = new THREE.InstancedMesh( geometria, material, N );

const aux = new THREE.Object3D();
const color = new THREE.Color();

for ( let i = 0; i < N; i ++ ) {
  aux.position.set(
    ( Math.random() - 0.5 ) * 80,
    ( Math.random() - 0.5 ) * 20,
    ( Math.random() - 0.5 ) * 80
  );
  aux.updateMatrix();
  malla.setMatrixAt( i, aux.matrix );
  malla.setColorAt( i, color.setHSL( ( i % 360 ) / 360, 0.6, 0.55 ) );
}

malla.instanceMatrix.needsUpdate = true;
malla.instanceColor.needsUpdate = true;
malla.frustumCulled = false;   // la esfera envolvente no describe el desplazamiento

scene.add( malla );

const reloj = new THREE.Timer();
reloj.connect( document );

renderer.setAnimationLoop( ( tiempo ) => {
  reloj.update( tiempo );
  uniforms.uTiempo.value = reloj.getElapsed();   // un flotante por frame, y ya está
  renderer.render( scene, camera );
} );

Cien mil cubos animados, cada uno con su color y su fase, y el bucle de render sube un solo número. Una llamada de dibujo, 1,2 millones de triángulos, 2,4 millones de invocaciones de vertex shader. En ese régimen ya estás limitado por la GPU, que es exactamente donde se quiere estar: la CPU no hace nada y toda la máquina trabaja en lo que sabe hacer.

Un detalle geométrico del parche: transformed.y se desplaza antes de aplicar la matriz de instancia, porque el shader estándar multiplica por instanceMatrix después de begin_vertex. Con instancias sin rotación, como las del ejemplo, eso equivale a desplazar en vertical. Si tus instancias están rotadas, el desplazamiento se rota con ellas, que puede ser lo que quieres o no; para desplazar siempre en el eje del mundo hay que parchear más adelante, después de la multiplicación.

Y el mismo patrón resuelve la limitación del alfa:

const alfas = new Float32Array( N ).fill( 1 );
geometria.setAttribute( 'aAlfa', new THREE.InstancedBufferAttribute( alfas, 1 ) );

// En el vertex: declarar `attribute float aAlfa;` y una `varying float vAlfa;`
// En el fragment: declarar la varying y multiplicar el alfa de salida por ella.

meshPerAttribute e InstancedBufferGeometry

El cuarto argumento de InstancedBufferAttribute es meshPerAttribute y por defecto vale uno: cada instancia consume un elemento del array. Con dos, cada valor lo comparten dos instancias consecutivas; con cuatro, cuatro. Sirve para datos que se repiten en grupos —el color de un equipo, la variante de una fila, el tono de un lote— y ahorra memoria proporcionalmente.

// Un color cada cuatro instancias: N/4 entradas en lugar de N.
const tonos = new Float32Array( ( N / 4 ) * 3 );
geometria.setAttribute( 'aTono', new THREE.InstancedBufferAttribute( tonos, 3, false, 4 ) );

Ese número es literalmente el divisor que se pasa a la API de gráficos, así que no hay ninguna emulación por medio.

Y para cerrar, la vía completamente manual. InstancedBufferGeometry es una BufferGeometry con una propiedad más:

const geo = new THREE.InstancedBufferGeometry();
geo.instanceCount = 2000;   // por defecto vale Infinity

Se usa con un Mesh normal, no con un InstancedMesh, y no trae ni matriz ni color de instancia: todo lo pones tú como InstancedBufferAttribute. Tiene sentido cuando las instancias no necesitan una matriz completa —partículas que solo necesitan posición y escala son siete flotantes en lugar de dieciséis— o cuando quieres control total sobre la disposición de los datos. Para casi todo lo demás, InstancedMesh es mejor punto de partida porque trae el raycasting, los volúmenes envolventes y la integración con las sombras resueltos.

El instancing bien hecho invierte la relación entre CPU y GPU, y eso cambia dónde hay que optimizar

Hay un momento concreto, al construir una escena instanciada, en que el sistema cruza una frontera y todo lo que sabías sobre dónde optimizar deja de aplicar. Antes del cruce, con matrices actualizadas en JavaScript, sigues limitado por la CPU: el bucle de composición domina, y cualquier mejora pasa por hacer menos trabajo en el hilo principal. Después del cruce, con matrices estáticas y variación en atributos, la CPU no hace nada y el límite lo pone el trabajo de vértices y de fragmentos. Ese cruce importa porque las optimizaciones útiles a cada lado son opuestas. A este lado, reducir el número de instancias no ayuda casi nada —el coste es del bucle, no del dibujo— y lo que ayuda es sacar cálculos del bucle. Al otro lado, reducir el número de instancias ayuda linealmente, bajar los triángulos de la geometría ayuda linealmente, y sacar trabajo del bucle no hace absolutamente nada porque el bucle ya no existe. He visto a gente pasar días optimizando un bucle que consumía el dos por ciento del frame porque era la parte del código que podían ver. La forma de saber en qué lado estás cuesta treinta segundos: mide el tiempo de CPU con performance.now() alrededor de todo tu frame y compáralo con el tiempo entre frames. Si son parecidos, estás a este lado. Si tu CPU tarda medio milisegundo y el frame dura dieciséis, has cruzado, y a partir de ahí la única palanca que queda es la geometría, el material y el número de píxeles que cubres.

⚔️ Instancias que no se parecen
  1. Colorea cinco mil instancias con setColorAt y comprueba que no hace falta activar vertexColors.
  2. Pon el material en gris medio y observa cómo el color de instancia se multiplica en lugar de sustituir.
  3. Añade el atributo de fase y anima cien mil instancias subiendo un solo uniform.
  4. Mide el tiempo de CPU de esa escena y compáralo con la versión que recompone matrices.
  5. Añade opacidad por instancia con un atributo propio y una varying.