wandres.dev
PBR I · La teoría del renderizado físico

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.

⏱ 20 min

Todo el renderizado basado en física es una aproximación a una sola ecuación que Kajiya escribió en 1986. No hace falta saber resolverla, pero sí saber qué dice, porque cada decisión que toma un motor en tiempo real es una renuncia concreta a un trozo de esa integral, y saber qué trozo has sacrificado explica la mitad de los artefactos que verás.

🎯 Al terminar esta lección sabrás
  • Leer la ecuación de renderizado término a término y saber qué mide cada uno.
  • Justificar por qué aparece el coseno del ángulo de incidencia.
  • Explicar por qué la ecuación es recursiva y qué implica eso para el tiempo real.
  • Localizar los cuatro sumandos en los que Three.js descompone el resultado.

La ecuación

Lo(x, wo) = Le(x, wo) + integral sobre el hemisferio de
              fr(x, wi, wo) * Li(x, wi) * cos(theta_i) dwi

Traducida: 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.

Sin envMap no estás calculando la mitad de la integral, la estás poniendo a cero

El error conceptual más caro del PBR en la web es tratar el mapa de entorno como decoración. No lo es: es el único término indirecto de la ecuación. Un MeshStandardMaterial sin envMap y sin scene.environment tiene la integral indirecta valiendo exactamente cero, así que todo lo que no reciba luz directa se va a negro puro, y las reflexiones especulares no tienen nada que reflejar. Por eso una escena PBR sin entorno se ve peor que la misma escena con Phong, y por eso la solución instintiva, subir la intensidad de las luces directas, la empeora todavía más: quemas lo iluminado sin tocar lo que está en sombra. La documentación de MeshStandardMaterial lo dice sin ambigüedad, “para mejores resultados especifica siempre un mapa de entorno”, y no es un consejo estético. Un RoomEnvironment procesado con PMREMGenerator pesa cero bytes de descarga y arregla el noventa por ciento de las escenas mal iluminadas.

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 albedo

Ese 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.