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.
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.