Blitz.Engine: Asset System



Avant de comprendre comment fonctionne le système d'actifs du moteur Blitz.Engine , nous devons décider quel actif est et ce que nous entendons exactement par système d'actifs. Selon Wikipedia, un actif de jeu est un objet numérique, composé principalement des mêmes données, une entité indivisible qui représente une partie du contenu du jeu et possède certaines propriétés. Du point de vue du modèle de programme, un actif peut apparaître comme un objet créé sur un ensemble de données. Les actifs peuvent être stockés dans un fichier séparé. À son tour, un système d'actifs est un grand nombre de codes de programme chargés de charger et d'exploiter des actifs de différents types.

En fait, un système d'actifs est une grande partie du moteur de jeu, qui peut devenir un assistant fidèle pour les développeurs de jeux ou transformer leur vie en enfer. À mon avis, la décision logique était de concentrer cet «enfer» en un seul endroit, en protégeant soigneusement les autres développeurs de l'équipe. Nous allons vous parler de ce que nous avons fait dans cette série d'articles - allons-y!

Articles prévus sur le sujet:

  • Énoncé des exigences et aperçu de l'architecture
  • Cycle de vie des actifs
  • Présentation détaillée de la classe AssetManager
  • Intégration dans ECS
  • GlobalAssetCache

Exigences et raisons


Les exigences du système de chargement des actifs sont nées entre un rocher et un endroit dur. Une enclume était le désir de faire quelque chose enfermé en soi pour qu'il fonctionne sans écrire de code externe. Eh bien, ou presque sans écrire de code externe. Le marteau est devenu réalité. Et voici ce que nous avons fini avec:

  1. Gestion automatique de la mémoire , ce qui signifie qu'il n'est pas nécessaire d'appeler la fonction de libération de l'actif. Autrement dit, dès que tous les objets externes utilisant l'actif sont détruits, l'actif est détruit. La motivation est simple: écrire moins de code. Moins de code signifie moins d'erreurs.
  2. , ( AssetManager’a). , . — . , «» .
    , , (). — , . , , . , , . , , .
  3. . : . , .
  4. (shared) . , . , . «» , .
  5. Priorisez le chargement des actifs . Il n'y a que 3 niveaux de priorité: Élevé, Moyen, Faible. Dans la même priorité, les actifs sont chargés dans l'ordre de la demande. Imaginez une situation: un joueur clique sur «Pour se battre» et le chargement du niveau commence. Parallèlement à cela, la tâche de préparation de l'image-objet de l'écran de chargement tombe dans la file d'attente de téléchargement. Mais puisque certains des éléments de niveau sont entrés dans la file d'attente avant le sprite, le joueur regarde l'écran noir pendant un certain temps.

De plus, nous avons formulé une règle simple pour nous-mêmes: "Tout ce qui peut être fait sur le thread AssetManager doit être fait sur le thread AssetManager." Par exemple, préparer une partition du paysage et de la texture des normales sur la base d'une carte de hauteur, relier un programme GPU, etc.

Quelques détails d'implémentation


Avant de commencer à comprendre le fonctionnement du système de chargement d'actifs, nous devons nous familiariser avec deux classes largement utilisées dans le moteur Blitz.Engine:

  • Type: informations d'exécution sur un type. Ce type est similaire au type Typedu langage C #, à l'exception qu'il ne donne pas accès aux champs et aux méthodes du type. Contient: tapez le nom, un certain nombre de signes comme is_floating, is_pointer, is_const, etc. La méthode Type::instance<T>renvoie une constante au sein d'un seul lancement d'application const Type*, ce qui vous permet de vérifier le formulaireif (type == Type::instance<T>())
  • Any: permet de regrouper la valeur de tout type mobile ou copiable. La connaissance du type de package est Anystockée en tant que const Type*. Anysait calculer un hachage en fonction de son contenu, et sait également comparer le contenu pour l'égalité. En cours de route, il vous permet d'effectuer des conversions du type actuel vers un autre. C'est une sorte de repenser n'importe quelle classe de la bibliothèque standard ou de la bibliothèque boost.

Liste de tous les actifs système de chargement est basé sur trois classes: AssetManager, AssetBase, IAssetSerializer. Cependant, avant de procéder à la description de ces classes, il faut dire que le code externe utilise un alias Asset<T>déclaré comme ceci:

Asset = std::shared_ptr<T>

où T est un AssetBase ou un type spécifique d'actif. En utilisant shared_ptr partout, nous atteignons la satisfaction de l'exigence numéro 1 (gestion automatique de la mémoire).

AssetManager- Il s'agit d'une classe finie qui n'a pas d'héritiers. Cette classe définit le cycle de vie d'un actif et envoie des messages sur les changements d'état d'un actif. Il AssetManagerstocke également un arbre de dépendance entre les actifs et une liaison d'actif aux fichiers sur le disque, écoute FileWatcheret implémente un rechargement d'actif. Et surtout, il AssetManagerlance un thread séparé, implémente une file d'attente de tâches pour préparer l'actif et encapsule toute la synchronisation avec d'autres threads d'application (la demande d'actif peut être exécutée à partir de n'importe quel thread d'application, y compris le flux de téléchargement). Fonctionne en

même temps AssetManageravec un atout abstraitAssetBase, déléguant la tâche de création et de chargement d'un actif d'un type spécifique à l'héritier de IAssetSerializer. Je vais vous en dire plus sur la façon dont cela se produit dans les articles suivants.

Dans le cadre de l'exigence numéro 4 (partage des actifs), l'une des questions les plus brûlantes était «quoi utiliser comme identifiant d'actif?» La solution la plus simple et apparemment évidente serait d'utiliser le chemin d'accès au fichier à télécharger. Cependant, cette décision impose un certain nombre de limitations sérieuses:

  1. Pour créer un actif, ce dernier doit être représenté sous la forme d'un fichier sur disque, ce qui supprime la possibilité de créer des actifs d'exécution basés sur d'autres actifs.
  2. . , GPUProgram (defines). , .
  3. , .
  4. .

Nous n'avons pas considéré les paragraphes 3 et 4 comme un argument au tout début, car personne ne pensait même que cela pourrait être utile. Cependant, ces fonctionnalités ont par la suite grandement facilité le développement de l'éditeur.

Ainsi, nous avons décidé d'utiliser la clé d'actif comme identifiant, qui au niveau AssetManagerest représenté par le type Any. L' Anyhéritier sait interpréter IAssetSerializer. Lui-même AssetManagerne connaît que la relation entre le type de clé et l'héritier IAssetSerializer. Le code qui demande un actif sait généralement de quel type d'actif il a besoin et fonctionne avec une clé d'un type spécifique. Tout se passe comme ceci:


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

La méthode hashet l'opérateur de comparaison à l'intérieur PathKeysont nécessaires pour le fonctionnement des opérations de classe correspondantes Any, mais nous ne nous attarderons pas sur cela en détail.

Donc, ce qui se passe dans le code ci-dessus: au moment de l'appel, la get_asset(key)clé sera copiée dans un objet temporaire du type Any, qui, à son tour, sera passé à la méthode get_asset. Ensuite, AssetManagerprenez le type de clé de l'argument. Dans notre cas, ce sera:

Type::instance<MyAsset::PathKey>

Par ce type, il trouvera l'objet sérialiseur et déléguera au sérialiseur toutes les opérations suivantes (création et chargement).

AssetBase- Il s'agit de la classe de base pour tous les types d'actifs dans le moteur. Cette classe stocke la clé d'actif, l'état actuel de l'actif (chargé, dans la file d'attente, etc.), ainsi que le texte d'erreur si le chargement de l'actif a échoué. En fait, la structure interne est un peu plus compliquée, mais nous considérerons cela avec le cycle de vie des actifs.

IAssetSerializer, comme son nom l'indique, est la classe de base de l'entité qui prépare l'actif. En fait, l'héritier de cette classe ne charge pas seulement l'actif:

  • Affectation et désallocation d'un objet d'actif d'un type spécifique.
  • Chargement d'un élément d'un type spécifique.
  • Compilation d'une liste de chemins de fichiers sur la base desquels l'actif est construit. Cette liste est nécessaire pour le mécanisme de rechargement des actifs lorsqu'un fichier change. La question se pose: pourquoi la liste des chemins, et pas un seul chemin? Des ressources simples, comme les textures, peuvent vraiment être construites sur la base d'un seul fichier. Cependant, si nous regardons le shader, nous verrons que le redémarrage devrait se produire non seulement si le texte du shader est modifié, mais également si le fichier connecté au shader est modifié via la directive include.
  • Enregistrement de l'actif sur le disque. Il est activement utilisé à la fois lors de la modification des ressources et de la préparation des ressources pour le jeu.
  • Indique les types de clés qu'il prend en charge.

Et la dernière question que je veux mettre en évidence dans le cadre de cet article: pourquoi pourriez-vous avoir besoin de plusieurs types de clés sur un sérialiseur / actif? Disons-le à son tour.

Un sérialiseur - plusieurs types de clés


Prenons un exemple d'actif GPUProgram(c'est-à-dire un shader). Afin de charger un shader dans notre moteur, les informations suivantes sont requises:

  1. Chemin d'accès au fichier shader.
  2. Liste des définitions de préprocesseur.
  3. La scène pour laquelle le shader est assemblé et compilé (sommet, fragment, calcul).
  4. Le nom du point d'entrée.

En rassemblant ces informations, nous obtenons la clé de shader, qui est utilisée dans le jeu. Cependant, lors du développement d'un jeu ou d'un moteur, il est souvent nécessaire d'afficher des informations de débogage à l'écran, parfois avec un shader spécifique. Et dans cette situation, il peut être pratique d'écrire le texte du shader directement dans le code. Pour ce faire, nous pouvons obtenir le deuxième type de clé qui, au lieu du chemin d'accès au fichier et de la liste des définitions de préprocesseur, contiendra le texte du shader.

Prenons un autre exemple: la texture. La façon la plus simple de créer une texture est de la charger à partir du disque. Pour ce faire, nous avons besoin du chemin d'accès au fichier ( PathKey). Mais nous pouvons également générer le contenu de la texture de manière algorithmique et créer une texture à partir d'un tableau d'octets ( MemoryKey). Le troisième type de clé peut être une clé pour créer une RenderTargettexture ( RTKey).

Selon le type de clé, différents moteurs de rastérisation de glyphes peuvent être utilisés: stb (StbFontKey), FreeType (FTFontKet) ou un générateur de polices de champ de distance signé auto-signé (SDFFontKey).

L'animation d'images clés peut être chargée ( PathKey) ou générée par du code ( MemoryKey).

Un atout - plusieurs types de clés


Imaginez que nous avons un ParticleEffectatout qui décrit les règles de génération de particules. De plus, nous avons un éditeur pratique pour cet actif. En même temps, l'éditeur de niveau et l'éditeur de particules sont une seule application multi-fenêtres. Ceci est pratique car vous pouvez ouvrir un niveau, y placer une source de particules et regarder l'effet dans l'environnement du niveau, tout en modifiant l'effet lui-même. Si nous avons un type de clé, alors l’objet effet utilisé dans le monde de l’édition d’effets et dans le monde des niveaux est le même. Toutes les modifications apportées dans l'éditeur d'effets seront immédiatement visibles dans le niveau. À première vue, cela peut sembler une bonne idée, mais examinons les scénarios suivants:

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

De plus, une situation est possible dans laquelle nous créons deux types d'actifs différents à partir d'un fichier sur un disque en utilisant deux types de clés différents. En utilisant le type de clé «jeu», nous créons une structure de données optimisée pour un travail rapide dans le jeu. En utilisant le type de clé "éditorial", nous créons une structure de données qui est pratique pour l'édition. De cette façon, notre éditeur implémente l'édition BlendTreepour les animations squelettiques. Basé sur un type de clé, le système d'actifs nous construit un actif avec un arbre honnête à l'intérieur et un tas de signaux sur le changement de topologie, ce qui est très pratique lors de l'édition, mais assez lentement dans le jeu. Selon un type de clé différent, le sérialiseur crée un autre type d'actif: l'actif n'a aucune méthode pour modifier l'arborescence et l'arborescence elle-même est transformée en un tableau de nœuds, où le lien vers le nœud est un index dans le tableau.

Épilogue


Pour résumer, je voudrais concentrer votre attention sur les solutions qui ont surtout influencé le développement ultérieur du moteur:

  1. Utilisation d'une structure personnalisée comme clé d'actif et non comme chemin de fichier.
  2. Chargement des ressources uniquement en mode asynchrone.
  3. Un schéma flexible pour gérer le partage des actifs (un actif - plusieurs types de clés).
  4. La possibilité de recevoir des actifs du même type en utilisant différentes sources de données (prise en charge de plusieurs types de clés dans un sérialiseur).

Vous apprendrez comment exactement ces décisions ont influencé la mise en œuvre du code interne et du code externe dans la prochaine série.

Auteur:Exmix

All Articles