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.
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 tipicaconsole.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 / PILos 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.
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.