Shadow acne y peter-panning: la causa geométrica y la cura
Por qué una superficie se sombrea a sí misma con bandas, por qué el bias constante despega la sombra del objeto, y cómo normalBias resuelve el problema atacando la causa en vez del síntoma.
El acné de sombra no es un fallo de configuración: es la consecuencia inevitable de comparar una profundidad continua con una discreta. Y el remedio que todo el mundo aplica primero —restar una constante— no arregla la causa, la desplaza: cambia bandas por sombras despegadas. Los dos artefactos son las dos caras del mismo error de muestreo, y por eso ajustarlos a ojo es una partida que no se gana. Se gana entendiendo la geometría, que además se puede dibujar.
- Derivar la causa exacta del acné a partir del tamaño de un texel y la inclinación de la superficie.
- Explicar por qué
biasconstante produce peter-panning y calcular cuánto despega la sombra. - Elegir entre
biasynormalBiassabiendo qué error corrige cada uno. - Aplicar el descarte de caras frontales como alternativa y conocer su precio.
La causa: un texel es un área, no un punto
Un shadow map tiene resolución finita. Cada texel cubre una porción de la escena que, proyectada sobre la superficie que ilumina, es un pequeño paralelogramo. Durante el primer pase, ese texel guarda un solo valor de profundidad: el que le tocó al rasterizar, correspondiente aproximadamente al centro del texel.
Durante el segundo pase, todos los fragmentos que caen dentro de ese paralelogramo se comparan contra ese mismo valor único. Y ahí está el problema: si la superficie está inclinada respecto a la dirección de la luz, la profundidad real varía a lo largo del paralelogramo. La mitad del texel queda por delante del valor guardado y pasa la prueba; la otra mitad queda por detrás y la suspende. Resultado: la superficie se declara en sombra a sí misma, en franjas alternas.
flowchart TB A[Un texel del shadow map cubre un area finita de la superficie] --> B[Ese texel guarda una unica profundidad la del centro] B --> C[La superficie esta inclinada respecto a los rayos de la luz] C --> D[Dentro del texel la profundidad real varia de un extremo a otro] D --> E[La mitad queda por delante del valor guardado y pasa la prueba] D --> F[La otra mitad queda por detras y suspende la prueba] F --> G[Bandas de auto sombra esto es el acne] G --> H[Cura ingenua restar una constante a la profundidad del fragmento] H --> I[Ahora tambien pasan fragmentos que si estaban ocluidos] I --> J[La sombra se despega del objeto esto es el peter panning] style A fill:#89b4fa,color:#11111b style B fill:#94e2d5,color:#11111b style C fill:#f9e2af,color:#11111b style D fill:#f9e2af,color:#11111b style E fill:#a6e3a1,color:#11111b style F fill:#f38ba8,color:#11111b style G fill:#f38ba8,color:#11111b style H fill:#f9e2af,color:#11111b style I fill:#f9e2af,color:#11111b style J fill:#f38ba8,color:#11111b
De aquí salen dos predicciones comprobables, y las dos se cumplen siempre:
El acné empeora con la inclinación. Si la superficie es perpendicular a los rayos, la profundidad no varía dentro del texel y no hay acné en absoluto. Cuanto más rasante la incidencia, mayor la variación y peor el artefacto. Por eso un suelo iluminado por un sol bajo se llena de bandas mientras que el mismo suelo con el sol en el cénit sale limpio.
El acné empeora con texels grandes. Duplicar mapSize reduce a la mitad el ancho del paralelogramo y por tanto a la mitad la variación de profundidad dentro de él. Y estrechar el frustum de la cámara de sombra hace exactamente lo mismo, gratis.
El error a corregir, en concreto, es la mitad de la variación de profundidad a lo ancho de un texel. Con t el tamaño de un texel proyectado sobre la superficie y θ el ángulo entre la normal y la dirección a la luz, ese error vale aproximadamente t * tan(θ). Fíjate en el tan: cuando θ se acerca a 90 grados, tiende a infinito. No existe una constante que cubra todos los ángulos.
Bias constante y su precio
La cura obvia es restar un margen antes de comparar:
sol.shadow.bias = -0.0005; // se RESTA de la profundidad del fragmento
Con eso, un fragmento tiene que estar más profundo que el texel por un margen para declararse en sombra, y el acné desaparece. Pero el mismo margen se aplica a los fragmentos que sí estaban legítimamente ocluidos: los que están a menos de ese margen por detrás de un oclusor real ahora pasan la prueba y se iluminan.
El síntoma es una franja iluminada entre el objeto y su sombra, como si el objeto levitase. Se llama peter-panning por la sombra de Peter Pan, que se le escapaba. Y el desplazamiento no es sutil: el margen se aplica en el espacio de profundidad normalizado de la cámara de sombra, así que un bias de -0.001 sobre un frustum de 500 unidades de fondo son medio metro de sombra perdida.
Tres cosas que hay que saber sobre bias en Three.js y que casi nunca se dicen:
- El signo es negativo. El valor por defecto es
0. Los valores que funcionan son negativos y del orden de-0.0001a-0.005. - La escala depende del frustum. El mismo
biasque va bien confar = 50es inservible confar = 500. Si cambias la cámara de sombra, tienes que recalibrar elbias. - No es lo mismo que el polygon offset del primer pase.
biasactúa en el segundo pase, sobre la lectura. No modifica lo que se escribió.
normalBias: mover el punto de muestreo, no la profundidad
normalBias ataca el problema por el otro lado, y es la mejor herramienta de las dos. En vez de falsear la profundidad, desplaza el punto que se usa para consultar el mapa a lo largo de la normal de la superficie:
sol.shadow.normalBias = 0.02; // en unidades de mundo, positivo
La documentación del código fuente lo dice literalmente: “Defines how much the position used to query the shadow map is offset along the object normal”. El punto consultado se levanta ligeramente sobre la superficie, y como el error de acné es precisamente un error tangencial —la profundidad varía a lo ancho del texel—, levantarse lo suficiente saca el punto de la zona ambigua.
Las ventajas frente a bias son concretas y las tres importan:
Está en unidades de mundo. normalBias = 0.02 significa dos centímetros si tu escena está en metros, y ese valor sigue siendo correcto si cambias el far del frustum. Es un número que puedes razonar.
Se adapta a la geometría. Como el desplazamiento va por la normal, las superficies rasantes —las que peor lo pasan— se desplazan en la dirección que más ayuda, mientras que las perpendiculares apenas se ven afectadas.
No produce peter-panning en la dirección de la luz. El desplazamiento es perpendicular a la superficie, no paralelo a los rayos, así que la sombra no se despega del contacto. Su artefacto propio es distinto: si te pasas, las sombras se deforman cerca de los bordes y las esquinas cóncavas pierden la sombra de contacto.
- Pon
bias = 0ynormalBias = 0. Mira el acné y confirma que existe. - Antes de tocar ningún bias, aprieta el frustum de la cámara de sombra hasta que abrace la escena. Suele eliminar la mitad del acné.
- Sube
normalBiasen pasos de 0.005 hasta que el acné desaparezca en el peor ángulo de la escena. - Solo si queda acné residual en superficies muy rasantes, añade un
biaspequeño, del orden de-0.0002. - Vuelve a mirar los puntos de contacto. Si la sombra se ha despegado, has pasado el paso 4.
La alternativa sin bias: renderizar las caras traseras
Existe una solución al acné que no usa ningún bias, y en geometría cerrada es superior a las dos anteriores. Consiste en invertir el descarte de caras durante el primer pase: en vez de guardar la profundidad de la cara frontal del oclusor, guardar la de la trasera.
Como la cara trasera está por detrás de la frontal en el grosor del objeto, el margen de seguridad lo aporta la propia geometría, y es exactamente proporcional a lo que hace falta. El acné desaparece sin desplazar nada.
// Three.js aplica esto durante el pase de sombra si el material lo pide.
material.shadowSide = THREE.BackSide;
Por defecto shadowSide es null, lo que hace que Three.js deduzca el lado a partir de material.side. Ponerlo explícitamente a THREE.BackSide activa el truco.
Su precio es la restricción: solo funciona con geometría cerrada y con grosor. Un plano de una sola cara no tiene cara trasera útil; un muro modelado como un único quad se vuelve completamente transparente para las sombras. Por eso la estrategia mixta es la habitual: shadowSide: THREE.BackSide en los objetos sólidos, y un normalBias moderado global para el resto.
Las APIs gráficas de escritorio resuelven esto con glPolygonOffset, que aplica un desplazamiento con dos términos: uno constante y otro proporcional a la derivada de la profundidad respecto a la pantalla, es decir a la pendiente del triángulo visto desde la luz. Ese segundo término es la solución teóricamente correcta, porque el error que hay que corregir es exactamente t * tan(θ) y la derivada es exactamente tan(θ) escalado. Un bias que se adapta a la pendiente cubre el rasante sin despegar el perpendicular. WebGLRenderer no expone polygon offset para el pase de sombra, y shadow.bias es puramente constante: no hay factor de pendiente en ninguna parte. Esto explica el fenómeno que agota a la gente y que parece una maldición: encuentras un valor que limpia el suelo y arruina la pared; lo ajustas para la pared y vuelve el acné al suelo. No estás haciéndolo mal, es que con un solo grado de libertad no puedes cubrir un rango de pendientes. normalBias es la respuesta de Three.js a esta limitación y funciona porque se aplica en espacio de mundo a lo largo de la normal, lo que lo hace implícitamente dependiente de la orientación de la superficie: consigue algo parecido a un bias con pendiente sin necesitar la derivada. Si aun así hay un ángulo que no cede, el orden correcto de escalada es: primero estrechar el frustum, después shadowSide trasero en los sólidos, después VSMShadowMap —que no compara profundidades sino momentos estadísticos y por tanto no sufre acné del mismo modo— y solo al final aceptar que ese ángulo concreto necesita una sombra horneada. Subir el bias hasta que se vea bien es exactamente lo que no funciona, y es lo que hace todo el mundo.
- Monta un plano grande y una esfera con una
DirectionalLightcon sombra, y baja el sol hasta que casi rase el suelo. - Con
bias = 0ynormalBias = 0, observa las bandas y comprueba que desaparecen al subir el sol. - Sube
biashasta-0.01y mira el punto de contacto de la esfera con el suelo: la sombra se ha ido. - Vuelve
biasa 0, subenormalBiashasta 0.5 y observa el artefacto contrario: la sombra se deforma pero no se despega. - Reduce
shadow.cameraa la mitad de ancho y comprueba cuánto acné se va sin tocar ningún bias.