IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Tutoriel Vulkan complet


précédentsommairesuivant

V. Tampons de sommets

V-A. Description des sommets en entrée

V-A-1. Introduction

Dans les prochains chapitres nous allons remplacer les sommets inscrits dans le vertex shader par un tampon de sommets stocké dans la mémoire de la carte graphique. Nous commencerons par la manière la plus simple pour créer un tampon qui soit accessible par le CPU. Nous utiliserons la fonction memcpy() pour y copier les sommets. Ensuite, nous verrons comment utiliser un tampon intermédiaire pour placer les données dans une mémoire haute performance.

V-A-2. Vertex shader

Premièrement, supprimons les données de sommets du vertex shader. Le vertex shader recevra les données d’un tampon de sommets. Ces données entrantes sont désignées par le mot clef in.

 
Sélectionnez
#version 450
#extension GL_ARB_separate_shader_objects : enable

layout(location = 0) in vec2 inPosition;
layout(location = 1) in vec3 inColor;

layout(location = 0) out vec3 fragColor;

void main() {
    gl_Position = vec4(inPosition, 0.0, 1.0);
    fragColor = inColor;
}

Les variables inPosition et inColor sont des attributs de sommet (vertex attributes). Ce sont des propriétés spécifiques à chaque sommet et provenant du tampon de sommets. Finalement, le fonctionnement est similaire aux deux tableaux que nous venons de supprimer. N’oubliez pas de recompiler le vertex shader !

Tout comme pour la variable fragColor, les annotations layout(location=x) assignent un indice aux entrées du vertex shader que nous pourrons référencer plus tard. Il est important de savoir que certains types, tels les vecteurs 64 bits dvec3, utilisent plusieurs emplacements. Cela signifie que l’indice de la variable en entrée suivante ne doit pas utiliser l’emplacement qui suit :

 
Sélectionnez
layout(location = 0) in dvec3 inPosition;
layout(location = 2) in vec3 inColor;

Vous pouvez trouver plus d'informations sur les mots clefs liés à l’agencement des données dans le wiki d'OpenGL.

V-A-3. Sommets

Nous déplaçons les données des sommets depuis le code du shader jusqu'au code C++. Commencez par inclure la bibliothèque GLM pour pouvoir utiliser les vecteurs et les matrices. Nous allons utiliser ces types pour définir les vecteurs de position et de couleur.

 
Sélectionnez
#include <glm/glm.hpp>

Créez une nouvelle structure Vertex. Elle possède deux champs que nous utiliserons dans le vertex shader :

 
Sélectionnez
struct Vertex {
    glm::vec2 pos;
    glm::vec3 color;
};

La bibliothèque GLM fournit des types C++ correspondant aux types fournis par le GLSL.

 
Sélectionnez
const std::vector<Vertex> vertices = {
    {{0.0f, -0.5f}, {1.0f, 0.0f, 0.0f}},
    {{0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}},
    {{-0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}
};

Nous utilisons la structure Vertex pour spécifier un tableau contenant les données des sommets. Nous reprenons exactement les mêmes positions et couleurs qu’auparavant, mais elles sont regroupées dans un unique tableau de sommets. Nous intercalons (interleaving) les couleurs parmi les positions.

V-A-4. Descriptions des liens

La prochaine étape consiste à indiquer à Vulkan comment envoyer le format des données au vertex shader, une fois qu'elles sont stockées dans le GPU. Il existe deux types de structure pour contenir cette information.

La première est la structure VkVertexInputBindingDescription. Nous ajoutons une fonction membre à la structure Vertex pour la renseigner.

 
Sélectionnez
struct Vertex {
    glm::vec2 pos;
    glm::vec3 color;

    static VkVertexInputBindingDescription getBindingDescription() {
        VkVertexInputBindingDescription bindingDescription{};

        return bindingDescription;
    }
};

Une liaison pour les sommets (vertex binding) décrit comment lire les données en mémoire pour les envoyer comme sommets. Par conséquent, il faut indiquer le nombre d’octets entre les données et s’il faut passer à la donnée suivante après chaque sommet ou après chaque instance.

 
Sélectionnez
VkVertexInputBindingDescription bindingDescription{};
bindingDescription.binding = 0;
bindingDescription.stride = sizeof(Vertex);
bindingDescription.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;

Nos données sont compactées en un seul tableau, nous n’avons donc besoin que d’un seul lien. La propriété binding indique l'indice du lien dans le tableau des liaisons. Le paramètre stride fournit le nombre d'octets séparant le début des données de chaque sommet. Finalement, la propriété inputRate peut prendre les valeurs suivantes :

  • VK_VERTEX_INPUT_RATE_VERTEX : passer au jeu de données suivant après chaque sommet ;
  • VK_VERTEX_INPUT_RATE_INSTANCE : passer au jeu de données suivant après chaque instance.

Nous ne faisons pas de rendu instancié donc nous utilisons VK_VERTEX_INPUT_RATE_VERTEX.

V-A-5. Description des attributs

La seconde structure de type VkVertexInputAttributeDescription décrit comment gérer les sommets entrants. Nous allons ajouter une autre fonction à la structure Vertex pour remplir ces structures :

 
Sélectionnez
#include <array>

...

static std::array<VkVertexInputAttributeDescription, 2> getAttributeDescriptions() {
    std::array<VkVertexInputAttributeDescription, 2> attributeDescriptions{};

    return attributeDescriptions;
}

Comme le prototype le laisse entendre, nous allons avoir besoin de deux instances de cette structure. Une description d’attribut permet de définir comment extraire un attribut de sommets à partir des données de sommets provenant d’une description de lien. Nous avons deux attributs : la position et la couleur, donc deux descriptions.

 
Sélectionnez
attributeDescriptions[0].binding = 0;
attributeDescriptions[0].location = 0;
attributeDescriptions[0].format = VK_FORMAT_R32G32_SFLOAT;
attributeDescriptions[0].offset = offsetof(Vertex, pos);

Le paramètre binding informe Vulkan du lien de provenance des données de sommets. Le paramètre location correspond à la valeur donnée à la directive location parmi les données entrantes dans le code du vertex shader. Dans notre cas, l'entrée 0 correspond à la position du sommet stockée dans une structure de deux nombres flottants sur 32 bits.

Le paramètre format permet de décrire le type des données de l'attribut. Étonnamment, les formats doivent être indiqués avec la même énumération que celle pour les formats de couleurs. Voici les valeurs les plus couramment utilisées :

  • float : VK_FORMAT_R32_SFLOAT ;
  • vec2 : VK_FORMAT_R32G32_SFLOAT ;
  • vec3 : VK_FORMAT_R32G32B32_SFLOAT ;
  • vec4 : VK_FORMAT_R32G32B32A32_SFLOAT.

Comme vous pouvez le voir, vous devez utiliser le format dont le nombre de composants de couleurs correspond au nombre de composants de la donnée reçue par le shader. Il est autorisé d'utiliser plus de données que ce qui est attendu par le shader : ces données superflues seront silencieusement ignorées. Si le nombre de canaux est inférieur au nombre de composants attendu par le shader, les valeurs vert, bleu et alpha prendront les valeurs par défauts (0, 0, 1). Le type de couleurs (SFLOAT, UINT, SINT) et la taille doivent correspondre au type spécifié en entrée dans le shader. Voici quelques exemples :

  • ivec2 correspond à VK_FORMAT_R32G32_SINT : un vecteur de deux entiers signés sur 32 bits ;
  • uvec4 correspond à VK_FORMAT_R32G32B32A32_UINT : un vecteur de quatre entiers non signés sur 32 bits ;
  • double correspond à VK_FORMAT_R64_SFLOAT : un nombre flottant en double précision sur 64 bits.

Le paramètre format détermine implicitement la taille en octets des données de l’attribut et le paramètre offset spécifie le décalage en octets dans les données de sommets pour lire les données de l’attribut. Le lien charge une instance Vertex à la fois et la position (pos) est à l’octet 0 (pas de décalage). C’est automatiquement calculé par la macro offsetof.

 
Sélectionnez
attributeDescriptions[1].binding = 0;
attributeDescriptions[1].location = 1;
attributeDescriptions[1].format = VK_FORMAT_R32G32B32_SFLOAT;
attributeDescriptions[1].offset = offsetof(Vertex, color);

L'attribut de couleur est décrit de la même façon.

V-A-6. Entrée des sommets dans le pipeline

Nous devons configurer le pipeline graphique pour accepter les données de sommets dans ce format grâce aux structures dans la fonction createGraphicsPipeline(). Trouvez la structure vertexInputInfo et modifiez-la pour pointer sur les deux descriptions que nous venons de créer :

 
Sélectionnez
auto bindingDescription = Vertex::getBindingDescription();
auto attributeDescriptions = Vertex::getAttributeDescriptions();

vertexInputInfo.vertexBindingDescriptionCount = 1;
vertexInputInfo.vertexAttributeDescriptionCount = static_cast<uint32_t>(attributeDescriptions.size());
vertexInputInfo.pVertexBindingDescriptions = &bindingDescription;
vertexInputInfo.pVertexAttributeDescriptions = attributeDescriptions.data();

Le pipeline peut maintenant accepter les données des sommets dans le format du tableau vertices et les passer au vertex shader. Si vous lancez le programme avec les couches de validation actives, vous verrez des messages liés à l’absence de tampon de sommets associé au lien. La prochaine étape est de créer ce tampon de sommets et de copier nos données dans celui-ci afin de les rendre accessibles par le GPU.

Code C++ / Vertex shader / Fragment shader

V-B. Création du tampon de sommets

V-B-1. Introduction

Dans Vulkan, les tampons sont des emplacements mémoire utilisés pour stocker des données pouvant être lues par la carte graphique. Les tampons peuvent stocker des données de sommets comme nous allons le faire dans ce chapitre, mais peuvent aussi être utilisés pour de nombreuses autres choses comme nous allons le voir dans les prochains chapitres. Contrairement aux objets Vulkan que nous avons vus jusqu’à présent, les tampons n’allouent pas automatiquement la mémoire dont ils ont besoin. Comme nous l’avons vu, la bibliothèque Vulkan donne énormément de contrôle aux développeurs et la gestion de la mémoire fait partie des choses déléguées aux développeurs.

V-B-2. Création du tampon

Ajoutez une fonction nommée createVertexBuffer() et appelez-la depuis la fonction initVulkan() juste avant l’appel à la fonction createCommandBuffers().

 
Sélectionnez
void initVulkan() {
    createInstance();
    setupDebugMessenger();
    createSurface();
    pickPhysicalDevice();
    createLogicalDevice();
    createSwapChain();
    createImageViews();
    createRenderPass();
    createGraphicsPipeline();
    createFramebuffers();
    createCommandPool();
    createVertexBuffer();
    createCommandBuffers();
    createSyncObjects();
}

...

void createVertexBuffer() {

}

La création d’un tampon se configure au travers d’une structure de type VkBufferCreateInfo.

 
Sélectionnez
VkBufferCreateInfo bufferInfo{};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = sizeof(vertices[0]) * vertices.size();

Le premier champ de cette structure s'appelle size. Il spécifie la taille du tampon en octets. Nous pouvons utiliser l’opérateur sizeof pour déterminer la taille de notre tableau de données.

 
Sélectionnez
bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;

Le deuxième champ, appelé usage, indique comment nous allons utiliser les données du tampon. Il est possible de définir plusieurs usages grâce à un OR binaire. Notre cas d’utilisation est celui d’un tampon de sommets. Nous verrons d’autres utilisations dans de prochains chapitres.

 
Sélectionnez
bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

De la même manière que les images de la « swap chain », les tampons peuvent appartenir à une famille de queues spécifique ou être partagés entre plusieurs familles. Le tampon sera uniquement utilisé par la queue graphique. Nous pouvons donc utiliser un accès exclusif à une queue.

Le paramètre flags permet de configurer la mémoire distincte (sparse) du tampon, chose ne nous intéressant pas pour le moment. Nous allons laisser la valeur par défaut de 0.

Nous pouvons maintenant créer le tampon en appelant la fonction vkCreateBuffer(). Définissez une variable membre pour stocker ce tampon :

 
Sélectionnez
VkBuffer vertexBuffer;

...

void createVertexBuffer() {
    VkBufferCreateInfo bufferInfo{};
    bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size = sizeof(vertices[0]) * vertices.size();
    bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

    if (vkCreateBuffer(device, &bufferInfo, nullptr, &vertexBuffer) != VK_SUCCESS) {
        throw std::runtime_error("Échec lors de la création du tampon de sommets !");
    }
}

Le tampon doit être utilisable dans les opérations de rendu, nous ne pouvons donc le détruire qu'à la fin du programme. Comme il ne dépend pas de la « swap chain », nous le libérons dans la fonction cleanup().

 
Sélectionnez
void cleanup() {
    cleanupSwapChain();

    vkDestroyBuffer(device, vertexBuffer, nullptr);

    ...
}

V-B-3. Exigences concernant la mémoire

Le tampon a été créé, mais il n'est associé à aucune région de la mémoire. Afin de pouvoir allouer de la mémoire pour le tampon, nous devons récupérer ses exigences concernant la mémoire avec la fonction vkGetBufferMemoryRequirements().

 
Sélectionnez
VkMemoryRequirements memRequirements;
vkGetBufferMemoryRequirements(device, vertexBuffer, &memRequirements);

La structure VkMemoryRequirements remplie par la fonction possède trois propriétés :

  • size : le nombre d'octets dont le tampon a besoin. Cela peut être différent de bufferInfo.size ;
  • alignment : le décalage en octets où le tampon débute, par rapport au début de la zone mémoire allouée. L’alignement dépend de bufferInfo.usage et bufferInfo.flags ;
  • memoryTypeBits : un champ de bits précisant les types de mémoire convenant au tampon.

Les cartes graphiques offrent plusieurs types de mémoire à partir desquels faire l’allocation. Chaque type de mémoire offre des caractéristiques différentes en termes de performance et peut ne permettre qu’un ensemble limité d’opérations. Nous devons trouver le bon type de mémoire à utiliser suivant les exigences de notre tampon ainsi que nos propres exigences. Créons une nouvelle fonction findMemoryType() pour cela.

 
Sélectionnez
uint32_t findMemoryType(uint32_t typeFilter, VkMemoryPropertyFlags properties) {

}

D’abord, nous récupérons les types de mémoire disponibles avec la fonction vkGetPhysicalDeviceMemoryProperties().

 
Sélectionnez
VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties);

La structure VkPhysicalDeviceMemoryProperties contient deux tableaux appelés memoryTypes et memoryHeaps. Les zones mémoire (memory heap) indiquent des zones distinctes telles que la mémoire vidéo (VRAM) ou l’espace du fichier d’échange en RAM lorsque la mémoire vidéo est remplie. Vous pouvez ainsi choisir la mémoire à utiliser pour faire votre allocation. Les différents types de mémoire existent parmi ces zones. Actuellement, notre seule préoccupation est le type de mémoire et non de quelle zone elle provient. Mais vous pouvez deviner que cela a un impact sur les performances.

Trouvons d’abord un type de mémoire adéquat pour le tampon :

 
Sélectionnez
for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
    if (typeFilter & (1 << i)) {
        return i;
    }
}

throw std::runtime_error("Aucun type de mémoire adéquat !");

Le paramètre typeFilter sera utilisé comme champ de bits pour indiquer les types de mémoire adéquats. Cela signifie que nous pouvons trouver un index correspondant à un type de mémoire adéquat avec une simple boucle qui vérifie si le bit est à 1.

Cependant, nous ne sommes pas seulement intéressés par un type adéquat pour le tampon de sommets. Nous devons aussi pouvoir écrire nos données de sommets dans cette mémoire. Le tableau memoryTypes contient des structures de type VkMemoryType spécifiant la zone et les propriétés de chaque type de mémoire. Ces propriétés renseignent sur des fonctionnalités spécifiques à la mémoire, telles que la possibilité de faire correspondre la région de mémoire à une zone mémoire sur le CPU. Cette propriété est indiquée par VK_MEMORY_PROPERTY_HOSY_VISIBLE_BIT, mais nous devons aussi utiliser VK_MEMORY_PROPERTY_HOSY_COHERENT_BIT. Nous verrons l’utilité de cette deuxième valeur lorsque nous accéderons à la mémoire.

Nous modifions la boucle pour vérifier le support de ces propriétés :

 
Sélectionnez
for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
    if ((typeFilter & (1 << i)) && (memProperties.memoryTypes[i].propertyFlags & properties) == properties) {
        return i;
    }
}

Nous ne pouvons pas simplement vérifier si le résultat du ET bit à bit est non nul. Nous devons comparer son résultat avec les propriétés voulues afin de nous assurer que toutes les propriétés voulues sont supportées. S’il existe un type mémoire adéquat pour le tampon qui possède toutes les propriétés que nous souhaitons, nous retournons son index, sinon nous envoyons une exception.

V-B-4. Allocation de mémoire

Maintenant que nous sommes capables de déterminer le bon type de mémoire, nous pouvons allouer de la mémoire en remplissant la structure VkMemoryAllocateInfo.

 
Sélectionnez
VkMemoryAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = memRequirements.size;
allocInfo.memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);

Pour allouer de la mémoire, il suffit de spécifier la taille et le type. Ces deux données proviennent des exigences mémoire du tampon de sommets et des fonctionnalités que nous souhaitons. Créez une variable membre pour stocker la référence vers la mémoire et allouez-la avec la fonction vkAllocateMemory().

 
Sélectionnez
VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;

...

if (vkAllocateMemory(device, &allocInfo, nullptr, &vertexBufferMemory) != VK_SUCCESS) {
    throw std::runtime_error("Échec lors de l’allocation de la mémoire pour le tampon de sommets !");
}

Si l'allocation a réussi, nous pouvons associer cette mémoire au tampon avec la fonction vkBindBufferMemory() :

 
Sélectionnez
vkBindBufferMemory(device, vertexBuffer, vertexBufferMemory, 0);

Les trois premiers paramètres sont évidents. Le quatrième indique le décalage entre le début de la mémoire et le début du tampon. Comme nous avons alloué la mémoire spécifiquement pour ce tampon, le décalage est 0. Si le décalage n’est pas zéro, il doit alors être divisible par memRequirements.alignement.

Évidemment et tout comme pour une allocation dynamique de mémoire en C++, la mémoire doit être libérée. La mémoire liée à un tampon doit être libérée une fois que le tampon n’est plus utilisé, c’est-à-dire après la destruction du tampon :

 
Sélectionnez
void cleanup() {
    cleanupSwapChain();

    vkDestroyBuffer(device, vertexBuffer, nullptr);
    vkFreeMemory(device, vertexBufferMemory, nullptr);

V-B-5. Remplissage du tampon de sommets

Nous pouvons maintenant copier les données de sommets dans le tampon. Cela se fait en faisant correspondre la mémoire du tampon à un emplacement mémoire accessible par le CPU grâce à la fonction vkMapMemory().

 
Sélectionnez
void* data;
vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0, &data);

Cette fonction nous permet d'accéder à une région d'une ressource mémoire spécifiée par un décalage et une taille. Le décalage et la taille sont 0 et bufferInfo.size respectivement. Il est aussi possible de spécifier la valeur VK_WHOLE_SIZE pour accéder à l’intégralité de la mémoire du tampon. L’avant-dernier paramètre permet de spécifier des indicateurs, mais, dans la version actuelle de Vulkan, il n’en existe pas. Nous devons donc passer 0. Le dernier paramètre indique le pointeur à remplir pour indiquer où la zone mémoire sera accessible.

 
Sélectionnez
void* data;
vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0, &data);
    memcpy(data, vertices.data(), (size_t) bufferInfo.size);
vkUnmapMemory(device, vertexBufferMemory);

Vous pouvez maintenant utiliser la fonction memcpy() pour copier les sommets dans la mémoire, puis supprimer la correspondance avec la fonction vkUnmapMemory(). Malheureusement, le pilote peut ne pas copier les données dans la mémoire du tampon immédiatement, notamment à cause des mécanismes de cache. Il est aussi possible que les écritures dans le tampon ne soient pas visibles dans la mémoire correspondante. Il existe deux façons de pallier ce problème :

  • utiliser une zone de mémoire cohérente avec l’hôte. Cette mémoire est spécifiée par la propriété VK_MEMORY_PROPERTY_HOST_COHERENT_BIT ;
  • appeler la fonction vkFlushMappedMemoryRanges() après avoir écrit dans la mémoire correspondante et appeler la fonction vkInvalidateMappedMemory() avant une lecture dans la mémoire correspondante.

Nous utiliserons la première approche qui nous assure que la mémoire correspondante est toujours cohérente avec le contenu de la mémoire allouée. Gardez à l’esprit que cela peut être moins efficace que d’utiliser une opération explicite, mais nous allons voir pourquoi ce n’est pas important dans le prochain chapitre.

L’utilisation d’une mémoire cohérente ou d’une opération de mise à jour des données signifie que le pilote sera informé de nos écritures dans le tampon, mais cela ne signifie pas que les données seront visibles par le GPU. Le transfert de données vers le GPU est une opération se produisant en arrière-plan. La spécification offre la seule garantie que le déplacement est effectif au prochain appel à vkQueueSubmit().

V-B-6. Lier le tampon de sommets

Il ne nous reste qu'à lier le tampon de sommets lors des opérations de rendu. Nous allons pour cela compléter la fonction createCommandBuffers().

 
Sélectionnez
vkCmdBindPipeline(commandBuffers[i], VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);

VkBuffer vertexBuffers[] = {vertexBuffer};
VkDeviceSize offsets[] = {0};
vkCmdBindVertexBuffers(commandBuffers[i], 0, 1, vertexBuffers, offsets);

vkCmdDraw(commandBuffers[i], static_cast<uint32_t>(vertices.size()), 1, 0, 0);

La fonction vkCmdBindVertexBuffers() associe des tampons de sommets aux liens, comme celui que nous avons mis en place dans le chapitre précédent. Les deuxième et troisième paramètres indiquent le décalage et le nombre de liaisons que nous allons associer. Les deux derniers paramètres correspondent à un tableau de tampons de sommets à lier et au décalage à partir duquel démarrer la lecture des données. Vous devez modifier l’appel à la fonction vkCmdDraw() pour passer le nombre de sommets du tampon et non plus le nombre 3 en dur.

Lancez maintenant le programme. Vous devriez voir le triangle habituel apparaître à l'écran.

Un triangle très coloré

Essayez de modifier la couleur du sommet en haut en modifiant le tableau vertices :

 
Sélectionnez
const std::vector<Vertex> vertices = {
    {{0.0f, -0.5f}, {1.0f, 1.0f, 1.0f}},
    {{0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}},
    {{-0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}
};

Relancez le programme et vous devriez obtenir ceci :

In triangle dont le sommet du haut est blanc

Dans le prochain chapitre, nous verrons une autre manière de copier les données de sommets vers le tampon. Elle est plus performante, mais nécessite plus de travail.

Code C++ / Vertex shader / Fragment shader

V-C. Tampon intermédiaire

V-C-1. Introduction

Le tampon de sommets que nous avons mis en place fonctionne correctement, mais le type de mémoire qui nous permet d’avoir un accès depuis le CPU n’est pas le type le plus optimal pour une lecture à partir de la carte graphique. La mémoire la plus efficace possède la propriété VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, mais n’est habituellement pas accessible par le CPU pour les cartes graphiques dédiées. Dans ce chapitre, nous allons créer deux tampons de sommets. Le premier, un tampon intermédiaire (staging buffer), utilisera de la mémoire accessible par le CPU pour envoyer les données du tableau de sommets vers le tampon. Le second sera dans la mémoire locale au périphérique graphique. Nous allons donc utiliser une commande de copie de tampon pour déplacer les données du tampon intermédiaire vers le vrai tampon de sommets.

V-C-2. Queue de transfert

La commande pour copier les tampons nécessite une famille de queues supportant les opérations de transfert. Une telle queue est indiquée par le bit VK_QUEUE_TRANFER_BIT. La bonne nouvelle est qu’une famille de queues estampillée VK_QUEUE_GRAPHICS_BIT ou VK_QUEUE_COMPUTE_BIT supporte implicitement les propriétés indiquées par le bit VK_QUEUE_TRANSFER_BIT. Par conséquent, le bit spécifique au transfert peut ne pas être présent dans la propriété queueFlags pour les queues graphiques ou de calcul.

Si vous aimez la difficulté, vous pouvez toujours utiliser une famille de queues dédiée pour les opérations de transfert. Vous devrez alors faire les modifications suivantes :

  • modifier la structure QueueFamilyIndices et la fonction findQueueFamilies() pour trouver une famille de queues ayant le bit VK_QUEUE_TRANSFER_BIT,mais pas le bit VK_QUEUE_GRAPHICS_BIT ;
  • modifier la fonction createLogicalDevice() pour récupérer une référence à une queue de transfert ;
  • créer un nouveau groupe de commandes pour les tampons de commandes envoyés à la famille de queues de transfert ;
  • changer la valeur de la propriété sharingMode des ressources pour être VK_SHARING_MODE_CONCURRENT et indiquer à la fois la queue des graphismes et la queue des transferts ;
  • envoyer toutes les commandes de transfert telles que vkCmdCopyBuffer() (que nous allons utiliser dans ce chapitre) à la queue de transfert et non pas à la queue des graphismes.

Cela représente pas mal de travail, mais vous en apprendrez beaucoup sur le partage des ressources entre les familles de queues.

V-C-3. Abstraction de la création des tampons

Comme nous allons créer plusieurs tampons, il est judicieux de placer la création des tampons dans une fonction. Appelez-la createBuffer et déplacez le code provenant de la fonction createVertexBuffer() (mis à part le code rendant le tampon visible au CPU) :

 
Sélectionnez
void createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, VkMemoryPropertyFlags properties, VkBuffer& buffer, VkDeviceMemory& bufferMemory) {
    VkBufferCreateInfo bufferInfo{};
    bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size = size;
    bufferInfo.usage = usage;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

     !if (vkCreateBuffer(device, &bufferInfo, nullptr, &buffer)= VK_SUCCESS) {
        "Échec lors de la création du tampon !"throw std::runtime_error();
    }

    VkMemoryRequirements memRequirements;
    vkGetBufferMemoryRequirements(device, buffer, &memRequirements);

    VkMemoryAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
    allocInfo.allocationSize = memRequirements.size;
    allocInfo.memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, properties);

     !if (vkAllocateMemory(device, &allocInfo, nullptr, &bufferMemory)= VK_SUCCESS) {
        throw std::runtime_error("Échec lors de l’allocation de la mémoire pour le tampon !");
    }

    vkBindBufferMemory(device, buffer, bufferMemory, 0);
}

Cette fonction nécessite plusieurs paramètres : la taille du tampon, les propriétés de la mémoire et l'utilisation prévue pour ce tampon. S’ajoutent à cela deux paramètres permettant à la fonction de renvoyer les références vers les ressources créées.

Vous pouvez maintenant supprimer le code de création du tampon et d'allocation de la mémoire de la fonction createVertexBuffer() et y mettre à la place l’appel à la nouvelle fonction :

 
Sélectionnez
void createVertexBuffer() {
    VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
    createBuffer(bufferSize, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, vertexBuffer, vertexBufferMemory);

    void* data;
    vkMapMemory(device, vertexBufferMemory, 0, bufferSize, 0, &data);
        memcpy(data, vertices.data(), (size_t) bufferSize);
    vkUnmapMemory(device, vertexBufferMemory);
}

Lancez votre programme et assurez-vous que le tampon de sommets fonctionne toujours aussi bien.

V-C-4. Utiliser un tampon intermédiaire

Nous allons maintenant faire en sorte que la fonction createVertexBuffer() n’utilise plus qu’un tampon visible par l’hôte comme tampon temporaire. Nous allons exploiter un tampon local au périphérique comme tampon de sommets.

 
Sélectionnez
void createVertexBuffer() {
    VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();

    VkBuffer stagingBuffer;
    VkDeviceMemory stagingBufferMemory;
    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer, stagingBufferMemory);

    void* data;
    vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
        memcpy(data, vertices.data(), (size_t) bufferSize);
    vkUnmapMemory(device, stagingBufferMemory);

    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, vertexBuffer, vertexBufferMemory);
}

Nous ajoutons un nouveau stagingBuffer avec un stagingBufferMemory pour transmettre les données. Dans ce chapitre, nous allons nous servir de deux nouvelles valeurs pour indiquer notre utilisation des tampons :

  • VK_BUFFER_USAGE_TRANSFER_SCR_BIT : indiquant que le tampon peut être employé comme source dans une opération de transfert mémoire ;
  • VK_BUFFER_USAGE_TRANSFER_DST_BIT : indiquant que le tampon peut être employé comme destination dans une opération de transfert de mémoire.

Le vertexBuffer est maintenant alloué à partir d'un type de mémoire local au périphérique, ce qui, en général, signifie que nous ne pouvons pas utiliser la fonction vkMapMemory(). Toutefois, nous pouvons copier les données du tampon intermédiaire (stagingBuffer) vers le tampon de sommets (vertexBuffer). Nous devons spécifier notre volonté en indiquant que le tampon intermédiaire sera la source d’un transfert et le tampon de sommets une destination lorsque nous définissons l’utilisation des tampons.

Nous allons maintenant écrire une fonction nommée copyBuffer() pour copier le contenu d’un tampon dans un autre.

 
Sélectionnez
void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {

}

Les opérations de transfert mémoire sont réalisées à travers un tampon de commandes, tout comme pour les commandes de rendu. Par conséquent, nous devons allouer un tampon de commandes temporaire. Vous pouvez envisager de créer un tampon de commandes dédié pour ce genre de tampon ayant une courte durée de vie, car l’implémentation pourrait optimiser l’allocation mémoire. Vous devrez utiliser VK_COMMAND_POOL_CREATE_TRANSIENT_BIT pendant la création du groupe de commandes.

 
Sélectionnez
void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {
    VkCommandBufferAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    allocInfo.commandPool = commandPool;
    allocInfo.commandBufferCount = 1;

    VkCommandBuffer commandBuffer;
    vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
}

Enregistrez ensuite le tampon de commandes :

 
Sélectionnez
VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;

vkBeginCommandBuffer(commandBuffer, &beginInfo);

Nous allons utiliser le tampon de commandes une seule fois et attendre que la copie soit terminée avant de sortir de la fonction. Il est conseillé d'informer le pilote de notre intention à l'aide de VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT.

 
Sélectionnez
VkBufferCopy copyRegion{};
copyRegion.srcOffset = 0; // Optionnel
copyRegion.dstOffset = 0; // Optionnel
copyRegion.size = size;
vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer, 1, &copyRegion);

La copie est réalisée à l'aide de la commande vkCmdCopyBuffer(). Ses paramètres sont le tampon source et le tampon de destination et un tableau des zones mémoire à copier. Ces régions sont définies grâce aux structures de type VkBufferCopy. Elles spécifient un décalage dans le tampon source, un décalage dans le tampon de destination et une taille. Par contre, il n’est pas possible d’utiliser la valeur VK_WHOLE_SIZE dans ce cas.

 
Sélectionnez
vkEndCommandBuffer(commandBuffer);

Ce tampon de commandes ne contient que la commande de copie. Nous pouvons donc arrêter l’enregistrement juste après la copie. Exécutez le tampon de commandes pour effectuer le transfert :

 
Sélectionnez
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;

vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);
vkQueueWaitIdle(graphicsQueue);

Contrairement aux commandes de rendu, il n’y a pas d’événement à attendre. Nous souhaitons juste exécuter le transfert des données immédiatement. Encore une fois, il y a deux façons d’attendre la fin du transfert. Nous pouvons utiliser une barrière et attendre avec la fonction vkWaitForFences(), ou simplement attendre que la queue de transfert devienne inactive avec la fonction vkQueueWaitIdle(). Une barrière permettrait d’effectuer plusieurs transferts en parallèle et d’attendre qu’ils soient tous terminés. Le pilote pourrait alors optimiser le transfert.

 
Sélectionnez
vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);

N'oubliez pas de libérer le tampon de commandes utilisé pour l'opération de transfert.

Nous pouvons maintenant appeler la fonction copyBuffer() depuis la fonction createVertexBuffer() pour que les sommets soient stockés dans le tampon local au périphérique.

 
Sélectionnez
createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, vertexBuffer, vertexBufferMemory);

copyBuffer(stagingBuffer, vertexBuffer, bufferSize);

Après avoir copié les données du tampon intermédiaire dans le tampon du périphérique, nous devons effectuer un peu de nettoyage :

 
Sélectionnez
    ...

    copyBuffer(stagingBuffer, vertexBuffer, bufferSize);

    vkDestroyBuffer(device, stagingBuffer, nullptr);
    vkFreeMemory(device, stagingBufferMemory, nullptr);
}

Lancez votre programme pour vérifier que vous voyez toujours le même triangle. L'amélioration n'est peut-être pas flagrante, mais les données des sommets sont maintenant chargées à partir d’une mémoire haute performance. Cela aura un intérêt lorsque nous afficherons des géométries plus complexes.

V-C-5. Conclusion

Notez que dans une application réelle, vous n’êtes pas censé appeler la fonction vkAllocateMemory() pour chaque tampon. Le nombre d’allocations effectuées est limité par le périphérique physique à maxMemoryAllocationCount. Sa valeur peut être plutôt basse, et ce, même sur un périphérique haut de gamme. Par exemple, sur une NVIDIA GTX 1080, la valeur est de 4096. La bonne façon pour allouer une zone mémoire pour pouvoir y stocker un grand nombre d’objets est d’utiliser un allocateur personnalisé qui répartira les allocations unitaires des différents objets dans la zone mémoire grâce au paramètre spécifiant un décalage (offset).

Vous pouvez implémenter votre propre allocateur, ou bien utiliser la bibliothèque VulkanMemoryAllocator créée par l’initiative GPUOpen. Toutefois, dans ce tutoriel, nous pouvons nous contenter d’une allocation mémoire pour chaque ressource, car nous n'atteindrons pas cette limite.

Code C++ / Vertex shader / Fragment shader

V-D. Tampon d’indices

V-D-1. Introduction

Les modèles 3D que vous allez afficher dans une application réelle vont certainement partager plusieurs sommets entre plusieurs triangles. Cela se produit même dans des cas simples, tels que l’affichage d’un rectangle :

[ALT-PASTOUCHE]

L’affichage d’un rectangle se réalise grâce à deux triangles. Nous avons donc besoin d’un tampon de sommet avec six sommets. Le problème est que les données de deux sommets sont dupliquées et nous obtenons 50 % de redondance. Cela devient pire avec des modèles plus complexes, où de nombreux sommets sont réutilisés dans trois triangles ou plus. La solution à ce problème est d’utiliser un tampon d’indices.

Un tampon d’indices est essentiellement un tableau de pointeurs vers le tampon de sommets. Il vous permet de réordonner les données de sommets et de réutiliser les données dans plusieurs triangles. Le schéma ci-dessus montre à quoi ressemble le tampon d’indices pour le rectangle si nous avions un tampon de sommets contenant quatre sommets uniques. Les trois premiers indices définissent le triangle haut droit et les trois derniers indices définissent les sommets du triangle bas gauche.

V-D-2. Création d'un tampon d’indices

Dans ce chapitre, nous allons modifier les données pour afficher un rectangle comme celui du schéma ci-dessus. Voici les nouvelles données définissant les quatre coins :

 
Sélectionnez
const std::vector<Vertex> vertices = {
    {{-0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}},
    {{0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}},
    {{0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}},
    {{-0.5f, 0.5f}, {1.0f, 1.0f, 1.0f}}
};

Le coin en haut gauche est rouge, celui en haut à droite est vert, celui en bas à droite est bleu et celui en bas à gauche est blanc. Nous allons ajouter un nouveau tableau nommé indices pour représenter le contenu du tampon d’indices. Il contient les indices comme spécifié dans le schéma et permet de dessiner le triangle haut droit et le triangle bas gauche

 
Sélectionnez
const std::vector<uint16_t> indices = {
    0, 1, 2, 2, 3, 0
};

Il est possible d'utiliser les types uint16_t ou uint32_t pour les valeurs du tampon d’indices suivant le nombre d’entrées dans le tableau vertices. Nous pouvons nous contenter du type uint16_t pour le moment, car nous allons utiliser moins de 65 535 sommets différents.

Comme pour les données des sommets, les indices doivent être envoyés au GPU à travers un objet du type VkBuffer. Définissez deux nouveaux membres pour stocker les ressources du tampon d’indices :

 
Sélectionnez
VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;
VkBuffer indexBuffer;
VkDeviceMemory indexBufferMemory;

La fonction createIndexBuffer() que nous ajoutons est quasiment identique à la fonction createVertexBuffer() :

 
Sélectionnez
void initVulkan() {
    …
    createVertexBuffer();
    createIndexBuffer();
    …
}

void createIndexBuffer() {
    VkDeviceSize bufferSize = sizeof(indices[0]) * indices.size();

    VkBuffer stagingBuffer;
    VkDeviceMemory stagingBufferMemory;
    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer, stagingBufferMemory);

    void* data;
    vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
    memcpy(data, indices.data(), (size_t) bufferSize);
    vkUnmapMemory(device, stagingBufferMemory);

    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, indexBuffer, indexBufferMemory);

    copyBuffer(stagingBuffer, indexBuffer, bufferSize);

    vkDestroyBuffer(device, stagingBuffer, nullptr);
    vkFreeMemory(device, stagingBufferMemory, nullptr);
}

Il n’y a que deux différences notables : la valeur de bufferSize correspond à la taille du tableau multipliée par la taille en mémoire du type des données utilisées (sizeof(uint16_t), ou sizeof(uint32_t). L’usage que nous avons du tampon indexBuffer doit être VK_BUFFER_USAGE_INDEX_BUFFER_BIT au lieu de VK_BUFFER_USAGE_VERTEX_BUFFER_BIT. À part ça, le processus est le même. Nous créons un tampon intermédiaire pour y copier le contenu du tableau indices puis nous copions le contenu du tampon intermédiaire dans le tampon du périphérique.

Le tampon d’indices doit être libéré à la fin du programme, tout comme pour le tampon de sommets.

 
Sélectionnez
void cleanup() {
    cleanupSwapChain();

    vkDestroyBuffer(device, indexBuffer, nullptr);
    vkFreeMemory(device, indexBufferMemory, nullptr);

    vkDestroyBuffer(device, vertexBuffer, nullptr);
    vkFreeMemory(device, vertexBufferMemory, nullptr);

    …
}

V-D-3. Utilisation d'un tampon d’indices

Pour utiliser le tampon d’indices lors des opérations de rendu nous devons effectuer deux modifications à la fonction createCommandBuffers(). Nous devons d’abord lier le tampon d’indices, tout comme nous l’avons fait pour le tampon de sommets. La différence est que nous pouvons seulement n’avoir qu’un tampon d’indices. Malheureusement, il n‘est pas possible d’utiliser un tampon d’indices différent pour chaque attribut de sommet : nous devons donc avoir de la duplication de sommets si un attribut varie.

 
Sélectionnez
vkCmdBindVertexBuffers(commandBuffers[i], 0, 1, vertexBuffers, offsets);

vkCmdBindIndexBuffer(commandBuffers[i], indexBuffer, 0, VK_INDEX_TYPE_UINT16);

Un tampon d’indices se lie grâce à la fonction vkCmdBindIndexBuffer(). La fonction prend en paramètres le tampon d’indices, un décalage et le type des données des indices. Comme indiqué précédemment, les types possibles sont VK_INDEX_TYPE_UINT16 et VK_INDEX_TYPE_UINT32.

La seule liaison du tampon d’indices ne change rien. Nous devons aussi modifier la commande de rendu pour dire à Vulkan d’utiliser le tampon d’indices. Enlevez la fonction vkCmdDraw() et remplacez-la par la fonction vkCmdDrawIndexed() :

 
Sélectionnez
vkCmdDrawIndexed(commandBuffers[i], static_cast<uint32_t>(indices.size()), 1, 0, 0, 0);

Cette fonction est similaire à la fonction vkCmdDraw(). Les deux premiers paramètres indiquent le nombre d’indices et le nombre d’instances. Nous n’utilisons pas l’instanciation, donc nous spécifions une unique instance. Le nombre d’indices représente le nombre de sommets qui seront passés au tampon de sommets. Le paramètre suivant indique un décalage dans le tampon d’indices. L’avant-dernier paramètre indique un nombre à ajouter aux indices du tampon d’indices lors du rendu. Le dernier paramètre indique un décalage pour l’instanciation, que nous n’utilisons pas.

Lancez le programme et vous devriez obtenir ceci :

[ALT-PASTOUCHE]

Vous savez maintenant économiser la mémoire en réutilisant les sommets à l'aide d'un tampon d’indices. Cela deviendra crucial dans les chapitres suivants dans lesquels vous allez apprendre à charger des modèles 3D complexes.

Nous avons déjà évoqué le fait que vous devriez allouer plusieurs ressources, telles que les tampons, avec une seule opération d’allocation mémoire. En réalité, vous devez aller encore plus loin. Les développeurs de pilotes recommandent que vous stockiez plusieurs tampons, tels que le tampon de sommets et le tampon d’indices dans un unique VkBuffer et d’utiliser des décalages dans les commandes telles que vkCmdBindVertexBuffers(). L’avantage est que vos données regroupées permettent une meilleure utilisation des caches. Il est même possible de réutiliser le même morceau de mémoire pour plusieurs ressources si elles ne sont pas utilisées dans les mêmes opérations de rendu et que les données sont mises à jour. Cela s’appelle de l’aliasing et certaines fonctions Vulkan possèdent des options spécifiques pour implémenter ce mécanisme.

Code C++ / Vertex shader / Fragment shader


précédentsommairesuivant

Licence Creative Commons
Le contenu de cet article est rédigé par Contributeurs Github et Developpez.com et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Partage dans les Mêmes Conditions 3.0 non transposé.
Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright © 2013 Developpez.com.