wandres.dev
MUNDOS GRANDES · LOD, culling y streaming

Frustum culling: qué hace Three.js por defecto

Cómo se descarta lo que no se ve, qué volumen usa Three.js exactamente, los cuatro casos en que el culling automático falla, y cuándo desactivarlo mejora el rendimiento.

⏱ 17 min

Three.js hace frustum culling desde siempre y sin que se lo pidas, lo cual es bueno hasta el día en que un objeto desaparece sin motivo aparente o hasta que descubres que el propio culling se ha convertido en el cuello de botella. La implementación es de una simplicidad casi provocadora —una esfera contra seis planos, por objeto, cada frame— y conocer sus límites exactos explica los dos síntomas.

🎯 Al terminar esta lección sabrás
  • Describir el volumen que Three.js usa para descartar cada objeto.
  • Identificar los casos en que el culling automático produce falsos descartes.
  • Decidir cuándo desactivar frustumCulled y qué implica.
  • Estimar cuándo el coste del culling supera al de dibujar.

Seis planos y una esfera

El frustum es la pirámide truncada que la cámara ve: seis planos —izquierdo, derecho, superior, inferior, cercano y lejano— que se extraen de la matriz de proyección combinada con la de vista. Three.js los construye con Frustum.setFromProjectionMatrix, que además distingue entre el sistema de coordenadas de WebGL, con Z de menos uno a uno, y el de WebGPU, con Z de cero a uno.

Para cada objeto renderizable, la prueba es esta:

// Lo que hace el renderer, en esencia:
if ( objeto.frustumCulled === false || frustum.intersectsObject( objeto ) === true ) {

	// entra en la lista de dibujado

}

Y intersectsObject es igual de directo:

// De Frustum.js, simplificado.
if ( geometry.boundingSphere === null ) geometry.computeBoundingSphere();

esfera.copy( geometry.boundingSphere ).applyMatrix4( objeto.matrixWorld );

return frustum.intersectsSphere( esfera );

Tres hechos que se derivan de estas líneas.

Se usa la esfera envolvente, no la caja. Es una prueba muy barata —seis productos escalares y seis comparaciones— y muy conservadora: descarta menos de lo posible. Un objeto largo y fino que quede justo fuera del borde de la pantalla puede tener una esfera envolvente que sí entra, y se dibujará aunque no aparezca ni un píxel suyo.

La esfera se calcula una vez y se transforma cada frame. applyMatrix4 sobre una esfera transforma el centro y escala el radio por el mayor factor de escala de la matriz. Eso significa que la prueba respeta traslaciones, rotaciones y escalas del objeto sin recalcular nada.

Se prueba objeto por objeto, recorriendo el grafo entero. No hay ninguna estructura espacial. Con diez mil objetos, se hacen diez mil pruebas por frame, más el recorrido del árbol con sus comprobaciones de visibilidad y capas.

Un detalle que sí es importante y a menudo se desconoce: el culling no poda subárboles. Que el padre quede fuera del frustum no impide que se pruebe a los hijos, porque un hijo puede estar en cualquier sitio del mundo. Si quieres podar de verdad, la herramienta es visible = false sobre el padre, que sí corta el recorrido.

Los cuatro casos en que falla

Vértices desplazados en el shader. El caso más frecuente y el más desconcertante. Si tu material mueve vértices —positionNode en TSL, onBeforeCompile sobre el vertex shader, una bandera, un terreno con displacement— la esfera envolvente sigue siendo la de la geometría original. Un objeto cuya geometría base es un plano de un metro pero cuyo shader lo estira diez metros hacia arriba desaparecerá en cuanto la parte original salga del frustum, aunque la parte desplazada siguiera siendo visible.

Objetos posicionados desde un storage buffer. Un sistema de partículas simulado en GPU, tal como el que viste con TSL: la CPU no sabe dónde están. Su esfera envolvente es la del sprite en el origen. Es la razón de que frustumCulled = false sea obligatorio en esos casos.

InstancedMesh sin bounding actualizado. InstancedMesh tiene su propia boundingSphere que cubre todas las instancias, pero no se recalcula sola. Si mueves instancias con setMatrixAt, hay que llamar a computeBoundingSphere() sobre la malla instanciada —no sobre su geometría— o desactivar el culling.

malla.setMatrixAt( i, matriz );
malla.instanceMatrix.needsUpdate = true;
malla.computeBoundingSphere();      // si no, el culling usa datos viejos

Skinning y morph targets. La geometría en pose de reposo puede tener una envolvente muy distinta de la del personaje en movimiento. Un personaje que levanta los brazos por encima de su caja de reposo puede verse recortado. La solución habitual es asignar a mano una boundingSphere generosa que cubra todas las poses.

En los cuatro casos hay dos salidas. La barata y correcta cuando el objeto es único: desactivar el culling.

objeto.frustumCulled = false;

La buena cuando hay muchos objetos: asignar una envolvente manual que cubra el caso real.

// Una esfera que cubre la deformacion maxima del shader.
geometria.boundingSphere = new THREE.Sphere( new THREE.Vector3( 0, 5, 0 ), 12 );

Con la envolvente manual, el culling sigue funcionando y además es correcto. Es siempre preferible a desactivarlo, salvo cuando de verdad no se puede saber.

⚠️
frustumCulled false no es gratis

Desactivar el culling no solo significa que el objeto se dibuja siempre: significa que todo su trabajo de CPU se hace siempre. Se actualizan sus uniforms, se resuelve su material, se prepara su draw call. Para un sistema de partículas gigante que ocupa media escena, da igual porque casi siempre es visible. Para cien objetos pequeños repartidos por un mundo grande, es un desperdicio serio. La regla: desactívalo cuando el objeto sea grande o esté casi siempre en pantalla; para todo lo demás, arregla la envolvente.

Cuando el culling es el problema

Con pocos objetos grandes, el culling es puro beneficio: descarta trabajo caro con una prueba baratísima. Con muchos objetos pequeños, la ecuación se invierte.

La prueba de frustum cuesta del orden de unos cientos de nanosegundos por objeto, sumando el recorrido del grafo, la copia de la esfera, la transformación por la matriz y los seis planos. Con veinte mil objetos son unos pocos milisegundos por frame antes de dibujar nada, y ese coste se paga entero aunque todos los objetos estén fuera de la pantalla.

Hay tres formas de atacarlo.

Reducir el número de objetos. Es la respuesta correcta casi siempre y no tiene nada que ver con el culling: fusionar geometría estática con BufferGeometryUtils.mergeGeometries, o usar InstancedMesh para las copias. Diez mil árboles pasan a ser un objeto, con una prueba de frustum en lugar de diez mil.

Podar subárboles con visible. Agrupa los objetos por región, dale a cada grupo una envolvente propia, y desactiva la visibilidad del grupo entero cuando su región no se ve. visible = false sí corta el recorrido del renderer, a diferencia del culling.

const frustum = new THREE.Frustum();
const matriz = new THREE.Matrix4();

function actualizarRegiones( camara ) {

	matriz.multiplyMatrices( camara.projectionMatrix, camara.matrixWorldInverse );
	frustum.setFromProjectionMatrix( matriz, THREE.WebGLCoordinateSystem );

	for ( const region of regiones ) {

		region.grupo.visible = frustum.intersectsBox( region.caja );

	}

}

Fíjate en el segundo argumento de setFromProjectionMatrix: hay que pasarle el sistema de coordenadas, porque el plano cercano se extrae de forma distinta en WebGL y en WebGPU. Con WebGLRenderer la constante es THREE.WebGLCoordinateSystem; si trabajas con WebGPURenderer, ese renderer expone la suya en renderer.coordinateSystem y basta con pasarla.

Desactivar el culling y dejar que la GPU descarte. Contraintuitivo pero real: para geometría muy pequeña y muy numerosa, la GPU descarta triángulos fuera de pantalla con hardware dedicado, gratis. Si el coste de CPU del culling supera al de enviar los draw calls, frustumCulled = false en masa puede ganar. Solo tiene sentido cuando ya has agrupado en instancias y sigues teniendo muchos objetos.

La escala del mundo decide el algoritmo, no el número de objetos

La conclusión que se saca después de pelearse un rato con esto es que la pregunta “¿me hace falta más culling?” está mal planteada, y que la buena es “¿qué fracción de mi escena está fuera de la pantalla en un momento dado?”. Piensa en los dos extremos. En un configurador de producto, la cámara orbita alrededor de un objeto que está siempre en el centro del encuadre: la fracción descartada es prácticamente cero, el frustum culling no descarta nada nunca, y sin embargo pagas su coste en cada frame. Ahí, cualquier esfuerzo en culling está mal invertido, y encima podrías desactivarlo en masa y ganar algo. En un mundo abierto con la cámara al nivel del suelo, en cambio, la fracción visible ronda el 2 % del mundo: el culling descarta el 98 % del trabajo y es la técnica más rentable que existe, hasta el punto de que merece la pena montar una estructura espacial encima para que ese 98 % ni siquiera se recorra. Y hay un tercer régimen que es el que más confunde, el de una escena mediana con la cámara alejada mirando el conjunto: la mayoría de los objetos están dentro del frustum, el culling no descarta casi nada, pero el coste de dibujarlos sí es alto. Ahí el problema no es de visibilidad sino de draw calls y de triángulos, y ninguna cantidad de culling va a arreglarlo: la respuesta es fusionar, instanciar y bajar detalle. La regla operativa que se deriva de esto es medible en dos minutos y evita semanas de trabajo mal dirigido: instrumenta cuántos objetos entran y cuántos se descartan por frame. Si descartas menos del 20 %, el culling no es tu palanca y probablemente estés pagando su coste sin recibir su beneficio. Si descartas más del 80 %, invierte en jerarquía espacial, porque cada objeto que ni siquiera se examina es un objeto gratis.

⚔️ Instrumenta tu culling
  1. Cuenta cuántos objetos hay en la escena y compáralo con renderer.info.render.calls mirando en varias direcciones.
  2. Pon un material que desplace vértices y comprueba que el objeto desaparece antes de tiempo.
  3. Arréglalo asignando una boundingSphere manual en lugar de desactivar el culling.
  4. Monta 20 000 cubos pequeños y mide el tiempo de CPU antes de dibujar, con y sin frustumCulled.
  5. Agrupa esos cubos en regiones con Frustum.intersectsBox sobre el grupo y compara.