我们编写自己的体素引擎

图片

注意:此项目完整源代码可在此处找到:[ ]。

当我正在处理的项目开始用尽时,我添加了新的可视化效果,这给了我继续前进的动力。

在最初的Task-Bot概念( 翻译为Habré)发布之后,我感到自己受到工作空间的限制。 似乎它阻止了机器人紧急行为的可能性。

以前学习现代OpenGL的失败尝试给我带来了心理障碍,但是在7月底,我终于以某种方式突破了它。 今天,在10月底,我已经对概念有了相当自信的了解,因此我发布了自己的简单体素引擎,它将成为Task-Bots生命和繁荣的环境。

我决定创建自己的引擎,因为我需要完全控制图形。 此外,我想测试一下自己。 从某种意义上说,我发明了自行车,但是我真的很喜欢这个过程!

整个项目的最终目标是对生态系统进行完整的模拟,其中由具有代理作用的漫游器操纵环境并与之交互。

由于引擎已经向前发展了很多,我将再次转向编程机器人,所以我决定写一篇关于引擎,其功能和实现的文章,以便将来着重于更高层次的任务。

引擎概念


该引擎完全是用C ++从头开始编写的(有些例外,例如查找路径)。 我使用SDL2渲染上下文和处理输入,使用OpenGL渲染3D场景,使用DearImgui控制仿真。

我决定使用体素主要是因为我想使用具有许多优点的网格:

  • 我已经很了解创建用于渲染的网格。
  • 世界各地的数据存储功能更加多样化和易于理解。
  • 我已经创建了基于网格生成地形和气候模拟的系统。
  • 网格中的机器人程序的任务更易于参数化。

该引擎由一个世界数据系统,一个渲染系统和几个辅助类(例如,用于声音和输入处理)组成。

在本文中,我将讨论当前的功能列表,并仔细研究更复杂的子系统。

世界一流


世界类是存储世界上所有信息的基础类。 它处理块数据的生成,加载和存储。

块数据以恒定大小(16 ^ 3)的块存储,并且世界存储加载到虚拟内存中的片段向量。 在大世界中,实际上只需要记住世界的某个部分,这就是我选择这种方法的原因。

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

片段以平面数组的形式存储块数据以及其他一些元数据。 最初,我实现了自己的稀疏八叉树来存储片段,但事实证明,随机访问时间太长,无法创建网格。 而且,尽管从内存的角度来看,平面阵列并不是最佳选择,但它提供了非常快速地构建带有块的网格和操作以及访问搜索路径的能力。

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

如果我曾经实现多线程保存和加载片段,那么将平面数组转换为稀疏的八叉树(反之亦然)可以是节省内存的完全可能的选择。 仍有优化的空间!

我对稀疏八叉树的实现存储在代码中,因此您可以安全地使用它。

片段存储和内存处理


片段只有在它们位于当前相机位置的渲染距离内时才可见。 这意味着当摄像机移动时,您需要在网格中动态加载和合成片段。

片段使用boost库进行序列化,世界数据存储为一个简单的文本文件,其中每个片段都是文件的一行。 它们以特定顺序生成,因此可以在世界文件中对其进行“排序”。 这对于进一步优化很重要。

对于大世界,主要瓶颈是读取世界文件并加载/写入片段。 理想情况下,我们只需要下载并传输世界文件。

为此, World::bufferChunks()方法将删除虚拟内存中不可见的片段,并智能地从world文件中加载新片段。

智能意味着他只需确定要加载的新片段,然后根据它们在保存文件中的位置对其进行排序,然后进行一次遍历。 一切都非常简单。

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


以较小的渲染距离加载片段的示例。 屏幕失真伪影是由视频录制软件引起的。 有时会出现下载峰值,主要是由于啮合

另外,我设置了一个标志,指示渲染器应重新创建已加载片段的网格。

蓝图类和editBuffer


editBuffer是一个可排序的bufferObjects容器,其中包含有关在世界空间和片段空间中进行编辑的信息。

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

如果在进行更改时在更改后立即将它们写入文件中,那么我们将必须传输整个文本文件并写入每个更改。 就性能而言,这是可怕的。

因此,首先我使用addEditBuffer方法(还计算更改在片段空间中的位置)将所有需要进行的更改写入editBuffer。 在将它们写入文件之前,我将根据它们在文件中的位置按照它们所属的片段的顺序对更改进行排序。

将更改写入文件包括一次文件传输,加载每一行(即片段),为此editBuffer进行更改,进行所有更改并将其写入临时文件,直到editBuffer变为空。 这可以通过足够快的evaluateBlueprint()函数完成。

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

蓝图类包含editBuffer以及一些允许您为特定对象(树,仙人掌,小屋等)创建editBuffer的方法。 然后,可以将蓝图转换为要放置对象的位置,然后将其写入世界的记忆中。

使用片段时最大的困难之一是片段边界之间几个块中的更改可能变成具有许多算术模并将更改分为几部分的单调过程。 这是蓝图类出色处理的主要问题。

我在世界产生阶段积极使用它来扩展将更改写入文件的“瓶颈”。

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

世界级存储了自己对世界所做的更改的蓝图,因此,在调用bufferChunks()时,所有更改都会一次性写入硬盘,然后从虚拟内存中删除。

渲染图


渲染器的结构不是很复杂,但是需要了解OpenGL的知识。 并非所有部分都很有趣,主要是OpenGL功能包装器。 我尝试了一段时间的可视化以获得自己喜欢的东西。

由于模拟不是来自第一人称,因此我选择了正交投影。 它可以以伪3D格式实现(即,预先投影图块并将其覆盖在软件渲染器中),但对我来说似乎很愚蠢。 我很高兴转而使用OpenGL。


渲染的基类称为View,它包含控制模拟可视化的大多数重要变量:

  • 屏幕尺寸和阴影纹理
  • 着色器对象,相机,矩阵等缩放系数
  • 几乎所有渲染器功能的布尔值
    • 菜单,雾,景深,纹理等
  • 照明,雾,天空,窗户选择等的颜色。

此外,还有几个帮助程序类执行OpenGL本身的渲染和包装!

  • 类着色器
    • 加载,编译,编译和使用GLSL着色器
  • 型号类别
    • 包含用于渲染的VAO(顶点阵列对象)数据片段,创建网格的功能和渲染方法。
  • 类广告牌
    • 包含要渲染的FBO(FrameBuffer对象)-用于创建后处理和阴影效果。
  • 雪碧课
    • 绘制相对于摄影机的四边形,从纹理文件(对于机器人和对象)加载。 也可以处理动画!
  • 接口类
    • 与ImGUI一起使用
  • 音响课
    • 非常基本的声音支持(如果您编译引擎,请按“ M”)


高景深(DOF)。 在较大的渲染距离下,它可能很慢,但是我在笔记本电脑上完成了所有这些工作。 也许在一台好的计算机上,刹车是看不见的。 我了解这会拉伤我的眼睛,这样做只是为了好玩。

上图显示了一些在操作过程中可以更改的参数。 我还实现了切换到全屏模式。 该图像显示了一个bot精灵的示例,该bot精灵被渲染为指向照相机的带纹理的四边形。 图像中的房屋和仙人掌是使用蓝图建造的。

创建片段网格


最初,我使用的是创建网格的朴素版本:我只是创建了一个多维数据集并丢弃了没有接触空白空间的顶点。 但是,此解决方案速度很慢,并且在加载新片段时,网格的创建比访问文件的过程更狭窄。

主要问题是从片段高效创建渲染的VBO,但是我设法用C ++实现了自己的“贪婪网格划分”版本,与OpenGL兼容(没有带有循环的奇怪结构)。 您可以凭良心使用我的代码。

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

通常,向贪婪网格划分的过渡平均减少了四边形绘制数60%。 然后,在进一步的次要优化(VBO索引)之后,该数量又减少了1/3(从6个顶点到边缘减少到4个顶点)。

在未最大化的窗口中渲染5x1x5片段的场景时,平均获得大约140 FPS(禁用了VSYNC)。

尽管我对这个结果感到非常满意,但我仍然想提出一个可以从世界数据中渲染非立方模型的系统。 与贪婪的网格物体集成并不是一件容易的事,因此值得考虑。

着色器和体素突出显示


由于在GPU上进行调试的复杂性,GLSL着色器的实现是编写引擎中最有趣且最烦人的部分之一。 我不是GLSL专家,所以我必须在旅途中学习很多东西。

我积极实现的效果使用FBO和纹理采样(例如,模糊,阴影和使用深度信息)。

我仍然不喜欢当前的照明模型,因为它不能很好地处理“黑暗”照明。 我希望将来在日夜交替的周期中解决此问题。

我还使用修改后的Bresenham算法实现了一个简单的体素选择功能(这是使用体素的另一个优势)。 这对于在仿真过程中获取空间信息很有用。 我的实现仅适用于正交投影,但是您可以使用它。


“突出显示”南瓜。

游戏类


已经创建了几个辅助类来处理输入,调试消息,以及具有基本功能的单独Item类(将进一步扩展)。

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

我的事件处理程序很丑陋,但是功能正常。 我会很乐意接受有关改进的建议,尤其是在使用SDL Poll Event方面。

最新笔记


引擎本身只是一个系统,我在其中放置了任务机器人(我将在下一篇文章中详细讨论它们)。 但是,如果您发现我的方法很有趣,并且想了解更多,请给我写信。

然后,我将任务机器人系统(该项目的真正核心)移植到了3D世界中,并显着扩展了其功能,但后来又进行了进一步的改进(但是,代码已在线发布)!

Source: https://habr.com/ru/post/zh-CN474414/


All Articles