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

El frustum culling que deja de funcionar por instancia

Por qué un InstancedMesh se descarta entero o no se descarta, los dos síntomas que produce, y las cuatro formas de recuperar el culling con sus costes.

⏱ 17 min

El instancing cambia mil objetos por uno, y con ellos cambia mil decisiones de culling por una. Eso tiene dos consecuencias que aparecen en momentos distintos del desarrollo: al principio, instancias que desaparecen al mover la cámara sin motivo aparente; más tarde, un bosque que se dibuja entero aunque solo se vea un rincón, y unos fotogramas por segundo que no bajan cuando deberían. Las dos salen del mismo mecanismo y ninguna se arregla sola.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el renderer descarta un InstancedMesh como una unidad.
  • Reconocer los dos síntomas del culling roto y distinguirlos.
  • Elegir entre recalcular, desactivar, particionar y ordenar.
  • Calcular el tamaño de celda que equilibra granularidad de culling y llamadas de dibujo.

Por qué se culla entero

La comprobación del renderer es esta, y ya la conocemos:

// Frustum.intersectsObject en r184
if ( object.boundingSphere !== undefined ) {
  if ( object.boundingSphere === null ) object.computeBoundingSphere();
  esfera.copy( object.boundingSphere ).applyMatrix4( object.matrixWorld );
} else {
  // ...usa geometry.boundingSphere
}
return this.intersectsSphere( esfera );

InstancedMesh define la propiedad boundingSphere, aunque valga null. Así que se toma la primera rama: el renderer llama a computeBoundingSphere() si hace falta, y esa esfera es la unión de las esferas de todas las instancias, calculada recorriendo el buffer de matrices.

De ahí sale todo. El objeto es uno solo para el culling: o se dibuja entero o no se dibuja nada. Un bosque de diez mil árboles repartidos por un kilómetro cuadrado tiene una esfera envolvente de un kilómetro de radio, así que en cuanto la cámara está dentro del bosque —que es siempre— esa esfera intersecta el frustum y se dibujan los diez mil, mires donde mires.

Y la esfera se calcula una vez y se cachea. El comentario del propio código fuente lo dice sin rodeos: puede que necesites recalcularla si transformas una instancia con setMatrixAt. Nadie lo hace por ti.

Los dos síntomas

Instancias que desaparecen. La esfera cacheada es más pequeña que la nube real de instancias, porque las moviste después de calcularla o porque las desplazas en el vertex shader. El renderer descarta el objeto entero cuando su esfera —demasiado pequeña— sale del frustum, aunque haya instancias que sí se ven. El efecto es que todo el bosque parpadea de golpe al girar la cámara, no una instancia suelta. Esa característica lo distingue de cualquier otro problema visual.

El culling no ahorra nada. El caso contrario y más frecuente: la esfera es correcta y enorme, así que nunca se descarta. Todas las instancias pagan su vertex shader en cada frame, aunque el noventa por ciento estén detrás de la cámara. El síntoma es que los fotogramas por segundo no mejoran al mirar a otro lado, que es la prueba de diagnóstico más rápida que existe: apunta la cámara al vacío y mira si sube. Si no sube, tu culling no está haciendo nada.

Y hay un tercer efecto colateral que conviene conocer: raycast sobre un InstancedMesh prueba la esfera global y luego itera todas las count instancias. Con cincuenta mil instancias de una geometría de mil triángulos, un solo clic recorre cincuenta millones de comprobaciones de triángulo. Es inviable, y la solución es la misma que para el culling: partir o filtrar antes.

Las cuatro respuestas

Recalcular. Después de mover instancias, actualiza la esfera:

malla.computeBoundingSphere();

Recorre las count matrices y hace la unión. Para diez mil instancias son décimas de milisegundo; para cien mil, milisegundos. No es algo que se llame cada frame sin medirlo antes. Es la respuesta correcta cuando las instancias se recolocan de vez en cuando y no continuamente.

Desactivar. Si el desplazamiento vive en el vertex shader, la esfera nunca podrá ser correcta:

malla.frustumCulled = false;

Cuesta una llamada de dibujo por frame, que son decenas de microsegundos. Es la respuesta correcta cuando el InstancedMesh es grande y ocupa buena parte de la escena, porque en ese caso el culling no iba a descartarlo de todas formas. También sirve la variante conservadora, ampliar el radio con el desplazamiento máximo del shader.

Particionar. La respuesta buena para mundos grandes: en lugar de un InstancedMesh con diez mil instancias, varios con unos cientos cada uno, repartidos por celdas espaciales. Cada uno tiene su propia esfera envolvente, pequeña y localizada, y el renderer los culla independientemente.

import * as THREE from 'three';

function repartirEnCeldas( posiciones, geometria, material, tamCelda ) {
  const celdas = new Map();

  for ( const p of posiciones ) {
    const clave = Math.floor( p.x / tamCelda ) + ':' + Math.floor( p.z / tamCelda );
    if ( ! celdas.has( clave ) ) celdas.set( clave, [] );
    celdas.get( clave ).push( p );
  }

  const grupo = new THREE.Group();
  const aux = new THREE.Object3D();

  for ( const lista of celdas.values() ) {
    const malla = new THREE.InstancedMesh( geometria, material, lista.length );

    for ( let i = 0; i < lista.length; i ++ ) {
      aux.position.copy( lista[ i ] );
      aux.updateMatrix();
      malla.setMatrixAt( i, aux.matrix );
    }

    malla.instanceMatrix.needsUpdate = true;
    malla.computeBoundingSphere();   // pequeña y localizada
    grupo.add( malla );
  }

  return grupo;
}

Ordenar y recortar. El culling por instancia de verdad, hecho por ti en la CPU: cada frame decides qué instancias se ven, escribes sus matrices al principio del buffer y bajas count.

let visibles = 0;
const aux = new THREE.Object3D();

for ( let i = 0; i < TOTAL; i ++ ) {
  if ( ! esVisible( i ) ) continue;
  aux.position.copy( posiciones[ i ] );
  aux.updateMatrix();
  malla.setMatrixAt( visibles ++, aux.matrix );
}

malla.count = visibles;
malla.instanceMatrix.needsUpdate = true;

Cuesta un recorrido de todas las instancias en JavaScript más la subida del buffer. Es decir, devuelve a la CPU parte del trabajo que el instancing había eliminado, y solo compensa cuando el coste de vértices de las instancias descartadas es mucho mayor que el del recorrido: geometrías grandes, muchas instancias fuera de cámara, o instancias con un vertex shader caro. Para cubos, casi nunca vale la pena.

Elegir el tamaño de celda

La partición tiene un óptimo y se puede razonar. Con N instancias repartidas por un área y celdas de lado L, hay dos efectos opuestos.

Cuantas más celdas, mejor granularidad: el culling descarta con más precisión y se dibujan menos instancias invisibles. Pero también más llamadas de dibujo, porque cada celda visible es una.

Cuantas menos celdas, menos llamadas, pero cada celda visible arrastra más instancias que no se ven.

El equilibrio se plantea así: fija un techo de llamadas de dibujo que estés dispuesto a pagar —cincuenta es un número cómodo— y elige el tamaño de celda tal que el número de celdas visibles a la vez se quede por debajo. Si tu cámara alcanza a ver doscientos metros y el frustum cubre aproximadamente un sector, el área visible es del orden de veinte mil metros cuadrados; con celdas de veinte metros, eso son unas cincuenta celdas. Encaja.

Un ejemplo con números redondos. Diez mil árboles en un kilómetro cuadrado, cámara que alcanza doscientos metros:

Estrategia Llamadas Instancias dibujadas
Un solo InstancedMesh 1 10 000
Celdas de 100 m, 100 celdas ~6 ~600
Celdas de 20 m, 2500 celdas ~50 ~150
Celdas de 5 m, 40 000 celdas ~800 ~40

La tercera fila es el punto dulce: cincuenta llamadas es nada y has pasado de dibujar diez mil instancias a ciento cincuenta. La cuarta ya ha cruzado al otro lado y ochocientas llamadas por frame empiezan a doler más de lo que ahorra.

Y merece la pena mencionar el contraste que abre la última lección: BatchedMesh sí hace culling por instancia, con la propiedad perObjectFrustumCulled activada por defecto. Construye el frustum en espacio local, prueba la esfera de cada instancia y emite un dibujo múltiple con solo las que pasan. Es exactamente el trabajo que aquí estás haciendo a mano con el patrón de ordenar y recortar, pero integrado y con la ventaja de que puede mezclar geometrías distintas.

Toda agregación cambia la granularidad de las decisiones, y hay que reponer las que se pierden

El instancing es un caso de un patrón que se repite en toda la ingeniería de rendimiento: agrupas cosas para amortizar un coste fijo, y al agruparlas pierdes la capacidad de decidir sobre cada una por separado. Es la misma estructura que el procesamiento por lotes en una base de datos, que el agrupamiento de peticiones de red, que la escritura almacenada en búfer. Y en todos los casos la lección es idéntica: la agregación no es gratis, tiene un coste que no aparece en el perfil de rendimiento sino en la pérdida de granularidad, y ese coste hay que reponerlo explícitamente si la decisión perdida era importante. En el instancing, las decisiones que se pierden son tres y conviene tenerlas contadas: el culling, que pasa de ser por objeto a ser por lote; el nivel de detalle, que ya no se puede elegir por instancia porque todas comparten geometría; y la ordenación por profundidad, que es la que hace que las transparencias se mezclen bien y que dentro de un lote simplemente no existe. La primera se repone particionando. La segunda se repone teniendo un InstancedMesh por nivel de detalle y moviendo instancias entre ellos, que es exactamente lo que hacen los sistemas de vegetación de los motores grandes. Y la tercera no se repone: por eso las transparencias instanciadas se ven mal y por eso casi todos los sistemas de partículas usan mezcla aditiva, que es conmutativa y por tanto no depende del orden. Cuando decidas instanciar algo, la pregunta útil no es cuánto vas a ganar sino cuál de esas tres decisiones te va a hacer falta, porque reponerla después cuesta bastante más que planearla antes.

⚔️ Recupera el culling
  1. Monta diez mil instancias repartidas por un área grande y comprueba que mirar al vacío no sube los fotogramas por segundo.
  2. Mueve instancias con setMatrixAt sin recalcular la esfera y provoca el parpadeo del objeto entero.
  3. Mide cuánto tarda computeBoundingSphere() con diez mil y con cien mil instancias.
  4. Implementa la partición en celdas y mide llamadas e instancias dibujadas con tres tamaños distintos.
  5. Implementa el patrón de ordenar y recortar y comprueba si compensa con tu geometría.