wandres.dev
SOMBRAS · Shadow maps y sus artefactos

Los cuatro tipos de filtrado de sombras

Qué hace exactamente BasicShadowMap, PCFShadowMap, PCFSoftShadowMap y VSMShadowMap, cuánto cuesta cada uno en lecturas de textura, y el árbol de decisión para elegir.

⏱ 17 min

La comparación de profundidades devuelve cero o uno. Todo lo que se parezca a un borde suave viene de tomar varias muestras y promediarlas, y ahí es donde renderer.shadowMap.type decide cuántas muestras, cómo se colocan y qué se promedia. Los cuatro valores posibles no son cuatro niveles de calidad de una misma escala: dos de ellos son variantes de la misma técnica y el cuarto cambia la representación del mapa por completo, con ventajas y con un artefacto propio que hay que conocer antes de elegirlo.

🎯 Al terminar esta lección sabrás
  • Enumerar los cuatro tipos y el número de lecturas de textura que hace cada uno.
  • Explicar por qué se promedian los resultados de la comparación y no las profundidades.
  • Reconocer el light bleeding de VSM y saber de dónde sale.
  • Elegir un tipo a partir del dispositivo objetivo y de la naturaleza de la escena.

Los cuatro valores

renderer.shadowMap.type = THREE.PCFSoftShadowMap;

Las cuatro constantes son THREE.BasicShadowMap, THREE.PCFShadowMap, THREE.PCFSoftShadowMap y THREE.VSMShadowMap. El valor por defecto es PCFShadowMap. Y hay una regla que se olvida constantemente: cambiar shadowMap.type después del primer render no recompila los materiales. El tipo forma parte de la definición del shader, así que hay que fijarlo antes de renderizar o forzar material.needsUpdate = true en todo.

BasicShadowMap. Una lectura de textura y una comparación. Cero filtrado: el borde de la sombra es el borde de la rejilla de texels, escalonado y duro. Es el más rápido con diferencia y el único donde shadow.radius no hace absolutamente nada. Su uso legítimo es el estilo deliberadamente pixelado y el móvil de gama muy baja.

PCFShadowMap. Percentage-Closer Filtering. Toma varias muestras alrededor del punto —Three.js usa un patrón de cuatro lecturas con un desplazamiento de medio texel—, compara cada una por separado, y promedia los resultados de la comparación. El orden importa muchísimo y es la clave de la técnica: promediar profundidades y comparar después daría una sola respuesta binaria, sin ninguna suavidad. Comparar primero y promediar después da un valor continuo entre 0 y 1 que es literalmente el porcentaje de muestras ocluidas, de ahí el nombre.

PCFSoftShadowMap. El mismo principio con un kernel mucho mayor: hasta dieciséis lecturas, con las coordenadas ajustadas por la fracción del texel para que la transición sea continua en vez de saltar de un texel al siguiente. Cuadruplica el coste de muestreo de PCFShadowMap y produce un borde notablemente más limpio. En este modo, además, shadow.radius no tiene efecto: el tamaño del kernel está fijado en el propio shader.

VSMShadowMap. Variance Shadow Maps, de Donnelly y Lauritzen. Cambia lo que se guarda: en vez de la profundidad, guarda dos números por texel —la media de la profundidad y la media de su cuadrado, es decir el primer y el segundo momento—. Con esos dos momentos se calcula la varianza y se aplica la desigualdad de Chebyshev para estimar la probabilidad de que el fragmento esté ocluido:

p( ocluido ) <= varianza / ( varianza + ( profundidad - media )^2 )

Lo revolucionario de esto es que los momentos se pueden filtrar linealmente. La media de dos momentos sigue siendo un momento válido, cosa que no ocurre con las profundidades. Eso permite desenfocar el shadow map con un gaussiano separable de verdad, generar mipmaps, y usar filtrado bilineal por hardware. Three.js lo hace en un pase aparte, sobre el render target mapPass, y ahí es donde shadow.radius y shadow.blurSamples sí funcionan.

El coste, en lecturas por fragmento y por luz

Tipo Lecturas radius Borde Coste relativo
BasicShadowMap 1 sin efecto duro y escalonado
PCFShadowMap 4 sin efecto útil suavizado leve
PCFSoftShadowMap 16 sin efecto suave y limpio 16×
VSMShadowMap 1 más el pase de desenfoque muy suave, ajustable 1× por fragmento, pase extra

La última fila es la que cambia el análisis. VSM hace una lectura por fragmento, igual que Basic, porque toda la suavidad ya está horneada en el mapa desenfocado. Su coste no está en el segundo pase sino en el primero: un desenfoque separable de dos direcciones sobre un render target de coma flotante, con blurSamples muestras cada uno. Con blurSamples = 8 y un mapa de 1024, eso son unos 16 millones de lecturas por luz y por actualización del mapa.

La conclusión operativa es contraintuitiva: VSM se vuelve más barato que PCFSoftShadowMap cuando la escena cubre muchos píxeles y la sombra se actualiza poco. Si además congelas el mapa con autoUpdate = false, VSM da sombras suaves por el precio de BasicShadowMap. Y al revés: si la luz se mueve cada frame y la sombra ocupa un rincón de la pantalla, PCF gana.

💡
radius y blurSamples solo cuentan con VSM

shadow.radius tiene valor por defecto 1 y shadow.blurSamples valor 8. La documentación del código lo dice: radius “has no effect when the shadow map type is BasicShadowMap”, y en la práctica tampoco lo tiene con PCFSoftShadowMap, donde el kernel es fijo. El sitio donde estos dos parámetros hacen algo real es VSM. Si estás moviendo radius y no ves nada, ese es el motivo.

Light bleeding, el precio de VSM

La desigualdad de Chebyshev da una cota superior de la probabilidad de oclusión, no la probabilidad exacta. Cuando dentro de la región filtrada hay dos oclusores a profundidades muy distintas —una pared cercana y otra lejana—, la varianza se dispara y la cota se vuelve muy laxa: la fórmula devuelve un valor bajo de oclusión donde debería devolver 1. Visualmente aparece luz filtrándose a través de geometría sólida, típicamente como un halo claro donde una sombra cercana se solapa con otra lejana.

Three.js mitiga esto con la técnica estándar de recortar la cola baja de la distribución, pero no la elimina. Las dos defensas prácticas son reducir radius —menos región filtrada, menos mezcla de profundidades— y evitar geometría con oclusores a profundidades muy dispares dentro del mismo frustum, que casualmente es la misma disciplina que ya te pide apretar la cámara de sombra.

El árbol de decisión

Ordenado por la pregunta que de verdad decide, que es el dispositivo:

Móvil de gama baja o muchas luces. BasicShadowMap con mapSize de 1024 y el frustum bien apretado. Un borde duro y bien colocado se lee mejor que un borde suave sobre una sombra mal encuadrada.

Móvil de gama media, una o dos luces. PCFShadowMap. Es el defecto y es el defecto por buenas razones: cuatro lecturas es un coste asumible en cualquier hardware con WebGL2.

Escritorio, la sombra es protagonista. PCFSoftShadowMap. Dieciséis lecturas por luz y por fragmento sombreado, que en una GPU dedicada no se nota.

Sombras estáticas grandes y suaves. VSMShadowMap con autoUpdate = false, radius entre 2 y 6 y blurSamples entre 8 y 16. Es el único camino a una penumbra ancha de verdad sin horneado.

Y una advertencia final que ahorra horas: si has llegado hasta aquí buscando arreglar una sombra fea, el tipo de filtrado es lo último que hay que tocar. Una sombra con el frustum mal encuadrado se ve mal en los cuatro modos; una con el frustum apretado se ve aceptable incluso en BasicShadowMap. El orden correcto es frustum, luego resolución, luego bias, y solo entonces filtrado.

Ningún filtrado de Three.js produce penumbra de contacto, y ese es el motivo de que las sombras sigan pareciendo de videojuego

Los cuatro tipos comparten una limitación estructural que ninguna combinación de parámetros supera: el ancho del suavizado es constante. En la realidad, la penumbra crece con la distancia entre el oclusor y el receptor, porque depende del tamaño angular de la fuente vista desde el punto sombreado. La sombra de un dedo apoyado sobre una mesa es afiladísima; la del mismo dedo a treinta centímetros es un borrón. Ese gradiente es una de las señales más fuertes que usa el sistema visual para juzgar el contacto entre objetos, y es exactamente lo que falta cuando una escena renderizada «parece flotar». La familia de técnicas que lo resuelve se llama Percentage-Closer Soft Shadows y funciona en dos etapas: primero una búsqueda de bloqueadores en un radio pequeño para estimar a qué distancia media está el oclusor, y después un PCF cuyo radio se calcula a partir de esa distancia y del tamaño declarado de la fuente. Cuesta dos rondas de muestreo en lugar de una y necesita un shader propio; Three.js no la trae, ni en WebGLRenderer ni en el pipeline de nodos. Las tres salidas reales son escribir el filtro a mano con onBeforeCompile sobre el chunk shadowmap_pars_fragment, hornear la penumbra en un lightmap donde el trazador de rayos de Blender sí la calcula correctamente, o aceptar el borde constante y compensar con una sombra de contacto añadida —una textura oscura bajo el objeto, o un pase de oclusión ambiental en pantalla como GTAOPass—. La tercera opción es la que usa el noventa por ciento de las webs 3D bien acabadas, y es mucho más barata que las otras dos.