Hágase la luz!

Programando un motor 3D desde cero - Brakeza3D

Hágase la luz!

Bienvenidos de nuevo! En el último artículo, os presenté el Frustum View, su utilidad y forma de implementarlo. Para esta ocasión, he pensado en algo mas vistoso y sencillo! La luz!.

La mayoría de engines 3D existentes, disponen de algún tipo de iluminación que suele resumir el trabajo a unos pequeños ajustes en tiempo de edición. Sin embargo, nosotros, vamos a implementar desde cero un sistema de iluminación básico, que contemple tanto luces estáticas, como dinámicas.

Si habéis leido todos los artículos y practicado por vuestra cuenta, no os será dificil llegar a implementar vuestro propio engine 3D con mapeado de texturas. No obstante, a ojos de cualquier jugón, vuestra escena se verá pobre, plana y sin volúmen.

Este sería el resultado de un mapa de Quake renderizado sin ningún tipo de iluminación:

Veamos a continuación, el resultado de la misma escena con iluminación estática:

Podéis comprobar que la imagen iluminada, resulta mucho mas creible y realista.

Tipos de luces

Por su naturaleza y en líneas generales podemos describir tres bloques:

  • Luz ambiental: El nivel mínimo de luz que afecta por igual a todos los píxeles de la escena.
  • Luz estática: Iluminación que no modificará su posición en el espacio.
  • Luz dinámica: Iluminación que modificará su posición en el espacio.

Para simplificar, y con el permiso de artistas 3D, vamos a resumir q la luz ambiental en nuestro caso, viene determinado por el color real de la textura para cada pixel, es decir, la iluminación natural de nuestra escena «sin» iluminación extra, vendrá determinada por nuestra textura. En motores punteros y en renderizado de imágenes realistas, el tratamiento de la luz a ambiental será sutil pero clave para el resultado final.

Cuando hablamos de iluminación estática, nos referimos a luces, que podemos garantizar que no se mueven. Esto tiene ciertas ventajas. Supongamos que tenemos una luz estática que ilumina un mundo 3D estático. Esta luz, siempre incide de la misma forma sobre la misma geometría, ni la luz ni la geometría cambian. Esto nos permite pre-cachear el nivel de luz que incide en cada pixel de la geometría, lo que da un rendimiento estupendo.

Por último tenemos las luces dinámicas, que como su nombre indica, modifican su posición en el espacio y sobre las que no podemos pre-cachear apenas información del nivel de luz que afecta a cada pixel. En definitiva, habrá que calcular el nivel de luz que cada luz dinámica aporta a cada pixel en cada frame. El rendimiento de un sistema de iluminación dinámico es exigente para la CPU, asi que el rendimiento se verá afectado a medida que aumentemos la resolución y el número de luces en escena.

En artículos anteriores, hablamos de la rasterización y mapeado UV de los triángulos que componen una escena. En ellos aprendimos como averiguar el color que cada pixel de un triángulo debe tener en escena. Hemos por tanto, de continuar sobre este punto para modificar el color de ese pixel, en función de las luces que le afecten.

¿Qué es un color?

Podemos resumir que un color es un número. Estamos acostumbrados a ver estos números en formato hexadecimal, por ejemplo: 0xFFFFFF. A pocos se les escapa, que este valor, son las componentes RGB del color.

Quizás os suena obvio todo esto, pero se trata de enfatizar que podemos operar con los colores aritméticamente hablando como números que son. Es ésta aritmética con colores, la que hará el trabajo final. Con esto quiero decir que literalmente podréis sumar, restar y multiplicar colores, por ejemplo: 0xFFFF00 + 0x0000FF = 0xFFFFFF. En el caso de luces, habitualmente se multiplican colores por «factores» de intensidad (que aprenderemos a calcular) con rangos [0, 1].

Atributos de las luces

En líneas generales, los atributos más importantes de una luz, serán su color y su intensidad.

En función del tipo de emisor de luz que estémos implementando también habremos de tener en cuenta su dirección. Podemos diferenciar puntos de luz (light points), donde la luz se emite en todas las direcciones, de la luz emitida por un foco (spotlights), donde la intensidad recibida por cada pixel depende del ángulo de incidencia. 

Si tenéis curiosidad por todos los tipos de luces en acción, os recomiendo probarlas todas con Blender, que es bastante sencillo. Nosotros, vamos al grano!

Relación entre la luz y un punto

La relación final entre un píxel (punto 3d rasterizado) expuesto contra un punto de luz, viene determinada por su distancia. A mayor distancia, la incidencia de la luz será menor, pudiendo llegar a ser inexistente si se encuentra muy lejos y la luz es muy tenue. De forma contraria, si la luz se encuentra muy cerca, la influencia de color de la luz sobre el pixel, será muy alta o total. Hemos de ser capaces de determinar la intensidad que cada luz aporta a un píxel.

Pipeline

Cada engine 3D, estructura su pipeline como desea en función de sus necesidades. Podemos decir que nuestro rasterizador CPU, entra dentro de lo que se llama IMR (Inmediate Mode Rendering), en el que inmediatamente después de que un pixel ha hecho todos sus cálculos, es directamente enviado al framebuffer de vídeo. La imágen final es el resultado del rasterizador, todo de un «solo paso».

Brakeza3D, incluye dos pipelines para el tratamiento de los gráficos, una orientada a CPU y otra orientada a GPU. En enfoques orientados a GPUs, cada fase del rasterizador se limita a una tarea pequeña, generándose varias «capas» de la imagen, que mezclaremos al terminar en una fase final (merge stage). En una de estas fases se realizarían los cálculos de luz per-pixel.

Vamos a centrarnos en la pipeline estándar explicada en artículos anteriores, pensada para implementaciones sobre CPU. Por tanto nosotros, vamos a colocar nuestro sistema de iluminación inmediatamente después del texturizado UV a nivel de pixel, es decir, ampliaremos el código que ya tendríamos para el mapeado de texturas, concretamente nos situamos en el rasterizador, que es la parte que procesa cada píxel de un triángulo individualmente.

En el momento de procesar la luz sobre un pixel, ya dispondremos de las componentes x, y, z además del color proporcionado por la textura. Solo necesitaremos calcular la distancia del punto 3D que representa el pixel que estamos dibujando a el punto en el espacio en que se haya la luz. En definitiva, necesitamos calcular la distancia entre dos puntos en el espacio. Finalmente, usaremos esta distancia junto con el color de luz, su intensidad y dirección para calcular el color final. Mas tarde veremos los detalles.

Lightmaps

Una de las ventajas de las luces estáticas que iluminan geometrías estáticas, es su predictibilidad, es decir, podemos predecir la luz que incide en cada pixel al que llega y almacenar esta información previamente antes de usarla. La información que almacenaremos de cada pixel iluminado será la distancia hasta la luz, lo que dará como resultado una imágen de tipo degradado, es decir, sin interpretamos el valor de la distancia de cada pixel como un color, el resultado tendría degradados.

Este mapa, es denominado mapa de luz o lightmap. Quake fué el primer videojuego en implementar esta técnica para optimización, por allá en 1996.

A diferencia de las texturas UV, cada triángulo de la escena tendría asignado un único mapa de luz.

Este lightmap, podemos procesarlo literalmente como si de una textura UV se tratase. Se puede decir que la iluminación en base a lightmaps apenas supone un «mapeo UV» extra en tiempo de ejecución. Al final dispondremos de un color aportado por la textura de color y otro por la textura de lightmapping, los mezclaremos según nuestras necesidades.

Veamos un ejemplo:

Color aportado por la textura UV:

Color aportado por el lightmapping:

Mezcla de ambas:

Cabe mencionar que un lightmap, no tiene que tener las dimensiones exactas de la geometría. En el caso del quake, los lightmaps son de dimensiones muy pequeñas, siendo algunos de apenas 4×4 píxeles, este es el motivo por el que véis muy pixelado el lightmapping de la imagen superior (al cual el quake original aplica un filtro bilinear para paliar este efecto).

En la práctica, la única diferencia entre una luz estática y una dinámica, es que las dinámicas generan sus lightmaps en tiempo real. Esto quiere decir, que por cada pixel en la escena, las luces dinámicas calculan su intensidad y se mezclan, todo en tiempo real.

Matemáticas

Vamos a describir las herramientas que necesitamos para poder generar nuestros propios lightmaps y/o calcular las intensidades de luz en tiempo real para luces dinámicas

Distancia entre dos puntos en el espacio: Existe multitud de información en internet al respecto. Voy a ir al grano:

float Maths::distanteBetweenpoints(Vertex3D v1, Vertex3D v2)
{
return sqrtf(
(v2.x - v1.x) * (v2.x - v1.x) +
(v2.y - v1.y) * (v2.y - v1.y) +
(v2.z - v1.z) * (v2.z - v1.z)
);
}

Recordemos que si necesitábamos calcular la distancia entre dos puntos, es para finalmente, utilizarla para calcular la intensidad que la luz aporta al pixel.

Para esto, vamos a utilizar una fórmula estándar. Estas son sus variables que conforman una luz:

Uint32 color;

float kc = 1; // constante de atenuación
float kl = 0; // atenuación linear
float kq = 0; // atenuación cuadrática

Supongamos un punto de luz en el espacio, que llamaremos P,  cuyo color llamaremos Co, su intensidad de su luz, que llamaremos C y al punto Q en el espacio que estamos iluminando que se encuentra a una distancia de la luz que llamaremos d. La intensidad de la luz en Q vendrá determinada por:

C = (1 / kc + kl * d + kq * (d*d)) * Co;

La manera mas sencilla de entender las constantes, es verlas en funcionamiento, os invito a probar diferentes valores. Si queréis saber más sobre estas constantes podéis revisar este enlace de Valve.

En la imagen superior podéis ver el resultado de diferentes configuraciones de las constantes constante-linear-cuadrática.

Si por el contrario, estámos calculando la intensidad para un spotlight o foco de luz, debemos añadir la dirección de la luz a la intensidad. La fórmula del cálculo de intensidad varia ligeramente:

C = ( max[-R dot L, 0]^p / kc + kl * d + kq * (d*d)) * Co;

Donde R representa el vector dirección de la luz y L es un vector unitario en la dirección de Q a la luz. El exponente p, controla cuanto de concentrada está el foco de luz:

En la imagen superior, podéis observar el efecto de alterar el exponente en una luz de tipo spotlight.

El código que procesa la iluminación en Brakeza3D para un spotlight tiene el siguiente aspecto:

Uint32 Maths::mixColor(Uint32 color, float distance, LightPoint3D *lp, Vertex3D Q)
{

Vertex3D P = *lp->getPosition();
Vertex3D R = lp->AxisForward();

Vector3D L = Vector3D(P, Q);
Vertex3D Lv = L.normal();

const float min = R * Lv;

float p = 100;
float max = fmaxf(min, 0);
float pow = powf(max, p);

float intensity = pow / (lp->kc + lp->kl*distance + lp->kq * (distance * distance));

int r_light = (int) (Tools::getRed(lp->color) * intensity);
int g_light = (int) (Tools::getGreen(lp->color) * intensity);
int b_light = (int) (Tools::getBlue(lp->color) * intensity);

int r_ori = (int) (Tools::getRed(color) * ( 1 - intensity) );
int g_ori = (int) (Tools::getGreen(color) * ( 1 - intensity) );
int b_ori = (int) (Tools::getBlue(color) * ( 1 - intensity) );

Uint32 c = Tools::createRGB(
r_light + r_ori,
g_light + g_ori,
b_light + b_ori
);

return c;
}

Este código, forma parte de un proceso recursivo, que empieza con el color original de pixel proporcionado por la textura UV y en el que en cada iteracción, lo mezcla originando otro nuevo color, asi sucesivamente hasta que no haya mas luces.

Resumen

Nos hemos aproximado al mundo de la iluminación de gráficos por computadora. Hemos visto que su implementación apenas se sustenta en 2 fórmulas matemáticas y conceptos de sencilla comprensión.  En futuros artículos abordaremos el sombreado, que al contrario de lo que pueda parecer, es todavía mas sencillo.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *