wandres.dev
RENDIMIENTO II · Las optimizaciones que valen

Fusionar, instanciar o agrupar: los tres caminos a un draw call

Qué hace exactamente mergeGeometries, en qué se diferencia de InstancedMesh y de BatchedMesh, qué pierdes con cada uno, y el criterio para elegir sin probar los tres.

⏱ 20 min

Cuando el perfilado señala a la CPU y el contador de draw calls está en cuatro cifras, la respuesta obvia es “junta objetos”. Pero hay tres formas de juntarlos y no son intercambiables: una copia la geometría tantas veces como objetos haya, otra la comparte y solo repite matrices, y la tercera admite geometrías distintas en la misma llamada. Cada una regala rendimiento a cambio de algo concreto, y elegir mal produce una escena que dibuja rápido pero que consume el triple de memoria o que ya no se puede recortar por frustum.

🎯 Al terminar esta lección sabrás
  • Explicar qué construye mergeGeometries y por qué su coste en memoria crece con el número de copias.
  • Distinguir InstancedMesh de BatchedMesh por lo que cada uno permite variar entre objetos.
  • Predecir qué se pierde en culling, en transformación individual y en materiales al agrupar.
  • Aplicar un criterio de decisión antes de escribir código, no después de medir tres versiones.

Fusionar: una geometría enorme y ninguna vuelta atrás

mergeGeometries toma un array de BufferGeometry y devuelve una sola cuyos atributos son la concatenación de todos. Vive en los addons, no en el núcleo, y exige que todas las geometrías tengan exactamente el mismo conjunto de atributos: si una trae uv y otra no, la función devuelve null y escribe un aviso en consola.

import * as THREE from 'three';
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';

const base = new THREE.BoxGeometry(1, 1, 1);
const piezas = [];

for (let i = 0; i < 2000; i++) {
  // clone() es obligatorio: applyMatrix4 modifica la geometria in situ
  const g = base.clone();
  g.applyMatrix4(
    new THREE.Matrix4().makeTranslation(
      Math.random() * 100 - 50,
      Math.random() * 100 - 50,
      Math.random() * 100 - 50,
    ),
  );
  piezas.push(g);
}

const fusionada = BufferGeometryUtils.mergeGeometries(piezas);

// Las piezas intermedias ya no sirven para nada y ocupan RAM
for (const g of piezas) g.dispose();
base.dispose();

const malla = new THREE.Mesh(fusionada, new THREE.MeshStandardMaterial());
scene.add(malla);

Lo importante está en applyMatrix4. Al fusionar no existen transformaciones por objeto: cada vértice tiene que llegar ya colocado en el espacio final. Eso significa que las posiciones se hornean en el buffer y que mover una de las dos mil cajas después implica reescribir su tramo de position a mano y marcar el atributo como sucio. Es posible, pero deja de ser una operación de grafo de escena y pasa a ser edición de memoria.

El coste en memoria es la consecuencia directa: dos mil cajas de veinticuatro vértices son cuarenta y ocho mil vértices reales en la GPU, con sus posiciones, normales y UV. La geometría original se ha copiado dos mil veces. Frente a eso, la ventaja es que el resultado es el caso más simple posible para el hardware: un buffer contiguo, un draw call, cero cambios de estado.

mergeGeometries acepta un segundo argumento, useGroups. Con true genera un grupo por geometría de entrada, lo que permite pasar un array de materiales al Mesh. Conviene entender lo que eso significa de verdad: cada grupo es un draw call independiente. Con useGroups: true has reducido el número de objetos del grafo de escena, no el número de llamadas de dibujo. Es útil para simplificar la jerarquía o para aplicar un material distinto a una parte del modelo, no para ganar rendimiento de CPU.

⚠️
Cuidado

Fusionar destruye el frustum culling individual. Una geometría de dos mil cajas repartidas por un kilómetro tiene una esfera envolvente de un kilómetro: en cuanto un solo trozo entra en el frustum, se dibujan los dos mil. Fusiona objetos que estén juntos en el espacio, no objetos que compartan material.

Instanciar: una geometría, muchas matrices

InstancedMesh es el caso opuesto. La geometría se sube una vez y lo que se repite es un atributo por instancia: una matriz de dieciséis flotantes, es decir sesenta y cuatro bytes. Dos mil cajas cuestan veinticuatro vértices más ciento veintiocho kilobytes de matrices, frente a los megabytes de la versión fusionada.

import * as THREE from 'three';

const geometria = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial();
const CUENTA = 2000;

const malla = new THREE.InstancedMesh(geometria, material, CUENTA);
const m = new THREE.Matrix4();
const color = new THREE.Color();

for (let i = 0; i < CUENTA; i++) {
  m.makeTranslation(
    Math.random() * 100 - 50,
    Math.random() * 100 - 50,
    Math.random() * 100 - 50,
  );
  malla.setMatrixAt(i, m);
  malla.setColorAt(i, color.setHSL(Math.random(), 0.6, 0.5));
}

malla.instanceMatrix.needsUpdate = true;
malla.instanceColor.needsUpdate = true;

// La esfera envolvente se cachea en cuanto se calcula y nadie la actualiza:
// hay que recalcularla a mano tras cada tanda de setMatrixAt.
malla.computeBoundingSphere();

scene.add(malla);

Dos detalles que cuestan horas si no se conocen. El primero es needsUpdate: modificar el contenido de instanceMatrix sin marcarlo no sube nada a la GPU, y el resultado es una escena donde todo está en el origen.

El segundo es el culling, y conviene contarlo con precisión porque circula muy mal. InstancedMesh se recorta como un único objeto y su boundingSphere nace valiendo null; la primera vez que el frustum la necesita, Three.js la calcula recorriendo las count matrices, así que esa primera esfera cubre la nube entera. El problema viene después: se cachea y no la actualiza nadie. Mientras coloques las instancias antes del primer render, como arriba, el culling es correcto llames o no a computeBoundingSphere. En cuanto muevas una instancia con setMatrixAt —y en un sistema vivo se mueven— la esfera se queda describiendo dónde estaban las cajas y no dónde están. El síntoma es inconfundible: la nube entera parpadea y desaparece de golpe al girar la cámara, las cincuenta mil a la vez, porque el descarte es por objeto y no por instancia. La corrección es llamar a computeBoundingSphere() sobre la malla —no sobre su geometría— después de cada tanda de movimientos, y si se mueven todos los fotogramas suele salir más barato frustumCulled = false y asumir que se dibujan siempre.

Hay una tercera propiedad muy infrautilizada: malla.count. Es el número de instancias que se dibujan realmente, y puede ser menor que la capacidad reservada. Reservar la capacidad máxima al principio y ajustar count cada frame es la forma barata de tener un sistema de objetos dinámico sin reasignar buffers.

Lo que InstancedMesh no permite es variar la geometría. Todas las instancias son el mismo modelo. Y el material es único para todas: puedes variar el color por instancia mediante setColorAt, y cualquier otra variación exige un InstancedBufferAttribute propio leído desde un shader.

Agrupar: BatchedMesh y el multi-draw

BatchedMesh resuelve exactamente lo que falta arriba: geometrías distintas en una sola llamada, siempre que compartan material. Internamente reserva un buffer grande de vértices e índices, coloca cada geometría en un tramo y emite un multi-draw con todos los rangos. Es la estructura que se acerca al render dirigido por GPU dentro de lo que permite la web.

import * as THREE from 'three';

const cono = new THREE.ConeGeometry(1, 2);
const caja = new THREE.BoxGeometry(2, 2, 2);
const esfera = new THREE.SphereGeometry(1, 16, 8);

const MAX_INSTANCIAS = 5000;
const MAX_VERTICES = 4096;
const MAX_INDICES = 8192;

const lote = new THREE.BatchedMesh(
  MAX_INSTANCIAS,
  MAX_VERTICES,
  MAX_INDICES,
  new THREE.MeshStandardMaterial(),
);

const ids = [
  lote.addGeometry(cono),
  lote.addGeometry(caja),
  lote.addGeometry(esfera),
];

const m = new THREE.Matrix4();
const rgba = new THREE.Vector4();

for (let i = 0; i < MAX_INSTANCIAS; i++) {
  const instancia = lote.addInstance(ids[i % ids.length]);
  m.makeTranslation(
    Math.random() * 200 - 100,
    Math.random() * 200 - 100,
    Math.random() * 200 - 100,
  );
  lote.setMatrixAt(instancia, m);
  rgba.set(Math.random(), Math.random(), Math.random(), 1);
  lote.setColorAt(instancia, rgba);
}

// Culling y ordenacion por instancia, no por objeto
lote.perObjectFrustumCulled = true;
lote.sortObjects = true;

scene.add(lote);

La diferencia conceptual con InstancedMesh está en perObjectFrustumCulled y sortObjects. BatchedMesh sí conoce cada instancia por separado, así que puede descartar las que quedan fuera del frustum y ordenarlas por profundidad antes de emitir el multi-draw. Eso recupera dos cosas que fusionar te quitaba, y lo hace sin pagar la copia de geometría.

Para escenas con muchísimas instancias transparentes, la ordenación por defecto se vuelve el cuello. setCustomSort acepta una función propia, y los addons traen un radixSort en three/addons/utils/SortUtils.js pensado justo para eso.

Nivel dios

El error de criterio más común no es elegir mal entre las tres, sino aplicarlas cuando el problema no era la CPU. Las tres técnicas reducen el número de llamadas de dibujo y ninguna reduce el número de fragmentos sombreados. Si el cuello está en el fragment shader, fusionar dos mil cajas no cambia un milisegundo el tiempo de frame, porque los mismos píxeles se siguen pintando el mismo número de veces. Antes de agrupar nada, comprueba que bajar la resolución del canvas a la mitad mejora el frame: si mejora mucho, el problema es de relleno y estás a punto de perder una tarde.

El criterio, en una decisión

La pregunta no es “cuál es más rápido” sino “qué necesito que varíe entre objetos”. Todo lo demás sale de ahí.

Necesitas Elige Lo que pagas
Nada varía, los objetos son estáticos y están juntos mergeGeometries Memoria multiplicada, culling colectivo
Varía la transformación y el color, misma geometría InstancedMesh Culling colectivo, una sola geometría
Varían transformación, color y geometría BatchedMesh Buffer preasignado, un solo material
Varía el material Ninguna de las tres Reduce primero el número de materiales

La última fila es la que más gente ignora. Si tus objetos tienen materiales distintos, ninguna de estas técnicas se aplica: todas exigen un material común o, en el caso de mergeGeometries con grupos, mantienen un draw call por material. El trabajo previo es reducir la variedad de materiales, y de eso trata la lección sobre compartir materiales.

Sobre la preasignación de BatchedMesh: los tres primeros argumentos son techos, no tamaños exactos, y reservan memoria de GPU desde el primer frame. Pasarse por diez es una forma silenciosa de gastar cientos de megabytes en buffers vacíos. Cuenta los vértices y los índices de las geometrías que vas a añadir y suma un margen razonable, no un número redondo grande.

⚔️ Reto práctico

Coge una escena con al menos quinientos objetos que compartan material y mide renderer.info.render.calls y renderer.info.memory.geometries antes de tocar nada. Implementa las tres variantes detrás de un interruptor y anota las dos métricas más el tiempo de frame en cada una. Después mueve la cámara hasta que solo cinco objetos queden dentro del frustum y vuelve a anotar: ahí es donde la versión fusionada se hunde y BatchedMesh con perObjectFrustumCulled gana por goleada.