La ecuación de renderizado, en versión digerible
Qué dice exactamente la integral que todo motor aproxima, por qué el coseno está ahí, y cómo Three.js la parte en cuatro sumandos que puedes leer en el shader.
La ecuación
Lo(x, wo) = Le(x, wo) + integral sobre el hemisferio de fr(x, wi, wo) * Li(x, wi) * cos(theta_i) dwiTraducida: la luz que sale de un punto x en la dirección wo es la que ese punto emite por sí mismo, más la suma de toda la luz que le llega desde cualquier dirección wi del hemisferio, multiplicada por la fracción que la superficie redirige de wi hacia wo, y ponderada por el coseno del ángulo entre wi y la normal.
La magnitud que se transporta, L, es radiancia: potencia por unidad de área proyectada y por unidad de ángulo sólido, en vatios por metro cuadrado y estereorradián. Se elige la radiancia y no otra cosa por una propiedad crucial: la radiancia se conserva a lo largo de un rayo en el vacío. Lo que sale de una superficie hacia la cámara es exactamente lo que llega a la cámara, sin caída con la distancia. La caída con el cuadrado de la distancia que sí observas en una bombilla no está en el transporte, está en el ángulo sólido que subtiende la fuente, que se hace más pequeño al alejarse.
fr es la BRDF, la función de distribución de reflectancia bidireccional. Es lo único que describe el material y tiene su propia lección.
Por qué el coseno
El coseno es la parte que más gente memoriza sin entender, y entenderla cuesta treinta segundos. Imagina un haz de luz de sección cuadrada, de un centímetro de lado, que incide sobre una superficie. Si incide perpendicular, esa potencia se reparte sobre un centímetro cuadrado de superficie. Si incide con sesenta grados de inclinación, la misma potencia se reparte sobre dos centímetros cuadrados, porque la huella del haz se estira. La densidad de potencia por unidad de área cae con el coseno del ángulo.
No es una propiedad del material ni de la luz: es geometría pura. Es exactamente la misma razón por la que en la Tierra hace más frío en los polos que en el ecuador con el mismo Sol. Y es la razón por la que el producto escalar entre la normal y la dirección de la luz aparece en absolutamente todos los modelos de iluminación, desde el Lambert más simple hasta el trazado de caminos.
En el shader de Three.js está literalmente ahí:
float dotNL = saturate( dot( geometryNormal, directLight.direction ) );vec3 irradiance = dotNL * directLight.color;Esa irradiance es la potencia por unidad de área que llega de esa luz. El saturate recorta a cero las direcciones que vienen de detrás de la superficie, que es la forma discreta de decir “el hemisferio, no la esfera”.
La recursión y la renuncia
El problema de la ecuación es Li, la radiancia que llega desde una dirección. ¿De dónde viene? De otra superficie. Y la radiancia que sale de esa otra superficie se calcula con la misma ecuación, que a su vez necesita la radiancia que le llega de otras. La ecuación se define en términos de sí misma sobre todas las superficies de la escena a la vez. Resolverla exactamente es lo que hace el trazado de caminos, con millones de rayos y minutos u horas por imagen.
Un motor en tiempo real parte esa integral en dos trozos y trata cada uno con una mentira distinta.
La luz directa es la que viene de las fuentes declaradas. En lugar de integrar sobre el hemisferio, se convierte en un sumatorio sobre un puñado de direcciones concretas, una por luz. Es exacto para fuentes puntuales, que no existen, y aproximado para todo lo demás. Las luces de área son la excepción: Three.js las resuelve con cosenos linealmente transformados, que sí integran sobre la superficie del rectángulo.
La luz indirecta es todo lo demás, y aquí la mentira es grande: se sustituye el resto de la escena por una imagen del entorno, precalculada y filtrada, que se muestrea en dos direcciones. Eso es la iluminación basada en imagen. Un fragmento no ve la escena; ve un mapa de entorno que alguien decidió que se parece a la escena.
Los cuatro sumandos de Three.js
El shader acumula el resultado en una estructura con cuatro campos, y esa división es la que verás en cualquier motor moderno:
| campo | qué acumula |
|---|---|
directDiffuse |
difuso de las luces declaradas |
directSpecular |
especular de las luces declaradas |
indirectDiffuse |
difuso del entorno y de los mapas de luz |
indirectSpecular |
especular del entorno, la reflexión |
Cada tipo de material define funciones que rellenan esos campos. RE_Direct_Physical, RE_IndirectDiffuse_Physical y RE_IndirectSpecular_Physical son los nombres reales en r184. Al final se suman los cuatro, se añade el término emisivo, y eso es el color del píxel antes del mapeo de tonos.
Que estén separados no es un detalle de implementación: es lo que permite que una oclusión ambiental multiplique solo indirectDiffuse sin tocar la luz directa, o que el sheen compense energía en tres de los cuatro. Cuando depures una escena que se ve mal, la primera pregunta útil es cuál de los cuatro sumandos está mal, no “por qué se ve raro”.
Puedes verificar el término difuso numéricamente sin ningún shader. Una superficie lambertiana con albedo a, bajo radiancia uniforme de valor 1 en todo el hemisferio, tiene que devolver exactamente a. Monte Carlo lo confirma:
// Estimador de Monte Carlo de la integral del hemisferio// para una BRDF lambertiana: fr = albedo / PI, Li = 1.function reflectanciaLambert(albedo = 0.8, muestras = 500000) { const fr = albedo / Math.PI; const pdf = 1 / (2 * Math.PI); // muestreo uniforme del hemisferio let suma = 0; for (let i = 0; i < muestras; i++) { // Con muestreo uniforme en angulo solido, cos(theta) es uniforme en [0,1]. const cosTheta = Math.random(); const Li = 1.0; suma += (fr * Li * cosTheta) / pdf; } return suma / muestras;}console.log(reflectanciaLambert(0.8)); // ~0.7999, es decir, el albedoEse resultado no es casualidad y explica de dónde sale el 1 / PI que aparece en todas las BRDF difusas. Es el tema de la lección siguiente.