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

El modelo de iluminación de Three.js y sus unidades

Qué evalúa Three.js por cada luz y por cada fragmento, qué unidad física tiene cada intensidad desde r155, y por qué los valores de los tutoriales antiguos ahora quedan ridículos.

⏱ 19 min

Antes de mirar el catálogo de luces conviene entender qué hace el renderizador con ellas, porque el catálogo solo tiene sentido dentro de ese modelo. Three.js hace forward rendering: cada material compila un shader que contiene un bucle desenrollado sobre todas las luces de la escena, y ese bucle se ejecuta una vez por fragmento visible. De ahí salen las dos consecuencias que gobiernan todo este nivel: el coste de una luz es lineal en píxeles cubiertos, y cambiar el número de luces recompila shaders.

🎯 Al terminar esta lección sabrás
  • Describir qué calcula Three.js por cada luz y por cada fragmento en el pase forward.
  • Traducir una especificación física real —lúmenes, vatios de una bombilla— a un valor de intensity.
  • Explicar por qué PointLight y SpotLight usan factores distintos entre power e intensity.
  • Predecir cuándo un cambio en la escena provoca una recompilación de shaders y evitarlo.

Qué ocurre por fragmento

El material estándar de Three.js implementa un BRDF de microfacetas: un término difuso de Lambert y un término especular GGX con Fresnel de Schlick y función de sombreado de Smith. Ese BRDF se evalúa una vez por luz y por fragmento, y los resultados se suman. En pseudocódigo, lo que hace el shader generado es esto:

// Esqueleto de lo que Three.js genera dentro de <lights_fragment_begin>
// y <lights_fragment_end>. Los bucles se desenrollan en tiempo de compilacion
// porque NUM_DIR_LIGHTS y compania son macros #define, no uniforms.

vec3 radianciaSalida = vec3( 0.0 );

#if ( NUM_DIR_LIGHTS > 0 )
  for ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {
    IncidentLight luzDirecta = getDirectionalLightInfo( directionalLights[ i ] );
    radianciaSalida += BRDF_Lambert( material.diffuseColor ) * luzDirecta.color * dotNL;
    radianciaSalida += BRDF_GGX( ... ) * luzDirecta.color * dotNL;
  }
#endif
// Idem para NUM_POINT_LIGHTS, NUM_SPOT_LIGHTS, NUM_RECT_AREA_LIGHTS...

Dos detalles de esto importan más que el resto.

El primero es que NUM_DIR_LIGHTS, NUM_POINT_LIGHTS y sus hermanos son macros del preprocesador, no uniforms. Forman parte de la clave del programa: si añades una luz puntual a una escena que tenía tres, la clave cambia, ningún programa de la caché encaja y Three.js compila de nuevo todos los materiales afectados. En una escena grande eso son cientos de milisegundos de bloqueo del hilo principal. Encender y apagar luces se hace poniendo intensity a cero o visible a false, nunca con scene.add() y scene.remove() durante la interacción.

El segundo es que el trabajo es por fragmento visible, no por objeto ni por triángulo. Una luz que ilumina un plano de fondo que cubre toda la pantalla cuesta exactamente lo mismo que una que ilumina un objeto diminuto, si ambos ocupan los mismos píxeles. Y el coste se multiplica por el overdraw: si tres superficies transparentes se solapan, el bucle de luces corre tres veces sobre esos píxeles.

Las unidades son físicas desde r155

Durante años Three.js tuvo un modo de iluminación heredado que multiplicaba internamente algunas intensidades por π y otras no, para que los valores por defecto de 1 produjeran algo razonable. Ese modo se desactivó por defecto en r155 y la propiedad WebGLRenderer.useLegacyLights se eliminó en r165. En r184 no existe. Lo que queda es un modelo con unidades fotométricas reales:

Luz Unidad de intensity Magnitud física
PointLight candela, cd intensidad luminosa
SpotLight candela, cd intensidad luminosa
RectAreaLight nit, cd/m² luminancia de la superficie
DirectionalLight lux, lx iluminancia sobre una superficie perpendicular
AmbientLight adimensional multiplicador de radiancia uniforme
HemisphereLight adimensional multiplicador de radiancia uniforme

Las tres primeras tienen un accesor power que traduce a lúmenes, que es la unidad que viene impresa en la caja de una bombilla:

import * as THREE from 'three';

const bombilla = new THREE.PointLight( 0xfff4e6 );
bombilla.power = 800;         // lumenes, una bombilla LED de 9 W tipica
console.log( bombilla.intensity ); // 63.66..., es decir 800 / (4 * PI)

const foco = new THREE.SpotLight( 0xffffff );
foco.power = 800;
console.log( foco.intensity );     // 254.6..., es decir 800 / PI

Los factores son distintos y no es un descuido. Una luz puntual radia isotrópicamente sobre toda la esfera, cuyo ángulo sólido es 4π estereorradianes, así que la conversión de flujo a intensidad es exacta: intensity = power / (4π). Para el foco, el código fuente de SpotLight dice literalmente “by convention for a spotlight, luminous power (lm) = π * luminous intensity (cd)”. Es una convención, no una derivación: el ángulo sólido real de un cono de semiángulo angle es 2π(1 - cos angle), que solo vale π cuando el semiángulo ronda los 60 grados. Three.js fija π para que el power de un foco no dependa de su apertura.

⚠️
Los tutoriales de antes de 2023 mienten con las intensidades

Cualquier ejemplo que ponga new THREE.PointLight( 0xffffff, 1 ) y espere ver algo es de la era heredada. Una candela es la intensidad de una vela: en el modelo actual, esa luz a dos metros de distancia y con decay = 2 deja el objeto prácticamente negro. Si un ejemplo antiguo no se ve, antes de tocar el material multiplica las intensidades de punto y foco por unos cuantos cientos.

La cadena completa: intensidad, exposición, espacio de color

Una intensidad física solo produce una imagen correcta si el resto de la cadena está montada. Three.js trabaja internamente en un espacio lineal de rango abierto, y la conversión al rango de pantalla la hacen dos piezas que no vienen configuradas por ti por defecto.

const renderer = new THREE.WebGLRenderer( { antialias: true } );

// Desde r152 este es el valor por defecto. No lo cambies salvo que
// escribas a un render target intermedio.
renderer.outputColorSpace = THREE.SRGBColorSpace;

// Esto SI hay que ponerlo. El valor por defecto es NoToneMapping,
// que recorta a saturacion en cuanto un canal pasa de 1.0.
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;

Sin mapeo tonal, un valor de radiancia de 3.0 y otro de 30.0 salen los dos como blanco puro: pierdes toda la información del rango alto y la escena parece «quemada» en cuanto usas valores físicos. ACESFilmicToneMapping comprime ese rango con una curva en S; AgXToneMapping y NeutralToneMapping son las otras dos opciones modernas, con menos desaturación en los altos. Y toneMappingExposure es el mando que corresponde al diafragma de una cámara: si toda la escena está oscura pero las proporciones entre luces son correctas, lo que hay que tocar es la exposición, no las siete intensidades.

Este es el orden mental correcto: primero fija las proporciones físicas entre luces, después ajusta la exposición global. Al revés acabas con una escena donde cada luz lleva un número arbitrario y ningún cambio es predecible.

El presupuesto de luces

Un MeshStandardMaterial con cuatro luces direccionales cuesta aproximadamente cuatro veces el bucle de una. No hay culling de luces en el pipeline forward de WebGLRenderer: si una luz puntual está en la escena, todos los fragmentos de todos los materiales que reaccionan a la luz pagan su evaluación, aunque la luz esté a cien metros y su distance la corte a dos.

De ahí sale la regla operativa de este nivel. En un móvil de gama media, entre dos y cuatro luces analíticas es un presupuesto razonable si además hay texturas y sombras; a partir de ahí conviene cambiar de estrategia y mover la iluminación a un entorno precalculado, que cuesta una sola búsqueda en textura independientemente de cuántas fuentes represente. Ese es exactamente el argumento del nivel de entornos e IBL.

El coste de una luz no está donde crees

La intuición dice que una luz cara es una luz con muchos cálculos. En Three.js la parte cara casi nunca es la aritmética del BRDF: una GPU integrada moderna traga sin despeinarse un GGX completo por luz y por fragmento. Lo que de verdad cuesta es lo que la luz arrastra. Una SpotLight con castShadow obliga a un pase de render adicional de la escena entera desde el punto de vista de la luz, con su propio frustum culling, sus propios draw calls y su propia escritura a un render target. En una escena con 200 objetos, eso duplica el número de draw calls del frame. Una PointLight con sombras es aún peor porque necesita seis vistas —las seis caras del cubo— y por tanto seis pases de la escena: es la única luz de Three.js cuyo coste de sombra es siete veces el de renderizar la escena normal. Y hay un segundo coste invisible: cada luz con sombra añade uniforms y sampler2DShadow al programa, lo que empuja hacia arriba el número de registros que necesita el shader; pasado cierto umbral la GPU reduce la ocupación —cuántos grupos de hilos puede tener en vuelo simultáneamente para tapar la latencia de memoria— y el frame se hunde de golpe, sin que ninguna métrica de «triángulos» o «draw calls» lo explique. Por eso el salto de rendimiento al quitar una sola luz con sombras suele ser desproporcionado respecto a lo que sugiere la cuenta de operaciones, y por eso la respuesta a «va lento con seis luces» casi nunca es optimizar el material: es decidir cuáles de esas seis luces necesitan de verdad proyectar sombra.

⚔️ Calibra una escena con valores reales
  1. Monta una habitación con un plano de suelo y un par de objetos con MeshStandardMaterial.
  2. Pon una PointLight con power = 800 a dos metros del suelo y ACESFilmicToneMapping. Ajusta solo toneMappingExposure hasta que la imagen sea legible.
  3. Añade una segunda bombilla de power = 400 y comprueba que la relación de brillo entre ambas zonas es la mitad, sin tocar nada más.
  4. Mide con renderer.info.programs.length cuántos programas hay compilados. Añade y quita una luz y vuelve a mirar.