Escribir un motor de juego en tu primer año: ¡fácil! (Casi)

¡Hola! Mi nombre es Gleb Maryin, estoy en mi primer año de pregrado "Matemática Aplicada y Ciencias de la Computación" en el St. Petersburg HSE. En el segundo semestre, todos los estudiantes de primer año de nuestro programa realizan proyectos en equipo en C ++. Mis compañeros de equipo y yo decidimos escribir un motor de juego. 

Lee lo que tenemos debajo del gato.


Somos tres en el equipo: yo, Alexei Luchinin e Ilya Onofriychuk. Ninguno de nosotros somos expertos en el desarrollo de juegos, mucho menos en la creación de motores de juegos. Este es el primer gran proyecto para nosotros: antes de eso, solo hacíamos la tarea y el trabajo de laboratorio, por lo que es poco probable que los profesionales en el campo de los gráficos por computadora encuentren nueva información aquí. Estaremos encantados si nuestras ideas ayudan a aquellos que también quieren crear su propio motor. Pero este tema es complejo y multifacético, y el artículo de ninguna manera pretende ser una literatura especializada completa.

Todos los demás interesados ​​en conocer nuestra implementación: ¡disfruten leyendo!

Artes graficas


Primera ventana, mouse y teclado


Para crear ventanas, manejar la entrada del mouse y el teclado, elegimos la biblioteca SDL2. Fue una elección al azar, pero hasta ahora no nos hemos arrepentido. 

Era importante en la primera etapa escribir un contenedor conveniente sobre la biblioteca para poder crear una ventana con un par de líneas, hacer manipulaciones con ella como mover el cursor e ingresar al modo de pantalla completa y manejar eventos: pulsaciones de teclas, movimientos del cursor. La tarea no fue difícil: rápidamente creamos un programa que puede cerrar y abrir una ventana, y cuando hace clic en RMB, muestra "¡Hola, Mundo!". 

Entonces apareció el ciclo principal del juego:

Event ev;
bool running = true;
while (running):
	ev = pullEvent();
	for handler in handlers[ev.type]:
		handler.handleEvent(ev);

Cada controlador de eventos adjunto, handlerspor ejemplo handlers[QUIT] = {QuitHandler()}. Su tarea es manejar el evento correspondiente. QuitHandlerEn el ejemplo, se expondrá running = false, deteniendo así el juego.

Hola Mundo


Para dibujar en el motor que utilizamos OpenGL. El primero Hello World, como creo, en muchos proyectos, fue un cuadrado blanco sobre fondo negro: 

glBegin(GL_QUADS);
glVertex2f(-1.0f, 1.0f);
glVertex2f(1.0f, 1.0f);
glVertex2f(1.0f, -1.0f);
glVertex2f(-1.0f, -1.0f);
glEnd();


Luego aprendimos a dibujar un polígono bidimensional y realizamos las figuras en una clase separada GraphicalObject2dque puede rotar glRotate, moverse glTranslatey estirarse con glScale. Configuramos el color en cuatro canales usando glColor4f(r, g, b, a).

Con esta funcionalidad, ya puedes hacer una hermosa fuente de cuadrados. Cree una clase ParticleSystemque tenga una matriz de objetos. Cada iteración del bucle principal, el sistema de partículas actualiza los cuadrados antiguos y recopila algunos nuevos que comienza en una dirección aleatoria:



Cámara


El siguiente paso fue escribir una cámara que pudiera moverse y mirar en diferentes direcciones. Para entender cómo resolver este problema, necesitábamos conocimiento del álgebra lineal. Si esto no es muy interesante para usted, puede omitir la sección, ver el gif y seguir leyendo .

Queremos dibujar un vértice en las coordenadas de la pantalla, conociendo sus coordenadas relativas al centro del objeto al que pertenece.

  1. Primero, necesitamos encontrar sus coordenadas relativas al centro del mundo en el que se encuentra el objeto.
  2. Luego, conociendo las coordenadas y la ubicación de la cámara, encuentre la posición del vértice en la base de la cámara.
  3. Luego proyecte el vértice en el plano de la pantalla. 

Como puede ver, hay tres etapas. Corresponden a multiplicaciones por tres matrices. Llamamos a estas matrices Model, Viewy Projection.

Comencemos por obtener las coordenadas del objeto en la base del mundo. Se pueden hacer tres transformaciones con un objeto: escalar, rotar y mover. Todas estas operaciones se especifican multiplicando el vector original (coordenadas en la base del objeto) por las matrices correspondientes. Entonces la matriz Modelse verá así: 

Model = Translate * Scale * Rotate. 

Además, conociendo la posición de la cámara, queremos determinar las coordenadas en su base: multiplicar las coordenadas obtenidas previamente por la matriz View. En C ++, esto se calcula convenientemente usando la función:


glm::mat4 View = glm::lookAt(cameraPosition, objectPosition, up);

Literalmente: mire objectPositiondesde una posición cameraPosition, y la dirección hacia arriba es "arriba". ¿Por qué es necesaria esta dirección? Imagina fotografiar una tetera. Apuntas la cámara hacia él y colocas la tetera en el marco. En este punto, puede decir exactamente dónde está el marco en la parte superior (lo más probable es que el hervidor tenga tapa). El programa no puede descubrir cómo organizar el marco, y es por eso que se debe especificar el vector "arriba".

Obtuvimos las coordenadas en la base de la cámara, queda por proyectar las coordenadas obtenidas en el plano de la cámara. La matriz está involucrada en esto Projection, lo que crea el efecto de reducir el objeto cuando se lo elimina.

Para obtener las coordenadas del vértice en la pantalla, debe multiplicar el vector por la matriz al menos cinco veces. Todas las matrices tienen un tamaño de 4 por 4, por lo que debe realizar bastantes operaciones de multiplicación. No queremos cargar núcleos de procesador con muchas tareas simples. Para esto, una tarjeta de video que tenga los recursos necesarios es mejor. Por lo tanto, debe escribir un sombreador: una pequeña instrucción para una tarjeta de video. OpenGL tiene un lenguaje de sombreador GLSL especial, similar a C, que nos ayudará a hacer esto. No entremos en los detalles de escribir un sombreador, es mejor mirar finalmente lo que sucedió:


Explicación: Hay diez cuadrados que están a poca distancia uno del otro. En el lado derecho de ellos hay un jugador que gira y mueve la cámara. 

Física


¿Qué es un juego sin física? Para manejar la interacción física, decidimos usar la biblioteca Box2d y creamos una clase WorldObject2dque heredó de GraphicalObject2d. Desafortunadamente, Box2d no funcionó fuera de la caja, por lo que el valiente Ilya escribió un contenedor para b2Body y todas las conexiones físicas que se encuentran en esta biblioteca.


Hasta ese momento, pensamos hacer que los gráficos en el motor fueran absolutamente bidimensionales, y para la iluminación, si decidimos agregarlo, utilizamos la técnica de emisión de rayos. Pero teníamos a mano una cámara maravillosa que puede mostrar objetos en las tres dimensiones. Por lo tanto, agregamos grosor a todos los objetos bidimensionales, ¿por qué no? Además, en el futuro, esto le permitirá crear una iluminación bastante hermosa que dejará sombras de objetos gruesos.

La iluminación apareció entre los casos. Para crearlo, fue necesario escribir las instrucciones apropiadas para dibujar cada píxel: un sombreador de fragmentos.



Texturas


Utilizamos la biblioteca DevIL para subir imágenes. Cada uno se GraphicalObject2dvolvió apto para una instancia de la clase GraphicalPolygon, la parte frontal del objeto, y la GraphicalEdgeparte lateral. En cada uno puede estirar su textura. Primer resultado:


Todo lo que se requiere de los gráficos está listo: dibujo, una fuente de luz y textura. Gráficos: eso es todo por ahora.

Máquina de estado, configurando el comportamiento de los objetos.


Cada objeto, sea lo que sea, un estado en la máquina de estado, gráfico o físico, debe estar "marcando", es decir, cada iteración del bucle del juego se actualiza.

Los objetos que se pueden actualizar se heredan de la clase de comportamiento que creamos. Tiene funciones onStart, onActive, onStopque le permiten anular el comportamiento del heredero al inicio, durante la vida y al final de su actividad. Ahora necesitamos crear un objeto supremo Activityque llame a estas funciones desde todos los objetos. La función de bucle que hace esto es la siguiente:

void loop():
    onAwake();
    awake = true;
    while (awake):
        onStart();
        running = true
        while (running):
            onActive();
        onStop();
    onDestroy();

Por ahora running == true, alguien puede llamar a una función pause()que lo hace running = false. Si alguien llama kill(), entonces awake, y runninggira false, y la actividad se detiene por completo.

Problema: queremos pausar un grupo de objetos, por ejemplo, un sistema de partículas y partículas dentro de él. En el estado actual, debe llamar manualmente onPausea cada objeto, lo que no es muy conveniente.

Solución: todos Behaviortendrán una matriz subBehaviorsque actualizará, es decir:

void onStart():
	onStart() 		//     
	for sb in subBehaviors:
		sb.onStart()	//       Behavior
void onActive():
	onActive()
	for sb in subBehaviors:
		sb.onActive()

Y así sucesivamente, para cada función.

Pero no todos los comportamientos se pueden establecer de esta manera. Por ejemplo, si un enemigo está caminando sobre una plataforma - enemigo, lo más probable es que tenga diferentes estados: está de pie - idle_stay, está caminando sobre una plataforma sin darse cuenta - idle_walky en cualquier momento puede notarnos y entrar en un estado de ataque - attack. También quiero establecer convenientemente las condiciones para la transición entre estados, por ejemplo:

bool isTransitionActivated(): 		//  idle_walk->attack
	return canSee(enemy);

El patrón deseado es una máquina de estados. También la convertimos en la heredera Behavior, ya que en cada tic es necesario verificar si ha llegado el momento de cambiar el estado. Esto es útil no solo para los objetos del juego. Por ejemplo, Leveleste es un estado Level Switcher, y las transiciones dentro de la máquina controladora son condiciones para cambiar de nivel en el juego.

El estado tiene tres etapas: ha comenzado, está marcando, se ha detenido. Puede agregar algunas acciones a cada etapa, por ejemplo, adjuntar una textura a un objeto, aplicarle un impulso, establecer la velocidad, etc.

Conservación


Al crear un nivel en el editor, quiero poder guardarlo, y el juego en sí mismo debería poder cargar el nivel de los datos guardados. Por lo tanto, todos los objetos que deben guardarse se heredan de la clase NamedStoredObject. Almacena una cadena con el nombre, el nombre de la clase y tiene una función dump()que descarga datos sobre un objeto en una cadena.  

Para guardar, queda por anular simplemente dump()para cada objeto. Loading es un constructor de una cadena que contiene toda la información sobre el objeto. La descarga se completa cuando se realiza dicho constructor para cada objeto. 

De hecho, el juego y el editor son casi de la misma clase, solo en el juego el nivel se carga en modo de lectura y en el editor en modo de grabación. El motor utiliza la biblioteca rapidjson para escribir y leer objetos de json.

GUI


En algún momento, la pregunta surgió ante nosotros: dejar que se escriban los gráficos, la máquina de estado y todo lo demás. ¿Cómo puede un usuario escribir un juego usando esto? 

En la versión original, tendría que heredar Game2dy anular onActive, y crear objetos en los campos de la clase. Pero durante la creación, no puede ver lo que está creando, y también necesitaría compilar su programa y vincularlo a nuestra biblioteca. ¡Horror! Habría ventajas: se podrían preguntar comportamientos tan complejos que uno podría haber imaginado: por ejemplo, mover el bloque de tierra tanto como la vida del jugador, y hacerlo siempre que Urano esté en la constelación de Tauro y el euro no exceda 40 rublos Sin embargo, aún decidimos hacer una interfaz gráfica.

En la interfaz gráfica, el número de acciones que se pueden realizar con un objeto será limitado: hojear la diapositiva de animación, aplicar fuerza, establecer una velocidad determinada, etc. La misma situación con las transiciones en la máquina de estado. En motores grandes, el problema de un número limitado de acciones se resuelve vinculando el programa actual con otro, por ejemplo, Unity y Godot usan el enlace con C #. Ya desde este script puede hacer cualquier cosa: y ver en qué constelación de Urano, y cuál es el tipo de cambio actual del euro. No tenemos esa funcionalidad en este momento, pero nuestros planes incluyen conectar el motor con Python 3.

Para implementar la interfaz gráfica, decidimos usar Dear ImGui, porque es muy pequeño (en comparación con el conocido Qt) y escribir en él es muy simple. ImGui: el paradigma de crear una interfaz gráfica. En él, cada iteración del bucle principal, todos los widgets y ventanas se vuelven a dibujar solo si es necesario. Por un lado, esto reduce la cantidad de memoria consumida, pero por otro lado, lo más probable es que tarde más de una ejecución de la compleja función de crear y guardar la información necesaria para el dibujo posterior. Solo queda implementar las interfaces para crear y editar.

Así es como se ve la interfaz gráfica en el momento del lanzamiento del artículo:


Editor de niveles


Editor de máquinas de estado

Conclusión


Hemos creado solo la base sobre la cual puedes colgar algo más interesante. En otras palabras, hay espacio para el crecimiento: puede implementar renderizado de sombras, la capacidad de crear más de una fuente de luz, puede conectar el motor con el intérprete de Python 3 para escribir scripts para el juego. Me gustaría refinar la interfaz: hacerla más bella, agregar más objetos diferentes, admitir teclas de acceso rápido ...

Todavía hay mucho trabajo, pero estamos contentos con lo que tenemos en este momento. 

Durante la creación del proyecto, obtuvimos mucha experiencia diversa: trabajar con gráficos, crear interfaces gráficas, trabajar con archivos json, envoltorios de numerosas bibliotecas C. Y también la experiencia de escribir el primer gran proyecto en equipo. Esperamos haber podido contarlo tan interesante como fue interesante tratarlo :)

Enlace al proyecto gihab : github.com/Glebanister/ample

All Articles