wandres.dev
SOMBRAS · Shadow maps y sus artefactos

La cámara de sombra: el ajuste que lo cambia todo

Cómo el frustum de la cámara de la luz determina la resolución efectiva de cada sombra, qué cámara usa cada tipo de luz, y por qué apretarlo mejora más que duplicar el tamaño del mapa.

⏱ 18 min

Si solo pudieras tocar un parámetro del sistema de sombras de Three.js, sería este. El shadow map tiene un número fijo de texels y la cámara de sombra decide sobre qué volumen del mundo se reparten. Un frustum el doble de ancho reparte los mismos texels sobre cuatro veces más superficie, con lo que la sombra pierde exactamente la misma calidad que si hubieras dividido la resolución por dos. La diferencia es que estrechar el frustum es gratis y duplicar la resolución cuesta cuatro veces la memoria.

🎯 Al terminar esta lección sabrás
  • Calcular el tamaño en unidades de mundo que cubre un texel de sombra, dado un frustum y un mapSize.
  • Configurar el frustum de una DirectionalLight para que abrace la escena sin margen sobrante.
  • Explicar de dónde salen los parámetros de la cámara de sombra de una SpotLight y por qué no se tocan a mano.
  • Depurar cualquier problema de sombras empezando por visualizar el frustum.

La cuenta que gobierna todo

Para una DirectionalLight, la cámara de sombra es una OrthographicCamera con estos valores por defecto:

// Lo que crea DirectionalLightShadow en su constructor:
new THREE.OrthographicCamera( -5, 5, 5, -5, 0.5, 500 );

Un volumen de 10 por 10 unidades de sección y 499.5 de profundidad. Con el mapSize por defecto de 512 por 512, cada texel cubre:

ancho del frustum / ancho del mapa = 10 / 512 = 0.0195 unidades

Casi dos centímetros si tu escena está en metros. Eso es aceptable para un objeto de mesa, y catastrófico para un exterior. Si estiras el frustum a 200 unidades para cubrir un terreno sin tocar la resolución:

200 / 512 = 0.39 unidades

Casi cuarenta centímetros por texel. La sombra de una farola desaparece en el ruido de la cuantización. Y esta es la relación que hay que interiorizar: la calidad de la sombra es el cociente entre el ancho del frustum y el ancho del mapa, y los dos términos son igual de potentes. Reducir el frustum a la mitad vale exactamente lo mismo que duplicar el mapa, pero el primero no cuesta memoria ni ancho de banda.

const sol = new THREE.DirectionalLight( 0xffffff, 3 );
sol.position.set( 8, 12, 6 );
sol.castShadow = true;

// Abrazar la escena: si todo cabe en un cubo de 20 unidades centrado
// en el origen, la diagonal proyectada nunca supera 14.2 por lado.
const s = 15;
sol.shadow.camera.left   = -s;
sol.shadow.camera.right  =  s;
sol.shadow.camera.top    =  s;
sol.shadow.camera.bottom = -s;
sol.shadow.camera.near   = 1;
sol.shadow.camera.far    = 40;
sol.shadow.mapSize.set( 2048, 2048 );

// Obligatorio tras tocar el frustum de una camara ortografica.
sol.shadow.camera.updateProjectionMatrix();
scene.add( sol );

La llamada a updateProjectionMatrix() no es opcional. Modificar left, right, top, bottom, near o far de una cámara no recalcula nada por sí solo; hay que pedirlo explícitamente. Es el olvido que produce el clásico «he cambiado los valores y no pasa nada».

⚠️
mapSize hay que fijarlo antes del primer render

Cambiar shadow.mapSize después de que se haya generado el mapa no lo redimensiona. Hay que liberar el render target existente para forzar la reconstrucción: sol.shadow.map.dispose(); sol.shadow.map = null;. Si defines el tamaño al construir la escena, este problema no aparece.

Cada luz tiene su cámara y no todas se tocan igual

Luz Clase de sombra Cámara Qué puedes tocar
DirectionalLight DirectionalLightShadow OrthographicCamera(-5, 5, 5, -5, 0.5, 500) los seis planos, a mano
SpotLight SpotLightShadow PerspectiveCamera(50, 1, 0.5, 500) near y focus; el resto es derivado
PointLight PointLightShadow PerspectiveCamera(90, 1, 0.5, 500) near y far

La SpotLight es el caso interesante porque su cámara se recalcula sola en cada frame. Su updateMatrices hace esto:

// SpotLightShadow.updateMatrices, textualmente:
const fov = RAD2DEG * 2 * light.angle * this.focus;
const aspect = ( this.mapSize.width / this.mapSize.height ) * this.aspect;
const far = light.distance || camera.far;

Traducido: el campo de visión sale del angle del foco, y el far sale de su distance si es distinto de cero. Poner foco.shadow.camera.fov = 30 a mano no sirve de nada, porque se sobrescribe en el siguiente frame. Lo único que controlas de verdad son near —que sí se respeta y es el ajuste de precisión que ya viste— y focus, ese multiplicador que estrecha el frustum dentro del cono para no malgastar los texels de las esquinas.

Para PointLight, la cámara es de 90 grados y aspecto 1 porque son las seis caras de un cubo; ahí no hay nada que tocar salvo near y far. Y el far importa: por defecto vale 500, lo que reparte la precisión del buffer sobre un rango absurdo para una bombilla de habitación. Bajarlo a la distancia real de la pared más lejana es de las mejoras más rápidas que existen.

Visualizar el frustum es el primer paso, siempre

Casi cualquier problema de sombras se diagnostica en diez segundos si ves la caja. CameraHelper dibuja el frustum exacto de cualquier cámara, incluida la de una sombra:

const helperSombra = new THREE.CameraHelper( sol.shadow.camera );
scene.add( helperSombra );

// Si mueves la luz, hay que actualizarlo:
function animate() {
  sol.shadow.camera.updateProjectionMatrix();
  helperSombra.update();
  renderer.render( scene, camera );
}

Con la caja visible, los tres diagnósticos más comunes son inmediatos:

Un objeto no proyecta sombra. Está fuera de la caja. O tiene castShadow = false.

La sombra aparece y desaparece al mover la cámara. El frustum es más pequeño que la escena y el objeto entra y sale por el plano far o por un lateral.

La sombra es un borrón. La caja es enorme comparada con el objeto. Aprieta.

Para ver el contenido del mapa en sí —lo que de verdad se ha renderizado en el primer pase— existe ShadowMapViewer en three/addons/utils/ShadowMapViewer.js, que pinta el shadow map en una esquina de la pantalla. Es lo que quieres cuando sospechas que un objeto se está saltando el primer pase.

Un frustum que se mueve con la cámara hace temblar la sombra, y la cura es cuantizar el movimiento

En cuanto haces lo correcto —seguir al jugador con la luz para que el frustum sea pequeño— aparece un artefacto nuevo que parece inexplicable: los bordes de las sombras hierven, tiemblan con un patrón de escalera que se mueve al desplazarse el jugador, aunque los objetos estén perfectamente quietos. La causa es la rejilla. El shadow map es una rejilla fija de texels anclada al frustum de la luz; si el frustum se desplaza medio texel, todo el mundo se remuestrea con un desfase de medio texel y el borde de cada sombra salta a la fila de texels de al lado. Como el desplazamiento del jugador es continuo y el remuestreo es discreto, obtienes un aliasing temporal muy visible que ninguna cantidad de filtrado PCF elimina, porque el filtrado suaviza dentro del frame pero no correlaciona entre frames. La cura estándar en la industria se llama texel snapping y consiste en no dejar que el frustum se mueva de forma continua: se calcula el desplazamiento deseado, se proyecta al espacio de la luz, y se redondea al múltiplo entero del tamaño de un texel antes de aplicarlo. Con eso la rejilla siempre cae en las mismas posiciones del mundo y las sombras dejan de hervir de golpe. WebGLRenderer no lo hace por ti —hay que implementarlo a mano sobre light.position y light.target.position, o usar el módulo CSM de los addons, que sí lo incluye—. Es la razón por la que las sombras de un motor comercial se ven estables mientras las de un prototipo hecho a mano vibran, y no tiene absolutamente nada que ver con la resolución del mapa: puedes subir a 4096 y seguirán hirviendo igual.