RectAreaLight, la tabla de costes y los helpers de luz
Qué es una fuente de área y por qué Three.js la resuelve con cosenos transformados linealmente, las tres restricciones que casi nadie lee, y cómo depurar una escena iluminada con los helpers correctos.
Todas las luces anteriores son puntos o direcciones: fuentes sin superficie. En la realidad ninguna fuente es un punto, y las que menos lo son —una ventana, un panel LED, un tubo fluorescente— son precisamente las que producen los reflejos especulares que el ojo lee como «esto es una foto». RectAreaLight es la única luz de Three.js que tiene extensión física, y viene con tres restricciones tan duras que conviene conocerlas antes de enamorarse del resultado.
- Explicar qué integral resuelve una fuente de área y por qué no se puede calcular directamente en tiempo real.
- Inicializar
RectAreaLightcorrectamente enWebGLRenderery enWebGPURenderer. - Enumerar sus tres restricciones y decidir si son aceptables para un caso concreto.
- Depurar la orientación y el alcance de cualquier luz con el helper que le corresponde.
Por qué una fuente con superficie es un problema distinto
Para una luz puntual, la contribución a un fragmento es una evaluación del BRDF en una única dirección. Para una fuente de área hay que integrar el BRDF sobre todas las direcciones que abarca la superficie de la fuente vista desde ese fragmento. Con un BRDF difuso lambertiano existe una solución analítica desde 1963 —la fórmula de Lambert para el factor de forma de un polígono—, pero el término especular GGX no tiene primitiva cerrada, y ahí se acaba la teoría fácil.
La técnica que usa Three.js es Linearly Transformed Cosines, publicada por Heitz, Dupuy, Hill y Neubelt en SIGGRAPH 2016. La idea es elegante: el lóbulo GGX, con su rugosidad y su ángulo de visión, se aproxima por un lóbulo coseno deformado por una matriz 3×3. Como la integral de un coseno sobre un polígono sí tiene forma cerrada, basta con aplicar la inversa de esa matriz al polígono de la luz, integrar el coseno sobre el polígono deformado, y ya está. Toda la complejidad se traslada a precalcular las matrices para cada par de rugosidad y ángulo de incidencia, y guardarlas en dos texturas de búsqueda.
Esas dos texturas son la razón de la primera restricción: hay que cargarlas a mano. Three.js no las incluye en el paquete principal para no aumentar el tamaño del núcleo con datos que casi nadie usa.
import * as THREE from 'three';
import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';
import { RectAreaLightHelper } from 'three/addons/helpers/RectAreaLightHelper.js';
// Una sola vez, antes de crear la primera RectAreaLight.
RectAreaLightUniformsLib.init();
const ventana = new THREE.RectAreaLight( 0xdce7ff, 8, 4, 2.5 );
ventana.position.set( -5, 3, 0 );
ventana.lookAt( 0, 1, 0 );
scene.add( ventana );
scene.add( new RectAreaLightHelper( ventana ) );
Con WebGPURenderer el mecanismo cambia de nombre y de firma, porque las tablas se suben como texturas de nodo:
import { RectAreaLightTexturesLib } from 'three/addons/lights/RectAreaLightTexturesLib.js';
THREE.RectAreaLightNode.setLTC( RectAreaLightTexturesLib.init() );
Las tres restricciones
Están documentadas en el propio código fuente de RectAreaLight, y las tres son absolutas.
No proyecta sombra. Ninguna. castShadow no hace nada. La razón es directa: un shadow map se renderiza desde un punto de vista, y una fuente de área no tiene un punto de vista sino infinitos. Las sombras de área en tiempo real requieren técnicas completamente distintas —shadow maps con penumbra variable, o trazado de rayos— que Three.js no implementa. Esto es lo que descarta a RectAreaLight como luz principal en cualquier escena donde las sombras cuenten la historia.
Solo funciona con materiales PBR. MeshStandardMaterial y MeshPhysicalMaterial. Con MeshPhongMaterial, MeshLambertMaterial o MeshToonMaterial la luz sencillamente no existe: los chunks de shader que implementan LTC solo se inyectan en el modelo físico. No hay warning; el objeto se queda negro.
La orientación es la del plano, y lookAt es el único camino sensato. El rectángulo emite hacia su cara frontal, definida por su normal local +Z. Como es un Object3D, podrías rotarlo con eulers, pero acertar a la primera es improbable. light.lookAt( x, y, z ) orienta la normal hacia ese punto y es lo que se usa siempre.
Su intensidad se mide en nits, candelas por metro cuadrado: es la luminancia de la superficie, no su potencia total. Por eso, a diferencia de las demás, su power depende del tamaño: power = intensity * width * height * π. La consecuencia práctica es la contraria a la intuición: si agrandas la ventana sin tocar intensity, la escena se ilumina más, porque estás añadiendo superficie emisora. Si lo que querías era mover la misma cantidad de luz a través de una ventana más grande, tienes que bajar intensity en la misma proporción.
Una RectAreaLight no se ve a sí misma: no hay geometría, no se renderiza nada. RectAreaLightHelper dibuja un plano con el color de la luz, y en muchos proyectos se deja puesto en producción porque hace exactamente lo que se quiere: representar visualmente el panel que emite. Si prefieres controlarlo tú, un Mesh con MeshBasicMaterial del mismo tamaño y colocado en la misma matriz hace el mismo papel.
La tabla de costes
Este es el resumen operativo de todo el nivel. Los órdenes de magnitud son para un MeshStandardMaterial en una GPU integrada de portátil o un móvil de gama media, y el «coste de sombra» cuenta pases completos de la escena.
| Luz | Coste por fragmento | Coste de sombra | Recomendación en móvil |
|---|---|---|---|
AmbientLight |
despreciable, una suma | ninguno | libre |
HemisphereLight |
despreciable, un dot y un mix |
ninguno | libre |
DirectionalLight |
bajo, dirección constante | 1 pase, cámara ortográfica | 1, con sombra |
PointLight |
medio, resta y atenuación por fragmento | 6 pases, atlas 4×2 | evitar con sombra |
SpotLight |
medio-alto, atenuación radial y angular | 1 pase, cámara de perspectiva | 1, con sombra, si hay presupuesto |
RectAreaLight |
alto, dos búsquedas en textura LTC y varias matrices | imposible | 1 como mucho, sin sombra |
La lectura correcta de esta tabla no es «usa siempre las baratas». Es que el gasto tiene que ir donde produce información visual. Una sombra dura y bien colocada comunica muchísimo sobre el espacio; una segunda luz puntual con sombra casi nunca comunica nada nuevo y cuesta seis pases.
Los helpers, y qué depura cada uno
La mitad de los problemas de iluminación se resuelven viendo dónde está la luz. Los helpers de núcleo son Object3D, se añaden a la escena y se actualizan con .update() cuando la luz se mueve.
import * as THREE from 'three';
import { RectAreaLightHelper } from 'three/addons/helpers/RectAreaLightHelper.js';
const helpers = [
new THREE.DirectionalLightHelper( sol, 2 ), // plano + linea de direccion
new THREE.PointLightHelper( bombilla, 0.3 ), // esfera de alambre
new THREE.SpotLightHelper( foco ), // cono de alambre
new THREE.HemisphereLightHelper( hemi, 1 ), // octaedro con los dos colores
new RectAreaLightHelper( ventana ), // rectangulo emisor
];
helpers.forEach( ( h ) => scene.add( h ) );
// SpotLightHelper y DirectionalLightHelper NO se actualizan solos.
function animate() {
foco.position.x = Math.sin( performance.now() * 0.001 ) * 4;
helpers.forEach( ( h ) => h.update && h.update() );
renderer.render( scene, camera );
}
Y hay un sexto helper que no es de luz pero es el más importante de todos cuando llegues a las sombras: CameraHelper sobre light.shadow.camera dibuja el frustum exacto que se está usando para el shadow map. Todo lo que quede fuera de esa caja no proyecta ni recibe sombra, y ver la caja convierte un problema misterioso en uno geométrico.
scene.add( new THREE.CameraHelper( sol.shadow.camera ) );
La aproximación LTC es asombrosamente buena en casi todo el rango, pero se degrada en dos extremos que conviene reconocer para no perder una tarde buscando un bug que no está en tu código. El primero es la rugosidad muy baja: cuando roughness baja de 0.05, el lóbulo GGX se estrecha tanto que la matriz de transformación se vuelve casi singular, y la tabla de búsqueda —que tiene una resolución finita de 64×64— no captura bien el pico. El síntoma es que el reflejo del panel sobre un espejo casi perfecto sale con el borde escalonado o con un brillo que parpadea al mover la cámara. El segundo es la incidencia rasante, cuando el fragmento ve el rectángulo casi de canto: la integral tiende a cero pero el error relativo crece, y aparece un halo tenue en la zona donde debería haber cero luz. Ninguno de los dos se arregla con un parámetro. Lo que sí funciona es tratar RectAreaLight como lo que es en la práctica: una fuente para materiales de rugosidad media, entre 0.15 y 0.7, que es exactamente el rango donde vive el 90 % de los materiales reales —plástico, madera barnizada, metal cepillado, cerámica—. Para el espejo casi perfecto, la respuesta correcta no es una luz de área sino un entorno con el panel pintado dentro, porque un reflejo especular puro es literalmente una muestra del entorno y no necesita ninguna integral. Es el mismo razonamiento que hace del IBL la técnica dominante: cuanto más liso el material, más barato es reflejar una imagen que integrar una fuente.