256 líneas de C ++ desnudo: escribir un trazador de rayos desde cero en unas pocas horas

Estoy publicando el próximo capítulo de mi curso de conferencias sobre gráficos por computadora ( aquí puede leer el original en ruso, aunque la versión en inglés es más reciente). Esta vez, el tema de conversación es dibujar escenas usando el trazado de rayos . Como de costumbre, trato de evitar las bibliotecas de terceros, ya que esto hace que los estudiantes miren bajo el capó.

Ya hay muchos proyectos similares en Internet, pero casi todos muestran programas terminados que son extremadamente difíciles de entender. Aquí, por ejemplo, un programa de renderizado muy famoso que cabe en una tarjeta de visita . Un resultado muy impresionante, pero entender este código es muy difícil. Mi objetivo no es mostrar cómo puedo, sino decir en detalle cómo reproducir esto. Además, me parece que esta conferencia específicamente ni siquiera es útil tanto como el material de capacitación en gráficos por computadora, sino más bien como una herramienta de programación. Siempre mostraré cómo llegar al resultado final, comenzando desde cero: cómo descomponer un problema complejo en etapas elementales solucionables.

Atención: solo mirar mi código, así como leer este artículo con una taza de té en la mano, no tiene sentido. Este artículo está diseñado para que puedas agarrar un teclado y escribir tu propio motor. Seguramente será mejor que el mío. Bueno, ¡o simplemente cambia el lenguaje de programación!

Entonces, hoy mostraré cómo dibujar tales imágenes:



Etapa uno: guarda la imagen en el disco


No quiero molestarme con los gestores de ventanas, el procesamiento del mouse / teclado y similares. El resultado de nuestro programa será una simple imagen guardada en el disco. Entonces, lo primero que debemos hacer es guardar la imagen en el disco. Aquí yace el código que te permite hacer esto. Déjame darte su archivo principal:

#include <limits> #include <cmath> #include <iostream> #include <fstream> #include <vector> #include "geometry.h" void render() { const int width = 1024; const int height = 768; std::vector<Vec3f> framebuffer(width*height); for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0); } } std::ofstream ofs; // save the framebuffer to file ofs.open("./out.ppm"); ofs << "P6\n" << width << " " << height << "\n255\n"; for (size_t i = 0; i < height*width; ++i) { for (size_t j = 0; j<3; j++) { ofs << (char)(255 * std::max(0.f, std::min(1.f, framebuffer[i][j]))); } } ofs.close(); } int main() { render(); return 0; } 

En la función principal, solo se llama a la función render (), nada más. ¿Qué hay dentro de la función render ()? En primer lugar, defino una imagen como una matriz unidimensional de valores de framebuffer de tipo Vec3f, estos son simples vectores tridimensionales que nos dan el color (r, g, b) para cada píxel.

La clase de vectores vive en el archivo geometry.h, no lo describiré aquí: en primer lugar, todo es trivial allí, la manipulación simple de vectores de dos y tres dimensiones (suma, resta, asignación, multiplicación por un producto escalar, escalar), y en segundo lugar, gbg ya lo describió en detalle como parte de un curso de conferencias sobre gráficos por computadora.

Guardo la imagen en formato ppm ; Esta es la forma más fácil de guardar imágenes, aunque no siempre es la más conveniente para su posterior visualización. Si desea guardar en otros formatos, le recomiendo conectar una biblioteca de terceros, por ejemplo, stb . Esta es una biblioteca maravillosa: es suficiente para incluir un archivo de encabezado stb_image_write.h en el proyecto, y esto permitirá guardar incluso en png, incluso en jpg.

En total, el objetivo de esta etapa es asegurarnos de que podemos a) crear una imagen en la memoria y escribir diferentes valores de color allí b) guardar el resultado en el disco para que pueda verse en un programa de terceros. Aquí está el resultado:



Etapa dos, la más difícil: directamente trazado de rayos


Esta es la etapa más importante y difícil de toda la cadena. Quiero definir una esfera en mi código y mostrarla en la pantalla sin molestarme ni con materiales ni con iluminación. Así es como debería verse nuestro resultado:



Por conveniencia, en mi repositorio, hay una confirmación para cada etapa; Github hace que sea muy conveniente ver sus cambios. Aquí, por ejemplo , lo que ha cambiado en la segunda confirmación en comparación con la primera.

Para empezar: ¿qué necesitamos para representar una esfera en la memoria de la computadora? Cuatro números son suficientes para nosotros: un vector tridimensional con el centro de la esfera y un escalar que describe el radio:

 struct Sphere { Vec3f center; float radius; Sphere(const Vec3f &c, const float &r) : center(c), radius(r) {} bool ray_intersect(const Vec3f &orig, const Vec3f &dir, float &t0) const { Vec3f L = center - orig; float tca = L*dir; float d2 = L*L - tca*tca; if (d2 > radius*radius) return false; float thc = sqrtf(radius*radius - d2); t0 = tca - thc; float t1 = tca + thc; if (t0 < 0) t0 = t1; if (t0 < 0) return false; return true; } }; 

Lo único no trivial en este código es una función que le permite verificar si un rayo dado (que se origina desde el origen en la dirección de dir) se cruza con nuestra esfera. Aquí se puede leer una descripción detallada del algoritmo para verificar la intersección del haz y la esfera. Recomiendo encarecidamente hacerlo y verificar mi código.

¿Cómo funciona el trazado de rayos? Muy simple En la primera etapa, simplemente cubrimos la imagen con un degradado:

  for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0); } } 

Ahora, para cada píxel, formaremos un rayo proveniente del centro de coordenadas y pasando a través de nuestro píxel, y verificaremos si este rayo se cruza con nuestra esfera.



Si no hay intersección con la esfera, entonces pondremos color1, de lo contrario color2:

 Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) { float sphere_dist = std::numeric_limits<float>::max(); if (!sphere.ray_intersect(orig, dir, sphere_dist)) { return Vec3f(0.2, 0.7, 0.8); // background color } return Vec3f(0.4, 0.4, 0.3); } void render(const Sphere &sphere) {  [...] for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { float x = (2*(i + 0.5)/(float)width - 1)*tan(fov/2.)*width/(float)height; float y = -(2*(j + 0.5)/(float)height - 1)*tan(fov/2.); Vec3f dir = Vec3f(x, y, -1).normalize(); framebuffer[i+j*width] = cast_ray(Vec3f(0,0,0), dir, sphere); } }  [...] } 

En este punto, recomiendo tomar un lápiz y verificar en papel todos los cálculos, tanto la intersección de un rayo con una esfera como el barrido de una imagen con rayos. Por si acaso, nuestra cámara está determinada por lo siguiente:

  • ancho de imagen
  • altura de la imagen
  • ángulo de visión, fov
  • ubicación de la cámara, Vec3f (0,0,0)
  • dirección de la mirada, a lo largo del eje z, en la dirección de menos infinito

Etapa tres: agregar más esferas


Todo lo más difícil está detrás de nosotros, ahora nuestro camino está despejado. Si podemos dibujar una esfera. entonces obviamente agregar un poco más de trabajo no es difícil. Aquí puede ver los cambios en el código, y aquí está el resultado:



Etapa cuatro: iluminación


Todos somos buenos en nuestra imagen, pero eso no es suficiente iluminación. En el resto del artículo, solo hablaremos de esto. Agregue algunas fuentes de luz puntuales:

 struct Light { Light(const Vec3f &p, const float &i) : position(p), intensity(i) {} Vec3f position; float intensity; }; 

Considerar la iluminación real es una tarea muy, muy difícil, por lo tanto, como todos los demás, engañaremos al ojo dibujando resultados completamente no físicos, pero muy probables y plausibles. Primera observación: ¿por qué hace frío en invierno y calor en verano? Porque calentar la superficie de la tierra depende del ángulo de incidencia de la luz solar. Cuanto más alto es el sol sobre el horizonte, más brillante se ilumina la superficie. Y viceversa, cuanto más bajo es el horizonte, más débil. Bueno, después de que el sol se pone en el horizonte, los fotones no nos alcanzan en absoluto. Con respecto a nuestras esferas: aquí está nuestro haz emitido desde la cámara (¡sin relación con los fotones, preste atención!) Interseccionado con la esfera. ¿Cómo entendemos cómo se ilumina el punto de intersección? Simplemente puede mirar el ángulo entre el vector normal en este punto y el vector que describe la dirección de la luz. Cuanto más pequeño es el ángulo, mejor se ilumina la superficie. Para hacerlo aún más conveniente, simplemente puede tomar el producto escalar entre el vector normal y el vector de iluminación. Recuerdo que el producto escalar entre dos vectores a y b es igual al producto de las normas de los vectores por el coseno del ángulo entre los vectores: a * b = | a | | b | cos (alfa (a, b)). Si tomamos vectores de longitud unitaria, el producto escalar más simple nos dará la intensidad de la iluminación de la superficie.

Por lo tanto, en la función cast_ray, en lugar de un color constante, devolveremos el color teniendo en cuenta las fuentes de luz:

 Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) { [...] float diffuse_light_intensity = 0; for (size_t i=0; i<lights.size(); i++) { Vec3f light_dir = (lights[i].position - point).normalize(); diffuse_light_intensity += lights[i].intensity * std::max(0.f, light_dir*N); } return material.diffuse_color * diffuse_light_intensity; } 

Vea los cambios aquí , pero el resultado del programa:



Etapa cinco: superficies brillantes


Un truco con un producto escalar entre un vector normal y un vector de luz se aproxima bastante bien a la iluminación de superficies mate, en la literatura se llama iluminación difusa. ¿Qué hacer si queremos liso y brillante? Quiero obtener esta foto:



Vea los pocos cambios necesarios para hacerse. En resumen, los reflejos en superficies brillantes son más brillantes, cuanto menor es el ángulo entre la dirección de visión y la dirección de la luz reflejada . Bueno, las esquinas, por supuesto, contaremos a través de productos escalares, exactamente como antes.

Esta gimnasia con iluminación mate y superficies brillantes se conoce como el modelo Phong . La wiki tiene una descripción bastante detallada de este modelo de iluminación; se lee bien en comparación con mi código. Aquí hay una imagen clave para entender:


Etapa Seis: Sombras


¿Por qué tenemos luz, pero no sombras? Lío! Quiero esta foto:



Solo seis líneas de código nos permiten lograr esto: al dibujar cada punto, solo nos aseguramos de que la fuente puntual de luz no se cruce con los objetos de nuestra escena, y si lo hace, la fuente de luz actual se salta. Solo hay una pequeña sutileza: cambio un poco el punto en la dirección de lo normal:

 Vec3f shadow_orig = light_dir*N < 0 ? point - N*1e-3 : point + N*1e-3; 

Por qué Sí, es solo que nuestro punto se encuentra en la superficie del objeto y (excluyendo el problema de los errores numéricos) cualquier rayo de este punto cruzará nuestra escena.

Paso siete: reflexiones


Esto es increíble, pero para agregar reflexiones a nuestra escena, solo necesitamos agregar tres líneas de código:

  Vec3f reflect_dir = reflect(dir, N).normalize(); Vec3f reflect_orig = reflect_dir*N < 0 ? point - N*1e-3 : point + N*1e-3; // offset the original point to avoid occlusion by the object itself Vec3f reflect_color = cast_ray(reflect_orig, reflect_dir, spheres, lights, depth + 1); 

Compruébelo usted mismo: en la intersección con el objeto, simplemente contamos el rayo reflejado (¡la función del cálculo de los golpes fue útil!) Y llamamos recursivamente a la función cast_ray en la dirección del rayo reflejado. Asegúrate de jugar con la profundidad de recursión , la configuré en cuatro, comienzo desde cero, ¿qué cambiará en la imagen? Aquí está mi resultado con una reflexión de trabajo y una profundidad de cuatro:



Etapa ocho: refracción


Al aprender a contar las reflexiones, las refracciones se cuentan exactamente igual . Una función que le permite calcular la dirección del rayo refractado (de acuerdo con la ley de Snell ) y tres líneas de código en nuestra función recursiva cast_ray. Aquí está el resultado, en el cual la bola más cercana se convirtió en "vidrio", refracta y refleja levemente:



Etapa nueve: agrega más objetos


¿Por qué estamos todos sin leche, pero sin leche? Hasta este momento, representamos solo esferas, ya que este es uno de los objetos matemáticos no triviales más simples. Y agreguemos un pedazo del avión. Un clásico del género es un tablero de ajedrez. Para esto, una docena de líneas en una función que considera la intersección del haz con la escena son suficientes para nosotros.

Bueno, aquí está el resultado:



Como prometí, exactamente 256 líneas de código, ¡ cuenta por ti mismo !

Etapa Diez: Tarea


Hemos recorrido un camino bastante largo: aprendimos a agregar objetos a la escena, a considerar una iluminación bastante complicada. Déjame dejar dos tareas como tarea. Absolutamente todo el trabajo preparatorio ya se ha realizado en la rama de tarea . Cada trabajo requerirá un máximo de diez líneas de código.

Tarea uno: mapa del entorno


Por el momento, si el rayo no cruza la escena, simplemente lo configuramos con un color constante. ¿Y por qué, de hecho, permanente? ¡Tomemos una foto esférica (archivo envmap.jpg ) y úsela como fondo! Para facilitar la vida, vinculé nuestro proyecto con la biblioteca stb para la conveniencia de trabajar con jpegs. Esto debería ser un render como este:



La segunda tarea: quack!


Podemos representar tanto esferas como planos (ver tablero de ajedrez). ¡Entonces agreguemos un dibujo de modelos triangulados! Escribí un código para leer la cuadrícula de triángulos, y agregué una función de intersección triángulo de rayos allí. ¡Ahora agregar un pato a nuestra escena debería ser completamente trivial!



Conclusión


Mi tarea principal es mostrar proyectos que sean interesantes (¡y fáciles!) Para programar, realmente espero poder hacerlo. Esto es muy importante, ya que estoy convencido de que un programador debe escribir mucho y con gusto. No sé sobre usted, pero la contabilidad personal y un zapador, con una complejidad de código bastante comparable, no me atraen en absoluto.

Se pueden escribir doscientas cincuenta líneas de trazado de rayos en unas pocas horas. Quinientas líneas de rasterizador de software se pueden dominar en unos pocos días. La próxima vez clasificaremos el rakecasting y, al mismo tiempo, mostraré los juegos más simples que escriben mis alumnos de primer año como parte de la enseñanza de la programación en C ++. Estén atentos!

Source: https://habr.com/ru/post/es436790/


All Articles