Blitz.Engine: نظام الأصول



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

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

مقالات مخططة حول الموضوع:

  • بيان المتطلبات ونظرة عامة على العمارة
  • دورة حياة الأصول
  • نظرة عامة مفصلة عن فئة AssetManager
  • الاندماج في ECS
  • GlobalAssetCache

المتطلبات والأسباب


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

  1. إدارة الذاكرة التلقائية ، مما يعني أنه لا توجد حاجة لاستدعاء وظيفة التحرير للأصل. أي بمجرد تدمير جميع الكائنات الخارجية التي تستخدم الأصل ، يتم تدمير الأصل. الدافع هنا بسيط - اكتب كود أقل. رمز أقل يعني أخطاء أقل.
  2. , ( AssetManager’a). , . — . , «» .
    , , (). — , . , , . , , . , , .
  3. . : . , .
  4. (shared) . , . , . «» , .
  5. إعطاء الأولوية لتحميل الأصول . هناك 3 مستويات للأولوية فقط: عالي ، متوسط ​​، منخفض. ضمن نفس الأولوية ، يتم تحميل الأصول بترتيب الطلب. تخيل الموقف: ينقر اللاعب على "للمعركة" ، ويبدأ تحميل المستوى. إلى جانب ذلك ، تقع مهمة إعداد نقش شاشة التحميل في قائمة انتظار التنزيل. ولكن نظرًا لأن بعض الأصول ذات المستوى دخلت قائمة الانتظار قبل العفريت ، ينظر اللاعب إلى الشاشة السوداء لبعض الوقت.

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

بعض تفاصيل التنفيذ


قبل أن نبدأ في فهم كيفية عمل نظام تحميل الأصول ، نحتاج إلى التعرف على فئتين تستخدمان على نطاق واسع في محرك Blitz.Engine:

  • Type: معلومات وقت التشغيل حول نوع ما. يشبه هذا النوع النوع Typeمن لغة C # ، باستثناء أنه لا يوفر الوصول إلى حقول وأساليب النوع. يحتوي على: نوع الاسم ، وعدد من العلامات مثل is_floating, is_pointer, is_const، وما إلى ذلك. تقوم الطريقة Type::instance<T>بإرجاع ثابت داخل تشغيل تطبيق واحد const Type*، مما يسمح لك بإجراء عمليات التحقق من النموذجif (type == Type::instance<T>())
  • Any: يسمح لك بتغليف قيمة أي نوع متحرك أو قابل للنسخ. يتم Anyتخزين معرفة نوع العبوة كعنصر ثابت Type*. Anyيعرف كيف يحسب التجزئة حسب محتوياته ، ويعرف أيضا كيف يقارن محتويات المساواة على طول الطريق ، يسمح لك بإجراء تحويلات من النوع الحالي إلى آخر. هذا هو نوع من إعادة التفكير في أي فئة من المكتبة القياسية أو المكتبة المعززة.

ويستند كل نظام التحميل قائمة الأصول على ثلاث فئات هي: AssetManager, AssetBase, IAssetSerializer. ومع ذلك ، قبل الانتقال إلى وصف هذه الفئات ، يجب القول أن الكود الخارجي يستخدم اسمًا مستعارًا Asset<T>تم تعريفه على هذا النحو :

Asset = std::shared_ptr<T>

حيث T هو AssetBase أو نوع معين من الأصول. باستخدام Shared_ptr في كل مكان ، نحقق تلبية المتطلبات رقم 1 (إدارة الذاكرة التلقائية).

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

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

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

  1. لإنشاء أصل ، يجب تمثيل الأخير كملف على القرص ، مما يزيل القدرة على إنشاء أصول وقت التشغيل بناءً على الأصول الأخرى.
  2. . , GPUProgram (defines). , .
  3. , .
  4. .

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

وبالتالي ، قررنا استخدام مفتاح الأصل كمعرف ، والذي AssetManagerيمثله النوع على المستوى Any. Anyالوريث يعرف كيف يفسر IAssetSerializer. نفسها AssetManagerلا تعرف سوى العلاقة بين نوع المفتاح والوريث IAssetSerializer. عادة ما تعرف الشفرة التي تطلب مادة عرض ما نوع مادة العرض التي تحتاجها وتعمل بمفتاح من نوع معين. كل شيء يسير على النحو التالي:


class Texture: public AssetBase
{
public:
    struct PathKey
    {
        FilePath path;
        size_t hash() const;
        bool operator==(const PathKey& other);
    };

    struct MemoryKey
    {
        u32 width = 1;
        u32 height = 1;
        u32 level_count = 1;
        TextureFormat format = RBGA8;
        TextureType type = TEX_2D;
        Vector<Vector<u8*>> data; // Face<MipLevels<Image>>

        size_t hash() const;
        bool operator==(const MemoryKey& other);
    };
};

class TextureSerializer: public IAssetSerializer
{
};

class AssetManager final
{
public:
    template<typename T>
    Asset<T> get_asset(const Any& key, ...);
    Asset<AssetBase> get_asset(const Any& key, ...);
};

int main()
{
   ...
   Texture::PathKey key("/path_to_asset");
   Asset<Texture> asset = asset_manager->get_asset<Texture>(key);
   ...

   Texture::MemoryKey mem_key;
   mem_key.width = 128;
   mem_key.format = 128;
   mem_key.level_count = 1;
   mem_key.format = A8;
   mem_key.type = TEX_2D;
   Vector<u8*>& mip_chain = mem_key.data.emplace_back();
   mip_chain.push_back(generage_sdf_font());
   
   Asset<Texture> sdf_font_texture = asset_manager->get_asset<Texture>(mem_key);
};

هناك حاجة إلى hashعامل الأسلوب والمقارنة في الداخل PathKeyمن أجل عمل عمليات الفئة المقابلة Any، ولكننا لن نتحدث عن هذا بالتفصيل.

لذلك ، ما يحدث في الرمز أعلاه: في وقت المكالمة ، get_asset(key)سيتم نسخ المفتاح إلى كائن مؤقت من النوع Any، والذي بدوره سيتم تمريره إلى الطريقة get_asset. بعد ذلك ، AssetManagerخذ نوع المفتاح من الوسيطة. في حالتنا ، سيكون:

Type::instance<MyAsset::PathKey>

من خلال هذا النوع ، سيجد كائن المتسلسل ويفوض للمسلسل جميع العمليات اللاحقة (الإنشاء والتحميل).

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

IAssetSerializer، كما يوحي الاسم ، هي الفئة الأساسية للكيان الذي يعد الأصل. في الواقع ، وريث هذه الفئة لا يقوم فقط بتحميل الأصل:

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

والسؤال الأخير الذي أريد تناوله في إطار هذه المقالة: لماذا قد تحتاج إلى عدة أنواع من المفاتيح لمُسلسِل / أصل واحد؟ دعنا نفرز الأمر بدوره.

مُسلسل واحد - عدة أنواع من المفاتيح


لنأخذ مثالاً على مادة عرض GPUProgram(أي تظليل). من أجل تحميل تظليل في محركنا ، المعلومات التالية مطلوبة:

  1. المسار إلى ملف تظليل.
  2. قائمة تعريفات المعالج الأولي.
  3. المرحلة التي يتم من خلالها تجميع وتجميع التظليل (قمة الرأس ، جزء ، حساب).
  4. اسم نقطة الدخول.

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

تأمل في مثال آخر: الملمس. أسهل طريقة لإنشاء نسيج هي تحميله من القرص. للقيام بذلك ، نحتاج إلى المسار إلى الملف ( PathKey). ولكن يمكننا أيضًا إنشاء محتويات النسيج خوارزميًا وإنشاء نسيج من صفيف من وحدات البايت ( MemoryKey). يمكن أن يكون النوع الثالث من المفتاح مفتاحًا لإنشاء RenderTargetنسيج ( RTKey).

اعتمادًا على نوع المفتاح ، يمكن استخدام العديد من محركات التنقيط للصور الرمزية: stb (StbFontKey) أو FreeType (FTFontKet) أو مولد خط مجال مسافة موقعة ذاتيًا (SDFFontKey).

يمكن تحميل الرسوم المتحركة للإطار الرئيسي ( PathKey) أو إنشاؤها بواسطة الرمز ( MemoryKey).

أصل واحد - عدة أنواع من المفاتيح


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

  1. , , , . , .
  2. - , . , .

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

الخاتمة


باختصار ، أود أن أركز انتباهك على الحلول التي أثرت أكثر من أي شيء آخر على زيادة تطوير المحرك:

  1. استخدام هيكل مخصص كمفتاح أصل ، وليس مسار ملف.
  2. تحميل الأصول فقط في الوضع غير المتزامن.
  3. مخطط مرن لإدارة مشاركة الأصول (أصل واحد - عدة أنواع من المفاتيح).
  4. القدرة على تلقي الأصول من نفس النوع باستخدام مصادر بيانات مختلفة (دعم عدة أنواع من المفاتيح في مُسلسل واحد).

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

مؤلف:Exmix

All Articles