Wir schreiben unseren eigenen Voxelmotor

Bild

Hinweis: Der vollständige Quellcode für dieses Projekt ist hier verfügbar: [ Quelle ].

Wenn dem Projekt, an dem ich arbeite, die Puste ausgeht, füge ich neue Visualisierungen hinzu, die mich motivieren, weiterzumachen.

Nach der Veröffentlichung des ursprünglichen Task-Bot- Konzepts [ Übersetzung in Habré] fühlte ich mich durch den zweidimensionalen Raum, in dem ich arbeitete, eingeschränkt. Es schien, dass es die Möglichkeiten für das aufkommende Verhalten von Bots zurückhielt.

Frühere erfolglose Versuche, modernes OpenGL zu lernen, haben mir eine mentale Barriere auferlegt, aber Ende Juli habe ich es irgendwie endlich durchbrochen. Heute, Ende Oktober, habe ich bereits ein ziemlich sicheres Verständnis für Konzepte, daher habe ich meine eigene einfache Voxel-Engine veröffentlicht, die die Umgebung für das Leben und den Wohlstand meiner Task-Bots sein wird.

Ich habe beschlossen, meine eigene Engine zu erstellen, da ich die volle Kontrolle über die Grafiken brauchte. außerdem wollte ich mich testen. In gewisser Weise habe ich ein Fahrrad erfunden, aber dieser Prozess hat mir sehr gut gefallen!

Das ultimative Ziel des gesamten Projekts war eine vollständige Simulation des Ökosystems, bei der Bots in der Rolle von Agenten die Umgebung manipulieren und mit ihr interagieren.

Da sich die Engine bereits einiges weiterentwickelt hat und ich wieder mit dem Programmieren von Bots fortfahren werde, habe ich beschlossen, einen Beitrag über die Engine, ihre Funktionen und ihre Implementierung zu schreiben, um mich in Zukunft auf übergeordnete Aufgaben zu konzentrieren.

Motorkonzept


Die Engine ist in C ++ vollständig von Grund auf neu geschrieben (mit einigen Ausnahmen, z. B. dem Finden eines Pfads). Ich verwende SDL2 zum Rendern von Kontext- und Verarbeitungseingaben, OpenGL zum Rendern einer 3D-Szene und DearImgui zum Steuern der Simulation.

Ich habe mich hauptsächlich deshalb für Voxel entschieden, weil ich mit einem Raster arbeiten wollte, das viele Vorteile hat:

  • Das Erstellen von Maschen zum Rendern ist mir gut bekannt.
  • Die weltweiten Datenspeicherfunktionen sind vielfältiger und verständlicher.
  • Ich habe bereits Systeme zur Erzeugung von Gelände- und Klimasimulationen basierend auf Maschen erstellt.
  • Die Aufgaben von Bots im Raster sind einfacher zu parametrisieren.

Die Engine besteht aus einem Weltdatensystem, einem Rendering-System und mehreren Hilfsklassen (z. B. für die Ton- und Eingabeverarbeitung).

In dem Artikel werde ich auf die aktuelle Liste der Funktionen eingehen und die komplexeren Subsysteme genauer betrachten.

Weltklasse


Die Weltklasse dient als Basisklasse zum Speichern aller Informationen der Welt. Es übernimmt das Generieren, Laden und Speichern von Blockdaten.

Die Blockdaten werden in Blöcken konstanter Größe (16 ^ 3) gespeichert, und die Welt speichert den in den virtuellen Speicher geladenen Fragmentvektor. In großen Welten ist es praktisch notwendig, sich nur an einen bestimmten Teil der Welt zu erinnern, weshalb ich diesen Ansatz gewählt habe.

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); //... 

Fragmente speichern Blockdaten sowie einige andere Metadaten in einem flachen Array. Anfangs habe ich meinen eigenen spärlichen Octree-Baum zum Speichern von Fragmenten implementiert, aber es stellte sich heraus, dass die zufällige Zugriffszeit zu hoch ist, um Netze zu erstellen. Und obwohl ein flaches Array aus Sicht des Speichers nicht optimal ist, bietet es die Möglichkeit, sehr schnell Netze und Manipulationen mit Blöcken zu erstellen sowie auf den Suchpfad zuzugreifen.

 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); }; 

Wenn ich jemals das Speichern und Laden von Fragmenten mit mehreren Threads implementiere, kann das Konvertieren eines flachen Arrays in einen spärlichen Octree-Baum und umgekehrt eine vollständig mögliche Option zum Speichern von Speicher sein. Es gibt noch Raum für Optimierungen!

Meine Implementierung des spärlichen Octree-Baums ist im Code gespeichert, sodass Sie ihn sicher verwenden können.

Fragmentspeicherung und Speicherbehandlung


Fragmente sind nur sichtbar, wenn sie sich innerhalb des Renderabstands zur aktuellen Kameraposition befinden. Dies bedeutet, dass Sie beim Bewegen der Kamera Fragmente in den Netzen dynamisch laden und zusammensetzen müssen.

Fragmente werden mithilfe der Boost-Bibliothek serialisiert, und Weltdaten werden als einfache Textdatei gespeichert, in der jedes Fragment eine Zeile der Datei ist. Sie werden in einer bestimmten Reihenfolge generiert, damit sie in einer Weltdatei "bestellt" werden können. Dies ist wichtig für weitere Optimierungen.

Im Falle einer großen Welt besteht der Hauptengpass darin, die Weltdatei zu lesen und Fragmente zu laden / schreiben. Im Idealfall müssen wir nur die Weltdatei herunterladen und übertragen.

Zu diesem World::bufferChunks() entfernt die World::bufferChunks() -Methode Fragmente, die sich im virtuellen Speicher befinden, aber unsichtbar sind, und lädt intelligent neue Fragmente aus der World-Datei.

Mit Intelligenz ist gemeint, dass er einfach entscheidet, welche neuen Fragmente geladen werden sollen, sie nach ihrer Position in der Sicherungsdatei sortiert und dann einen Durchgang ausführt. Alles ist sehr einfach.

 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(); } } 


Ein Beispiel für das Laden von Fragmenten mit geringem Renderabstand. Bildschirmverzerrungsartefakte werden durch Videoaufzeichnungssoftware verursacht. Manchmal treten bei Downloads merkliche Spitzen auf, die hauptsächlich durch Vernetzung verursacht werden

Außerdem habe ich ein Flag gesetzt, das angibt, dass der Renderer das Netz des geladenen Fragments neu erstellen soll.

Blueprint Class und editBuffer


editBuffer ist ein sortierbarer bufferObjects-Container, der Informationen zum Bearbeiten im Weltraum und im Fragmentraum enthält.

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

Wenn Sie Änderungen an der Welt vornehmen und diese unmittelbar nach der Änderung in eine Datei schreiben, müssen Sie die gesamte Textdatei übertragen und JEDE Änderung schreiben. Das ist schrecklich in Bezug auf die Leistung.

Also schreibe ich zuerst alle Änderungen, die an editBuffer vorgenommen werden müssen, mit der addEditBuffer-Methode (die auch die Position der Änderungen im Fragmentraum berechnet). Bevor ich sie in eine Datei schreibe, sortiere ich die Änderungen in der Reihenfolge der Fragmente, zu denen sie gehören, nach ihrem Speicherort in der Datei.

Das Schreiben von Änderungen in eine Datei besteht aus einer Dateiübertragung, dem Laden jeder Zeile (d. H. Eines Fragments), für die Änderungen an editBuffer vorgenommen wurden, dem Vornehmen aller Änderungen und dem Schreiben in eine temporäre Datei, bis editBuffer leer wird. Dies geschieht in der Funktion evaluateBlueprint() , die schnell genug ist.

 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; } 

Die Blueprint-Klasse enthält editBuffer sowie verschiedene Methoden, mit denen Sie editBuffer für bestimmte Objekte (Bäume, Kakteen, Hütten usw.) erstellen können. Dann kann die Blaupause in die Position konvertiert werden, an der Sie das Objekt platzieren möchten, und es dann einfach in die Erinnerung der Welt schreiben.

Eine der größten Schwierigkeiten bei der Arbeit mit Fragmenten besteht darin, dass sich Änderungen in mehreren Blöcken zwischen den Grenzen von Fragmenten als monotoner Prozess mit viel arithmetischem Modulo herausstellen und die Änderungen in mehrere Teile aufteilen können. Dies ist das Hauptproblem, mit dem die Blueprint-Klasse hervorragend umgeht.

Ich benutze es aktiv in der Phase der Weltgeneration, um den „Engpass“ beim Schreiben von Änderungen in eine Datei zu erweitern.

 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))); } 

Die Weltklasse speichert ihren eigenen Entwurf von Änderungen, die an der Welt vorgenommen wurden, sodass beim Aufruf von bufferChunks () alle Änderungen in einem Durchgang auf die Festplatte geschrieben und dann aus dem virtuellen Speicher gelöscht werden.

Rendern


Der Renderer in seiner Struktur ist nicht sehr kompliziert, erfordert jedoch Kenntnisse in OpenGL, um verstanden zu werden. Nicht alle Teile sind interessant, hauptsächlich OpenGL-Wrapper. Ich habe einige Zeit mit Visualisierung experimentiert, um das zu bekommen, was mir gefällt.

Da die Simulation nicht von der ersten Person stammt, habe ich die orthografische Projektion gewählt. Es könnte im Pseudo-3D-Format implementiert werden (d. H. Um Kacheln vorab zu projizieren und sie in einem Software-Renderer zu überlagern), aber es kam mir albern vor. Ich bin froh, dass ich auf OpenGL umgestiegen bin.


Die Basisklasse für das Rendern heißt Ansicht und enthält die meisten wichtigen Variablen, die die Simulationsvisualisierung steuern:

  • Bildschirmgröße und Schattentextur
  • Shader-Objekte, Kamera, Matrix usw. Zoomfaktoren
  • Boolesche Werte für fast alle Rendererfunktionen
    • Menü, Nebel, Schärfentiefe, Kornstruktur usw.
  • Farben für Beleuchtung, Nebel, Himmel, Fensterauswahl usw.

Darüber hinaus gibt es mehrere Hilfsklassen, die das Rendern und Umbrechen von OpenGL selbst durchführen!

  • Klassen-Shader
    • Lädt, kompiliert, kompiliert und verwendet GLSL-Shader
  • Modellklasse
    • Enthält VAO-Datenfragmente (Vertex Arrays Object) zum Rendern, die Funktion zum Erstellen von Netzen und die Rendermethode.
  • Klassenwerbetafel
    • Enthält das FBO (FrameBuffer Object), in das gerendert werden soll - nützlich zum Erstellen von Nachbearbeitungs- und Shadowing-Effekten.
  • Sprite-Klasse
    • Zeichnet ein Viereck, das relativ zur Kamera ausgerichtet ist und aus einer Texturdatei (für Bots und Objekte) geladen wird. Kann auch mit Animationen umgehen!
  • Schnittstellenklasse
    • Mit ImGUI arbeiten
  • Audioklasse
    • Sehr rudimentäre Soundunterstützung (wenn Sie den Motor kompilieren, drücken Sie "M")


Hohe Schärfentiefe (DOF). Bei großen Rendering-Entfernungen kann es langsam sein, aber ich habe das alles auf meinem Laptop gemacht. Vielleicht sind auf einem guten Computer die Bremsen unsichtbar. Ich verstehe, dass es meine Augen belastet und dies nur zum Spaß getan hat.

Das Bild oben zeigt einige Parameter, die während der Manipulation geändert werden können. Ich habe auch das Umschalten in den Vollbildmodus implementiert. Das Bild zeigt ein Beispiel eines Bot-Sprites, das als strukturiertes Viereck in Richtung der Kamera gerendert wurde. Die Häuser und Kakteen auf dem Bild sind mit Blaupausen gebaut.

Erstellen von Fragmentnetzen


Anfangs habe ich die naive Version des Erstellens von Netzen verwendet: Ich habe einfach einen Würfel erstellt und Scheitelpunkte verworfen, die den leeren Raum nicht berührten. Diese Lösung war jedoch langsam, und beim Laden neuer Fragmente stellte sich heraus, dass die Erstellung von Netzen noch engere „Engpässe“ aufwies als der Zugriff auf die Datei.

Das Hauptproblem war die effiziente Erstellung von Fragmenten gerenderter VBOs, aber ich konnte in C ++ meine eigene Version von "Greedy Meshing" (Greedy Meshing) implementieren, die mit OpenGL kompatibel ist (ohne seltsame Strukturen mit Schleifen). Sie können meinen Code mit gutem Gewissen verwenden.

 void Model::fromChunkGreedy(Chunk chunk){ //... (this is part of the model class - find on github!) } 

Im Allgemeinen reduzierte der Übergang zu gierigem Ineinandergreifen die Anzahl der gezeichneten Vierecke um durchschnittlich 60%. Nach weiteren geringfügigen Optimierungen (VBO-Indizierung) wurde die Anzahl um weitere 1/3 reduziert (von 6 Eckpunkten bis zur Kante auf 4 Eckpunkte).

Beim Rendern einer Szene mit 5x1x5-Fragmenten in einem nicht maximierten Fenster erhalte ich durchschnittlich 140 FPS (bei deaktiviertem VSYNC).

Obwohl ich mit diesem Ergebnis sehr zufrieden bin, möchte ich dennoch ein System zum Rendern nicht-kubischer Modelle aus Weltdaten entwickeln. Es ist nicht so einfach, sich in gieriges Meshing zu integrieren, daher lohnt es sich, darüber nachzudenken.

Shader- und Voxel-Hervorhebung


Die Implementierung von GLSL-Shadern ist aufgrund der Komplexität des Debuggens auf der GPU einer der interessantesten und gleichzeitig nervigsten Teile beim Schreiben der Engine. Ich bin kein GLSL-Spezialist, daher musste ich unterwegs viel lernen.

Die Effekte, die ich implementiert habe, verwenden aktiv FBO und Texturabtastung (z. B. Unschärfe, Abschattung und Verwendung von Tiefeninformationen).

Ich mag das aktuelle Beleuchtungsmodell immer noch nicht, weil es die „Dunkelheit“ nicht sehr gut handhabt. Ich hoffe, dass dies in Zukunft behoben wird, wenn ich an dem Zyklus der Veränderung von Tag und Nacht arbeite.

Ich habe auch eine einfache Voxelauswahlfunktion unter Verwendung des modifizierten Bresenham-Algorithmus implementiert (dies ist ein weiterer Vorteil der Verwendung von Voxeln). Es ist nützlich, um räumliche Informationen während der Simulation zu erhalten. Meine Implementierung funktioniert nur für orthografische Projektionen, aber Sie können sie verwenden.


"Hervorgehobener" Kürbis.

Spielklassen


Es wurden mehrere Hilfsklassen für die Verarbeitung von Eingaben, das Debuggen von Nachrichten sowie eine separate Item-Klasse mit grundlegenden Funktionen (die weiter ausgebaut werden) erstellt.

 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); }; 

Mein Event-Handler ist hässlich, aber funktional. Ich nehme gerne Empfehlungen zur Verbesserung an, insbesondere zur Verwendung von SDL Poll Event.

Neueste Notizen


Die Engine selbst ist nur ein System, in das ich meine Task-Bots einsetze (ich werde im nächsten Beitrag ausführlich darauf eingehen). Aber wenn Sie meine Methoden interessant fanden und mehr wissen möchten, schreiben Sie mir.

Dann habe ich das Task-Bot-System (das eigentliche Herz dieses Projekts) in die 3D-Welt portiert und seine Funktionen erheblich erweitert, aber dazu später mehr (der Code wurde jedoch bereits online gestellt)!

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


All Articles