إنشاء Minecraft في أسبوع واحد في C ++ و Vulkan

لقد حددت لنفسي مهمة إعادة إنشاء لعبة Minecraft من الصفر في أسبوع واحد باستخدام محركي الخاص في C ++ و Vulkan. لقد ألهمت Hopson ، الذي فعل الشيء نفسه مع C ++ و OpenGL. في المقابل ، كان مستوحى من Shane Beck ، الذي استلهم من Minecraft ، مصدر الإلهام الذي كان Infiniminer ، والذي من المفترض أنه مستوحى من التعدين الحقيقي.


مستودع جيثب لهذا المشروع هنا . كل يوم له علامة git الخاصة به.

بالطبع ، لم أخطط لإعادة إنشاء لعبة Minecraft. كان من المفترض أن يكون هذا المشروع تعليميًا. كنت أرغب في معرفة استخدام Vulkan في شيء أكثر تعقيدًا من vulkan-tutorial.com أو عرض Sasha Willem. لذلك ، ينصب التركيز الرئيسي على تصميم محرك Vulkan ، وليس على تصميم اللعبة.

مهام


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

  • إنشاء نظام عرض التضاريس
    • يهرس
    • إضاءة
  • إنشاء نظام مولد التضاريس
    • ارتياح
    • الأشجار
    • المناطق الأحيائية
  • إضافة القدرة على تغيير التضاريس وتحريك الكتل

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

مكتبات


بالطبع ، لم أكن لأكتب تطبيق Vulkan من البداية. لتسريع عملية التطوير ، سأستخدم مكتبات جاهزة كلما أمكن ذلك. يسمى:

  • VulkanWrapper - غلاف C ++ الخاص بي لـ Vulkan API
  • GLFW - للنوافذ وإدخال المستخدم
  • VulkanMemoryAllocator - لتخصيص ذاكرة Vulkan
  • GLM - لمصفوفات ومصفوفات الرياضيات
  • entt - للإشارات / الفتحات و ECS
  • stb - لمرافق تحميل الصور
  • FastNoise - لتوليد ضجيج ثلاثي الأبعاد

اليوم 1


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

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

ينقسم المشروع إلى قسمين: VoxelEngineو VoxelGame.


اليوم الثاني


قمت بدمج مكتبة Vulkan Memory Allocator. تهتم هذه المكتبة بمعظم لوحة مرجعية لتخصيص ذاكرة Vulkan: أنواع الذاكرة وأكوام ذاكرة الجهاز والتخصيص الثانوي.

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


لقد تغير القليل

يوم 3


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

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

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

يحتوي المحرك على ثلاثة أنواع من العقد. AcquireNodeيتلقى صورة من سلسلة عازلة (swapchain) ، TransferNodeوينقل البيانات من وحدة المعالجة المركزية إلى GPU ، PresentNodeويوفر صورة لسلسلة عازلة ليتم عرضها.

كل عقدة يمكن أن تنفذ preRender، renderو postRenderالتي يتم تنفيذها في كل إطار. AcquireNodeيحصل على صورة لسلسلة من المخازن المؤقتة أثناء preRender. PresentNodeتوفر هذه الصورة في الوقت المحدد postRender.

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


أقسم داخل المحرك قد تغير

اليوم الرابع


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

لقد تباطأت في ذلك اليوم لأنني كنت أحاول العثور على التكوين الصحيح للعرض ثلاثي الأبعاد باستخدام Vulkan. تركز معظم المواد عبر الإنترنت على العرض باستخدام OpenGL ، والذي يستخدم أنظمة إحداثيات مختلفة قليلاً عن Vulkan. في OpenGL ، يتم تحديد المحور Z لمساحة المقطع بالشكل [-1, 1]، وتكون الحافة العلوية للشاشة Y = 1. في Vulkan ، يتم تحديد المحور Z كـ [0, 1]، والحافة العلوية للشاشة عند Y = -1. بسبب هذه الاختلافات الصغيرة ، لا تعمل مصفوفات الإسقاط القياسية لـ GLM بشكل صحيح لأنها مصممة لـ OpenGL.

GLM لديه خيارGLM_FORCE_DEPTH_ZERO_TO_ONE، القضاء على المشكلة في المحور Z. بعد ذلك ، يمكن التخلص من المشكلة في المحور Y ببساطة عن طريق تغيير علامة عنصر (1, 1)مصفوفة الإسقاط (يستخدم GLM الفهرسة من 0).

إذا قلبنا المحور Y ، فسنحتاج إلى قلب بيانات القمة ، لأنه قبل ذلك ، يشير الاتجاه السلبي للمحور Y إلى الأعلى.


الآن في 3D!

يوم 5


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

يعتبر إدخال لوحة المفاتيح والماوس جزءًا أساسيًا من الأغلفة الرفيعة أعلى GLFW ، ويتم فتحه من خلال معالجات الإشارة entt.

للمقارنة فقط - نفس الشيء الذي فعله هوبسون في اليوم الأول من مشروعه.


يوم 6


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

كان أحد التجريدات فئة قالب ChunkData<T, chunkSize>يحدد مكعبًا من نوع Tحجم chunkSizeكل جانب. يخزن هذا الفصل البيانات في صفيف 1D ويعالج بيانات الفهرسة بإحداثيات ثلاثية الأبعاد. حجم كل كتلة هو 16 × 16 × 16 ، وبالتالي فإن البيانات الداخلية عبارة عن مصفوفة بسيطة بطول 4096.

وهناك تجريد آخر هو إنشاء مكرر للمواضع التي تولد إحداثيات من (0, 0, 0)إلى(15, 15, 15). تضمن هاتين الفئتين إجراء عمليات التكرار مع بيانات الكتلة بترتيب خطي لزيادة موقع ذاكرة التخزين المؤقت. لا يزال الإحداثيات ثلاثية الأبعاد متاحة للعمليات الأخرى التي تحتاج إليها. على سبيل المثال:

for (glm::ivec3 pos : Chunk::Positions()) {
    auto& data = chunkData[pos];
    glm::ivec3 offset = ...;
    auto& neighborData = chunkData[pos + offset];
}

لدي العديد من الصفائف الثابتة التي تحدد الإزاحات التي يشيع استخدامها في اللعبة. على سبيل المثال ، Neighbors6تحدد 6 جيران لها المكعب وجوه مشتركة.

static constexpr std::array<glm::ivec3, 6> Neighbors6 = {
        glm::ivec3(1, 0, 0),    //right
        glm::ivec3(-1, 0, 0),   //left
        glm::ivec3(0, 1, 0),    //top
        glm::ivec3(0, -1, 0),   //bottom
        glm::ivec3(0, 0, 1),    //front
        glm::ivec3(0, 0, -1)    //back
    };

Neighbors26- هذه كلها جيران لها المكعب وجه أو حافة أو قمة مشتركة. أي أنها شبكة 3x3x3 بدون مكعب مركزي. هناك أيضًا صفائف مماثلة لمجموعات أخرى من الجيران وللمجموعات ثنائية الأبعاد من الجيران.

هناك مصفوفة تحدد البيانات المطلوبة لإنشاء وجه واحد للمكعب. تتوافق اتجاهات كل وجه في هذا المصفوفة مع الاتجاهات في المصفوفة Neighbors6.

static constexpr std::array<FaceArray, 6> NeighborFaces = {
    //right face
    FaceArray {
        glm::ivec3(1, 1, 1),
        glm::ivec3(1, 1, 0),
        glm::ivec3(1, 0, 1),
        glm::ivec3(1, 0, 0),
    },
    ...
};

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

for (glm::ivec3 pos : Chunk::Positions()) {
    Block block = chunk.blocks()[pos];
    if (block.type == 0) continue;

    for (size_t i = 0; i < Chunk::Neighbors6.size(); i++) {
        glm::ivec3 offset = Chunk::Neighbors6[i];
        glm::ivec3 neighborPos = pos + offset;

        //NOTE: bounds checking omitted

        if (chunk.blocks()[neighborPos].type == 0) {
            Chunk::FaceArray& faceArray = Chunk::NeighborFaces[i];
            for (size_t j = 0; j < faceArray.size(); j++) {
                m_vertexData.push_back(pos + faceArray[j]);
                m_colorData.push_back(glm::i8vec4(pos.x * 16, pos.y * 16, pos.z * 16, 0));
            }
        }
    }
}

استبدلت TriangleRendererمع ChunkRenderer. أضفت أيضًا مخزنًا مؤقتًا للعمق بحيث يمكن عرض شبكة الكتلة بشكل صحيح. كان من الضروري إضافة حافة أخرى إلى الرسم البياني للعرض بين TransferNodeو ChunkRenderer. تنقل هذه الحافة ملكية موارد عائلة قائمة الانتظار بين قائمة انتظار النقل وقائمة انتظار الرسومات.

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

يجب أن تكمل جميع الأوامر التي تعتمد على سلسلة المخزن المؤقت (والآن هذه كلها أوامر رسم) التنفيذ قبل تدمير سلسلة المخزن المؤقت القديمة. هذا يعني أن وحدة معالجة الرسومات بالكامل ستكون خاملة.

تحتاج إلى تغيير مسار الرسومات لتوفير إطار عرض ديناميكي وتغيير الحجم.

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

الآن الشبكة هي رقعة شطرنج بسيطة ثلاثية الأبعاد. يتم ضبط ألوان RGB للشبكة وفقًا لموضع XYZ مضروبًا في 16.



اليوم السابع


لم أجعل عملية اللعبة واحدة ، بل عدة كتل في وقت واحد. تتم إدارة كتل متعددة وشبكاتها من قبل مكتبة ECS entt. ثم قمت بإعادة تصميم جهاز عرض الكتل بحيث يتم عرض جميع الكتل الموجودة في ECS. لا يزال لدي كتلة واحدة فقط، لكنني يمكن إضافة مواقع جديدة إذا لزم الأمر.

لقد قمت بإعادة هيكلة الشبكة بحيث يمكن تحديث بياناتها بعد إنشائها. سيسمح لي هذا بتحديث شبكة الكتلة في المستقبل عندما أضيف القدرة على إضافة المكعبات وإزالتها.

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

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

إذا كانت عقدة عرض الرسم البياني تريد استخدام مورد (مخزن مؤقت أو صورة) ، فيجب أن تستدعي الطريقة syncداخل الطريقة preRender. تحصل هذه الطريقة على مؤشر shared_ptrلمورد. هذهshared_ptrيضمن عدم حذف المورد أثناء تنفيذ المخازن المؤقتة للأوامر. (من حيث الأداء ، هذا الحل ليس جيدًا جدًا. المزيد عن هذا لاحقًا.)

الآن يتم إعادة إنشاء شبكة الكتل في كل إطار.


استنتاج


هذا كل ما فعلته في أسبوع - أعدت أساسيات جعل العالم مع كتل voxel متعددة وسيستمر في العمل في الأسبوع الثاني.

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


All Articles