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(); }
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:
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){
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 mallasAdemá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.
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){
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(){
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
- 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){
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{ public:
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)!