Notxor tiene un blog

Defenestrando la vida

Rayos y centellas

2020-11-16

Ya tenía claro que no se puede satisfacer, ni caer bien, a todo el mundo. Y con esta serie de artículos sobre el raytracer hecho con erlang los hechos vuelven a confirmármelo. Me encuentro con gente que me pide, o me sugiere, dónde poner el énfasis en esta serie de artículos. Unos afirman, que el tema de las matemáticas no es interesante y que me centre en el código. Otros, al contrario, ponen su atención en el funcionamiento teórico del raytracer y me piden más explicaciones sobre el asunto. Por otro lado, hay quienes me dicen que las explicaciones matemáticas no pueden faltar y que el código es secundario y se puede obviar, sobre todo teniendo un repositorio donde los interesados pueden ir a mirarlo y, por último, hay quien quiere todas las explicaciones sobre el código y menos explicaciones teóricas.

Como no puedo conciliar todas esas peticiones en un mismo artículo, lo único que voy a hacer es llevar los temas más a mi manera y las partes que no le gusten al lector que se las salte... como el que lee una novela saltándose los párrafos gordos y leyendo sólo los diálogos, luego dirán que no se han enterado de nada. O dicho de otra manera: lo voy a escribir a mi manera, así que estás en tu derecho de leerlo a la tuya. Eso sí, voy a poner un poco más de atención y separar los temas de explicaciones teóricas y matemáticas del apartado de código y erlang. El que no esté interesado en la programación en ese lenguaje, se lo podrá saltar más fácilmente.

¿Cómo funciona un raytracer?

Ya lo expliqué, aunque en un solo párrafo y de forma rápida, más bien atropellada, porque pensé que a estas alturas el que más y el que menos ya lo sabría. Pero me habéis hecho preguntas que me hacen sospechar que no tenéis claro cómo funciona un raytracer. Por eso me he planteado explicarlo más despacio.

rayo-rebotado.png

Cuando hablaba, en el artículo anterior, de imitar la luz al revés me refería a que en la vida real, la luz que nos llega al ojo es una parte infinitesimal de la que parte de las fuentes de luz. Los fotones rebotan azarosamente sobre los objetos y algunos de ellos, muy pocos, llegan a nuestros ojos. El color lo marca la longitud de onda que llega a nuestros receptores, es decir: la que los objetos no hayan absorbido durante el camino de esa luz.

Programar un sistema que utilice el mismo procedimiento es prácticamente imposible: habría que simular miles de millones de rayos para seleccionar el color sólo de los que llegan al receptor. Quizá la computación cuántica llegue a tal nivel de proceso, pero de momento es inalcanzable.

Así pues lo que se hace es simular el «camino de vuelta» de los rayos que nos interesan: los que llegan al receptor. Sin duda, es un planteamiento más sencillo de acometer aunque no tan sencillo como parece explicado de primeras. La pregunta es: ¿de qué color tengo que pintar un pixel determinado? Parece una pregunta fácil, pero la respuesta es compleja.

tipos-de-rayo.png

El color de un pixel dependerá no sólo del material del objeto, acordaos de nuestra primera esfera dibujada:

imagen-esfera-plana.png

Como veis, la esfera es absolutamente plana, lo que nos permitiría percibir su forma sería la diferente orientación de su superficie con respecto a la fuente de luz; también dependería el color de la luz reflejada y refractada que hay en el «mundo» y de las sombras que puedan proyectar otros objetos. Repito: cuando enviamos desde la cámara unos rayos de color cuya finalidad es averiguar qué color tendrá el pixel determinado sabemos que ese color dependerá también del color de la luz reflejada por otros objetos a su alrededor y si ha alcanzado algún objeto transparente, también dependerá de la refracción que tenga ese rayo y del color del objeto visto a través y así un largo etcétera.

En la otra figura anterior he dibujado algunos de los rayos que un raytracer utiliza en sus cálculos:

  • Rayo de color es aquel que se lanza desde el punto de vista para obtener qué color tendrá el pixel de la imagen final. Depende del material del objeto con el que choque y de su orientación hacia la luz.
  • Rayo de reflexión modifica el color del punto alcanzado por el rayo de color. Sabemos que hay materiales que reflejan más su entorno que otros: sin llegar al extremo del espejo, compara un metal pulido con una goma de borrar.
  • Rayo de sombra se traza para averiguar la cantidad de luz que llega al punto alcanzado por el rayo. Si hay objetos que se interpongan a esa luz y si la superficie está o no orientada hacia la fuente.
  • Rayo de refracción es aquel que se traza cuando se alcanza un objeto transparente. Todos hemos observado cómo la luz se desvía al atravesar un vaso de agua y por tanto, el raytracer debe hacer lo mismo si quiere ser realista.

Pero todo esto son complicaciones que nos iremos encontrando cuando el sistema avance más de estos primeros balbuceos en los que nos encontramos.

Procesando los rayos

En la vida real, el fotón que llega a estimular la cámara o el ojo ha seguido un tortuoso camino, a pesar de que sabemos que la luz se transmite en línea recta ─en el espacio en el que nos movemos─. El asunto, llevado a las matemáticas, es que no hay una fórmula exacta o sencilla que trace todo ese camino entre ojo y fuente de luz y/o viceversa, todo lo que tenemos es la fórmula de una recta con origen y dirección. La solución es que podemos hacer el proceso por partes, siendo cada parte un rayo independiente. El punto en el espacio en el que el rayo encuentra un objeto, deberá lanzar al menos uno de sombra para calcular la cantidad de luz que recibe y si el material del objeto con el que ha chocado refleja mucho, o poco, su entorno otro, enviará otro de reflexión, y/o si es transparente tendrá que enviar otro de refracción, etc.

Al final, todo consiste en procesar rayos, muchos rayos, infinidad de rayos. Ahora imaginad un rayo que encuentra una superficie de un espejo enfrentado a otro espejo y empieza a rebotar. Ya se verá1 que hará falta limitar esos rebotes para no entrar en un cálculo infinito, pero aún no hemos llegado a ese extremo.

Refactorización de código

En estos días, desde el último artículo, no he avanzado en el contenido del libro y me he centrado más en aspectos de cómo traducir todo el sistema a erlang. El código ha sufrido una profunda refactorización. En realidad debería decir refactorizaciones porque en estos días he hecho dos. La primera no me gustó, porque no me aportaba nada de aprendizaje sobre erlang. Era montar una aplicación tocha, con supervisores y procesos apoyándome en OTP2. Llegué incluso a rehacer todo el código para dibujar nuestra esfera sin ningún problema y fue tan sencillo que me dije: no he aprendido nada.

Como soy tendente a complicarme yo sólo la vida, pensé: ¿qué pasa si lo hago a mi manera, a las bravas? ... por supuesto, en ello estoy, complicándome la vida. Entre otras cosas porque me tropecé con un error que se me hizo muy difícil de encontrar y me costó trazar nuestra esfera. Finalmente, con la ayuda del amigo deesix lo encontramos. Problemas por mi falta de fluidez con erlang. El resumen de la búsqueda del bug era que repasé centenares de veces la fórmulas y los cálculos que hacían las fórmulas. En algunos casos llegué a hacer los cálculos con papel y lápiz, ─y mucha goma de borrar─, para comprobar si un determinado valor estaba siendo calculado correctamente. Y sabía que estaba rondando la cámara virtual, pero tardé en encontrarlo. Al final, la ayuda del amigo Deesix fue determinante para encontrarlo y era un problema de asignación de valores a una variable.

Los libros que voy siguiendo3 a estas alturas comienzan a derivar clases y utilizar otras características de C++ que desde erlang no tiene sentido seguir, así pues, para estos problemas no son una referencia. Quería aprender, y de momento estoy aprendiendo un montón sobre cómo funciona erlang y lo potente que es. Sin embargo, estoy aprendiendo por la vía dura de darme cabezazos casi constantemente, más contra mi falta de habilidad que contra la complejidad del mismo sistema. El objetivo buscado era conseguir lanzar todos los rayos para la imagen básica... la repito aquí para saber a qué nos referimos:

imagen.png

Bueno, pues básicamente me planteé hacer tres procesos:

  • Imagen: que inicia el render, almacena la imagen en sus pasos intermedios y la escribe a disco al finalizar.
  • Cámara: lanza los rayos desde su punto de origen hacia el mundo que determina el color del rayo. Hay un proceso por rayo... en la imagen que estamos generando son \(400 \times 225 = 90.000\) procesos independientes.
  • Mundo: almacena una lista de los objetos que lo forman y se la envía a los procesos de los rayos para que comprueben si chocan con ellos.

Lo interesante de estas cosas de los procesos de erlang es que te dan automáticamente el procesado en paralelo, y eso también tuvo algunas consecuencias que mi lógica lineal no consiguió atrapar a la primera.

Por ejemplo, hasta ahora no hacía falta almacenar la información de cada pixel pues se procesaban uno tras otro y se escribían directamente en su correspondiente fichero. Tuve que hacer algunos cambios y ahora la imagen se guarda en memoria en un diccionario indexado por el valor del pixel. El código de la función principal que veníamos trabajando quedó así:

dibujar(Estado) ->
    io:format("Iniciando dibujo...~n"),

    %% Datos de la Imagen
    #state{ancho=Ancho,alto=Alto} = Estado,
    io:format("Establecidos datos de la imagen...~n"),

    %% Render
    lists:map(
      fun(J) ->
              io:format("."),
              lists:map(
                fun(I) ->
                        U = I / (Ancho - 1),
                        V = J / (Alto - 1),
                        UV = {U,V},
                        Pixel = {I,J},
                        camara ! {trazar_rayo,Pixel,UV}
                end,
                lists:seq(0, Ancho - 1))
      end,
      lists:seq(Alto - 1, 0, -1)),
    io:format("\nFinalizado\n").

Como veis el bucle lo único que calcula es el pixel y el UV para mandárselo a la cámara y se olvida de todo lo demás. Para volcar la imagen hice otra función que se llama aparte:

escribir_imagen(Estado) ->
    #state{ancho=Ancho,alto=Alto,lienzo=Lienzo} = Estado,
    Cabecera = io_lib:format("P3~n~p ~p~n255~n", [Ancho,Alto]),
    Imagen =
        lists:map(
          fun(J) ->
                  io:format("."),
                  lists:map(
                    fun(I) ->
                            Color = maps:get({I,J}, Lienzo, {1,0,1}),
                            rerl:escribe_color(Color)
                    end,
                    lists:seq(0, Ancho - 1))
          end,
          lists:seq(Alto - 1, 0, -1)),
    {ok, F} = file:open(Estado#state.fichero, [write]),
    file:write(F, Cabecera ++ Imagen),
    file:close(F),
    io:format("\nFinalizado\n").

De esta función, cabe llamar la atención al color por defecto que se empleará en el caso de que algún determinado pixel no se encuentre en el diccionario que guarda la imagen: {1,0,1}. Elegí poner un valor por defecto por dos razones:

  1. Pensé que se me perdían algunos rayos y me adelanté a que en imágenes más complejas no pudiera distinguir el negro de falta un pixel del negro generado por el cálculo. El ojo humano no es perfecto y darse cuenta de errores de pérdida de rayos por el color va a ser complicado... sobre todo si utilizas un color tan habitual como el negro.
  2. No hubiera puesto ningún valor por defecto si hubiera estado seguro de que se dibujaban todos los rayos.

Esto fue porque me llevé el primer susto cuando tenía ya todo preparado para que empezara a funcionar de manera paralela. Lancé el procedimiento y ese lanzamiento fue paralelo4. Obtuve una imagen tal que:

imagen-defecto.png

¿Cómo? ¿No ha dibujado nada?, pensé. Y empecé a repasar el código una y otra vez... si ésto debería funcionar. No encontrando el fallo le puse algunos mensajes de aviso que me informaban si el proceso pasaba por algunas determinadas funciones o no. Me di cuenta de que además esos mensajes llegaban en un orden casi aleatorio, para obtener una imagen tal que:

imagen-trozo.png

La conclusión fue sencilla después de que el lelo se diera cuenta del para y de que el pobre proceso mundo estaba trabajando como podía cuando los demás ya habían dado por concluida la jornada laboral. Los procesos cámara e imagen estaban en total reposo mientras el pobre mundo iba sacando el higadillo para poder terminar antes de que yo cortara el proceso creyéndolo acabado. La solución de momento es ponerle un contador a la cámara y que controle cuantos rayos se han lanzado. Cuando el proceso mundo le manda el color de un pixel a imagen, ésta informa a la cámara de que actualice el contador de rayos sueltos. De esta manera se obtuvo la imagen esperada:

imagen.png

En el futuro quizá haya que automatizar el proceso de generar la imagen de otra manera, teniendo claras las circunstancias en las que se puede dar por concluido el procesamiento, pero de momento, como el contador funciona y es simple de entender hasta para una mente tan obtusa como la mía, pues lo mantengo.

Viendo el chasco con este primer intento por paralelizar los cálculos, me decidí a convertirlo todo en procesos al más puro estilo erlang, hasta los rayos. En realidad, si miramos el código, el proceso del rayo está preparado para perpetuarse en un bucle, pero se mantiene sólo hasta haber escrito el pixel. Sin embargo, no estoy seguro, pero creo que será útil que se perpetúe durante un tiempo si ha de lanzar otros rayos desde él: rayos de sobra, o de reflexión, o de refracción o antialiasing y esperar a tener una respuesta con un color más preciso.

start(R, Pixel, UV) ->
    spawn(?MODULE, init, [#state{rayo=R,pixel=Pixel,uv=UV}]).

init(State) ->
    self() ! impacto,
    loop(State).

loop(State) ->
    receive
        impacto ->
            impacto(State, 0, ?INFINITO);
        _ ->
            loop(State)
    end.

%%-------------------------------------------------------------------------
%% Funciones privadas
%%-------------------------------------------------------------------------

color(Objeto, R, Tmin, Tmax) ->
    Hay_impacto = rerl_objeto:impacta(Objeto, {R,Tmin,Tmax}),
    case Hay_impacto of
        {true,Np} ->
            {X,Y,Z} = Np,                                  % normal en el punto
            rerl:mul({X+1,Y+1,Z+1}, 0.5);                  % usar coordenadas como {R,G,B}
        false ->
            {_,Y,_} = rerl:vector_unidad(R#rayo.dir),
            T = 0.5 * (Y + 1),
            rerl:suma(rerl:mul({1.0,1.0,1.0}, (1-T)),      % Del color blanco {1,1,1}
                      rerl:mul({0.5,0.7,1.0},  T));        % al color azul {0.5,07,1.0}
        _ ->
            {1,0,0}
    end.

impacto(Estado, Tmin, Tmax) ->
    mundo ! {self(), {get_objetos}},
    Objetos =
        receive
            {_, Respuesta} ->
                Respuesta
        end,
    #state{rayo=R,pixel=Pixel} = Estado,
    Impactos =
        lists:map(
          fun(Obj) ->
                  color(Obj, R, Tmin, Tmax)
          end,
          Objetos),
    Color = hd(Impactos),
    imagen ! {pixel_color,Pixel,Color}.

Cargar el mundo desde un archivo

Esta parte no viene en libro, pero ya puestos a tirar de características de erlang como el autor tira de las de C++ pues me dio por hacerle un pequeño cargador de mundos al sistema. Entre los mensajes que puede recibir el proceso rerl_mundo está el siguiente:

{cargar_mundo, Nombre_Fichero} ->
    {ok,Geoms} = file:consult(Nombre_Fichero),
    Pids = [nuevo_objeto(X) || X <- Geoms],
    loop(Pids);

El fichero que tengo preparado para la carga lo he llamado mundo.rerl y tiene el siguiente contenido:

{esfera,{0,0,-1},0.5}.
{esfera,{0,-100.5,-1},100}.

No está automatizado aún para que lo cargue desde línea de comandos, pero haciendo pruebas desde el lanzador funciona perfectamente y genera dos objetos esfera con las geometrías dadas. Pero ya lo veremos más adelante.

Además, también son independientes los procesos para los distintos objetos del mundo aunque el código aún sólo reconoce la esfera como geometría válida.

Pongo el código aquí de tres funciones para llamar la atención sobre algún punto interesante de cómo funciona erlang5:

%%-------------------------------------------------------------------------
%% Llamadas externas al proceso
%%-------------------------------------------------------------------------

loop(Estado) ->
    receive
        {set_geometria,Geometria} ->
            loop(Estado#estado{geometria=Geometria});
        {From, {impacta,R,Tmin,Tmax}} ->
            Respuesta = hay_impacto(Estado, R, Tmin, Tmax),
            From ! {self(),Respuesta},
            loop(Estado);
        terminar ->
            ok;
        _ ->
            loop(Estado)
    end.

impacta(From, {R,Tmin,Tmax}) ->
    From ! {self(),{impacta,R,Tmin,Tmax}},
    receive
        {From,Respuesta} ->
            Respuesta
    end.

%%-------------------------------------------------------------------------
%% Interno: cálculos de impacto con el objeto en su Geometría.
%%-------------------------------------------------------------------------

hay_impacto(Estado, R, _Tmin, _Tmax)
  when is_record(Estado#estado.geometria, esfera) ->
    #esfera{centro=Centro,radio=Radio} = Estado#estado.geometria,
    OC = rerl:resta(R#rayo.origen, Centro),
    A = rerl:modulo_cuadrado(R#rayo.dir),
    Medio_B = rerl:punto(OC, R#rayo.dir),
    C = rerl:modulo_cuadrado(OC) - (Radio * Radio),
    Discriminante = (Medio_B * Medio_B) - (A * C),
    T =
        if Discriminante < 0 ->
                -1;
           true ->
                Raiz = math:sqrt(Discriminante),
                ((-1*Medio_B) - Raiz) / A
        end,
    if T > 0 ->
            Punto = rerl:vector_unidad(rerl:resta(rerl:hacia(R, T), {0,0,-1})),
            {true,Punto};
       true ->
            false
    end;
hay_impacto(_,_,_,_) ->
    geometria_desconocida.

Primero quiero llamar la atención del tándem:

{From,{impacta,R,Tmin,Tmax}} ->
    Respuesta = hay_impacto(Estado, R, Tmin, Tmax),
    From ! {self(), Respuesta},
    loop(Estado);

y

impacta(From, {R,Tmin,Tmax}) ->
    From ! {self(),{impacta,R,Tmin,Tmax}},
    receive
        {From,Respuesta} ->
            Respuesta
    end.

La segunda es una llamada interna a la segunda de manera que desde fuera se puede emplear tanto la sintaxis:

Pid_objeto ! {self(),{impacta,R,Tmin,Tmax}}.

como

rerl_objeto:impacta(Pid_objeto, {R,Tmin,Tmax}}.

Ambas dos son accesibles para el código, pero la primera necesita ser capturada y analizada por el proceso que envía la petición mientras que la segunda devuelve esa información de forma directa, porque ya le ha hecho el proceso que debería hacer el llamante. Eso evita tener que capturar esa comunicación poniendo buzones receive cada dos por tres... con un uso inteligente de atoms puedes utilizar la misma función para varias operaciones como hace OTP con las llamadas a handle_call6, handle_cast6 y handle_info6.

Por último, en la función hay_impacto se lleva a cabo el equivalente al polimorfismo de la programación orientada a objetos. Como se puede ver la cabecera incluye una cláusula when que comprueba que la geometría que le llega es la de un registro esfera.

Otro aspecto que he reducido o cambiado en esta última refactorización es eliminar el registro vec3 para los puntos y vectores. Lo dejé como una simple tupla de tres elementos y así puedo utilizarlas como {X,Y,Z} o como {R,G,B}, si hace falta más adelante esa distinción ya se hará.

A todo ésto, iba haciendo avances en la programación, pero el error en los cálculos seguía y éste era el resultado:

imagen-descuadrada.png

Eso sí, lo hacía mucho más deprisa.

Admitir varios objetos en el render

El problema se arregló como dije, con ayuda. Me repasé las fórmulas sienes y sienes de veces sin encontrar el error, las deconstruí realizando los cálculos paso a paso y aún así la cámara seguía sin enfocarse. Cuando se encontró el error fue el momento para perseguir el objetivo inmediato que sigue el libro: diferenciar la cara interna de la externa de los objetos.

Además, también, contaros cómo se ha conseguido representar varios objetos en la escena, siguiendo el espíritu del libro, pero con un código que se parece al original aún menos de lo que venía pareciéndose hasta ahora.

El tema comienza con el registro impacto:

-record(impacto, { p            = {0,0,0}    % vec3:     Punto del impacto
                 , normal       = {0,1,0}    % vec3:     Normal en el punto del impacto
                 , t            = 0          % Num:      Factor de distancia del impacto
                 , cara_frontal = false}).   % Booleano: define si es impacto externo

Ese registro está preparado para devolver toda la información que necesita el sistema para calcular el color. Echo de menos el mismo registro de color en él, pero supongo que al no tener todavía ningún tipo de soporte para texturas no lo necesita.

Básicamente consiste en determinar si el rayo coincide con la superficie desde el interior o desde el exterior y cuál es la normal en ese punto:

punto-impacto.png

Para ver si está impactando en una cara desde el exterior se utiliza la función:

cara_frontal(Rayo, Impacto, Normal) ->
    Cara_Frontal = punto(Rayo#rayo.dir, Normal) < 0,
    N =
        case Cara_Frontal of
            true -> Normal;
            false -> mul(Normal, -1)
        end,
    Impacto#impacto{cara_frontal=Cara_Frontal,normal=N}.

Básicamente, como se puede ver es que comprueba el ángulo que forman el rayo y la normal en el punto. Si están enfrentados es una cara externa y en caso contrario interna (y le damos la vuelta a la normal).

También es importante saber en qué punto(s) se produce ese impacto. Hay que recordar que en nuestro modelo una esfera es sólo una fórmula matemática y por tanto esos puntos no tienen grosor. Además de que lo más probable es que si hay impacto con un objeto 3D no sea único sino que atraviese el objeto al menos en dos puntos7.

Ese registro se genera en el objeto, concretamente en la función hay_impacto/4:

hay_impacto(#esfera{centro=Centro,radio=Radio}, #rayo{origen=Origen,dir=Dir}=R, Tmin, Tmax) ->
    OC = rerl:resta(Origen, Centro),
    A = rerl:modulo_cuadrado(Dir),
    Medio_B = rerl:punto(OC, Dir),
    C = rerl:modulo_cuadrado(OC) - (Radio * Radio),
    Discriminante = (Medio_B * Medio_B) - (A * C),
    if
        Discriminante > 0 ->
            Raiz = math:sqrt(Discriminante),
            T1 = (-Medio_B - Raiz) / A,
            if
                T1 < Tmax andalso T1 > Tmin ->
                    T = T1,
                    P = rerl:hacia(R, T),
                    Normal = rerl:dividir(rerl:resta(P, Centro), Radio),
                    Impacto = #impacto{p=P,t=T,normal=Normal},
                    Exterior = rerl:cara_frontal(R, Impacto, Normal),
                    {true, Impacto#impacto{cara_frontal=Exterior}};
                true ->
                    %% Este choque debería ser tenido en cuenta como impacto de salida
                    %% entiendo que en el libro lo obvia porque aún no hay materiales
                    %% trasparentes y sólo lo tiene en cuenta si falla el anterior.
                    T2 = (-Medio_B + Raiz) / A,
                    if
                        T2 < Tmax andalso T2 > Tmin ->
                            T = T2,
                            P = rerl:hacia(R, T),
                            Normal = rerl:dividir(rerl:resta(P, Centro), Radio),
                            Impacto = #impacto{p=P,t=T,normal=Normal},
                            Exterior = rerl:cara_frontal(R, Impacto, Normal),
                            {true, Impacto#impacto{cara_frontal=Exterior}};
                        true ->
                            {false, #impacto{t=?INFINITO}}
                    end
            end;
        true ->
            {false, #impacto{t=?INFINITO}}
    end;
hay_impacto(_,_,_,_) ->
    geometria_desconocida.

Como se puede apreciar se generan impactos, bueno, en realidad se genera sólo uno, ─o ninguno─. En el caso de que no haya impacto se devuelve una tupla {false,#impacto{t=?INFINITO}}. Esto sirve, para que el rayo pueda ordenar por distancia los impactos que haya:

impacto(Estado, Tmin, Tmax) ->
    mundo ! {self(), {get_objetos}},
    Objetos =
        receive
            {_, Respuesta} ->
                Respuesta
        end,
    #state{rayo=R,pixel=Pixel} = Estado,
    %% Obtener todos los impactos del rayo con objetos
    Impactos =
        lists:map(
          fun(Obj) ->
                  rerl_objeto:impacta(Obj, {R,Tmin,Tmax})
          end,
          Objetos),
    %% Ordenarlos de más cercano a más lejano
    Iorden =
        lists:sort(
          fun(I,J) ->
                  {_,#impacto{t=T1}} = I,
                  {_,#impacto{t=T2}} = J,
                  T1 < T2
          end,
          Impactos),
    Color = color(Estado,hd(Iorden)),
    imagen ! {pixel_color,Pixel,Color}.

Esta función obtiene una lista de objetos en el mundo y establece si se cruza con ellos. Esos Impactos luego se ordenan en Iorden y se obtiene el Color más cercano.

De momento, también, ese color lo decide el rayo sin embargo, me parecería más lógico que lo decidiera el objeto o la textura.

La imagen obtenida es:

imagen-dos-esferas.png

Os recuerdo los datos con los que se generan ambas esferas:

{esfera,{0,0,-1},0.5}.
{esfera,{0,-100.5,-1},100}.

Nuestra esfera de siempre es la que tiene centro en {0,0,-1} y un radio 0.5. La otra se puede observar que tiene un radio 100 y se sitúa su centro en {0,-100.5,-1}. Es decir, esta segunda se sitúa justo debajo de nuestra esfera de siempre a -100.5 en la coordenada y. 100 de su propio radio y 0.5 de la otra. Por tanto, sabemos que ambas están situadas una encima de la otra y apenas se tocan en un solo punto.

Si vemos la esfera inferior de color verde mientras la pequeña la vemos azul es por el tema de las normales. Como sabéis se está utilizando como color el valor de la normal en el punto. Mientras a la pequeña la vemos desde un lateral orientado en el eje z, a la grande la vemos desde la parte superior, orientada con el eje y. Puesto que pasamos los valores de {x,y,z} directamente a valores {r,g,b} los orientados en y se mapean a g y los orientados a z se mapean a b. También podemos ver que la parte derecha de la esfera pequeña es más rojiza que la izquierda, puesto que en horizontal el componente que se alinea es x que se mapea a r.

La última refactorización

Después de todo lo anterior y como no terminaba de encontrar el punto a la paralelización de los procesos de render me decidí a dividir la imagen en teselas y hacer el dibujo por partes. Básicamente, he añadido dos archivos más al desarrollo rerl_renderer.erl y rerl_tesela.erl. La función del primero es controlar qué teselas hay que dibujar. Levanta tantos procesos tesela como se especifique por parámetro y éstos le van pidiendo las teselas que haya que dibujar, cuando un proceso finaliza una tesela pide la siguiente hasta que ya se han dibujado todas. Momento en que los procesos de dibujo van muriendo y cuando han muerto todos se escribe la imagen.

-module(rerl_renderer).

-include("registros.hrl").

-record(state, {teselas  = []       % Lista de teselas que procesar
              , procesos = 10       % Número de procesos por defecto
              , camara   = {}       % Datos de la cámara necesarios para calcular el pixel
              , finados  = 0}).     % Número de procesos finalizados

-export([start/3, init/1]).

start(Teselas,Procesos,Camara) ->
    Estado = #state{teselas=Teselas,procesos=Procesos,camara=Camara},
    spawn(rerl_renderer, init, [Estado]).

init(Estado) ->
    loop(Estado).

loop(Estado) ->
    receive
        {iniciar_render,Lado,Ix,Iy,Muestras} ->
            io:format("Iniciando dibujo...~n"),
            #state{procesos=Procesos,camara=Cam} = Estado,
            iniciar_procesos(Lado, {Ix,Iy}, Muestras, Procesos, Cam),
            loop(Estado);
        {From, nueva_tesela} ->
            {Respuesta,Mosaico} = devolver_nueva_tesela(Estado),
            From ! Respuesta,
            Nuevo_Estado = Estado#state{teselas=Mosaico},
            loop(Nuevo_Estado);
        proceso_finado ->
            Finados = Estado#state.finados + 1,
            if Finados =:= Estado#state.procesos ->
                    imagen ! escribir_imagen;
               true ->
                    loop(#state{finados=Finados})
            end;
        _ ->
            loop(Estado)
    end.

%%%-------------------------------------------------------------------------
%%% Funciones internas
%%%-------------------------------------------------------------------------

iniciar_procesos(Lado, {Ix,Iy}, Muestras, Procesos, Cam) ->
    lists:foreach(
      fun(_) ->
              P = rerl_tesela:start(Lado, {Ix,Iy}, Muestras, Cam, self()),
              P ! render_tesela
      end,
      lists:seq(1,Procesos)).

devolver_nueva_tesela(#state{teselas=Mosaico}) ->
    case Mosaico of
        [] ->
            {no_hay,[]};
        [H|T] ->
            {{ok,H}, T}
    end.

Como se puede apreciar por el código anterior es todo muy simple.

Por otro lado, todo el código para dibujar cada pixel ha pasado a trabajar en rerl_tesela.

-module(rerl_tesela).

-include("registros.hrl").

-record(state, {lado     = 25         % Tamaño de la tesela cuadrada
              , imagen   = {400,225}  % Tamaño de la imagen en pixeles
              , muestras = 10         % Número de muestras por pixel
              , camara   = {}         % Datos de la cámara necesarios para calcular el pixel
              , renderer = nil}).     % Pid del proceso controlador del render

-export([start/5, init/1]).

start(Lado, Imagen, Muestras, Cam, PidR) ->
    Estado = #state{lado=Lado,imagen=Imagen,muestras=Muestras,camara=Cam,renderer=PidR},
    spawn(rerl_tesela, init, [Estado]).

init(Estado) ->
    loop(Estado).

loop(Estado) ->
    receive
        render_tesela ->
            siguiente_tesela(Estado),
            loop(Estado);
        terminar ->
            ok;
        _ ->
            loop(Estado)
    end.

%%%===================================================================
%%% Funciones internas
%%%===================================================================

%%
%% Pide una tesela que procesar y si no hay informa al controlador de
%% render de que para su proceso.
%%
siguiente_tesela(#state{renderer=Rendr}=Estado) ->
    Rendr ! {self(), nueva_tesela},
    Tesela =
        receive
            Respuesta ->
                Respuesta
        end,
    case Tesela of
        no_hay ->
            Rendr ! proceso_finado,
            self() ! terminar;
        {ok, Vt} ->
            render_tesela(Estado, Vt)
    end.

%%
%% Recorre los pixeles dentro de la tesela para hacer el render
%%
render_tesela(#state{lado=Lado,imagen={Ix,Iy}}=Estado, {Tx,Ty}) ->
    %% convertir la posición de la tesela en pixeles de la imagen.
    Escala = (Lado - 1),
    Xmin = Tx * Lado,
    Xmax = min(Xmin + Escala, Ix),
    Ymin = Ty * Lado,
    Ymax = min(Ymin + Escala, Iy),
    lists:map(
      fun(Y) ->
              lists:map(
                fun(X) ->
                        procesa_pixel({X,Y}, Estado)
                end,
                lists:seq(Xmin, Xmax))
      end,
      lists:seq(Ymin,Ymax)),
    io:format("."),
    self() ! render_tesela.

%%%-------------------------------------------------------------------------
%%% Funciones de render
%%%-------------------------------------------------------------------------

%%
%% Calcula el color obtenido por un rayo particular
%%
color_rayo(Rayo) ->
    Impactos = rerl_mundo:llamada(mundo, {impacto,Rayo,0,?INFINITO}),
    Orden_impactos =
        lists:sort(
          fun(I,J) ->
                  {_,#impacto{t=T1}} = I,
                  {_,#impacto{t=T2}} = J,
                  T1 < T2
          end,
          Impactos),
    Impacto_Cercano = hd(Orden_impactos),
    case Impacto_Cercano of
        {true,Np} ->
            {X,Y,Z} = Np#impacto.normal,                   % normal en el punto
            rerl:mul({X+1,Y+1,Z+1}, 0.5);                  % usar coordenadas como {R,G,B}
        {false,_} ->
            #rayo{dir=Dir} = Rayo,
            {_,Y,_} = rerl:vector_unidad(Dir),
            T = 0.5 * (Y + 1),
            rerl:suma(rerl:mul({1.0,1.0,1.0}, (1-T)),      % Del color blanco {1,1,1}
                      rerl:mul({0.5,0.7,1.0},  T))         % al color azul {0.5,07,1.0}
    end.

%%
%% Calcula la dirección de un nuevo rayo. El origen siempre es el
%% origen de la cámara.
%%
lanza_rayo(Cam, U, V) ->
    {Origen, Eii, Horizontal, Vertical} = Cam,
    %% Eii + Hor*U + Ver*V - Origen
    Dir = rerl:resta(
            rerl:suma(
              rerl:suma(
                Eii,
                rerl:mul(Horizontal,U)),
              rerl:mul(Vertical, V)),
            Origen),
    Rayo = #rayo{origen=Origen,dir=Dir},
    color_rayo(Rayo).

%%
%% Envía tantos rayos como defina el valor de `Muestras` y los integra
%% calculandon el valor del pixel como suma de cada color obtenido
%% aplicando el factor 1/Muestras.
%%
toma_muestras(Pixel, _Estado, Color, 0) ->
    imagen ! {pixel_color,Pixel,Color};
toma_muestras(Pixel, #state{imagen={Ix,Iy},camara=Cam,muestras=Muestras}=Estado, Color, Contador) ->
    {I,J} = Pixel,
    U = (I + rerl:random()) / (Ix - 1),
    V = (J + rerl:random()) / (Iy - 1),
    Ci = lanza_rayo(Cam, U, V),
    Nuevo_Color = rerl:suma(Color, rerl:dividir(Ci, Muestras)),
    toma_muestras(Pixel, Estado, Nuevo_Color, Contador - 1).

%%
%% Llamada a la función recursiva para que procese cada pixel.
%%
procesa_pixel(Pixel,#state{muestras=Muestras}=Estado) ->
    toma_muestras(Pixel, Estado, {0.0,0.0,0.0}, Muestras).

Para mostrar cómo se va realizando el render por teselas adjunto una imagen detenida en medio del proceso:

imagen-teselas.png

Tiempos de ejecución

También le añadí un contador de tiempos a la imagen, desde el momento en se que lanza hasta el momento en que se guarda la imagen a archivo. Con este contador he conseguido averiguar algunas cosas interesantes sobre el procesado paralelo de la generación de imagen. Por ejemplo, lanzando 10 procesos y lanzando 100 he encontrado que el tiempo final del render no varía mucho. Ambos valores sobrepasan la capacidad de hilos que puede dar la máquina y lo que obtienes al final es un montón de procesos parados esperando su turno para ejecutarse. Eso sí, cuantos más procesos más memoria se gasta. No es muy importante con dichas cantidades, que pueden variar entre los 0,8Kb y 1,3Mb, pero cuando lanzas 100 procesos por pixel, puede llegar a desbordar la memoria y bloquear la máquina.

Tal y como va el sistema ahora, con 100 muestras (samples) por pixel, una imagen sencilla como la que está generando el proceso tarda, en mi máquina, que ya tiene sus añitos, alrededor de minuto y medio. Puede parecer mucho, sobre todo porque sin el refinado de las muestras tarda apenas un par de segundos. Sin embargo, por comparar veamos las dos imágenes juntas:

imagen-dos-esferas.png imagen-100-samples.png

Conclusiones

Ya sé que el artículo de hoy ha sido un poco ladrillo y parece que avanza poco en el objetivo. Pero... De momento tengo un sistema que funciona con procesos paralelos. No está afinado, todavía tengo que afinarlo más. Los planes, de momento, son eliminar los procesos de los objetos y el mundo para que los procesos de render por teselas no tengan que estar llamando a sólo unos pocos procesos desperdiciando un poco la paralelización del procedimiento. Es decir, que los datos sean accesibles para todos de manera directa sin estar enviando llamadas al mismo proceso creando un embudo o cuello de botella. Por otro lado, seguramente los procesos de imagen, cámara y controlador terminen unificados. Sin embargo, mis planes siguientes es avanzar un poco más en el tutorial del libro y abandonar un poco el de los procesos. No por olvidarlos sino por no saturarme con el cómo dejando de lado el qué. Seguramente haya algún avance más que se pueda llevar a cabo en la generación de imágenes para ir, poco a poco, avanzando también en el cómo más adelante. Además, conociendo mejor el sistema y las necesidades que irá generando, y otras que me gustaría ir probando: como por ejemplo, si consigo que el proceso paralelo se pueda distribuir por una red de ordenadores de forma sencilla y aumentar así la velocidad en la que se pueden realizar las imágenes.

De ahí a montar una granja de render hay un abismo, pero sería uno de los objetivos que me gustaría aunque no sea más que esbozar y poder tener dos o tres máquinas generando la misma imagen y aprender cómo se puede controlar un proceso distribuido como ese.

Me gustaría también, en un futuro, toquetear el tema de los diferentes ficheros de entrada. Hacer, por ejemplo, que pudiera interpretar diferentes datos que provengan de, por ejemplo, povray, renderman, vray, etc.; además de su propio sistema. Y, por último, también me gustaría explorar la vía de hacer algún plugin para Blender3D o, aprovechando que chufla también en erlang, para Wings3d... pero para estas cosas, primero hay que terminar lo básico.

Nota al pie de página:

1

Espero porque no lo he mirado, pero lo habitual en todos los raytracers que conozco es así.

2

Una librería que proporciona aplicaciones estables y escalables de una manera sencilla.

3

Ya los enlacé en los otros artículos, pero lo vuelvo a hacer aquí https://raytracing.github.io/

4

En ambos sentidos con una sola palabra, paralelo, o con dos: para lelo.

5

Y también para no olvidarme de algunas cosas que me han hecho darme muchos cabezazos contra el muro de la incomprensión de una mente más inclinada a los procesos lineales.

6

call son llamadas que esperan que se devuelva un valor, cast es una llamada asíncrona e info son eventos que llegan al proceso, como una conexión TCP o el evento de un timer.

7

Digo al menos dos porque aunque puede haber sólo 1 si el rayo es tangente al objeto, lo más probable es que sean dos. Además en figuras más complejas como los toroides o modelos poligonales, la trayectoria puede encontrar la superficie del objeto más de dos veces.

Categoría: raytrace erlang

Comentarios

Debido a algunos ataques mailintencionados a través de la herramienta de comentarios, he decidido no proporcionar dicha opción en el Blog. Si alguien quiere comentar algo, me puede encontrar en Mastodon y en Diaspora con el nick de Notxor.

También se ha abierto un grupo en Telegram, para usuarios de esa red.

Disculpen las molestias.