Occlusion culling y por qué no es gratis
Qué es descartar lo tapado, por qué la consulta al hardware llega con retraso, cómo se usan occlusionTest e isOccluded en WebGPURenderer, y las alternativas que no dependen de la GPU.
El problema de la latencia
Saber si un objeto está tapado exige comparar su profundidad con la del buffer de profundidad ya poblado por el resto de la escena. Eso solo se puede hacer después de haber dibujado lo que tapa. El hardware ofrece occlusion queries: dibujas una versión barata del objeto —normalmente su caja envolvente, sin escribir color ni profundidad— y la GPU cuenta cuántos fragmentos habrían pasado la prueba de profundidad. Si son cero, está tapado.
El problema no es la consulta, es leer su resultado. La CPU va varios frames por delante de la GPU: cuando el hilo de JavaScript está preparando el frame n, la GPU puede estar todavía en el n-2. Pedir el resultado de una consulta emitida en este mismo frame obliga a esperar a que la GPU termine, lo que vacía toda la tubería y destruye el paralelismo entre CPU y GPU. En una escena que iba a 60 fps, esa espera puede costar más que dibujar todo lo que ibas a descartar.
La solución universal, en todos los motores, es la misma: usar el resultado del frame anterior. Se emite la consulta este frame y se lee el resultado dos frames después, sin bloquear. A cambio, todas las decisiones de visibilidad van con retraso, y de ahí salen los artefactos característicos.
occlusionTest en WebGPURenderer
Three.js expone esta capacidad en el renderer de WebGPU con dos piezas: una marca por objeto y una consulta en el renderer.
import * as THREE from 'three/webgpu';// Marcar el objeto para que se le haga la consulta de oclusion.esfera.occlusionTest = true;// Consultar el resultado desde un nodo o desde el bucle.const tapada = renderer.isOccluded( esfera );isOccluded( objeto ) devuelve true si el objeto quedó completamente tapado. Consulta el contexto de render actual, así que su valor solo es válido dentro del ciclo de render o desde un nodo con actualización por objeto.
El ejemplo oficial de Three.js hace justo eso: un nodo personalizado con updateType de objeto que consulta la oclusión y cambia un uniform de color. Trasladado a una decisión de visibilidad:
const candidatos = [];for ( const o of objetosPesados ) { o.occlusionTest = true; candidatos.push( o );}function animate() { // Leer el resultado del frame anterior y aplicarlo a este. for ( const o of candidatos ) { const tapado = renderer.isOccluded( o ); // Nunca ocultar de golpe: contar frames tapados para evitar parpadeo. o.userData.tapadoDesde = tapado ? ( o.userData.tapadoDesde ?? 0 ) + 1 : 0; o.visible = o.userData.tapadoDesde < 3; } renderer.render( scene, camera );}Ese contador de frames es imprescindible. Sin él, un objeto que entra y sale de la oclusión durante un giro de cámara parpadea a la frecuencia del frame.
En el backend de WebGL de WebGPURenderer, así como en WebGLRenderer clásico, esta funcionalidad no está disponible: las occlusion queries de WebGL2 existen a nivel de API, pero Three.js no las expone en su renderer clásico. Si necesitas occlusion culling con WebGL, tendrás que implementarlo con render targets y lectura asíncrona, o irte a las alternativas de la última sección.
Los artefactos y sus mitigaciones
Aparición tardía. Un objeto que se destapa tarda uno o dos frames en volver a dibujarse. Con la cámara girando rápido, se ve como si el mundo se materializase al borde de la pantalla. Mitigación: ampliar el frustum de consulta más allá del visible, para que los objetos se destapen antes de entrar en el encuadre.
Parpadeo en el borde. Objetos que quedan justo en el límite entran y salen. Mitigación: el contador de frames del ejemplo, que es histéresis temporal en lugar de espacial.
Coste de las propias consultas. Cada consulta es un draw call adicional, aunque barato. Con dos mil candidatos, has añadido dos mil draw calls para ahorrarte, con suerte, mil quinientos. Mitigación: no hacer la consulta a todos. Los candidatos buenos son objetos grandes y caros; los pequeños no compensan nunca.
El caso peor es el caso común. En un exterior abierto no hay casi oclusión, así que pagas todas las consultas y no descartas nada. La técnica tiene sentido en interiores, ciudades densas y escenas con mucha profundidad, no en un paisaje.
De ahí sale la regla que resume la técnica: el occlusion culling por hardware compensa cuando tienes pocos oclusores grandes y muchos objetos caros detrás. Un almacén con estanterías, un edificio con plantas, una ciudad vista desde la calle. En cualquier otro caso, el balance suele ser negativo.
Las alternativas sin consultas
Portales y celdas. El enfoque clásico para interiores, y sigue siendo el mejor cuando la topología lo permite. Divides el espacio en habitaciones —celdas— conectadas por puertas y ventanas —portales—. Desde una celda solo se ve lo que hay en ella y lo que se alcanza a través de los portales visibles, recortando el frustum en cada uno. El coste es proporcional al número de celdas realmente visibles, no al total de la escena, y no hay ninguna latencia porque todo se calcula en CPU.
function celdasVisibles( celdaActual, frustum, visitadas = new Set() ) { if ( visitadas.has( celdaActual ) ) return visitadas; visitadas.add( celdaActual ); for ( const portal of celdaActual.portales ) { if ( ! frustum.intersectsBox( portal.caja ) ) continue; // El frustum se estrecha al pasar por el portal. const frustumRecortado = recortarPorPortal( frustum, portal ); celdasVisibles( portal.destino, frustumRecortado, visitadas ); } return visitadas;}Visibilidad precalculada. Si la geometría es estática, puedes calcular fuera de línea qué se ve desde cada región y guardarlo como una tabla. En tiempo de ejecución es una consulta de índice: coste cero. El precio es el tiempo de precálculo y la memoria de la tabla, que crece con el cuadrado del número de regiones.
Oclusores manuales. El punto intermedio más práctico: el diseñador marca a mano media docena de superficies grandes como oclusores —las paredes principales, el suelo del piso superior— y en tiempo de ejecución se comprueba si cada objeto candidato queda completamente detrás de alguno de ellos, con una prueba de caja contra la pirámide de sombra del oclusor. Es CPU pura, sin latencia, y con seis oclusores bien elegidos se captura la mayor parte del beneficio.
Depth pre-pass. No es culling, pero resuelve una parte del mismo problema. Dibujas primero la escena solo a profundidad, sin color ni sombreado, y después la dibujas de verdad con la prueba de profundidad en igualdad. Los fragmentos tapados fallan la prueba antes de ejecutar el fragment shader, así que no pagas su sombreado. No ahorra draw calls ni geometría —de hecho los duplica— pero elimina el overdraw de fragmentos caros, que en una escena con materiales pesados puede ser la mayor parte del coste.