DirectionalLight: la fuente que está infinitamente lejos
Por qué una luz direccional no tiene posición sino dirección, qué implica eso para su atenuación y su sombra, cómo funciona el target, y cuándo la abstracción del sol deja de valer.
DirectionalLight es la luz que más gente usa mal, y siempre por el mismo motivo: tiene una propiedad position que en el cálculo de iluminación no se usa como posición. Solo importa la resta entre position y target.position, normalizada. Mover la luz de diez a mil unidades de altura no cambia ni un píxel del sombreado. Lo que sí cambia, y mucho, es la sombra, y esa disociación es el origen de la mitad de los problemas de este nivel.
- Derivar la dirección efectiva de una
DirectionalLighta partir depositionytarget. - Explicar por qué una fuente direccional no tiene atenuación con la distancia.
- Añadir correctamente el objeto
targeta la escena y hacer que siga a otro objeto. - Distinguir qué parámetros afectan al sombreado y cuáles solo a la sombra.
Rayos paralelos, o el límite de una fuente lejana
Una fuente puntual emite rayos que divergen desde un punto. A medida que la fuente se aleja, el cono de rayos que llega a un objeto de tamaño fijo se estrecha, hasta que en el límite todos los rayos son paralelos. Eso es una DirectionalLight: el límite de una fuente puntual infinitamente lejana e infinitamente potente. El sol, a 150 millones de kilómetros, es una aproximación excelente de ese límite para cualquier escena que quepa en un planeta.
De ahí salen las tres propiedades del modelo, y las tres son consecuencia de la misma idealización:
La dirección es constante en toda la escena. El shader recibe un único vec3 por luz direccional. No hay que calcularlo por fragmento, a diferencia de una luz puntual, donde cada fragmento resta su propia posición.
No hay atenuación. La ley del inverso del cuadrado dice que la irradiancia cae con la distancia al cuadrado, pero eso es porque el área que cubre un cono crece con el cuadrado. Rayos paralelos no divergen, así que el área no crece y la irradiancia no cae. Un objeto a diez unidades y otro a diez mil reciben la misma cantidad de luz. Por eso DirectionalLight no tiene decay ni distance.
La unidad es el lux. Como no hay caída, la magnitud natural no es la intensidad luminosa de la fuente sino la iluminancia que llega a una superficie perpendicular a los rayos. Un día soleado a mediodía ronda los 100 000 lux; un día muy nublado, 1 000. Three.js no trabaja con esos números absolutos porque el mapeo tonal se llevaría por delante todo el rango, pero la proporción sí es la buena: un cielo nublado es dos órdenes de magnitud menos que un sol directo.
const sol = new THREE.DirectionalLight( 0xfff4e5, 3.0 );
sol.position.set( 4, 8, 3 ); // solo define la DIRECCION
scene.add( sol );
// Estos dos casos producen exactamente el mismo sombreado:
sol.position.set( 4, 8, 3 );
sol.position.set( 400, 800, 300 );
El target y la trampa clásica
La dirección efectiva es target.position - light.position, tomada en espacio de mundo. DirectionalLight crea su propio target en el constructor: un Object3D vacío en el origen. Y ahí está la trampa.
Un Object3D que no está en el grafo de escena no recibe actualizaciones de matrixWorld. Si mueves light.target.position sin haber añadido el target a la escena, su matrixWorld sigue siendo la identidad y la luz sigue apuntando al origen. El síntoma es exasperante porque el código parece correcto: cambias el valor, no pasa nada, y no hay ningún error en consola.
const sol = new THREE.DirectionalLight( 0xffffff, 3 );
sol.position.set( 5, 10, 5 );
// SIN esta linea, mover sol.target.position no hace absolutamente nada.
scene.add( sol.target );
scene.add( sol );
sol.target.position.set( -8, 0, 4 ); // ahora si
La alternativa limpia, y la que se usa cuando la luz tiene que seguir a un personaje, es reemplazar el target por un objeto que ya esté en la escena:
// El sol apunta siempre al jugador, se mueva donde se mueva.
sol.target = jugador;
Aquí no hace falta scene.add( sol.target ) porque jugador ya está en el grafo y su matrixWorld se actualiza sola. Este patrón, combinado con mover sol.position para que mantenga un desplazamiento constante respecto al jugador, es la base de las sombras direccionales en mundos grandes: la cámara de sombra viaja con el jugador en vez de tener que cubrir el mundo entero.
Si metes la luz dentro de un Group y rotas el grupo, la dirección sí cambia, porque matrixWorld de la luz cambia. Pero el target sigue donde estaba, porque es hermano y no hijo. Si quieres que un grupo rote luz y target juntos, mete los dos dentro del grupo.
Dónde deja de valer la abstracción
La idealización de rayos paralelos falla en dos escenarios concretos, y conviene reconocerlos antes de pelearse con los parámetros.
El primero es la sombra. Aunque el sombreado no dependa de la distancia, la sombra sí depende de la posición, porque el shadow map se renderiza desde una cámara ortográfica colocada en light.position y orientada hacia target. El frustum de esa cámara —shadow.camera.left, right, top, bottom, near, far— tiene por defecto un volumen de 10 por 10 por 499.5 unidades, y todo lo que quede fuera simplemente no proyecta sombra. Ahí es donde la posición vuelve a importar y donde hay que trabajar de verdad; es el tema del nivel de sombras.
El segundo es la penumbra. Una fuente direccional ideal produce sombras con el borde perfectamente afilado, porque no tiene tamaño angular. El sol real tiene medio grado de diámetro aparente, lo que produce una penumbra que crece con la distancia al oclusor: la sombra de un poste es nítida en su base y difusa a diez metros. Three.js no modela eso con DirectionalLight. Se aproxima con el filtrado del shadow map, que produce un desenfoque de anchura constante, no proporcional a la distancia. Si esa diferencia importa para tu escena, la respuesta no es un parámetro de la luz: es un filtro de sombras con radio variable o un lightmap horneado.
Todo el mundo llega tarde a esto. Una DirectionalLight tiene un shadow map de resolución fija que cubre un volumen ortográfico. La resolución angular efectiva de la sombra es, por tanto, el ancho del frustum dividido entre el ancho del mapa: un frustum de 100 unidades con un mapa de 2048 da un texel cada 4.9 centímetros, que ya se ve dentado en primer plano. Si estiras el frustum a 500 unidades para cubrir el mundo, el texel pasa a 24 centímetros y la sombra de una silla es un borrón. Y si lo encoges para que la silla se vea bien, todo lo que esté a más de veinte metros deja de proyectar sombra. No hay un valor que funcione, porque estás pidiéndole a una distribución uniforme de texels que sirva a una escena donde la densidad de píxeles en pantalla cae con el cuadrado de la distancia. La solución de la industria desde hace veinte años son las cascadas: partir el frustum de la cámara en tres o cuatro rebanadas por distancia y dar a cada una su propio shadow map, de modo que la rebanada cercana tenga texels finos sobre un volumen pequeño y la lejana texels gruesos sobre un volumen grande. Three.js trae una implementación en three/addons/csm/CSM.js, que sustituye tu DirectionalLight por un conjunto de luces coordinadas e inyecta código en los materiales para elegir la cascada correcta por fragmento. Cuesta memoria —tres o cuatro mapas en vez de uno— y cuesta un pase de sombra por cascada, pero es la diferencia entre un exterior que se puede mirar y uno que no. Si tu escena cabe en veinte metros, no lo necesitas; si es un terreno, no hay alternativa.
- Monta un plano y un cubo con una
DirectionalLightconcastShadowy unCameraHelpersobresol.shadow.camera. - Multiplica
sol.positionpor diez y comprueba que el sombreado no cambia y la sombra sí. - Olvida a propósito
scene.add( sol.target ), mueve el target y observa que no pasa nada. - Sustituye el target por el cubo y mueve el cubo: la dirección de la luz debe seguirlo.