Saltar al contenido

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.

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.

JS
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:

JS
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.

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.

JS
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.

JS
scene.add( new THREE.CameraHelper( sol.shadow.camera ) );