PointLight y SpotLight: la atenuación, el cono y la penumbra
La fórmula exacta de atenuación que usa Three.js, qué hace distance y por qué no es un radio, cómo se combinan angle y penumbra en el borde del cono, y el coste real de cada una.
La fórmula de atenuación, entera
Three.js implementa la atenuación en el chunk lights_pars_begin con esta función, que es la del modelo de Unreal Engine 4 y aparece en su curso de SIGGRAPH 2013:
float getDistanceAttenuation( float lightDistance, float cutoffDistance, float decayExponent ) { float distanceFalloff = 1.0 / max( pow( lightDistance, decayExponent ), 0.01 ); if ( cutoffDistance > 0.0 ) { distanceFalloff *= pow2( saturate( 1.0 - pow4( lightDistance / cutoffDistance ) ) ); } return distanceFalloff;}Léelo por partes porque cada una responde a un problema distinto.
El término principal. 1 / pow(d, decay). Con decay = 2, que es el valor por defecto de PointLight y SpotLight desde r155, esto es exactamente la ley del inverso del cuadrado. El max(..., 0.01) evita la división por cero cuando un fragmento está pegado a la luz; sin él, una luz dentro de una malla produce infinitos y la superficie se vuelve blanca.
La ventana de corte. Si distance es mayor que cero, se multiplica por un factor que vale 1 cerca de la luz y llega a 0 exactamente en distance, con derivada nula. Esa es la diferencia clave: distance no es un radio de acción, es la distancia en la que la ventana fuerza el apagado. La atenuación real es el producto de las dos cosas, así que una luz con distance = 10 no ilumina hasta 10 con plena intensidad y luego se apaga: cae por el inverso del cuadrado desde el principio y además se comprime hacia cero al acercarse a 10.
El valor por defecto de distance es 0, que desactiva la ventana. Es la opción físicamente correcta: la luz cae hasta el infinito y en la práctica se vuelve invisible por sí sola. La razón para poner un distance finito es de rendimiento en otros motores, donde permite hacer culling de la luz; en el pipeline forward de WebGLRenderer no hay culling de luces, así que aquí distance es puramente una herramienta artística: te deja decir «esta antorcha no ilumina el otro extremo de la sala» sin bajar la intensidad.
El cono de SpotLight: angle, penumbra y el borde
SpotLight añade sobre la atenuación radial una atenuación angular. Dos parámetros la controlan y se combinan de una forma que no es evidente.
angle es el semiángulo del cono, en radianes, medido desde el eje. Su valor por defecto es Math.PI / 3, es decir 60 grados de semiángulo, 120 de apertura total. Su límite superior es Math.PI / 2; por encima el foco dejaría de ser un cono.
penumbra va de 0 a 1 y define qué fracción del cono, medida desde el borde hacia dentro, se dedica a la transición. Con penumbra = 0 el borde es un salto duro. Con penumbra = 1 la transición ocupa el cono entero y el foco se convierte en un degradado suave desde el eje.
El cálculo que hace Three.js es este:
// Precalculado en la CPU y subido como uniforms:// coneCos = cos( angle )// penumbraCos = cos( angle * ( 1.0 - penumbra ) )float getSpotAttenuation( float coneCosine, float penumbraCosine, float angleCosine ) { return smoothstep( coneCosine, penumbraCosine, angleCosine );}Donde angleCosine es el coseno del ángulo entre el eje del foco y la dirección al fragmento. Es un smoothstep entre dos cosenos, no entre dos ángulos, y esa es la razón de que la transición no sea perceptualmente lineal en penumbra: los valores bajos apenas se notan y los altos cambian mucho. En la práctica, penumbra por debajo de 0.2 se ve casi igual que 0, y el rango útil está entre 0.3 y 1.
SpotLight tiene además dos propiedades que no tiene ninguna otra luz:
const foco = new THREE.SpotLight( 0xffffff, 500, 0, Math.PI / 8, 0.4, 2 );foco.position.set( 0, 6, 0 );scene.add( foco );scene.add( foco.target );// Una textura que modula el color de la luz, como un gobo de teatro.// AVISO: solo tiene efecto si foco.castShadow es true.foco.map = new THREE.TextureLoader().load( '/gobo.png' );foco.castShadow = true;La propiedad map proyecta una textura desde la luz —lo que en cine se llama gobo y en videojuegos light cookie—. La restricción está documentada en el propio código fuente: se desactiva si castShadow es false, porque reutiliza la matriz de proyección del shadow map. Es la trampa más común con esta propiedad.
Para focos con distribución fotométrica real existe IESLoader, en three/addons/loaders/IESLoader.js, que lee ficheros .ies —el formato que publican los fabricantes de luminarias— y produce una textura que se asigna a foco.map.
El coste, dicho sin rodeos
Aquí está lo que decide arquitecturas. El coste de sombreado por fragmento de una PointLight y una SpotLight es comparable y modesto. El coste de la sombra no lo es.
| Luz | Cámara de sombra | Pases de la escena | Memoria a 1024 |
|---|---|---|---|
DirectionalLight |
ortográfica, 1 vista | 1 | 1 mapa |
SpotLight |
perspectiva, 1 vista | 1 | 1 mapa |
PointLight |
perspectiva 90°, 6 caras | 6 | 1 mapa de 4096×2048 |
PointLightShadow renderiza las seis direcciones del cubo en un único atlas de 4×2 celdas —de ahí el _frameExtents de (4, 2) en su implementación—, así que una PointLight con mapSize de 1024 reserva una textura de 4096 por 2048. Y, sobre todo, ejecuta seis veces el recorrido de la escena, el culling y los draw calls.
Traducido a decisiones: una SpotLight con sombras en un móvil de gama media es una decisión defendible si la escena tiene pocos objetos, y suele costar entre uno y tres milisegundos de frame. Una PointLight con sombras en ese mismo móvil es, en la inmensa mayoría de los casos, un error: multiplica por siete el trabajo de CPU del frame para producir una sombra omnidireccional que el usuario casi nunca necesita. El sustituto es casi siempre una SpotLight apuntando hacia abajo, o una sombra de contacto horneada en una textura.