Remarque: le code source complet de ce projet est disponible ici: [ source ].Lorsque le projet sur lequel je travaille commence à s'essouffler, j'ajoute de nouvelles visualisations qui me motivent à aller de l'avant.
Après la sortie du concept original de
Task-Bot [
traduction en Habré], j'ai senti que j'étais limité par l'espace bidimensionnel dans lequel je travaillais. Il semblait qu'il restreignait les possibilités de comportement émergent des robots.
Les précédentes tentatives infructueuses pour apprendre l'OpenGL moderne m'ont mis une barrière mentale, mais fin juillet, je l'ai finalement franchie. Aujourd'hui, fin octobre, j'ai déjà une compréhension assez confiante des concepts, j'ai donc sorti mon propre moteur de voxel simple, qui sera l'environnement pour la vie et la prospérité de mes Task-Bots.
J'ai décidé de créer mon propre moteur, car j'avais besoin d'un contrôle total sur les graphismes; en plus, je voulais me tester. D'une certaine manière, j'inventais un vélo, mais j'ai vraiment aimé ce processus!
Le but ultime de l'ensemble du projet était une simulation complète de l'écosystème, où des robots jouant le rôle d'agents manipulent l'environnement et interagissent avec lui.
Étant donné que le moteur a déjà avancé un peu et que je passerai à nouveau à la programmation des bots, j'ai décidé d'écrire un article sur le moteur, ses fonctions et sa mise en œuvre afin de me concentrer sur les tâches de niveau supérieur à l'avenir.
Concept moteur
Le moteur est entièrement écrit à partir de zéro en C ++ (à quelques exceptions près, comme la recherche d'un chemin d'accès). J'utilise SDL2 pour rendre le contexte et traiter les entrées, OpenGL pour rendre une scène 3D et DearImgui pour contrôler la simulation.
J'ai décidé d'utiliser des voxels principalement parce que je voulais travailler avec une grille qui présente de nombreux avantages:
- La création de maillages pour le rendu est bien comprise pour moi.
- Les capacités de stockage de données du monde sont plus diverses et compréhensibles.
- J'ai déjà créé des systèmes pour générer des simulations de terrain et de climat à partir de maillages.
- Les tâches des bots dans la grille sont plus faciles à paramétrer.
Le moteur se compose d'un système de données mondial, d'un système de rendu et de plusieurs classes auxiliaires (par exemple, pour le traitement du son et des entrées).
Dans l'article, je vais parler de la liste actuelle des fonctionnalités, ainsi que regarder de plus près les sous-systèmes les plus complexes.
Classe mondiale
La classe mondiale sert de classe de base pour stocker toutes les informations du monde. Il gère la génération, le chargement et le stockage des données de bloc.
Les données de bloc sont stockées dans des morceaux de taille constante (16 ^ 3), et le monde stocke le vecteur de fragment chargé dans la mémoire virtuelle. Dans les grands mondes, il est pratiquement nécessaire de ne se souvenir que d'une certaine partie du monde, c'est pourquoi j'ai choisi cette approche.
class World{ public: World(std::string _saveFile){ saveFile = _saveFile; loadWorld(); }
Les fragments stockent les données de bloc, ainsi que certaines autres métadonnées, dans un tableau plat. Au départ, j'ai implémenté ma propre arborescence octree clairsemée pour stocker des fragments, mais il s'est avéré que le temps d'accès aléatoire était trop élevé pour créer des maillages. Et bien qu'un tableau plat ne soit pas optimal du point de vue de la mémoire, il offre la possibilité de créer très rapidement des maillages et des manipulations avec des blocs, ainsi qu'un accès au chemin de recherche.
class Chunk{ public:
Si jamais j'implémente des fragments d'enregistrement et de chargement multithread, la conversion d'un tableau plat en une arborescence octree clairsemée et vice versa peut être une option tout à fait possible pour économiser de la mémoire. Il y a encore de la place pour l'optimisation!
Mon implémentation de l'arbre octree clairsemé est stockée dans le code, vous pouvez donc l'utiliser en toute sécurité.
Stockage des fragments et gestion de la mémoire
Les fragments ne sont visibles que lorsqu'ils se trouvent dans la distance de rendu de la position actuelle de la caméra. Cela signifie que lorsque la caméra se déplace, vous devez charger et composer dynamiquement des fragments dans les maillages.
Les fragments sont sérialisés à l'aide de la bibliothèque boost, et les données mondiales sont stockées sous la forme d'un simple fichier texte, dans lequel chaque fragment est une ligne du fichier. Ils sont générés dans un ordre spécifique afin de pouvoir être "ordonnés" dans un fichier monde. Ceci est important pour d'autres optimisations.
Dans le cas d'un grand monde, le principal goulot d'étranglement est la lecture du fichier mondial et le chargement / écriture de fragments. Idéalement, il suffit de télécharger et de transférer le fichier mondial.
Pour ce faire, la méthode
World::bufferChunks()
supprime les fragments qui sont dans la mémoire virtuelle mais qui sont invisibles et charge intelligemment les nouveaux fragments du fichier world.
Par intelligence, il veut simplement décider quels nouveaux fragments charger, les trier par leur position dans le fichier de sauvegarde, puis faire un passage. Tout est très simple.
void World::bufferChunks(View view){
Un exemple de chargement de fragments avec une petite distance de rendu. Les artefacts de distorsion d'écran sont causés par un logiciel d'enregistrement vidéo. Des pics notables dans les téléchargements se produisent parfois, principalement dus au maillageDe plus, j'ai défini un indicateur indiquant que le moteur de rendu doit recréer le maillage du fragment chargé.
Classe Blueprint et editBuffer
editBuffer est un conteneur bufferObjects triable qui contient des informations sur l'édition dans l'espace universel et l'espace fragmenté.
Si, lorsque vous apportez des modifications au monde, les écrivez dans un fichier immédiatement après avoir effectué la modification, alors nous devrons transférer le fichier texte entier et écrire CHAQUE modification. C'est terrible en termes de performances.
Donc, j'écris d'abord toutes les modifications qui doivent être apportées à editBuffer en utilisant la méthode addEditBuffer (qui calcule également la position des modifications dans l'espace du fragment). Avant de les écrire dans un fichier, je trie les changements dans l'ordre des fragments auxquels ils appartiennent en fonction de leur emplacement dans le fichier.
L'écriture des modifications dans un fichier consiste en un transfert de fichier, le chargement de chaque ligne (c'est-à-dire un fragment), pour lequel il y a des modifications dans editBuffer, en apportant toutes les modifications et en l'écrivant dans un fichier temporaire jusqu'à ce que editBuffer devienne vide. Cela se fait dans la fonction
evaluateBlueprint()
, qui est assez rapide.
bool World::evaluateBlueprint(Blueprint &_blueprint){
La classe blueprint contient editBuffer, ainsi que plusieurs méthodes qui vous permettent de créer des editBuffers pour des objets spécifiques (arbres, cactus, huttes, etc.). Ensuite, le plan peut être converti à la position où vous souhaitez placer l'objet, puis il suffit de l'écrire dans la mémoire du monde.
L'une des plus grandes difficultés lorsque vous travaillez avec des fragments est que les changements dans plusieurs blocs entre les limites des fragments peuvent se révéler être un processus monotone avec beaucoup de modulo arithmétique et divisant les changements en plusieurs parties. C'est le principal problème que la classe de plans directeurs gère avec brio.
Je l'utilise activement au stade de la génération mondiale pour élargir le «goulot d'étranglement» de l'écriture des modifications dans un fichier.
void World::generate(){
La classe mondiale stocke son propre plan des modifications apportées au monde, de sorte que lorsque bufferChunks () est appelé, toutes les modifications sont écrites sur le disque dur en une seule passe, puis supprimées de la mémoire virtuelle.
Rendu
Le rendu dans sa structure n'est pas très compliqué, mais il nécessite une connaissance d'OpenGL pour comprendre. Toutes ses parties ne sont pas intéressantes, ce sont principalement des wrappers de fonctionnalités OpenGL. J'ai expérimenté la visualisation pendant un certain temps pour obtenir ce que j'aime.
La simulation n'étant pas de la première personne, j'ai choisi la projection orthographique. Il pouvait être implémenté au format pseudo-3D (c'est-à-dire pour pré-projeter des tuiles et les superposer dans un logiciel de rendu), mais cela me semblait idiot. Je suis content d'avoir opté pour OpenGL.
La classe de base pour le rendu est appelée View, elle contient la plupart des variables importantes qui contrôlent la visualisation de la simulation:
- Taille de l'écran et texture de l'ombre
- Objets shader, caméra, matrice, etc. facteurs de zoom
- Valeurs booléennes pour presque toutes les fonctions de rendu
- Menu, brouillard, profondeur de champ, texture du grain, etc.
- Couleurs pour l'éclairage, le brouillard, le ciel, la sélection des fenêtres, etc.
De plus, il existe plusieurs classes d'assistance qui effectuent le rendu et le wrapper d'OpenGL lui-même!
- Shader de classe
- Charge, compile, compile et utilise des shaders GLSL
- Classe de modèle
- Contient des fragments de données VAO (Vertex Arrays Object) pour le rendu, la fonction de création de maillages et la méthode de rendu.
- Panneau d'affichage de classe
- Contient le FBO (FrameBuffer Object) pour le rendu - utile pour créer des effets de post-traitement et d'ombrage.
- Classe Sprite
- Dessine un quadrilatère orienté par rapport à la caméra, chargé à partir d'un fichier de texture (pour les robots et les objets). Peut également gérer des animations!
- Classe d'interface
- Pour travailler avec ImGUI
- Classe audio
- Prise en charge sonore très rudimentaire (si vous compilez le moteur, appuyez sur "M")
Haute profondeur de champ (DOF). À de grandes distances de rendu, cela peut être lent, mais j'ai fait tout cela sur mon ordinateur portable. Peut-être que sur un bon ordinateur, les freins seront invisibles. Je comprends que cela me fatigue les yeux et je l'ai fait juste pour le plaisir.L'image ci-dessus montre certains paramètres qui peuvent être modifiés pendant la manipulation. J'ai également mis en place le passage en mode plein écran. L'image montre un exemple d'un sprite de bot rendu comme un quadrilatère texturé dirigé vers la caméra. Les maisons et les cactus de l'image sont construits à l'aide d'un plan.
Création de maillages de fragments
Au départ, j'ai utilisé la version naïve de création de maillages: j'ai simplement créé un cube et jeté les sommets qui ne touchaient pas l'espace vide. Cependant, cette solution était lente et lors du chargement de nouveaux fragments, la création de maillages s'est avérée être des «goulots d'étranglement» encore plus étroits que l'accès au fichier.
Le principal problème était la création efficace de VBO rendus à partir de fragments, mais j'ai réussi à implémenter en C ++ ma propre version de «maillage gourmand», compatible avec OpenGL (sans structures étranges avec boucles). Vous pouvez utiliser mon code en toute conscience.
void Model::fromChunkGreedy(Chunk chunk){
En général, la transition vers un maillage gourmand a réduit le nombre de quadrangles dessinés de 60% en moyenne. Ensuite, après d'autres optimisations mineures (indexation VBO), le nombre a été réduit d'un autre 1/3 (de 6 sommets au bord à 4 sommets).
Lors du rendu d'une scène de fragments 5x1x5 dans une fenêtre qui n'est pas maximisée, j'obtiens une moyenne d'environ 140 FPS (avec VSYNC désactivé).
Bien que je sois assez satisfait de ce résultat, j'aimerais toujours proposer un système de rendu de modèles non cubiques à partir de données mondiales. Ce n'est pas si facile à intégrer avec un maillage gourmand, donc ça vaut la peine d'être considéré.
Shaders et surbrillance voxel
L'implémentation des shaders GLSL est l'une des parties les plus intéressantes et en même temps les plus ennuyeuses de l'écriture du moteur en raison de la complexité du débogage sur le GPU. Je ne suis pas un spécialiste GLSL, j'ai donc dû apprendre beaucoup en déplacement.
Les effets que j'ai mis en œuvre utilisent activement le FBO et l'échantillonnage de texture (par exemple, le flou, l'ombrage et l'utilisation des informations de profondeur).
Je n'aime toujours pas le modèle d'éclairage actuel, car il ne gère pas très bien le «sombre». J'espère que cela sera corrigé à l'avenir lorsque je travaillerai sur le cycle de changement du jour et de la nuit.
J'ai également implémenté une fonction de sélection de voxels simple en utilisant l'algorithme de Bresenham modifié (c'est un autre avantage de l'utilisation de voxels). Il est utile pour obtenir des informations spatiales lors de la simulation. Mon implémentation ne fonctionne que pour les projections orthographiques, mais vous pouvez l'utiliser.
Citrouille "surlignée".Cours de jeu
Plusieurs classes auxiliaires ont été créées pour le traitement des entrées, des messages de débogage, ainsi qu'une classe Item distincte avec des fonctionnalités de base (qui seront encore développées).
class eventHandler{ public:
Mon gestionnaire d'événements est moche, mais fonctionnel. J'accepterai volontiers des recommandations pour son amélioration, en particulier sur l'utilisation de SDL Poll Event.
Dernières notes
Le moteur lui-même est juste un système dans lequel je mets mes tâches-bots (je vais en parler en détail dans le prochain post). Mais si vous avez trouvé mes méthodes intéressantes et que vous voulez en savoir plus, écrivez-moi.
Ensuite, j'ai porté le système de robots de tâches (le véritable cœur de ce projet) dans le monde 3D et j'ai considérablement élargi ses capacités, mais plus à ce sujet plus tard (cependant, le code a déjà été mis en ligne)!