Blitz.Engine: Sistem Aset



Sebelum kita memahami bagaimana sistem aset Blitz. Mesin mesin bekerja , kita perlu memutuskan apa aset itu dan apa yang sebenarnya kita maksudkan dengan sistem aset. Menurut Wikipedia, aset game adalah objek digital, terutama terdiri dari data yang sama, entitas yang tidak dapat dibagi yang mewakili bagian dari konten game dan memiliki properti tertentu. Dari sudut pandang model program, aset dapat muncul sebagai objek yang dibuat pada beberapa kumpulan data. Aset dapat disimpan sebagai file terpisah. Pada gilirannya, sistem aset banyak kode program yang bertanggung jawab untuk memuat dan mengoperasikan berbagai jenis aset.

Faktanya, sistem aset adalah bagian besar dari mesin permainan, yang dapat menjadi asisten setia bagi pengembang game atau mengubah hidup mereka menjadi neraka. Menurut pendapat saya, keputusan logis adalah untuk memusatkan "neraka" ini di satu tempat, dengan hati-hati melindungi pengembang tim lain darinya. Kami akan memberi tahu Anda apa yang kami lakukan dalam serangkaian artikel ini - ayo!

Artikel yang direncanakan pada topik:

  • Pernyataan persyaratan dan tinjauan arsitektur
  • Siklus Hidup Aset
  • Ikhtisar kelas AssetManager terperinci
  • Integrasi dalam ECS
  • GlobalAssetCache

Persyaratan dan Alasan


Persyaratan sistem pemuatan aset lahir antara batu dan tempat yang keras. Landasan adalah keinginan untuk melakukan sesuatu yang terlampir pada dirinya sendiri sehingga akan bekerja tanpa menulis kode eksternal. Baik, atau hampir tanpa menulis kode eksternal. Palu menjadi kenyataan. Dan inilah yang akhirnya kami lakukan:

  1. Manajemen memori otomatis , yang berarti tidak perlu memanggil fungsi pelepasan untuk aset. Yaitu, segera setelah semua objek eksternal menggunakan aset dihancurkan, aset dihancurkan. Motivasi di sini sederhana - tulis lebih sedikit kode. Lebih sedikit kode berarti lebih sedikit kesalahan.
  2. , ( AssetManager’a). , . — . , «» .
    , , (). — , . , , . , , . , , .
  3. . : . , .
  4. (shared) . , . , . «» , .
  5. Prioritaskan pemuatan aset . Hanya ada 3 level prioritas: Tinggi, Sedang, Rendah. Dalam prioritas yang sama, aset dimuat sesuai urutan permintaan. Bayangkan sebuah situasi: seorang pemain mengklik "Untuk bertarung", dan pemuatan level dimulai. Bersamaan dengan ini, tugas mempersiapkan sprite dari layar memuat masuk ke dalam antrian unduhan. Tetapi karena beberapa aset level masuk ke antrian sebelum sprite, pemain melihat layar hitam untuk beberapa waktu.

Selain itu, kami merumuskan aturan sederhana untuk diri kita sendiri: "Segala sesuatu yang dapat dilakukan pada utas AssetManager harus dilakukan pada utas AssetManager." Misalnya, menyiapkan partisi lanskap dan tekstur normals berdasarkan peta ketinggian, menghubungkan program GPU, dll.

Beberapa detail implementasi


Sebelum kita mulai memahami cara kerja sistem pemuatan aset, kita perlu membiasakan diri dengan dua kelas yang banyak digunakan di Blitz. Mesin mesin:

  • Type: informasi runtime tentang beberapa jenis. Tipe ini mirip dengan tipe Typedari bahasa C #, dengan pengecualian bahwa itu tidak menyediakan akses ke bidang dan metode tipe. Berisi: ketikkan nama, sejumlah tanda suka is_floating, is_pointer, is_const, dll. Metode Type::instance<T>mengembalikan konstanta dalam satu peluncuran aplikasi const Type*, yang memungkinkan Anda untuk melakukan pemeriksaan formulirif (type == Type::instance<T>())
  • Any: memungkinkan Anda untuk mengemas nilai jenis apa pun yang bergerak atau dapat disalin. Pengetahuan tentang jenis apa yang dikemas Anydisimpan sebagai const Type*. Anytahu cara menghitung hash menurut isinya, dan juga tahu cara membandingkan konten untuk kesetaraan. Sepanjang jalan, ini memungkinkan Anda untuk melakukan konversi dari jenis saat ini ke yang lain. Ini adalah semacam memikirkan kembali kelas apa pun dari perpustakaan standar atau meningkatkan perpustakaan.

Semua sistem pembebanan daftar aset didasarkan pada tiga kelas: AssetManager, AssetBase, IAssetSerializer. Namun, sebelum melanjutkan ke deskripsi kelas-kelas ini, harus dikatakan bahwa kode eksternal menggunakan alias Asset<T>yang dideklarasikan seperti ini:

Asset = std::shared_ptr<T>

di mana T adalah AssetBase atau jenis aset tertentu. Menggunakan shared_ptr di mana-mana, kami mencapai pemenuhan persyaratan nomor 1 (Manajemen memori otomatis).

AssetManager- Ini adalah kelas terbatas yang tidak memiliki ahli waris. Kelas ini mendefinisikan siklus hidup suatu aset dan mengirimkan pesan tentang perubahan status aset. Itu juga AssetManagermenyimpan pohon ketergantungan antara aset dan aset yang mengikat file pada disk, mendengarkan FileWatcherdan mengimplementasikan pemuatan aset. Dan yang paling penting, AssetManagermeluncurkan utas terpisah, mengimplementasikan antrian tugas untuk mempersiapkan aset, dan merangkum semua sinkronisasi dengan utas aplikasi lainnya (permintaan aset dapat dijalankan dari utas aplikasi apa pun, termasuk aliran unduhan).

Pada saat yang sama AssetManagerberoperasi dengan aset abstrakAssetBase, mendelegasikan tugas membuat dan memuat aset jenis tertentu ke ahli waris dari IAssetSerializer. Saya akan memberi tahu Anda lebih banyak tentang bagaimana ini terjadi dalam artikel-artikel berikutnya.

Sebagai bagian dari persyaratan nomor 4 (Pembagian aset), salah satu pertanyaan terpanas adalah "apa yang harus digunakan sebagai pengidentifikasi aset?" Solusi paling sederhana dan tampaknya jelas adalah menggunakan jalur ke file yang akan diunduh. Namun, keputusan ini membebankan sejumlah batasan serius:

  1. Untuk membuat aset, yang terakhir harus direpresentasikan sebagai file pada disk, yang menghilangkan kemampuan untuk membuat aset runtime berdasarkan aset lain.
  2. . , GPUProgram (defines). , .
  3. , .
  4. .

Kami tidak menganggap paragraf 3 dan 4 sebagai argumen di awal, karena bahkan tidak ada pemikiran bahwa ini mungkin berguna. Namun, fitur-fitur ini selanjutnya sangat memudahkan pengembangan editor.

Karena itu, kami memutuskan untuk menggunakan kunci aset sebagai pengidentifikasi, yang pada level AssetManagerdiwakili oleh jenisnya Any. AnyPewaris tahu bagaimana menafsirkan IAssetSerializer. Itu sendiri AssetManagerhanya tahu hubungan antara jenis kunci dan ahli waris IAssetSerializer. Kode yang meminta aset biasanya tahu jenis aset apa yang dibutuhkan dan beroperasi dengan kunci jenis tertentu. Semuanya berjalan seperti ini:


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

Metode hashdan operator pembanding di dalam PathKeydiperlukan untuk memfungsikan operasi kelas yang sesuai Any, tetapi kami tidak akan membahasnya secara rinci.

Jadi, apa yang terjadi pada kode di atas: pada saat panggilan, get_asset(key)kunci akan disalin ke objek sementara dari tipe Any, yang, pada gilirannya, akan diteruskan ke metode get_asset. Selanjutnya, AssetManagerambil jenis kunci dari argumen. Dalam kasus kami, itu akan menjadi:

Type::instance<MyAsset::PathKey>

Dengan tipe ini, ia akan menemukan objek serializer dan mendelegasikan ke serializer semua operasi selanjutnya (pembuatan dan pemuatan).

AssetBase- Ini adalah kelas dasar untuk semua jenis aset di mesin. Kelas ini menyimpan kunci aset, keadaan saat ini dari aset (dimuat, dalam antrian, dll.), Serta teks kesalahan jika pemuatan aset gagal. Sebenarnya, struktur internal sedikit lebih rumit, tetapi kami akan mempertimbangkan ini bersama dengan siklus hidup aset.

IAssetSerializer, seperti namanya, adalah kelas dasar untuk entitas yang menyiapkan aset. Bahkan, pewaris kelas ini tidak hanya memuat aset:

  • Alokasi dan alokasi objek aset jenis tertentu.
  • Memuat aset dari jenis tertentu.
  • Menyusun daftar jalur file berdasarkan aset yang dibangun. Daftar ini diperlukan untuk mekanisme pemuatan aset saat file berubah. Muncul pertanyaan: mengapa daftar jalur, dan bukan satu jalur? Aset sederhana, seperti tekstur, dapat benar-benar dibangun berdasarkan satu file. Namun, jika kita melihat shader, kita akan melihat bahwa reboot harus terjadi tidak hanya jika teks shader berubah, tetapi juga jika file yang terhubung ke shader diubah melalui include directive.
  • Menyimpan aset ke disk. Ini digunakan secara aktif saat mengedit aset dan dalam mempersiapkan aset untuk game.
  • Laporkan jenis kunci yang didukungnya.

Dan pertanyaan terakhir yang ingin saya bahas dalam kerangka artikel ini: mengapa Anda mungkin perlu memiliki beberapa jenis kunci untuk satu serializer / aset? Mari kita bereskan pada gilirannya.

Satu serializer - beberapa jenis tombol


Mari kita ambil contoh aset GPUProgram(yaitu, shader). Untuk memuat shader di mesin kami, informasi berikut diperlukan:

  1. Path ke file shader.
  2. Daftar definisi preprosesor.
  3. Tahap dimana shader dirangkai dan dikompilasi (vertex, fragmen, compute).
  4. Nama titik masuknya.

Mengumpulkan informasi ini bersama-sama, kita mendapatkan kunci shader, yang digunakan dalam game. Namun, selama pengembangan game atau mesin, seringkali perlu untuk menampilkan beberapa informasi debug pada layar, kadang-kadang dengan shader tertentu. Dan dalam situasi ini akan lebih mudah untuk menulis teks shader langsung dalam kode. Untuk melakukan ini, kita bisa mendapatkan tipe kunci kedua, yang alih-alih path ke file dan daftar definisi preprocessor akan berisi teks shader.

Perhatikan contoh lain: tekstur. Cara termudah untuk membuat tekstur adalah dengan memuatnya dari disk. Untuk melakukan ini, kita perlu path ke file ( PathKey). Tetapi kita juga dapat menghasilkan konten tekstur secara algoritmik dan membuat tekstur dari array byte ( MemoryKey). Jenis kunci ketiga bisa menjadi kunci untuk membuat RenderTargettekstur ( RTKey).

Bergantung pada jenis kuncinya, berbagai mesin rasterisasi mesin terbang dapat digunakan: stb (StbFontKey), FreeType (FTFontKet) atau generator font bidang tandatangan yang ditandatangani sendiri (SDFFontKey).

Animasi keyframe dapat dimuat ( PathKey) atau dihasilkan oleh kode ( MemoryKey).

Satu aset - beberapa jenis kunci


Bayangkan bahwa kita memiliki ParticleEffectaset yang menjelaskan aturan untuk pembuatan partikel. Selain itu, kami memiliki editor yang nyaman untuk aset ini. Pada saat yang sama, editor level dan editor partikel adalah satu aplikasi multi-jendela. Ini nyaman karena Anda dapat membuka level, menempatkan sumber partikel di dalamnya dan melihat efeknya di lingkungan level, sambil mengedit efek itu sendiri. Jika kita memiliki satu jenis kunci, maka objek efek yang digunakan di dunia pengeditan efek dan di dunia level adalah satu dan sama. Semua perubahan yang dilakukan pada editor efek akan segera terlihat di tingkat. Pada pandangan pertama, ini mungkin tampak seperti ide yang keren, tetapi mari kita lihat skenario berikut ini:

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

Selain itu, sebuah situasi dimungkinkan di mana kami membuat dua jenis aset yang berbeda dari satu file pada disk menggunakan dua jenis kunci yang berbeda. Menggunakan tipe "game" kunci, kami membuat struktur data yang dioptimalkan untuk kerja cepat dalam game. Menggunakan tipe kunci "editorial", kami membuat struktur data yang nyaman untuk diedit. Dengan cara ini, editor kami mengimplementasikan pengeditan BlendTreeuntuk animasi kerangka. Berdasarkan satu jenis kunci, sistem aset membangun kita sebuah aset dengan pohon jujur ​​di dalamnya dan banyak sinyal tentang perubahan topologi, yang sangat nyaman saat mengedit, tetapi agak lambat dalam permainan. Menggunakan jenis kunci yang berbeda, serializer membuat jenis aset lain: aset tidak memiliki metode untuk mengubah pohon, dan pohon itu sendiri diubah menjadi array node, di mana tautan ke node adalah indeks dalam array.

Epilog


Kesimpulannya, saya ingin memusatkan perhatian Anda pada solusi yang paling memengaruhi pengembangan mesin:

  1. Menggunakan struktur khusus sebagai kunci aset, bukan jalur file.
  2. Pemuatan aset hanya dalam mode asinkron.
  3. Skema yang fleksibel untuk mengelola pembagian aset (satu aset - beberapa jenis kunci).
  4. Kemampuan untuk menerima aset dengan tipe yang sama menggunakan sumber data yang berbeda (mendukung beberapa jenis kunci dalam satu serializer).

Anda akan belajar bagaimana tepatnya keputusan-keputusan ini memengaruhi penerapan kode internal dan eksternal di seri berikutnya.

Penulis:Exmix

All Articles