Notxor tiene un blog

Defenestrando la vida

Cámara

2020-12-06
imagen-final.png

Reorganización del código

Desde la última entrega de la serie he modificado algunas cosas del pequeño raytracer. Hasta ahora venía lanzando procesos independientes para imagen y camara, haciéndolo por separado desde el lanzador que había metido con calzador en la librería general del raytracer.

Lo que he hecho es utilizar sólamente el proceso camara desde la definición de la escena y que sea éste proceso el que levante los otros procesos auxiliares como imagen o renderer. Así pues, en el fichero de escena, a partir de ahora, sólo encontraremos los parámetros de configuración de la imagen, la cámara y los objetos1 del mundo.

El formato ppm

Además, en los últimos días he cambiado el formato de salida. Hasta ahora venía guardándose el fichero ppm como P3 (ASCII) al formato binario P6. Es básicamente lo mismo, pero ocupa bastante menos y se escribe bastante más rápido.

Explico un poco más cómo funciona ésto del formato ppm porque hay quien ha mostrado curiosidad por él y por qué lo utiliza el libro. No estoy seguro de saber por qué lo usa el autor, sin embargo parece ser el formato mejor pensado, quizá, para ser entendido por humanos. Por otro lado, debería referirme a dicho formato como PNM o Portable Anymap Format, que engloba a varios tipos de fichero cada uno con su extensión:

Tipo ASCII raw Ext. Colores
Portable Bitmap P1 P4 .pbm 0-1 (Blanco y Negro)
Portable Graymap P2 P5 .pgm 0-255 y 0-65535 (Escala de Grises)
Portable Pixmap P3 P6 .ppm 0-255 por cada canal: R,G,B.2

Estos ficheros cuentan con una cabecera en ASCII con el tipo de fichero seguido con hasta tres valores numéricos y separado de los datos por un salto de línea:

Cabecera Significado
P1..P6 Tipo de fichero
"#" Comentario encabezado por #
1 Ancho de la imagen
2 Alto de la imagen
3 Valor máximo del pixel
"\n" Separación de cabecera y datos

Estos valores no tienen por qué estar escritos uno en cada línea. El único valor que debe estar en una línea es el comentario, en el caso de que lo haya.

Por poner un ejemplo de cómo se ve un fichero ASCII:

P2
# Shows the word "FEEP" (example from Netpbm man page on PGM)
24 7
15
0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0
0  3  3  3  3  0  0  7  7  7  7  0  0 11 11 11 11  0  0 15 15 15 15  0
0  3  0  0  0  0  0  7  0  0  0  0  0 11  0  0  0  0  0 15  0  0 15  0
0  3  3  3  0  0  0  7  7  7  0  0  0 11 11 11  0  0  0 15 15 15 15  0
0  3  0  0  0  0  0  7  0  0  0  0  0 11  0  0  0  0  0 15  0  0  0  0
0  3  0  0  0  0  0  7  7  7  7  0  0 11 11 11 11  0  0 15  0  0  0  0
0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0
feep.png
Figura 2: Imagen magnificada

Estos son los formatos básicos. Hay otro que se marca con P7 que permite la utilización del canal transparente. Dicho de otro modo, su modelo de color es RGBA. Nos referimos a este tipo, para englobarlo con los anteriores como PAM (Portable Arbitrary Map).

Cabecera Valor Significado
P7   número mágico
WIDTH Número ancho de la imagen
HEIGHT Número alto de la imagen
DEPHT Número profundidad de color
MAXVAL Número valor máximo
TUPLTYPE Cadena tipo de imagen
ENDHDR   Final de la cabecera

La mayoría de estos valores no necesitan explicación, sin embargo, TUPLTYPE implica algunas consideraciones más. Este formato no tiene versión ASCII legible por humanos y puede englobar a todos los formatos anteriores según el valor de ese parámetro:

TUPLTYPE MAXVAL DEPTH Comentario
BLACKANDWHITE 1 1 Equivalente a un pbm tipo P4
GRAYSCALE 2..65535 1 Equivalente a un pgm tipo P5
RGB 1..65535 3 Equivalente a un ppm tipo P6
BLACKANDWHITE_ALPHA 1 2 2 bytes por pixel
GRAYSCALE_ALPHA 2..65535 2  
RGB_ALPHA 1..65535 4  

En todos los casos, cuando MAXVAL > 255 se utilizan 2 bytes por canal.

En el raytracer utilizo el formato de fichero P6. La cabecera de la imagen final que he puesto es: P6 600 400 255, un salto de línea y luego los datos en binario. La misma cabecera para un fichero en esta última versión sería:

P7
WIDTH 600
HEIGHT 400
DEPTH 3
MAXVAL 255
TUPLTYPE RGB
ENDHDR

A continuación se encontrarían los datos de los pixeles, de la misma manera que en nuestra imagen P6. La única diferencia entre ambos fichero serían las respectivas cabeceras.

Como dije antes, venía utilizando el modo P3 como formato de fichero, pero el que sea legible por humanos no aporta mucho al fichero más allá de necesitar bastante más espacio para almacenarse. El tema es que, puesto que no vamos a hacer modificaciones manuales a la imagen, podemos utilizar su formato raw o binario. Para ello he modificado el código de escritura del archivo gráfico de la siguiente manera:

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

escribe_color({R,G,B}) ->
    [round(255 * rerl:clamp(R, 0.0, 0.9999)),
     round(255 * rerl:clamp(G, 0.0, 0.9999)),
     round(255 * rerl:clamp(B, 0.0, 0.9999))].

Como se puede apreciar, lo que ha cambiado es que ya no se convierte el color a una cadena ASCII, lo demás continúa igual. Salvo que la función escribe_color/1 ha pasado de la librería rerl a ser parte del módulo imagen pues sólo se la llama desde allí.

No descarto, en un futuro, modificar la salida al modo P7 y que se pueda seleccionar el tipo de salida del raytracer desde el blanco y negro hasta el color completo con, o sin, canal alfa.

Puesto que ya tenemos nuestra cámara generándonos las imágenes en un formato raw3 ahora nos faltan algunos otros detalles para fabricarnos nuestra cámara virtual.

Cámara

El esquema más sencillo de nuestra cámara está representado por un punto en el espacio y un lienzo en el que dibujar:

camara.png
Figura 3: Representación esquemática de nuestra cámara

En nuestra cámara podemos observar que el ángulo de visión \(\theta\) es equivalente a \(2h\) y por tanto, para calcular \(h\) podemos hacer \(h=tan(\frac{\theta}{2})\) que más adelante nos hará falta.

De momento, lo que más falta nos hace para representar una cámara independiente es que la podemos mover y apuntar mirando en una dirección determinada, que la podamos inclinar en cualquier dirección del espacio 3D. La alternativa sería dejar la cámara fija y mover el mundo para situarlo frente a ella, que es lo que veníamos haciendo.

Hasta ahora, para resumir un poco lo que era nuestra cámara, situábamos su origen en el punto {0,0,0}; apuntaba, o miraba, en la dirección {0,0,-1} y su inclinación era el vector {0,1,0}. Sin embargo, queremos hacer que nuestra cámara se pueda colocar en cualquier punto del espacio, mirando a cualquier otro punto del mismo y poder inclinarla en cualquier dirección. Por tanto, hay que cambiar el sistema de referencia a algo como:

camara-lookat.png

Hay que remarcar que aunque no se aprecie de manera exacta en la figura tanto Vup como v y w coinciden en el mismo plano. Si el valor Vup es {0,1,0} el plano al que me refiero será vertical y nuestra cámara hará unas imágenes horizontales perfectas, pero si lo inclinamos 45º a la izquierda estableciéndolo a {-1,1,0} obtendremos una imagen tal que:

imagen-inclinada.png

Código de la cámara

Muy bonito todo pero ¿cómo ha quedado el código? Ya me perdonaréis si me enrollo y no pongo una sola fórmula ni una sola línea de código. Así que allá va un poco de código. Primero comento que he creado un registro cámara para los valores básicos:

-record(camara, { origen = {0,0,0}             % Posición de la cámara
                , mira   = {0,0,-1}            % Punto al que mira
                , sup    = {0,1,0}             % Orientación
                , angulo = 80}).               % Ángulo del campo de visión

Comentar sólamente que el valor del ángulo por defecto es de 80º para el campo de visión vertical. Como la elección de ese valor por defecto no es aleatoria y necesita un poco más de explicación quiero sólo remarcar el hecho y pedir paciencia, porque el porqué vendrá explicado más adelante.

Para lanzar los rayos que generan la imagen, primero necesitamos el punto desde el que debemos hacerlo (origen). También necesitamos saber en qué dirección se encuentra el lienzo (mira), hacia dónde está mirando la cámara. Por último, también necesitamos saber la verticalidad de la cámara (arriba).

El registro nos sirve también para poder tener valores por defecto que funcionan en la mayor parte de los casos. De esta manera en el fichero de la escena seguramente nos sobre con pasar a la función start de la cámara, los parámetros que cambien: generalmente, los puntos origen y mira.

Dicha función queda de la siguiente manera:

start(#camara{origen=Origen,mira=MiraA,sup=VArriba,angulo=CV},
      #config{ratio=Ratio,ancho=Ix,muestras=Muestras,profun=Profun,tesela=Tesela,procesos=Procesos}=Config) ->
    io:format("Estableciendo datos de la imagen...~n"),
    Pid = rerl_imagen:start(Config),
    register(imagen, Pid),

    io:format("Estableciendo datos de la cámara...~n"),
    Theta = rerl:grados_a_radianes(CV),        % Calcular el ángulo en radianes del campo de visión
    H = math:tan(Theta / 2),
    Viewport_Alto = 2.0 * H,                            % Calcular el factor de altura de la imagen
    Viewport_Ancho = Viewport_Alto * Ratio,

    W = rerl:vector_unidad(rerl:resta(Origen, MiraA)),
    U = rerl:vector_unidad(rerl:cruz(VArriba, W)),
    V = rerl:cruz(W, U),

    Iy = round(Ix / Ratio),
    Horizontal = rerl:mul(U, Viewport_Ancho),
    Vertical = rerl:mul(V, Viewport_Alto),
    %% Eii = Origen - Viewport_Ancho/2 - Viewport_Alto/2 - {0,0,Focal}
    Medio_H = rerl:dividir(Horizontal, 2),
    Medio_V = rerl:dividir(Vertical, 2),
    Eii = rerl:resta(
            rerl:resta(
              rerl:resta(Origen, Medio_H), Medio_V),
            W),
    Estado = #state{origen=Origen
                  , alto=Viewport_Alto
                  , ancho=Viewport_Ancho
                  , ix=Ix
                  , iy=Iy
                  , ratio=Ratio
                  , horizontal=Horizontal
                  , vertical=Vertical
                  , eii=Eii
                  , muestras=Muestras
                  , profundidad=Profun
                  , tesela=Tesela
                  , procesos=Procesos},
    spawn(rerl_camara, init, [Estado]).

El cambio no es muy pronunciado: como se puede apreciar es que se calcula U y V en base al ángulo del campo de visión vertical.

La imagen conseguida con un fichero escena tal que:

escena() ->
    Config =
        #config{
           nombre = "imagen.ppm",          % Nombre del fichero de imagen generado
           ratio = 16 / 9,                 % Ratio ancho/alto de la imagen
           ancho = 400,                    % Ancho en puntos de la imagen
           muestras = 100,                 % Nº de muestras por pixel
           profun = 50,                    % Número de «rebotes» por rayo.
           tesela = 25,                    % Tamaño del lado de la tesela en puntos
           procesos = 10},                 % Número de procesos de cálculo

    io:format("Iniciando la cámara...~n"),
    O = {-2,2,1},                          % Origen de la cámara
    MiraA = {0,0,-1},
    CV = 90,                               % Campo de visión vertical (ángulo en grados)
    Camara = #camara{origen=O,mira=MiraA,angulo=CV},
    Pid_camara = rerl_camara:start(Camara, Config),
    register(camara, Pid_camara),

    %% Materiales de la escena
    Material_Suelo = #material{tipo=difuso,albedo={0.2,0.2,0.0}},
    Difuso = #material{tipo=difuso,albedo={1.0,0.0,0.0}},
    Cristal = #material{tipo=transparente,albedo={1.0,1.0,1.0},ir=1.5},
    Metal = #material{tipo=reflexion,albedo={0.8,0.6,0.2},rugoso=0.3},

    %% Esferas: geometrías de la escena
    Esfera_Suelo = #esfera{centro={0,-100.5,-1},radio=100},
    Esfera_Central = #esfera{centro={0,0,-1},radio=0.5},
    Esfera_Izda = #esfera{centro={-1.0,0.0,-1.0},radio=0.5},
    Esfera_Dcha = #esfera{centro={1.0,0.0,-1.0},radio=0.5},

    %% Montar los objetos para el render, cada geometría con su material
    Objs = [#objeto{geom=Esfera_Central,material=Difuso}
          , #objeto{geom=Esfera_Suelo,material=Material_Suelo}
          , #objeto{geom=Esfera_Izda,material=Cristal}
          , #objeto{geom=Esfera_Dcha,material=Metal}],

    %% Tirar el render
    camara ! {render,Objs}.

Genera la siguiente imagen:

imagen-camara-movida.png

Sin embargo, si modificamos el ángulo a 20:

imagen-zoom.png

Como se puede apreciar a ángulos de visión más estrechos, la imagen se acerca como en un teleobjetivo. Para ángulos grandes la imagen se alejará como en un gran angular. Por ejemplo, con un valor de 160º, que sería casi un angular ojo de pez, la imagen se vería así:

imagen-ojo-pez.png

Como observaréis, los grandes angulares distorsionan y curvan los objetos. La esfera inferior aparece con lados casi rectilíneos no como se esperan ver en una esfera.

¿Por qué cambia tanto la imagen cambiando sólo el ángulo del campo de visión? Dejadme un momento que explico un poco mejor cómo funciona una cámara fotográfica.

Cámara fotográfica

Al ojo humano podemos calcularle un ángulo de visión, o campo visual, de 130º en horizontal y 80º en vertical4.

Para los cálculos vamos a fijarnos en el siguiente esquema:

esquema-lente.png
Figura 9: Esquema de una lente simple.

En una lente simple, no hay sensores como en las cámaras para tener como referencia su tamaño, puesto que las distancias focales y cualquier otro parámetro estará calculado en función a él. Resulta un poco difícil explicar, por qué los angulares en la fotografía se miden en mm o por qué decimos que un objetivo de 50mm es el angular normal. Para ello tengo que cansinaros un poco más con las matemáticas.

Vamos a darle un poco al cálculo del ángulo de visión en función de la distancia focal y el tamaño del sensor (el normal es 35mm):

\[ \theta = 2 arctan \frac{S}{2f(m+1)}\]

donde \(\theta\) es el ángulo de visión, \(S\) el tamaño del sensor, \(F\) la distancia focal y \(m\) el factor de magnificación que los aficionados a la fotografía conocerán como macro. Para distancias de enfoque grandes (\(m \thickapprox 0\)) sería:

\[ \theta = 2 arctan \frac{S}{2f}\]

El factor de magnificación, o macro, es despreciable en distancias de enfoque lejanas (al infinito), pero tiene que tomarse en cuenta en distancias de enfoque cercanas. Se calcula mediante la fórmula:

\[m = \frac{f}{D - f}\]

Donde \(f\) es la distancia focal en mm y \(D\) es la distancia de enfoque. Por ejemplo, con un objetivo de 50mm enfocado a una distancia de 55cm el cálculo sería:

\[m = \frac{f}{D - f} = \frac{50}{550 - 50} = 0,1\]

Por eso, entre las recomendaciones habituales que se les da a los fotógrafos noveles cuando te están enseñando fotografía, encuadres y demás, es que te alejes del sujeto al menos un metro para no distorsionar la imagen... pero seguimos.

Por hacer una comparación si calculamos con un objetivo de 50mm su ángulo de visión para estar enfocado al infinito, puesto que el factor m es igual a cero (\(m=0\)), dicho ángulo será:

\[\theta = 2 arctan \frac{S}{2f(m+1)} = 2 arctan \frac{36}{2 \cdot 50} = 40º\]

Sin embargo, si lo enfocamos a 55cm como dijimos antes:

\[\theta = 2 arctan \frac{S}{2f(m+1)} = 2 arctan \frac{36}{2 \cdot 50 (0,1 + 1)} = 36º\]

Es decir, el ángulo es cuatro grados menor, no podrás enfocar bien a distancias menores, a no ser que el objetivo sea macro y permita mover las lentes para reducir ese ángulo. Si no es macro, tendrás que cambiar el objetivo y calzarle a la cámara un gran angular.

Enfoque

En una fotografía estamos acostumbrados a ver que hay objetos que vemos borrosos. Aquellos que están demasiado lejos o demasiado cerca del punto enfocado.

lente-fina.png

A simple vista podemos observar que cuanto más grande sea la lente más grande será el ángulo en el que se enfocan los objetos. No podemos cambiar el tamaño de la lente, sin embargo, podemos modificar el ángulo en el que incide la luz en ella obturador, que no sólo modifica la cantidad de luz que entra en la cámara, sino que también afecta al enfoque. A quienes les gusta la fotografía saben que cuando juegan con la apertura del diafragma del objetivo, consiguen un rango de objetos enfocados más amplio cuando utilizan aperturas más pequeñas. Sin embargo, a aperturas grandes enseguida los objetos situados un poco antes o después, quedan desenfocados.

Enfocar la cámara

Como hemos visto antes, el cálculo del enfoque necesita también la distancia focal f y la apertura de la lente. El registro de la cámara se modifica añadiendo ambos valores:

-record(camara, { origen   = {0,0,0}           % Posición de la cámara
                , mira     = {0,0,-1}          % Punto al que mira
                , sup      = {0,1,0}           % Orientación del lado superior de la cámara
                , angulo   = 80                % Ángulo del campo de visión
                , apertura = 2.0               % Diámetro de la lente
                , f        = 1.0}).            % Distancia focal

El código de la cámara queda de la siguiente manera:

start(#camara{origen=Origen,mira=MiraA,sup=VArriba,angulo=CV,apertura=Apertura,f=Dist_Focal},
      #config{ratio=Ratio,ancho=Ix,muestras=Muestras,profun=Profun,tesela=Tesela,procesos=Procesos}=Config) ->
    io:format("Estableciendo datos de la imagen...~n"),
    Pid = rerl_imagen:start(Config),
    register(imagen, Pid),

    io:format("Estableciendo datos de la cámara...~n"),
    Theta = rerl:grados_a_radianes(CV),        % Calcular el ángulo en radianes del campo de visión
    H = math:tan(Theta / 2),
    Viewport_Alto = 2.0 * H,                   % Calcular el factor de altura de la imagen
    Viewport_Ancho = Viewport_Alto * Ratio,

    W = rerl:vector_unidad(rerl:resta(Origen, MiraA)),
    U = rerl:vector_unidad(rerl:cruz(VArriba, W)),
    V = rerl:cruz(W, U),

    Iy = round(Ix / Ratio),
    Horizontal = rerl:mul(rerl:mul(U, Viewport_Ancho), Dist_Focal),
    Vertical = rerl:mul(rerl:mul(V, Viewport_Alto), Dist_Focal),
    %% Eii = Origen - Viewport_Ancho/2 - Viewport_Alto/2 - {0,0,Focal}
    Medio_H = rerl:dividir(Horizontal, 2),
    Medio_V = rerl:dividir(Vertical, 2),
    Eii = rerl:resta(
            rerl:resta(
              rerl:resta(Origen, Medio_H), Medio_V),
            rerl:mul(W, Dist_Focal)),
    Estado = #state{origen=Origen
                  , alto=Viewport_Alto
                  , ancho=Viewport_Ancho
                  , ix=Ix
                  , iy=Iy
                  , ratio=Ratio
                  , u=U
                  , v=V
                  , horizontal=Horizontal
                  , vertical=Vertical
                  , eii=Eii
                  , radio_lente = Apertura / 2
                  , muestras=Muestras
                  , profundidad=Profun
                  , tesela=Tesela
                  , procesos=Procesos},
    spawn(rerl_camara, init, [Estado]).

El cambio fundamental para conseguir el desenfoque o blur de las imágenes se da a la hora de lanzar los rayos:

%%
%% Calcula la dirección de un nuevo rayo. El origen siempre es el
%% origen de la cámara.
%%
lanza_rayo(#state{profundidad=Prf}=Estado, Cam, S, T) ->
    {Origen,Eii,U,V,R_Lente,Horizontal,Vertical} = Cam,
    {X,Y,_} = rerl:mul(rerl:random_en_disco_unidad(),R_Lente),
    %% Offset = U*X + V*Y,
    Offset = rerl:suma(rerl:mul(U, X), rerl:mul(V, Y)),
    %% Eii + Hor*U + Ver*V - Origen
    Dir = rerl:resta(
            rerl:resta(
              rerl:suma(
                rerl:suma(
                  Eii,
                  rerl:mul(Horizontal, S)),
                rerl:mul(Vertical, T)),
              Origen),
            Offset),
    O = rerl:suma(Origen, Offset),
    Rayo = #rayo{origen=O,dir=Dir},
    color_rayo(Estado, Rayo, Prf).

El truco está en que en lugar de enviarlos desde el punto exacto del origen de la cámara, se utiliza un punto ligeramente distante de él según la distancia y la apertura. Para ello, hacemos que en lugar de un punto, sea un círculo.

Para ello necesitamos también una función que nos dé un punto aleatorio dentro de un disco de tamaño 1:

random_en_disco_unidad() ->
    P = {random(-1.0,1.0),random(-1.0,1.0),0},
    M = modulo_cuadrado(P),
    if M >= 1 ->
            random_en_disco_unidad();
       true ->
            P
    end.

Y tenemos nuestra imagen con desenfoque. Con una distancia focal de 1:1 obtenemos:

imagen-f-1.png

Cambiando la apertura a 1:25 obtenemos.

imagen-f-25.png

Supongo que está claro, no sólo por las explicaciones anteriores, sino también atendiendo a las imágenes obtenidas, que una apertura más pequeña (\(1/25\)) genera imágenes más enfocadas que una apertura más grande (\(1/1\)).

Conclusiones

Con esto hemos llegado al final del primer libro-tutorial de cómo programar un raytracer. Tenemos una cámara plenamente funcional y los materiales básicos: reflexión, refracción y color sólido. Únicamente conoce una geometría: la esfera. Pero en definitiva es todo lo que se espera de un raytracer. No lo he hecho en un fin de semana porque era un plazo irreal y porque he perdido mucho tiempo en tareas paralelas, como contarlo en el blog o traducir el código y el método a erlang.

Por otro lado, el raytracer funciona de forma paralela, se pueden ajustar el número de procesos que lanzará y repartiendo la imagen en pequeñas partes, ─o grandes, porque también se puede configurar el tamaño─, al hacerlo.

A partir de aquí, me tengo que pensar si sigo con la serie de artículos, aunque quiero terminar la serie de los tres libros lo mismo es muy aburrido el monotema en el que casi se está convirtiendo el blog.

Nota al pie de página:

1

Recordad que el objeto lo habíamos definido como un record compuesto de geometría y material. Así pues, encontraremos las definiciones de las geometrías y materiales en el fichero de la escena.

2

Algunos programas dan soporte a 0-65535 colores por canal RBG, pero no todos.

3

Los aficionados a la fotografía (digital) estarán encantados con el artículo de hoy, ─espero─: formato raw, distancia focal, apertura de obturardos... hoy traigo de todo eso.

4

Por eso se ha elegido el ángulo de 80º como el valor por defecto para el campo de visión vertical al definir la cámara.

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.