wandres.dev
LUCES · El catálogo y su coste

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.

⏱ 20 min

Estas son las dos luces con posición de verdad, y por tanto las dos que atenúan. La atenuación es el punto donde Three.js dejó de ser un juguete y pasó a un modelo fotométrico: decay = 2 es la ley del inverso del cuadrado, y distance no es lo que casi todo el mundo cree. Entender la fórmula completa —incluida la ventana de corte que Three.js aplica cuando distance no es cero— es lo que separa un foco que se apaga con naturalidad de uno que se corta como una cuchilla.

🎯 Al terminar esta lección sabrás
  • Escribir la fórmula de atenuación que Three.js evalúa por fragmento y explicar cada término.
  • Distinguir el papel de decay del de distance y saber cuándo poner distance a algo distinto de cero.
  • Construir el borde de un cono con angle y penumbra y predecir la anchura de la transición.
  • Justificar por qué una PointLight con sombras es siete veces más cara que una SpotLight.

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.

⚠️
decay 0 y decay 1 existen pero rompen el modelo

decay = 0 elimina la caída y convierte una luz puntual en algo que no existe físicamente; decay = 1 es la caída lineal de los motores de los noventa. Están permitidos y a veces resuelven un problema artístico, pero en cuanto los usas has salido del modelo físicamente correcto y ya no puedes razonar sobre intensidades en candelas. Si te encuentras bajando decay para que una luz llegue más lejos, lo que quieres casi siempre es subir 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.

💡
Un foco necesita target igual que una luz direccional

SpotLight también tiene un target que hay que añadir a la escena o reemplazar por un objeto que ya esté en ella. Y hay un detalle propio: el constructor hace this.position.copy( Object3D.DEFAULT_UP ), es decir, coloca el foco en (0, 1, 0) y no en el origen, para que la dirección por defecto no sea degenerada.

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.

El foco es un cono, pero su shadow map es un cuadrado, y ahí se va la mitad de tu resolución

SpotLightShadow construye su cámara así: fov = RAD2DEG * 2 * light.angle * this.focus. El fov de una PerspectiveCamera es el ángulo vertical completo, así que la cámara de sombra cubre exactamente el diámetro angular del cono. Pero el frustum de una cámara de perspectiva con aspect = 1 es una pirámide de base cuadrada, y el cono de luz es un círculo inscrito en ese cuadrado. La proporción entre el área del círculo y la del cuadrado es π/4, o sea 0.785: el 21.5 % de los texels de tu shadow map caen fuera del cono y no se usan jamás. Con mapSize de 1024, eso son 225 000 texels renderizados, almacenados y muestreados para nada. Aquí es donde entra la propiedad focus, que casi nadie toca: multiplica el fov de la cámara de sombra por un factor entre 0 y 1, estrechando el frustum dentro del cono. Con focus = 0.9 la cámara cubre solo el 90 % central del ángulo, los texels se concentran donde de verdad hay luz y la resolución efectiva de la sombra sube un 23 % sin gastar un byte más. El precio es que el 10 % exterior del cono se queda sin sombra correcta, lo cual es aceptable justo cuando penumbra es alto y ese borde ya está desvanecido de todos modos. Es el ajuste de mejor relación entre esfuerzo y ganancia de todo el sistema de sombras de Three.js, y no aparece en ningún tutorial porque su efecto solo se ve si comparas dos capturas al mismo tiempo.