|
XSight Ray Tracer: notas técnicas
Las siguientes líneas explican someramente las técnicas utilizadas para generar las imágenes mencionadas en la publicidad de nuestra nueva línea de cursos a distancia, y el alcance de las mismas. Hay dos imágenes grandes en el anuncio:
La segunda de ellas se generó mediante POV Ray, un potente y popular programa de generación de imágenes de dominio público. La primera, en cambio, ha sido creada con la aplicación explicada en el curso mencionado. Si es usted nuevo en este campo, creo que es bueno explicarle en qué consiste este tipo de aplicaciones, y hasta donde pueden llegar. Por lo tanto, hablaré por igual de méritos y limitaciones del algoritmo que sirve de esqueleto al curso. Recuerde que lo importante del proyecto es demostrar la aplicación de técnicas orientadas a objetos en C#.
RAY TRACING
La aplicación implementa una familia de técnicas agrupadas con el nombre de ray tracing. El algoritmo básico consiste en lanzar "rayos visuales" desde un ojo o cámara virtual, y calcular para cada rayo cuál sería el color percibido, teniendo en cuenta los objetos sobre los que incide, sus rebotes sobre superficies reflectantes, las fuentes de luz presentes, etcétera. Como todo adulto sabe (o debería saber) la naturaleza funciona al revés de esta descripción: son las fuentes de luz las que lanzan millones de billones de tontimillones de rayos por picosegundo. Muchos de ellos se pierden en la lejanía, o rebotan como balas perdidas entre los objetos de la escena para que al final sólo unos pocos elegidos arriben al ojo del observador. Naturalmente, es imposible simular directamente el comportamiento natural, y la técnica de ray tracing es una buena aproximación con resultados sorprendentes y en ocasiones incluso impactantes.
El ray tracing clásico sufre de varias limitaciones conocidas (aunque se han ideado paliativos para la mayoría de ellas). La principal: no se calcula la contribución a la iluminación global de las superficies difusas. Si usted se pone una camisa roja brillante y entra en una habitación blanca, además de ser un hortera y un incauto, al entrar en la habitación actuará como un foco difuso de luz rojiza... eso sí, un foco muy débil. La siguiente imagen ejemplifica otra limitación:
La bola de cristal ha concentrado parte de la luz incidente sobre el plano tangente a su base. El problema consiste en que, al concentrarse la luz, es imposible trazar el camino inverso de cada uno de los rayos incidentes (aunque existen soluciones estocásticas). Advierto, no obstante, que la imagen ha sido generada precisamente con el ray tracer antes mencionado, usando una técnica llamada photon mapping.
¿TIENE ALGO QUE VER CON OPEN GL O DIRECTX?
No soy un experto en OpenGL ni en DirectX (por desgracia), pero aunque estos sistemas también permiten la visualización de imágenes tridimensionales, las técnicas utilizadas son muy diferentes respecto al ray tracing. Para empezar, están diseñadas para la animación en tiempo real, y aunque cuentan con un soporte hardware cada vez más sofisticado, es también cierto que para lograr eficiencia, la calidad visual no es siquiera comparable a la del ray tracing, o hablando con propiedad, a la de aquellas otras técnicas cuyo objetivo principal es el "fotorrealismo".
La mayoría de estos sistemas de visualización en tiempo real utilizan un modelo de escena muy simple. Los objetos se representan como mallas de triángulos o polígonos. Para evitar las discontinuidades en objetos curvos, estos polígonos pueden almacenarse junto con los vectores normales correspondientes a cada vértice. Por supuesto, una escena diseñada con polígonos puede alimentar igualmente a un ray tracer, y precisamente esta es una opción que ofrecen muchos editores interactivos de escenas: para la edición y la previsualización, se utilizan técnicas sencillas, y luego, el artista tiene la posibilidad de utilizar un ray tracer para obtener un resultado final de calidad superior.
ANTIALIASING
Una cosa son las limitaciones del ray tracing, y otra las limitaciones razonables del proyecto de ejemplo. Veamos algunas de ellas:
Uno de los problemas con los que tiene que tratar cualquier software gráfico es la representación de los bordes de objetos mediante escalones. Esta es una manifestación de un problema más general, conocido en inglés como aliasing. No se trata sólo de bordes rasgados: el mal puede afectar a cualquier otro fenómeno periódico con frecuencias superiores a cierto límite.
Existen muchas soluciones para atenuar los efectos de este fenómeno inevitable, pero nuestra aplicación utiliza una de las más sencillas. En principio, si se quiere generar una imagen de 1000x1000 píxeles, podríamos generar inicialmente una imagen de 2000x2000, de manera que para cada píxel de la imagen final se generen cuatro píxeles en la imagen intermedia. Luego, estos puntos pueden promediarse en grupos de cuatro: la imagen resultante no "desperdicia" entonces toda la información de la imagen mayor. Está claro, sin embargo, que la generación consumiría hasta cuatro veces el tiempo necesario para una imagen sin retoques.
La técnica que hemos implementado inicialmente comienza generando la imagen con su resolución final. Para cada píxel, se analiza su diferencia respecto a los píxeles vecinos. Si supera cierto umbral, se generan cuatro muestras adicionales dentro del área del píxel inicial, y se promedian. La imagen de la derecha muestra los puntos que se muestrean inicialmente en negro; los puntos azules corresponden a las muestras adicionales. Como puede ver, una de las ventajas de este algoritmo consiste en que cada punto central comparte vecinos azules con sus vecinos en color negro. El peor de los casos ocurriría si el sobremuestreo fuese necesario para toda la imagen, y en ese caso, sólo se necesitarían el doble de puntos que en la imagen final. Como media, sin embargo, el sobremuestreo no será necesario para muchos puntos, en particular, para aquellos que forman parte de zonas uniformemente coloreadas.
Por supuesto, esta es la variante más sencilla, y hay muchas formas de mejorarla. Por una parte, se puede aumentar el número de puntos para las zonas sobremuestreadas. Ese incremento puede realizarse también mediante adaptación al contexto, recursivamente. La técnica más popular, sin embargo, puede que sea la conocida como jittering, que consiste en añadir algo de ruido controlado a la imagen original. Esta es, quizás, la técnica más efectiva, pero no debe utilizarse ingenuamente si las imágenes se van a utilizar en una secuencia de animación.
La imagen de la derecha, la que contiene tres barras a diferentes distancias de la cámara, muestra otra "sencilla" adición a nuestro modelo básico. Esta vez se trata de simular el comportamiento del foco de una cámara real. La barra de color cian se encuentra en el plano de enfoque de la escena, y se ve con nitidez (si obviamos los problemas de la conversión a JPEG), mientras que las otras dos barras se ven borrosas. En este caso, hemos proporcionado una implementación alternativa de la interfaz ISampler, que es la que controla cuántos rayos se disparan para cada píxel de la imagen final. El nuevo "sampler" puede elegir puntos de partida diferentes para cada rayo. Los puntos de partida se ubican aleatoriamente dentro de un área que corresponde a la superficie de la lente virtual.
Para esta técnica sí es necesario utilizar números aleatorios, por lo que he aprovechado para mezclar antialiasing con la simulación de foco en esta implementación particular. La imagen del ejemplo ha sido generada disparando 49 rayos por píxel. Creo que la calidad final es más que aceptable, y el nuevo algoritmo no supone una carga excesiva: esta imagen sólo ha tardado unos 10-12 segundos en ser generada.
Y si se fija bien, verá otra novedad: el color del fondo varía insensiblemente desde el negro a un tono muy oscuro de azul. Esto se debe a la nueva interfaz, IBackground, que permite elegir un color para el fondo en dependencia de la dirección de observación. En este ejemplo he usado una clase que especifica un gradiente de colores, pero es posible programar fondos más complejos con relativa facilidad. Por ejemplo, se puede programar un cielo diurno o nocturno con nubes. Tenga presente que, como las nubes se encuentran a una distancia considerable de la tierra, un desplazamiento dentro de la escena no afecta normalmente a la posición de éstas respecto al observador. Esta es una de las ventajas de contar con una interfaz del tipo mencionado.
REFLEXIONES, REBOTES Y ADC
El interés principal de la escena de ejemplo está en que las esferas se comportan como espejos casi perfectos, un comportamiento que el ray tracing modela con mucho éxito. Si observa la imagen con cuidado, verá cómo aumenta la complejidad de los efectos de la reflexión según la ubicación de cada esfera dentro de la escena. Por ejemplo, la esfera blanca primera según la diagonal que parte de la esquina superior izquierda, tiene un reflejo relativamente sencillo: la esfera amarilla con la que contacta, muestra una imagen especular de la esfera blanca, y sobre ese reflejo se superpone la sombra de la misma esfera blanca. Observe, sin embargo, las reflexiones recursivas entre la esfera azul central y la esfera azul inferior, más pequeña. Hay reflejos, sombras, reflejos de sombras, reflejos de reflejos...
En principio, para seguir la trayectoria de cada rayo habría que recorrer una estructura infinita. Sin embargo, la mayoría de los ray tracers aplican dos optimizaciones. Por una parte, el rayo se descarta a partir de cierto número de rebotes. En nuestro ejemplo, hemos utilizado 25 rebotes como máximo para cada rayo. Por otra parte, cada rebote disminuye la intensidad del rayo. A partir de cierto punto, el efecto de los rebotes restantes puede ignorarse. Esta técnica se conoce como adaptive depth control, o ADC.
En nuestro ejemplo, la intensidad mínima se eligió como la milésima parte de la luminosidad máxima posible. Estos son valores altos para el número de reflexiones y pequeños para el umbral ADC. Para escenas "normales" no es necesario forzar tanto la aplicación. En este caso, se logra una mayor calidad, con el correspondiente coste en eficiencia.
Hay una importante limitación en la versión actual del ray tracer. No se permiten materiales transparentes ni traslúcidos. Esta limitación permite una simplificación fundamental: no hace falta una pila para seguir la pista a cada rayo. A pesar de ello, es relativamente sencillo ampliar el núcleo del algoritmo para incluir transparencia refractiva: no la hemos incluido de momento para simplificar las explicaciones del curso.
MODELO DE ILUMINACION
La escena representada tiene cinco fuentes de luz puntuales1. Es importante advertirlo, porque esto quintuplica el tiempo de procesamiento. Se puede intentar adivinar la posición de los focos mediante la dirección de las sombras, o localizando ciertos zonas blancas brillantes y difusas en la superficie de las esferas. Estas zonas se generan mediante un truco: en pura teoría, una fuente de luz sólo podría adivinarse en una imagen generada con un ray tracer por medio de sus efectos indirectos. Si el rayo de luz no toca objeto alguno, la cámara no lo captará, incluso si el rayo vuela directamente hacia la lente. Se trata de una limitación del modelo usado por el algoritmo.
El truco empleado se denomina specular diffuse highlights, e intenta simular la dispersión de la luz entre difusa y especular que presentan muchas superficies. En esta versión del ray tracer, se utiliza un modelo muy simple: el modelo de Phong. Lo interesante es que se puede cambiar el modelo de iluminación proporcionando clases que deben implementar tipos de interfaces muy sencillos.
He simplificado inicialmente el modelo de reflexión. En la presente versión, la cantidad de luz que se refleja es independiente del ángulo de incidencia. En la vida real, sin embargo, muchos materiales muestran reflectividad variable, en dependencia del ángulo mencionado. ¿Ha intentado alguna vez peinarse ante una lámina de vidrio? Es complicado, porque la luz que llega al vidrio formando un ángulo de 90 grados con su superficie, casi no se refleja, mientras que ocurre todo lo contrario a medida que el ángulo disminuye. Existe una fórmula, debida al físico francés Fresnel, que permite calcular la reflectividad en función del ángulo de incidencia y del índice de refracción del material. La fórmula es también aplicable a metales, para los que se estipula un índice de refracción complejo.
Otro detalle: las esferas dan la impresión de estar pintadas con esmalte; la alternativa para objetos reflectantes sería darles aspecto metálico. La diferencia tiene que ver también con el modelo de Fresnel: los metales suelen colorear los reflejos con su propio color. Cuando se trata de cromo, estaño, mercurio o plata, cuyos colores propios casi siempre pueden representarse como tonos de gris, la diferencia no es tan notable como cuando la luz se refleja en oro, bronce o cobre. Para complicar las cosas, esta coloración depende también del ángulo de reflexión. La arquitectura de la aplicación permite añadir estas mejoras con suma facilidad.
TEXTURAS
¿Qué tipo de "ruido" es necesario para este tipo de aplicaciones? Resulta que el ruido blanco "normal" no nos vale de mucho: el ruido blanco visual es eso que vemos cuando sintonizamos un canal en la tele que no está transmitiendo (en efecto, es el ruido "poltergeist"). Esto se debe a que el color de la imagen cambia muy bruscamente de punto a punto... o dicho en jerga matemática, porque el ruido tiene un espectro de frecuencia uniforme. ¿Y que tiene de malo eso? Pues que no se corresponde con ningún modelo físico de material, así de simple.
El tipo de ruido más usado en la generación de imágenes se conoce como ruido de Perlin, en homenaje al apellido de su creador.
La imagen adyacente muestra dos aplicaciones del ruido sólido de Perlin. La más evidente es la textura de la piedra. Si observa el fondo, verá que puede pasar por un cielo azul levemente nublado.
La función de ruido para el cielo muestra algunos de los apaños que se suelen realizar en este área. Si hubiésemos usado directamente la salida de la función de ruido para interpolar entre el azul y el blanco, la mitad del cielo aparecería azul y la otra mitad blanca... y no quedaría demasiado natural. Como la función de ruido devuelve un valor entre 0.0 y 1.1, lo que he hecho es elevar el valor de retorno al cubo, asumiendo que el cero genera azul y el uno, el blanco. De este modo, la función caerá las más de las veces más cerca del azul que del blanco. Esta otra imagen es muy parecida.
GENERACION DE LA ESCENA
La escena en sí se "crea" o "define" con relativa facilidad. Nuestro sistema declara una serie de tipos de interfaz y de clases que ofrecen implementaciones alternativas para ellos. Por ejemplo, existe un tipo de interfaz ISampler que se encarga de calcular el color de un píxel aislado. La clase BasicSampler implementa dicha interfaz lanzando un solo rayo para cada píxel, y la clase derivada SuperSampler implementa el algoritmo ya explicado de sobremuestreo. Independientemente del método de muestreo, podemos elegir un "modelo" de cámara, mediante una clase que implemente la interfaz ICamera; las opciones actuales son OrthogonalCamera, que lanza rayos paralelos, y PerspectiveCamera, que simula una cámara de sombra. Los objetos geométricos, por su parte, deben implementar la interfaz IShape, y ya existen clases como Sphere, Box, Plane, Transform (para girar, trasladar o cambiar la escala a uno o varios objetos básicos) y Union.
Los "copos esféricos" se generan mediante un método recursivo basado en unos pocos pasos:
- En cada llamada se indica el número de "niveles" de la escena a generar.
- Primero, se genera una esfera con un radio predeterminado, un color que depende del nivel y con centro en el origen de coordenadas.
- Si el nivel es mayor que cero, a continuación se crean recursivamente escenas pertenecientes al nivel inferior, utilizando una fracción del radio original.
- Cada escena subordinada se "transforma" (con la ayuda de una matriz de rotación y una translación) para ubicarla de forma que su esfera central sea tangente a la esfera inicial.
En el ejemplo mostrado, se crean seis escenas subordinadas en cada nivel. Hay cuatro niveles en la imagen, por lo que el número total de esferas es de 1 + 6 + 36 + 216 = 259. Es cierto que algunas de estas esferas quedan en el interior de otras mayores. En cada paso, el radio de la esfera central se redujo dividiéndolo por 2.7. No hay ningún misterio en este factor: lo elegí por tanteo, buscando que la imagen final resultase interesante. Finalmente, se eligió una posición para la cámara, también por tanteo, y se rotó el conjunto de esferas buscando un ángulo de visión más o menos impactante.
EL LENGUAJE DE DESCRIPCION DE ESCENAS
Bien: ésta es la parte que no va a ir incluida en el curso, por su mayor complejidad y dependencia de herramientas externas; no obstante, he creído que resultaría interesante para muchos saber qué puede hacerse en esta dirección. He dedicado un par de días a añadir un compilador que genere escenas a partir de una sencilla descripción de los objetos involucrados, y aunque no se explique su funcionamiento en el curso, está claro que debe incluirse en éste a modo de herramienta para la puesta a punta.
Requisitos del lenguaje:
- Por una parte, no quería un lenguaje excesivamente complicado, para que su implementación no me robase demasiado tiempo. Llegué a pensar en XML, como dicta la moda... pero XML no ha sido diseñado para ser generado manualmente por humanos, aunque algunos todavía se empeñen en sostener tamaña tontería.
- El lenguaje debía ser totalmente extensible. Por ejemplo, el lenguaje de escenas de POV Ray tiene palabras reservadas como sphere, box, etc. En mi lenguaje, por el contrario, sphere sólo tendría sentido si "alguien" registrase una clase Sphere como implementación de objeto geométrico.
Me fijé entonces en qué debía generar el lenguaje: un script SDL (por Scene Description Language) debe construir un árbol (más bien un grafo) de objetos. Esto determinó mi decisión final: mi SDL debía ser homologable al subconjunto de un lenguaje típico empleado para la construcción de instancias de clases... con algún que otro añadido y algunas omisiones. ¿El resultado? Juzgue usted mismo; el siguiente guión describe la escena de los "copos esféricos":
sampler
focal(25, 0.0008, 0.1, 5);
camera
perspective([100,120,20], [110,120,100], [0,1,0], 75, 800, 600);
background
gradient(color black, color(0,0,0.2), [0,1,0.5]);
ambient
0.10;
lights
point([0,200,-100], color 0.10);
point([108,-50,80], color 0.10);
point([110,-50,81], color 0.10);
point([110,-50,79], color 0.10);
point([112,-50,80], color 0.10);
objects
set heavy(clr) = metal(clr, 0.75, 0.90);
set ball(rad, mat) = sphere([0,0,0], rad, mat);
set piece(rad, radp, radn, sat, mat) = union(
ball(rad, mat),
translate(radn,0,0,rotate(0,0,+90,sat)),
translate(radp,0,0,rotate(0,0,-90,sat)),
translate(0,radp,0,sat),
translate(0,radn,0,rotate(0,0,180,sat)),
translate(0,0,radn,rotate(-90,0,0,sat)),
translate(0,0,radp,rotate(+90,0,0,sat)));
set level4 = ball( 1.27, heavy(color seashell));
set level3 = piece( 3.43, 4.70, -4.70, level4, heavy(color goldenrod));
set level2 = piece( 9.26, 12.69, -12.69, level3, heavy(color royalblue));
set level1 = piece(25.00, 34.22, -34.22, level2, heavy(color midnightblue));
translate(110, 120, 100, rotate(10, 20, -10, level1));
end.
Sólo son palabras reservadas las que están destacadas en negritas. Focal, por ejemplo, es un alias para una clase FocalSampler registrada "de serie" dentro del compilador. Lo mismo ocurre con los nombres de "clases" de las secciones de cámara, fondo, luces y objetos. Metal es una clase registrada que implementa la interfaz IMaterial. Y los nombres de colores (SeaShell, Goldenrod, MidnightBlue...) son los nombres de colores registrados de .NET. Translate y Rotate son también clases que implementan IShape, aunque a primera vista no lo parezcan. De hecho, he registrado incluso una clase Dress que sirve para cambiar el material de un objeto ya existente. Eso sí: hay construcciones sintácticas especiales para tipos de uso frecuente; en particular, para vectores y colores.
[0, 0, 0] // Un vector
color 0.12 // Un color (tono de gris)
color 0.0, 1.0, 0.0 // Un color RGB (máximo: 1.0 para cada componente del color)
color SteelBlue // Un color "registrado" por .NET
Otro recurso llamativo es la posibilidad de definir "macros":
set piece(rad, radp, radn, sat, mat) = union(
...
Observe que, en este caso, al expandir la macro se pueden usar valores numéricos para algunos parámetros, como rad que indica el radio de la pieza central, pero también se pueden pasar objetos completos, como en el parámetro sat (es decir, la pieza satélite). En este momento, hay limitaciones "razonables": no existe la posibilidad de usar expresiones, y no se soportan macros recursivas. Quizás más adelante...
Para la implementación del compilador he reutilizado parte de la infraestructura del compilador de Freya. El analizador sintáctico y el lexical se alimentan con tablas generadas por GOLD Parser, traducidas a C# desde XML con la ayuda de una aplicación creada a propósito para el proyecto Freya.
Por cierto, si quiere ver una imagen curiosa, pulse sobre éste enlace (¿mandala?, ¿calendario azteca?, ¿descenso al Maelstrom?). Sorprendente, ¿no es cierto? La explicación es interesante: se me "quedó" la cámara dentro de una de las esferas (una de las azules pequeñas). Siempre hay un satélite que se queda dentro de la esfera alrededor de la cuál debería girar, y ese satélite amarillo interno es lo que se muestra en la imagen, con su correspondiente "sub-satélite" blanco. Recuerde, no obstante, que estamos hablando de esferas con superficies muy reflectantes. La imagen central rebota hasta el aburrimiento dentro de la superficie cóncava del interior de la esfera. Lo que es realmente interesante es que este proceso genera "ruido"... pero un ruido muy controlado. La causa del ruido son las inevitables pérdidas de precisión de las operaciones con valores reales. Y si se pregunta cómo podemos ver dentro de una esfera hueca no transparente, la respuesta está en la "luz ambiente": un resplandor uniforme al que le hemos asignado una intensidad de 0.15 en el script.
The ways of God in Nature, as in Providence, are not as our ways;
nor are the models that we frame any way commensurate to the vastness, profundity, and unsearchableness of His works, which have a depth in them greater than the well of Democritus.
Joseph Glanville
... Y, VOLVIENDO AL CURSO...
¿Se puede desarrollar un ray tracer profesional en C#? ¿Es adecuada la plataforma .NET para estas acrobacias? Vayamos por partes:
NO: evidentemente, la técnica de ray tracing es muy exigente con los recursos de la máquina; sobre todo, cuando se empiezan a introducir mejoras para superar las limitaciones iniciales de la misma. Está claro que lo ideal es programar una aplicación de este tipo utilizando algún lenguaje de programación que genere código nativo extremadamente eficiente. Ese lenguaje puede ser C, C++... o incluso Delphi, aunque algunos fanáticos del curly-curly no estén de acuerdo con esto último.
Y sin embargo: SI. Una escena simple puede generarse en un segundo o menos con la aplicación de ejemplo del curso. La imagen del ejemplo, de 1000x1000 píxeles con sobremuestreo, tardó inicialmente unos 45 minutos2 (dos minutos y poco para una resolución de 300x300). Al aplicar una técnica muy sencilla a las listas de objetos... ¡el tiempo se redujo a sólo 2 minutos! Tengo libros especializados que muestran escenas de complejidad similar... y para algunas de ellas se mencionan tiempos de generación de ocho horas, o incluso de varios días. Estamos hablando de principios de los noventa, claro, pero creo que la moraleja es clara. Lo que hace diez años podía llevarse un día entero, con el hardware actual puede terminarse en menos de una hora.
Pero no es ésta la principal justificación. Si el ray tracer de marras fuese un encargo comercial o un producto propio para la venta, hubiese empezado igual, desarrollando un prototipo en C#, para luego pasar en la etapa más apropiada a otro lenguaje con generación nativa, probablemente C++. La potencia y elegancia de C# permite localizar fallos importantes y mejoras al diseño, y ensayar nuevos algoritmos de acuerdo a las estadísticas recogidas durante el funcionamiento del prototipo.
Quizás más adelante, con el prototipo en un estado estable y con tiempo libre suficiente, pase esta aplicación a C++. Entonces podremos comparar la velocidad de funcionamiento antes y después de la conversión. Esta migración, además, no significa la expulsión de nuestro ray tracer del paraíso .NET. El núcleo del algoritmo puede convertirse en una DLL o en una clase COM que pueda ser reutilizada desde la CLR. Mejor aún: intuyo que será sencillo dejar la vía abierta a extensiones basadas en el modelo de objetos de .NET. Pero ya llegará ese momento...
1 Tres de las fuentes de luz iluminan la parte inferior de la imagen, muy cercanas entre sí. Este es un truco de modelado que permite crear sombras "suaves" para disimular la dificultad del ray tracing para tratar con fuentes de luz no puntuales. De todos modos, existen algoritmos estocásticos para simular luces con área no nula.
2 Debo reconocer que aproveché esos 45 minutos para grabar, con el mismo ordenador, algunos solos de órgano para una vieja composición musical que estoy retocando. La pieza es un instrumental titulado Overflow: Rondo for a computer crash... y no, no está inspirada en esa empresa de desarrollo de software que seguramente le ha venido a la mente.
|