El problema de la llamada de dibujo
Qué cuesta exactamente dibujar un objeto, el trabajo que Three.js hace por cada malla en cada frame, y los números concretos de mil cubos separados frente a mil instancias.
La intuición dice que una escena es cara porque tiene muchos triángulos. Casi nunca es así. Un objeto de un millón de triángulos se dibuja sin despeinarse en cualquier portátil; mil objetos de mil triángulos cada uno —los mismos triángulos en total— tumban la misma máquina a treinta fotogramas por segundo. La diferencia no está en la GPU, que en el segundo caso está prácticamente ociosa: está en la CPU, y concretamente en el coste fijo de decirle a la GPU que dibuje algo. Ese coste fijo es lo que el instancing elimina.
- Desglosar el trabajo que ocurre en cada llamada de dibujo, en el motor y en el driver.
- Enumerar lo que Three.js hace por cada objeto de la escena en cada frame.
- Medir llamadas y tiempo de CPU con
renderer.infoyperformance.now(). - Reconocer los casos en que el instancing no arregla nada.
Qué cuesta una llamada de dibujo
Dibujar un objeto no es una instrucción: es una secuencia de cambios de estado seguida de una orden. En cada uno de ellos hay que validar, comparar contra el estado actual y, si difiere, escribir en el buffer de comandos que acabará llegando a la GPU.
Para un objeto cualquiera, esa secuencia incluye seleccionar el programa de shaders si es distinto del anterior, enlazar el objeto de array de vértices con los punteros de todos los atributos, enlazar cada textura en su unidad, subir los uniforms que hayan cambiado —en particular la matriz de modelo y vista y la matriz normal, que son distintas para cada objeto— y finalmente emitir la orden de dibujo.
Nada de eso lo hace la GPU. Todo ocurre en la CPU, en el hilo principal de JavaScript primero y en el driver después. Y hay un detalle que agrava la situación en el navegador: por razones de seguridad, las llamadas de WebGL atraviesan una capa de validación adicional que no existe en aplicaciones nativas. Un motor nativo puede permitirse decenas de miles de llamadas por frame; en la web, el techo práctico está uno o dos órdenes de magnitud por debajo.
Lo importante es que ese coste es fijo por objeto e independiente de su tamaño. Dibujar un cubo de doce triángulos cuesta casi exactamente lo mismo que dibujar una malla de un millón. De ahí sale toda la aritmética del instancing.
El trabajo que Three.js hace por objeto
Antes de llegar a la llamada de dibujo, el motor ya ha hecho su parte. En cada render(), por cada objeto del grafo de escena:
Recorre el grafo y actualiza la matriz de mundo, componiendo posición, cuaternión y escala y multiplicando por la del padre. Es una composición y una multiplicación de matrices de cuatro por cuatro por objeto.
Comprueba el frustum culling: computa o recupera la esfera envolvente, la transforma por la matriz de mundo y la prueba contra seis planos.
Si pasa, lo añade a la lista de render, calculando su profundidad respecto a la cámara para poder ordenar después.
Al terminar el recorrido, ordena la lista: los opacos de delante hacia atrás para aprovechar el rechazo temprano de profundidad, los transparentes de atrás hacia adelante para que la mezcla salga bien.
Y luego, por cada elemento de la lista, calcula la matriz de modelo y vista y la matriz normal, comprueba si el programa o el material han cambiado, sube los uniforms que hagan falta y emite el dibujo.
Sumado, eso son del orden de quince a treinta microsegundos por objeto en una máquina de escritorio típica, y bastante más en un móvil. Parece poco. Multiplicado por mil, son entre quince y treinta milisegundos, y el presupuesto entero del frame a sesenta hercios es de dieciséis coma seis.
Los números
El experimento cabe en una pantalla. Mil cubos idénticos repartidos por un volumen, montados de las dos maneras:
import * as THREE from 'three';
const N = 1000;
const geometria = new THREE.BoxGeometry( 0.4, 0.4, 0.4 );
const material = new THREE.MeshStandardMaterial( { color: 0x89b4fa, roughness: 0.4 } );
function posicionAleatoria( objeto ) {
objeto.position.set(
( Math.random() - 0.5 ) * 20,
( Math.random() - 0.5 ) * 20,
( Math.random() - 0.5 ) * 20
);
objeto.rotation.set( Math.random() * 6.28, Math.random() * 6.28, 0 );
}
// Versión A: mil mallas separadas.
const grupoA = new THREE.Group();
for ( let i = 0; i < N; i ++ ) {
const m = new THREE.Mesh( geometria, material );
posicionAleatoria( m );
grupoA.add( m );
}
// Versión B: una sola InstancedMesh.
const grupoB = new THREE.InstancedMesh( geometria, material, N );
const auxiliar = new THREE.Object3D();
for ( let i = 0; i < N; i ++ ) {
posicionAleatoria( auxiliar );
auxiliar.updateMatrix();
grupoB.setMatrixAt( i, auxiliar.matrix );
}
grupoB.instanceMatrix.needsUpdate = true;
Y la medición, que es lo que convierte la lección en un hecho comprobable:
let acumulado = 0;
let frames = 0;
renderer.setAnimationLoop( () => {
const t0 = performance.now();
renderer.render( scene, camera );
acumulado += performance.now() - t0;
if ( ++ frames === 60 ) {
console.log(
( acumulado / 60 ).toFixed( 2 ), 'ms de CPU |',
renderer.info.render.calls, 'llamadas |',
renderer.info.render.triangles, 'triángulos'
);
acumulado = 0;
frames = 0;
}
} );
renderer.info se resetea al principio de cada render(), no al final: los contadores se ponen a cero antes de dibujar la escena y se van llenando durante la pasada. Por eso leerlo justo después da los valores de ese frame, y leerlo justo antes da los del anterior.
Los resultados, en órdenes de magnitud que verás reproducidos en cualquier máquina razonable:
| Escena | Llamadas | Triángulos | CPU por frame | Fotogramas por segundo |
|---|---|---|---|---|
| 1000 mallas separadas | 1000 | 12 000 | 15 a 30 ms | 30 a 60 |
1000 en un InstancedMesh |
1 | 12 000 | 0,1 a 0,3 ms | tope de refresco |
| 1000 separadas, con sombras | 2000 | 24 000 | 30 a 55 ms | 18 a 30 |
| 1000 instanciadas, con sombras | 2 | 24 000 | 0,2 a 0,5 ms | tope de refresco |
Los triángulos son idénticos en las dos filas de cada par. Doce mil triángulos es una cifra ridícula: una sola esfera con los valores por defecto son novecientos sesenta. La GPU no está haciendo prácticamente nada en ninguno de los dos casos. Toda la diferencia está en la CPU, y es de dos órdenes de magnitud.
Fíjate también en el efecto de las sombras: cada luz que proyecta sombra vuelve a recorrer la escena y a emitir sus propias llamadas. Con mallas separadas, eso duplica el problema; con instancias, duplica algo que ya era despreciable. Y aquí hay un detalle del contador que engaña: la pasada de sombras ocurre antes del reinicio automático, así que sus llamadas no aparecen en renderer.info.render.calls. Las columnas de las dos últimas filas son las llamadas realmente emitidas; para verlas en el contador hay que poner renderer.info.autoReset = false y reiniciarlo a mano al terminar el frame.
Y una consecuencia que sorprende: los objetos estáticos no son gratis. Mil cubos que no se mueven pagan igual el recorrido, el culling, la ordenación y la subida de uniformes en cada frame. Se puede recortar algo con matrixAutoUpdate = false, que evita recomponer la matriz de un objeto que no cambia, pero el grueso del coste sigue ahí.
Lo que el instancing no arregla
Vale la pena delimitar el problema, porque el instancing se ha convertido en una respuesta refleja y no siempre aplica.
Si el cuello está en la GPU, no cambia nada. Cien objetos grandes con un material caro, muchas luces, transparencias solapadas o texturas de alta resolución están limitados por el trabajo de fragmentos. Convertirlos en instancias reduce cien llamadas a una, es decir, ahorra un par de milisegundos de un frame que dura treinta. La señal para distinguir los dos casos es directa: si el tiempo de CPU medido con performance.now() alrededor de render() es mucho menor que el tiempo de frame, el cuello no es tuyo.
Si los objetos tienen geometrías distintas, InstancedMesh no sirve. Todas las instancias comparten una geometría y un material. Para geometrías distintas con el mismo material existe BatchedMesh, y para materiales distintos no hay solución dentro de una sola llamada: hay que unificarlos con un atlas de texturas o con datos por instancia.
Si el problema son los materiales, no los objetos. Cien objetos con cien materiales distintos cuestan cien cambios de programa, que son de las operaciones más caras del conjunto. Ahí la optimización no es instanciar sino reducir el número de materiales, y hacerlo suele ganar más que cualquier otra cosa.
Y una alternativa que a veces es mejor que el instancing y que casi nadie considera: si los objetos son estáticos y comparten material, fusionarlos en una sola geometría con mergeGeometries de los addons da una sola llamada de dibujo sin ninguna de las complicaciones del instancing. Pierdes la capacidad de moverlos por separado y de cullearlos por separado, que es exactamente lo que también pierdes con InstancedMesh, así que para geometría de decorado fija suele ser la opción más simple.
La forma más útil de entender el instancing no es “una llamada en lugar de mil”. Es un cambio en quién sabe la posición de cada objeto. Sin instancing, la posición de cada malla vive en la CPU, en su matriz de mundo, y viaja a la GPU como un uniform justo antes de dibujarla; por eso hace falta una llamada por objeto, porque un uniform es constante durante toda una llamada y no se puede cambiar a mitad. Con instancing, las mil matrices viven en la memoria de la GPU, en un buffer, y el vertex shader lee la que le toca según el índice de instancia. La CPU deja de participar. Ese desplazamiento —de estado por llamada a datos en un buffer— es el patrón más importante de toda la optimización gráfica moderna, y una vez que lo reconoces lo ves en todas partes: los atlas de texturas convierten un enlace de textura por objeto en una coordenada por vértice; los arrays de texturas hacen lo mismo con un índice por instancia; los buffers de uniformes agrupan cientos de valores en una sola subida; el dibujo indirecto lleva la idea hasta el final poniendo en la GPU incluso el número de instancias a dibujar, de modo que la CPU emite una orden sin saber cuántos objetos hay. Todos son la misma jugada: convertir estado en datos. Y el corolario práctico, que sirve para decidir mucho más allá del instancing, es que cuando midas y descubras que estás limitado por la CPU, la pregunta correcta no es cómo hacer más rápido cada cambio de estado, sino qué estado puedes convertir en un buffer.
- Monta las dos versiones de mil cubos y anota llamadas, triángulos y milisegundos de CPU de cada una.
- Sube a diez mil objetos y comprueba que la versión separada se vuelve inusable y la instanciada no cambia.
- Añade una luz con sombra y verifica que las llamadas se duplican.
- Pon
matrixAutoUpdate = falseen las mil mallas separadas y mide cuánto ahorra. - Sustituye las mallas separadas por una geometría fusionada con
mergeGeometriesy compara con el instancing.