Écrire un moteur de jeu dès votre première année: facile! (Presque)

salut! Je m'appelle Gleb Maryin, je suis en première année de premier cycle "Mathématiques appliquées et informatique" au HSE de Saint-Pétersbourg. Au deuxième semestre, tous les étudiants de première année de notre programme font des projets d'équipe en C ++. Mes coéquipiers et moi avons décidé d'écrire un moteur de jeu. 

Lisez ce que nous obtenons sous le chat.


Nous sommes trois dans l'équipe: moi, Alexei Luchinin et Ilya Onofriychuk. Aucun de nous n'est expert en développement de jeux, encore moins en création de moteurs de jeux. Il s'agit du premier grand projet pour nous: avant cela, nous ne faisions que des devoirs et des travaux de laboratoire, il est donc peu probable que les professionnels du domaine de l'infographie trouvent par eux-mêmes de nouvelles informations. Nous serons heureux si nos idées aident ceux qui veulent également créer leur propre moteur. Mais ce sujet est complexe et multiforme, et l'article ne prétend en aucun cas être une littérature spécialisée complète.

Tous ceux qui souhaitent en savoir plus sur notre mise en œuvre - bonne lecture!

Arts graphiques


Première fenêtre, souris et clavier


Pour créer des fenêtres, gérer la saisie de la souris et du clavier, nous avons choisi la bibliothèque SDL2. C'était un choix aléatoire, mais jusqu'à présent nous ne l'avons pas regretté. 

Il était important au tout premier stade d'écrire un wrapper pratique sur la bibliothèque afin que vous puissiez créer une fenêtre avec quelques lignes, faire des manipulations avec comme déplacer le curseur et entrer en mode plein écran et gérer les événements: frappes, mouvements du curseur. La tâche n'a pas été difficile: nous avons rapidement créé un programme qui permet de fermer et d'ouvrir une fenêtre, et lorsque vous cliquez sur RMB, affichez «Bonjour tout le monde!». 

Puis le cycle de jeu principal est apparu:

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

Chaque gestionnaire d'événements attaché - handlerspar exemple handlers[QUIT] = {QuitHandler()}. Leur tâche consiste à gérer l'événement correspondant. QuitHandlerDans l'exemple, il exposera running = false, arrêtant ainsi le jeu.

Bonjour le monde


Pour dessiner dans le moteur que nous utilisons OpenGL. Le premier Hello World, comme je pense, dans de nombreux projets, était un carré blanc sur fond noir: 

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


Ensuite, nous avons appris à dessiner un polygone à deux dimensions et réalisé les figures dans une classe distincte GraphicalObject2d, qui peut tourner avec glRotate, se déplacer avec glTranslateet s'étirer avec glScale. Nous définissons la couleur sur quatre canaux en utilisant glColor4f(r, g, b, a).

Avec cette fonctionnalité, vous pouvez déjà faire une belle fontaine de carrés. Créez une classe ParticleSystemqui possède un tableau d'objets. À chaque itération de la boucle principale, le système de particules met à jour les anciens carrés et en recueille de nouveaux qu'il démarre dans une direction aléatoire:



Caméra


L'étape suivante consistait à écrire une caméra capable de se déplacer et de regarder dans différentes directions. Pour comprendre comment résoudre ce problème, nous avions besoin de connaissances en algèbre linéaire. Si ce n'est pas très intéressant pour vous, vous pouvez ignorer la section, voir le gif et continuer la lecture .

Nous voulons dessiner un sommet dans les coordonnées de l'écran, connaissant ses coordonnées par rapport au centre de l'objet auquel il appartient.

  1. Tout d'abord, nous devons trouver ses coordonnées par rapport au centre du monde dans lequel se trouve l'objet.
  2. Puis, connaissant les coordonnées et l'emplacement de la caméra, trouvez la position du sommet dans la base de la caméra.
  3. Projetez ensuite le sommet sur le plan de l'écran. 

Comme vous pouvez le voir, il y a trois étapes. La multiplication par trois matrices leur correspond. Nous avons appelé ces matrices Model, Viewet Projection.

Commençons par obtenir les coordonnées de l'objet dans la base du monde. Trois transformations peuvent être effectuées avec un objet: redimensionner, faire pivoter et déplacer. Toutes ces opérations sont spécifiées en multipliant le vecteur d'origine (coordonnées dans la base de l'objet) par les matrices correspondantes. Ensuite, la matrice Modelressemblera à ceci: 

Model = Translate * Scale * Rotate. 

De plus, connaissant la position de la caméra, nous voulons déterminer les coordonnées dans sa base: multiplier les coordonnées précédemment obtenues par la matrice View. En C ++, cela est facilement calculé à l'aide de la fonction:


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

Littéralement: regardez objectPositiond'une position cameraPosition, et la direction vers le haut est «vers le haut». Pourquoi cette direction est-elle nécessaire? Imaginez prendre une photo d'une bouilloire. Vous pointez la caméra vers lui et placez la bouilloire dans le cadre. À ce stade, vous pouvez dire exactement où le cadre est en haut (très probablement là où la bouilloire a un couvercle). Le programme ne peut pas comprendre comment organiser le cadre, et c'est pourquoi le vecteur "up" doit être spécifié.

Nous avons obtenu les coordonnées dans la base de la caméra, il reste à projeter les coordonnées obtenues sur le plan de la caméra. La matrice est engagée dans cela Projection, ce qui crée l'effet de réduire l'objet lorsqu'il est retiré de nous.

Pour obtenir les coordonnées du sommet à l'écran, vous devez multiplier le vecteur par la matrice au moins cinq fois. Toutes les matrices ont une taille de 4 par 4, vous devez donc effectuer plusieurs opérations de multiplication. Nous ne voulons pas charger des cœurs de processeur avec beaucoup de tâches simples. Pour cela, une carte vidéo disposant des ressources nécessaires est préférable. Donc, vous devez écrire un shader: une petite instruction pour une carte vidéo. OpenGL a un langage de shader GLSL spécial, similaire à C, qui nous aidera à le faire. N'entrons pas dans les détails de l'écriture d'un shader, il vaut mieux regarder enfin ce qui s'est passé:


Explication: Il y a dix carrés qui sont distants l'un de l'autre. Sur le côté droit d'eux est un joueur qui tourne et déplace la caméra. 

La physique


Qu'est-ce qu'un jeu sans physique? Pour gérer l'interaction physique, nous avons décidé d'utiliser la bibliothèque Box2d et avons créé une classe WorldObject2dhéritée de GraphicalObject2d. Malheureusement, Box2d n'a pas fonctionné hors de la boîte, donc le courageux Ilya a écrit un wrapper pour b2Body et toutes les connexions physiques qui se trouvent dans cette bibliothèque.


Jusqu'à ce moment, nous avons pensé à rendre les graphismes du moteur absolument bidimensionnels, et pour l'éclairage, si nous décidons de l'ajouter, utilisez la technique du raycasting. Mais nous avions en main un magnifique appareil photo qui peut afficher des objets dans les trois dimensions. Par conséquent, nous avons ajouté de l'épaisseur à tous les objets bidimensionnels - pourquoi pas? De plus, à l'avenir, cela vous permettra de faire un très bel éclairage qui laissera des ombres d'objets épais.

L'éclairage est apparu entre les cas. Pour le créer, il était nécessaire d'écrire les instructions appropriées pour dessiner chaque pixel - un fragment shader.



Textures


Nous avons utilisé la bibliothèque DevIL pour télécharger des images. Chacun GraphicalObject2dest devenu en forme une instance de la classe GraphicalPolygon- la partie avant de l'objet - et GraphicalEdge- la partie latérale. Sur chacun, vous pouvez étirer votre texture. Premier résultat:


Tout ce qui est requis des graphiques est prêt: dessin, une seule source lumineuse et texture. Graphiques - c'est tout pour l'instant.

State machine, définition du comportement des objets


Chaque objet, quel qu'il soit - un état dans la machine à états, graphique ou physique - doit être "ticking", c'est-à-dire que chaque itération de la boucle de jeu est mise à jour.

Les objets pouvant être mis à jour sont hérités de la classe de comportement que nous avons créée. Il dispose de fonctions onStart, onActive, onStopqui vous permettent de passer outre le comportement de l'héritier au démarrage, pendant la vie et à la fin de son activité. Maintenant, nous devons créer un objet suprême Activityqui appelle ces fonctions à partir de tous les objets. La fonction de boucle qui le fait est la suivante:

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

Pour l'instant running == true, quelqu'un peut appeler une fonction pause()qui le fait running = false. Si quelqu'un appelle kill(), puis awake, et runningtournez-vous vers l' falseactivité et arrêtez-vous complètement.

Problème: nous voulons mettre en pause un groupe d'objets, par exemple, un système de particules et de particules à l'intérieur. Dans l'état actuel, vous devez appeler manuellement onPausepour chaque objet, ce qui n'est pas très pratique.

Solution: tout Behaviorle monde aura un tableau subBehaviorsqu'il mettra à jour, c'est-à-dire:

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

Et ainsi de suite, pour chaque fonction.

Mais tous les comportements ne peuvent pas être définis de cette manière. Par exemple, si un ennemi marche sur une plate-forme - ennemi, alors il a très probablement des états différents: il est debout - idle_stay, il marche sur une plate-forme sans nous remarquer - idle_walket à tout moment il peut nous remarquer et entrer dans un état d'attaque - attack. Je veux également définir commodément les conditions de la transition entre les États, par exemple:

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

Le modèle souhaité est une machine d'état. Nous avons également fait d'elle l'héritière Behavior, car à chaque tick il faut vérifier si le moment est venu de changer d'état. Ceci est utile non seulement pour les objets du jeu. Par exemple, Levelil s'agit d'un état Level Switcheret les transitions à l'intérieur de la machine du contrôleur sont des conditions pour changer de niveau dans le jeu.

L'État a trois étapes: il a commencé, il tourne, il s'est arrêté. Vous pouvez ajouter des actions à chaque étape, par exemple, attacher une texture à un objet, lui appliquer une impulsion, définir la vitesse, etc.

Préservation


En créant un niveau dans l'éditeur, je veux pouvoir le sauvegarder, et le jeu lui-même devrait pouvoir charger le niveau à partir des données enregistrées. Par conséquent, tous les objets qui doivent être enregistrés sont hérités de la classe NamedStoredObject. Il stocke une chaîne avec le nom, le nom de classe et possède une fonction dump()qui transfère les données sur un objet dans une chaîne.  

Pour effectuer une sauvegarde, il reste simplement à remplacer dump()pour chaque objet. Le chargement est un constructeur à partir d'une chaîne contenant toutes les informations sur l'objet. Le téléchargement est terminé lorsqu'un tel constructeur est créé pour chaque objet. 

En fait, le jeu et l'éditeur sont presque la même classe, seulement dans le jeu le niveau est chargé en mode lecture et dans l'éditeur en mode enregistrement. Le moteur utilise la bibliothèque rapidjson pour écrire et lire des objets à partir de json.

GUI


À un moment donné, la question s'est posée à nous: que les graphiques, la machine d'état et tout le reste soient écrits. Comment un utilisateur peut-il écrire un jeu en utilisant cela? 

Dans la version originale, il devrait hériter Game2det remplacer onActive, et créer des objets dans les champs de la classe. Mais pendant la création, il ne peut pas voir ce qu'il crée, et il devrait également compiler son programme et se connecter à notre bibliothèque. Horreur! Il y aurait des avantages - on pourrait demander des comportements si complexes qu'on aurait pu imaginer: par exemple, déplacer le bloc de terre autant que la vie du joueur, et cela à condition qu'Uranus soit dans la constellation du Taureau et que l'euro ne dépasse pas 40 roubles. Cependant, nous avons quand même décidé de faire une interface graphique.

Dans l'interface graphique, le nombre d'actions pouvant être effectuées avec un objet sera limité: parcourir la diapositive d'animation, appliquer une force, définir une certaine vitesse, etc. Même situation avec les transitions dans la machine d'état. Dans les gros moteurs, le problème d'un nombre limité d'actions est résolu en liant le programme actuel à un autre - par exemple, Unity et Godot utilisent la liaison avec C #. Déjà à partir de ce script, vous pouvez tout faire: et voir dans quelle constellation Uranus, et quel est le taux de change de l'euro actuel. Nous n'avons pas de telles fonctionnalités pour le moment, mais nos plans incluent la connexion du moteur avec Python 3.

Pour implémenter l'interface graphique, nous avons décidé d'utiliser Dear ImGui, car il est très petit (par rapport au Qt bien connu) et écrire dessus est très simple. ImGui - le paradigme de la création d'une interface graphique. Dans celui-ci, à chaque itération de la boucle principale, tous les widgets et fenêtres sont redessinés uniquement si nécessaire. D'une part, cela réduit la quantité de mémoire consommée, mais d'autre part, cela prend très probablement plus d'une exécution de la fonction complexe de création et d'enregistrement des informations nécessaires pour le dessin ultérieur. Il ne reste plus qu'à implémenter les interfaces de création et d'édition.

Voici à quoi ressemble l'interface graphique au moment de la publication:


Éditeur de niveau


Éditeur de machine d'état

Conclusion


Nous avons créé uniquement la base sur laquelle vous pouvez accrocher quelque chose de plus intéressant. En d'autres termes, il y a de la place pour la croissance: vous pouvez implémenter le rendu d'ombre, la possibilité de créer plus d'une source lumineuse, vous pouvez connecter le moteur avec l'interpréteur Python 3 pour écrire des scripts pour le jeu. Je voudrais affiner l'interface: la rendre plus belle, ajouter plus d'objets différents, prendre en charge les touches de raccourci ...

Il y a encore beaucoup de travail, mais nous sommes satisfaits de ce que nous avons en ce moment. 

Lors de la création du projet, nous avons acquis de nombreuses expériences diverses: travailler avec des graphiques, créer des interfaces graphiques, travailler avec des fichiers json, wrappers de nombreuses bibliothèques C. Et aussi l'expérience d'écrire le premier grand projet en équipe. Nous espérons avoir pu en parler aussi intéressant qu'intéressant de le traiter :)

Lien vers le projet gihab : github.com/Glebanister/ample

All Articles