Saltar al contenido

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.

Por qué se culla entero

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

JS
// Frustum.intersectsObject en r184if ( 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:

JS
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:

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

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

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