Nous écrivons notre propre moteur voxel

image

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(); } //Data Storage std::vector<Chunk> chunks; //Loaded Chunks std::stack<int> updateModels; //Models to be re-meshed void bufferChunks(View view); //Generation void generate(); Blueprint blueprint; bool evaluateBlueprint(Blueprint &_blueprint); //File IO Management std::string saveFile; bool loadWorld(); bool saveWorld(); //other... int SEED = 100; int chunkSize = 16; int tickLength = 1; glm::vec3 dim = glm::vec3(20, 5, 20); //... 

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: //Position information and size information glm::vec3 pos; int size; BiomeType biome; //Data Storage Member int data[16*16*16] = {0}; bool refreshModel = false; //Get the Flat-Array Index int getIndex(glm::vec3 _p); void setPosition(glm::vec3 _p, BlockType _type); BlockType getPosition(glm::vec3 _p); glm::vec4 getColorByID(BlockType _type); }; 

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){ //Load / Reload all Visible Chunks evaluateBlueprint(blueprint); //Chunks that should be loaded glm::vec3 a = glm::floor(view.viewPos/glm::vec3(chunkSize))-view.renderDistance; glm::vec3 b = glm::floor(view.viewPos/glm::vec3(chunkSize))+view.renderDistance; //Can't exceed a certain size a = glm::clamp(a, glm::vec3(0), dim-glm::vec3(1)); b = glm::clamp(b, glm::vec3(0), dim-glm::vec3(1)); //Chunks that need to be removed / loaded std::stack<int> remove; std::vector<glm::vec3> load; //Construct the Vector of chunks we should load for(int i = ax; i <= bx; i ++){ for(int j = ay; j <= by; j ++){ for(int k = az; k <= bz; k ++){ //Add the vector that we should be loading load.push_back(glm::vec3(i, j, k)); } } } //Loop over all existing chunks for(unsigned int i = 0; i < chunks.size(); i++){ //Check if any of these chunks are outside of the limits if(glm::any(glm::lessThan(chunks[i].pos, a)) || glm::any(glm::greaterThan(chunks[i].pos, b))){ //Add the chunk to the erase pile remove.push(i); } //Don't reload chunks that remain for(unsigned int j = 0; j < load.size(); j++){ if(glm::all(glm::equal(load[j], chunks[i].pos))){ //Remove the element from load load.erase(load.begin()+j); } } //Flags for the Viewclass to use later updateModels = remove; //Loop over the erase pile, delete the relevant chunks. while(!remove.empty()){ chunks.erase(chunks.begin()+remove.top()); remove.pop(); } //Check if we want to load any guys if(!load.empty()){ //Sort the loading vector, for single file-pass std::sort(load.begin(), load.end(), [](const glm::vec3& a, const glm::vec3& b) { if(ax > bx) return true; if(ax < bx) return false; if(ay > by) return true; if(ay < by) return false; if(az > bz) return true; if(az < bz) return false; return false; }); boost::filesystem::path data_dir( boost::filesystem::current_path() ); data_dir /= "save"; data_dir /= saveFile; std::ifstream in((data_dir/"world.region").string()); Chunk _chunk; int n = 0; while(!load.empty()){ //Skip Lines (this is dumb) while(n < load.back().x*dim.z*dim.y+load.back().y*dim.z+load.back().z){ in.ignore(1000000,'\n'); n++; } //Load the Chunk { boost::archive::text_iarchive ia(in); ia >> _chunk; chunks.push_back(_chunk); load.pop_back(); } } in.close(); } } 


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 maillage

De 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é.

 //EditBuffer Object Struct struct bufferObject { glm::vec3 pos; glm::vec3 cpos; BlockType type; }; //Edit Buffer! std::vector<bufferObject> editBuffer; 

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){ //Check if the editBuffer isn't empty! if(_blueprint.editBuffer.empty()){ return false; } //Sort the editBuffer std::sort(_blueprint.editBuffer.begin(), _blueprint.editBuffer.end(), std::greater<bufferObject>()); //Open the File boost::filesystem::path data_dir(boost::filesystem::current_path()); data_dir /= "save"; data_dir /= saveFile; //Load File and Write File std::ifstream in((data_dir/"world.region").string()); std::ofstream out((data_dir/"world.region.temp").string(), std::ofstream::app); //Chunk for Saving Data Chunk _chunk; int n_chunks = 0; //Loop over the Guy while(n_chunks < dim.x*dim.y*dim.z){ if(in.eof()){ return false; } //Archive Serializers boost::archive::text_oarchive oa(out); boost::archive::text_iarchive ia(in); //Load the Chunk ia >> _chunk; //Overwrite relevant portions while(!_blueprint.editBuffer.empty() && glm::all(glm::equal(_chunk.pos, _blueprint.editBuffer.back().cpos))){ //Change the Guy _chunk.setPosition(glm::mod(_blueprint.editBuffer.back().pos, glm::vec3(chunkSize)), _blueprint.editBuffer.back().type); _blueprint.editBuffer.pop_back(); } //Write the chunk back oa << _chunk; n_chunks++; } //Close the fstream and ifstream in.close(); out.close(); //Delete the first file, rename the temp file boost::filesystem::remove_all((data_dir/"world.region").string()); boost::filesystem::rename((data_dir/"world.region.temp").string(),(data_dir/"world.region").string()); //Success! return true; } 

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(){ //Create an editBuffer that contains a flat surface! blueprint.flatSurface(dim.x*chunkSize, dim.z*chunkSize); //Write the current blueprint to the world file. evaluateBlueprint(blueprint); //Add a tree Blueprint _tree; evaluateBlueprint(_tree.translate(glm::vec3(x, y, z))); } 

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){ //... (this is part of the model class - find on github!) } 

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{ /* This class handles user input, creates an appropriate stack of activated events and handles them so that user inputs have continuous effect. */ public: //Queued Inputs std::deque<SDL_Event*> inputs; //General Key Inputs std::deque<SDL_Event*> scroll; //General Key Inputs std::deque<SDL_Event*> rotate; //Rotate Key Inputs SDL_Event* mouse; //Whatever the mouse is doing at a moment SDL_Event* windowevent; //Whatever the mouse is doing at a moment bool _window; bool move = false; bool click = false; bool fullscreen = false; //Take inputs and add them to stack void input(SDL_Event *e, bool &quit, bool &paused); //Handle the existing stack every tick void update(World &world, Player &player, Population &population, View &view, Audio &audio); //Handle Individual Types of Events void handlePlayerMove(World &world, Player &player, View &view, int a); void handleCameraMove(World &world, View &view); }; 

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)!

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


All Articles