Un pequeño raytracer
Hoy vengo a hablaros de un pequeño proyecto, ─otro─, referido a una de mis ancestrales aficiones. Digo ancestrales porque viene de muy atrás, de hace muchos años, de cuando el mundo era joven y los ingenuos humanos no se conectaban con sus ordenadores a Internet nada más encenderlos: había que encender el módem y escuchar una serie de pitidos (y no otra) antes de ello. De cuando la información la compartíamos en disquetes de forma habitual y el conocimiento lo adquiríamos de los libros. Éste va a ser un proyecto de porque sí, porque me apetece y porque no es necesario hacerlo, ni sirve para nada: el típico proyecto que me gusta para aprender más.
Quien me conoce de hace tiempo sabe de mi pasión con los trazadores de rayos. Algunos recordarán algún artículo publicado junto con el autor de YafRay1, en alguna jornada de Blendiberia2, aunque mi afición a este tipo de herramientas es muy anterior.
En la imagen muestro un libro que aún conservo y que aún recuerdo cómo lo compré allá por el 1996 o el 19973. Recuerdo que fue en La Casa del Libro, yo estaba de visita en Madrid, y sólo existían dos Casa del Libro: la original de Gran Vía y otra de baratillo frente a Cortilandia. Lo recuerdo porque me llamaron, de alguna manera, la atención. Llegué a las estanterías con el ánimo de mirar libros de informática. En aquel entonces estaba interesado más en programación y trasteaba con C/C++ y ensamblador. Recuerdo también que mirando entre los libros me llamó la atención el subtítulo del libro de arriba. Había tocado algo de AutoCAD, aunque no mucho. En aquella época, dicha herramienta, era 2D, dibujabas planos para imprimirlos en papel con un plotter y poco más. Lo del 3D me resultó curioso. Lo cogí de la estantería, miré la portada y me pareció muy prometedora: Una imagen resultona (impresionante en aquella época), «código fuente para generar imágenes», «incluye disquete»... lo abrí. Comencé a hojearlo casi página a página, tanto rato le eché que terminé sentado en el suelo, mirando las imágenes, ─algunas impresionantes─, las fórmulas matemáticas, los listados de código y tanto tiempo estuve allí sentado que finalmente un empleado se acercó a ver qué estaba haciendo. «¿Vas a comprarlo o te lo quieres aprender ahí sentado?», me preguntó. A punto estuve de tirarle el libro a la cabeza por distraerme, pero sonrió con la expresión de quien sabe que tiene la venta hecha. Pagué con tarjeta, no llevaba efectivo suficiente para pagar el libro y me fui. Recuerdo que había quedado con unos amigos y me pasé el tiempo deseando que acabara el fin de semana para irme a casa donde estaba mi ordenador para enchufarle mi disquete de «Pov-Ray 2.2» y probarlo.
Desde entonces ha llovido mucho, pero siempre estas herramientas siempre me han parecido casi mágicas. Sé cómo funcionan, entiendo los entresijos del funcionamiento y las matemáticas que los soportan. Incluso años después participé en un intento de hacer un raytracer cuyo objetivo era simular más perfectamente el funcionamiento de una cámara fotográfica. Había llegado a NicoDigital, un foro sobre Blender3D ya desaparecido. Recuerdo que aquel intento lo promovió Javier Belanche y quería llamarlo como su pintor favorito: vermeer. Incluso me envió un paquetón de fotocopias con artículos y capítulos de libros que había recolectado sobre cómo hacer un raytracer, la mayoría en inglés.
Entre unas cosas y otras el tema se enfrió, los tres que estábamos no teníamos muy claro cómo empezar: uno estudió psicología, otro bellas artes y ningún informático en el equipo, acabó con un intento muy fallido.
No fue el último. De vez en cuando, encuentro algún tutorial de cómo hacer un raytracer donde encuentras mucho pseudocódigo y algo de código, pero el poco código que hay no funciona, está obsoleto o te faltan librerías; las explicaciones no son muy claras con introducciones largas y farragosas plagadas de teoría y matemáticas que yo puedo obviar, aunque las repase de todos modos. Cuando he conseguido algo ha sido casi de casualidad y obteniendo un código poco flexible y extensible, más digno de la basura que de comenzar un proyecto serio.
Nuevo intento
Hace poco llegué a otra página web: https://raytracing.github.io/ donde presenta tres libros4. Después de ojearlos los tres (son muy cortitos) comencé por el primero: Ray Tracing in One Weekend.
En el título promete hacer un raytracer en un fin de semana... muy poco tiempo me parece. No digo que no haya alguien capaz de hacerlo, pero no creo que esté a mi alcance. Llevo un par de ratos leyendo y picando código, pero estoy convencido que lo del fin de semana será si picas código sin parar para dormir, ni comer, ni cagar.
El caso es que ya tengo algo picado, no mucho. Pero ha sido
prometedor: primera explicación, primer código metido y primera imagen
conseguida. Vale que aún no he trazado un sólo rayo pero me anima el
haber seguido las explicaciones y haber conseguido el resultado
esperado. Teniendo en cuenta además, que el código que el libro
propone es C++ y yo lo estoy haciendo en erlang
. ¿Por qué? Porque al
verlo me he imaginado lo que puede ser un proceso imagen
lanzando
procesos rayos
que se distribuyen por varias máquinas en distintos
nodos de una red y conseguir hacer un raytracer distribuido sin las
complicaciones de otros lenguajes.
En contra me tropiezo con el problema de convertir el código POO de C++ a código funcional de erlang.
El ejemplo conseguido hasta ahora
Vale, es poca cosa pero funcionó a la primera, a pesar de que a las explicaciones que da el «libro» le he hecho algunos cambios. Concretamente he separado la generación del gráfico, escribiéndolo directamente a un fichero, del contador que muestra el avance del render. En el libro, utiliza el flujo normal de salida para escribir el fichero y el flujo de error para indicar el avance5. Además, le he añadido un lanzador de nombre rerl, que viene a ser raytracerl más corto... y que lo he acortado también porque me imaginaba a Joaquín Reyes presentándolo al grito de raytraceeeerrllll imitando a cualquier informático famoso6 con acento manchego y no me resultaba serio.
El primer problema que me encontré es que al hacerlo en erlang
había
que lanzar la máquina virtual, cargar el módulo y llamar a la función
que dibuja la imagen. La solución: hacer un lanzador que lo haga por
nosotros en forma de escript
:
#!/usr/bin/env escript %% -*- erlang -*- %%! -smp enable -sname lanzador main([Nombre_Fichero]) -> imagen:dibujar(Nombre_Fichero); main([]) -> imagen:dibujar().
Como se ve, el módulo que lleva el cotarro se llama imagen
y tiene
una función que se llama dibujar
. Si se le pasa un nombre de
fichero, llama a imagen
pasándole ese nombre donde debe escribir el
resultado. En otras ocasiones, cuando hago un escript
, suelo
intentar controlar algún posible error, como que falte algún
parámetro. Sin embargo, en este caso, como es una herramienta de
lanzamiento para pruebas y cualquier fallo es importante, prefiero no
manejar error y que me informe de los detalles fallo que ha ocurrido.
Por otro lado, el código que genera la imagen que he mostrado más arriba es el siguiente:
-module(imagen). -define(ANCHO,256). -define(ALTO,256). -define(FICHERO,"imagen.ppm"). -export([ dibujar/0 , dibujar/1]). dibujar() -> dibujar(?FICHERO). dibujar(Fichero) -> Cabecera = io_lib:format("P3~n~p ~p~n255~n", [?ANCHO,?ALTO]), {ok,F} = file:open(Fichero, [write]), B = 63, Imagen = lists:map( fun(G) -> io:format("Faltan ~p filas \e[36D", [G]), lists:map( fun(R) -> io_lib:format("~p ~p ~p~n", [R,G,B]) end, lists:seq(0,255)) end, lists:seq(255,0,-1)), file:write(F, Cabecera ++ Imagen), file:close(F), io:format("\nFin.\n").
Muy sencillo de entender: el módulo imagen
está compuesto por dos
funciones con el mismo nombre, dibujar
, que puede llevar el
parámetro del nombre de fichero o no. En caso de no llevar, utiliza la
constante ?FICHERO
para llamar a la función con parámetro.
Por el nombre del fichero por defecto se puede ver que utilizamos el
formato de imagen ppm
, que es un formato de imagen definido mediante
texto ASCII y si no tienes muy claro cómo funciona, lo voy a explicar
más despacio. Veamos la cabecera:
P3 256 256 255
La cabecera que genera el programa se divide en las siguiente líneas7:
- Número mágico: en la familia de ficheros gráficos denominados como
portable pixel format PPM, los dos caracteres
P3
indican que es un fichero ASCII que contiene valores RGB. - Ancho y alto: en pixeles de la imagen. En este caso la imagen
tendrá \(256 \times 256\) es decir un cuadrado de
0
a255
píxeles de lado. - Valor máximo del color: en este caso el valor de cada componente
puede llegar hasta
255
, el mínimo siempre es0
.
A partir de ahí, en el ejemplo, cada punto será dibujado por una
combinación RGB tal que los componentes R
y G
se toman de dos
contadores obtenidos mediante lists:seq
y el valor B
se mantiene
constante en 63
. Luego crea una cadena por cada punto, uno por
línea, con la instrucción io_lib:format("~p ~p ~p~n", [R,G,B])
.
Al final, cuando tiene la cabecera y la imagen preparadas, las escribe en un fichero y después lo cierra guardando el contenido.
Simulando clases con erlang
Lo siguiente que hace el librillo es crearse algunas clases básicas:
en realidad una clase vec3
para almacenar y operar valores
representados por 3 valores individuales. Lo utiliza tanto para
guardar valores de vectores, propiamente dichos, como para guardar
colores. En erlang
podría utilizar tuplas con el formato {A,B,C}
y luego operar la tupla según el concepto que almacene. Sin embargo,
creo que me irá mejor no mezclar las churras y las merinas.
No lo he mirado, pero seguramente en el futuro, el autor, separará
esos conceptos derivando clases, por lo que voy desobedecer un poco la
línea argumental inicial del tutorial y voy crear los dos conceptos
separados. Podría, por ejemplo, crear tuplas del estilo
{color,{r,g,b}}
o {vec3,{x,y,z}}
, pero ésto es la definición de
un registro o record
. Como además supongo que esas definiciones las
necesitaré en todos los módulos que se irán creando, las pongo en un
fichero aparte que llamo registros.hrl
y que presenta el siguiente
contenido:
-record(vec3, {x,y,z}). -record(color, {r,g,b}).
La clase vec3
Como digo, el autor crea la clase vec3
que en mi caso, he decidido
separar en dos: vec3
y color
. En su código fuente, crea algunos
operadores para operar con ellos y también algunos alias como point3
y color
, definiendo después algunas funciones auxiliares más: las
típicas de sumar vectores o multiplicarlos, operar también con
escalares, etc. Mi módulo lo he dejado así:
-module(vec3). -include("registros.hrl"). -export([ nuevo/3 , suma/2 , dife/2 , mul/2 , divi/2 , cruz/2 , punto/2 , modulo_cuadrado/1 , modulo/1 , unidad/1]). nuevo(X,Y,Z) -> #vec3{x=X,y=Y,z=Z}. suma(V1,V2) -> nuevo(V1#vec3.x + V2#vec3.x, V1#vec3.y + V2#vec3.y, V1#vec3.z + V2#vec3.z). dife(V1,V2) -> nuevo(V1#vec3.x - V2#vec3.x, V1#vec3.y - V2#vec3.y, V1#vec3.z - V2#vec3.z). mul(Vector, X) -> nuevo(Vector#vec3.x * X, Vector#vec3.y * X, Vector#vec3.z * X). divi(Vector, X) -> mul(Vector, 1/X). %% Producto vectorial cruz(V1,V2) -> nuevo(V1#vec3.x * V2#vec3.x, V1#vec3.y * V2#vec3.y, V1#vec3.z * V2#vec3.z). %% Producto escalar punto(V1,V2) -> V1#vec3.x * V2#vec3.x + V1#vec3.y * V2#vec3.y + V1#vec3.z * V2#vec3.z. modulo_cuadrado(V) -> V#vec3.x*V#vec3.x + V#vec3.y*V#vec3.y + V#vec3.z*V#vec3.z. modulo(V) -> math:sqrt(modulo_cuadrado(V)). unidad(V) -> divi(V, modulo(V)).
Tengo que duplicar algo de código, aunque no todo, para el módulo
color
. Por lo menos lo que el libro define en la cabecera y la que
luego utiliza para escribir el color en el fichero. El módulo me ha
quedado así:
-module(color). -export([ nuevo/3 , escribe/1 , mul/2 , suma/2]). -include("registros.hrl"). nuevo(R,G,B) -> #color{r=R,g=G,b=B}. escribe(C) -> io_lib:format("~p ~p ~p~n", [round(255*C#color.r),round(255*C#color.g),round(255*C#color.b)]). mul(C,X) -> nuevo(C#color.r * X, C#color.g * X, C#color.b * X). suma(C1,C2) -> nuevo(C1#color.r + C2#color.r, C1#color.g + C2#color.g, C1#color.b + C2#color.b).
Por último, está el código del módulo imagen
, es decir, el código
que genera el resultado final utilizando los módulos8 ha quedado
así:
-module(imagen). -define(ANCHO,256). -define(ALTO,256). -define(FICHERO,"imagen.ppm"). -include("registros.hrl"). -export([ dibujar/0 , dibujar/1]). dibujar() -> dibujar(?FICHERO). dibujar(Fichero) -> Cabecera = io_lib:format("P3~n~p ~p~n255~n", [?ANCHO,?ALTO]), {ok,F} = file:open(Fichero, [write]), Imagen = lists:map( fun(J) -> io:format("Faltan ~p filas \e[36D", [J]), lists:map( fun(I) -> Color = color:nuevo(I/(?ANCHO - 1), J/(?ALTO - 1), 0.25), color:escribe(Color) end, lists:seq(0,255)) end, lists:seq(255,0,-1)), file:write(F, Cabecera ++ Imagen), file:close(F), io:format("\nFin.\n").
El código, de momento, no es nada concurrente, sigue el desarrollo y
las explicaciones del libro que están muy dirigidas por un lenguaje
POO como C++
. El resultado obtenido con éstos últimos cambios es el
mismo que mostré al principio, pero se le ha añadido una capa de
modularidad con objetos. Desde el punto de vista de erlang
seguramente, el proceso estrella podría ser imagen
que fuera
lanzando procesos color
o pixel
que calculen el valor para cada
punto de la imagen. De momento, no hay cálculos complejos y es todo
muy lineal, pero creo que puede ser una línea de desarrollo futuro. Ya
veremos por dónde continúa el libro y hacia dónde me obliga a tirar a
mí.
Otro problema sobrevenido es el tema de la compilación de los módulos.
Los cambios en este último procesamiento de la imagen me funcionaron
al tercer intento, no porque tuviera ningún error, sino porque había
módulos sin compilar. Necesito un Makefile
. Además está todo metido
en el mismo directorio, los fuentes y los .beam
y todo a mogollón.
La verdad es que es un poco farragoso. Para evitarlo he metido todo el
código fuente en un directorio src
y creado un Makefile
que lo
compila y lo guarda en un directorio ebin
:
.PHONY: clean compilar all DIR_SRC = ./src DIR_BIN = ./ebin LANZADOR = rerl SOURCES = vec3.erl \ imagen.erl \ color.erl \ $(LANZADOR) TARGETS = $(DIR_BIN)/vec3.beam \ $(DIR_BIN)/imagen.beam \ $(DIR_BIN)/color.beam \ $(DIR_BIN)/$(LANZADOR) compilar: $(DIR_BIN) $(TARGETS) clean: rm -rf $(DIR_BIN) $(DIR_BIN)/%.beam: $(DIR_SRC)/%.erl $(DIR_SRC)/registros.hrl erlc -o $(DIR_BIN) $< $(DIR_BIN)/$(LANZADOR): $(DIR_SRC)/$(LANZADOR) cp $(DIR_SRC)/$(LANZADOR) $(DIR_BIN)/$(LANZADOR) $(DIR_BIN): mkdir $(DIR_BIN) all: compilar
Esto del Makefile
y la organización de los directorios, no viene en
el libro, pero me parecía necesario para aclararme yo. De esta forma
sigo también las estructuras de directorios habituales en erlang
:
los fuentes en un directorio src
y los binarios listos para
ejecutarse en un directorio llamado ebin
.
Sólo recordaros, si alguien copia el Makefile
del código anterior
seguramente no os funcione. El pasar de texto a html
y de html
a
texto puede haber hecho desaparecer los tabuladores. Recordad que
make
utiliza tabuladores y no se pueden sustituir por espacios,
editar el fichero, borrad la indentación y hacedla con tabuladores.
Y creo que por hoy ya os he cansinado lo suficiente con mis cosas y proyectos de aprendizaje.
Conclusiones
Primera aproximación a un tutorial que promete explicar, por lo menos a priori, cada paso de forma clara y directa. No puedo afirmar si el código que escribe el autor es correcto y compila en todos los casos, porque estoy utilizando otro lenguaje para realizar los ejercicios que propone y tengo que escribir mi propio código. Recordad: en mi máquina funciona.
El utilizar erlang
y no C++
puede representar un problema
importante más adelante cuando se meta de lleno en el uso de clases y
otras características de la programación orientada a objetos que
tendré que adaptar a la programación funcional. Como ejercicio parece
un interesante juego intelectual. Al principio me preocupará más
entender cómo funciona todo el sistema y no tanto la eficiencia, sin
embargo, intentaré también ir preparando el código para optimizarlo.
El formato de salida del fichero se mantiene durante los tres libros, por lo que he podido ver. No es un formato muy eficiente, ocupa bastante en disco, pero es sencillo de escribir y de leer.
De momento es muy pronto para saber qué módulos se convertirán en el
futuro en procesos de erlang
para ser procesados de forma
concurrente. A bote pronto, creo que serán los píxeles de imagen y los
rayos que se envían para calcular su color. Pero aún no hemos llegado
a trazar ningún rayo... que, espero, lo haré en una próxima entrega
de estos apuntes.
Por último, ya disculparéis si utilizo el blog como mi libreta de notas. Alguien puede aburrirse con esto, pero quizá a otros les sirva de inspiración o incluso para resolver alguna duda.
Footnotes:
Ahora llamado Yafaray https://www.yafaray.org
Cuando empezamos a reunirnos unos pocos aficionados, antes de que se profesionalizara el evento.
La fecha no la recuerdo de forma exacta, lo demás sí.
No muy largos pero al parecer sí muy prácticos.
Es una solución ingeniosa, pero no me parece limpia.
Si te lo imaginas con Stallman es inquietante... habría que proponérselo a Joaquín Reyes.
Pueden ser cadenas separadas por espacios simplemente, pero he preferido dividirlas en líneas para visualizarlo mejor.
En realidad, de momento, sólo utiliza el módulo de color
, sin
embargo en el código en C++
es relevante, porque utiliza vec3
para
colores también.
Comentarios