wandres.dev
MUNDOS GRANDES · LOD, culling y streaming

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.

⏱ 18 min

El frustum culling descarta lo que está fuera del encuadre. El occlusion culling intenta descartar lo que está dentro del encuadre pero tapado por otra cosa: la habitación al otro lado de la pared, los edificios detrás del edificio de delante. En un interior o en una ciudad eso puede ser el 90 % de la escena, y por eso suena tan atractivo. Lo que casi nunca se explica es que la consulta que responde “¿esto se ve?” solo la puede contestar la GPU, que va un frame o dos por detrás, y que ese retraso es la fuente de todos los problemas de la técnica.

🎯 Al terminar esta lección sabrás
  • Explicar por qué una consulta de oclusión no puede responderse en el mismo frame.
  • Usar occlusionTest e isOccluded con WebGPURenderer.
  • Enumerar los artefactos característicos del culling por consulta y sus mitigaciones.
  • Elegir entre consultas de hardware, portales y visibilidad precalculada.

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.

🛑
El error de razonamiento circular

Hay una trampa lógica que se cae sola en cuanto la escuchas y que aun así se implementa constantemente: si ocultas un objeto porque estaba tapado, en el frame siguiente ya no se dibuja, así que su consulta no se emite y nunca sabrás que ha dejado de estarlo. El objeto desaparece para siempre. Por eso los sistemas reales emiten la consulta con una geometría de sustitución —la caja envolvente, sin escribir en el framebuffer— independientemente de si el objeto se dibuja o no, y por eso occlusionTest de Three.js hace la consulta sobre el objeto que sí se está renderizando. Si construyes tu propio sistema, la consulta y el dibujado tienen que ser dos cosas separadas.

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.

La ordenación front-to-back hace la mitad del trabajo, gratis

Antes de montar cualquier sistema de oclusión, conviene entender que ya tienes uno funcionando y que probablemente no lo sabes. Las GPUs modernas hacen early depth test: descartan un fragmento comparando su profundidad antes de ejecutar el fragment shader, no después. Eso significa que si dibujas primero lo que está cerca, todo lo que quede detrás falla la prueba de profundidad y se descarta sin sombrear. La oclusión, a nivel de fragmento, la hace el hardware sola. Y Three.js ya ordena los objetos opacos de delante hacia atrás antes de dibujarlos, precisamente para maximizar ese efecto. Por eso una escena con mucha oclusión no cuesta proporcionalmente más que una sin ella: los píxeles tapados se descartan casi gratis. Lo que sigue costando es el trabajo de vértices y de CPU —el draw call, la transformación de la geometría, la actualización de uniforms— que es lo único que el occlusion culling explícito puede ahorrar. Ahí está la clave para decidir si merece la pena: el occlusion culling por hardware solo compensa si tus objetos tapados son caros en vértices o en draw calls, no en píxeles, porque los píxeles ya te salen gratis. Y de esa misma observación sale la advertencia que arruina escenas enteras: hay tres cosas que desactivan el early depth test y con él toda esta ventaja invisible. Escribir gl_FragDepth o su equivalente en el shader, usar discard —o alphaTest, que se implementa con discard—, y el blending, que obliga a leer el color de destino y por tanto a ejecutar el fragment shader. Un follaje con alphaTest en primer plano no solo se sombrea entero: hace que todo lo que hay detrás también se sombree, porque el hardware no puede saber si esos fragmentos van a sobrevivir hasta ejecutarlos. Si tu escena tiene mucha vegetación con recorte alfa y va lenta, ese es el primer sitio donde mirar, y la solución —usar geometría real en lugar de recorte, o hacer un pre-pass de profundidad que sí escriba— es mucho más barata que montar un sistema de consultas de oclusión.

⚔️ Mide antes de creer
  1. Monta un interior con cuatro habitaciones y mide cuántos objetos están tapados desde cada una.
  2. Marca los objetos pesados con occlusionTest y comprueba el resultado de isOccluded frame a frame.
  3. Añade el contador de frames tapados y verifica que el parpadeo desaparece.
  4. Implementa el sistema de celdas y portales y compara su coste de CPU con el de las consultas.
  5. Añade un plano de follaje con alphaTest delante de la escena y mide qué le pasa al tiempo de GPU.