Blitz.Engine: Asset System



Bevor wir verstehen, wie das Asset-System der Blitz.Engine- Engine funktioniert , müssen wir entscheiden, was Asset ist und was genau wir unter Asset-System verstehen. Laut Wikipedia ist ein Spielobjekt ein digitales Objekt, das hauptsächlich aus denselben Daten besteht, eine unteilbare Einheit, die einen Teil des Spielinhalts darstellt und bestimmte Eigenschaften aufweist. Aus Sicht des Programmmodells kann ein Asset als Objekt angezeigt werden, das in einem Datensatz erstellt wurde. Assets können als separate Datei gespeichert werden. Ein Asset-System ist wiederum eine Menge Programmcode, der für das Laden und Betreiben von Assets verschiedener Typen verantwortlich ist.

Tatsächlich ist ein Asset-System ein großer Teil der Spiel-Engine, die ein loyaler Assistent für Spieleentwickler werden oder ihr Leben in die Hölle verwandeln kann. Meiner Meinung nach war die logische Entscheidung, diese „Hölle“ an einem Ort zu konzentrieren und andere Teamentwickler sorgfältig davor zu schützen. Wir werden Ihnen erzählen, was wir in dieser Artikelserie gemacht haben - los geht's!

Geplante Artikel zum Thema:

  • Anforderungserklärung und Architekturübersicht
  • Asset-Lebenszyklus
  • Detaillierte Übersicht über die AssetManager-Klasse
  • Integration in ECS
  • GlobalAssetCache

Anforderungen und Gründe


Die Anforderungen an das Asset-Loading-System wurden zwischen einem Felsen und einem harten Ort geboren. Ein Amboss war der Wunsch, etwas in sich geschlossenes zu tun, damit es funktioniert, ohne externen Code zu schreiben. Gut oder fast ohne externen Code zu schreiben. Der Hammer wurde Realität. Und hier ist, was wir am Ende hatten:

  1. Automatische Speicherverwaltung , dh die Freigabefunktion für das Asset muss nicht aufgerufen werden. Das heißt, sobald alle externen Objekte, die das Asset verwenden, zerstört werden, wird das Asset zerstört. Die Motivation hier ist einfach - schreiben Sie weniger Code. Weniger Code bedeutet weniger Fehler.
  2. , ( AssetManager’a). , . — . , «» .
    , , (). — , . , , . , , . , , .
  3. . : . , .
  4. (shared) . , . , . «» , .
  5. Priorisieren Sie das Laden von Assets . Es gibt nur 3 Prioritätsstufen: Hoch, Mittel, Niedrig. Innerhalb derselben Priorität werden Assets in der Reihenfolge der Anforderung geladen. Stellen Sie sich eine Situation vor: Ein Spieler klickt auf „Zum Kampf“ und das Laden des Levels beginnt. Gleichzeitig fällt die Aufgabe, das Sprite des Ladebildschirms vorzubereiten, in die Download-Warteschlange. Da jedoch einige der Level-Assets vor dem Sprite in die Warteschlange geraten sind, schaut der Spieler einige Zeit auf den schwarzen Bildschirm.

Darüber hinaus haben wir eine einfache Regel für uns selbst formuliert: "Alles, was im AssetManager-Thread ausgeführt werden kann, muss im AssetManager-Thread ausgeführt werden." Zum Beispiel das Vorbereiten einer Partition der Landschaft und der Textur von Normalen basierend auf einer Höhenkarte, das Verknüpfen eines GPU-Programms usw.

Einige Implementierungsdetails


Bevor wir verstehen, wie das Asset-Ladesystem funktioniert, müssen wir uns mit zwei Klassen vertraut machen, die in der Blitz.Engine-Engine weit verbreitet sind:

  • Type: Laufzeitinformationen zu einem Typ. Dieser Typ ähnelt dem Typ Typeaus der C # -Sprache, mit der Ausnahme, dass er keinen Zugriff auf die Felder und Methoden des Typs bietet. Enthält: Typname, eine Reihe von Zeichen wie is_floating, is_pointer, is_constusw. Die Methode Type::instance<T>gibt innerhalb eines Anwendungsstarts eine Konstante zurück const Type*, mit der Sie das Formular überprüfen könnenif (type == Type::instance<T>())
  • Any: Ermöglicht das Packen des Werts eines beliebigen beweglichen oder kopierbaren Typs. Das Wissen darüber, welcher Typ verpackt ist, Anywird als const gespeichert Type*. Anyweiß, wie man einen Hash anhand seines Inhalts berechnet und wie man Inhalte auf Gleichheit vergleicht. Auf dem Weg können Sie Konvertierungen vom aktuellen Typ in einen anderen durchführen. Dies ist eine Art Umdenken jeder Klasse aus der Standardbibliothek oder der Boost-Bibliothek.

Das Ladesystem für alle Assets-Listen basiert auf drei Klassen : AssetManager, AssetBase, IAssetSerializer. Bevor Sie jedoch mit der Beschreibung dieser Klassen fortfahren, müssen Sie sagen, dass der externe Code einen Alias ​​verwendet Asset<T>, der wie folgt deklariert ist:

Asset = std::shared_ptr<T>

Dabei ist T eine AssetBase oder ein bestimmter Asset-Typ. Mit shared_ptr erreichen wir überall die Erfüllung der Anforderung Nummer 1 (Automatische Speicherverwaltung).

AssetManager- Dies ist eine endliche Klasse, die keine Erben hat. Diese Klasse definiert den Lebenszyklus eines Assets und sendet Nachrichten über Änderungen im Status eines Assets. Außerdem wird AssetManagerein Abhängigkeitsbaum zwischen Assets und einem Asset gespeichert, das an Dateien auf der Festplatte gebunden ist, FileWatcherund es wird ein erneutes Laden von Assets abgehört und implementiert. Und am wichtigsten ist, dass AssetManagerein separater Thread gestartet, eine Task-Warteschlange zum Vorbereiten des Assets implementiert und die gesamte Synchronisation mit anderen Anwendungsthreads gekapselt wird (die Asset-Anforderung kann von jedem Anwendungsthread einschließlich des Download-Streams ausgeführt werden).

Gleichzeitig AssetManagerarbeitet mit einem abstrakten VermögenswertAssetBaseDelegieren der Aufgabe zum Erstellen und Laden eines Assets eines bestimmten Typs an den Erben von IAssetSerializer. Ich werde Ihnen in den folgenden Artikeln mehr darüber erzählen, wie dies geschieht.

Im Rahmen der Anforderung Nr. 4 (Asset Sharing) lautete eine der heißesten Fragen: „Was ist als Asset-ID zu verwenden?“. Die einfachste und offensichtlichste Lösung wäre, den Pfad zu der herunterzuladenden Datei zu verwenden. Diese Entscheidung bringt jedoch eine Reihe schwerwiegender Einschränkungen mit sich:

  1. Um ein Asset zu erstellen, muss letzteres als Datei auf der Festplatte dargestellt werden, wodurch die Möglichkeit entfällt, Laufzeit-Assets basierend auf anderen Assets zu erstellen.
  2. . , GPUProgram (defines). , .
  3. , .
  4. .

Wir haben die Absätze 3 und 4 am Anfang nicht als Argument betrachtet, da nicht einmal der Gedanke bestand, dass dies nützlich sein könnte. Diese Funktionen erleichterten jedoch später die Entwicklung des Editors erheblich.

Daher haben wir uns entschlossen, den Asset-Schlüssel als Kennung zu verwenden, die auf der Ebene AssetManagerdurch den Typ dargestellt wird Any. Der AnyErbe kann interpretieren IAssetSerializer. Selbst AssetManagerkennt nur die Beziehung zwischen der Art des Schlüssels und dem Erben IAssetSerializer. Der Code, der ein Asset anfordert, weiß normalerweise, welchen Asset-Typ er benötigt, und arbeitet mit einem Schlüssel eines bestimmten Typs. Es geht alles ungefähr so:


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

Die Methode hashund der Vergleichsoperator im Inneren PathKeywerden für das Funktionieren der entsprechenden Klassenoperationen benötigt Any, aber wir werden nicht im Detail darauf eingehen.

Was also im obigen Code passiert: get_asset(key)Zum Zeitpunkt des Aufrufs wird der Schlüssel in ein temporäres Objekt des Typs kopiert Any, das wiederum an die Methode übergeben wird get_asset. AssetManagerNehmen Sie als nächstes den Schlüsseltyp aus dem Argument. In unserem Fall wird es sein:

Type::instance<MyAsset::PathKey>

Bei diesem Typ findet er das Serializer-Objekt und delegiert alle nachfolgenden Vorgänge (Erstellen und Laden) an den Serializer.

AssetBase- Dies ist die Basisklasse für alle Arten von Assets in der Engine. Diese Klasse speichert den Asset-Schlüssel, den aktuellen Status des Assets (geladen, in der Warteschlange usw.) sowie den Fehlertext, wenn das Laden des Assets fehlgeschlagen ist. Tatsächlich ist die interne Struktur etwas komplizierter, aber wir werden dies zusammen mit dem Lebenszyklus der Vermögenswerte berücksichtigen.

IAssetSerializerWie der Name schon sagt, ist dies die Basisklasse für die Entität, die das Asset vorbereitet. Tatsächlich lädt der Erbe dieser Klasse nicht nur den Vermögenswert:

  • Zuordnung und Freigabe eines Asset-Objekts eines bestimmten Typs.
  • Laden eines Assets eines bestimmten Typs.
  • Kompilieren einer Liste von Dateipfaden, auf deren Grundlage das Asset erstellt wird. Diese Liste wird für den Mechanismus zum erneuten Laden von Assets benötigt, wenn sich eine Datei ändert. Es stellt sich die Frage: Warum die Liste der Pfade und nicht ein Pfad? Einfache Assets wie Texturen können wirklich auf der Basis einer einzelnen Datei erstellt werden. Wenn wir uns jedoch den Shader ansehen, werden wir sehen, dass der Neustart nicht nur erfolgen sollte, wenn der Shadertext geändert wird, sondern auch, wenn die mit dem Shader verbundene Datei über die include-Direktive geändert wird.
  • Asset auf Festplatte speichern. Es wird sowohl beim Bearbeiten von Assets als auch beim Vorbereiten von Assets für das Spiel aktiv verwendet.
  • Gibt die unterstützten Schlüsseltypen an.

Und die letzte Frage, die ich im Rahmen dieses Artikels hervorheben möchte: Warum benötigen Sie möglicherweise mehrere Schlüsseltypen auf einem Serializer / Asset? Lassen Sie es uns der Reihe nach klären.

Ein Serializer - mehrere Arten von Schlüsseln


Nehmen wir ein Beispiel für ein Asset GPUProgram(dh einen Shader). Um einen Shader in unseren Motor zu laden, sind folgende Informationen erforderlich:

  1. Der Pfad zur Shader-Datei.
  2. Liste der Präprozessordefinitionen.
  3. Die Phase, für die der Shader zusammengestellt und kompiliert wird (Vertex, Fragment, Compute).
  4. Der Name des Einstiegspunkts.

Wenn wir diese Informationen zusammenfassen, erhalten wir den Shader-Schlüssel, der im Spiel verwendet wird. Während der Entwicklung eines Spiels oder einer Engine ist es jedoch häufig erforderlich, einige Debugging-Informationen auf dem Bildschirm anzuzeigen, manchmal mit einem bestimmten Shader. In dieser Situation ist es praktisch, den Shader-Text direkt in den Code zu schreiben. Dazu können wir den zweiten Schlüsseltyp erhalten, der anstelle des Pfads zur Datei und der Liste der Präprozessordefinitionen den Text des Shaders enthält.

Betrachten Sie ein anderes Beispiel: Textur. Der einfachste Weg, eine Textur zu erstellen, besteht darin, sie von der Festplatte zu laden. Dazu benötigen wir den Pfad zur Datei ( PathKey). Wir können den Inhalt der Textur aber auch algorithmisch generieren und eine Textur aus einem Array von Bytes ( MemoryKey) erstellen . Der dritte Schlüsseltyp kann ein Schlüssel zum Erstellen einer RenderTargetTextur sein ( RTKey).

Abhängig von der Art des Schlüssels können verschiedene Glyphen-Rasterisierungs-Engines verwendet werden: stb (StbFontKey), FreeType (FTFontKet) oder ein selbstsignierter signierter Distanzfeld-Schriftgenerator (SDFFontKey).

Keyframe-Animationen können geladen ( PathKey) oder durch Code ( MemoryKey) generiert werden .

Ein Asset - mehrere Arten von Schlüsseln


Stellen Sie sich vor, wir haben ein ParticleEffectAsset, das die Regeln für die Partikelerzeugung beschreibt. Darüber hinaus haben wir einen praktischen Editor für dieses Asset. Gleichzeitig sind der Ebeneneditor und der Partikeleditor eine Anwendung mit mehreren Fenstern. Dies ist praktisch, da Sie eine Ebene öffnen, eine Partikelquelle darin platzieren und den Effekt in der Umgebung der Ebene betrachten können, während Sie den Effekt selbst bearbeiten. Wenn wir einen Schlüsseltyp haben, ist das Effektobjekt, das in der Welt der Effektbearbeitung und in der Ebenenwelt verwendet wird, ein und dasselbe. Alle im Effekteditor vorgenommenen Änderungen sind sofort in der Ebene sichtbar. Auf den ersten Blick mag dies eine coole Idee sein, aber schauen wir uns die folgenden Szenarien an:

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

Darüber hinaus ist eine Situation möglich, in der wir zwei verschiedene Arten von Assets aus einer Datei auf einer Festplatte mit zwei verschiedenen Arten von Schlüsseln erstellen. Mit dem Schlüsseltyp "Spiel" erstellen wir eine Datenstruktur, die für die schnelle Arbeit im Spiel optimiert ist. Mit dem Schlüsseltyp "redaktionell" erstellen wir eine Datenstruktur, die für die Bearbeitung geeignet ist. Auf diese Weise implementiert unser Editor die Bearbeitung BlendTreefür Skelettanimationen. Basierend auf einer Art von Schlüssel erstellt das Asset-System ein Asset mit einem ehrlichen Baum im Inneren und einer Reihe von Signalen zum Ändern der Topologie, was beim Bearbeiten sehr praktisch ist, aber im Spiel eher langsam. Mit einem anderen Schlüsseltyp erstellt der Serializer einen anderen Asset-Typ: Das Asset verfügt über keine Methoden zum Ändern des Baums, und der Baum selbst wird in ein Array von Knoten umgewandelt, wobei die Verknüpfung zum Knoten ein Index im Array ist.

Epilog


Zusammenfassend möchte ich Ihre Aufmerksamkeit auf die Lösungen konzentrieren, die die Weiterentwicklung des Motors am meisten beeinflusst haben:

  1. Verwenden einer benutzerdefinierten Struktur als Asset-Schlüssel, nicht als Dateipfad.
  2. Laden von Assets nur im asynchronen Modus.
  3. Ein flexibles Schema für die Verwaltung der Asset-Freigabe (ein Asset - mehrere Arten von Schlüsseln).
  4. Die Möglichkeit, Assets desselben Typs unter Verwendung verschiedener Datenquellen zu empfangen (Unterstützung für mehrere Schlüsseltypen in einem Serializer).

In der nächsten Reihe erfahren Sie, wie genau diese Entscheidungen die Implementierung des internen und des externen Codes beeinflusst haben.

Autor:Exmix

All Articles