نكتب محرك فوكسل الخاصة بنا

صورة

ملاحظة: كود المصدر الكامل لهذا المشروع متاح هنا: [ المصدر ].

عندما يبدأ المشروع الذي أعمل عليه في النفاد ، أضفت تصورات جديدة تمنحني الحافز للمضي قدمًا.

بعد إصدار مفهوم Task-Bot الأصلي [ الترجمة إلى Habré] ، شعرت بأنني كنت مقيدًا بالفضاء ثنائي الأبعاد الذي عملت فيه. يبدو أنه كان يعيق إمكانات السلوك الناشئ للروبوتات.

المحاولات السابقة غير الناجحة لتعلم OpenGL الحديثة قد وضعت أمامي عائقًا عقليًا ، لكنني في نهاية شهر يوليو اخترقت ذلك بطريقة أو بأخرى. اليوم ، في نهاية شهر أكتوبر ، لدي بالفعل فهم قوي للمفاهيم ، لذلك أطلقت محرك voxel البسيط الخاص بي ، والذي سيكون بمثابة بيئة لحياة وازدهار برنامج Task-Bots الخاص بي.

قررت إنشاء محرك خاص بي ، لأنني كنت بحاجة إلى التحكم الكامل في الرسومات ؛ الى جانب ذلك ، كنت أرغب في اختبار نفسي. بطريقة ما ، كنت أخترع دراجة ، لكني أحببت هذه العملية حقًا!

كان الهدف النهائي للمشروع بأكمله هو محاكاة كاملة للنظام الإيكولوجي ، حيث يقوم الروبوتات في دور الوكلاء بمعالجة البيئة والتفاعل معها.

نظرًا لأن المحرك قد تحرك بالفعل إلى الأمام قليلاً وسأنتقل إلى برمجة برامج الروبوت مرة أخرى ، فقد قررت كتابة منشور عن المحرك ووظائفه وتنفيذه من أجل التركيز على المهام ذات المستوى الأعلى في المستقبل.

مفهوم المحرك


تتم كتابة المحرك بالكامل من البداية في C ++ (مع بعض الاستثناءات ، مثل البحث عن مسار). أستخدم SDL2 لتقديم السياق ومعالجة المدخلات ، OpenGL لتقديم مشهد ثلاثي الأبعاد ، و DearImgui للتحكم في المحاكاة.

قررت استخدام voxels بشكل أساسي لأنني أردت العمل مع شبكة لها العديد من المزايا:

  • من المفهوم جيدا خلق تنسجم لتقديمها.
  • قدرات تخزين البيانات في العالم أكثر تنوعًا ومفهومة.
  • لقد قمت بالفعل بإنشاء أنظمة لتوليد التضاريس والمحاكاة المناخية القائمة على الشبكات.
  • من السهل تحديد مهام برامج الروبوت في الشبكة.

يتكون المحرك من نظام بيانات عالمي ، ونظام التقديم والعديد من الفئات المساعدة (على سبيل المثال ، لمعالجة الصوت والمدخلات).

سوف أتحدث في المقال عن قائمة الميزات الحالية ، فضلاً عن إلقاء نظرة فاحصة على الأنظمة الفرعية الأكثر تعقيدًا.

مستوى عالمي


تعتبر الطبقة العالمية بمثابة الطبقة الأساسية لتخزين جميع معلومات العالم. وهو يتولى توليد وتحميل وتخزين بيانات الكتلة.

يتم تخزين بيانات الكتلة في مجموعات ذات حجم ثابت (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); //... 

تقوم الأجزاء بتخزين بيانات الكتلة ، وكذلك بعض البيانات التعريفية الأخرى ، في صفيف ثابت. في البداية ، قمت بتطبيق شجرة octree المتناثرة الخاصة بي لتخزين الأجزاء ، لكن اتضح أن وقت الوصول العشوائي مرتفع جدًا بحيث لا يمكن إنشاء شبكات. على الرغم من أن الصفيف المسطح ليس هو الأمثل من وجهة نظر الذاكرة ، إلا أنه يوفر القدرة على بناء الشبكات والتلاعب بسرعة كبيرة مع كتل ، وكذلك الوصول إلى مسار البحث.

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

إذا قمت في أي وقت بتطبيق أجزاء متعددة من عمليات التحميل والتحميل ، فيمكن أن يكون تحويل صفيف مسطح إلى شجرة أوكتري متفرقة والعكس خيارًا ممكنًا تمامًا لحفظ الذاكرة. لا يزال هناك مجال للتحسين!

يتم تخزين تنفيذ شجرة octree المتناثرة في الشفرة ، بحيث يمكنك استخدامها بأمان.

تخزين جزء ومعالجة الذاكرة


تظهر الشظايا فقط عندما تكون داخل مسافة التقديم لموضع الكاميرا الحالي. هذا يعني أنه عندما تتحرك الكاميرا ، فأنت تحتاج إلى تحميل الأجزاء بشكل ديناميكي في الشبكات.

يتم إجراء تسلسل الأجزاء باستخدام مكتبة التعزيز ، ويتم تخزين بيانات العالم كملف نصي بسيط ، حيث كل جزء جزء من الملف. يتم إنشاؤها في ترتيب معين بحيث يمكن "ترتيب" في ملف العالم. هذا مهم لمزيد من التحسينات.

في حالة وجود عالم كبير ، فإن عنق الزجاجة الرئيسي يكمن في قراءة الملف العالمي وشظايا التحميل / الكتابة. من الناحية المثالية ، نحتاج فقط إلى تنزيل ونقل ملف العالم.

للقيام بذلك ، يزيل الأسلوب World::bufferChunks() الأجزاء الموجودة في الذاكرة الظاهرية ولكنها غير مرئية ، ويقوم بذكاء بتحميل الأجزاء الجديدة من الملف العالمي.

عن طريق الذكاء يعني أنه يقرر ببساطة تحديد الأجزاء الجديدة التي سيتم تحميلها ، وفرزها حسب موقعها في ملف الحفظ ، ثم القيام بتمريرة واحدة. كل شيء بسيط جدا.

 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; 

إذا ، عند إجراء تغييرات على العالم ، كتابتها إلى ملف فور إجراء التغيير ، فسيتعين علينا نقل الملف النصي بالكامل وكتابة كل تغيير. هذا أمر فظيع من حيث الأداء.

لذلك أولاً ، أكتب كل التغييرات التي يجب إجراؤها على editBuffer باستخدام طريقة addEditBuffer (التي تحسب أيضًا موضع التغييرات في مساحة الجزء). قبل كتابتها في ملف ، أفرز التغييرات حسب ترتيب الأجزاء التي تنتمي إليها وفقًا لموقعها في الملف.

تتكون كتابة التغييرات على ملف من نقل ملف واحد ، وتحميل كل سطر (أي جزء) ، حيث توجد تغييرات على 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 ، بالإضافة إلى العديد من الطرق التي تسمح لك بإنشاء editBuffers لكائنات محددة (الأشجار ، الصبار ، الأكواخ ، إلخ). بعد ذلك ، يمكن تحويل المخطط إلى الموضع الذي تريد وضع الكائن فيه ، ثم اكتبه إلى ذاكرة العالم.

واحدة من أكبر الصعوبات عند العمل مع الأجزاء هي أن التغييرات في عدة كتل بين حدود الأجزاء يمكن أن تكون عملية رتيبة مع الكثير من معامل الحساب وتقسيم التغييرات إلى عدة أجزاء. هذه هي المشكلة الرئيسية التي تعالج فئة المخطط ببراعة.

أنا استخدامها بنشاط في مرحلة الجيل العالمي لتوسيع "عنق الزجاجة" لكتابة التغييرات على ملف.

 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. لقد جربت التصور لبعض الوقت للحصول على ما يعجبني.

نظرًا لأن المحاكاة ليست من الشخص الأول ، فقد اخترت الإسقاط الهجائي. يمكن تنفيذه بتنسيق pseudo-3D (على سبيل المثال ، لبلاطات ما قبل المشروع وتراكبها في عارض برامج) ، ولكن بدا الأمر سخيفًا بالنسبة لي. أنا سعيد لأنني تحولت إلى استخدام برنامج OpenGL.


تسمى الفئة الأساسية للعرض "عرض" ، وهي تحتوي على معظم المتغيرات المهمة التي تتحكم في تصور المحاكاة:

  • حجم الشاشة والملمس الظل
  • كائنات تظليل ، كاميرا ، مصفوفة ، إلخ. عوامل التكبير
  • القيم المنطقية لكل وظائف العارض تقريبًا
    • القائمة ، الضباب ، عمق المجال ، نسيج الحبوب ، إلخ.
  • ألوان للإضاءة ، الضباب ، السماء ، اختيار النافذة ، إلخ.

بالإضافة إلى ذلك ، هناك العديد من فئات المساعدة التي تؤدي عملية التقديم والتفاف لبرنامج OpenGL نفسه!

  • الطبقة شادر
    • يحمّل ويجمع ويجمع ويستخدم تظليل GLSL
  • فئة النموذج
    • يحتوي على أجزاء بيانات VAO (كائن Vertex Arrays Object) للعرض ، ووظيفة إنشاء الشبكات وطريقة التجسيد.
  • لوحة فئة
    • يحتوي على FBO (كائن FrameBuffer) لعرضه - مفيد لإنشاء تأثيرات ما بعد المعالجة والتظليل.
  • الطبقة العفريت
    • يرسم اتجاه رباعي الاتجاه بالنسبة للكاميرا ، ويتم تحميله من ملف نسيج (للروبوتات والكائنات). يمكن أيضا التعامل مع الرسوم المتحركة!
  • الطبقة واجهة
    • للعمل مع ImGUI
  • فئة الصوت
    • دعم صوت بدائي للغاية (إذا قمت بترجمة المحرك ، فاضغط على "M")


ارتفاع عمق المجال (DOF). على مسافات تقديم كبيرة ، يمكن أن يكون بطيئًا ، لكني فعلت كل هذا على جهاز الكمبيوتر المحمول. ربما على جهاز كمبيوتر جيد ستكون الفرامل غير مرئية. أنا أفهم أنه يجهد عيني وفعل ذلك للمتعة فقط.

توضح الصورة أعلاه بعض المعلمات التي يمكن تغييرها أثناء المعالجة. أنا أيضا تنفيذ التحول إلى وضع ملء الشاشة. تُظهر الصورة مثالًا لعفوت بوت تم تقديمه على شكل رباعي الأطراف موجه نحو الكاميرا. تم بناء المنازل والصبار في الصورة باستخدام مخطط.

إنشاء تنسجم الشظايا


في البداية ، استخدمت الإصدار الساذج لإنشاء الشبكات: لقد قمت ببساطة بإنشاء مكعب ورؤوس تم إهمالها ولم تمس مساحة فارغة. ومع ذلك ، كان هذا الحل بطيئًا ، وعند تحميل أجزاء جديدة ، اتضح أن إنشاء الشبكات يكون "اختناقات" أكثر ضيقًا من الوصول إلى الملف.

كانت المشكلة الرئيسية هي الإنشاء الفعال لشظايا VBOs المقدمة ، لكنني تمكنت من تنفيذ الإصدار C ++ الخاص بي من "الربط الجشع" (الربط الجشع) ، المتوافق مع OpenGL (بدون هياكل غريبة مع الحلقات). يمكنك استخدام الكود الخاص بي بضمير مرتاح.

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

بشكل عام ، أدى الانتقال إلى التشبيك الجشع إلى تقليل عدد الزوايا المربوطة بمعدل 60٪. بعد ذلك ، بعد مزيد من التحسينات الطفيفة (فهرسة VBO) ، تم تخفيض الرقم بمقدار الثلث الآخر (من 6 رؤوس إلى الحافة إلى 4 رؤوس).

عند عرض مشهد بحجم 5 × 1 × 5 في نافذة غير مكتملة ، أحصل على معدل حوالي 140 إطارًا في الثانية (مع تعطيل VSYNC).

على الرغم من أنني سعيد جدًا بهذه النتيجة ، إلا أنني ما زلت أرغب في التوصل إلى نظام لتقديم نماذج غير مكعبة من البيانات العالمية. ليس من السهل الاندماج مع التشابك الجشع ، لذلك يجدر التفكير فيه.

تظليل و voxel تسليط الضوء


يعد تطبيق تظليل GLSL أحد أكثر الأجزاء إثارة للاهتمام وفي الوقت نفسه الأجزاء المزعجة لكتابة المحرك نظرًا لتعقيد تصحيح الأخطاء على وحدة معالجة الرسومات. أنا لست متخصصًا في GLSL ، لذلك كان علي أن أتعلم الكثير أثناء التنقل.

تستخدم التأثيرات التي قمت بتطبيقها بنشاط استخدام FBO وأخذ عينات النسيج (على سبيل المثال ، عدم وضوح التظليل واستخدام معلومات العمق).

ما زلت لا أحب طراز الإضاءة الحالي ، لأنه لا يعالج "الظلام" جيدًا. آمل أن يتم إصلاح ذلك في المستقبل عندما أعمل على دورة تغيير النهار والليل.

قمت أيضًا بتنفيذ وظيفة اختيار voxel بسيطة باستخدام خوارزمية Bresenham المعدلة (هذه ميزة أخرى لاستخدام voxels). من المفيد الحصول على معلومات مكانية أثناء المحاكاة. يعمل تطبيقي فقط على الإسقاطات الهجائية ، ولكن يمكنك استخدامه.


"تسليط الضوء" اليقطين.

فصول اللعبة


تم إنشاء العديد من الفئات المساعدة لمعالجة المدخلات وتصحيح الرسائل بالإضافة إلى فصل عنصر منفصل مع وظائف أساسية (والتي سيتم توسيعها أكثر).

 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.

آخر الملاحظات


المحرك نفسه هو مجرد نظام أضع فيه روبوتات المهام الخاصة بي (سأتحدث عنها بالتفصيل في المنشور التالي). ولكن إذا وجدت أساليبي مثيرة للاهتمام وتريد معرفة المزيد ، فاكتب لي.

بعد ذلك قمت بنقل نظام bot (القلب الحقيقي لهذا المشروع) إلى العالم ثلاثي الأبعاد وقمت بتوسيع قدراته بشكل كبير ، ولكن المزيد عن ذلك لاحقًا (ومع ذلك ، تم نشر الكود بالفعل عبر الإنترنت)!

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


All Articles