Nós escrevemos nosso próprio mecanismo voxel

imagem

Nota: o código fonte completo para este projeto está disponível aqui: [ fonte ].

Quando o projeto em que estou trabalhando começa a ficar sem vapor, adiciono novas visualizações que me motivam a seguir em frente.

Após o lançamento do conceito original do Task-Bot [ tradução para Habré], senti que estava limitado pelo espaço bidimensional em que trabalhava. Parecia que estava escondendo as possibilidades para o comportamento emergente dos bots.

Tentativas mal sucedidas anteriores de aprender o OpenGL moderno colocaram diante de mim uma barreira mental, mas no final de julho de alguma forma eu finalmente a rompi. Hoje, no final de outubro, eu já tenho uma compreensão bastante confiante dos conceitos, então lancei meu próprio mecanismo voxel simples, que será o ambiente para a vida e a prosperidade dos meus Task-Bots.

Decidi criar meu próprio mecanismo, porque precisava de controle total sobre os gráficos; além disso, eu queria me testar. De certa forma, eu estava inventando uma bicicleta, mas gostei muito desse processo!

O objetivo final de todo o projeto era uma simulação completa do ecossistema, onde bots no papel de agentes manipulam o ambiente e interagem com ele.

Como o mecanismo já avançou bastante e passarei a programar bots novamente, decidi escrever um post sobre o mecanismo, suas funções e implementação, a fim de focar em tarefas de nível superior no futuro.

Conceito de motor


O mecanismo é completamente escrito do zero em C ++ (com algumas exceções, como encontrar um caminho). Uso SDL2 para renderizar contexto e processar entradas, OpenGL para renderizar uma cena 3D e DearImgui para controlar a simulação.

Decidi usar voxels principalmente porque queria trabalhar com uma grade com muitas vantagens:

  • Criar malhas para renderização é bem compreendido para mim.
  • Os recursos de armazenamento de dados do mundo são mais diversos e compreensíveis.
  • Já criei sistemas para gerar simulações de terreno e clima com base em malhas.
  • As tarefas dos bots na grade são mais fáceis de parametrizar.

O mecanismo consiste em um sistema de dados mundiais, um sistema de renderização e várias classes auxiliares (por exemplo, para processamento de som e entrada).

No artigo, falarei sobre a lista atual de recursos, além de examinar mais de perto os subsistemas mais complexos.

Classe mundial


A classe mundial serve como a classe base para armazenar todas as informações do mundo. Ele lida com a geração, carregamento e armazenamento de dados em bloco.

Os dados do bloco são armazenados em pedaços de tamanho constante (16 ^ 3) e o mundo armazena o vetor de fragmento carregado na memória virtual. Nos mundos grandes, é praticamente necessário lembrar apenas uma certa parte do mundo, razão pela qual escolhi essa abordagem.

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

Fragmentos armazenam dados de bloco, bem como alguns outros metadados, em uma matriz plana. Inicialmente, implementei minha própria árvore de octree esparsa para armazenar fragmentos, mas o tempo de acesso aleatório é muito alto para criar malhas. E, embora uma matriz plana não seja ideal do ponto de vista da memória, ela oferece a capacidade de criar malhas e manipulações muito rapidamente com blocos, além de acessar o caminho de pesquisa.

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

Se alguma vez eu implementar fragmentos de salvamento e carregamento com vários threads, converter uma matriz plana em uma árvore octree esparsa e vice-versa pode ser uma opção completamente possível para economizar memória. Ainda há espaço para otimização!

Minha implementação da árvore octree esparsa é armazenada no código, para que você possa usá-la com segurança.

Armazenamento de fragmentos e manipulação de memória


Os fragmentos são visíveis apenas quando estão dentro da distância de renderização da posição atual da câmera. Isso significa que, quando a câmera se move, você precisa carregar e compor dinamicamente fragmentos nas malhas.

Os fragmentos são serializados usando a biblioteca de reforço, e os dados mundiais são armazenados como um arquivo de texto simples, no qual cada fragmento é uma linha do arquivo. Eles são gerados em uma ordem específica para que possam ser "ordenados" em um arquivo mundial. Isso é importante para novas otimizações.

No caso de um mundo grande, o principal gargalo é a leitura do arquivo mundial e o carregamento / gravação de fragmentos. Idealmente, precisamos apenas baixar e transferir o arquivo mundial.

Para fazer isso, o método World::bufferChunks() remove fragmentos que estão na memória virtual, mas são invisíveis, e carrega de forma inteligente novos fragmentos do arquivo mundial.

Por inteligência entende-se que ele simplesmente decide quais novos fragmentos carregar, classificando-os por sua posição no arquivo salvo e, em seguida, fazendo uma passagem. Tudo é muito simples.

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


Um exemplo de carregamento de fragmentos com uma pequena distância de renderização. Os artefatos de distorção da tela são causados ​​pelo software de gravação de vídeo. Às vezes ocorrem picos visíveis nos downloads, causados ​​principalmente por malhas

Além disso, defino um sinalizador indicando que o renderizador deve recriar a malha do fragmento carregado.

Classe Blueprint e editBuffer


O editBuffer é um contêiner bufferObjects classificável que contém informações sobre edição no espaço do mundo e no espaço do fragmento.

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

Se, ao fazer alterações no mundo, gravá-las em um arquivo imediatamente após a alteração, teremos que transferir o arquivo de texto inteiro e escrever TODAS as alterações. Isso é terrível em termos de desempenho.

Então, primeiro escrevo todas as alterações que precisam ser feitas no editBuffer usando o método addEditBuffer (que também calcula a posição das alterações no espaço do fragmento). Antes de gravá-las em um arquivo, classifico as alterações na ordem dos fragmentos aos quais eles pertencem, de acordo com a localização no arquivo.

A gravação de alterações em um arquivo consiste em uma transferência de arquivo, o carregamento de cada linha (ou seja, fragmento), para o qual há alterações no editBuffer, fazendo todas as alterações e gravando-o em um arquivo temporário até o editBuffer ficar vazio. Isso é feito na função evaluateBlueprint() , que é rápida o suficiente.

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

A classe blueprint contém editBuffer, bem como vários métodos que permitem criar editBuffers para objetos específicos (árvores, cactos, cabanas etc.). Em seguida, o blueprint pode ser convertido para a posição em que você deseja colocar o objeto e, em seguida, basta gravá-lo na memória do mundo.

Uma das maiores dificuldades ao trabalhar com fragmentos é que alterações em vários blocos entre os limites dos fragmentos podem se tornar um processo monótono com muito módulo aritmético e dividir as alterações em várias partes. Esse é o principal problema que a classe blueprint trata de maneira brilhante.

Eu o uso ativamente no estágio de geração mundial para expandir o "gargalo" de gravar alterações em um arquivo.

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

A classe mundial armazena seu próprio modelo de alterações feitas no mundo, de modo que, quando bufferChunks () é chamado, todas as alterações são gravadas no disco rígido de uma só vez e excluídas da memória virtual.

Renderização


O renderizador em sua estrutura não é muito complicado, mas requer conhecimento do OpenGL para entender. Nem todas as partes são interessantes, principalmente são wrappers de funcionalidade OpenGL. Eu experimentei a visualização por algum tempo para obter o que eu gosto.

Como a simulação não é da primeira pessoa, escolhi a projeção ortográfica. Ele poderia ser implementado no formato pseudo-3D (ou seja, para pré-projetar blocos e sobrepor-los em um renderizador de software), mas me pareceu tolo. Estou feliz por ter mudado para o OpenGL.


A classe base para renderização é chamada View, contém a maioria das variáveis ​​importantes que controlam a visualização da simulação:

  • Tamanho da tela e textura de sombra
  • Objetos shader, câmera, matriz, etc. fatores de zoom
  • Valores booleanos para quase todas as funções do renderizador
    • Menu, neblina, profundidade de campo, textura de grão, etc.
  • Cores para iluminação, nevoeiro, céu, seleção de janelas, etc.

Além disso, existem várias classes auxiliares que executam a renderização e o empacotamento do próprio OpenGL!

  • Sombreador de classe
    • Carrega, compila, compila e usa sombreadores GLSL
  • Classe de modelo
    • Contém fragmentos de dados VAO (Vertex Arrays Object) para renderização, a função de criação de malhas e o método de renderização.
  • Quadro de avisos da classe
    • Contém o FBO (FrameBuffer Object) para renderizar como útil para criar efeitos de pós-processamento e sombreamento.
  • Classe Sprite
    • Desenha uma orientação quadrilateral em relação à câmera, carregada de um arquivo de textura (para bots e objetos). Também pode lidar com animações!
  • Classe de interface
    • Para trabalhar com o ImGUI
  • Aula de áudio
    • Suporte de som muito rudimentar (se você compilar o mecanismo, pressione "M")


Profundidade de campo alta (DOF). Em grandes distâncias de renderização, pode ser lento, mas fiz tudo isso no meu laptop. Talvez em um bom computador os freios sejam invisíveis. Entendo que isso estressa meus olhos e o fez apenas por diversão.

A imagem acima mostra alguns parâmetros que podem ser alterados durante a manipulação. Também implementei a mudança para o modo de tela cheia. A imagem mostra um exemplo de um sprite de robô renderizado como um quadrilátero texturizado direcionado para a câmera. As casas e cactos na imagem são construídos usando o blueprint.

Criando malhas de fragmentos


Inicialmente, usei a versão ingênua da criação de malhas: simplesmente criei um cubo e descartei vértices que não tocavam no espaço vazio. No entanto, essa solução foi lenta e, ao carregar novos fragmentos, a criação de malhas acabou sendo "gargalos" ainda mais estreitos do que o acesso ao arquivo.

O principal problema foi a criação eficiente de VBOs renderizados a partir de fragmentos, mas consegui implementar em C ++ minha própria versão do “greedy meshing”, compatível com o OpenGL (sem estruturas estranhas com loops). Você pode usar meu código com a consciência limpa.

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

Em geral, a transição para a malha gananciosa reduziu o número de quadrângulos desenhados em uma média de 60%. Depois, após outras otimizações menores (indexação VBO), o número foi reduzido em mais 1/3 (de 6 vértices até a aresta para 4 vértices).

Ao renderizar uma cena de fragmentos 5x1x5 em uma janela que não é maximizada, recebo uma média de cerca de 140 FPS (com o VSYNC desativado).

Embora eu esteja bastante satisfeito com esse resultado, ainda gostaria de criar um sistema para renderizar modelos não-cúbicos a partir de dados mundiais. Não é tão fácil integrar-se com malhas gananciosas, por isso vale a pena considerar.

Shaders e destaque em voxel


A implementação dos shaders GLSL é uma das partes mais interessantes e ao mesmo tempo as mais irritantes de escrever o mecanismo devido à complexidade da depuração na GPU. Como não sou especialista em GLSL, tive que aprender muito em qualquer lugar.

Os efeitos que eu implementei usam ativamente o FBO e a amostragem de textura (por exemplo, desfoque, sombreamento e uso de informações de profundidade).

Ainda não gosto do modelo de iluminação atual, porque ele não lida muito bem com o “escuro”. Espero que isso seja corrigido no futuro quando eu trabalhar no ciclo de mudança do dia e da noite.

Também implementei uma função simples de seleção de voxel usando o algoritmo de Bresenham modificado (essa é outra vantagem do uso de voxels). É útil para obter informações espaciais durante a simulação. Minha implementação funciona apenas para projeções ortográficas, mas você pode usá-lo.


Abóbora "em destaque".

Classes de jogo


Várias classes auxiliares foram criadas para processar entradas, mensagens de depuração e também uma classe Item separada com funcionalidade básica (que será expandida ainda mais).

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

Meu manipulador de eventos é feio, mas funcional. Terei prazer em aceitar recomendações para sua melhoria, especialmente no uso do SDL Poll Event.

Últimas notas


O mecanismo em si é apenas um sistema no qual coloco meus bots de tarefas (falarei sobre eles em detalhes no próximo post). Mas se você achou meus métodos interessantes e deseja saber mais, escreva para mim.

Então eu enviei o sistema de bot de tarefas (o verdadeiro coração deste projeto) para o mundo 3D e ampliei significativamente seus recursos, mas mais sobre isso mais tarde (no entanto, o código já foi publicado online)!

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


All Articles