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