Introduction

Dans les quatres prochains chapitres nous allons remplacer les sommets inscrits dans le vertex shader par un vertex buffer stocké dans la mémoire de la carte graphique. Nous commencerons par une manière simple de procéder en créant un buffer manipulable depuis le CPU et en y copiant des données avec memcpy. Puis nous verrons comment avantageusement utiliser un staging buffer pour accéder à de la mémoire de haute performance.

Vertex shader

Premièrement, changeons le vertex shader en retirant les coordonnées des sommets de son code. Elles seront maintenant stockés dans une variable. Elle sera liée au contenu du vertex buffer, ce qui est indiqué par le mot-clef in. Faisons de même avec la couleur.

#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;

out gl_PerVertex {
    vec4 gl_Position;
};

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

Les variables inPosition et inColor sont des vertex attributes. Ce sont des propriétés spécifiques du sommet à l'origine de l'invocation du shader. Ces données peuvent être de différentes natures, des couleurs aux coordonnées en passant par des coordonnées de texture. Recompilez ensuite le vertex shader.

Tout comme pour fragColor, les annotations de type layout(location=x) assignent un indice à l'entrée. Cet indice est utilisé depuis le code C++ pour les reconnaître. Il est important de savoir que certains types - comme les vecteurs de flottants de double précision (64 bits) - prennent deux emplacements. Voici un exemple d'une telle situation, où il est nécessaire de prévoir un écart entre deux entrés :

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

Vous pouvez trouver plus d'information sur les qualificateurs d'organisation sur le wiki.

Sommets

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

#include <glm/glm.hpp>

Créez une nouvelle structure appelée Vertex. Elle possède deux attributs que nous utiliserons pour le vertex shader :

struct Vertex {
    glm::vec2 pos;
    glm::vec3 color;
};

GLM nous fournit des types très pratiques simulant les types utilisés par GLSL.

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 utiliserons ensuite un tableau de structures pour représenter un ensemble de sommets. Nous utiliserons les mêmes couleurs et les mêmes positions qu'avant, mais elles seront combinées en un seul tableau d'objets.

Lier les descriptions

La prochaine étape consiste à indiquer à Vulkan comment passer ces données au shader une fois qu'elles sont stockées dans le GPU. Nous verrons plus tard comment les y stocker. Il y a deux types de structures que nous allons devoir utiliser.

Pour la première, appelée VkVertexInputBindingDescription, nous allons ajouter une fonction à Vertex qui renverra une instance de cette structure.

struct Vertex {
    glm::vec2 pos;
    glm::vec3 color;

    static VkVertexInputBindingDescription getBindingDescription() {
        VkVertexInputBindingDescription bindingDescription = {};

        return bindingDescription;
    }
};

Un vertex binding décrit la lecture des données stockées en mémoire. Elle fournit le nombre d'octets entre les jeux de données et la manière de passer d'un ensemble de données (par exemple une coordonnée) au suivant. Elle permet à Vulkan de savoir comment extraire chaque jeu de données correspondant à une invocation du vertex shader du vertex buffer.

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'aurons besoin que d'un seul vertex binding. Le membre binding indique l'indice du vertex binding dans le tableau des bindings. Le paramètre stride fournit le nombre d'octets séparant les débuts de deux ensembles de données, c'est à dire l'écart entre les données devant ếtre fournies à une invocation de vertex shader et celles devant être fournies à la suivante. Enfin inputRate peut prendre les valeurs suivantes :

  • VK_VERTEX_INPUT_RATE_VERTEX: Passer au jeu de données suivante après chaque sommet
  • VK_VERTEX_INPUT_RATE_INSTANCE: Passer au jeu de données suivantes après chaque instance

Nous n'utilisons pas d'instanced rendering donc nous utiliserons VK_VERTEX_INPUT_RATE_VERTEX.

Description des attributs

La seconde structure dont nous avons besoin est VkVertexInputAttributeDescription. Nous allons également en créer deux instances depuis une fonction membre de Vertex :

#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 de ces structures. Elles décrivent chacunes l'origine et la nature des données stockées dans une variable shader annotée du location=x, et la manière d'en déterminer les valeurs depuis les données extraites par le binding. Comme nous avons deux de ces variables, nous avons besoin de deux de ces structures. Voici ce qu'il faut remplir pour la position.

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 de la provenance des données du sommet qui mené à l'invocation du vertex shader, en lui fournissant le vertex binding qui les a extraites. Le paramètre location correspond à la valeur donnée à la directive location dans le code du vertex shader. Dans notre cas l'entrée 0 correspond à la position du sommet stockée dans un vecteur de floats de 32 bits.

Le paramètre format permet donc de décrire le type de donnée de l'attribut. Étonnement les formats doivent être indiqués avec des valeurs énumérées dont les noms semblent correspondre à des gradients de couleur :

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

Comme vous pouvez vous en douter il faudra utiliser le format dont le nombre de composants de couleurs correspond au nombre de données à transmettre. Il est autorisé d'utiliser plus de données que ce qui est prévu dans le shader, et ces données surnuméraires seront silencieusement ignorées. Si par contre il n'y a pas assez de valeurs les valeurs suivantes seront utilisées par défaut pour les valeurs manquantes : 0, 0 et 1 pour les deuxième, troisième et quatrième composantes. Il n'y a pas de valeur par défaut pour le premier membre car ce cas n'est pas autorisé. Les types (SFLOAT, UINT et SINT) et le nombre de bits doivent par contre correspondre parfaitement à ce qui est indiqué dans le shader. Voici quelques exemples :

  • ivec2 correspond à VK_FORMAT_R32G32_SINT et est un vecteur à deux composantes d'entiers signés de 32 bits
  • uvec4 correspond à VK_FORMAT_R32G32B32A32_UINT et est un vecteur à quatre composantes d'entiers non signés de 32 bits
  • double correspond à VK_FORMAT_R64_SFLOAT et est un float à précision double (donc de 64 bits)

Le paramètre format définit implicitement la taille en octets des données. Mais le binding extrait dans notre cas deux données pour chaque sommet : la position et la couleur. Pour savoir quels octets doivent être mis dans la variable à laquelle la structure correspond, le paramètre offset permet d'indiquer de combien d'octets il faut se décaler dans les données extraites pour se trouver au début de la variable. Ce décalage est calculé automatiquement par la macro offsetof.

L'attribut de couleur est décrit de la même façon. Essayez de le remplir avant de regarder la solution ci-dessous.

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

Entrée des sommets dans la pipeline

Nous devons maintenant mettre en place la réception par la pipeline graphique des données des sommets. Nous allons modifier une structure dans createGraphicsPipeline. Trouvez vertexInputInfo et ajoutez-y les références aux deux structures de description que nous venons de créer :

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

La pipeline peut maintenant accepter les données des vertices dans le format que nous utilisons et les fournir au vertex shader. Si vous lancez le programme vous verrez que les validation layers rapportent qu'aucun vertex buffer n'est mis en place. Nous allons donc créer un vertex buffer et y placer les données pour que le GPU puisse les utiliser.

Code C++ / Vertex shader / Fragment shader