Escribimos nuestro propio motor voxel

imagen

Nota: el código fuente completo para este proyecto está disponible aquí: [ fuente ].

Cuando el proyecto en el que estoy trabajando comienza a agotarse, agrego nuevas visualizaciones que me dan motivación para seguir adelante.

Después del lanzamiento del concepto original de Task-Bot [ traducción al Habré], sentí que estaba limitado por el espacio bidimensional en el que trabajaba. Parecía que estaba frenando las posibilidades del comportamiento emergente de los bots.

Los anteriores intentos fallidos de aprender OpenGL moderno me pusieron ante mí una barrera mental, pero a fines de julio finalmente la rompí. Hoy, a fines de octubre, ya tengo una comprensión bastante segura de los conceptos, así que lancé mi propio motor simple de vóxel, que será el entorno para la vida y la prosperidad de mis Task-Bots.

Decidí crear mi propio motor, porque necesitaba un control total sobre los gráficos; Además, quería ponerme a prueba. En cierto modo, estaba inventando una bicicleta, ¡pero realmente me gustó este proceso!

El objetivo final de todo el proyecto era una simulación completa del ecosistema, donde los bots como agentes manipulan el entorno e interactúan con él.

Dado que el motor ya se ha movido un poco hacia adelante y pasaré a programar bots nuevamente, decidí escribir una publicación sobre el motor, sus funciones e implementación para centrarme en tareas de nivel superior en el futuro.

Concepto del motor


El motor está completamente escrito desde cero en C ++ (con algunas excepciones, como encontrar una ruta). Utilizo SDL2 para representar el contexto y procesar la entrada, OpenGL para representar una escena 3D y DearImgui para controlar la simulación.

Decidí usar voxels principalmente porque quería trabajar con una cuadrícula que tiene muchas ventajas:

  • La creación de mallas para renderizar me es bien entendida.
  • Las capacidades de almacenamiento de datos del mundo son más diversas y comprensibles.
  • Ya he creado sistemas para generar simulaciones de terreno y clima basadas en mallas.
  • Las tareas de los bots en la cuadrícula son más fáciles de parametrizar.

El motor consta de un sistema de datos mundial, un sistema de representación y varias clases auxiliares (por ejemplo, para el procesamiento de sonido y entrada).

En el artículo hablaré sobre la lista actual de características, y también examinaré más de cerca los subsistemas más complejos.

Clase mundial


La clase mundial sirve como la clase base para almacenar toda la información del mundo. Maneja la generación, carga y almacenamiento de datos de bloque.

Los datos del bloque se almacenan en fragmentos de tamaño constante (16 ^ 3), y el mundo almacena el vector de fragmentos cargado en la memoria virtual. En mundos grandes, es prácticamente necesario recordar solo una cierta parte del mundo, por eso elegí este enfoque.

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

Los fragmentos almacenan datos de bloque, así como algunos otros metadatos, en una matriz plana. Inicialmente, implementé mi propio árbol de octree escaso para almacenar fragmentos, pero resultó que el tiempo de acceso aleatorio es demasiado alto para crear mallas. Y aunque una matriz plana no es óptima desde el punto de vista de la memoria, proporciona la capacidad de construir muy rápidamente mallas y manipulaciones con bloques, así como acceso a la ruta de búsqueda.

 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 alguna vez implemento guardar y cargar fragmentos con varios subprocesos, convertir una matriz plana en un árbol de octree escaso y viceversa puede ser una opción completamente posible para ahorrar memoria. ¡Todavía hay espacio para la optimización!

Mi implementación del árbol de octree escaso se almacena en el código, por lo que puede usarlo con seguridad.

Almacenamiento de fragmentos y manejo de memoria


Los fragmentos son visibles solo cuando están dentro de la distancia de representación de la posición actual de la cámara. Esto significa que cuando la cámara se mueve, debe cargar dinámicamente y componer fragmentos en las mallas.

Los fragmentos se serializan utilizando la biblioteca de impulso, y los datos mundiales se almacenan como un archivo de texto simple, en el que cada fragmento es una línea del archivo. Se generan en un orden específico para que se puedan "ordenar" en un archivo mundial. Esto es importante para futuras optimizaciones.

En el caso de un mundo grande, el cuello de botella principal es leer el archivo mundial y cargar / escribir fragmentos. Idealmente, solo necesitamos descargar y transferir el archivo mundial.

Para hacer esto, el método World::bufferChunks() elimina fragmentos que están en la memoria virtual pero que son invisibles, y carga de manera inteligente nuevos fragmentos del archivo mundial.

Por inteligencia se entiende que simplemente decide qué nuevos fragmentos cargar, ordenándolos por su posición en el archivo guardado y luego haciendo una pasada. Todo es muy sencillo.

 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 ejemplo de carga de fragmentos con una pequeña distancia de representación. Los artefactos de distorsión de pantalla son causados ​​por el software de grabación de video. A veces se producen picos notables en las descargas, principalmente causados ​​por mallas

Además, configuré una bandera que indica que el renderizador debe recrear la malla del fragmento cargado.

Blueprint Class y editBuffer


editBuffer es un contenedor ordenable de bufferObjects que contiene información sobre la edición en el espacio mundial y el espacio de fragmentos.

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

Si, al realizar cambios en el mundo, escribirlos en un archivo inmediatamente después de realizar el cambio, tendremos que transferir todo el archivo de texto y escribir CADA cambio. Esto es terrible en términos de rendimiento.

Entonces, primero escribo todos los cambios que deben hacerse para editBuffer usando el método addEditBuffer (que también calcula la posición de los cambios en el espacio de fragmentos). Antes de escribirlos en un archivo, clasifico los cambios en el orden de los fragmentos a los que pertenecen según su ubicación en el archivo.

La escritura de cambios en un archivo consiste en una transferencia de archivos, cargando cada línea (es decir, un fragmento), para la cual hay cambios en editBuffer, haciendo todos los cambios y escribiéndolos en un archivo temporal hasta que editBuffer se vacíe. Esto se hace en la función evaluateBlueprint() , que es lo suficientemente rápida.

 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 clase blueprint contiene editBuffer, así como varios métodos que le permiten crear editBuffers para objetos específicos (árboles, cactus, chozas, etc.). Luego, el plano se puede convertir a la posición donde desea colocar el objeto, y luego simplemente escribirlo en la memoria del mundo.

Una de las mayores dificultades cuando se trabaja con fragmentos es que los cambios en varios bloques entre los límites de los fragmentos pueden resultar un proceso monótono con una gran cantidad de módulo aritmético y dividir los cambios en varias partes. Este es el principal problema que la clase de anteproyecto maneja brillantemente.

Lo uso activamente en la etapa de generación mundial para expandir el "cuello de botella" de escribir cambios en un archivo.

 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 clase mundial almacena su propio modelo de cambios realizados en el mundo, de modo que cuando se llama a bufferChunks (), todos los cambios se escriben en el disco duro en una sola pasada y luego se eliminan de la memoria virtual.

Renderizado


El renderizador en su estructura no es muy complicado, pero requiere conocimiento de OpenGL para entenderlo. No todas sus partes son interesantes, principalmente son envoltorios de funcionalidad OpenGL. Experimenté con la visualización durante bastante tiempo para obtener lo que me gusta.

Como la simulación no es de la primera persona, elegí la proyección ortográfica. Podría implementarse en formato pseudo-3D (es decir, para preproyectar mosaicos y superponerlos en un procesador de software), pero me pareció una tontería. Me alegro de haber cambiado a usar OpenGL.


La clase base para renderizar se llama Ver, contiene la mayoría de las variables importantes que controlan la visualización de la simulación:

  • Tamaño de pantalla y textura de sombra
  • Objetos de sombreado, cámara, matriz, etc. factores de zoom
  • Valores booleanos para casi todas las funciones de renderizador
    • Menú, niebla, profundidad de campo, textura de grano, etc.
  • Colores para iluminación, niebla, cielo, selección de ventanas, etc.

Además, hay varias clases auxiliares que realizan la representación y el ajuste de OpenGL.

  • Sombreador de clase
    • Carga, compila, compila y usa sombreadores GLSL
  • Clase de modelo
    • Contiene fragmentos de datos VAO (objeto de matrices de vértices) para renderizar, la función de crear mallas y el método de renderizado.
  • Cartelera de clase
    • Contiene el FBO (objeto FrameBuffer) para renderizar, útil para crear efectos de procesamiento posterior y sombreado.
  • Clase de sprites
    • Dibuja un cuadrilátero orientado en relación con la cámara, cargado desde un archivo de textura (para bots y objetos). ¡También puede manejar animaciones!
  • Clase de interfaz
    • Para trabajar con ImGUI
  • Clase de audio
    • Soporte de sonido muy rudimentario (si compila el motor, presione "M")


Alta profundidad de campo (DOF). A grandes distancias de renderizado, puede ser lento, pero hice todo esto en mi computadora portátil. Quizás en una buena computadora los frenos sean invisibles. Entiendo que me fatiga los ojos y lo hice solo por diversión.

La imagen de arriba muestra algunos parámetros que se pueden cambiar durante la manipulación. También implementé el cambio al modo de pantalla completa. La imagen muestra un ejemplo de un sprite bot representado como un cuadrilátero texturizado dirigido hacia la cámara. Las casas y los cactus de la imagen están construidos con planos.

Crear mallas de fragmentos


Inicialmente, utilicé la versión ingenua de crear mallas: simplemente creé un cubo y descarté vértices que no tocaban el espacio vacío. Sin embargo, esta solución fue lenta, y al cargar nuevos fragmentos, la creación de mallas resultó ser "cuellos de botella" aún más estrechos que el acceso al archivo.

El principal problema fue la creación eficiente de fragmentos de VBO renderizados, pero logré implementar en C ++ mi propia versión de "mallado codicioso" (mallado codicioso), compatible con OpenGL (sin estructuras extrañas con bucles). Puedes usar mi código con la conciencia tranquila.

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

En general, la transición a mallas codiciosas redujo el número de cuadrángulos dibujados en un promedio del 60%. Luego, después de otras optimizaciones menores (indexación VBO), el número se redujo en otro 1/3 (de 6 vértices al borde a 4 vértices).

Cuando renderizo una escena de fragmentos de 5x1x5 en una ventana que no está maximizada, obtengo un promedio de aproximadamente 140 FPS (con VSYNC deshabilitado).

Aunque estoy bastante contento con este resultado, todavía me gustaría encontrar un sistema para renderizar modelos no cúbicos a partir de datos mundiales. No es tan fácil integrarse con mallas codiciosas, por lo que vale la pena considerarlo.

Sombreadores y resaltado de vóxel


La implementación de sombreadores GLSL es una de las partes más interesantes y al mismo tiempo más molestas de escribir el motor debido a la complejidad de la depuración en la GPU. No soy especialista en GLSL, así que tuve que aprender mucho sobre la marcha.

Los efectos que he implementado usan activamente FBO y muestreo de textura (por ejemplo, desenfoque, sombreado y uso de información de profundidad).

Todavía no me gusta el modelo de iluminación actual, porque no maneja muy bien el "oscuro". Espero que esto se solucione en el futuro cuando trabaje en el ciclo de cambiar el día y la noche.

También implementé una función de selección de voxel simple usando el algoritmo de Bresenham modificado (esta es otra ventaja de usar voxels). Es útil para obtener información espacial durante la simulación. Mi implementación solo funciona para proyecciones ortográficas, pero puede usarla.


Calabaza "resaltada".

Clases de juego


Se han creado varias clases auxiliares para procesar entradas, depurar mensajes, así como una clase de elemento separada con funcionalidad básica (que se ampliará aún más).

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

Mi controlador de eventos es feo, pero funcional. Con mucho gusto aceptaré recomendaciones para su mejora, especialmente en el uso de SDL Poll Event.

Últimas notas


El motor en sí mismo es solo un sistema en el que pongo mis bots de tareas (hablaré de ellos en detalle en la próxima publicación). Pero si encontraste mis métodos interesantes y quieres saber más, escríbeme.

Luego porté el sistema de bot de tareas (el corazón real de este proyecto) al mundo 3D y expandí significativamente sus capacidades, pero más sobre eso más tarde (sin embargo, ¡el código ya se publicó en línea)!

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


All Articles