Introducción a la programación: un simple juego de disparos en 3D desde cero durante el fin de semana, parte 1

Este texto está destinado a aquellos que solo dominan la programación. La idea principal es mostrar paso a paso cómo puedes hacer independientemente el juego a la Wolfenstein 3D . Atención, no voy a competir con Carmack en absoluto, él es un genio y su código es hermoso. Apunto a un lugar completamente diferente: uso la enorme potencia informática de las computadoras modernas para que los estudiantes puedan crear proyectos divertidos en pocos días sin atascarse en la naturaleza de la optimización. Específicamente escribo código lento, ya que es mucho más corto y más fácil de entender. Carmack escribe 0x5f3759df , escribo 1 / sqrt (x). Tenemos diferentes objetivos.

Estoy convencido de que un buen programador se obtiene solo de alguien que codifica en casa por placer, y no solo en parejas en la universidad. En nuestra universidad, a los programadores se les enseña una serie interminable de todo tipo de catálogos de bibliotecas y otros tipos de aburrimiento. Brr Mi objetivo es mostrar ejemplos de proyectos que sean interesantes para programar. Este es un círculo vicioso: si es interesante hacer un proyecto, entonces una persona pasa mucho tiempo en él, gana experiencia y ve cosas aún más interesantes a su alrededor (¡se ha vuelto más accesible!), Y nuevamente se sumerge en un nuevo proyecto. Esto se llama capacitación de proyectos, alrededor de ganancias sólidas.

La hoja resultó ser larga, así que dividí el texto en dos partes:


La ejecución del código desde mi repositorio se ve así:


Este no es un juego terminado, sino solo un espacio en blanco para los estudiantes. Un ejemplo de un juego terminado escrito por dos estudiantes de primer año, vea la segunda parte .

Resulta que te engañé un poco, no te diré cómo hacer un juego completo en un fin de semana. Hice solo un motor 3D. Los monstruos no corren hacia mí y el personaje principal no dispara. Pero al menos escribí este motor en un sábado, puedes consultar el historial de confirmaciones. En principio, los domingos son suficientes para hacer que algo sea jugable, es decir, un fin de semana que puedas conocer.

Al momento de escribir esto, el repositorio contiene 486 líneas de código:

haqreu@daffodil:~/tinyraycaster$ cat *.cpp *.h | wc -l 486 

El proyecto depende de SDL2, pero en general la interfaz de la ventana y el procesamiento de eventos desde el teclado aparecen bastante tarde, a la medianoche del sábado :), cuando todo el código de representación ya está hecho.

Entonces, divido todo el código en pasos, comenzando con el compilador de C ++ simple. Como en mis artículos anteriores sobre la programación ( tyts , tyts , tyts ), me adhiero a la regla "un paso = una confirmación", ya que github hace que sea muy conveniente ver el historial de cambios en el código.

Etapa 1: guarda la imagen en el disco


Entonces vamos. Todavía estamos muy lejos de la interfaz de la ventana, para empezar simplemente guardaremos las imágenes en el disco. Total, necesitamos poder almacenar la imagen en la memoria de la computadora y guardarla en el disco en un formato que algún programa de terceros entienda. Quiero obtener este archivo:



Aquí está el código completo de C ++ que dibuja lo que necesitamos:

 #include <iostream> #include <fstream> #include <vector> #include <cstdint> #include <cassert> uint32_t pack_color(const uint8_t r, const uint8_t g, const uint8_t b, const uint8_t a=255) { return (a<<24) + (b<<16) + (g<<8) + r; } void unpack_color(const uint32_t &color, uint8_t &r, uint8_t &g, uint8_t &b, uint8_t &a) { r = (color >> 0) & 255; g = (color >> 8) & 255; b = (color >> 16) & 255; a = (color >> 24) & 255; } void drop_ppm_image(const std::string filename, const std::vector<uint32_t> &image, const size_t w, const size_t h) { assert(image.size() == w*h); std::ofstream ofs(filename); ofs << "P6\n" << w << " " << h << "\n255\n"; for (size_t i = 0; i < h*w; ++i) { uint8_t r, g, b, a; unpack_color(image[i], r, g, b, a); ofs << static_cast<char>(r) << static_cast<char>(g) << static_cast<char>(b); } ofs.close(); } int main() { const size_t win_w = 512; // image width const size_t win_h = 512; // image height std::vector<uint32_t> framebuffer(win_w*win_h, 255); // the image itself, initialized to red for (size_t j = 0; j<win_h; j++) { // fill the screen with color gradients for (size_t i = 0; i<win_w; i++) { uint8_t r = 255*j/float(win_h); // varies between 0 and 255 as j sweeps the vertical uint8_t g = 255*i/float(win_w); // varies between 0 and 255 as i sweeps the horizontal uint8_t b = 0; framebuffer[i+j*win_w] = pack_color(r, g, b); } } drop_ppm_image("./out.ppm", framebuffer, win_w, win_h); return 0; } 

Si no tiene un compilador a mano, entonces esto no importa, si tiene una cuenta en un github, puede ver este código, editarlo y ejecutarlo (sic!) Con un solo clic directamente desde el navegador.

Abierto en gitpod

Siguiendo este enlace, gitpod creará una máquina virtual para usted, iniciará VS Code y abrirá una terminal en la máquina remota. En el historial de comandos de terminal (haga clic en la consola y presione la flecha hacia arriba), ya hay un conjunto completo de comandos que le permiten compilar el código, ejecutarlo y abrir la imagen resultante.

Entonces, ¿qué necesitas entender de este código? Primero, los colores que almaceno en un entero de cuatro bytes escriben uint32_t. Cada byte es un componente de R, G, B o A. Las funciones pack_color () y unpack_color () le permiten acceder a los componentes individuales de cada color.

La segunda imagen bidimensional, la guardo en la matriz unidimensional habitual. Para llegar al píxel con coordenadas (x, y) no escribo la imagen [x] [y], pero escribo la imagen [x + y * ancho]. Si este método de empaquetar información bidimensional en una matriz unidimensional es nuevo para usted, entonces ahora tome un bolígrafo y lidie con él. Para mí personalmente, esta etapa ni siquiera llega al cerebro, se procesa directamente en la médula espinal. Las matrices tridimensionales y más dimensionales se pueden empaquetar exactamente de la misma manera, pero no nos elevaremos por encima de los dos componentes.

Luego reviso mi imagen en un simple ciclo doble, la relleno con un degradado y la guardo en el disco en formato .ppm.



Etapa 2: dibuja un mapa de nivel


Necesitamos un mapa de nuestro mundo. En este punto, solo quiero determinar la estructura de datos y dibujar un mapa en la pantalla. Debería verse más o menos así:



Los cambios que puedes ver aquí . Todo es simple allí: codifiqué el mapa en una matriz unidimensional de caracteres, definí la función de dibujar un rectángulo y caminé alrededor del mapa, dibujando cada celda.

Les recuerdo que este botón lanzará el código justo en esta etapa:

Abierto en gitpod



Etapa 3: agrega un jugador


¿Qué necesitamos para poder dibujar un jugador en el mapa? Las coordenadas GPS son suficientes :)



Agregue dos variables x e y, y dibuje al jugador en el lugar apropiado:



Los cambios que puedes ver aquí . Sobre gitpod no recordaré más :)

Abierto en gitpod



Etapa 4: también conocido como rastreador virtual de primer rayo


Además de las coordenadas del jugador, sería bueno saber en qué dirección está mirando. Por lo tanto, agregamos otra variable player_a, que da la dirección de la mirada del jugador (el ángulo entre la dirección de la mirada y el eje de abscisas):



Y ahora quiero poder deslizarme a lo largo del rayo naranja. Como hacerlo Extremadamente simple Veamos un triángulo rectángulo verde. Sabemos que cos (player_a) = a / c, y que sin (player_a) = b / c.



¿Qué sucede si tomo arbitrariamente c (positivo) y cuento x = player_x + c * cos (player_a) e y = player_y + c * sin (player_a)? Nos encontraremos en el punto morado; ¡Al variar el parámetro c de cero a infinito, podemos hacer que este punto púrpura se deslice a lo largo de nuestro rayo naranja, y c es la distancia de (x, y) a (player_x, player_y)!

El corazón de nuestro motor gráfico es este ciclo:

  float c = 0; for (; c<20; c+=.05) { float x = player_x + c*cos(player_a); float y = player_y + c*sin(player_a); if (map[int(x)+int(y)*map_w]!=' ') break; } 

Movimos el punto (x, y) a lo largo del rayo, si se encuentra con un obstáculo en el mapa, entonces terminamos el ciclo, ¡y la variable c da la distancia al obstáculo! ¿Qué no es un telémetro láser?



Los cambios que puedes ver aquí .

Abierto en gitpod



Etapa 5: Resumen del sector


Un rayo está bien, pero aún así nuestros ojos ven todo un sector. Llamemos al ángulo de visión fov (campo de visión):



Y vamos a lanzar 512 rayos (por cierto, ¿por qué 512?), Barriendo suavemente todo el sector de visualización:


Los cambios que puedes ver aquí .

Abierto en gitpod



Etapa 6: 3D!


Y ahora el punto clave. Para cada uno de los 512 rayos, obtuvimos la distancia al obstáculo más cercano, ¿verdad? Y ahora hagamos una segunda imagen de 512 píxeles de ancho (spoiler); en el que para cada rayo dibujaremos un segmento vertical, y la altura del segmento es inversamente proporcional a la distancia al obstáculo:



Una vez más, esta es la clave para crear la ilusión 3D, asegúrese de comprender lo que está en juego. Dibujando segmentos verticales, de hecho, dibujamos una cerca, donde la altura de cada estaca es menor, cuanto más lejos esté de nosotros:



Los cambios que puedes ver aquí .

Abierto en gitpod



Etapa 7: primera animación


En esta etapa, por primera vez, estamos dibujando algo dinámico (solo dejo caer 360 imágenes en el disco). Todo es trivial: cambio jugador_a, hago un dibujo, guardo, cambio jugador_a, dibujo, salvo. Para hacerlo un poco más divertido, asigné un valor de color aleatorio a cada tipo de celda en nuestro mapa.


Los cambios que puedes ver aquí .

Abierto en gitpod



Etapa 8: corrección del ojo de pez


¿Notaste el gran efecto ojo de pez que obtenemos cuando miramos una pared de cerca? Se ve así:



Por qué Si, muy simple. Aquí miramos la pared:



Para dibujar nuestro muro, destacamos nuestro sector de visión azul con un rayo púrpura. Tome el valor específico de la dirección del haz, como en esta imagen. La longitud del segmento naranja es claramente menor que la longitud del púrpura. Dado que para determinar la altura de cada segmento vertical que dibujamos en la pantalla, dividimos por la distancia al obstáculo, el ojo de pez es bastante natural.

Para corregir esta distorsión no es nada difícil, mira cómo se hace . Asegúrese de entender de dónde vino el coseno. Dibujar un diagrama en una hoja de papel ayuda mucho.



Abierto en gitpod



Paso 9: cargue el archivo de textura


Es hora de lidiar con las texturas. Soy perezoso para escribir un descargador de imágenes, así que tomé la excelente biblioteca stb . Preparé un archivo con texturas para las paredes, todas las texturas son cuadradas y se empaquetan horizontalmente en la imagen:



En este punto, acabo de cargar las texturas en la memoria. Para probar el código escrito, simplemente dibujo como textura con el índice 5 en la esquina superior izquierda de la pantalla:


Los cambios que puedes ver aquí .

Abierto en gitpod



Etapa 10: uso rudimentario de texturas


Ahora arrojo colores generados al azar y tiñe mis paredes tomando el píxel superior izquierdo de la textura correspondiente:


Los cambios que puedes ver aquí .

Abierto en gitpod



Etapa 11: texturizar las paredes de verdad


Y ahora ha llegado el momento tan esperado cuando finalmente vemos las paredes de ladrillo:



La idea básica es muy simple: aquí nos deslizamos a lo largo del rayo actual y nos detenemos en el punto x, y. Supongamos que nos instalamos en una pared "horizontal", entonces y es casi entero (no realmente, porque nuestra forma de movernos a lo largo del rayo introduce un pequeño error). Tomemos la parte fraccional de x y llamémosla hitx. La parte fraccional es menor que uno, por lo tanto, si multiplicamos hitx por el tamaño de la textura (tengo 64), esto nos dará la columna de textura que debe dibujarse en este lugar. Queda por estirarlo al tamaño correcto y la cosa está en el sombrero:



En general, la idea es extremadamente primitiva, pero requiere una ejecución cuidadosa, ya que también tenemos paredes "verticales" (aquellas con hitx cercano a cero [x entero]). Para ellos, la columna de textura está determinada por hity, la parte fraccionaria de y. Los cambios que puedes ver aquí .

Abierto en gitpod



Etapa 12: ¡es hora de refactorizar!


En esta etapa, no hice nada nuevo, solo comencé la limpieza general. Hasta ahora, tenía un archivo gigantesco (¡185 líneas!), Y se hizo difícil trabajar en él. Por lo tanto, lo dividí en una nube de pequeños, desafortunadamente, de paso, casi duplicando el tamaño del código (319 líneas), sin agregar ninguna funcionalidad. Pero luego se ha vuelto mucho más conveniente usar, por ejemplo, para generar una animación, es suficiente para hacer un bucle de este tipo:

  for (size_t frame=0; frame<360; frame++) { std::stringstream ss; ss << std::setfill('0') << std::setw(5) << frame << ".ppm"; player.a += 2*M_PI/360; render(fb, map, player, tex_walls); drop_ppm_image(ss.str(), fb.img, fb.w, fb.h); } 

Bueno, aquí está el resultado:


Los cambios que puedes ver aquí .

Abierto en gitpod

Continuará ... inmediatamente


En esta nota optimista, termino la mitad actual de mi hoja, la segunda mitad está disponible aquí . En él agregaremos monstruos y enlaces a SDL2 para que puedas dar un paseo por nuestro mundo virtual.

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


All Articles