À la différence d'anciens APIs, le code des shaders doit être fourni à Vulkan sous la forme de bytecode et non sous une forme facilement compréhensible par l'homme, comme GLSL ou HLSL. Ce format est appelé SPIR-V et est conçu pour fonctionner avec Vulkan et OpenCL (deux APIs de Khronos). Ce format peut servir à écrire du code éxécuté sur la carte graphique pour les graphismes et pour le calcul, mais nous nous concentrerons sur la pipeline graphique dans ce tutoriel.

L'avantage d'un tel format est que le compilateur spécifique de la carte graphique a beaucoup moins de travail d'interprétation. L'expérience a en effet montré qu'avec les syntaxes compréhensibles par l'homme, certains compilateurs étaient très laxistes par rapport à la spécification qui leur était fournie. Si vous écriviez du code complexe, il pouvait être accepté par l'un et pas par l'autre, ou pire s'éxécuter différemment. Avec le format de plus bas niveau qu'est SPIR-V, ces problèmes seront normalement éliminés.

Cela ne veut cependant pas dire que nous devrons écrire ces bytecodes à la main. Khronos fournit même un compilateur transformant GLSL en SPIR-V. Ce compilateur standard vérifiera que votre code correspond à la spécification. Vous pouvez également l'inclure comme une bibliothèque pour produire du SPIR-V au runtime, mais nous ne ferons pas cela dans ce tutoriel. Le compilateur est fourni avec le SDK et s'appelle glslangValidator, mais nous allons utiliser un autre compilateur nommé glslc, écrit par Google. L'avantage de ce dernier est qu'il utilise le même format d'options que GCC ou Clang, et inclu quelques fonctionnalités supplémentaires comme les includes. Les deux compilateurs sont fournis dans le SDK, vous n'avez donc rien de plus à télécharger.

GLSL est un langage possédant une syntaxe proche du C. Les programmes y ont une fonction main invoquée pour chaque objet à traiter. Plutôt que d'utiliser des paramètres et des valeurs de retour, GLSL utilise des variables globales pour les entrées et sorties des invocations. Le langage possède des fonctionnalités avancées pour aider le travail avec les mathématiques nécessaires aux graphismes, avec par exemple des vecteurs, des matrices et des fonctions pour les traiter. On y trouve des fonctions pour réaliser des produits vectoriels ou des réflexions d'un vecteurs par rapport à un autre. Le type pour les vecteurs s'appelle vec et est suivi d'un nombre indiquant le nombre d'éléments, par exemple vec3. On peut accéder à ses données comme des membres avec par exemple .y, mais aussi créer de nouveaux vecteurs avec plusieurs indications, par exemple vec3(1.0, 2.0, 3.0).xz qui crée un vec2 égal à (1.0, 3.0). Leurs constructeurs peuvent aussi être des combinaisons de vecteurs et de valeurs. Par exemple il est possible de créer un vec3 ainsi : vec3(vec2(1.0, 2.0), 3.0).

Comme nous l'avons dit au chapitre précédent, nous devrons écrire un vertex shader et un fragment shader pour pouvoir afficher un triangle à l'écran. Les deux prochaines sections couvrirons ce travail, puis nous verrons comment créer des bytecodes SPIR-V avec ce code.

Le vertex shader

Le vertex shader traite chaque sommet envoyé depuis le programme C++. Il récupère des données telles la position, la normale, la couleur ou les coordonnées de texture. Ses sorties sont la position du somment dans l'espace de l'écran et les autres attributs qui doivent être fournies au reste de la pipeline, comme la couleur ou les coordonnées de texture. Ces valeurs seront interpolées lors de la rasterization afin de produire un dégradé continu. Ainsi les invocation du fragment shader recevrons des vecteurs dégradés entre deux sommets.

Une clip coordinate est un vecteur à quatre éléments émis par le vertex shader. Il est ensuite transformé en une normalized screen coordinate en divisant ses trois premiers composants par le quatrième. Ces coordonnées sont des coordonnées homogènes qui permettent d'accéder au frambuffer grâce à un repère de [-1, 1] par [-1, 1]. Il ressemble à cela :

Vous devriez déjà être familier de ces notions si vous avez déjà utilisé des graphismes 3D. Si vous avez utilisé OpenGL avant vous vous rendrez compte que l'axe Y est maintenenant inversé et que l'axe Z va de 0 à 1, comme Direct3D.

Pour notre premier triangle nous n'appliquerons aucune transformation, nous nous contenterons de spécifier directement les coordonnées des trois sommets pour créer la forme suivante :

Nous pouvons directement émettre ces coordonnées en mettant leur quatrième composant à 1 de telle sorte que la division ne change pas les valeurs.

Ces coordonnées devraient normalement être stockées dans un vertex buffer, mais sa création et son remplissage ne sont pas des opérations triviales. J'ai donc décidé de retarder ce sujet afin d'obtenir plus rapidement un résultat visible à l'écran. Nous ferons ainsi quelque chose de peu orthodoxe en attendant : inclure les coordonnées directement dans le vertex shader. Son code ressemble donc à ceci :

#version 450

vec2 positions[3] = vec2[](
    vec2(0.0, -0.5),
    vec2(0.5, 0.5),
    vec2(-0.5, 0.5)
);

void main() {
    gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
}

La fonction main est invoquée pour chaque sommet. La variable prédéfinie gl_VertexIndex contient l'index du sommet à l'origine de l'invocation du main. Elle est en général utilisée comme index dans le vertex buffer, mais nous l'emploierons pour déterminer la coordonnée à émettre. Cette coordonnée est extraite d'un tableau prédéfini à trois entrées, et est combinée avec un z à 0.0 et un w à 1.0 pour faire de la division une identité. La variable prédéfinie gl_Position fonctionne comme sortie pour les coordonnées.

Le fragment shader

Le triangle formé par les positions émises par le vertex shader remplit un certain nombre de fragments. Le fragment shader est invoqué pour chacun d'entre eux et produit une couleur et une profondeur, qu'il envoie à un ou plusieurs framebuffer(s). Un fragment shader colorant tout en rouge est ainsi écrit :

#version 450

layout(location = 0) out vec4 outColor;

void main() {
    outColor = vec4(1.0, 0.0, 0.0, 1.0);
}

Le main est appelé pour chaque fragment de la même manière que le vertex shader est appelé pour chaque sommet. Les couleurs sont des vecteurs de quatre composants : R, G, B et le canal alpha. Les valeurs doivent être incluses dans [0, 1]. Au contraire de gl_Position, il n'y a pas (plus exactement il n'y a plus) de variable prédéfinie dans laquelle entrer la valeur de la couleur. Vous devrez spécifier votre propre variable pour contenir la couleur du fragment, où layout(location = 0) indique l'index du framebuffer où la couleur sera écrite. Ici, la couleur rouge est écrite dans outColor liée au seul et unique premier framebuffer.

Une couleur pour chaque vertex

Afficher ce que vous voyez sur cette image ne serait pas plus intéressant qu'un triangle entièrement rouge?

Nous devons pour cela faire quelques petits changements aux deux shaders. Spécifions d'abord une couleur distincte pour chaque sommet. Ces couleurs seront inscrites dans le vertex shader de la même manière que les positions :

vec3 colors[3] = vec3[](
    vec3(1.0, 0.0, 0.0),
    vec3(0.0, 1.0, 0.0),
    vec3(0.0, 0.0, 1.0)
);

Nous devons maintenant passer ces couleurs au fragment shader afin qu'il puisse émettre des valeurs interpolées et dégradées au framebuffer. Ajoutez une variable de sortie pour la couleur dans le vertex shader et donnez lui une valeur dans le main:

layout(location = 0) out vec3 fragColor;

void main() {
    gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
    fragColor = colors[gl_VertexIndex];
}

Nous devons ensuite ajouter l'entrée correspondante dans le fragment shader, dont la valeur sera l'interpolation correspondant à la position du fragment pour lequel le shader sera invoqué :

layout(location = 0) in vec3 fragColor;

void main() {
    outColor = vec4(fragColor, 1.0);
}

Les deux variables n'ont pas nécessairement le même nom, elles seront reliées selon l'index fourni dans la directive location. La fonction main doit être modifiée pour émettre une couleur possédant un canal alpha. Le résultat montré dans l'image précédente est dû à l'interpolation réalisée lors de la rasterization.

Compilation des shaders

Créez un dossier shaders à la racine de votre projet, puis enregistrez le vertex shader dans un fichier appelé shader.vert et le fragment shader dans un fichier appelé shader.frag. Les shaders en GLSL n'ont pas d'extension officielle mais celles-ci correspondent à l'usage communément accepté.

Le contenu de shader.vert devrait être:

#version 450

out gl_PerVertex {
    vec4 gl_Position;
};

layout(location = 0) out vec3 fragColor;

vec2 positions[3] = vec2[](
    vec2(0.0, -0.5),
    vec2(0.5, 0.5),
    vec2(-0.5, 0.5)
);

vec3 colors[3] = vec3[](
    vec3(1.0, 0.0, 0.0),
    vec3(0.0, 1.0, 0.0),
    vec3(0.0, 0.0, 1.0)
);

void main() {
    gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
    fragColor = colors[gl_VertexIndex];
}

Et shader.frag devrait contenir :

#version 450

layout(location = 0) in vec3 fragColor;

layout(location = 0) out vec4 outColor;

void main() {
    outColor = vec4(fragColor, 1.0);
}

Nous allons maintenant compiler ces shaders en bytecode SPIR-V à l'aide du programme glslc.

Windows

Créez un fichier compile.bat et copiez ceci dedans :

C:/VulkanSDK/x.x.x.x/Bin32/glslc.exe shader.vert -o vert.spv
C:/VulkanSDK/x.x.x.x/Bin32/glslc.exe shader.frag -o frag.spv
pause

Corrigez le chemin vers glslc.exe pour que le .bat pointe effectivement là où le vôtre se trouve. Double-cliquez pour lancer ce script.

Linux

Créez un fichier compile.sh et copiez ceci dedans :

/home/user/VulkanSDK/x.x.x.x/x86_64/bin/glslc shader.vert -o vert.spv
/home/user/VulkanSDK/x.x.x.x/x86_64/bin/glslc shader.frag -o frag.spv

Corrigez le chemin menant au glslc pour qu'il pointe là où il est. Rendez le script exécutable avec la commande chmod +x compile.sh et lancez-le.

Fin des instructions spécifiques

Ces deux commandes instruisent le compilateur de lire le code GLSL source contenu dans un fichier et d'écrire le bytecode SPIR-V dans un fichier grâce à l'option -o (output).

Si votre shader contient une erreur de syntaxe le compilateur vous indiquera le problème et la ligne à laquelle il apparait. Essayez de retirer un point-virgule et voyez l'efficacité du debogueur. Essayez également de voir les arguments supportés. Il est possible de le forcer à émettre le bytecode sous un format compréhensible permettant de voir exactement ce que le shader fait et quelles optimisations le compilateur y a réalisées.

La compilation des shaders en ligne de commande est l'une des options les plus simples et les plus évidentes. C'est ce que nous utiliserons dans ce tutoriel. Sachez qu'il est également possible de compiler les shaders depuis votre code. Le SDK inclue la librairie libshaderc , qui permet de compiler le GLSL en SPIR-V depuis le programme C++.

Charger un shader

Maintenant que vous pouvez créer des shaders SPIR-V il est grand temps de les charger dans le programme et de les intégrer à la pipeline graphique. Nous allons d'abord écrire une fonction qui réalisera le chargement des données binaires à partir des fichiers.

#include <fstream>

...

static std::vector<char> readFile(const std::string& filename) {
    std::ifstream file(filename, std::ios::ate | std::ios::binary);

    if (!file.is_open()) {
        throw std::runtime_error(std::string {"échec de l'ouverture du fichier "} + filename + "!");
    }
}

La fonction readFile lira tous les octets du fichier qu'on lui indique et les retournera dans un vector de caractères servant ici d'octets. L'ouverture du fichier se fait avec deux paramètres particuliers :

  • ate : permet de commencer la lecture à la fin du fichier
  • binary : indique que le fichier doit être lu comme des octets et que ceux-ci ne doivent pas être formatés

Commencer la lecture à la fin permet d'utiliser la position du pointeur comme indicateur de la taille totale du fichier et nous pouvons ainsi allouer un stockage suffisant :

size_t fileSize = (size_t) file.tellg();
std::vector<char> buffer(fileSize);

Après cela nous revenons au début du fichier et lisons tous les octets d'un coup :

file.seekg(0);
file.read(buffer.data(), fileSize);

Nous pouvons enfin fermer le fichier et retourner les octets :

file.close();

return buffer;

Appelons maintenant cette fonction depuis createGraphicsPipeline pour charger les bytecodes des deux shaders :

void createGraphicsPipeline() {
    auto vertShaderCode = readFile("shaders/vert.spv");
    auto fragShaderCode = readFile("shaders/frag.spv");
}

Assurez-vous que les shaders soient correctement chargés en affichant la taille des fichiers lus depuis votre programme puis en comparez ces valeurs à la taille des fichiers indiquées par l'OS. Notez que le code n'a pas besoin d'avoir un caractère nul en fin de chaîne car nous indiquerons à Vulkan sa taille exacte.

Créer des modules shader

Avant de passer ce code à la pipeline nous devons en faire un VkShaderModule. Créez pour cela une fonction createShaderModule.

VkShaderModule createShaderModule(const std::vector<char>& code) {

}

Cette fonction prendra comme paramètre le buffer contenant le bytecode et créera un VkShaderModule avec ce code.

La création d'un module shader est très simple. Nous avons juste à indiquer un pointeur vers le buffer et la taille de ce buffer. Ces informations seront inscrites dans la structure VkShaderModuleCreatInfo. Le seul problème est que la taille doit être donnée en octets mais le pointeur sur le code est du type uint32_t et non du type char. Nous devrons donc utiliser reinterpet_cast sur notre pointeur. Cet opérateur de conversion nécessite que les données aient un alignement compatible avec uint32_t. Heuresement pour nous l'objet allocateur de la classe std::vector s'assure que les données satisfont le pire cas d'alignement.

VkShaderModuleCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
createInfo.codeSize = code.size();
createInfo.pCode = reinterpret_cast<const uint32_t*>(code.data());

Le VkShaderModule peut alors être créé en appelant la fonction vkCreateShaderModule :

VkShaderModule shaderModule;
if (vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule) != VK_SUCCESS) {
    throw std::runtime_error("échec de la création d'un module shader!");
}

Les paramètres sont les mêmes que pour la création des objets précédents : le logical device, le pointeur sur la structure avec les informations, le pointeur vers l'allocateur optionnnel et la référence à l'objet créé. Le buffer contenant le code peut être libéré immédiatement après l'appel. Retournez enfin le shader module créé :

return shaderModule;

Les modules shaders ne sont au fond qu'une fine couche autour du byte code chargé depuis les fichiers. Au moment de la création de la pipeline, les codes des shaders sont compilés et mis sur la carte. Nous pouvons donc détruire les modules dès que la pipeline est crée. Nous en ferons donc des variables locales à la fonction createGraphicsPipeline :

void createGraphicsPipeline() {
    auto vertShaderModule = createShaderModule(vertShaderCode);
    fragShaderModule = createShaderModule(fragShaderCode);

    vertShaderModule = createShaderModule(vertShaderCode);
    fragShaderModule = createShaderModule(fragShaderCode);

Ils doivent être libérés une fois que la pipeline est créée, juste avant que createGraphicsPipeline ne retourne. Ajoutez ceci à la fin de la fonction :

    ...
    vkDestroyShaderModule(device, fragShaderModule, nullptr);
    vkDestroyShaderModule(device, vertShaderModule, nullptr);
}

Le reste du code de ce chapitre sera ajouté entre les deux parties de la fonction présentés ci-dessus.

Création des étapes shader

Nous devons assigner une étape shader aux modules que nous avons crées. Nous allons utiliser une structure du type VkPipelineShaderStageCreateInfo pour cela.

Nous allons d'abord remplir cette structure pour le vertex shader, une fois de plus dans la fonction createGraphicsPipeline.

VkPipelineShaderStageCreateInfo vertShaderStageInfo{};
vertShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
vertShaderStageInfo.stage = VK_SHADER_STAGE_VERTEX_BIT;

La première étape, sans compter le membre sType, consiste à dire à Vulkan à quelle étape le shader sera utilisé. Il existe une valeur d'énumération pour chacune des étapes possibles décrites dans le chapitre précédent.

vertShaderStageInfo.module = vertShaderModule;
vertShaderStageInfo.pName = "main";

Les deux membres suivants indiquent le module contenant le code et la fonction à invoquer en entrypoint. Il est donc possible de combiner plusieurs fragment shaders dans un seul module et de les différencier à l'aide de leurs points d'entrée. Nous nous contenterons du main standard.

Il existe un autre membre, celui-ci optionnel, appelé pSpecializationInfo, que nous n'utiliserons pas mais qu'il est intéressant d'évoquer. Il vous permet de donner des valeurs à des constantes présentes dans le code du shader. Vous pouvez ainsi configurer le comportement d'un shader lors de la création de la pipeline, ce qui est plus efficace que de le faire pendant l'affichage, car alors le compilateur (qui n'a toujours pas été invoqué!) peut éliminer des pants entiers de code sous un if vérifiant la valeur d'une constante ainsi configurée. Si vous n'avez aucune constante mettez ce paramètre à nullptr.

Modifier la structure pour qu'elle corresponde au fragment shader est très simple :

VkPipelineShaderStageCreateInfo fragShaderStageInfo{};
fragShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
fragShaderStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;
fragShaderStageInfo.module = fragShaderModule;
fragShaderStageInfo.pName = "main";

Intégrez ces deux valeurs dans un tableau que nous utiliserons plus tard et vous aurez fini ce chapitre!

VkPipelineShaderStageCreateInfo shaderStages[] = {vertShaderStageInfo, fragShaderStageInfo};

C'est tout ce que nous dirons sur les étapes programmables de la pipeline. Dans le prochain chapitre nous verrons les étapes à fonction fixée.

Code C++ / Vertex shader / Fragment shader