Création de Minecraft en une semaine en C ++ et Vulkan

Je me suis fixé la tâche de recréer Minecraft à partir de zéro en une semaine en utilisant mon propre moteur en C ++ et Vulkan. J'ai été inspiré par Hopson , qui a fait de même avec C ++ et OpenGL. À son tour, il a été inspiré par Shane Beck , qui a été inspiré par Minecraft, dont la source d'inspiration était Infiniminer, dont la création, vraisemblablement, a été inspirée par la vraie exploitation minière.


Le référentiel GitHub pour ce projet est ici . Chaque jour a sa propre balise git.

Bien sûr, je n'avais pas l'intention de recréer littéralement Minecraft. Ce projet était censé être éducatif. Je voulais en savoir plus sur l'utilisation de Vulkan dans quelque chose de plus compliqué que vulkan-tutorial.com ou la démo de Sasha Willem. Par conséquent, l'accent est mis principalement sur la conception du moteur Vulkan, et non sur la conception du jeu.

Tâches


Le développement sur Vulkan est beaucoup plus lent que sur OpenGL, donc je ne pouvais pas intégrer de nombreuses fonctionnalités de ce Minecraft dans le jeu. Il n'y a pas de monstres, pas d'artisanat, pas de pierre rouge, pas de physique des blocs, etc. Dès le début, les objectifs du projet étaient les suivants:

  • Création d'un système de rendu de terrain
    • Brassage
    • Éclairage
  • Création d'un système générateur de terrain
    • Le soulagement
    • Des arbres
    • Biomes
  • Ajout de la possibilité de changer de terrain et de déplacer des blocs

Je devais trouver un moyen d'implémenter tout cela sans ajouter d'interface graphique au jeu, car je ne trouvais aucune bibliothèque d'interface graphique fonctionnant avec Vulkan et facile à intégrer.

Bibliothèques


Bien sûr, je n'allais pas écrire une application Vulkan à partir de zéro. Pour accélérer le processus de développement, j'utiliserai autant que possible des bibliothèques prêtes à l'emploi. À savoir:

  • VulkanWrapper - mon propre wrapper C ++ pour l'API Vulkan
  • GLFW - pour les fenêtres et les entrées utilisateur
  • VulkanMemoryAllocator - pour allouer de la mémoire Vulkan
  • GLM - pour vecteurs et matrices mathématiques
  • entt - pour signaux / slots et ECS
  • stb - pour les utilitaires de chargement d'images
  • FastNoise - pour générer du bruit 3D

Jour 1


Le premier jour, j'ai préparé un passe-partout Vulkan et un squelette moteur. La plupart du code était un passe-partout et je pouvais simplement le copier depuis vulkan-tutorial.com . Il comprenait également une astuce avec le stockage des données de sommet dans le cadre d'un vertex shader. Cela signifiait que je n'avais même pas à régler l'allocation de mémoire. Un simple convoyeur qui ne peut faire qu'une seule chose: dessiner un triangle.

Le moteur est assez simple pour prendre en charge le rendu des triangles. Il a une fenêtre et une boucle de jeu auxquelles les systèmes peuvent être connectés. L'interface graphique est limitée par la fréquence d'images affichée dans le titre de la fenêtre.

Le projet est divisé en deux parties: VoxelEngineet VoxelGame.


Jour 2


J'ai intégré la bibliothèque Vulkan Memory Allocator. Cette bibliothèque prend en charge la plupart du passe-partout d'allocation de mémoire Vulkan: types de mémoire, tas de mémoire de périphérique et allocation secondaire.

Maintenant que j'avais une allocation de mémoire, j'ai créé des classes pour les maillages et les tampons de vertex. J'ai changé le rendu des triangles pour qu'il utilise la classe des maillages, et non les tableaux intégrés dans le shader. Actuellement, les données de maillage sont transférées vers le GPU en rendant manuellement les triangles.


Peu de choses ont changé

3e jour


J'ai ajouté un système de rendu graphique. Ce post a été pris comme base pour créer cette classe , mais la classe est très simplifiée. Mon graphique de rendu contient uniquement les éléments essentiels pour gérer la synchronisation avec Vulkan.

Le graphique de rendu me permet de définir des nœuds et des bords. Les nœuds sont le travail effectué par le GPU. Les nervures sont des dépendances de données entre les nœuds. Chaque nœud reçoit son propre tampon d'instructions, dans lequel il écrit. Le graphique est engagé dans des tampons de commande à double tampon et les synchronise avec les trames précédentes. Les bords sont utilisés pour insérer automatiquement des barrières de convoyeur avant et après l'écriture d'un nœud dans chaque tampon d'instructions. Les barrières de pipeline synchronisent l'utilisation de toutes les ressources et transfèrent la propriété entre les files d'attente. De plus, les bords insèrent des sémaphores entre les nœuds.

Les nœuds et les bords forment un graphe acyclique dirigé . Le graphique de rendu effectue ensuite un tri topologique.nœuds, ce qui conduit à la création d'une liste plate de nœuds triés de telle sorte que chaque nœud va après tous les nœuds dont il dépend.

Le moteur a trois types de nœuds. AcquireNodereçoit une image d'une chaîne tampon (swapchain), TransferNodetransfère les données de la CPU vers le GPU et PresentNodefournit une image d'une chaîne tampon à afficher.

Chaque nœud peut implémenter preRender, renderet postRender, qui sont exécutés dans chaque trame. AcquireNodeobtient une image d'une chaîne de tampons pendant preRender. PresentNodefournit cette image à temps postRender.

J'ai refactorisé le rendu triangle afin qu'il utilise un système de graphes de rendu, plutôt que de tout traiter moi-même. Il y a un bord entre AcquireNodeetTriangleRendererainsi qu'entre TriangleRendereret PresentNode. Cela garantit que l'image de la chaîne tampon est correctement synchronisée lors de son utilisation pendant la trame.


Je jure que le moteur a changé

Jour 4


J'ai créé une caméra et un système de rendu 3D. Jusqu'à présent, la caméra reçoit son propre pool de tampons et de descripteurs persistants.

J'ai ralenti ce jour-là parce que j'essayais de trouver la bonne configuration pour le rendu 3D avec Vulkan. La plupart des documents en ligne se concentrent sur le rendu à l'aide d'OpenGL, qui utilise des systèmes de coordonnées légèrement différents de Vulkan. Dans OpenGL, l'axe Z de l'espace de clip est spécifié comme [-1, 1]et le bord supérieur de l'écran est à Y = 1. Dans Vulkan, l'axe Z est spécifié comme [0, 1]et le bord supérieur de l'écran est à Y = -1. En raison de ces petites différences, les matrices de projection GLM standard ne fonctionnent pas correctement car elles sont conçues pour OpenGL.

GLM a une optionGLM_FORCE_DEPTH_ZERO_TO_ONE, éliminant le problème avec l'axe Z. Après cela, le problème avec l'axe Y peut être éliminé en changeant simplement le signe de (1, 1)l' élément de matrice de projection (GLM utilise l'indexation à partir de 0).

Si nous inversons l'axe Y, nous devons inverser les données du sommet, car avant cela, la direction négative de l'axe Y pointait vers le haut.


Maintenant en 3D!

5e jour


J'ai ajouté une entrée utilisateur et la possibilité de déplacer la caméra avec la souris. Le système d'entrée est trop sophistiqué, mais il élimine les bizarreries de l'entrée GLFW. En particulier, j'ai eu le problème de changer la position de la souris tout en la bloquant.

L'entrée du clavier et de la souris est essentiellement une enveloppe mince au-dessus de GLFW, ouverte via des gestionnaires de signaux entt.

Juste pour comparaison - à peu près la même chose que Hopson a fait le premier jour de son projet.


6e jour


J'ai commencé à ajouter du code pour générer et rendre des blocs de voxels. L'écriture du code de maillage a été facile car je l'ai déjà fait et connaissais des abstractions qui me permettaient de faire moins d'erreurs.

L'une des abstractions était une classe de modèle ChunkData<T, chunkSize>qui définit un cube de type de la Ttaille chunkSizede chaque côté. Cette classe stocke les données dans un tableau 1D et traite les données d'indexation avec une coordonnée 3D. La taille de chaque bloc est de 16 x 16 x 16, donc les données internes sont un simple tableau d'une longueur de 4096.

Une autre abstraction consiste à créer un itérateur de positions qui génère des coordonnées de (0, 0, 0)à(15, 15, 15). Ces deux classes garantissent que les itérations avec les données de bloc sont effectuées dans un ordre linéaire pour augmenter la localité du cache. La coordonnée 3D est toujours disponible pour les autres opérations qui en ont besoin. Par exemple:

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

J'ai plusieurs tableaux statiques qui spécifient les décalages couramment utilisés dans le jeu. Par exemple, il Neighbors6définit 6 voisins avec lesquels le cube a des faces communes.

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- ce sont tous des voisins avec lesquels le cube a une face, une arête ou un sommet commun. Autrement dit, il s'agit d'une grille 3x3x3 sans cube central. Il existe également des tableaux similaires pour d'autres ensembles de voisins et pour des ensembles 2D de voisins.

Il existe un tableau définissant les données nécessaires pour créer une face du cube. Les directions de chaque face de ce tableau correspondent aux directions du tableau 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),
    },
    ...
};

Grâce à cela, le code de création de maillage est très simple. Il contourne simplement les données des blocs et ajoute une face lorsque le bloc est solide, mais pas son voisin. Le code vérifie simplement chaque face de chaque cube d'un bloc. Ceci est similaire à la méthode "naïve" décrite ici .

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

J'ai remplacé TriangleRendererpar ChunkRenderer. J'ai également ajouté un tampon de profondeur pour que le maillage du bloc puisse s'afficher correctement. Il était nécessaire d'ajouter un bord supplémentaire au graphique de rendu entre TransferNodeet ChunkRenderer. Cette périphérie transfère la propriété des ressources de la famille de files d'attente entre la file d'attente de transfert et la file d'attente graphique.

Ensuite, j'ai changé le moteur afin qu'il puisse gérer correctement les événements de changement de fenêtre. Dans OpenGL, cela se fait simplement, mais de manière assez confuse dans Vulkan. Étant donné que la chaîne de tampons doit être créée explicitement et avoir une taille constante, lorsque vous redimensionnez la fenêtre, vous devez la recréer. Vous devez recréer toutes les ressources qui dépendent de la chaîne de mémoire tampon.

Toutes les commandes qui dépendent de la chaîne de mémoire tampon (et maintenant ce sont toutes des commandes de dessin) doivent terminer l'exécution avant de détruire l'ancienne chaîne de mémoire tampon. Cela signifie que l'ensemble du GPU sera inactif.

Vous devez modifier le pipeline graphique pour fournir une fenêtre dynamique et un redimensionnement.

Une chaîne tampon ne peut pas être créée si la taille de la fenêtre est 0 sur l'axe X ou Y. Y compris lorsque la fenêtre est réduite. Autrement dit, lorsque cela se produit, le jeu entier est interrompu et se poursuit uniquement lorsque la fenêtre se déplie.

Maintenant, le maillage est un simple échiquier tridimensionnel. Les couleurs RVB du maillage sont définies en fonction de sa position XYZ multipliée par 16.



7e jour


J'ai fait le processus de jeu non pas un, mais plusieurs blocs à la fois. Plusieurs blocs et leurs maillages sont gérés par la bibliothèque ECS entt. Ensuite, j'ai refactorisé le rendu de bloc pour qu'il rende tous les blocs qui sont dans ECS. Je n'ai toujours qu'un bloc, mais je pourrais en ajouter de nouveaux si nécessaire.

J'ai refactorisé le maillage afin que ses données puissent être mises à jour après sa création. Cela me permettra de mettre à jour le maillage des blocs à l'avenir lorsque j'ajouterai la possibilité d'ajouter et de supprimer des cubes.

Lorsque vous ajoutez ou supprimez un cube, le nombre de sommets du maillage peut potentiellement augmenter ou diminuer. Le tampon de vertex précédemment sélectionné ne peut être utilisé que si le nouveau maillage est de la même taille ou plus petit. Mais si le maillage est plus grand, de nouveaux tampons de vertex doivent être créés.

Le tampon de vertex précédent ne peut pas être supprimé immédiatement. Il peut y avoir des tampons d'instructions exécutés à partir de trames précédentes qui sont spécifiques à un objet particulier VkBuffer. Le moteur doit conserver un tampon jusqu'à ce que ces tampons de commande soient terminés. Autrement dit, si nous dessinons un maillage dans un cadre i, le GPU peut utiliser ce tampon avant le début du cadre i + 2. Le tampon ne peut pas être retiré du CPU tant que le GPU n'a pas fini de l'utiliser. J'ai donc changé le graphique de rendu pour qu'il suive la durée de vie des ressources.

Si le nœud du graphe de rendu souhaite utiliser une ressource (tampon ou image), il doit alors appeler la méthode syncà l'intérieur de la méthode preRender. Cette méthode obtient un pointeur shared_ptrsur une ressource. Cetteshared_ptrgarantit que la ressource ne sera pas supprimée pendant l'exécution des tampons de commande. (En termes de performances, cette solution n'est pas très bonne. Plus d'informations à ce sujet plus tard.)

Maintenant, le maillage du bloc est régénéré dans chaque cadre.


Conclusion


C'est tout ce que j'ai fait en une semaine - j'ai préparé les bases du rendu du monde avec plusieurs blocs de voxels et je continuerai à travailler la deuxième semaine.

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


All Articles