Plaintext
Vulkan Tutorial
Alexander Overvoorde
April 2023
Contents
Introduction 5
À propos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
E-book . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
Structure du tutoriel . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
Vue d’ensemble 9
Origine de Vulkan . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
Le nécessaire pour afficher un triangle . . . . . . . . . . . . . . . . . . 10
Étape 1 - Instance et sélection d’un physical device . . . . . . . . 10
Étape 2 – Logical device et familles de queues (queue families) . 10
Étape 3 – Surface d’affichage (window surface) et swap chain . . 11
Étape 4 - Image views et framebuffers . . . . . . . . . . . . . . . 11
Étape 5 - Render passes . . . . . . . . . . . . . . . . . . . . . . . 12
Étape 6 - Le pipeline graphique . . . . . . . . . . . . . . . . . . . 12
Étape 7 - Command pools et command buffers . . . . . . . . . . 12
Étape 8 - Boucle principale . . . . . . . . . . . . . . . . . . . . . 13
Résumé . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
Concepts de l’API . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
Conventions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
Validation layers . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
Environnement de développement 16
Windows . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
SDK Vulkan . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
GLFW . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
GLM . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
Préparer Visual Studio . . . . . . . . . . . . . . . . . . . . . . . . 19
Linux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
Paquets Vulkan . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
GLFW . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
GLM . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
Compilateur de shader . . . . . . . . . . . . . . . . . . . . . . . . 27
Préparation d’un fichier makefile . . . . . . . . . . . . . . . . . . 27
1
MacOS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
Le SDK Vulkan . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
GLFW . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
GLM . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
Préparation de Xcode . . . . . . . . . . . . . . . . . . . . . . . . 32
Dessiner un triangle 37
Mise en place . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
Code de base . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
Instance . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
Validation layers . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
Physical devices et queue families . . . . . . . . . . . . . . . . . . 55
Logical device et queues . . . . . . . . . . . . . . . . . . . . . . . 63
Présentation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66
Window surface . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66
Swap chain . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71
Image views . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
Pipeline graphique basique . . . . . . . . . . . . . . . . . . . . . . . . 85
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
Modules shaders . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
Fonctions fixées . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98
Render pass . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
Effectuer le rendu . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
Framebuffers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
Command buffers . . . . . . . . . . . . . . . . . . . . . . . . . . . 116
Rendu et présentation . . . . . . . . . . . . . . . . . . . . . . . . 122
Recréation de la swap chain . . . . . . . . . . . . . . . . . . . . . . . . 137
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137
Recréer la swap chain . . . . . . . . . . . . . . . . . . . . . . . . 137
Swap chain non-optimales ou dépassées . . . . . . . . . . . . . . 140
Explicitement gérer les redimensionnements . . . . . . . . . . . . 141
Gestion de la minimisation de la fenêtre . . . . . . . . . . . . . . 142
Vertex buffers 144
Description des entrées des sommets . . . . . . . . . . . . . . . . . . . 144
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 144
Vertex shader . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 144
Sommets . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 145
Lier les descriptions . . . . . . . . . . . . . . . . . . . . . . . . . 146
Description des attributs . . . . . . . . . . . . . . . . . . . . . . . 147
Entrée des sommets dans la pipeline . . . . . . . . . . . . . . . . 148
Création de vertex buffers . . . . . . . . . . . . . . . . . . . . . . . . . 149
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 149
Création d’un buffer . . . . . . . . . . . . . . . . . . . . . . . . . 149
Fonctionnalités nécessaires de la mémoire . . . . . . . . . . . . . 151
2
Allocation de mémoire . . . . . . . . . . . . . . . . . . . . . . . . 153
Remplissage du vertex buffer . . . . . . . . . . . . . . . . . . . . 154
Lier le vertex buffer . . . . . . . . . . . . . . . . . . . . . . . . . 155
Buffer intermédiaire . . . . . . . . . . . . . . . . . . . . . . . . . . . . 157
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 157
Queue de transfert . . . . . . . . . . . . . . . . . . . . . . . . . . 157
Abstraction de la création des buffers . . . . . . . . . . . . . . . . 158
Utiliser un buffer intermédiaire . . . . . . . . . . . . . . . . . . . 159
Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163
Index buffer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163
Création d’un index buffer . . . . . . . . . . . . . . . . . . . . . . 164
Utilisation d’un index buffer . . . . . . . . . . . . . . . . . . . . . 166
Uniform buffers 168
Descriptor layout et buffer . . . . . . . . . . . . . . . . . . . . . . . . . 168
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 168
Vertex shader . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 169
Organisation du set de descripteurs . . . . . . . . . . . . . . . . . 170
Uniform buffer . . . . . . . . . . . . . . . . . . . . . . . . . . . . 172
Mise à jour des données uniformes . . . . . . . . . . . . . . . . . 174
Descriptor pool et sets . . . . . . . . . . . . . . . . . . . . . . . . . . . 176
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 176
Pool de descripteurs . . . . . . . . . . . . . . . . . . . . . . . . . 177
Set de descripteurs . . . . . . . . . . . . . . . . . . . . . . . . . . 178
Utiliser des sets de descripteurs . . . . . . . . . . . . . . . . . . . 181
Alignement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 182
Plusieurs sets de descripteurs . . . . . . . . . . . . . . . . . . . . 185
Texture mapping 186
Images . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 186
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 186
Librairie de chargement d’image . . . . . . . . . . . . . . . . . . 187
Charger une image . . . . . . . . . . . . . . . . . . . . . . . . . . 188
Buffer intermédiaire . . . . . . . . . . . . . . . . . . . . . . . . . 190
Texture d’image . . . . . . . . . . . . . . . . . . . . . . . . . . . 190
Transitions de l’organisation . . . . . . . . . . . . . . . . . . . . . 195
Copier un buffer dans une image . . . . . . . . . . . . . . . . . . 198
Préparer la texture d’image . . . . . . . . . . . . . . . . . . . . . 199
Derniers champs de la barrière de transition . . . . . . . . . . . . 200
Nettoyage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 202
Vue sur image et sampler . . . . . . . . . . . . . . . . . . . . . . . . . 202
Vue sur une image texture . . . . . . . . . . . . . . . . . . . . . . 202
Samplers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 205
Capacité du device à supporter l’anistropie . . . . . . . . . . . . 209
Sampler d’image combiné . . . . . . . . . . . . . . . . . . . . . . . . . 210
3
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 210
Modifier les descripteurs . . . . . . . . . . . . . . . . . . . . . . . 210
Coordonnées de texture . . . . . . . . . . . . . . . . . . . . . . . 212
Shaders . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 214
Buffer de profondeur 219
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 219
Géométrie en 3D . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 219
Image de pronfondeur et views sur cette image . . . . . . . . . . . . . 222
Explicitement transitionner l’image de profondeur . . . . . . . . 226
Render pass . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 227
Framebuffer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 228
Supprimer les valeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . 229
État de profondeur et de stencil . . . . . . . . . . . . . . . . . . . . . . 230
Gestion des redimensionnements de la fenêtre . . . . . . . . . . . . . . 231
Charger des modèles 233
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 233
Une librairie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 233
Exemple de modèle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 234
Charger les vertices et les indices . . . . . . . . . . . . . . . . . . . . . 235
Déduplication des vertices . . . . . . . . . . . . . . . . . . . . . . . . . 239
Générer des mipmaps 242
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 242
Création des images . . . . . . . . . . . . . . . . . . . . . . . . . . . . 243
Génération des mipmaps . . . . . . . . . . . . . . . . . . . . . . . . . . 245
Support pour le filtrage linéaire . . . . . . . . . . . . . . . . . . . . . . 249
Sampler . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 250
Multisampling 254
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 254
Récupération du nombre maximal de samples . . . . . . . . . . . . . . 256
Mettre en place une cible de rendu . . . . . . . . . . . . . . . . . . . . 257
Ajouter de nouveaux attachements . . . . . . . . . . . . . . . . . . . . 259
Amélioration de la qualité . . . . . . . . . . . . . . . . . . . . . . . . . 262
Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 263
FAQ 265
4
Introduction
NOTICE: The English version of the tutorial has recently changed
significantly (for the better) and these changes have not yet been
applied to the French translation.
À propos
Ce tutoriel vous enseignera les bases de l’utilisation de l’API Vulkan qui ex-
pose les graphismes et le calcul sur cartes graphiques. Vulkan est une nouvelle
API créée par le groupe Khronos (connu pour OpenGL). Elle fournit une bien
meilleure abstraction des cartes graphiques modernes. Cette nouvelle interface
vous permet de mieux décrire ce que votre application souhaite faire, ce qui
peut mener à de meilleures performances et à des comportements moins vari-
ables comparés à des APIs existantes comme OpenGL et Direct3D. Les concepts
introduits par Vulkan sont similaires à ceux de Direct3D 12 et Metal. Cepen-
dant Vulkan a l’avantage d’être complètement cross-platform, et vous permet
ainsi de développer pour Windows, Linux, Mac et Android en même temps.
Il y a cependant un contre-coup à ces avantages. L’API vous impose d’être
explicite sur chaque détail. Vous ne pourrez rien laisser au hasard, et il n’y a
aucune structure, aucun environnement créé pour vous par défaut. Il faudra le
recréer à partir de rien. Le travail du driver graphique sera ainsi considérable-
ment réduit, ce qui implique un plus grand travail de votre part pour assurer
un comportement correct.
Le message véhiculé ici est que Vulkan n’est pas fait pour tout le monde. Cette
API est conçue pour les programmeurs concernés par la programmation avec
GPU de haute performance, et qui sont prêts à y travailler sérieusement. Si
vous êtes intéressées dans le développement de jeux vidéo, et moins dans les
graphismes eux-mêmes, vous devriez plutôt continuer d’utiliser OpenGL et Di-
rectX, qui ne seront pas dépréciés en faveur de Vulkan avant un certain temps.
Une autre alternative serait d’utiliser un moteur de jeu comme Unreal Engine
ou Unity, qui pourront être capables d’utiliser Vulkan tout en exposant une API
de bien plus haut niveau.
5
Cela étant dit, présentons quelques prérequis pour ce tutoriel:
• Une carte graphique et un driver compatibles avec Vulkan (NVIDIA,
AMD, Intel)
• De l’expérience avec le C++ (familiarité avec RAII, listes d’initialisation,
et autres fonctionnalités modernes)
• Un compilateur avec un support décent des fonctionnalités du C++17
(Visual Studio 2017+, GCC 7+ ou Clang 5+)
• Un minimum d’expérience dans le domaine de la programmation
graphique
Ce tutoriel ne considérera pas comme acquis les concepts d’OpenGL et de
Direct3D, mais il requiert que vous connaissiez les bases du rendu 3D. Il
n’expliquera pas non plus les mathématiques derrière la projection de perspec-
tive, par exemple. Lisez ce livre pour une bonne introduction des concepts de
rendu 3D. D’autres ressources pour le développement d’application graphiques
sont : * Ray tracing en un week-end * Livre sur le Physical Based Rendering *
Une application de Vulkan dans les moteurs graphiques open source Quake et
de DOOM 3
Vous pouvez utiliser le C plutôt que le C++ si vous le souhaitez, mais vous
devrez utiliser une autre bibliothèque d’algèbre linéaire et vous structurerez
vous-même votre code. Nous utiliserons des possibilités du C++ (RAII, classes)
pour organiser la logique et la durée de vie des ressources. Il existe aussi une
version alternative de ce tutoriel pour les développeurs rust.
Pour faciliter la tâche des développeurs utilisant d’autres langages de program-
mation, et pour acquérir de l’expérience avec l’API de base, nous allons utiliser
l’API C originelle pour travailler avec Vulkan. Cependant, si vous utilisez le
C++, vous pourrez préférer utiliser le binding Vulkan-Hpp plus récent, qui per-
met de s’éloigner de certains détails ennuyeux et d’éviter certains types d’erreurs.
E-book
Si vous préférez lire ce tutoriel en E-book, vous pouvez en télécharger une version
EPUB ou PDF ici:
• EPUB
• PDF
Structure du tutoriel
Nous allons commencer par une approche générale du fonctionnement de Vulkan,
et verrons d’abord rapidement le travail à effectuer pour afficher un premier tri-
angle à l’écran. Le but de chaque petite étape aura ainsi plus de sens quand vous
aurez compris leur rôle dans le fonctionnement global. Ensuite, nous préparerons
l’environnement de développement, avec le SDK Vulkan, la bibliothèque GLM
6
pour les opérations d’algèbre linéaire, et GLFW pour la création d’une fenêtre.
Ce tutoriel couvrira leur mise en place sur Windows avec Visual Studio, sur
Linux Ubuntu avec GCC et sur MacOS.
Après cela, nous implémenterons tous les éléments nécessaires à un programme
Vulkan pour afficher votre premier triangle. Chaque chapitre suivra approxima-
tivement la structure suivante :
• Introduction d’un nouveau concept et de son utilité
• Utilisation de tous les appels correspondants à l’API pour leur mise en
place dans votre programme
• Placement d’une partie de ces appels dans des fonctions pour une réutili-
sation future
Bien que chaque chapitre soit écrit comme suite du précédent, il est également
possible de lire chacun d’entre eux comme un article introduisant une certaine
fonctionnalité de Vulkan. Ainsi le site peut vous être utile comme référence.
Toutes les fonctions et les types Vulkan sont liés à leur spécification, vous pouvez
donc cliquer dessus pour en apprendre plus. La spécification est par contre en
Anglais. Vulkan est une API récente, il peut donc y avoir des lacunes dans la
spécification elle-même. Vous êtes encouragés à transmettre vos retours dans ce
repo Khronos.
Comme indiqué plus haut, Vulkan est une API assez prolixe, avec de nom-
breux paramètres, pensés pour vous fournir un maximum de contrôle sur le
hardware graphique. Ainsi des opérations comme créer une texture prennent de
nombreuses étapes qui doivent être répétées chaque fois. Nous créerons notre
propre collection de fonctions d’aide tout le long du tutoriel.
Chaque chapitre se conclura avec un lien menant à la totalité du code écrit
jusqu’à ce point. Vous pourrez vous y référer si vous avez un quelconque doute
quant à la structure du code, ou si vous rencontrez un bug et que voulez com-
parer. Tous les fichiers de code ont été testés sur des cartes graphiques de
différents vendeurs pour pouvoir affirmer qu’ils fonctionnent. Chaque chapitre
possède également une section pour écrire vos commentaires en relation avec le
sujet discuté. Veuillez y indiquer votre plateforme, la version de votre driver,
votre code source, le comportement attendu et celui obtenu pour nous simplifier
la tâche de vous aider.
Ce tutoriel est destiné à être un effort de communauté. Vulkan est encore une
API très récente et les meilleures manières d’arriver à un résultat n’ont pas
encore été déterminées. Si vous avez un quelconque retour sur le tutoriel et le
site lui-même, n’hésitez alors pas à créer une issue ou une pull request sur le
repo GitHub. Vous pouvez watch le dépôt afin d’être notifié des dernières mises
à jour du site.
Après avoir accompli le rituel de l’affichage de votre premier triangle avec Vulkan,
nous étendrons le programme pour y inclure les transformations linéaires, les
textures et les modèles 3D.
7
Si vous avez déjà utilisé une API graphique auparavant, vous devez savoir qu’il
y a nombre d’étapes avant d’afficher la première géométrie sur l’écran. Il y aura
beaucoup plus de ces étapes préliminaires avec Vulkan, mais vous verrez que
chacune d’entre elle est simple à comprendre et n’est pas redondante. Gardez
aussi à l’esprit qu’une fois que vous savez afficher un triangle - certes peu in-
téressant -, afficher un modèle 3D parfaitement texturé ne nécessite pas tant de
travail supplémentaire, et que chaque étape à partir de ce point est bien mieux
récompensée visuellement.
Si vous rencontrez un problème en suivant ce tutoriel, vérifiez d’abord dans la
FAQ que votre problème et sa solution n’y sont pas déjà listés. Si vous êtes
toujours coincé après cela, demandez de l’aide dans la section des commentaires
du chapitre le plus en lien avec votre problème.
Prêt à vous lancer dans le futur des API graphiques de haute performance?
Allons-y!
8
Vue d’ensemble
Ce chapitre commencera par introduire Vulkan et les problèmes auxquels l’API
s’adresse. Nous nous intéresserons ensuite aux éléments requis pour l’affichage
d’un premier triangle. Cela vous donnera une vue d’ensemble pour mieux re-
placer les futurs chapitres dans leur contexte. Nous conclurons sur la structure
de Vulkan et la manière dont l’API est communément utilisée.
Origine de Vulkan
Comme les APIs précédentes, Vulkan est conçue comme une abstraction des
GPUs. Le problème avec la plupart de ces APIs est qu’elles furent créées à une
époque où le hardware graphique était limité à des fonctionnalités prédéfinies
tout juste configurables. Les développeurs devaient fournir les sommets dans un
format standardisé, et étaient ainsi à la merci des constructeurs pour les options
d’éclairage et les jeux d’ombre.
Au fur et à mesure que les cartes graphiques progressèrent, elles offrirent de plus
en plus de fonctionnalités programmables. Il fallait alors intégrer toutes ces nou-
velles fonctionnalités aux APIs existantes. Ceci résulta en une abstraction peu
pratique et le driver devait deviner l’intention du développeur pour relier le pro-
gramme aux architectures modernes. C’est pour cela que les drivers étaient mis
à jour si souvent, et que certaines augmentaient soudainement les performances.
À cause de la complexité de ces drivers, les développeurs devaient gérer les dif-
férences de comportement entre les fabricants, dont par exemple des tolérances
plus ou moins importantes pour les shaders. Un exemple de fonctionnalité est le
tiled rendering, pour laquelle une plus grande flexibilité mènerait à de meilleures
performance. Ces APIs anciennes souffrent également d’une autre limitation :
le support limité du multithreading, menant à des goulot d’étranglement du coté
du CPU. Au-delà des nouveautés techniques, la dernière décennie a aussi été té-
moin de l’arrivée de matériel mobile. Ces GPUs portables ont des architectures
différentes qui prennent en compte des contraintes spatiales ou énergétiques.
Vulkan résout ces problèmes en ayant été repensée à partir de rien pour des
architectures modernes. Elle réduit le travail du driver en permettant (en fait
en demandant) au développeur d’expliciter ses objectifs en passant par une API
9
plus prolixe. Elle permet à plusieurs threads d’invoquer des commandes de
manière asynchrone. Elle supprime les différences lors de la compilation des
shaders en imposant un format en bytecode compilé par un compilateur officiel.
Enfin, elle reconnaît les capacités des cartes graphiques modernes en unifiant le
computing et les graphismes dans une seule et unique API.
Le nécessaire pour afficher un triangle
Nous allons maintenant nous intéresser aux étapes nécessaires à l’affichage d’un
triangle dans un programme Vulkan correctement conçu. Tous les concepts ici
évoqués seront développés dans les prochains chapitres. Le but ici est simple-
ment de vous donner une vue d’ensemble afin d’y replacer tous les éléments.
Étape 1 - Instance et sélection d’un physical device
Une application commence par paramétrer l’API à l’aide d’une «VkInstance».
Une instance est créée en décrivant votre application et les extensions que vous
comptez utiliser. Après avoir créé votre VkInstance, vous pouvez demander
l’accès à du hardware compatible avec Vulkan, et ainsi sélectionner un ou
plusieurs «VkPhysicalDevice» pour y réaliser vos opérations. Vous pouvez
traiter des informations telles que la taille de la VRAM ou des capacités de la
carte graphique, et ainsi préférer par exemple du matériel dédié.
Étape 2 – Logical device et familles de queues (queue fam-
ilies)
Après avoir sélectionné le hardware qui vous convient, vous devez créer un
VkDevice (logical device). Vous décrivez pour cela quelles VkPhysicalDeviceFeatures
vous utiliserez, comme l’affichage multi-fenêtre ou des floats de 64 bits. Vous
devrez également spécifier quelles vkQueueFamilies vous utiliserez. La plupart
des opérations, comme les commandes d’affichage et les allocations de mémoire,
sont exécutés de manière asynchrone en les envoyant à une VkQueue. Ces
queues sont crées à partir d’une famille de queues, chacune de ces dernières
supportant uniquement une certaine collection d’opérations. Il pourrait par
exemple y avoir des familles différentes pour les graphismes, le calcul et les
opérations mémoire. L’existence d’une famille peut aussi être un critère pour
la sélection d’un physical device. En effet une queue capable de traiter les
commandes graphiques et opérations mémoire permet d’augmenter encore un
peu les performances. Il sera possible qu’un périphérique supportant Vulkan
ne fournisse aucun graphisme, mais à ce jour toutes les opérations que nous
allons utiliser devraient être disponibles.
10
Étape 3 – Surface d’affichage (window surface) et swap
chain
À moins que vous ne soyez intéressé que par le rendu off-screen, vous devrez
créer une fenêtre dans laquelle afficher les éléments. Les fenêtres peuvent être
crées avec les APIs spécifiques aux différentes plateformes ou avec des librairies
telles que GLFW et SDL. Nous utiliserons GLFW dans ce tutoriel, mais nous
verrons tout cela dans le prochain chapitre.
Nous avons cependant encore deux composants à évoquer pour afficher quelque
chose : une Surface (VkSurfaceKHR) et une Swap Chain (VkSwapchainKHR). Re-
marquez le suffixe «KHR», qui indique que ces fonctionnalités font partie d’une
extension. L’API est elle-même totalement agnostique de la plateforme sur
laquelle elle travaille, nous devons donc utiliser l’extension standard WSI (Win-
dow System Interface) pour interagir avec le gestionnaire de fenêtre. La Surface
est une abstraction cross-platform de la fenêtre, et est généralement créée en
fournissant une référence à une fenêtre spécifique à la plateforme, par exemple
«HWND» sur Windows. Heureusement pour nous, la librairie GLFW possède
une fonction permettant de gérer tous les détails spécifiques à la plateforme
pour nous.
La swap chain est une collection de cibles sur lesquelles nous pouvons effectuer
un rendu. Son but principal est d’assurer que l’image sur laquelle nous travail-
lons n’est pas celle utilisée par l’écran. Nous sommes ainsi sûrs que l’image
affichée est complète. Chaque fois que nous voudrons afficher une image nous
devrons demander à la swap chain de nous fournir une cible disponible. Une fois
le traitement de la cible terminé, nous la rendrons à la swap chain qui l’utilisera
en temps voulu pour l’affichage à l’écran. Le nombre de cibles et les conditions
de leur affichage dépend du mode utilisé lors du paramétrage de la Swap Chain.
Ceux-ci peuvent être le double buffering (synchronisation verticale) ou le triple
buffering. Nous détaillerons tout cela dans le chapitre dédié à la Swap Chain.
Certaines plateformes permettent d’effectuer un rendu directement à l’écran
sans passer par un gestionnaire de fenêtre, et ce en vous donnant la possibilité
de créer une surface qui fait la taille de l’écran. Vous pouvez alors par exemple
créer votre propre gestionnaire de fenêtre.
Étape 4 - Image views et framebuffers
Pour dessiner sur une image originaire de la swap chain, nous devons l’encapsuler
dans une VkImageView et un VkFramebuffer. Une vue sur une image cor-
respond à une certaine partie de l’image utilisée, et un framebuffer référence
plusieurs vues pour les traiter comme des cible de couleur, de profondeur ou de
stencil. Dans la mesure où il peut y avoir de nombreuses images dans la swap
chain, nous créerons en amont les vues et les framebuffers pour chacune d’entre
elles, puis sélectionnerons celle qui nous convient au moment de l’affichage.
11
Étape 5 - Render passes
Avec Vulkan, une render pass décrit les types d’images utilisées lors du rendu,
comment elles sont utilisées et comment leur contenu doit être traité. Pour
notre affichage d’un triangle, nous dirons à Vulkan que nous utilisons une seule
image pour la couleur et que nous voulons qu’elle soit préparée avant l’affichage
en la remplissant d’une couleur opaque. Là où la passe décrit le type d’images
utilisées, un framebuffer sert à lier les emplacements utilisés par la passe à une
image complète.
Étape 6 - Le pipeline graphique
Le pipeline graphique est configuré lors de la création d’un VkPipeline. Il décrit
les éléments paramétrables de la carte graphique, comme les opérations réalisées
par le depth buffer (gestion de la profondeur), et les étapes programmables à
l’aide de VkShaderModules. Ces derniers sont créés à partir de byte code. Le
driver doit également être informé des cibles du rendu utilisées dans le pipeline,
ce que nous lui disons en référençant la render pass.
L’une des particularités les plus importantes de Vulkan est que la quasi total-
ité de la configuration des étapes doit être réalisée à l’avance. Cela implique
que si vous voulez changer un shader ou la conformation des sommets, la total-
ité du pipeline doit être recréée. Vous aurez donc probablement de nombreux
VkPipeline correspondant à toutes les combinaisons dont votre programme
aura besoin. Seules quelques configurations basiques peuvent être changées de
manière dynamique, comme la couleur de fond. Les états doivent aussi être
anticipés : il n’y a par exemple pas de fonction de blending par défaut.
La bonne nouvelle est que grâce à cette anticipation, ce qui équivaut à peu près à
une compilation versus une interprétation, il y a beaucoup plus d’optimisations
possibles pour le driver et le temps d’exécution est plus prévisible, car les grandes
étapes telles le changement de pipeline sont faites très explicites.
Étape 7 - Command pools et command buffers
Comme dit plus haut, nombre d’opérations comme le rendu doivent être trans-
mise à une queue. Ces opérations doivent d’abord être enregistrées dans un
VkCommandBuffer avant d’être envoyées. Ces command buffers sont alloués à
partir d’une «VkCommandPool» spécifique à une queue family. Pour afficher
notre simple triangle nous devrons enregistrer les opérations suivantes :
• Lancer la render pass
• Lier le pipeline graphique
• Afficher 3 sommets
• Terminer la passe
Du fait que l’image que nous avons extraite du framebuffer pour nous en servir
comme cible dépend de l’image que la swap chain nous fournira, nous devons
12
préparer un command buffer pour chaque image possible et choisir le bon au
moment de l’affichage. Nous pourrions en créer un à chaque frame mais ce ne
serait pas aussi efficace.
Étape 8 - Boucle principale
Maintenant que nous avons inscrit les commandes graphiques dans des command
buffers, la boucle principale n’est plus qu’une question d’appels. Nous acquérons
d’abord une image de la swap chain en utilisant vkAcquireNextImageKHR. Nous
sélectionnons ensuite le command buffer approprié pour cette image et le postons
à la queue avec vkQueueSubmit. Enfin, nous retournons l’image à la swap chain
pour sa présentation à l’écran à l’aide de vkQueuePresentKHR.
Les opérations envoyées à la queue sont exécutées de manière asynchrone. Nous
devons donc utiliser des objets de synchronisation tels que des sémaphores pour
nous assurer que les opérations sont exécutées dans l’ordre voulu. L’exécution du
command buffer d’affichage doit de plus attendre que l’acquisition de l’image soit
terminée, sinon nous pourrions dessiner sur une image utilisée pour l’affichage.
L’appel à vkQueuePresentKHR doit aussi attendre que l’affichage soit terminé.
Résumé
Ce tour devrait vous donner une compréhension basique du travail que nous
aurons à fournir pour afficher notre premier triangle. Un véritable programme
contient plus d’étapes comme allouer des vertex Buffers, créer des Uniform
Buffers et envoyer des textures, mais nous verrons cela dans des chapitres suiv-
ants. Nous allons commencer par les bases car Vulkan a suffisamment d’étapes
ainsi. Notez que nous allons “tricher” en écrivant les coordonnées du triangle
directement dans un shader, afin d’éviter l’utilisation d’un vertex buffer qui
nécessite une certaine familiarité avec les Command Buffers.
En résumé nous devrons, pour afficher un triangle :
• Créer une VkInstance
• Sélectionner une carte graphique compatible (VkPhysicalDevice)
• Créer un VkDevice et une VkQueue pour l’affichage et la présentation
• Créer une fenêtre, une surface dans cette fenêtre et une swap chain
• Considérer les images de la swap chain comme des VkImageViews puis des
VkFramebuffers
• Créer la render pass spécifiant les cibles d’affichage et leurs usages
• Créer des framebuffers pour ces passes
• Générer le pipeline graphique
• Allouer et enregistrer des Command Buffers contenant toutes les comman-
des pour toutes les images de la swap chain
• Dessiner sur les frames en acquérant une image, en soumettant la com-
mande d’affichage correspondante et en retournant l’image à la swap chain
Cela fait beaucoup d’étapes, cependant le but de chacune d’entre elles sera
13
explicitée clairement et simplement dans les chapitres suivants. Si vous êtes
confus quant à l’intérêt d’une étape dans le programme entier, référez-vous à ce
premier chapitre.
Concepts de l’API
Ce chapitre va conclure en survolant la structure de l’API à un plus bas niveau.
Conventions
Toute les fonctions, les énumérations et les structures de Vulkan sont définies
dans le header vulkan.h, inclus dans le SDK Vulkan développé par LunarG.
Nous verrons comment l’installer dans le prochain chapitre.
Les fonctions sont préfixées par ‘vk’, les types comme les énumération et les
structures par ‘Vk’ et les macros par ‘VK_’. L’API utilise massivement les
structures pour la création d’objet plutôt que de passer des arguments à des
fonctions. Par exemple la création d’objet suit généralement le schéma suivant
:
1 VkXXXCreateInfo createInfo{};
2 createInfo.sType = VK_STRUCTURE_TYPE_XXX_CREATE_INFO;
3 createInfo.pNext = nullptr;
4 createInfo.foo = ...;
5 createInfo.bar = ...;
6
7 VkXXX object;
8 if (vkCreateXXX(&createInfo, nullptr, &object) != VK_SUCCESS) {
9 std::cerr << "failed to create object" << std::endl;
10 return false;
11 }
De nombreuses structure imposent que l’on spécifie explicitement leur type dans
le membre donnée «sType». Le membre donnée «pNext» peut pointer vers une
extension et sera toujours nullptr dans ce tutoriel. Les fonctions qui créent ou
détruisent les objets ont un paramètre appelé VkAllocationCallbacks, qui vous
permettent de spécifier un allocateur. Nous le mettrons également à nullptr.
La plupart des fonctions retournent un VkResult, qui peut être soit VK_SUCCESS
soit un code d’erreur. La spécification décrit lesquels chaque fonction renvoie et
ce qu’ils signifient.
Validation layers
Vulkan est pensé pour la performance et pour un travail minimal pour le driver.
Il inclue donc très peu de gestion d’erreur et de système de débogage. Le driver
crashera beaucoup plus souvent qu’il ne retournera de code d’erreur si vous faites
14
quelque chose d’inattendu. Pire, il peut fonctionner sur votre carte graphique
mais pas sur une autre.
Cependant, Vulkan vous permet d’effectuer des vérifications précises de chaque
élément à l’aide d’une fonctionnalité nommée «validation layers». Ces layers
consistent en du code s’insérant entre l’API et le driver, et permettent de lancer
des analyses de mémoire et de relever les défauts. Vous pouvez les activer
pendant le développement et les désactiver sans conséquence sur la performance.
N’importe qui peut écrire ses validation layers, mais celui du SDK de LunarG est
largement suffisant pour ce tutoriel. Vous aurez cependant à écrire vos propres
fonctions de callback pour le traitement des erreurs émises par les layers.
Du fait que Vulkan soit si explicite pour chaque opération et grâce à l’extensivité
des validations layers, trouver les causes de l’écran noir peut en fait être plus
simple qu’avec OpenGL ou Direct3D!
Il reste une dernière étape avant de commencer à coder : mettre en place
l’environnement de développement.
15
Environnement de
développement
Dans ce chapitre nous allons paramétrer votre environnement de développement
pour Vulkan et installer des librairies utiles. Tous les outils que nous allons
utiliser, excepté le compilateur, seront compatibles Windows, Linux et MacOS.
Cependant les étapes pour les installer diffèrent un peu, d’où les sections suiv-
antes.
Windows
Si vous développez pour Windows, je partirai du principe que vous utilisez
Visual Studio pour ce projet. Pour un support complet de C++17, il vous faut
Visual Studio 2017 or 2019. Les étapes décrites ci-dessous ont été écrites pour
VS 2017.
SDK Vulkan
Le composant central du développement d’applications Vulkan est le SDK. Il
inclut les headers, les validation layers standards, des outils de débogage et un
loader pour les fonctions Vulkan. Ce loader récupère les fonctions dans le driver
à l’exécution, comme GLEW pour OpenGL - si cela vous parle.
Le SDK peut être téléchargé sur le site de LunarG en utilisant les boutons en
bas de page. Vous n’avez pas besoin de compte, mais celui-ci vous donne accès
à une documentation supplémentaire qui pourra vous être utile.
16
Réalisez l’installation et notez l’emplacement du SDK. La première chose
que nous allons faire est vérifier que votre carte graphique supporte Vulkan.
Allez dans le dossier d’installation du SDK, ouvrez le dossier “Bin” et lancez
“vkcube.exe”. Vous devriez voire la fenêtre suivante :
Si vous recevez un message d’erreur assurez-vous que votre driver est à jour, in-
clut Vulkan et que votre carte graphique est supportée. Référez-vous au chapitre
introductif pour les liens vers les principaux constructeurs.
Il y a d’autres programmes dans ce dossier qui vous seront utiles : “glslang-
Validator.exe” et “glslc.exe”. Nous en aurons besoin pour la compilation des
shaders. Ils transforment un code compréhensible facilement et semblable au
C (le GLSL) en bytecode. Nous couvrirons cela dans le chapitre des modules
shader. Le dossier “Bin” contient aussi les fichiers binaires du loader Vulkan et
des validation layers. Le dossier “Lib” en contient les librairies.
17
Enfin, le dossier “Include” contient les headers Vulkan. Vous pouvez parourir
les autres fichiers, mais nous ne les utiliserons pas dans ce tutoriel.
GLFW
Comme dit précédemment, Vulkan ignore la plateforme sur laquelle il opère,
et n’inclut pas d’outil de création de fenêtre où afficher les résultats de notre
travail. Pour bien exploiter les possibilités cross-platform de Vulkan et éviter les
horreurs de Win32, nous utiliserons la librairie GLFW pour créer une fenêtre
et ce sur Windows, Linux ou MacOS. Il existe d’autres librairies telles que SDL,
mais GLFW a l’avantage d’abstraire d’autres aspects spécifiques à la plateforme
requis par Vulkan.
Vous pouvez trouver la dernière version de GLFW sur leur site officiel. Nous
utiliserons la version 64 bits, mais vous pouvez également utiliser la version 32
bits. Dans ce cas assurez-vous de bien lier le dossier “Lib32” dans le SDK et
non “Lib”. Après avoir téléchargé GLFW, extrayez l’archive à l’emplacement
qui vous convient. J’ai choisi de créer un dossier “Librairies” dans le dossier de
Visual Studio.
GLM
Contrairement à DirectX 12, Vulkan n’intègre pas de librairie pour l’algèbre
linéaire. Nous devons donc en télécharger une. GLM est une bonne librairie
conçue pour être utilisée avec les APIs graphiques, et est souvent utilisée avec
OpenGL.
GLM est une librairie écrite exclusivement dans les headers, il suffit donc d’en
télécharger la dernière version, la stocker où vous le souhaitez et l’inclure là où
vous en aurez besoin. Vous devrez vous trouver avec quelque chose de semblable
:
18
Préparer Visual Studio
Maintenant que vous avez installé toutes les dépendances, nous pouvons pré-
parer un projet Visual Studio pour Vulkan, et écrire un peu de code pour vérifier
que tout fonctionne.
Lancez Visual Studio et créez un nouveau projet “Windows Desktop Wizard”,
entrez un nom et appuyez sur OK.
19
Assurez-vous que “Console Application (.exe)” est séléctionné pour le type
d’application afin que nous ayons un endroit où afficher nos messages d’erreur,
et cochez “Empty Project” afin que Visual Studio ne génère pas un code de
base.
20
Appuyez sur OK pour créer le projet et ajoutez un fichier source C++. Vous
devriez déjà savoir faire ça, mais les étapes sont tout de même incluses ici.
Ajoutez maintenant le code suivant à votre fichier. Ne cherchez pas à en com-
prendre les tenants et aboutissants, il sert juste à s’assurer que tout compile
correctement et qu’une application Vulkan fonctionne. Nous recommencerons
tout depuis le début dès le chapitre suivant.
1 #define GLFW_INCLUDE_VULKAN
2 #include <GLFW/glfw3.h>
3
4 #define GLM_FORCE_RADIANS
5 #define GLM_FORCE_DEPTH_ZERO_TO_ONE
6 #include <glm/vec4.hpp>
7 #include <glm/mat4x4.hpp>
8
9 #include <iostream>
21
10
11 int main() {
12 glfwInit();
13
14 glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
15 GLFWwindow* window = glfwCreateWindow(800, 600, "Vulkan window",
nullptr, nullptr);
16
17 uint32_t extensionCount = 0;
18 vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount,
nullptr);
19
20 std::cout << extensionCount << " extensions supported\n";
21
22 glm::mat4 matrix;
23 glm::vec4 vec;
24 auto test = matrix * vec;
25
26 while(!glfwWindowShouldClose(window)) {
27 glfwPollEvents();
28 }
29
30 glfwDestroyWindow(window);
31
32 glfwTerminate();
33
34 return 0;
35 }
Configurons maintenant le projet afin de se débarrasser des erreurs. Ouvrez le
dialogue des propriétés du projet et assurez-vous que “All Configurations” est
sélectionné, car la plupart des paramètres s’appliquent autant à “Debug” qu’à
“Release”.
22
Allez à “C++” -> “General” -> “Additional Include Directories” et appuyez
sur “<Edit…>” dans le menu déroulant.
Ajoutez les dossiers pour les headers Vulkan, GLFW et GLM :
23
Ensuite, ouvrez l’éditeur pour les dossiers des librairies sous “Linker” -> “Gen-
eral” :
Et ajoutez les emplacements des fichiers objets pour Vulkan et GLFW :
Allez à “Linker” -> “Input” et appuyez sur “<Edit…>” dans le menu déroulant
“Additional Dependencies” :
Entrez les noms des fichiers objets GLFW et Vulkan :
24
Vous pouvez enfin fermer le dialogue des propriétés. Si vous avez tout fait
correctement vous ne devriez plus voir d’erreur dans votre code.
Assurez-vous finalement que vous compilez effectivement en 64 bits :
Appuyez sur F5 pour compiler et lancer le projet. Vous devriez voir une fenêtre
s’afficher comme cela :
Si le nombre d’extensions est nul, il y a un problème avec la configuration de
Vulkan sur votre système. Sinon, vous êtes fin prêts à vous lancer avec Vulkan!
Linux
Ces instructions sont conçues pour les utilisateurs d’Ubuntu et Fedora, mais
vous devriez pouvoir suivre ces instructions depuis une autre distribution si vous
adaptez les commandes “apt” ou “dnf” à votre propre gestionnaire de packages.
Il vous faut un compilateur qui supporte C++17 (GCC 7+ ou Clang 5+). Vous
aurez également besoin de make.
Paquets Vulkan
Les composants les plus importants pour le développement d’applications
Vulkan sous Linux sont le loader Vulkan, les validation layers et quelques
utilitaires pour tester que votre machine est bien en état de faire fonctionner
une application Vulkan: * sudo apt install vulkan-tools ou sudo dnf
install vulkan-tools: Les utilitaires en ligne de commande, plus précisément
vulkaninfo et vkcube. Lancez ceux-ci pour vérifier le bon fonctionnement de
votre machine pour Vulkan. * sudo apt install libvulkan-dev ou sudo
dnf install vulkan-headers vulkan-loader-devel: Installe le loader
Vulkan. Il sert à aller chercher les fonctions auprès du driver de votre GPU
au runtime, de la même façon que GLEW le fait pour OpenGL - si vous êtes
familier avec ceci. * sudo apt install vulkan-validationlayers-dev ou
sudo dnf install mesa-vulkan-devel vulkan-validation-layers-devel:
25
Installe les layers de validation standards. Ceux-ci sont cruciaux pour débugger
vos applications Vulkan, et nous en reparlerons dans un prochain chapitre.
Si l’installation est un succès, vous devriez être prêt pour la partie Vulkan.
N’oubliez pas de lancer vkcube et assurez-vous de voir la fenêtre suivante:
GLFW
Comme dit précédemment, Vulkan ignore la plateforme sur laquelle il opère, et
n’inclut pas d’outil de création de fenêtre où afficher les résultats de notre travail.
Pour bien exploiter les possibilités cross-platform de Vulkan, nous utiliserons la
librairie GLFW pour créer une fenêtre sur Windows, Linux ou MacOS indif-
féremment. Il existe d’autres librairies telles que SDL, mais GLFW à l’avantage
d’abstraire d’autres aspects spécifiques à la plateforme requis par Vulkan.
Nous allons installer GLFW à l’aide de la commande suivante:
1 sudo apt install libglfw3-dev
26
ou
1 sudo dnf install glfw-devel
GLM
Contrairement à DirectX 12, Vulkan n’intègre pas de librairie pour l’algèbre
linéaire. Nous devons donc en télécharger une. GLM est une bonne librairie
conçue pour être utilisée avec les APIs graphiques, et est souvent utilisée avec
OpenGL.
Cette librairie contenue intégralement dans les headers peut être installée depuis
le package “libglm-dev” ou “glm-devel” :
1 sudo apt install libglm-dev
ou
1 sudo dnf install glm-devel
Compilateur de shader
Nous avons tout ce qu’il nous faut, excepté un programme qui compile le code
GLSL lisible par un humain en bytecode.
Deux compilateurs de shader populaires sont glslangValidator de Khronos
et glslc de Google. Ce dernier a l’avantage d’être proche de GCC et Clang à
l’usage,. Pour cette raison, nous l’utiliserons: Ubuntu, téléchargez les exécuta-
bles non officiels et copiez glslc dans votre répertoire /usr/local/bin. Notez
que vous aurez certainement besoin d’utiliser sudo en fonctions de vos permis-
sions. Fedora, utilise sudo dnf install glslc. Pour tester, lancez glslc
depuis le répertoire de votre choix et il devrait se plaindre qu’il n’a reçu aucun
shader à compiler de votre part:
glslc: error: no input files
Nous couvrirons l’usage de glslc plus en détails dans le chapitre des modules
shaders
Préparation d’un fichier makefile
Maintenant que vous avez installé toutes les dépendances, nous pouvons pré-
parer un makefile basique pour Vulkan et écrire un code très simple pour
s’assurer que tout fonctionne correctement.
Ajoutez maintenant le code suivant à votre fichier. Ne cherchez pas à en com-
prendre les tenants et aboutissants, il sert juste à s’assurer que tout compile
correctement et qu’une application Vulkan fonctionne. Nous recommencerons
tout depuis le début dès le chapitre suivant.
27
1 #define GLFW_INCLUDE_VULKAN
2 #include <GLFW/glfw3.h>
3
4 #define GLM_FORCE_RADIANS
5 #define GLM_FORCE_DEPTH_ZERO_TO_ONE
6 #include <glm/vec4.hpp>
7 #include <glm/mat4x4.hpp>
8
9 #include <iostream>
10
11 int main() {
12 glfwInit();
13
14 glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
15 GLFWwindow* window = glfwCreateWindow(800, 600, "Vulkan window",
nullptr, nullptr);
16
17 uint32_t extensionCount = 0;
18 vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount,
nullptr);
19
20 std::cout << extensionCount << " extensions supported\n";
21
22 glm::mat4 matrix;
23 glm::vec4 vec;
24 auto test = matrix * vec;
25
26 while(!glfwWindowShouldClose(window)) {
27 glfwPollEvents();
28 }
29
30 glfwDestroyWindow(window);
31
32 glfwTerminate();
33
34 return 0;
35 }
Nous allons maintenant créer un makefile pour compiler et lancer ce code. Créez
un fichier “Makefile”. Je pars du principe que vous connaissez déjà les bases
de makefile, dont les variables et les règles. Sinon vous pouvez trouver des
introductions claires sur internet, par exemple ici.
Nous allons d’abord définir quelques variables pour simplifier le reste du fichier.
Définissez CFLAGS, qui spécifiera les arguments pour la compilation :
1 CFLAGS = -std=c++17 -O2
28
Nous utiliserons du C++ moderne (-std=c++17) et compilerons avec le
paramètre d’optimisation -O2. Vous pouvez le retirer pour compiler nos
programmes plus rapidement, mais n’oubliez pas de le remettre pour compiler
des exécutables prêts à être distribués.
Définissez de manière analogue LDFLAGS :
1 LDFLAGS = -lglfw -lvulkan -ldl -lpthread -lX11 -lXxf86vm -lXrandr
-lXi
Le premier flag correspond à GLFW, -lvulkan correspond au loader dynamique
des fonctions Vulkan. Le reste des options correspondent à des bibliothèques
systèmes liés à la gestion des fenêtres et aux threads nécessaire pour le bon
fonctionnement de GLFW.
Spécifier les commandes pour la compilation de “VulkanTest” est désormais un
jeu d’enfant. Assurez-vous que vous utilisez des tabulations et non des espaces
pour l’indentation.
1 VulkanTest: main.cpp
2 g++ $(CFLAGS) -o VulkanTest main.cpp $(LDFLAGS)
Vérifiez que le fichier fonctionne en le sauvegardant et en exécutant make depuis
un terminal ouvert dans le dossier le contenant. Vous devriez avoir un exécutable
appelé “VulkanTest”.
Nous allons ensuite définir deux règles, test et clean. La première exécutera
le programme et le second supprimera l’exécutable :
1 .PHONY: test clean
2
3 test: VulkanTest
4 ./VulkanTest
5
6 clean:
7 rm -f VulkanTest
Lancer make test doit vous afficher le programme sans erreur, listant le nombre
d’extensions disponible pour Vulkan. L’application devrait retourner le code de
retour 0 (succès) quand vous fermez la fenêtre vide. Vous devriez désormais
avoir un makefile ressemblant à ceci :
1 CFLAGS = -std=c++17 -O2
2 LDFLAGS = -lglfw -lvulkan -ldl -lpthread -lX11 -lXxf86vm -lXrandr
-lXi
3
4 VulkanTest: main.cpp
5 g++ $(CFLAGS) -o VulkanTest main.cpp $(LDFLAGS)
6
29
7 .PHONY: test clean
8
9 test: VulkanTest
10 ./VulkanTest
11
12 clean:
13 rm -f VulkanTest
Vous pouvez désormais utiliser ce dossier comme exemple pour vos futurs projets
Vulkan. Faites-en une copie, changez le nom du projet pour quelque chose
comme HelloTriangle et retirez tout le code contenu dans main.cpp.
Bravo, vous êtes fin prêts à vous lancer avec Vulkan!
MacOS
Ces instructions partent du principe que vous utilisez Xcode et le gestionnaire
de packages Homebrew. Vous aurez besoin de MacOS 10.11 minimum, et votre
ordinateur doit supporter l’API Metal.
Le SDK Vulkan
Le SDK est le composant le plus important pour programmer une application
avec Vulkan. Il inclue headers, validations layers, outils de débogage et un loader
dynamique pour les fonctions Vulkan. Le loader cherche les fonctions dans le
driver pendant l’exécution, comme GLEW pour OpenGL, si cela vous parle.
Le SDK se télécharge sur le site de LunarG en utilisant les boutons en bas de
page. Vous n’avez pas besoin de créer de compte, mais il permet d’accéder à
une documentation supplémentaire qui pourra vous être utile.
La version MacOS du SDK utilise MoltenVK. Il n’y a pas de support natif pour
Vulkan sur MacOS, donc nous avons besoin de MoltenVK pour transcrire les
appels à l’API Vulkan en appels au framework Metal d’Apple. Vous pouvez
ainsi exploiter pleinement les possibilités de cet API automatiquement.
Une fois téléchargé, extrayez-en le contenu où vous le souhaitez. Dans le dossier
extrait, il devrait y avoir un sous-dossier “Applications” comportant des exé-
30
cutables lançant des démos du SDK. Lancez “vkcube” pour vérifier que vous
obtenez ceci :
GLFW
Comme dit précédemment, Vulkan ignore la plateforme sur laquelle il opère, et
n’inclut pas d’outil de création de fenêtre où afficher les résultats de notre travail.
Pour bien exploiter les possibilités cross-platform de Vulkan, nous utiliserons
la librairie GLFW pour créer une fenêtre qui supportera Windows, Linux et
MacOS. Il existe d’autres librairies telles que SDL, mais GLFW à l’avantage
d’abstraire d’autres aspects spécifiques à la plateforme requis par Vulkan.
Nous utiliserons le gestionnaire de package Homebrew pour installer GLFW. Le
support Vulkan sur MacOS n’étant pas parfaitement disponible (à l’écriture du
moins) sur la version 3.2.1, nous installerons le package “glfw3” ainsi :
1 brew install glfw3 --HEAD
31
GLM
Vulkan n’inclut aucune libraire pour l’algèbre linéaire, nous devons donc en
télécharger une. GLM est une bonne librairie souvent utilisée avec les APIs
graphiques dont OpenGL.
Cette librairie est intégralement codée dans les headers et se télécharge avec le
package “glm” :
1 brew install glm
Préparation de Xcode
Maintenant que nous avons toutes les dépendances nous pouvons créer dans
Xcode un projet Vulkan basique. La plupart des opérations seront de la “tuyau-
terie” pour lier les dépendances au projet. Notez que vous devrez remplacer
toutes les mentions “vulkansdk” par le dossier où vous avez extrait le SDK
Vulkan.
Lancez Xcode et créez un nouveau projet. Sur la fenêtre qui s’ouvre sélectionnez
Application > Command Line Tool.
Sélectionnez “Next”, inscrivez un nom de projet et choisissez “C++” pour “Lan-
guage”.
32
Appuyez sur “Next” et le projet devrait être créé. Copiez le code suivant à la
place du code généré dans le fichier “main.cpp” :
1 #define GLFW_INCLUDE_VULKAN
2 #include <GLFW/glfw3.h>
3
4 #define GLM_FORCE_RADIANS
5 #define GLM_FORCE_DEPTH_ZERO_TO_ONE
6 #include <glm/vec4.hpp>
7 #include <glm/mat4x4.hpp>
8
9 #include <iostream>
10
11 int main() {
12 glfwInit();
13
14 glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
15 GLFWwindow* window = glfwCreateWindow(800, 600, "Vulkan window",
nullptr, nullptr);
16
17 uint32_t extensionCount = 0;
18 vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount,
nullptr);
19
20 std::cout << extensionCount << " extensions supported\n";
21
22 glm::mat4 matrix;
33
23 glm::vec4 vec;
24 auto test = matrix * vec;
25
26 while(!glfwWindowShouldClose(window)) {
27 glfwPollEvents();
28 }
29
30 glfwDestroyWindow(window);
31
32 glfwTerminate();
33
34 return 0;
35 }
Gardez à l’esprit que vous n’avez pas à comprendre tout ce que le code fait, dans
la mesure où il se contente d’appeler quelques fonctions de l’API pour s’assurer
que tout fonctionne. Nous verrons toutes ces fonctions en détail plus tard.
Xcode devrait déjà vous afficher des erreurs comme le fait que des librairies
soient introuvables. Nous allons maintenant les faire disparaître. Sélectionnez
votre projet sur le menu Project Navigator. Ouvrez Build Settings puis :
• Trouvez le champ Header Search Paths et ajoutez “/usr/local/include”
(c’est ici que Homebrew installe les headers) et “vulkansdk/macOS/in-
clude” pour le SDK.
• Trouvez le champ Library Search Paths et ajoutez “/usr/local/lib”
(même raison pour les librairies) et “vulkansdk/macOS/lib”.
Vous avez normalement (avec des différences évidentes selon l’endroit où vous
avez placé votre SDK) :
Maintenant, dans le menu Build Phases, ajoutez les frameworks “glfw3” et
“vulkan” dans Link Binary With Librairies. Pour nous simplifier les choses,
nous allons ajouter les librairies dynamiques directement dans le projet (référez-
vous à la documentation de ces librairies si vous voulez les lier de manière
statique).
• Pour glfw ouvrez le dossier “/usr/local/lib” où vous trouverez un fichier
avec un nom comme “libglfw.3.x.dylib” où x est le numéro de la version.
Glissez ce fichier jusqu’à la barre des “Linked Frameworks and Librairies”
dans Xcode.
• Pour Vulkan, rendez-vous dans “vulkansdk/macOS/lib” et répétez
l’opération pour “libvulkan.1.dylib” et “libvulkan.1.x.xx .dylib” avec les
34
x correspondant à la version du SDK que vous avez téléchargé.
Maintenant que vous avez ajouté ces librairies, remplissez le champ Destination
avec “Frameworks” dans Copy Files, supprimez le sous-chemin et décochez
“Copy only when installing”. Cliquez sur le “+” et ajoutez-y les trois mêmes
frameworks.
Votre configuration Xcode devrait ressembler à cela :
Il ne reste plus qu’à définir quelques variables d’environnement. Sur la barre
d’outils de Xcode allez à Product > Scheme > Edit Scheme..., et dans la liste
Arguments ajoutez les deux variables suivantes :
• VK_ICD_FILENAMES = vulkansdk/macOS/share/vulkan/icd.d/MoltenVK_icd.json
• VK_LAYER_PATH = vulkansdk/macOS/share/vulkan/explicit_layer.d
Vous avez normalement ceci :
35
Vous êtes maintenant prêts! Si vous lancez le projet (en pensant à bien choisir
Debug ou Release) vous devrez avoir ceci :
Si vous obtenez 0 extensions supported, il y a un problème avec la configura-
tion de Vulkan sur votre système. Les autres données proviennent de librairies,
et dépendent de votre configuration.
Vous êtes maintenant prêts à vous lancer avec Vulkan!.
36
Dessiner un triangle
Mise en place
Code de base
Structure générale
Dans le chapitre précédent nous avons créé un projet Vulkan avec une config-
uration solide et nous l’avons testé. Nous recommençons ici à partir du code
suivant :
1 #include <vulkan/vulkan.h>
2
3 #include <iostream>
4 #include <stdexcept>
5 #include <functional>
6 #include <cstdlib>
7
8 class HelloTriangleApplication {
9 public:
10 void run() {
11 initVulkan();
12 mainLoop();
13 cleanup();
14 }
15
16 private:
17 void initVulkan() {
18
19 }
20
21 void mainLoop() {
22
23 }
24
25 void cleanup() {
37
26
27 }
28 };
29
30 int main() {
31 HelloTriangleApplication app;
32
33 try {
34 app.run();
35 } catch (const std::exception& e) {
36 std::cerr << e.what() << std::endl;`
37 return EXIT_FAILURE;
38 }
39
40 return EXIT_SUCCESS;
41 }
Nous incluons d’abord le header Vulkan du SDK, qui fournit les fonctions, les
structures et les énumérations. <stdexcept> et <iostream> nous permettront
de reporter et de traiter les erreurs. Le header <functional> nous servira pour
l’écriture d’une lambda dans la section sur la gestion des ressources. <cstdlib>
nous fournit les macros EXIT_FAILURE et EXIT_SUCCESS (optionnelles).
Le programme est écrit à l’intérieur d’une classe, dans laquelle seront stockés les
objets Vulkan. Nous avons également une fonction pour la création de chacun
de ces objets. Une fois toute l’initialisation réalisée, nous entrons dans la boucle
principale, qui attend que nous fermions la fenêtre pour quitter le programme,
après avoir libéré grâce à la fonction cleanup toutes les ressources que nous
avons allouées .
Si nous rencontrons une quelconque erreur lors de l’exécution nous lèverons une
std::runtime_error comportant un message descriptif, qui sera affiché sur le
terminal depuis la fonction main. Afin de s’assurer que nous récupérons bien
toutes les erreurs, nous utilisons std::exception dans le catch. Nous verrons
bientôt que la requête de certaines extensions peut mener à lever des exceptions.
À peu près tous les chapitres à partir de celui-ci introduiront une nouvelle fonc-
tion appelée dans initVulkan et un nouvel objet Vulkan qui sera justement
créé par cette fonction. Il sera soit détruit dans cleanup, soit libéré automa-
tiquement.
Gestion des ressources
De la même façon qu’une quelconque ressource explicitement allouée par new
doit être explicitement libérée par delete, nous devrons explicitement détruire
quasiment toutes les ressources Vulkan que nous allouerons. Il est possible
d’exploiter des fonctionnalités du C++ pour s’acquitter automatiquement de
38
cela. Ces possibilités sont localisées dans <memory> si vous désirez les utiliser.
Cependant nous resterons explicites pour toutes les opérations dans ce tutoriel,
car la puissance de Vulkan réside en particulier dans la clareté de l’expression de
la volonté du programmeur. De plus, cela nous permettra de bien comprendre
la durée de vie de chacun des objets.
Après avoir suivi ce tutoriel vous pourrez parfaitement implémenter une ges-
tion automatique des ressources en spécialisant std::shared_ptr par exemple.
L’utilisation du RAII à votre avantage est toujours recommandé en C++ pour
de gros programmes Vulkan, mais il est quand même bon de commencer par
connaître les détails de l’implémentation.
Les objets Vulkan peuvent être créés de deux manières. Soit ils sont directement
créés avec une fonction du type vkCreateXXX, soit ils sont alloués à l’aide d’un
autre objet avec une fonction vkAllocateXXX. Après vous être assuré qu’il
n’est plus utilisé où que ce soit, il faut le détruire en utilisant les fonctions
vkDestroyXXX ou vkFreeXXX, respectivement. Les paramètres de ces fonctions
varient sauf pour l’un d’entre eux : pAllocator. Ce paramètre optionnel vous
permet de spécifier un callback sur un allocateur de mémoire. Nous n’utiliserons
jamais ce paramètre et indiquerons donc toujours nullptr.
Intégrer GLFW
Vulkan marche très bien sans fenêtre si vous voulez l’utiliser pour du rendu
sans écran (offscreen rendering en Anglais), mais c’est tout de même plus
intéressant d’afficher quelque chose! Remplacez d’abord la ligne #include
<vulkan/vulkan.h> par :
1 #define GLFW_INCLUDE_VULKAN
2 #include <GLFW/glfw3.h>
GLFW va alors automatiquement inclure ses propres définitions des fonctions
Vulkan et vous fournir le header Vulkan. Ajoutez une fonction initWindow et
appelez-la depuis run avant les autres appels. Nous utiliserons cette fonction
pour initialiser GLFW et créer une fenêtre.
1 void run() {
2 initWindow();
3 initVulkan();
4 mainLoop();
5 cleanup();
6 }
7
8 private:
9 void initWindow() {
10
11 }
39
Le premier appel dans initWindow doit être glfwInit(), ce qui initialise la
librairie. Dans la mesure où GLFW a été créée pour fonctionner avec OpenGL,
nous devons lui demander de ne pas créer de contexte OpenGL avec l’appel
suivant :
1 glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
Dans la mesure où redimensionner une fenêtre n’est pas chose aisée avec Vulkan,
nous verrons cela plus tard et l’interdisons pour l’instant.
1 glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE);
Il ne nous reste plus qu’à créer la fenêtre. Ajoutez un membre privé
GLFWWindow* m_window pour en stocker une référence, et initialisez la ainsi :
1 window = glfwCreateWindow(800, 600, "Vulkan", nullptr, nullptr);
Les trois premiers paramètres indiquent respectivement la largeur, la hauteur et
le titre de la fenêtre. Le quatrième vous permet optionnellement de spécifier un
moniteur sur lequel ouvrir la fenêtre, et le cinquième est spécifique à OpenGL.
Nous devrions plutôt utiliser des constantes pour la hauteur et la largeur dans
la mesure où nous aurons besoin de ces valeurs dans le futur. J’ai donc ajouté
ceci au-dessus de la définition de la classe HelloTriangleApplication :
1 const uint32_t WIDTH = 800;
2 const uint32_t HEIGHT = 600;
et remplacé la création de la fenêtre par :
1 window = glfwCreateWindow(WIDTH, HEIGHT, "Vulkan", nullptr, nullptr);
Vous avez maintenant une fonction initWindow ressemblant à ceci :
1 void initWindow() {
2 glfwInit();
3
4 glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
5 glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE);
6
7 window = glfwCreateWindow(WIDTH, HEIGHT, "Vulkan", nullptr,
nullptr);
8 }
Pour s’assurer que l’application tourne jusqu’à ce qu’une erreur ou un clic
sur la croix ne l’interrompe, nous devons écrire une petite boucle de gestion
d’évènements :
40
1 void mainLoop() {
2 while (!glfwWindowShouldClose(window)) {
3 glfwPollEvents();
4 }
5 }
Ce code est relativement simple. GLFW récupère tous les évènements
disponibles, puis vérifie qu’aucun d’entre eux ne correspond à une demande
de fermeture de fenêtre. Ce sera aussi ici que nous appellerons la fonction qui
affichera un triangle.
Une fois la requête pour la fermeture de la fenêtre récupérée, nous devons détru-
ire toutes les ressources allouées et quitter GLFW. Voici notre première version
de la fonction cleanup :
1 void cleanup() {
2 glfwDestroyWindow(window);
3
4 glfwTerminate();
5 }
Si vous lancez l’application, vous devriez voir une fenêtre appelée “Vulkan” qui
se ferme en cliquant sur la croix. Maintenant que nous avons une base pour
notre application Vulkan, créons notre premier objet Vulkan!!
Code C++
Instance
Création d’une instance
La première chose à faire avec Vulkan est son initialisation au travers d’une
instance. Cette instance relie l’application à l’API. Pour la créer vous devrez
donner quelques informations au driver.
Créez une fonction createInstance et appelez-la depuis la fonction initVulkan
:
1 void initVulkan() {
2 createInstance();
3 }
Ajoutez ensuite un membre donnée représentant cette instance :
1 private:
2 VkInstance instance;
Pour créer l’instance, nous allons d’abord remplir une première structure avec
des informations sur notre application. Ces données sont optionnelles, mais elles
41
peuvent fournir des informations utiles au driver pour optimiser ou dignostiquer
les erreurs lors de l’exécution, par exemple en reconnaissant le nom d’un moteur
graphique. Cette structure s’appelle VkApplicationInfo :
1 void createInstance() {
2 VkApplicationInfo appInfo{};
3 appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
4 appInfo.pApplicationName = "Hello Triangle";
5 appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0);
6 appInfo.pEngineName = "No Engine";
7 appInfo.engineVersion = VK_MAKE_VERSION(1, 0, 0);
8 appInfo.apiVersion = VK_API_VERSION_1_0;
9 }
Comme mentionné précédemment, la plupart des structures Vulkan vous de-
mandent d’expliciter leur propre type dans le membre sType. Cela permet
d’indiquer la version exacte de la structure que nous voulons utiliser : il y aura
dans le futur des extensions à celles-ci. Pour simplifier leur implémentation,
les utiliser ne nécessitera que de changer le type VK_STRUCTURE_TYPE_XXX en
VK_STRUCTURE_TYPE_XXX_2 (ou plus de 2) et de fournir une structure complé-
mentaire à l’aide du pointeur pNext. Nous n’utiliserons aucune extension, et
donnerons donc toujours nullptr à pNext.
Avec Vulkan, nous rencontrerons souvent (TRÈS souvent) des structures à rem-
plir pour passer les informations à Vulkan. Nous allons maintenant remplir le
reste de la structure permettant la création de l’instance. Celle-ci n’est pas op-
tionnelle. Elle permet d’informer le driver des extensions et des validation layers
que nous utiliserons, et ceci de manière globale. Globale siginifie ici que ces don-
nées ne serons pas spécifiques à un périphérique. Nous verrons la signification
de cela dans les chapitres suivants.
1 VkInstanceCreateInfo createInfo{};
2 createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
3 createInfo.pApplicationInfo = &appInfo;
Les deux premiers paramètres sont simples. Les deux suivants spécifient les
extensions dont nous aurons besoin. Comme nous l’avons vu dans l’introduction,
Vulkan ne connaît pas la plateforme sur laquelle il travaille, et nous aurons donc
besoin d’extensions pour utiliser des interfaces avec le gestionnaire de fenêtre.
GLFW possède une fonction très pratique qui nous donne la liste des extensions
dont nous aurons besoin pour afficher nos résultats. Remplissez donc la structure
de ces données :
1 uint32_t glfwExtensionCount = 0;
2 const char** glfwExtensions;
3
4 glfwExtensions =
glfwGetRequiredInstanceExtensions(&glfwExtensionCount);
42
5
6 createInfo.enabledExtensionCount = glfwExtensionCount;
7 createInfo.ppEnabledExtensionNames = glfwExtensions;
Les deux derniers membres de la structure indiquent les validations layers à
activer. Nous verrons cela dans le prochain chapitre, laissez ces champs vides
pour le moment :
1 createInfo.enabledLayerCount = 0;
Nous avons maintenant indiqué tout ce dont Vulkan a besoin pour créer notre
première instance. Nous pouvons enfin appeler vkCreateInstance :
1 VkResult result = vkCreateInstance(&createInfo, nullptr, &instance);
Comme vous le reverrez, l’appel à une fonction pour la création d’un objet
Vulkan a le prototype suivant :
• Pointeur sur une structure contenant l’information pour la création
• Pointeur sur une fonction d’allocation que nous laisserons toujours
nullptr
• Pointeur sur une variable stockant une référence au nouvel objet
Si tout s’est bien passé, la référence à l’instance devrait être contenue dans le
membre VkInstance. Quasiment toutes les fonctions Vulkan retournent une
valeur de type VkResult, pouvant être soit VK_SUCCESS soit un code d’erreur.
Afin de vérifier si la création de l’instance s’est bien déroulée nous pouvons
placer l’appel dans un if :
1 if (vkCreateInstance(&createInfo, nullptr, &instance) != VK_SUCCESS)
{
2 throw std::runtime_error("Echec de la création de l'instance!");
3 }
Lancez votre programme pour voir si l’instance s’est créée correctement.
Vérification du support des extensions
Si vous regardez la documentation pour vkCreateInstance vous pourrez voir
que l’un des messages d’erreur possible est VK_ERROR_EXTENSION_NOT_PRESENT.
Nous pourrions juste interrompre le programme et afficher une erreur si une
extension manque. Ce serait logique pour des fonctionnalités cruciales comme
l’affichage, mais pas dans le cas d’extensions optionnelles.
La fonction vkEnumerateInstanceExtensionProperties permet de récupérer
la totalité des extensions supportées par le système avant la création de
l’instance. Elle demande un pointeur vers une variable stockant le nombre
d’extensions supportées et un tableau où stocker des informations sur chacune
des extensions. Elle possède également un paramètre optionnel permettant de
43
filtrer les résultats pour une validation layer spécifique. Nous l’ignorerons pour
le moment.
Pour allouer un tableau contenant les détails des extensions nous devons déjà
connaître le nombre de ces extensions. Vous pouvez ne demander que cette
information en laissant le premier paramètre nullptr :
1 uint32_t extensionCount = 0;
2 vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount,
nullptr);
Nous utiliserons souvent cette méthode. Allouez maintenant un tableau pour
stocker les détails des extensions (incluez ) :
1 std::vector<VkExtensionProperties> extensions(extensionCount);
Nous pouvons désormais accéder aux détails des extensions :
1 vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount,
extensions.data());
Chacune des structure VkExtensionProperties contient le nom et la version
maximale supportée de l’extension. Nous pouvons les afficher à l’aide d’une
boucle for toute simple (\t représente une tabulation) :
1 std::cout << "Extensions disponibles :\n";
2
3 for (const auto& extension : extensions) {
4 std::cout << '\t' << extension.extensionName << '\n';
5 }
Vous pouvez ajouter ce code dans la fonction createInstance si vous voulez
indiquer des informations à propos du support Vulkan sur la machine. Petit
challenge : programmez une fonction vérifiant si les extensions dont vous avez
besoin (en particulier celles indiquées par GLFW) sont disponibles.
Libération des ressources
L’instance contenue dans VkInstance ne doit être détruite qu’à la fin du pro-
gramme. Nous la détruirons dans la fonction cleanup grâce à la fonction
vkDestroyInstance :
1 void cleanup() {
2 vkDestroyInstance(instance, nullptr);
3
4 glfwDestroyWindow(window);
5
6 glfwTerminate();
7 }
44
Les paramètres de cette fonction sont évidents. Nous y retrouvons le paramètre
pour un désallocateur que nous laissons nullptr. Toutes les ressources que nous
allouerons à partir du prochain chapitre devront être libérées avant la libération
de l’instance.
Avant d’avancer dans les notions plus complexes, créons un moyen de déboger
notre programme avec les validations layers..
Code C++
Validation layers
Que sont les validation layers?
L’API Vulkan est conçue pour limiter au maximum le travail du driver. Par
conséquent il n’y a aucun traitement d’erreur par défaut. Une erreur aussi
simple que se tromper dans la valeur d’une énumération ou passer un pointeur
nul comme argument non optionnel résultent en un crash. Dans la mesure
où Vulkan nous demande d’être complètement explicite, il est facile d’utiliser
une fonctionnalité optionnelle et d’oublier de mettre en place l’utilisation de
l’extension à laquelle elle appartient, par exemple.
Cependant de telles vérifications peuvent être ajoutées à l’API. Vulkan possède
un système élégant appelé validation layers. Ce sont des composants optionnels
s’insérant dans les appels des fonctions Vulkan pour y ajouter des opérations.
Voici un exemple d’opérations qu’elles réalisent :
• Comparer les valeurs des paramètres à celles de la spécification pour dé-
tecter une mauvaise utilisation
• Suivre la création et la destruction des objets pour repérer les fuites de
mémoire
• Vérifier la sécurité des threads en suivant l’origine des appels
• Afficher toutes les informations sur les appels à l’aide de la sortie standard
• Suivre les appels Vulkan pour créer une analyse dynamique de l’exécution
du programme
Voici ce à quoi une fonction de diagnostic pourrait ressembler :
1 VkResult vkCreateInstance(
2 const VkInstanceCreateInfo* pCreateInfo,
3 const VkAllocationCallbacks* pAllocator,
4 VkInstance* instance) {
5
6 if (pCreateInfo == nullptr || instance == nullptr) {
7 log("Pointeur nul passé à un paramètre obligatoire!");
8 return VK_ERROR_INITIALIZATION_FAILED;
9 }
10
11 return real_vkCreateInstance(pCreateInfo, pAllocator, instance);
45
12 }
Les validation layers peuvent être combinées à loisir pour fournir toutes les
fonctionnalités de débogage nécessaires. Vous pouvez même activer les valida-
tions layers lors du développement et les désactiver lors du déploiement sans
aucun problème, sans aucune répercussion sur les performances et même sur
l’exécutable!
Vulkan ne fournit aucune validation layer, mais nous en avons dans le SDK de
LunarG. Elles sont complètement open source, vous pouvez donc voir quelles
erreurs elles suivent et contribuer à leur développement. Les utiliser est la
meilleure manière d’éviter que votre application fonctionne grâce à un com-
portement spécifique à un driver.
Les validations layers ne sont utilisables que si elles sont installées sur la machine.
Il faut le SDK installé et mis en place pour qu’elles fonctionnent.
Il a existé deux formes de validation layers : les layers spécifiques à l’instance
et celles spécifiques au physical device (gpu). Elles ne vérifiaient ainsi respec-
tivement que les appels aux fonctions d’ordre global et les appels aux fonctions
spécifiques au GPU. Les layers spécifiques du GPU sont désormais dépréciées.
Les autres portent désormais sur tous les appels. Cependant la spécification
recommande encore que nous activions les validations layers au niveau du log-
ical device, car cela est requis par certaines implémentations. Nous nous con-
tenterons de spécifier les mêmes layers pour le logical device que pour le physical
device, que nous verrons plus tard.
Utiliser les validation layers
Nous allons maintenant activer les validations layers fournies par le SDK de
LunarG. Comme les extensions, nous devons indiquer leurs nom. Au lieu de
devoir spécifier les noms de chacune d’entre elles, nous pouvons les activer à
l’aide d’un nom générique : VK_LAYER_KHRONOS_validation.
Mais ajoutons d’abord deux variables spécifiant les layers à activer et si le pro-
gramme doit en effet les activer. J’ai choisi d’effectuer ce choix selon si le
programme est compilé en mode debug ou non. La macro NDEBUG fait partie du
standard c++ et correspond au second cas.
1 const uint32_t WIDTH = 800;
2 const uint32_t HEIGHT = 600;
3
4 const std::vector<const char*> validationLayers = {
5 "VK_LAYER_KHRONOS_validation"
6 };
7
8 #ifdef NDEBUG
9 constexpr bool enableValidationLayers = false;
46
10 #else
11 constexpr bool enableValidationLayers = true;
12 #endif
Ajoutons une nouvelle fonction checkValidationLayerSupport, qui de-
vra vérifier si les layers que nous voulons utiliser sont disponibles. Lis-
tez d’abord les validation layers disponibles à l’aide de la fonction
vkEnumerateInstanceLayerProperties. Elle s’utilise de la même façon
que vkEnumerateInstanceExtensionProperties.
1 bool checkValidationLayerSupport() {
2 uint32_t layerCount;
3 vkEnumerateInstanceLayerProperties(&layerCount, nullptr);
4
5 std::vector<VkLayerProperties> availableLayers(layerCount);
6 vkEnumerateInstanceLayerProperties(&layerCount,
availableLayers.data());
7
8 return false;
9 }
Vérifiez que toutes les layers de validationLayers sont présentes dans la liste
des layers disponibles. Vous aurez besoin de <cstring> pour la fonction strcmp.
1 for (const char* layerName : validationLayers) {
2 bool layerFound = false;
3
4 for (const auto& layerProperties : availableLayers) {
5 if (strcmp(layerName, layerProperties.layerName) == 0) {
6 layerFound = true;
7 break;
8 }
9 }
10
11 if (!layerFound) {
12 return false;
13 }
14 }
15
16 return true;
Nous pouvons maintenant utiliser cette fonction dans createInstance :
1 void createInstance() {
2 if (enableValidationLayers && !checkValidationLayerSupport()) {
3 throw std::runtime_error("les validations layers sont
activées mais ne sont pas disponibles!");
47
4 }
5
6 ...
7 }
Lancez maintenant le programme en mode debug et assurez-vous qu’il fonc-
tionne. Si vous obtenez une erreur, référez-vous à la FAQ.
Modifions enfin la structure VkCreateInstanceInfo pour inclure les noms des
validation layers à utiliser lorsqu’elles sont activées :
1 if (enableValidationLayers) {
2 createInfo.enabledLayerCount =
static_cast<uint32_t>(validationLayers.size());
3 createInfo.ppEnabledLayerNames = validationLayers.data();
4 } else {
5 createInfo.enabledLayerCount = 0;
6 }
Si l’appel à la fonction checkValidationLayerSupport est un succès,
vkCreateInstance ne devrait jamais retourner VK_ERROR_LAYER_NOT_PRESENT,
mais exécutez tout de même le programme pour être sûr que d’autres erreurs
n’apparaissent pas.
Fonction de rappel des erreurs
Les validation layers affichent leur messages dans la console par défaut, mais
on peut s’occuper de l’affichage nous-même en fournissant un callback explicite
dans notre programme. Ceci nous permet également de choisir quels types de
message afficher, car tous ne sont pas des erreurs (fatales). Si vous ne voulez
pas vous occuper de ça maintenant, vous pouvez sauter à la dernière section de
ce chapitre.
Pour configurer un callback permettant de s’occuper des messages et des détails
associés, nous devons mettre en place un debug messenger avec un callback en
utilisant l’extension VK_EXT_debug_utils.
Créons d’abord une fonction getRequiredExtensions. Elle nous fournira les
extensions nécessaires selon que nous activons les validation layers ou non :
1 std::vector<const char*> getRequiredExtensions() {
2 uint32_t glfwExtensionCount = 0;
3 const char** glfwExtensions;
4 glfwExtensions =
glfwGetRequiredInstanceExtensions(&glfwExtensionCount);
5
6 std::vector<const char*> extensions(glfwExtensions,
glfwExtensions + glfwExtensionCount);
7
48
8 if (enableValidationLayers) {
9 extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
10 }
11
12 return extensions;
13 }
Les extensions spécifiées par GLFW seront toujours nécessaires, mais celle pour
le débogage n’est ajoutée que conditionnellement. Remarquez l’utilisation de
la macro VK_EXT_DEBUG_UTILS_EXTENSION_NAME au lieu du nom de l’extension
pour éviter les erreurs de frappe.
Nous pouvons maintenant utiliser cette fonction dans createInstance :
1 auto extensions = getRequiredExtensions();
2 createInfo.enabledExtensionCount =
static_cast<uint32_t>(extensions.size());
3 createInfo.ppEnabledExtensionNames = extensions.data();
Exécutez le programme et assurez-vous que vous ne recevez pas l’erreur
VK_ERROR_EXTENSION_NOT_PRESENT. Nous ne devrions pas avoir besoin de
vérifier sa présence dans la mesure où les validation layers devraient impliquer
son support, mais sait-on jamais.
Intéressons-nous maintenant à la fonction de rappel. Ajoutez la fonction sta-
tique debugCallback à votre classe avec le prototype PFN_vkDebugUtilsMessengerCallbackEXT.
VKAPI_ATTR et VKAPI_CALL assurent une compatibilité avec tous les compila-
teurs.
1 static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(
2 VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity,
3 VkDebugUtilsMessageTypeFlagsEXT messageType,
4 const VkDebugUtilsMessengerCallbackDataEXT* pCallbackData,
5 void* pUserData) {
6
7 std::cerr << "validation layer: " << pCallbackData->pMessage <<
std::endl;
8
9 return VK_FALSE;
10 }
Le premier paramètre indique la sévérité du message, et peut prendre les valeurs
suivantes :
• VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT: Message de
suivi des appels
• VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT: Message d’information
(allocation d’une ressource…)
49
• VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT: Message rele-
vant un comportement qui n’est pas un bug mais plutôt une imperfection
involontaire
• VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT: Message relevant
un comportement invalide pouvant mener à un crash
Les valeurs de cette énumération on été conçues de telle sorte qu’il est possible
de les comparer pour vérifier la sévérité d’un message, par exemple :
1 if (messageSeverity >=
VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT) {
2 // Le message est suffisamment important pour être affiché
3 }
Le paramètre messageType peut prendre les valeurs suivantes :
• VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT : Un événement quel-
conque est survenu, sans lien avec les performances ou la spécification
• VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT : Une violation
de la spécification ou une potentielle erreur est survenue
• VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT : Utilisation po-
tentiellement non optimale de Vulkan
Le paramètre pCallbackData est une structure du type VkDebugUtilsMessengerCallbackDataEXT
contenant les détails du message. Ses membres les plus importants sont :
• pMessage: Le message sous la forme d’une chaîne de type C terminée par
le caractère nul \0
• pObjects: Un tableau d’objets Vulkan liés au message
• objectCount: Le nombre d’objets dans le tableau précédent
Finalement, le paramètre pUserData est un pointeur sur une donnée quelconque
que vous pouvez spécifier à la création de la fonction de rappel.
La fonction de rappel que nous programmons retourne un booléen détermi-
nant si la fonction à l’origine de son appel doit être interrompue. Si elle re-
tourne VK_TRUE, l’exécution de la fonction est interrompue et cette dernière re-
tourne VK_ERROR_VALIDATION_FAILED_EXT. Cette fonctionnalité n’est globale-
ment utilisée que pour tester les validation layers elles-mêmes, nous retournerons
donc invariablement VK_FALSE.
Il ne nous reste plus qu’à fournir notre fonction à Vulkan. Surprenamment,
même le messager de débogage se gère à travers une référence de type
VkDebugUtilsMessengerEXT, que nous devrons explicitement créer et détruire.
Une telle fonction de rappel est appelée messager, et vous pouvez en posséder
autant que vous le désirez. Ajoutez un membre donnée pour le messager sous
l’instance :
1 VkDebugUtilsMessengerEXT callback;
50
Ajoutez ensuite une fonction setupDebugMessenger et appelez la dans
initVulkan après createInstance :
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 }
5
6 void setupDebugMessenger() {
7 if (!enableValidationLayers) return;
8
9 }
Nous devons maintenant remplir une structure avec des informations sur le
messager :
1 VkDebugUtilsMessengerCreateInfoEXT createInfo{};
2 createInfo.sType =
VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
3 createInfo.messageSeverity =
VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
4 createInfo.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT
| VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT;
5 createInfo.pfnUserCallback = debugCallback;
6 createInfo.pUserData = nullptr; // Optionnel
Le champ messageSeverity vous permet de filtrer les niveaux de sévérité pour
lesquels la fonction de rappel sera appelée. J’ai laissé tous les types sauf
VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT, ce qui permet de recevoir
toutes les informations à propos de possibles bugs tout en éliminant la verbose.
De manière similaire, le champ messageType vous permet de filtrer les types
de message pour lesquels la fonction de rappel sera appelée. J’y ai mis tous les
types possibles. Vous pouvez très bien en désactiver s’ils ne vous servent à rien.
Le champ pfnUserCallback indique le pointeur vers la fonction de rappel.
Vous pouvez optionnellement ajouter un pointeur sur une donnée de votre choix
grâce au champ pUserData. Le pointeur fait partie des paramètres de la fonction
de rappel.
Notez qu’il existe de nombreuses autres manières de configurer des messagers
auprès des validation layers, mais nous avons ici une bonne base pour ce tutoriel.
Référez-vous à la spécification de l’extension pour plus d’informations sur ces
possibilités.
51
Cette structure doit maintenant être passée à la fonction vkCreateDebugUtilsMessengerEXT
afin de créer l’objet VkDebugUtilsMessengerEXT. Malheureusement cette
fonction fait partie d’une extension non incluse par GLFW. Nous de-
vons donc gérer son activation nous-mêmes. Nous utiliserons la fonction
vkGetInstancePorcAddr pous en récupérer un pointeur. Nous allons créer
notre propre fonction - servant de proxy - pour abstraire cela. Je l’ai ajoutée
au-dessus de la définition de la classe HelloTriangleApplication.
1 VkResult CreateDebugUtilsMessengerEXT(VkInstance instance, const
VkDebugUtilsMessengerCreateInfoEXT* pCreateInfo, const
VkAllocationCallbacks* pAllocator, VkDebugUtilsMessengerEXT*
pCallback) {
2 auto func = (PFN_vkCreateDebugUtilsMessengerEXT)
vkGetInstanceProcAddr(instance,
"vkCreateDebugUtilsMessengerEXT");
3 if (func != nullptr) {
4 return func(instance, pCreateInfo, pAllocator, pCallback);
5 } else {
6 return VK_ERROR_EXTENSION_NOT_PRESENT;
7 }
8 }
La fonction vkGetInstanceProcAddr retourne nullptr si la fonction n’a pas
pu être chargée. Nous pouvons maintenant utiliser cette fonction pour créer le
messager s’il est disponible :
1 if (CreateDebugUtilsMessengerEXT(instance, &createInfo, nullptr,
&callback) != VK_SUCCESS) {
2 throw std::runtime_error("le messager n'a pas pu être créé!");
3 }
Le troisième paramètre est l’invariable allocateur optionnel que nous laissons
nullptr. Les autres paramètres sont assez logiques. La fonction de rappel
est spécifique de l’instance et des validation layers, nous devons donc passer
l’instance en premier argument. Lancez le programme et vérifiez qu’il fonctionne.
Vous devriez avoir le résultat suivant :
qui indique déjà un bug dans notre application! En effet l’objet VkDebugUtilsMessengerEXT
doit être libéré explicitement à l’aide de la fonction vkDestroyDebugUtilsMessagerEXT.
De même qu’avec vkCreateDebugUtilsMessangerEXT nous devons charger
52
dynamiquement cette fonction. Notez qu’il est normal que le message s’affiche
plusieurs fois; il y a plusieurs validation layers, et dans certains cas leurs
domaines d’expertise se recoupent.
Créez une autre fonction proxy en-dessous de CreateDebugUtilsMessengerEXT
:
1 void DestroyDebugUtilsMessengerEXT(VkInstance instance,
VkDebugUtilsMessengerEXT callback, const VkAllocationCallbacks*
pAllocator) {
2 auto func = (PFN_vkDestroyDebugUtilsMessengerEXT)
vkGetInstanceProcAddr(instance,
"vkDestroyDebugUtilsMessengerEXT");
3 if (func != nullptr) {
4 func(instance, callback, pAllocator);
5 }
6 }
Nous pouvons maintenant l’appeler dans notre fonction cleanup :
1 void cleanup() {
2 if (enableValidationLayers) {
3 DestroyDebugUtilsMessengerEXT(instance, callback, nullptr);
4 }
5
6 vkDestroyInstance(instance, nullptr);
7
8 glfwDestroyWindow(window);
9
10 glfwTerminate();
11 }
Si vous exécutez le programme maintenant, vous devriez constater que le mes-
sage n’apparait plus. Si vous voulez voir quel fonction a lancé un appel au
messager, vous pouvez insérer un point d’arrêt dans la fonction de rappel.
Déboguer la création et la destruction de l’instance
Même si nous avons mis en place un système de débogage très efficace, deux
fonctions passent sous le radar. Comme il est nécessaire d’avoir une instance
pour appeler vkCreateDebugUtilsMessengerEXT, la création de l’instance n’est
pas couverte par le messager. Le même problème apparait avec la destruction
de l’instance.
En lisant la documentation on voit qu’il existe un messager spécifiquement
créé pour ces deux fonctions. Il suffit de passer un pointeur vers une in-
stance de VkDebugUtilsMessengerCreateInfoEXT au membre pNext de
VkInstanceCreateInfo. Plaçons le remplissage de la structure de création du
messager dans une fonction :
53
1 void
populateDebugMessengerCreateInfo(VkDebugUtilsMessengerCreateInfoEXT&
createInfo) {
2 createInfo = {};
3 createInfo.sType =
VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
4 createInfo.messageSeverity =
VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
5 createInfo.messageType =
VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT;
6 createInfo.pfnUserCallback = debugCallback;
7 }
8 ...
9 void setupDebugMessenger() {
10 if (!enableValidationLayers) return;
11 VkDebugUtilsMessengerCreateInfoEXT createInfo;
12 populateDebugMessengerCreateInfo(createInfo);
13 if (CreateDebugUtilsMessengerEXT(instance, &createInfo, nullptr,
&debugMessenger) != VK_SUCCESS) {
14 throw std::runtime_error("failed to set up debug
messenger!");
15 }
16 }
Nous pouvons réutiliser cette fonction dans createInstance :
1 void createInstance() {
2 ...
3
4 VkInstanceCreateInfo createInfo{};
5 createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
6 createInfo.pApplicationInfo = &appInfo;
7
8 ...
9
10 VkDebugUtilsMessengerCreateInfoEXT debugCreateInfo{};
11 if (enableValidationLayers) {
12 createInfo.enabledLayerCount =
static_cast<uint32_t>(validationLayers.size());
13 createInfo.ppEnabledLayerNames = validationLayers.data();
14 populateDebugMessengerCreateInfo(debugCreateInfo);
15 createInfo.pNext = (VkDebugUtilsMessengerCreateInfoEXT*)
54
&debugCreateInfo;
16 } else {
17 createInfo.enabledLayerCount = 0;
18
19 createInfo.pNext = nullptr;
20 }
21
22 if (vkCreateInstance(&createInfo, nullptr, &instance) !=
VK_SUCCESS) {
23 throw std::runtime_error("failed to create instance!");
24 }
25 }
La variable debugCreateInfo est en-dehors du if pour qu’elle ne soit pas détru-
ite avant l’appel à vkCreateInstance. La structure fournie à la création de
l’instance à travers la structure VkInstanceCreateInfo mènera à la création
d’un messager spécifique aux deux fonctions qui sera détruit automatiquement
à la destruction de l’instance.
Configuration
Les validation layers peuvent être paramétrées de nombreuses autres
manières que juste avec les informations que nous avons fournies dans
la structure VkDebugUtilsMessangerCreateInfoEXT. Ouvrez le SDK
Vulkan et rendez-vous dans le dossier Config. Vous y trouverez le fichier
vk_layer_settings.txt qui vous expliquera comment configurer les validation
layers.
Pour configurer les layers pour votre propre application, copiez le fichier dans
les dossiers Debug et/ou Release, puis suivez les instructions pour obtenir le
comportement que vous souhaitez. Cependant, pour le reste du tutoriel, je
partirai du principe que vous les avez laissées avec leur comportement par défaut.
Tout au long du tutoriel je laisserai de petites erreurs intentionnelles pour vous
montrer à quel point les validation layers sont pratiques, et à quel point vous
devez comprendre tout ce que vous faites avec Vulkan. Il est maintenant temps
de s’intéresser aux devices Vulkan dans le système.
Code C++
Physical devices et queue families
Sélection d’un physical device
La librairie étant initialisée à travers VkInstance, nous pouvons dès à présent
chercher et sélectionner une carte graphique (physical device) dans le système
qui supporte les fonctionnalitées dont nous aurons besoin. Nous pouvons en fait
55
en sélectionner autant que nous voulons et travailler avec chacune d’entre elles,
mais nous n’en utiliserons qu’une dans ce tutoriel pour des raisons de simplicité.
Ajoutez la fonction pickPhysicalDevice et appelez la depuis initVulkan :
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 pickPhysicalDevice();
5 }
6
7 void pickPhysicalDevice() {
8
9 }
Nous stockerons le physical device que nous aurons sélectionnée dans un nouveau
membre donnée de la classe, et celui-ci sera du type VkPhysicalDevice. Cette
référence sera implicitement détruit avec l’instance, nous n’avons donc rien à
ajouter à la fonction cleanup.
1 VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;
Lister les physical devices est un procédé très similaire à lister les extensions.
Comme d’habitude, on commence par en lister le nombre.
1 uint32_t deviceCount = 0;
2 vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);
Si aucun physical device ne supporte Vulkan, il est inutile de continuer
l’exécution.
1 if (deviceCount == 0) {
2 throw std::runtime_error("aucune carte graphique ne supporte
Vulkan!");
3 }
Nous pouvons ensuite allouer un tableau contenant toutes les références aux
VkPhysicalDevice.
1 std::vector<VkPhysicalDevice> devices(deviceCount);
2 vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());
Nous devons maintenant évaluer chacun des gpus et vérifier qu’ils conviennent
pour ce que nous voudrons en faire, car toutes les cartes graphiques n’ont pas
été crées égales. Voici une nouvelle fonction qui fera le travail de sélection :
1 bool isDeviceSuitable(VkPhysicalDevice device) {
2 return true;
3 }
56
Nous allons dans cette fonction vérifier que le physical device respecte nos con-
ditions.
1 for (const auto& device : devices) {
2 if (isDeviceSuitable(device)) {
3 physicalDevice = device;
4 break;
5 }
6 }
7
8 if (physicalDevice == VK_NULL_HANDLE) {
9 throw std::runtime_error("aucun GPU ne peut exécuter ce
programme!");
10 }
La section suivante introduira les premières contraintes que devront remplir les
physical devices. Au fur et à mesure que nous utiliserons de nouvelles fonction-
nalités, nous les ajouterons dans cette fonction.
Vérification des fonctionnalités de base
Pour évaluer la compatibilité d’un physical device nous devons d’abord nous
informer sur ses capacités. Des propriétés basiques comme le nom, le type
et les versions de Vulkan supportées peuvent être obtenues en appelant
vkGetPhysicalDeviceProperties.
1 VkPhysicalDeviceProperties deviceProperties;
2 vkGetPhysicalDeviceProperties(device, &deviceProperties);
Le support des fonctionnalités optionnelles telles que les textures compressées,
les floats de 64 bits et le multi viewport rendering (pour la VR) s’obtiennent
avec vkGetPhysicalDeviceFeatures :
1 VkPhysicalDeviceFeatures deviceFeatures;
2 vkGetPhysicalDeviceFeatures(device, &deviceFeatures);
De nombreux autres détails intéressants peuvent être requis, mais nous en rem-
parlerons dans les prochains chapitres.
Voyons un premier exemple. Considérons que notre application a besoin
d’une carte graphique dédiée supportant les geometry shaders. Notre fonction
isDeviceSuitable ressemblerait alors à cela :
1 bool isDeviceSuitable(VkPhysicalDevice device) {
2 VkPhysicalDeviceProperties deviceProperties;
3 VkPhysicalDeviceFeatures deviceFeatures;
4 vkGetPhysicalDeviceProperties(device, &deviceProperties);
5 vkGetPhysicalDeviceFeatures(device, &deviceFeatures);
6
57
7 return deviceProperties.deviceType ==
VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU &&
8 deviceFeatures.geometryShader;
9 }
Au lieu de choisir le premier physical device nous convenant, nous pourrions
attribuer un score à chacun d’entre eux et utiliser celui dont le score est le plus
élevé. Vous pourriez ainsi préférer une carte graphique dédiée, mais utiliser
un GPU intégré au CPU si le système n’en détecte aucune. Vous pourriez
implémenter ce concept comme cela :
1 #include <map>
2
3 ...
4
5 void pickPhysicalDevice() {
6 ...
7
8 // L'utilisation d'une map permet de les trier automatiquement
de manière ascendante
9 std::multimap<int, VkPhysicalDevice> candidates;
10
11 for (const auto& device : devices) {
12 int score = rateDeviceSuitability(device);
13 candidates.insert(std::make_pair(score, device));
14 }
15
16 // Voyons si la meilleure possède les fonctionnalités dont nous
ne pouvons nous passer
17 if (candidates.rbegin()->first > 0) {
18 physicalDevice = candidates.rbegin()->second;
19 } else {
20 throw std::runtime_error("aucun GPU ne peut executer ce
programme!");
21 }
22 }
23
24 int rateDeviceSuitability(VkPhysicalDevice device) {
25 ...
26
27 int score = 0;
28
29 // Les carte graphiques dédiées ont un énorme avantage en terme
de performances
30 if (deviceProperties.deviceType ==
VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {
58
31 score += 1000;
32 }
33
34 // La taille maximale des textures affecte leur qualité
35 score += deviceProperties.limits.maxImageDimension2D;
36
37 // L'application (fictive) ne peut fonctionner sans les geometry
shaders
38 if (!deviceFeatures.geometryShader) {
39 return 0;
40 }
41
42 return score;
43 }
Vous n’avez pas besoin d’implémenter tout ça pour ce tutoriel, mais faites-le
si vous voulez, à titre d’entrainement. Vous pourriez également vous contenter
d’afficher les noms des cartes graphiques et laisser l’utilisateur choisir.
Nous ne faisons que commencer donc nous prendrons la première carte suppor-
tant Vulkan :
1 bool isDeviceSuitable(VkPhysicalDevice device) {
2 return true;
3 }
Nous discuterons de la première fonctionnalité qui nous sera nécessaire dans la
section suivante.
Familles de queues (queue families)
Il a été évoqué que chaque opération avec Vulkan, de l’affichage jusqu’au charge-
ment d’une texture, s’effectue en ajoutant une commande à une queue. Il existe
différentes queues appartenant à différents types de queue families. De plus
chaque queue family ne permet que certaines commandes. Il se peut par ex-
emple qu’une queue ne traite que les commandes de calcul et qu’une autre ne
supporte que les commandes d’allocation de mémoire.
Nous devons analyser quelles queue families existent sur le système et lesquelles
correspondent aux commandes que nous souhaitons utiliser. Nous allons donc
créer la fonction findQueueFamilies dans laquelle nous chercherons les com-
mandes nous intéressant.
Nous allons chercher une queue qui supporte les commandes graphiques, la
fonction pourrait ressembler à ça:
1 uint32_t findQueueFamilies(VkPhysicalDevice device) {
2 // Code servant à trouver la famille de queue "graphique"
3 }
59
Mais dans un des prochains chapitres, nous allons avoir besoin d’une autre
famille de queues, il est donc plus intéressant de s’y préparer dès maintenant en
empactant plusieurs indices dans une structure:
1 struct QueueFamilyIndices {
2 uint32_t graphicsFamily;
3 };
4
5 QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device) {
6 QueueFamilyIndices indices;
7 // Code pour trouver les indices de familles à ajouter à la
structure
8 return indices
9 }
Que se passe-t-il si une famille n’est pas disponible ? On pourrait lancer une
exception dans findQueueFamilies, mais cette fonction n’est pas vraiment le
bon endroit pour prendre des decisions concernant le choix du bon Device. Par
exemple, on pourrait préférer des Devices avec une queue de transfert dédiée,
sans toutefois le requérir. Par conséquent nous avons besoin d’indiquer si une
certaine famille de queues à été trouvé.
Ce n’est pas très pratique d’utiliser une valeur magique pour indiquer la
non-existence d’une famille, comme n’importe quelle valeur de uint32_t
peut théoriquement être une valeur valide d’index de famille, incluant 0.
Heureusement, le C++17 introduit un type qui permet la distinction entre le
cas où la valeur existe et celui où elle n’existe pas:
1 #include <optional>
2
3 ...
4
5 std::optional<uint32_t> graphicsFamily;
6
7 std::cout << std::boolalpha << graphicsFamily.has_value() <<
std::endl; // faux
8
9 graphicsFamily = 0;
10
11 std::cout << std::boolalpha << graphicsFamily.has_value() <<
std::endl; // vrai
std::optional est un wrapper qui ne contient aucune valeur tant que vous ne
lui en assignez pas une. Vous pouvez, quelque soit le moment, lui demander si il
contient une valeur ou non en appelant sa fonction membre has_value(). On
peut donc changer le code comme suit:
1 #include <optional>
60
2
3 ...
4
5 struct QueueFamilyIndices {
6 std::optional<uint32_t> graphicsFamily;
7 };
8
9 QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device) {
10 QueueFamilyIndices indices;
11
12 // Assigne l'index aux familles qui ont pu être trouvées
13
14 return indices;
15 }
On peut maintenant commencer à implémenter findQueueFamilies:
1 QueueFamilyIndices findQueueFamily(VkPhysicalDevice) {
2 QueueFamilyIndices indices;
3
4 ...
5
6 return indices;
7 }
Récupérer la liste des queue families disponibles se fait de la même manière que
d’habitude, avec la fonction vkGetPhysicalDeviceQueueFamilyProperties :
1 uint32_t queueFamilyCount = 0;
2 vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount,
nullptr);
3
4 std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount);
5 vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount,
queueFamilies.data());
La structure VkQueueFamilyProperties contient des informations sur la queue
family, et en particulier le type d’opérations qu’elle supporte et le nombre de
queues que l’on peut instancier à partir de cette famille. Nous devons trouver
au moins une queue supportant VK_QUEUE_GRAPHICS_BIT :
1 int i = 0;
2 for (const auto& queueFamily : queueFamilies) {
3 if (queueFamily.queueFlags & VK_QUEUE_GRAPHICS_BIT) {
4 indices.graphicsFamily = i;
5 }
6
61
7 i++;
8 }
Nous pouvons maintenant utiliser cette fonction dans isDeviceSuitable pour
s’assurer que le physical device peut recevoir les commandes que nous voulons
lui envoyer :
1 bool isDeviceSuitable(VkPhysicalDevice device) {
2 QueueFamilyIndices indices = findQueueFamilies(device);
3
4 return indices.graphicsFamily.has_value();
5 }
Pour que ce soit plus pratique, nous allons aussi ajouter une fonction générique
à la structure:
1 struct QueueFamilyIndices {
2 std::optional<uint32_t> graphicsFamily;
3
4 bool isComplete() {
5 return graphicsFamily.has_value();
6 }
7 };
8
9 ...
10
11 bool isDeviceSuitable(VkPhysicalDevice device) {
12 QueueFamilyIndices indices = findQueueFamilies(device);
13
14 return indices.isComplete();
15 }
On peut également utiliser ceci pour sortir plus tôt de findQueueFamilies:
1 for (const auto& queueFamily : queueFamilies) {
2 ...
3
4 if (indices.isComplete()) {
5 break;
6 }
7
8 i++;
9 }
Bien, c’est tout ce dont nous aurons besoin pour choisir le bon physical device!
La prochaine étape est de créer un logical device pour créer une interface avec
la carte.
Code C++
62
Logical device et queues
Introduction
La sélection d’un physical device faite, nous devons générer un logical device pour
servir d’interface. Le processus de sa création est similaire à celui de l’instance
: nous devons décrire ce dont nous aurons besoin. Nous devons également
spécifier les queues dont nous aurons besoin. Vous pouvez également créer
plusieurs logical devices à partir d’un physical device si vous en avez besoin.
Commencez par ajouter un nouveau membre donnée pour stocker la référence
au logical device.
1 VkDevice device;
Ajoutez ensuite une fonction createLogicalDevice et appelez-la depuis
initVulkan.
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 pickPhysicalDevice();
5 createLogicalDevice();
6 }
7
8 void createLogicalDevice() {
9
10 }
Spécifier les queues à créer
La création d’un logical device requiert encore que nous remplissions des
informations dans des structures. La première de ces structures s’appelle
VkDeviceQueueCreateInfo. Elle indique le nombre de queues que nous
désirons pour chaque queue family. Pour le moment nous n’avons besoin que
d’une queue originaire d’une unique queue family : la première avec un support
pour les graphismes.
1 QueueFamilyIndices indices = findQueueFamilies(physicalDevice);
2
3 VkDeviceQueueCreateInfo queueCreateInfo{};
4 queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
5 queueCreateInfo.queueFamilyIndex = indices.graphicsFamily.value();
6 queueCreateInfo.queueCount = 1;
Actuellement les drivers ne vous permettent que de créer un petit nombre de
queues pour chacune des familles, et vous n’avez en effet pas besoin de plus.
Vous pouvez très bien créer les commandes (command buffers) depuis plusieurs
63
threads et les soumettre à la queue d’un coup sur le thread principal, et ce sans
perte de performance.
Vulkan permet d’assigner des niveaux de priorité aux queues à l’aide de floats
compris entre 0.0 et 1.0. Vous pouvez ainsi influencer l’exécution des command
buffers. Il est nécessaire d’indiquer une priorité même lorsqu’une seule queue
est présente :
1 float queuePriority = 1.0f;
2 queueCreateInfo.pQueuePriorities = &queuePriority;
Spécifier les fonctionnalités utilisées
Les prochaines informations à fournir sont les fonctionnalités du physical device
que nous souhaitons utiliser. Ce sont celles dont nous avons vérifié la présence
avec vkGetPhysicalDeviceFeatures dans le chapitre précédent. Nous n’avons
besoin de rien de spécial pour l’instant, nous pouvons donc nous contenter de
créer la structure et de tout laisser à VK_FALSE, valeur par défaut. Nous revien-
drons sur cette structure quand nous ferons des choses plus intéressantes avec
Vulkan.
1 VkPhysicalDeviceFeatures deviceFeatures{};
Créer le logical device
Avec ces deux structure prêtes, nous pouvons enfin remplir la structure princi-
pale appelée VkDeviceCreateInfo.
1 VkDeviceCreateInfo createInfo{};
2 createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
Référencez d’abord les structures sur la création des queues et sur les fonction-
nalités utilisées :
1 createInfo.pQueueCreateInfos = &queueCreateInfo;
2 createInfo.queueCreateInfoCount = 1;
3
4 createInfo.pEnabledFeatures = &deviceFeatures;
Le reste ressemble à la structure VkInstanceCreateInfo. Nous devons spécifier
les extensions spécifiques de la carte graphique et les validation layers.
Un exemple d’extension spécifique au GPU est VK_KHR_swapchain. Celle-ci
vous permet de présenter à l’écran les images sur lesquels votre programme a
effectué un rendu. Il est en effet possible que certains GPU ne possèdent pas
cette capacité, par exemple parce qu’ils ne supportent que les compute shaders.
Nous reviendrons sur cette extension dans le chapitre dédié à la swap chain.
64
Comme dit dans le chapitre sur les validation layers, nous activerons les mêmes
que celles que nous avons spécifiées lors de la création de l’instance. Nous
n’avons pour l’instant besoin d’aucune validation layer en particulier. Notez
que le standard ne fait plus la différence entre les extensions de l’instance
et celles du device, au point que les paramètres enabledLayerCount et
ppEnabledLayerNames seront probablement ignorés. Nous les remplissons
quand même pour s’assurer de la bonne compatibilité avec les anciennes
implémentations.
1 createInfo.enabledExtensionCount = 0;
2
3 if (enableValidationLayers) {
4 createInfo.enabledLayerCount =
static_cast<uint32_t>(validationLayers.size());
5 createInfo.ppEnabledLayerNames = validationLayers.data();
6 } else {
7 createInfo.enabledLayerCount = 0;
8 }
C’est bon, nous pouvons maintenant instancier le logical device en appelant la
fonction vkCreateDevice.
1 if (vkCreateDevice(physicalDevice, &createInfo, nullptr, &device) !=
VK_SUCCESS) {
2 throw std::runtime_error("échec lors de la création d'un logical
device!");
3 }
Les paramètres sont d’abord le physical device dont on souhaite extraire une
interface, ensuite la structure contenant les informations, puis un pointeur op-
tionnel pour l’allocation et enfin un pointeur sur la référence au logical device
créé. Vérifions également si la création a été un succès ou non, comme lors de
la création de l’instance.
Le logical device doit être explicitement détruit dans la fonction cleanup avant
le physical device :
1 void cleanup() {
2 vkDestroyDevice(device, nullptr);
3 ...
4 }
Les logical devices n’interagissent pas directement avec l’instance mais seulement
avec le physical device, c’est pourquoi il n’y a pas de paramètre pour l’instance.
Récupérer des références aux queues
Les queue families sont automatiquement crées avec le logical device. Cependant
nous n’avons aucune interface avec elles. Ajoutez un membre donnée pour
65
stocker une référence à la queue family supportant les graphismes :
1 VkQueue graphicsQueue;
Les queues sont implicitement détruites avec le logical device, nous n’avons donc
pas à nous en charger dans cleanup.
Nous pourrons ensuite récupérer des références à des queues avec la fonction
vkGetDeviceQueue. Les paramètres en sont le logical device, la queue family,
l’indice de la queue à récupérer et un pointeur où stocker la référence à la queue.
Nous ne créons qu’une seule queue, nous écrirons donc 0 pour l’indice de la
queue.
1 vkGetDeviceQueue(device, indices.graphicsFamily.value(), 0,
&graphicsQueue);
Avec le logical device et les queues nous allons maintenant pouvoir faire tra-
vailler la carte graphique! Dans le prochain chapitre nous mettrons en place les
ressources nécessaires à la présentation des images à l’écran.
Code C++
Présentation
Window surface
Introduction
Vulkan ignore la plateforme sur laquelle il opère et ne peut donc pas directe-
ment établir d’interface avec le gestionnaire de fenêtres. Pour créer une interface
permettant de présenter les rendus à l’écran, nous devons utiliser l’extension
WSI (Window System Integration). Nous verrons dans ce chapitre l’extension
VK_KHR_surface, l’une des extensions du WSI. Nous pourrons ainsi obtenir
l’objet VkSurfaceKHR, qui est un type abstrait de surface sur lequel nous pour-
rons effectuer des rendus. Cette surface sera en lien avec la fenêtre que nous
avons créée grâce à GLFW.
L’extension VK_KHR_surface, qui se charge au niveau de l’instance, a déjà
été ajoutée, car elle fait partie des extensions retournées par la fonction
glfwGetRequiredInstanceExtensions. Les autres fonctions WSI que nous
verrons dans les prochains chapitres feront aussi partie des extensions retournées
par cette fonction.
La surface de fenêtre doit être créée juste après l’instance car elle peut influencer
le choix du physical device. Nous ne nous intéressons à ce sujet que maintenant
car il fait partie du grand ensemble que nous abordons et qu’en parler plus tôt
aurait été confus. Il est important de noter que cette surface est complètement
optionnelle, et vous pouvez l’ignorer si vous voulez effectuer du rendu off-screen
66
ou du calculus. Vulkan vous offre ces possibilités sans vous demander de re-
courir à des astuces comme créer une fenêtre invisible, là où d’autres APIs le
demandaient (cf OpenGL).
Création de la window surface
Commencez par ajouter un membre donnée surface sous le messager.
1 VkSurfaceKHR surface;
Bien que l’utilisation d’un objet VkSurfaceKHR soit indépendant de la plate-
forme, sa création ne l’est pas. Celle-ci requiert par exemple des références à
HWND et à HMODULE sous Windows. C’est pourquoi il existe des extensions spéci-
fiques à la plateforme, dont par exemple VK_KHR_win32_surface sous Windows,
mais celles-ci sont aussi évaluées par GLFW et intégrées dans les extensions re-
tournées par la fonction glfwGetRequiredInstanceExtensions.
Nous allons voir l’exemple de la création de la surface sous Windows, même
si nous n’utiliserons pas cette méthode. Il est en effet contre-productif
d’utiliser une librairie comme GLFW et un API comme Vulkan pour se
retrouver à écrire du code spécifique à la plateforme. La fonction de GLFW
glfwCreateWindowSurface permet de gérer les différences de plateforme.
Cet exemple ne servira ainsi qu’à présenter le travail de bas niveau, dont la
connaissance est toujours utile à une bonne utilisation de Vulkan.
Une window surface est un objet Vulkan comme un autre et nécessite donc de
remplir une structure, ici VkWin32SurfaceCreateInfoKHR. Elle possède deux
paramètres importants : hwnd et hinstance. Ce sont les références à la fenêtre
et au processus courant.
1 VkWin32SurfaceCreateInfoKHR createInfo{};
2 createInfo.sType = VK_STRUCTURE_TYPE_WIN32_SURFACE_CREATE_INFO_KHR;
3 createInfo.hwnd = glfwGetWin32Window(window);
4 createInfo.hinstance = GetModuleHandle(nullptr);
Nous pouvons extraire HWND de la fenêtre à l’aide de la fonction glfwGetWin32Window.
La fonction GetModuleHandle fournit une référence au HINSTANCE du thread
courant.
La surface peut maintenant être crée avec vkCreateWin32SurfaceKHR. Cette
fonction prend en paramètre une instance, des détails sur la création de la
surface, l’allocateur optionnel et la variable dans laquelle placer la référence.
Bien que cette fonction fasse partie d’une extension, elle est si communément
utilisée qu’elle est chargée par défaut par Vulkan. Nous n’avons ainsi pas à la
charger à la main :
1 if (vkCreateWin32SurfaceKHR(instance, &createInfo, nullptr,
&surface) != VK_SUCCESS) {
67
2 throw std::runtime_error("échec de la creation d'une window
surface!");
3 }
Ce processus est similaire pour Linux, où la fonction vkCreateXcbSurfaceKHR
requiert la fenêtre et une connexion à XCB comme paramètres pour X11.
La fonction glfwCreateWindowSurface implémente donc tout cela pour nous
et utilise le code correspondant à la bonne plateforme. Nous devons maintenant
l’intégrer à notre programme. Ajoutez la fonction createSurface et appelez-la
dans initVulkan après la création de l’instance et du messager :
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 createSurface();
5 pickPhysicalDevice();
6 createLogicalDevice();
7 }
8
9 void createSurface() {
10
11 }
L’appel à la fonction fournie par GLFW ne prend que quelques paramètres au
lieu d’une structure, ce qui rend le tout très simple :
1 void createSurface() {
2 if (glfwCreateWindowSurface(instance, window, nullptr, &surface)
!= VK_SUCCESS) {
3 throw std::runtime_error("échec de la création de la window
surface!");
4 }
5 }
Les paramètres sont l’instance, le pointeur sur la fenêtre, l’allocateur optionnel
et un pointeur sur une variable de type VkSurfaceKHR. GLFW ne fournit aucune
fonction pour détruire cette surface mais nous pouvons le faire nous-mêmes avec
une simple fonction Vulkan :
1 void cleanup() {
2 ...
3 vkDestroySurfaceKHR(instance, surface, nullptr);
4 vkDestroyInstance(instance, nullptr);
5 ...
6 }
Détruisez bien la surface avant l’instance.
68
Demander le support pour la présentation
Bien que l’implémentation de Vulkan supporte le WSI, il est possible que
d’autres éléments du système ne le supportent pas. Nous devons donc allonger
isDeviceSuitable pour s’assurer que le logical device puisse présenter les
rendus à la surface que nous avons créée. La présentation est spécifique aux
queues families, ce qui signifie que nous devons en fait trouver une queue family
supportant cette présentation.
Il est possible que les queue families supportant les commandes d’affichage et
celles supportant les commandes de présentation ne soient pas les mêmes, nous
devons donc considérer que ces deux queues sont différentes. En fait, les spéci-
ficités des queues families diffèrent majoritairement entre les vendeurs, et assez
peu entre les modèles d’une même série. Nous devons donc étendre la structure
QueueFamilyIndices :
1 struct QueueFamilyIndices {
2 std::optional<uint32_t> graphicsFamily;
3 std::optional<uint32_t> presentFamily;
4
5 bool isComplete() {
6 return graphicsFamily.has_value() &&
presentFamily.has_value();
7 }
8 };
Nous devons ensuite modifier la fonction findQueueFamilies pour qu’elle
cherche une queue family pouvant supporter les commandes de présentation. La
fonction qui nous sera utile pour cela est vkGetPhysicalDeviceSurfaceSupportKHR.
Elle possède quatre paramètres, le physical device, un indice de queue fam-
ily, la surface et un booléen. Appelez-la depuis la même boucle que pour
VK_QUEUE_GRAPHICS_BIT :
1 VkBool32 presentSupport = false;
2 vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface,
&presentSupport);
Vérifiez simplement la valeur du booléen et stockez la queue dans la structure
si elle est intéressante :
1 if (presentSupport) {
2 indices.presentFamily = i;
3 }
Il est très probable que ces deux queue families soient en fait les mêmes, mais
nous les traiterons comme si elles étaient différentes pour une meilleure com-
patibilité. Vous pouvez cependant ajouter un alorithme préférant des queues
combinées pour améliorer légèrement les performances.
69
Création de la queue de présentation (presentation queue)
Il nous reste à modifier la création du logical device pour extraire de celui-ci la
référence à une presentation queue VkQueue. Ajoutez un membre donnée pour
cette référence :
1 VkQueue presentQueue;
Nous avons besoin de plusieurs structures VkDeviceQueueCreateInfo, une pour
chaque queue family. Une manière de gérer ces structures est d’utiliser un set
contenant tous les indices des queues et un vector pour les structures :
1 #include <set>
2
3 ...
4
5 QueueFamilyIndices indices = findQueueFamilies(physicalDevice);
6
7 std::vector<VkDeviceQueueCreateInfo> queueCreateInfos;
8 std::set<uint32_t> uniqueQueueFamilies =
{indices.graphicsFamily.value(), indices.presentFamily.value()};
9
10 float queuePriority = 1.0f;
11 for (uint32_t queueFamily : uniqueQueueFamilies) {
12 VkDeviceQueueCreateInfo queueCreateInfo{};
13 queueCreateInfo.sType =
VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
14 queueCreateInfo.queueFamilyIndex = queueFamily;
15 queueCreateInfo.queueCount = 1;
16 queueCreateInfo.pQueuePriorities = &queuePriority;
17 queueCreateInfos.push_back(queueCreateInfo);
18 }
Puis modifiez VkDeviceCreateInfo pour qu’il pointe sur le contenu du vector :
1 createInfo.queueCreateInfoCount =
static_cast<uint32_t>(queueCreateInfos.size());
2 createInfo.pQueueCreateInfos = queueCreateInfos.data();
Si les queues sont les mêmes, nous n’avons besoin de les indiquer qu’une seule
fois, ce dont le set s’assure. Ajoutez enfin un appel pour récupérer les queue
families :
1 vkGetDeviceQueue(device, indices.presentFamily.value(), 0,
&presentQueue);
Si les queues sont les mêmes, les variables contenant les références contiennent
la même valeur. Dans le prochain chapitre nous nous intéresserons aux swap
chain, et verrons comment elle permet de présenter les rendus à l’écran.
70
Code C++
Swap chain
Vulkan ne possède pas de concept comme le framebuffer par défaut, et nous
devons donc créer une infrastructure qui contiendra les buffers sur lesquels nous
effectuerons les rendus avant de les présenter à l’écran. Cette infrastructure
s’appelle swap chain sur Vulkan et doit être créée explicitement. La swap chain
est essentiellement une file d’attente d’images attendant d’être affichées. Notre
application devra récupérer une des images de la file, dessiner dessus puis la
retourner à la file d’attente. Le fonctionnement de la file d’attente et les condi-
tions de la présentation dépendent du paramétrage de la swap chain. Cependant,
l’intérêt principal de la swap chain est de synchroniser la présentation avec le
rafraîchissement de l’écran.
Vérification du support de la swap chain
Toutes les cartes graphiques ne sont pas capables de présenter directement les
images à l’écran, et ce pour différentes raisons. Ce pourrait être car elles sont
destinées à être utilisées dans un serveur et n’ont aucune sortie vidéo. De plus,
dans la mesure où la présentation est très dépendante du gestionnaire de fenêtres
et de la surface, la swap chain ne fait pas partie de Vulkan “core”. Il faudra
donc utiliser des extensions, dont VK_KHR_swapchain.
Pour cela nous allons modifier isDeviceSuitable pour qu’elle vérifie si
cette extension est supportée. Nous avons déjà vu comment lister les
extensions supportées par un VkPhysicalDevice donc cette modification
devrait être assez simple. Notez que le header Vulkan intègre la macro
VK_KHR_SWAPCHAIN_EXTENSION_NAME qui permet d’éviter une faute de frappe.
Toutes les extensions ont leur macro.
Déclarez d’abord une liste d’extensions nécessaires au physical device, comme
nous l’avons fait pour les validation layers :
1 const std::vector<const char*> deviceExtensions = {
2 VK_KHR_SWAPCHAIN_EXTENSION_NAME
3 };
Créez ensuite une nouvelle fonction appelée checkDeviceExtensionSupport et
appelez-la depuis isDeviceSuitable comme vérification supplémentaire :
1 bool isDeviceSuitable(VkPhysicalDevice device) {
2 QueueFamilyIndices indices = findQueueFamilies(device);
3
4 bool extensionsSupported = checkDeviceExtensionSupport(device);
5
6 return indices.isComplete() && extensionsSupported;
7 }
71
8
9 bool checkDeviceExtensionSupport(VkPhysicalDevice device) {
10 return true;
11 }
Énumérez les extensions et vérifiez si toutes les extensions requises en font partie.
1 bool checkDeviceExtensionSupport(VkPhysicalDevice device) {
2 uint32_t extensionCount;
3 vkEnumerateDeviceExtensionProperties(device, nullptr,
&extensionCount, nullptr);
4
5 std::vector<VkExtensionProperties>
availableExtensions(extensionCount);
6 vkEnumerateDeviceExtensionProperties(device, nullptr,
&extensionCount, availableExtensions.data());
7
8 std::set<std::string>
requiredExtensions(deviceExtensions.begin(),
deviceExtensions.end());
9
10 for (const auto& extension : availableExtensions) {
11 requiredExtensions.erase(extension.extensionName);
12 }
13
14 return requiredExtensions.empty();
15 }
J’ai décidé d’utiliser une collection de strings pour représenter les extensions req-
uises en attente de confirmation. Nous pouvons ainsi facilement les éliminer en
énumérant la séquence. Vous pouvez également utiliser des boucles imbriquées
comme dans checkValidationLayerSupport, car la perte en performance n’est
pas capitale dans cette phase de chargement. Lancez le code et vérifiez que votre
carte graphique est capable de gérer une swap chain. Normalement la disponi-
bilité de la queue de présentation implique que l’extension de la swap chain est
supportée. Mais soyons tout de mêmes explicites pour cela aussi.
Activation des extensions du device
L’utilisation de la swap chain nécessite l’extension VK_KHR_swapchain. Son
activation ne requiert qu’un léger changement à la structure de création du
logical device :
1 createInfo.enabledExtensionCount =
static_cast<uint32_t>(deviceExtensions.size());
2 createInfo.ppEnabledExtensionNames = deviceExtensions.data();
Supprimez bien l’ancienne ligne createInfo.enabledExtensionCount = 0;.
72
Récupérer des détails à propos du support de la swap chain
Vérifier que la swap chain est disponible n’est pas suffisant. Nous devons vérifier
qu’elle est compatible avec notre surface de fenêtre. La création de la swap chain
nécessite un nombre important de paramètres, et nous devons récupérer encore
d’autres détails pour pouvoir continuer.
Il y a trois types de propriétés que nous devrons vérifier :
• Possibilités basiques de la surface (nombre min/max d’images dans la swap
chain, hauteur/largeur min/max des images)
• Format de la surface (format des pixels, palette de couleur)
• Mode de présentation disponibles
Nous utiliserons une structure comme celle dans findQueueFamilies pour con-
tenir ces détails une fois qu’ils auront été récupérés. Les trois catégories men-
tionnées plus haut se présentent sous la forme de la structure et des listes de
structures suivantes :
1 struct SwapChainSupportDetails {
2 VkSurfaceCapabilitiesKHR capabilities;
3 std::vector<VkSurfaceFormatKHR> formats;
4 std::vector<VkPresentModeKHR> presentModes;
5 };
Créons maintenant une nouvelle fonction querySwapChainSupport qui remplira
cette structure :
1 SwapChainSupportDetails querySwapChainSupport(VkPhysicalDevice
device) {
2 SwapChainSupportDetails details;
3
4 return details;
5 }
Cette section couvre la récupération des structures. Ce qu’elles signifient sera
expliqué dans la section suivante.
Commençons par les capacités basiques de la texture. Il suffit de demander ces
informations et elles nous seront fournies sous la forme d’une structure du type
VkSurfaceCapabilitiesKHR.
1 vkGetPhysicalDeviceSurfaceCapabilitiesKHR(device, surface,
&details.capabilities);
Cette fonction requiert que le physical device et la surface de fenêtre soient
passées en paramètres, car elle en extrait ces capacités. Toutes les fonctions
récupérant des capacités de la swap chain demanderont ces paramètres, car ils
en sont les composants centraux.
73
La prochaine étape est de récupérer les formats de texture supportés. Comme
c’est une liste de structure, cette acquisition suit le rituel des deux étapes :
1 uint32_t formatCount;
2 vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount,
nullptr);
3
4 if (formatCount != 0) {
5 details.formats.resize(formatCount);
6 vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface,
&formatCount, details.formats.data());
7 }
Finalement, récupérer les modes de présentation supportés suit le même principe
et utilise vkGetPhysicalDeviceSurfacePresentModesKHR :
1 uint32_t presentModeCount;
2 vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface,
&presentModeCount, nullptr);
3
4 if (presentModeCount != 0) {
5 details.presentModes.resize(presentModeCount);
6 vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface,
&presentModeCount, details.presentModes.data());
7 }
Tous les détails sont dans des structures, donc étendons isDeviceSuitable une
fois de plus et utilisons cette fonction pour vérifier que le support de la swap
chain nous correspond. Nous ne demanderons que des choses très simples dans
ce tutoriel.
1 bool swapChainAdequate = false;
2 if (extensionsSupported) {
3 SwapChainSupportDetails swapChainSupport =
querySwapChainSupport(device);
4 swapChainAdequate = !swapChainSupport.formats.empty() &&
!swapChainSupport.presentModes.empty();
5 }
Il est important de ne vérifier le support de la swap chain qu’après s’être assuré
que l’extension est disponible. La dernière ligne de la fonction devient donc :
1 return indices.isComplete() && extensionsSupported &&
swapChainAdequate;
Choisir les bons paramètres pour la swap chain
Si la fonction swapChainAdequate retourne true le support de la swap chain
est assuré. Il existe cependant encore plusieurs modes ayant chacun leur intérêt.
74
Nous allons maintenant écrire quelques fonctions qui détermineront les bons
paramètres pour obtenir la swap chain la plus efficace possible. Il y a trois
types de paramètres à déterminer :
• Format de la surface (profondeur de la couleur)
• Modes de présentation (conditions de “l’échange” des images avec l’écran)
• Swap extent (résolution des images dans la swap chain)
Pour chacun de ces paramètres nous aurons une valeur idéale que nous choisirons
si elle est disponible, sinon nous nous rabattrons sur ce qui nous restera de
mieux.
Format de la surface La fonction utilisée pour déterminer ce paramètre
commence ainsi. Nous lui passerons en argument le membre donnée formats
de la structure SwapChainSupportDetails.
1 VkSurfaceFormatKHR chooseSwapSurfaceFormat(const
std::vector<VkSurfaceFormatKHR>& availableFormats) {
2
3 }
Chaque VkSurfaceFormatKHR contient les données format et colorSpace. Le
format indique les canaux de couleur disponibles et les types qui contiennent
les valeurs des gradients. Par exemple VK_FORMAT_B8G8R8A8_SRGB signifie que
nous stockons les canaux de couleur R, G, B et A dans cet ordre et en entiers
non signés de 8 bits. colorSpace permet de vérifier que le sRGB est supporté
en utilisant le champ de bits VK_COLOR_SPACE_SRGB_NONLINEAR_KHR.
Pour l’espace de couleur nous utiliserons sRGB si possible, car il en résulte un
rendu plus réaliste. Le format le plus commun est VK_FORMAT_B8G8R8A8_SRGB.
Itérons dans la liste et voyons si le meilleur est disponible :
1 for (const auto& availableFormat : availableFormats) {
2 if (availableFormat.format == VK_FORMAT_B8G8R8A8_SRGB &&
availableFormat.colorSpace ==
VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {
3 return availableFormat;
4 }
5 }
Si cette approche échoue aussi nous pourrions trier les combinaisons disponibles,
mais pour rester simple nous prendrons le premier format disponible.
1 VkSurfaceFormatKHR chooseSwapSurfaceFormat(const
std::vector<VkSurfaceFormatKHR>& availableFormats) {
2
3 for (const auto& availableFormat : availableFormats) {
75
4 if (availableFormat.format == VK_FORMAT_B8G8R8A8_SRGB &&
availableFormat.colorSpace ==
VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {
5 return availableFormat;
6 }
7 }
8
9 return availableFormats[0];
10 }
Mode de présentation Le mode de présentation est clairement le paramètre
le plus important pour la swap chain, car il touche aux conditions d’affichage
des images à l’écran. Il existe quatre modes avec Vulkan :
• VK_PRESENT_MODE_IMMEDIATE_KHR : les images émises par votre appli-
cation sont directement envoyées à l’écran, ce qui peut produire des
déchirures (tearing).
• VK_PRESENT_MODE_FIFO_KHR : la swap chain est une file d’attente, et
l’écran récupère l’image en haut de la pile quand il est rafraîchi, alors que
le programme insère ses nouvelles images à l’arrière. Si la queue est pleine
le programme doit attendre. Ce mode est très similaire à la synchronisa-
tion verticale utilisée par la plupart des jeux vidéo modernes. L’instant
durant lequel l’écran est rafraichi s’appelle l’intervalle de rafraîchissement
vertical (vertical blank).
• VK_PRESENT_MODE_FIFO_RELAXED_KHR : ce mode ne diffère du précédent
que si l’application est en retard et que la queue est vide pendant le vertical
blank. Au lieu d’attendre le prochain vertical blank, une image arrivant
dans la file d’attente sera immédiatement transmise à l’écran.
• VK_PRESENT_MODE_MAILBOX_KHR : ce mode est une autre variation du
second mode. Au lieu de bloquer l’application quand le file d’attente est
pleine, les images présentes dans la queue sont simplement remplacées
par de nouvelles. Ce mode peut être utilisé pour implémenter le triple
buffering, qui vous permet d’éliminer le tearing tout en réduisant le temps
de latence entre le rendu et l’affichage qu’une file d’attente implique.
Seul VK_PRESENT_MODE_FIFO_KHR est toujours disponible. Nous aurons donc en-
core à écrire une fonction pour réaliser un choix, car le mode que nous choisirons
préférentiellement est VK_PRESENT_MODE_MAILBOX_KHR :
1 VkPresentModeKHR chooseSwapPresentMode(const
std::vector<VkPresentModeKHR> &availablePresentModes) {
2 return VK_PRESENT_MODE_FIFO_KHR;
3 }
Je pense que le triple buffering est un très bon compromis. Vérifions si ce mode
est disponible :
76
1 VkPresentModeKHR chooseSwapPresentMode(const
std::vector<VkPresentModeKHR> &availablePresentModes) {
2 for (const auto& availablePresentMode : availablePresentModes) {
3 if (availablePresentMode == VK_PRESENT_MODE_MAILBOX_KHR) {
4 return availablePresentMode;
5 }
6 }
7
8 return VK_PRESENT_MODE_FIFO_KHR;
9 }
Le swap extent Il ne nous reste plus qu’une propriété, pour laquelle nous
allons créer encore une autre fonction :
1 VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR&
capabilities) {
2
3 }
Le swap extent donne la résolution des images dans la swap chain et
correspond quasiment toujours à la résolution de la fenêtre que nous util-
isons. L’étendue des résolutions disponibles est définie dans la structure
VkSurfaceCapabilitiesKHR. Vulkan nous demande de faire correspondre
notre résolution à celle de la fenêtre fournie par le membre currentExtent.
Cependant certains gestionnaires de fenêtres nous permettent de choisir une
résolution différente, ce que nous pouvons détecter grâce aux membres width
et height qui sont alors égaux à la plus grande valeur d’un uint32_t. Dans ce
cas nous choisirons la résolution correspondant le mieux à la taille de la fenêtre,
dans les bornes de minImageExtent et maxImageExtent.
1 #include <cstdint> // uint32_t
2 #include <limits> // std::numeric_limits
3 #include <algorithm> // std::clamp
4
5 ...
6
7 VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR&
capabilities) {
8 if (capabilities.currentExtent.width !=
std::numeric_limits<uint32_t>::max()) {
9 return capabilities.currentExtent;
10 } else {
11 VkExtent2D actualExtent = {WIDTH, HEIGHT};
12
13 actualExtent.width = std::clamp(actualExtent.width,
capabilities.minImageExtent.width,
capabilities.maxImageExtent.width);
77
14 actualExtent.height = std::clamp(actualExtent.height,
capabilities.minImageExtent.height,
capabilities.maxImageExtent.height);
15
16 return actualExtent;
17 }
18 }
La fonction clamp est utilisée ici pour limiter les valeurs WIDTH et HEIGHT entre
le minimum et le maximum supportés par l’implémentation.
Création de la swap chain
Maintenant que nous avons toutes ces fonctions nous pouvons enfin acquérir
toutes les informations nécessaires à la création d’une swap chain.
Créez une fonction createSwapChain. Elle commence par récupérer les résultats
des fonctions précédentes. Appelez-la depuis initVulkan après la création du
logical device.
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 createSurface();
5 pickPhysicalDevice();
6 createLogicalDevice();
7 createSwapChain();
8 }
9
10 void createSwapChain() {
11 SwapChainSupportDetails swapChainSupport =
querySwapChainSupport(physicalDevice);
12
13 VkSurfaceFormatKHR surfaceFormat =
chooseSwapSurfaceFormat(swapChainSupport.formats);
14 VkPresentModeKHR presentMode =
chooseSwapPresentMode(swapChainSupport.presentModes);
15 VkExtent2D extent =
chooseSwapExtent(swapChainSupport.capabilities);
16 }
Il nous reste une dernière chose à faire : déterminer le nombre d’images dans la
swap chain. L’implémentation décide d’un minimum nécessaire pour fonctionner
:
1 uint32_t imageCount = swapChainSupport.capabilities.minImageCount;
78
Se contenter du minimum pose cependant un problème. Il est possible que le
driver fasse attendre notre programme car il n’a pas fini certaines opérations,
ce que nous ne voulons pas. Il est recommandé d’utiliser au moins une image
de plus que ce minimum :
1 uint32_t imageCount = swapChainSupport.capabilities.minImageCount +
1;
Il nous faut également prendre en compte le maximum d’images supportées par
l’implémentation. La valeur 0 signifie qu’il n’y a pas de maximum autre que la
mémoire.
1 if (swapChainSupport.capabilities.maxImageCount > 0 && imageCount >
swapChainSupport.capabilities.maxImageCount) {
2 imageCount = swapChainSupport.capabilities.maxImageCount;
3 }
Comme la tradition le veut avec Vulkan, la création d’une swap chain nécessite
de remplir une grande structure. Elle commence de manière familière :
1 VkSwapchainCreateInfoKHR createInfo{};
2 createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
3 createInfo.surface = surface;
Après avoir indiqué la surface à laquelle la swap chain doit être liée, les détails
sur les images de la swap chain doivent être fournis :
1 createInfo.minImageCount = imageCount;
2 createInfo.imageFormat = surfaceFormat.format;
3 createInfo.imageColorSpace = surfaceFormat.colorSpace;
4 createInfo.imageExtent = extent;
5 createInfo.imageArrayLayers = 1;
6 createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;
Le membre imageArrayLayers indique le nombre de couches que chaque im-
age possède. Ce sera toujours 1 sauf si vous développez une application stéréo-
scopique 3D. Le champ de bits imageUsage spécifie le type d’opérations que nous
appliquerons aux images de la swap chain. Dans ce tutoriel nous effectuerons
un rendu directement sur les images, nous les utiliserons donc comme color
attachement. Vous voudrez peut-être travailler sur une image séparée pour pou-
voir appliquer des effets en post-processing. Dans ce cas vous devrez utiliser
une valeur comme VK_IMAGE_USAGE_TRANSFER_DST_BIT à la place et utiliser
une opération de transfert de mémoire pour placer le résultat final dans une
image de la swap chain.
1 QueueFamilyIndices indices = findQueueFamilies(physicalDevice);
2 uint32_t queueFamilyIndices[] = {indices.graphicsFamily.value(),
indices.presentFamily.value()};
79
3
4 if (indices.graphicsFamily != indices.presentFamily) {
5 createInfo.imageSharingMode = VK_SHARING_MODE_CONCURRENT;
6 createInfo.queueFamilyIndexCount = 2;
7 createInfo.pQueueFamilyIndices = queueFamilyIndices;
8 } else {
9 createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
10 createInfo.queueFamilyIndexCount = 0; // Optionnel
11 createInfo.pQueueFamilyIndices = nullptr; // Optionnel
12 }
Nous devons ensuite indiquer comment les images de la swap chain seront util-
isées dans le cas où plusieurs queues seront à l’origine d’opérations. Cela sera
le cas si la queue des graphismes n’est pas la même que la queue de présenta-
tion. Nous devrons alors dessiner avec la graphics queue puis fournir l’image à
la presentation queue. Il existe deux manières de gérer les images accédées par
plusieurs queues :
• VK_SHARING_MODE_EXCLUSIVE : une image n’est accesible que par une
queue à la fois et sa gestion doit être explicitement transférée à une autre
queue pour pouvoir être utilisée. Cette option offre le maximum de per-
formances.
• VK_SHARING_MODE_CONCURRENT : les images peuvent être simplement util-
isées par différentes queue families.
Si nous avons deux queues différentes, nous utiliserons le mode concurrent
pour éviter d’ajouter un chapitre sur la possession des ressources, car cela né-
cessite des concepts que nous ne pourrons comprendre correctement que plus
tard. Le mode concurrent vous demande de spécifier à l’avance les queues qui
partageront les images en utilisant les paramètres queueFamilyIndexCount et
pQueueFamilyIndices. Si les graphics queue et presentation queue sont les
mêmes, ce qui est le cas sur la plupart des cartes graphiques, nous devons rester
sur le mode exclusif car le mode concurrent requiert au moins deux queues
différentes.
1 createInfo.preTransform =
swapChainSupport.capabilities.currentTransform;
Nous pouvons spécifier une transformation à appliquer aux images quand
elles entrent dans la swap chain si cela est supporté (à vérifier avec
supportedTransforms dans capabilities), comme par exemple une ro-
tation de 90 degrés ou une symétrie verticale. Si vous ne voulez pas de
transformation, spécifiez la transformation actuelle.
1 createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
Le champ compositeAlpha indique si le canal alpha doit être utilisé pour
mélanger les couleurs avec celles des autres fenêtres. Vous voudrez quasiment
80
tout le temps ignorer cela, et indiquer VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR
:
1 createInfo.presentMode = presentMode;
2 createInfo.clipped = VK_TRUE;
Le membre presentMode est assez simple. Si le membre clipped est activé
avec VK_TRUE alors les couleurs des pixels masqués par d’autres fenêtres seront
ignorées. Si vous n’avez pas un besoin particulier de lire ces informations, vous
obtiendrez de meilleures performances en activant ce mode.
1 createInfo.oldSwapchain = VK_NULL_HANDLE;
Il nous reste un dernier champ, oldSwapChain. Il est possible avec Vulkan que
la swap chain devienne invalide ou mal adaptée pendant que votre application
tourne, par exemple parce que la fenêtre a été redimensionnée. Dans ce cas la
swap chain doit être intégralement recréée et une référence à l’ancienne swap
chain doit être fournie. C’est un sujet compliqué que nous aborderons dans un
chapitre futur. Pour le moment, considérons que nous ne devrons jamais créer
qu’une swap chain.
Ajoutez un membre donnée pour stocker l’objet VkSwapchainKHR :
1 VkSwapchainKHR swapChain;
Créer la swap chain ne se résume plus qu’à appeler vkCreateSwapchainKHR :
1 if (vkCreateSwapchainKHR(device, &createInfo, nullptr, &swapChain)
!= VK_SUCCESS) {
2 throw std::runtime_error("échec de la création de la swap
chain!");
3 }
Les paramètres sont le logical device, la structure contenant les informa-
tions, l’allocateur optionnel et la variable contenant la référence à la swap
chain. Cet objet devra être explicitement détruit à l’aide de la fonction
vkDestroySwapchainKHR avant de détruire le logical device :
1 void cleanup() {
2 vkDestroySwapchainKHR(device, swapChain, nullptr);
3 ...
4 }
Lancez maintenant l’application et contemplez la création de la swap chain!
Si vous obtenez une erreur de violation d’accès dans vkCreateSwapchainKHR
ou voyez un message comme Failed to find 'vkGetInstanceProcAddress'
in layer SteamOverlayVulkanLayer.ddl, allez voir la FAQ à propos de la
layer Steam.
81
Essayez de retirer la ligne createInfo.imageExtent = extent; avec les vali-
dation layers actives. Vous verrez que l’une d’entre elles verra l’erreur et un
message vous sera envoyé :
Récupérer les images de la swap chain
La swap chain est enfin créée. Il nous faut maintenant récupérer les références
aux VkImage dans la swap chain. Nous les utiliserons pour l’affichage et dans
les chapitres suivants. Ajoutez un membre donnée pour les stocker :
1 std::vector<VkImage> swapChainImages;
Ces images ont été créées par l’implémentation avec la swap chain et elles
seront automatiquement supprimées avec la destruction de la swap chain, nous
n’aurons donc rien à rajouter dans la fonction cleanup.
Ajoutons le code nécessaire à la récupération des références à la fin de
createSwapChain, juste après l’appel à vkCreateSwapchainKHR. Comme
notre logique n’a au final informé Vulkan que d’un minimum pour le nombre
d’images dans la swap chain, nous devons nous enquérir du nombre d’images
avant de redimensionner le conteneur.
1 vkGetSwapchainImagesKHR(device, swapChain, &imageCount, nullptr);
2 swapChainImages.resize(imageCount);
3 vkGetSwapchainImagesKHR(device, swapChain, &imageCount,
swapChainImages.data());
Une dernière chose : gardez dans des variables le format et le nombre d’images
de la swap chain, nous en aurons besoin dans de futurs chapitres.
1 VkSwapchainKHR swapChain;
2 std::vector<VkImage> swapChainImages;
3 VkFormat swapChainImageFormat;
4 VkExtent2D swapChainExtent;
5
6 ...
7
8 swapChainImageFormat = surfaceFormat.format;
9 swapChainExtent = extent;
Nous avons maintenant un ensemble d’images sur lesquelles nous pouvons tra-
vailler et qui peuvent être présentées pour être affichées. Dans le prochain
chapitre nous verrons comment utiliser ces images comme des cibles de rendu,
puis nous verrons le pipeline graphique et les commandes d’affichage!
Code C++
82
Image views
Quelque soit la VkImage que nous voulons utiliser, dont celles de la swap chain,
nous devons en créer une VkImageView pour la manipuler. Cette image view
correspond assez litéralement à une vue dans l’image. Elle décrit l’accès à
l’image et les parties de l’image à accéder. Par exemple elle indique si elle doit
être traitée comme une texture 2D pour la profondeur sans aucun niveau de
mipmapping.
Dans ce chapitre nous écrirons une fonction createImageViews pour créer une
image view basique pour chacune des images dans la swap chain, pour que nous
puissions les utiliser comme cibles de couleur.
Ajoutez d’abord un membre donnée pour y stocker une image view :
1 std::vector<VkImageView> swapChainImageViews;
Créez la fonction createImageViews et appelez-la juste après la création de la
swap chain.
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 createSurface();
5 pickPhysicalDevice();
6 createLogicalDevice();
7 createSwapChain();
8 createImageViews();
9 }
10
11 void createImageViews() {
12
13 }
Nous devons d’abord redimensionner la liste pour pouvoir y mettre toutes les
image views que nous créerons :
1 void createImageViews() {
2 swapChainImageViews.resize(swapChainImages.size());
3
4 }
Créez ensuite la boucle qui parcourra toutes les images de la swap chain.
1 for (size_t i = 0; i < swapChainImages.size(); i++) {
2
3 }
Les paramètres pour la création d’image views se spécifient dans la structure
VkImageViewCreateInfo. Les deux premiers paramètres sont assez simples :
83
1 VkImageViewCreateInfo createInfo{};
2 createInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
3 createInfo.image = swapChainImages[i];
Les champs viewType et format indiquent la manière dont les images doivent
être interprétées. Le paramètre viewType permet de traiter les images comme
des textures 1D, 2D, 3D ou cube map.
1 createInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
2 createInfo.format = swapChainImageFormat;
Le champ components vous permet d’altérer les canaux de couleur. Par exemple,
vous pouvez envoyer tous les canaux au canal rouge pour obtenir une texture
monochrome. Vous pouvez aussi donner les valeurs constantes 0 ou 1 à un canal.
Dans notre cas nous garderons les paramètres par défaut.
1 createInfo.components.r = VK_COMPONENT_SWIZZLE_IDENTITY;
2 createInfo.components.g = VK_COMPONENT_SWIZZLE_IDENTITY;
3 createInfo.components.b = VK_COMPONENT_SWIZZLE_IDENTITY;
4 createInfo.components.a = VK_COMPONENT_SWIZZLE_IDENTITY;
Le champ subresourceRange décrit l’utilisation de l’image et indique quelles
parties de l’image devraient être accédées. Notre image sera utilisée comme
cible de couleur et n’aura ni mipmapping ni plusieurs couches.
1 createInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
2 createInfo.subresourceRange.baseMipLevel = 0;
3 createInfo.subresourceRange.levelCount = 1;
4 createInfo.subresourceRange.baseArrayLayer = 0;
5 createInfo.subresourceRange.layerCount = 1;
Si vous travailliez sur une application 3D stéréoscopique, vous devrez alors créer
une swap chain avec plusieurs couches. Vous pourriez alors créer plusieurs image
views pour chaque image. Elles représenteront ce qui sera affiché pour l’œil
gauche et pour l’œil droit.
Créer l’image view ne se résume plus qu’à appeler vkCreateImageView :
1 if (vkCreateImageView(device, &createInfo, nullptr,
&swapChainImageViews[i]) != VK_SUCCESS) {
2 throw std::runtime_error("échec de la création d'une image
view!");
3 }
À la différence des images, nous avons créé les image views explicitement et
devons donc les détruire de la même manière, ce que nous faisons à l’aide d’une
boucle :
84
1 void cleanup() {
2 for (auto imageView : swapChainImageViews) {
3 vkDestroyImageView(device, imageView, nullptr);
4 }
5
6 ...
7 }
Une image view est suffisante pour commencer à utiliser une image comme
une texture, mais pas pour que l’image soit utilisée comme cible d’affichage.
Pour cela nous avons encore une étape, appelée framebuffer. Mais nous devons
d’abord mettre en place le pipeline graphique.
Code C++
Pipeline graphique basique
Introduction
Dans les chapitres qui viennent nous allons configurer une pipeline graphique
pour qu’elle affiche notre premier triangle. La pipeline graphique est l’ensemble
des opérations qui prennent les vertices et les textures de vos éléments et les
utilisent pour en faire des pixels sur les cibles d’affichage. Un résumé simplifié
ressemble à ceci :
85
L’input assembler collecte les données des sommets à partir des buffers que vous
avez mis en place, et peut aussi utiliser un index buffer pour répéter certains
éléments sans avoir à stocker deux fois les mêmes données dans un buffer.
Le vertex shader est exécuté pour chaque sommet et leur applique en général
86
des transformations pour que leurs coordonnées passent de l’espace du modèle
(model space) à l’espace de l’écran (screen space). Il fournit ensuite des données
à la suite de la pipeline.
Les tesselation shaders permettent de subdiviser la géométrie selon des règles
paramétrables afin d’améliorer la qualité du rendu. Ce procédé est notamment
utilisé pour que des surface comme les murs de briques ou les escaliers aient l’air
moins plats lorsque l’on s’en approche.
Le geometry shader est invoqué pour chaque primitive (triangle, ligne, points…)
et peut les détruire ou en créer de nouvelles, du même type ou non. Ce travail
est similaire au tesselation shader tout en étant beaucoup plus flexible. Il n’est
cependant pas beaucoup utilisé à cause de performances assez moyennes sur les
cartes graphiques (avec comme exception les GPUs intégrés d’Intel).
La rasterization transforme les primitives en fragments. Ce sont les pixels
auxquels les primitives correspondent sur le framebuffer. Tout fragment en
dehors de l’écran est abandonné. Les attributs sortant du vertex shader sont
interpolés lorsqu’ils sont donnés aux étapes suivantes. Les fragments cachés
par d’autres fragments sont aussi quasiment toujours éliminés grâce au test de
profondeur (depth testing).
Le fragment shader est invoqué pour chaque fragment restant et détermine à
quel(s) framebuffer(s) le fragment est envoyé, et quelles données y sont inscrites.
Il réalise ce travail à l’aide des données interpolées émises par le vertex shader,
ce qui inclut souvent des coordonnées de texture et des normales pour réaliser
des calculs d’éclairage.
Le color blending applique des opérations pour mixer différents fragments corre-
spondant à un même pixel sur le framebuffer. Les fragments peuvent remplacer
les valeurs des autres, s’additionner ou se mélanger selon les paramètres de
transparence (ou plus correctement de translucidité, en anglais translucency).
Les étapes écrites en vert sur le diagramme s’appellent fixed-function stages
(étapes à fonction fixée). Il est possible de modifier des paramètres influençant
les calculs, mais pas de modifier les calculs eux-mêmes.
Les étapes colorées en orange sont programmables, ce qui signifie que vous
pouvez charger votre propre code dans la carte graphique pour y appliquer
exactement ce que vous voulez. Cela vous permet par exemple d’utiliser les
fragment shaders pour implémenter n’importe quoi, de l’utilisation de textures
et d’éclairage jusqu’au ray tracing. Ces programmes tournent sur de nombreux
coeurs simultanément pour y traiter de nombreuses données en parallèle.
Si vous avez utilisé d’anciens APIs comme OpenGL ou Direct3D, vous êtes
habitués à pouvoir changer un quelconque paramètre de la pipeline à tout mo-
ment, avec des fonctions comme glBlendFunc ou OMSSetBlendState. Cela
n’est plus possible avec Vulkan. La pipeline graphique y est quasiment fixée,
et vous devrez en recréer une complètement si vous voulez changer de shader,
y attacher différents framebuffers ou changer le color blending. Devoir créer
87
une pipeline graphique pour chacune des combinaisons dont vous aurez besoin
tout au long du programme représente un gros travail, mais permet au driver
d’optimiser beaucoup mieux l’exécution des tâches car il sait à l’avance ce que
la carte graphique aura à faire.
Certaines étapes programmables sont optionnelles selon ce que vous voulez faire.
Par exemple la tesselation et le geometry shader peuvent être désactivés. Si
vous n’êtes intéressé que par les valeurs de profondeur vous pouvez désactiver
le fragment shader, ce qui est utile pour les shadow maps.
Dans le prochain chapitre nous allons d’abord créer deux étapes nécessaires à
l’affichage d’un triangle à l’écran : le vertex shader et le fragment shader. Les
étapes à fonction fixée seront mises en place dans le chapitre suivant. La dernière
préparation nécessaire à la mise en place de la pipeline graphique Vulkan sera
de fournir les framebuffers d’entrée et de sortie.
Créez la fonction createGraphicsPipeline et appelez-la depuis initVulkan
après createImageViews. Nous travaillerons sur cette fonction dans les
chapitres suivants.
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 createSurface();
5 pickPhysicalDevice();
6 createLogicalDevice();
7 createSwapChain();
8 createImageViews();
9 createGraphicsPipeline();
10 }
11
12 ...
13
14 void createGraphicsPipeline() {
15
16 }
Code C++
Modules shaders
À 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.
88
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 fonction-
nalité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 ex-
emple .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
89
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,
90
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 :
1 #version 450
2
3 vec2 positions[3] = vec2[](
4 vec2(0.0, -0.5),
5 vec2(0.5, 0.5),
6 vec2(-0.5, 0.5)
7 );
8
9 void main() {
10 gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
11 }
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 :
1 #version 450
2
3 layout(location = 0) out vec4 outColor;
4
5 void main() {
6 outColor = vec4(1.0, 0.0, 0.0, 1.0);
7 }
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ù
91
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 :
1 vec3 colors[3] = vec3[](
2 vec3(1.0, 0.0, 0.0),
3 vec3(0.0, 1.0, 0.0),
4 vec3(0.0, 0.0, 1.0)
5 );
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 vari-
able de sortie pour la couleur dans le vertex shader et donnez lui une valeur
dans le main:
1 layout(location = 0) out vec3 fragColor;
2
3 void main() {
4 gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
5 fragColor = colors[gl_VertexIndex];
6 }
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é :
92
1 layout(location = 0) in vec3 fragColor;
2
3 void main() {
4 outColor = vec4(fragColor, 1.0);
5 }
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:
1 #version 450
2
3 out gl_PerVertex {
4 vec4 gl_Position;
5 };
6
7 layout(location = 0) out vec3 fragColor;
8
9 vec2 positions[3] = vec2[](
10 vec2(0.0, -0.5),
11 vec2(0.5, 0.5),
12 vec2(-0.5, 0.5)
13 );
14
15 vec3 colors[3] = vec3[](
16 vec3(1.0, 0.0, 0.0),
17 vec3(0.0, 1.0, 0.0),
18 vec3(0.0, 0.0, 1.0)
19 );
20
21 void main() {
22 gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
23 fragColor = colors[gl_VertexIndex];
24 }
Et shader.frag devrait contenir :
1 #version 450
93
2
3 layout(location = 0) in vec3 fragColor;
4
5 layout(location = 0) out vec4 outColor;
6
7 void main() {
8 outColor = vec4(fragColor, 1.0);
9 }
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 :
1 C:/VulkanSDK/x.x.x.x/Bin32/glslc.exe shader.vert -o vert.spv
2 C:/VulkanSDK/x.x.x.x/Bin32/glslc.exe shader.frag -o frag.spv
3 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 :
1 /home/user/VulkanSDK/x.x.x.x/x86_64/bin/glslc shader.vert -o vert.spv
2 /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++.
94
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.
1 #include <fstream>
2
3 ...
4
5 static std::vector<char> readFile(const std::string& filename) {
6 std::ifstream file(filename, std::ios::ate | std::ios::binary);
7
8 if (!file.is_open()) {
9 throw std::runtime_error(std::string {"échec de l'ouverture
du fichier "} + filename + "!");
10 }
11 }
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 :
1 size_t fileSize = (size_t) file.tellg();
2 std::vector<char> buffer(fileSize);
Après cela nous revenons au début du fichier et lisons tous les octets d’un coup
:
1 file.seekg(0);
2 file.read(buffer.data(), fileSize);
Nous pouvons enfin fermer le fichier et retourner les octets :
1 file.close();
2
3 return buffer;
Appelons maintenant cette fonction depuis createGraphicsPipeline pour
charger les bytecodes des deux shaders :
1 void createGraphicsPipeline() {
95
2 auto vertShaderCode = readFile("shaders/vert.spv");
3 auto fragShaderCode = readFile("shaders/frag.spv");
4 }
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.
1 VkShaderModule createShaderModule(const std::vector<char>& code) {
2
3 }
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 aligne-
ment 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.
1 VkShaderModuleCreateInfo createInfo{};
2 createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
3 createInfo.codeSize = code.size();
4 createInfo.pCode = reinterpret_cast<const uint32_t*>(code.data());
Le VkShaderModule peut alors être créé en appelant la fonction vkCreateShaderModule
:
1 VkShaderModule shaderModule;
2 if (vkCreateShaderModule(device, &createInfo, nullptr,
&shaderModule) != VK_SUCCESS) {
3 throw std::runtime_error("échec de la création d'un module
shader!");
4 }
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
96
peut être libéré immédiatement après l’appel. Retournez enfin le shader module
créé :
1 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 :
1 void createGraphicsPipeline() {
2 auto vertShaderModule = createShaderModule(vertShaderCode);
3 fragShaderModule = createShaderModule(fragShaderCode);
4
5 vertShaderModule = createShaderModule(vertShaderCode);
6 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 :
1 ...
2 vkDestroyShaderModule(device, fragShaderModule, nullptr);
3 vkDestroyShaderModule(device, vertShaderModule, nullptr);
4 }
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.
1 VkPipelineShaderStageCreateInfo vertShaderStageInfo{};
2 vertShaderStageInfo.sType =
VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
3 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.
1 vertShaderStageInfo.module = vertShaderModule;
2 vertShaderStageInfo.pName = "main";
97
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
:
1 VkPipelineShaderStageCreateInfo fragShaderStageInfo{};
2 fragShaderStageInfo.sType =
VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
3 fragShaderStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;
4 fragShaderStageInfo.module = fragShaderModule;
5 fragShaderStageInfo.pName = "main";
Intégrez ces deux valeurs dans un tableau que nous utiliserons plus tard et vous
aurez fini ce chapitre!
1 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
Fonctions fixées
Les anciens APIs définissaient des configurations par défaut pour toutes les
étapes à fonction fixée de la pipeline graphique. Avec Vulkan vous devez être
explicite dans ce domaine également et devrez donc configurer la fonction de
mélange par exemple. Dans ce chapitre nous remplirons toutes les structures
nécessaires à la configuration des étapes à fonction fixée.
Entrée des sommets
La structure VkPipelineVertexInputStateCreateInfo décrit le format des
sommets envoyés au vertex shader. Elle fait cela de deux manières :
• Liens (bindings) : espace entre les données et information sur ces données;
sont-elles par sommet ou par instance? (voyez l’instanciation)
98
• Descriptions d’attributs : types d’attributs passés au vertex shader, de
quels bindings les charger et avec quel décalage entre eux.
Dans la mesure où nous avons écrit les coordonnées directement dans le vertex
shader, nous remplirons cette structure en indiquant qu’il n’y a aucune donnée
à charger. Nous y reviendrons dans le chapitre sur les vertex buffers.
1 VkPipelineVertexInputStateCreateInfo vertexInputInfo{};
2 vertexInputInfo.sType =
VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
3 vertexInputInfo.vertexBindingDescriptionCount = 0;
4 vertexInputInfo.pVertexBindingDescriptions = nullptr; // Optionnel
5 vertexInputInfo.vertexAttributeDescriptionCount = 0;
6 vertexInputInfo.pVertexAttributeDescriptions = nullptr; // Optionnel
Les membres pVertexBindingDescriptions et pVertexAttributeDescriptions
pointent vers un tableau de structures décrivant les détails du charge-
ment des données des sommets. Ajoutez cette structure à la fonction
createGraphicsPipeline juste après le tableau shaderStages.
Input assembly
La structure VkPipelineInputAssemblyStateCreateInfo décrit la nature de
la géométrie voulue quand les sommets sont reliés, et permet d’activer ou non la
réévaluation des vertices. La première information est décrite dans le membre
topology et peut prendre ces valeurs :
• VK_PRIMITIVE_TOPOLOGY_POINT_LIST : chaque sommet est un point
• VK_PRIMITIVE_TOPOLOGY_LINE_LIST : dessine une ligne liant deux som-
met en n’utilisant ces derniers qu’une seule fois
• VK_PRIMITIVE_TOPOLOGY_LINE_STRIP : le dernier sommet de chaque
ligne est utilisée comme premier sommet pour la ligne suivante
• VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST : dessine un triangle en util-
isant trois sommets, sans en réutiliser pour le triangle suivant
• VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP : le deuxième et troisième
sommets sont utilisées comme les deux premiers pour le triangle suivant
Les sommets sont normalement chargés séquentiellement depuis le vertex
buffer. Avec un element buffer vous pouvez cependant choisir vous-même
les indices à charger. Vous pouvez ainsi réaliser des optimisations, comme
n’utiliser une combinaison de sommet qu’une seule fois au lieu de d’avoir
les mêmes données plusieurs fois dans le buffer. Si vous mettez le membre
primitiveRestartEnable à la valeur VK_TRUE, il devient alors possible
d’interrompre les liaisons des vertices pour les modes _STRIP en utilisant
l’index spécial 0xFFFF ou 0xFFFFFFFF.
Nous n’afficherons que des triangles dans ce tutoriel, nous nous contenterons
donc de remplir la structure de cette manière :
99
1 VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
2 inputAssembly.sType =
VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
3 inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
4 inputAssembly.primitiveRestartEnable = VK_FALSE;
Viewports et ciseaux
Un viewport décrit simplement la région d’un framebuffer sur laquelle le rendu
sera effectué. Il couvrira dans la pratique quasiment toujours la totalité du
framebuffer, et ce sera le cas dans ce tutoriel.
1 VkViewport viewport{};
2 viewport.x = 0.0f;
3 viewport.y = 0.0f;
4 viewport.width = (float) swapChainExtent.width;
5 viewport.height = (float) swapChainExtent.height;
6 viewport.minDepth = 0.0f;
7 viewport.maxDepth = 1.0f;
N’oubliez pas que la taille des images de la swap chain peut différer des macros
WIDTH et HEIGHT. Les images de la swap chain seront plus tard les framebuffers
sur lesquels la pipeline opérera, ce que nous devons prendre en compte en don-
nant les dimensions dynamiquement acquises.
Les valeurs minDepth et maxDepth indiquent l’étendue des valeurs de profondeur
à utiliser pour le frambuffer. Ces valeurs doivent être dans [0.0f, 1.0f] mais
minDepth peut être supérieure à maxDepth. Si vous ne faites rien de particulier
contentez-vous des valeurs 0.0f et 1.0f.
Alors que les viewports définissent la transformation de l’image vers le frame-
buffer, les rectangles de ciseaux définissent la région de pixels qui sera conservée.
Tout pixel en dehors des rectangles de ciseaux seront éliminés par le rasterizer.
Ils fonctionnent plus comme un filtre que comme une transformation. Les dif-
férence sont illustrée ci-dessous. Notez que le rectangle de ciseau dessiné sous
l’image de gauche n’est qu’une des possibilités : tout rectangle plus grand que
le viewport aurait fonctionné.
100
Dans ce tutoriel nous voulons dessiner sur la totalité du framebuffer, et ce sans
transformation. Nous définirons donc un rectangle de ciseaux couvrant tout le
frambuffer :
1 VkRect2D scissor{};
2 scissor.offset = {0, 0};
3 scissor.extent = swapChainExtent;
Le viewport et le rectangle de ciseau se combinent en un viewport state à l’aide
de la structure VkPipelineViewportStateCreateInfo. Il est possible sur cer-
taines cartes graphiques d’utiliser plusieurs viewports et rectangles de ciseaux,
c’est pourquoi la structure permet d’envoyer des tableaux de ces deux données.
L’utilisation de cette possibilité nécessite de l’activer au préalable lors de la
création du logical device.
1 VkPipelineViewportStateCreateInfo viewportState{};
2 viewportState.sType =
VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
3 viewportState.viewportCount = 1;
4 viewportState.pViewports = &viewport;
5 viewportState.scissorCount = 1;
6 viewportState.pScissors = &scissor;
Rasterizer
Le rasterizer récupère la géométrie définie par des sommets et calcule les frag-
ments qu’elle recouvre. Ils sont ensuite traités par le fragment shaders. Il réalise
également un test de profondeur, le face culling et le test de ciseau pour vérifier
si le fragment doit effectivement être traité ou non. Il peut être configuré pour
émettre des fragments remplissant tous les polygones ou bien ne remplissant
101
que les cotés (wireframe rendering). Tout cela se configure dans la structure
VkPipelineRasterizationStateCreateInfo.
1 VkPipelineRasterizationStateCreateInfo rasterizer{};
2 rasterizer.sType =
VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
3 rasterizer.depthClampEnable = VK_FALSE;
Si le membre depthClampEnable est mis à VK_TRUE, les fragments au-delà des
plans near et far ne pas supprimés mais affichés à cette distance. Cela est
utile dans quelques situations telles que les shadow maps. Cela aussi doit être
explicitement activé lors de la mise en place du logical device.
1 rasterizer.rasterizerDiscardEnable = VK_FALSE;
Si le membre rasterizerDiscardEnable est mis à VK_TRUE, aucune géométrie
ne passe l’étape du rasterizer, ce qui désactive purement et simplement toute
émission de donnée vers le frambuffer.
1 rasterizer.polygonMode = VK_POLYGON_MODE_FILL;
Le membre polygonMode définit la génération des fragments pour la géométrie.
Les modes suivants sont disponibles :
• VK_POLYGON_MODE_FILL : remplit les polygones de fragments
• VK_POLYGON_MODE_LINE : les côtés des polygones sont dessinés comme des
lignes
• VK_POLYGON_MODE_POINT : les sommets sont dessinées comme des points
Tout autre mode que fill doit être activé lors de la mise en place du logical
device.
1 rasterizer.lineWidth = 1.0f;
Le membre lineWidth définit la largeur des lignes en terme de fragments. La
taille maximale supportée dépend du GPU et pour toute valeur autre que 1.0f
l’extension wideLines doit être activée.
1 rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
2 rasterizer.frontFace = VK_FRONT_FACE_CLOCKWISE;
Le membre cullMode détermine quel type de face culling utiliser. Vous pouvez
désactiver tout ce filtrage, n’éliminer que les faces de devant, que celles de
derrière ou éliminer toutes les faces. Le membre frontFace indique l’ordre
d’évaluation des vertices pour dire que la face est devant ou derrière, qui est le
sens des aiguilles d’une montre ou le contraire.
1 rasterizer.depthBiasEnable = VK_FALSE;
2 rasterizer.depthBiasConstantFactor = 0.0f; // Optionnel
3 rasterizer.depthBiasClamp = 0.0f; // Optionnel
4 rasterizer.depthBiasSlopeFactor = 0.0f; // Optionnel
102
Le rasterizer peut altérer la profondeur en y ajoutant une valeur constante ou
en la modifiant selon l’inclinaison du fragment. Ces possibilités sont parfois
exploitées pour le shadow mapping mais nous ne les utiliserons pas. Laissez
depthBiasEnabled à la valeur VK_FALSE.
Multisampling
La structure VkPipelineMultisampleCreateInfo configure le multisampling,
l’un des outils permettant de réaliser l’anti-aliasing. Le multisampling combine
les résultats d’invocations du fragment shader sur des fragments de différents
polygones qui résultent au même pixel. Cette superposition arrive plutôt sur
les limites entre les géométries, et c’est aussi là que les problèmes visuels de
hachage arrivent le plus. Dans la mesure où le fragment shader n’a pas besoin
d’être invoqué plusieurs fois si seul un polygone correspond à un pixel, cette
approche est beaucoup plus efficace que d’augmenter la résolution de la texture.
Son utilisation nécessite son activation au niveau du GPU.
1 VkPipelineMultisampleStateCreateInfo multisampling{};
2 multisampling.sType =
VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
3 multisampling.sampleShadingEnable = VK_FALSE;
4 multisampling.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
5 multisampling.minSampleShading = 1.0f; // Optionnel
6 multisampling.pSampleMask = nullptr; // Optionnel
7 multisampling.alphaToCoverageEnable = VK_FALSE; // Optionnel
8 multisampling.alphaToOneEnable = VK_FALSE; // Optionnel
Nous reverrons le multisampling plus tard, pour l’instant laissez-le désactivé.
Tests de profondeur et de pochoir
Si vous utilisez un buffer de profondeur (depth buffer) et/ou de pochoir (sten-
cil buffer) vous devez configurer les tests de profondeur et de pochoir avec la
structure VkPipelineDepthStencilStateCreateInfo. Nous n’avons aucun de
ces buffers donc nous indiquerons nullptr à la place d’une structure. Nous y
reviendrons au chapitre sur le depth buffering.
Color blending
La couleur donnée par un fragment shader doit être combinée avec la couleur
déjà présente dans le framebuffer. Cette opération s’appelle color blending et il
y a deux manières de la réaliser :
• Mélanger linéairement l’ancienne et la nouvelle couleur pour créer la
couleur finale
• Combiner l’ancienne et la nouvelle couleur à l’aide d’une opération bit à
bit
103
Il y a deux types de structures pour configurer le color blending. La première,
VkPipelineColorBlendAttachmentState, contient une configuration pour
chaque framebuffer et la seconde, VkPipelineColorBlendStateCreateInfo
contient les paramètres globaux pour ce color blending. Dans notre cas nous
n’avons qu’un seul framebuffer :
1 VkPipelineColorBlendAttachmentState colorBlendAttachment{};
2 colorBlendAttachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT |
VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT |
VK_COLOR_COMPONENT_A_BIT;
3 colorBlendAttachment.blendEnable = VK_FALSE;
4 colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_ONE; //
Optionnel
5 colorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ZERO; //
Optionnel
6 colorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD; // Optionnel
7 colorBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; //
Optionnel
8 colorBlendAttachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; //
Optionnel
9 colorBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD; // Optionnel
Cette structure spécifique de chaque framebuffer vous permet de configurer le
color blending. L’opération sera effectuée à peu près comme ce pseudocode le
montre :
1 if (blendEnable) {
2 finalColor.rgb = (srcColorBlendFactor * newColor.rgb)
<colorBlendOp> (dstColorBlendFactor * oldColor.rgb);
3 finalColor.a = (srcAlphaBlendFactor * newColor.a) <alphaBlendOp>
(dstAlphaBlendFactor * oldColor.a);
4 } else {
5 finalColor = newColor;
6 }
7
8 finalColor = finalColor & colorWriteMask;
Si blendEnable vaut VK_FALSE la nouvelle couleur du fragment shader est in-
scrite dans le framebuffer sans modification et sans considération de la valeur
déjà présente dans le framebuffer. Sinon les deux opérations de mélange sont
exécutées pour former une nouvelle couleur. Un AND binaire lui est appliquée
avec colorWriteMask pour déterminer les canaux devant passer.
L’utilisation la plus commune du mélange de couleurs utilise le canal alpha pour
déterminer l’opacité du matériau et donc le mélange lui-même. La couleur finale
devrait alors être calculée ainsi :
1 finalColor.rgb = newAlpha * newColor + (1 - newAlpha) * oldColor;
104
2 finalColor.a = newAlpha.a;
Avec cette méthode la valeur alpha correspond à une pondération pour la nou-
velle valeur par rapport à l’ancienne. Les paramètres suivants permettent de
faire exécuter ce calcul :
1 colorBlendAttachment.blendEnable = VK_TRUE;
2 colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA;
3 colorBlendAttachment.dstColorBlendFactor =
VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
4 colorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD;
5 colorBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE;
6 colorBlendAttachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO;
7 colorBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD;
Vous pouvez trouver toutes les opérations possibles dans les énumérations
VkBlendFactor et VkBlendOp dans la spécification.
La seconde structure doit posséder une référence aux structures spécifiques des
framebuffers. Vous pouvez également y indiquer des constantes utilisables lors
des opérations de mélange que nous venons de voir.
1 VkPipelineColorBlendStateCreateInfo colorBlending{};
2 colorBlending.sType =
VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
3 colorBlending.logicOpEnable = VK_FALSE;
4 colorBlending.logicOp = VK_LOGIC_OP_COPY; // Optionnel
5 colorBlending.attachmentCount = 1;
6 colorBlending.pAttachments = &colorBlendAttachment;
7 colorBlending.blendConstants[0] = 0.0f; // Optionnel
8 colorBlending.blendConstants[1] = 0.0f; // Optionnel
9 colorBlending.blendConstants[2] = 0.0f; // Optionnel
10 colorBlending.blendConstants[3] = 0.0f; // Optionnel
Si vous voulez utiliser la seconde méthode de mélange (la combinaison bit à
bit) vous devez indiquer VK_TRUE au membre logicOpEnable et déterminer
l’opération dans logicOp. Activer ce mode de mélange désactive automatique-
ment la première méthode aussi radicalement que si vous aviez indiqué VK_FALSE
au membre blendEnable de la précédente structure pour chaque framebuffer.
Le membre colorWriteMask sera également utilisé dans ce second mode pour
déterminer les canaux affectés. Il est aussi possible de désactiver les deux modes
comme nous l’avons fait ici. Dans ce cas les résultats des invocations du frag-
ment shader seront écrits directement dans le framebuffer.
États dynamiques
Un petit nombre d’états que nous avons spécifiés dans les structures précédentes
peuvent en fait être altérés sans avoir à recréer la pipeline. On y trouve la taille
105
du viewport, la largeur des lignes et les constantes de mélange. Pour cela vous
devrez remplir la structure VkPipelineDynamicStateCreateInfo comme suit :
1 std::vector<VkDynamicState> dynamicStates = {
2 VK_DYNAMIC_STATE_VIEWPORT,
3 VK_DYNAMIC_STATE_LINE_WIDTH
4 };
5
6 VkPipelineDynamicStateCreateInfo dynamicState{};
7 dynamicState.sType =
VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
8 dynamicState.dynamicStateCount =
static_cast<uint32_t>(dynamicStates.size());
9 dynamicState.pDynamicStates = dynamicStates.data();
Les valeurs données lors de la configuration seront ignorées et vous devrez en
fournir au moment du rendu. Nous y reviendrons plus tard. Cette structure
peut être remplacée par nullptr si vous ne voulez pas utiliser de dynamisme
sur ces états.
Pipeline layout
Les variables uniform dans les shaders sont des données globales similaires aux
états dynamiques. Elles doivent être déterminées lors du rendu pour altérer les
calculs des shaders sans avoir à les recréer. Elles sont très utilisées pour fournir
les matrices de transformation au vertex shader et pour créer des samplers de
texture dans les fragment shaders.
Ces variables doivent être configurées lors de la création de la pipeline en créant
une variable de type VkPipelineLayout. Même si nous n’en utilisons pas dans
nos shaders actuels nous devons en créer un vide.
Créez un membre donnée pour stocker la structure car nous en aurons besoin
plus tard.
1 VkPipelineLayout pipelineLayout;
Créons maintenant l’objet dans la fonction createGraphicsPipline :
1 VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
2 pipelineLayoutInfo.sType =
VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
3 pipelineLayoutInfo.setLayoutCount = 0; // Optionnel
4 pipelineLayoutInfo.pSetLayouts = nullptr; // Optionnel
5 pipelineLayoutInfo.pushConstantRangeCount = 0; // Optionnel
6 pipelineLayoutInfo.pPushConstantRanges = nullptr; // Optionnel
7
8 if (vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr,
&pipelineLayout) != VK_SUCCESS) {
106
9 throw std::runtime_error("échec de la création du pipeline
layout!");
10 }
Cette structure informe également sur les push constants, une autre manière
de passer des valeurs dynamiques au shaders que nous verrons dans un futur
chapitre. Le pipeline layout sera utilisé pendant toute la durée du programme,
nous devons donc le détruire dans la fonction cleanup :
1 void cleanup() {
2 vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
3 ...
4 }
Conclusion
Voila tout ce qu’il y a à savoir sur les étapes à fonction fixée! Leur configuration
représente un gros travail mais nous sommes au courant de tout ce qui se passe
dans la pipeline graphique, ce qui réduit les chances de comportement imprévu
à cause d’un paramètre par défaut oublié.
Il reste cependant encore un objet à créer avant du finaliser la pipeline graphique.
Cet objet s’appelle passe de rendu.
Code C++ / Vertex shader / Fragment shader
Render pass
Préparation
Avant de finaliser la création de la pipeline nous devons informer Vulkan des
attachements des framebuffers utilisés lors du rendu. Nous devons indiquer com-
bien chaque framebuffer aura de buffers de couleur et de profondeur, combien de
samples il faudra utiliser avec chaque frambuffer et comment les utiliser tout au
long des opérations de rendu. Toutes ces informations sont contenues dans un ob-
jet appelé render pass. Pour le configurer, créons la fonction createRenderPass.
Appelez cette fonction depuis initVulkan avant createGraphicsPipeline.
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 createSurface();
5 pickPhysicalDevice();
6 createLogicalDevice();
7 createSwapChain();
8 createImageViews();
9 createRenderPass();
10 createGraphicsPipeline();
107
11 }
12
13 ...
14
15 void createRenderPass() {
16
17 }
Description de l’attachement
Dans notre cas nous aurons un seul attachement de couleur, et c’est une image
de la swap chain.
1 void createRenderPass() {
2 VkAttachmentDescription colorAttachment{};
3 colorAttachment.format = swapChainImageFormat;
4 colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT;
5 }
Le format de l’attachement de couleur est le même que le format de l’image
de la swap chain. Nous n’utilisons pas de multisampling pour le moment donc
nous devons indiquer que nous n’utilisons qu’un seul sample.
1 colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
2 colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
Les membres loadOp et storeOp définissent ce qui doit être fait avec les données
de l’attachement respectivement avant et après le rendu. Pour loadOp nous
avons les choix suivants :
• VK_ATTACHMENT_LOAD_OP_LOAD : conserve les données présentes dans
l’attachement
• VK_ATTACHMENT_LOAD_OP_CLEAR : remplace le contenu par une constante
• VK_ATTACHMENT_LOAD_OP_DONT_CARE : ce qui existe n’est pas défini et ne
nous intéresse pas
Dans notre cas nous utiliserons l’opération de remplacement pour obtenir un
framebuffer noir avant d’afficher une nouvelle image. Il n’y a que deux possibil-
ités pour le membre storeOp :
• VK_ATTACHMENT_STORE_OP_STORE : le rendu est gardé en mémoire et ac-
cessible plus tard
• VK_ATTACHMENT_STORE_OP_DONT_CARE : le contenu du framebuffer est in-
défini dès la fin du rendu
Nous voulons voir le triangle à l’écran donc nous voulons l’opération de stockage.
1 colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
2 colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
108
Les membres loadOp et storeOp s’appliquent aux données de couleur et de
profondeur, et stencilLoadOp et stencilStoreOp s’appliquent aux données
de stencil. Notre application n’utilisant pas de stencil buffer, nous pouvons
indiquer que les données ne nous intéressent pas.
1 colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
2 colorAttachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
Les textures et les framebuffers dans Vulkan sont représentés par des objets de
type VkImage possédant un certain format de pixels. Cependant l’organisation
des pixels dans la mémoire peut changer selon ce que vous faites de cette image.
Les organisations les plus communes sont :
• VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL : images utilisées comme
attachements de couleur
• VK_IMAGE_LAYOUT_PRESENT_SRC_KHR : images présentées à une swap
chain
• VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL : image utilisées comme des-
tination d’opérations de copie de mémoire
Nous discuterons plus précisément de ce sujet dans le chapitre sur les textures.
Ce qui compte pour le moment est que les images doivent changer d’organisation
mémoire selon les opérations qui leur sont appliquées au long de l’exécution de
la pipeline.
Le membre initialLayout spécifie l’organisation de l’image avant le début
du rendu. Le membre finalLayout fournit l’organisation vers laquelle l’image
doit transitionner à la fin du rendu. La valeur VK_IMAGE_LAYOUT_UNDEFINED
indique que le format précédent de l’image ne nous intéresse pas, ce qui
peut faire perdre les données précédentes. Mais ce n’est pas un problème
puisque nous effaçons de toute façon toutes les données avant le rendu.
Puis, afin de rendre l’image compatible avec la swap chain, nous fournissons
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR pour finalLayout.
Subpasses et références aux attachements
Une unique passe de rendu est composée de plusieurs subpasses. Les subpasses
sont des opérations de rendu dépendant du contenu présent dans le framebuffer
quand elles commencent. Elles peuvent consister en des opérations de post-
processing exécutées l’une après l’autre. En regroupant toutes ces opérations
en une seule passe, Vulkan peut alors réaliser des optimisations et conserver de
la bande passante pour de potentiellement meilleures performances. Pour notre
triangle nous nous contenterons d’une seule subpasse.
Chacune d’entre elle référence un ou plusieurs attachements décrits par les struc-
tures que nous avons vues précédemment. Ces références sont elles-mêmes des
structures du type VkAttachmentReference et ressemblent à cela :
109
1 VkAttachmentReference colorAttachmentRef{};
2 colorAttachmentRef.attachment = 0;
3 colorAttachmentRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
Le paramètre attachment spécifie l’attachement à référencer à l’aide d’un
indice correspondant à la position de la structure dans le tableau de de-
scriptions d’attachements. Notre tableau ne consistera qu’en une seule
référence donc son indice est nécessairement 0. Le membre layout donne
l’organisation que l’attachement devrait avoir au début d’une subpasse util-
sant cette référence. Vulkan changera automatiquement l’organisation de
l’attachement quand la subpasse commence. Nous voulons que l’attachement
soit un color buffer, et pour cela la meilleure performance sera obtenue avec
VK_IMAGE_LAYOUT_COLOR_OPTIMAL, comme son nom le suggère.
La subpasse est décrite dans la structure VkSubpassDescription :
1 VkSubpassDescription subpass{};
2 subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
Vulkan supportera également des compute subpasses donc nous devons indiquer
que celle que nous créons est destinée aux graphismes. Nous spécifions ensuite
la référence à l’attachement de couleurs :
1 subpass.colorAttachmentCount = 1;
2 subpass.pColorAttachments = &colorAttachmentRef;
L’indice de cet attachement est indiqué dans le fragment shader avec le
location = 0 dans la directive layout(location = 0)out vec4 outColor.
Les types d’attachements suivants peuvent être indiqués dans une subpasse :
• pInputAttachments : attachements lus depuis un shader
• pResolveAttachments : attachements utilisés pour le multisampling
d’attachements de couleurs
• pDepthStencilAttachment : attachements pour la profondeur et le stencil
• pPreserveAttachments : attachements qui ne sont pas utilisés par cette
subpasse mais dont les données doivent être conservées
Passe de rendu
Maintenant que les attachements et une subpasse simple ont été décrits nous
pouvons enfin créer la render pass. Créez une nouvelle variable du type
VkRenderPass au-dessus de la variable pipelineLayout :
1 VkRenderPass renderPass;
2 VkPipelineLayout pipelineLayout;
L’objet représentant la render pass peut alors être créé en remplissant la struc-
ture VkRenderPassCreateInfo dans laquelle nous devons remplir un tableau
110
d’attachements et de subpasses. Les objets VkAttachmentReference référen-
cent les attachements en utilisant les indices de ce tableau.
1 VkRenderPassCreateInfo renderPassInfo{};
2 renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
3 renderPassInfo.attachmentCount = 1;
4 renderPassInfo.pAttachments = &colorAttachment;
5 renderPassInfo.subpassCount = 1;
6 renderPassInfo.pSubpasses = &subpass;
7
8 if (vkCreateRenderPass(device, &renderPassInfo, nullptr,
&renderPass) != VK_SUCCESS) {
9 throw std::runtime_error("échec de la création de la render
pass!");
10 }
Comme l’organisation de la pipeline, nous aurons à utiliser la référence à la
passe de rendu tout au long du programme. Nous devons donc la détruire dans
la fonction cleanup :
1 void cleanup() {
2 vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
3 vkDestroyRenderPass(device, renderPass, nullptr);
4 ...
5 }
Nous avons eu beaucoup de travail, mais nous allons enfin créer la pipeline
graphique et l’utiliser dès le prochain chapitre!
Code C++ / Vertex shader / Fragment shader
Conclusion
Nous pouvons maintenant combiner toutes les structures et tous les objets des
chapitres précédentes pour créer la pipeline graphique! Voici un petit récapitu-
latif des objets que nous avons :
• Étapes shader : les modules shader définissent le fonctionnement des
étapes programmables de la pipeline graphique
• Étapes à fonction fixée : plusieurs structures paramètrent les étapes à
fonction fixée comme l’assemblage des entrées, le rasterizer, le viewport et
le mélange des couleurs
• Organisation de la pipeline : les uniformes et push constants utilisées par
les shaders, auxquelles on attribue une valeur pendant l’exécution de la
pipeline
• Render pass : les attachements référencés par la pipeline et leurs utilisa-
tions
111
Tout cela combiné définit le fonctionnement de la pipeline graphique. Nous
pouvons maintenant remplir la structure VkGraphicsPipelineCreateInfo à la
fin de la fonction createGraphicsPipeline, mais avant les appels à la fonc-
tion vkDestroyShaderModule pour ne pas invalider les shaders que la pipeline
utilisera.
Commençons par référencer le tableau de VkPipelineShaderStageCreateInfo.
1 VkGraphicsPipelineCreateInfo pipelineInfo{};
2 pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
3 pipelineInfo.stageCount = 2;
4 pipelineInfo.pStages = shaderStages;
Puis donnons toutes les structure décrivant les étapes à fonction fixée.
1 pipelineInfo.pVertexInputState = &vertexInputInfo;
2 pipelineInfo.pInputAssemblyState = &inputAssembly;
3 pipelineInfo.pViewportState = &viewportState;
4 pipelineInfo.pRasterizationState = &rasterizer;
5 pipelineInfo.pMultisampleState = &multisampling;
6 pipelineInfo.pDepthStencilState = nullptr; // Optionnel
7 pipelineInfo.pColorBlendState = &colorBlending;
8 pipelineInfo.pDynamicState = nullptr; // Optionnel
Après cela vient l’organisation de la pipeline, qui est une référence à un objet
Vulkan plutôt qu’une structure.
1 pipelineInfo.layout = pipelineLayout;
Finalement nous devons fournir les références à la render pass et aux indices
des subpasses. Il est aussi possible d’utiliser d’autres render passes avec cette
pipeline mais elles doivent être compatibles avec renderPass. La signification
de compatible est donnée ici, mais nous n’utiliserons pas cette possibilité dans
ce tutoriel.
1 pipelineInfo.renderPass = renderPass;
2 pipelineInfo.subpass = 0;
Il nous reste en fait deux paramètres : basePipelineHandle et basePipelineIndex.
Vulkan vous permet de créer une nouvelle pipeline en “héritant” d’une pipeline
déjà existante. L’idée derrière cette fonctionnalité est qu’il est moins coû-
teux de créer une pipeline à partir d’une qui existe déjà, mais surtout que
passer d’une pipeline à une autre est plus rapide si elles ont un même
parent. Vous pouvez spécifier une pipeline de deux manières : soit en
fournissant une référence soit en donnant l’indice de la pipeline à hériter.
Nous n’utilisons pas cela donc nous indiquerons une référence nulle et
un indice invalide. Ces valeurs ne sont de toute façon utilisées que si le
champ flags de la structure VkGraphicsPipelineCreateInfo comporte
VK_PIPELINE_CREATE_DERIVATIVE_BIT.
112
1 pipelineInfo.basePipelineHandle = VK_NULL_HANDLE; // Optionnel
2 pipelineInfo.basePipelineIndex = -1; // Optionnel
Préparons-nous pour l’étape finale en créant un membre donnée où stocker la
référence à la VkPipeline :
1 VkPipeline graphicsPipeline;
Et créons enfin la pipeline graphique :
1 if (vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1,
&pipelineInfo, nullptr, &graphicsPipeline) != VK_SUCCESS) {
2 throw std::runtime_error("échec de la création de la pipeline
graphique!");
3 }
La fonction vkCreateGraphicsPipelines possède en fait plus de paramètres
que les fonctions de création d’objet que nous avons pu voir jusqu’à présent. Elle
peut en effet accepter plusieurs structures VkGraphicsPipelineCreateInfo et
créer plusieurs VkPipeline en un seul appel.
Le second paramètre que nous n’utilisons pas ici (mais que nous reverrons dans
un chapitre qui lui sera dédié) sert à fournir un objet VkPipelineCache op-
tionnel. Un tel objet peut être stocké et réutilisé entre plusieurs appels de la
fonction et même entre plusieurs exécutions du programme si son contenu est
correctement stocké dans un fichier. Cela permet de grandement accélérer la
création des pipelines.
La pipeline graphique est nécessaire à toutes les opérations d’affichage, nous ne
devrons donc la supprimer qu’à la fin du programme dans la fonction cleanup :
1 void cleanup() {
2 vkDestroyPipeline(device, graphicsPipeline, nullptr);
3 vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
4 ...
5 }
Exécutez votre programme pour vérifier que tout ce travail a enfin résulté dans
la création d’une pipeline graphique. Nous sommes de plus en plus proches
d’avoir un dessin à l’écran! Dans les prochains chapitres nous générerons les
framebuffers à partir des images de la swap chain et préparerons les commandes
d’affichage.
Code C++ / Vertex shader / Fragment shader
113
Effectuer le rendu
Framebuffers
Nous avons beaucoup parlé de framebuffers dans les chapitres précédents, et
nous avons mis en place la render pass pour qu’elle en accepte un du même
format que les images de la swap chain. Pourtant nous n’en avons encore créé
aucun.
Les attachements de différents types spécifiés durant la render pass sont liés en
les considérant dans des objets de type VkFramebuffer. Un tel objet référence
toutes les VkImageView utilisées comme attachements par une passe. Dans notre
cas nous n’en aurons qu’un : un attachement de couleur, qui servira de cible
d’affichage uniquement. Cependant l’image utilisée dépendra de l’image fournie
par la swap chain lors de la requête pour l’affichage. Nous devons donc créer
un framebuffer pour chacune des images de la swap chain et utiliser le bon au
moment de l’affichage.
Pour cela créez un autre std::vector qui contiendra des framebuffers :
1 std::vector<VkFramebuffer> swapChainFramebuffers;
Nous allons remplir ce vector depuis une nouvelle fonction createFramebuffers
que nous appellerons depuis initVulkan juste après la création de la pipeline
graphique :
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 createSurface();
5 pickPhysicalDevice();
6 createLogicalDevice();
7 createSwapChain();
8 createImageViews();
9 createRenderPass();
10 createGraphicsPipeline();
11 createFramebuffers();
12 }
13
14 ...
15
16 void createFramebuffers() {
17
18 }
Commencez par redimensionner le conteneur afin qu’il puisse stocker tous les
framebuffers :
1 void createFramebuffers() {
114
2 swapChainFramebuffers.resize(swapChainImageViews.size());
3 }
Nous allons maintenant itérer à travers toutes les images et créer un framebuffer
à partir de chacune d’entre elles :
1 for (size_t i = 0; i < swapChainImageViews.size(); i++) {
2 VkImageView attachments[] = {
3 swapChainImageViews[i]
4 };
5
6 VkFramebufferCreateInfo framebufferInfo{};
7 framebufferInfo.sType =
VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
8 framebufferInfo.renderPass = renderPass;
9 framebufferInfo.attachmentCount = 1;
10 framebufferInfo.pAttachments = attachments;
11 framebufferInfo.width = swapChainExtent.width;
12 framebufferInfo.height = swapChainExtent.height;
13 framebufferInfo.layers = 1;
14
15 if (vkCreateFramebuffer(device, &framebufferInfo, nullptr,
&swapChainFramebuffers[i]) != VK_SUCCESS) {
16 throw std::runtime_error("échec de la création d'un
framebuffer!");
17 }
18 }
Comme vous le pouvez le voir la création d’un framebuffer est assez simple.
Nous devons d’abord indiquer avec quelle renderPass le framebuffer doit être
compatible. Sachez que si vous voulez utiliser un framebuffer avec plusieurs
render passes, les render passes spécifiées doivent être compatibles entre elles. La
compatibilité signifie ici approximativement qu’elles utilisent le même nombre
d’attachements du même type. Ceci implique qu’il ne faut pas s’attendre à ce
qu’une render pass puisse ignorer certains attachements d’un framebuffer qui en
aurait trop.
Les paramètres attachementCount et pAttachments doivent donner la taille du
tableau contenant les VkImageViews qui servent d’attachements.
Les paramètres width et height sont évidents. Le membre layers correspond
au nombres de couches dans les images fournies comme attachements. Les
images de la swap chain n’ont toujours qu’une seule couche donc nous indiquons
1.
Nous devons détruire les framebuffers avant les image views et la render pass
dans la fonction cleanup :
115
1 void cleanup() {
2 for (auto framebuffer : swapChainFramebuffers) {
3 vkDestroyFramebuffer(device, framebuffer, nullptr);
4 }
5
6 ...
7 }
Nous avons atteint le moment où tous les objets sont prêts pour l’affichage.
Dans le prochain chapitre nous allons écrire les commandes d’affichage.
Code C++ / Vertex shader / Fragment shader
Command buffers
Les commandes Vulkan, comme les opérations d’affichage et de transfert mé-
moire, ne sont pas réalisées avec des appels de fonctions. Il faut pré-enregistrer
toutes les opérations dans des command buffers. L’avantage est que vous pou-
vez préparer tout ce travail à l’avance et depuis plusieurs threads, puis vous
contenter d’indiquer à Vulkan quel command buffer doit être exécuté. Cela ré-
duit considérablement la bande passante entre le CPU et le GPU et améliore
grandement les performances.
Command pools
Nous devons créer une command pool avant de pouvoir créer les command
buffers. Les command pools gèrent la mémoire utilisée par les buffers, et c’est
de fait les command pools qui nous instancient les command buffers. Ajoutez
un nouveau membre donnée à la classe de type VkCommandPool :
1 VkCommandPool commandPool;
Créez ensuite la fonction createCommandPool et appelez-la depuis initVulkan
après la création du framebuffer.
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 createSurface();
5 pickPhysicalDevice();
6 createLogicalDevice();
7 createSwapChain();
8 createImageViews();
9 createRenderPass();
10 createGraphicsPipeline();
11 createFramebuffers();
12 createCommandPool();
13 }
116
14
15 ...
16
17 void createCommandPool() {
18
19 }
La création d’une command pool ne nécessite que deux paramètres :
1 QueueFamilyIndices queueFamilyIndices =
findQueueFamilies(physicalDevice);
2
3 VkCommandPoolCreateInfo poolInfo{};
4 poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
5 poolInfo.queueFamilyIndex =
queueFamilyIndices.graphicsFamily.value();
6 poolInfo.flags = 0; // Optionel
Les commands buffers sont exécutés depuis une queue, comme la queue des
graphismes et de présentation que nous avons récupérées. Une command pool
ne peut allouer des command buffers compatibles qu’avec une seule famille de
queues. Nous allons enregistrer des commandes d’affichage, c’est pourquoi nous
avons récupéré une queue de graphismes.
Il existe deux valeurs acceptées par flags pour les command pools :
• VK_COMMAND_POOL_CREATE_TRANSIENT_BIT : informe que les command
buffers sont ré-enregistrés très souvent, ce qui peut inciter Vulkan (et
donc le driver) à ne pas utiliser le même type d’allocation
• VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT : permet aux
command buffers d’être ré-enregistrés individuellement, ce que les autres
configurations ne permettent pas
Nous n’enregistrerons les command buffers qu’une seule fois au début du pro-
gramme, nous n’aurons donc pas besoin de ces fonctionnalités.
1 if (vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool) !=
VK_SUCCESS) {
2 throw std::runtime_error("échec de la création d'une command
pool!");
3 }
Terminez la création de la command pool à l’aide de la fonction vkCreateComandPool.
Elle ne comprend pas de paramètre particulier. Les commandes seront utilisées
tout au long du programme pour tout affichage, nous ne devons donc la détruire
que dans la fonction cleanup :
1 void cleanup() {
2 vkDestroyCommandPool(device, commandPool, nullptr);
117
3
4 ...
5 }
Allocation des command buffers
Nous pouvons maintenant allouer des command buffers et enregistrer les com-
mandes d’affichage. Dans la mesure où l’une des commandes consiste à lier
un framebuffer nous devrons les enregistrer pour chacune des images de la swap
chain. Créez pour cela une liste de VkCommandBuffer et stockez-la dans un mem-
bre donnée de la classe. Les command buffers sont libérés avec la destruction
de leur command pool, nous n’avons donc pas à faire ce travail.
1 std::vector<VkCommandBuffer> commandBuffers;
Commençons maintenant à travailler sur notre fonction createCommandBuffers
qui allouera et enregistrera les command buffers pour chacune des images de la
swap chain.
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 createSurface();
5 pickPhysicalDevice();
6 createLogicalDevice();
7 createSwapChain();
8 createImageViews();
9 createRenderPass();
10 createGraphicsPipeline();
11 createFramebuffers();
12 createCommandPool();
13 createCommandBuffers();
14 }
15
16 ...
17
18 void createCommandBuffers() {
19 commandBuffers.resize(swapChainFramebuffers.size());
20 }
Les command buffers sont alloués par la fonction vkAllocateCommandBuffers
qui prend en paramètre une structure du type VkCommandBufferAllocateInfo.
Cette structure spécifie la command pool et le nombre de buffers à allouer depuis
celle-ci :
1 VkCommandBufferAllocateInfo allocInfo{};
2 allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
118
3 allocInfo.commandPool = commandPool;
4 allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
5 allocInfo.commandBufferCount = (uint32_t) commandBuffers.size();
6
7 if (vkAllocateCommandBuffers(device, &allocInfo,
commandBuffers.data()) != VK_SUCCESS) {
8 throw std::runtime_error("échec de l'allocation de command
buffers!");
9 }
Les command buffers peuvent être primaires ou secondaires, ce que l’on indique
avec le paramètre level. Il peut prendre les valeurs suivantes :
• VK_COMMAND_BUFFER_LEVEL_PRIMARY : peut être envoyé à une queue pour
y être exécuté mais ne peut être appelé par d’autres command buffers
• VK_COMMAND_BUFFER_LEVEL_SECONDARY : ne peut pas être directement
émis à une queue mais peut être appelé par un autre command buffer
Nous n’utiliserons pas la fonctionnalité de command buffer secondaire ici.
Sachez que le mécanisme de command buffer secondaire est à la base de la
génération rapie de commandes d’affichage depuis plusieurs threads.
Début de l’enregistrement des commandes
Nous commençons l’enregistrement des commandes en appelant vkBeginCommandBuffer.
Cette fonction prend une petite structure du type VkCommandBufferBeginInfo
en argument, permettant d’indiquer quelques détails sur l’utilisation du
command buffer.
1 for (size_t i = 0; i < commandBuffers.size(); i++) {
2 VkCommandBufferBeginInfo beginInfo{};
3 beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
4 beginInfo.flags = 0; // Optionnel
5 beginInfo.pInheritanceInfo = nullptr; // Optionel
6
7 if (vkBeginCommandBuffer(commandBuffers[i], &beginInfo) !=
VK_SUCCESS) {
8 throw std::runtime_error("erreur au début de
l'enregistrement d'un command buffer!");
9 }
10 }
L’utilisation du command buffer s’indique avec le paramètre flags, qui peut
prendre les valeurs suivantes :
• VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT : le command buffer
sera ré-enregistré après son utilisation, donc invalidé une fois son exécution
terminée
119
• VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT : ce command
buffer secondaire sera intégralement exécuté dans une unique render pass
• VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT : le command buffer
peut être ré-envoyé à la queue alors qu’il y est déjà et/ou est en cours
d’exécution
Nous n’avons pas besoin de ces flags ici.
Le paramètre pInheritanceInfo n’a de sens que pour les command buffers
secondaires. Il indique l’état à hériter de l’appel par le command buffer primaire.
Si un command buffer est déjà prêt un appel à vkBeginCommandBuffer le
regénèrera implicitement. Il n’est pas possible d’enregistrer un command buffer
en plusieurs fois.
Commencer une render pass
L’affichage commence par le lancement de la render pass réalisé par
vkCmdBeginRenderPass. La passe est configurée à l’aide des paramètres
remplis dans une structure de type VkRenderPassBeginInfo.
1 VkRenderPassBeginInfo renderPassInfo{};
2 renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
3 renderPassInfo.renderPass = renderPass;
4 renderPassInfo.framebuffer = swapChainFramebuffers[i];
Ces premiers paramètres sont la render pass elle-même et les attachements à lui
fournir. Nous avons créé un framebuffer pour chacune des images de la swap
chain qui spécifient ces images comme attachements de couleur.
1 renderPassInfo.renderArea.offset = {0, 0};
2 renderPassInfo.renderArea.extent = swapChainExtent;
Les deux paramètres qui suivent définissent la taille de la zone de rendu. Cette
zone de rendu définit où les chargements et stockages shaders se produiront. Les
pixels hors de cette région auront une valeur non définie. Elle doit correspondre
à la taille des attachements pour avoir une performance optimale.
1 VkClearValue clearColor = {{{0.0f, 0.0f, 0.0f, 1.0f}}};
2 renderPassInfo.clearValueCount = 1;
3 renderPassInfo.pClearValues = &clearColor;
Les deux derniers paramètres définissent les valeurs à utiliser pour remplacer le
contenu (fonctionnalité que nous avions activée avec VK_ATTACHMENT_LOAD_CLEAR).
J’ai utilisé un noir complètement opaque.
1 vkCmdBeginRenderPass(commandBuffers[i], &renderPassInfo,
VK_SUBPASS_CONTENTS_INLINE);
120
La render pass peut maintenant commencer. Toutes les fonctions enregistra-
bles se reconnaisent à leur préfixe vkCmd. Comme elles retournent toutes void
nous n’avons aucun moyen de gérer d’éventuelles erreurs avant d’avoir fini
l’enregistrement.
Le premier paramètre de chaque commande est toujours le command buffer qui
stockera l’appel. Le second paramètre donne des détails sur la render pass à
l’aide de la structure que nous avons préparée. Le dernier paramètre informe sur
la provenance des commandes pendant l’exécution de la passe. Il peut prendre
ces valeurs :
• VK_SUBPASS_CONTENTS_INLINE : les commandes de la render pass seront
inclues directement dans le command buffer (qui est donc primaire)
• VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFER : les commandes de
la render pass seront fournies par un ou plusieurs command buffers sec-
ondaires
Nous n’utiliserons pas de command buffer secondaire, nous devons donc fournir
la première valeur à la fonction.
Commandes d’affichage basiques
Nous pouvons maintenant activer la pipeline graphique :
1 vkCmdBindPipeline(commandBuffers[i],
VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);
Le second paramètre indique que la pipeline est bien une pipeline graphique et
non de calcul. Nous avons fourni à Vulkan les opérations à exécuter avec la
pipeline graphique et les attachements que le fragment shader devra utiliser. Il
ne nous reste donc plus qu’à lui dire d’afficher un triangle :
1 vkCmdDraw(commandBuffers[i], 3, 1, 0, 0);
Le fonction vkCmdDraw est assez ridicule quand on sait tout ce qu’elle implique,
mais sa simplicité est due à ce que tout a déjà été préparé en vue de ce moment
tant attendu. Elle possède les paramètres suivants en plus du command buffer
concerné :
• vertexCount : même si nous n’avons pas de vertex buffer, nous avons
techniquement trois vertices à dessiner
• instanceCount : sert au rendu instancié (instanced rendering); indiquez
1 si vous ne l’utilisez pas
• firstVertex : utilisé comme décalage dans le vertex buffer et définit ainsi
la valeur la plus basse pour glVertexIndex
• firstInstance : utilisé comme décalage pour l’instanced rendering et
définit ainsi la valeur la plus basse pour gl_InstanceIndex
121
Finitions
La render pass peut ensuite être terminée :
1 vkCmdEndRenderPass(commandBuffers[i]);
Et nous avons fini l’enregistrement du command buffer :
1 if (vkEndCommandBuffer(commandBuffers[i]) != VK_SUCCESS) {
2 throw std::runtime_error("échec de l'enregistrement d'un command
buffer!");
3 }
Dans le prochain chapitre nous écrirons le code pour la boucle principale. Elle
récupérera une image de la swap chain, exécutera le bon command buffer et
retournera l’image complète à la swap chain.
Code C++ / Vertex shader / Fragment shader
Rendu et présentation
Mise en place
Nous en sommes au chapitre où tout s’assemble. Nous allons écrire une fonction
drawFrame qui sera appelée depuis la boucle principale et affichera les triangles
à l’écran. Créez la fonction et appelez-la depuis mainLoop :
1 void mainLoop() {
2 while (!glfwWindowShouldClose(window)) {
3 glfwPollEvents();
4 drawFrame();
5 }
6 }
7
8 ...
9
10 void drawFrame() {
11
12 }
Synchronisation
Le fonction drawFrame réalisera les opérations suivantes :
• Acquérir une image depuis la swap chain
• Exécuter le command buffer correspondant au framebuffer dont
l’attachement est l’image obtenue
• Retourner l’image à la swap chain pour présentation
122
Chacune de ces actions n’est réalisée qu’avec un appel de fonction. Cependant
ce n’est pas aussi simple : les opérations sont par défaut exécutées de manière
asynchrones. La fonction retourne aussitôt que les opérations sont lancées, et par
conséquent l’ordre d’exécution est indéfini. Cela nous pose problème car chacune
des opérations que nous voulons lancer dépendent des résultats de l’opération
la précédant.
Il y a deux manières de synchroniser les évènements de la swap chain : les fences
et les sémaphores. Ces deux objets permettent d’attendre qu’une opération se
termine en relayant un signal émis par un processus généré par la fonction à
l’origine du lancement de l’opération.
Ils ont cependant une différence : l’état d’une fence peut être accédé depuis
le programme à l’aide de fonctions telles que vkWaitForFences alors que les
sémaphores ne le permettent pas. Les fences sont généralement utilisées pour
synchroniser votre programme avec les opérations alors que les sémaphores syn-
chronisent les opérations entre elles. Nous voulons synchroniser les queues, les
commandes d’affichage et la présentation, donc les sémaphores nous conviennent
le mieux.
Sémaphores
Nous aurons besoin d’un premier sémaphore pour indiquer que l’acquisition de
l’image s’est bien réalisée, puis d’un second pour prévenir de la fin du rendu et
permettre à l’image d’être retournée dans la swap chain. Créez deux membres
données pour stocker ces sémaphores :
1 VkSemaphore imageAvailableSemaphore;
2 VkSemaphore renderFinishedSemaphore;
Pour leur création nous allons avoir besoin d’une dernière fonction create...
pour cette partie du tutoriel. Appelez-la createSemaphores :
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 createSurface();
5 pickPhysicalDevice();
6 createLogicalDevice();
7 createSwapChain();
8 createImageViews();
9 createRenderPass();
10 createGraphicsPipeline();
11 createFramebuffers();
12 createCommandPool();
13 createCommandBuffers();
14 createSemaphores();
15 }
123
16
17 ...
18
19 void createSemaphores() {
20
21 }
La création d’un sémaphore passe par le remplissage d’une structure de type
VkSemaphoreCreateInfo. Cependant cette structure ne requiert pour l’instant
rien d’autre que le membre sType :
1 void createSemaphores() {
2 VkSemaphoreCreateInfo semaphoreInfo{};
3 semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
4 }
De futures version de Vulkan ou des extensions pourront à terme donner un
intérêt aux membre flags et pNext, comme pour d’autres structures. Créez les
sémaphores comme suit :
1 if (vkCreateSemaphore(device, &semaphoreInfo, nullptr,
&imageAvailableSemaphore) != VK_SUCCESS ||
2 vkCreateSemaphore(device, &semaphoreInfo, nullptr,
&renderFinishedSemaphore) != VK_SUCCESS) {
3
4 throw std::runtime_error("échec de la création des sémaphores!");
5 }
Les sémaphores doivent être détruits à la fin du programme depuis la fonction
cleanup :
1 void cleanup() {
2 vkDestroySemaphore(device, renderFinishedSemaphore, nullptr);
3 vkDestroySemaphore(device, imageAvailableSemaphore, nullptr);
Acquérir une image de la swap chain
La première opération à réaliser dans drawFrame est d’acquérir une image depuis
la swap chain. La swap chain étant une extension nous allons encore devoir
utiliser des fonction suffixées de KHR :
1 void drawFrame() {
2 uint32_t imageIndex;
3 vkAcquireNextImageKHR(device, swapChain, UINT64_MAX,
imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex);
4 }
124
Les deux premiers paramètres de vkAcquireNextImageKHR sont le logical device
et la swap chain depuis laquelle récupérer les images. Le troisième paramètre
spécifie une durée maximale en nanosecondes avant d’abandonner l’attente si
aucune image n’est disponible. Utiliser la plus grande valeur possible pour un
uint32_t le désactive.
Les deux paramètres suivants sont les objets de synchronisation qui doivent être
informés de la complétion de l’opération de récupération. Ce sera à partir du
moment où le sémaphore que nous lui fournissons reçoit un signal que nous
pouvons commencer à dessiner.
Le dernier paramètre permet de fournir à la fonction une variable dans laquelle
elle stockera l’indice de l’image récupérée dans la liste des images de la swap
chain. Cet indice correspond à la VkImage dans notre vector swapChainImages.
Nous utiliserons cet indice pour invoquer le bon command buffer.
Envoi du command buffer
L’envoi à la queue et la synchronisation de celle-ci sont configurés à l’aide de
paramètres dans la structure VkSubmitInfo que nous allons remplir.
1 VkSubmitInfo submitInfo{};
2 submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
3
4 VkSemaphore waitSemaphores[] = {imageAvailableSemaphore};
5 VkPipelineStageFlags waitStages[] =
{VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT};
6 submitInfo.waitSemaphoreCount = 1;
7 submitInfo.pWaitSemaphores = waitSemaphores;
8 submitInfo.pWaitDstStageMask = waitStages;
Les trois premiers paramètres (sans compter sType) fournissent le sémaphore
indiquant si l’opération doit attendre et l’étape du rendu à laquelle s’arrêter.
Nous voulons attendre juste avant l’écriture des couleurs sur l’image. Par con-
tre nous laissons à l’implémentation la possibilité d’exécuter toutes les étapes
précédentes d’ici là. Notez que chaque étape indiquée dans waitStages corre-
spond au sémaphore de même indice fourni dans waitSemaphores.
1 submitInfo.commandBufferCount = 1;
2 submitInfo.pCommandBuffers = &commandBuffers[imageIndex];
Les deux paramètres qui suivent indiquent les command buffers à exécuter. Nous
devons ici fournir le command buffer qui utilise l’image de la swap chain que
nous venons de récupérer comme attachement de couleur.
1 VkSemaphore signalSemaphores[] = {renderFinishedSemaphore};
2 submitInfo.signalSemaphoreCount = 1;
3 submitInfo.pSignalSemaphores = signalSemaphores;
125
Les paramètres signalSemaphoreCount et pSignalSemaphores indiquent les
sémaphores auxquels indiquer que les command buffers ont terminé leur exécu-
tion. Dans notre cas nous utiliserons notre renderFinishedSemaphore.
1 if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE) !=
VK_SUCCESS) {
2 throw std::runtime_error("échec de l'envoi d'un command
buffer!");
3 }
Nous pouvons maintenant envoyer notre command buffer à la queue des
graphismes en utilisant vkQueueSubmit. Cette fonction prend en argument
un tableau de structures de type VkSubmitInfo pour une question d’efficacité.
Le dernier paramètre permet de fournir une fence optionnelle. Celle-ci sera
prévenue de la fin de l’exécution des command buffers. Nous n’en utilisons pas
donc passerons VK_NULL_HANDLE.
Subpass dependencies
Les subpasses s’occupent automatiquement de la transition de l’organisation
des images. Ces transitions sont contrôlées par des subpass dependencies. Elles
indiquent la mémoire et l’exécution entre les subpasses. Nous n’avons certes
qu’une seule subpasse pour le moment, mais les opérations avant et après cette
subpasse comptent aussi comme des subpasses implicites.
Il existe deux dépendances préexistantes capables de gérer les transitions au
début et à la fin de la render pass. Le problème est que cette première dépen-
dance ne s’exécute pas au bon moment. Elle part du principe que la transition
de l’organisation de l’image doit être réalisée au début de la pipeline, mais
dans notre programme l’image n’est pas encore acquise à ce moment! Il existe
deux manières de régler ce problème. Nous pourrions changer waitStages
pour imageAvailableSemaphore à VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT
pour être sûrs que la pipeline ne commence pas avant que l’image ne soit
acquise, mais nous perdrions en performance car les shaders travaillant sur
les vertices n’ont pas besoin de l’image. Il faudrait faire quelque chose de
plus subtil. Nous allons donc plutôt faire attendre la render pass à l’étape
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT et faire la transition à
ce moment. Cela nous donne de plus une bonne excuse pour s’intéresser au
fonctionnement des subpass dependencies.
Celles-ci sont décrites dans une structure de type VkSubpassDependency. Créez
en une dans la fonction createRenderPass :
1 VkSubpassDependency dependency{};
2 dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
3 dependency.dstSubpass = 0;
126
Les deux premiers champs permettent de fournir l’indice de la subpasse d’origine
et de la subpasse d’arrivée. La valeur particulière VK_SUBPASS_EXTERNAL réfère
à la subpass implicite soit avant soit après la render pass, selon que cette valeur
est indiquée dans respectivement srcSubpass ou dstSubpass. L’indice 0 corre-
spond à notre seule et unique subpasse. La valeur fournie à dstSubpass doit
toujours être supérieure à srcSubpass car sinon une boucle infinie peut appa-
raître (sauf si une des subpasse est VK_SUBPASS_EXTERNAL).
1 dependency.srcStageMask =
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
2 dependency.srcAccessMask = 0;
Les deux paramètres suivants indiquent les opérations à attendre et les étapes
durant lesquelles les opérations à attendre doivent être considérées. Nous
voulons attendre la fin de l’extraction de l’image avant d’y accéder, hors ceci est
déjà configuré pour être synchronisé avec l’étape d’écriture sur l’attachement.
C’est pourquoi nous n’avons qu’à attendre à cette étape.
1 dependency.dstStageMask =
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
2 dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
Nous indiquons ici que les opérations qui doivent attendre pendant l’étape liée
à l’attachement de couleur sont celles ayant trait à l’écriture. Ces paramètres
permettent de faire attendre la transition jusqu’à ce qu’elle soit possible, ce
qui correspond au moment où la passe accède à cet attachement puisqu’elle est
elle-même configurée pour attendre ce moment.
1 renderPassInfo.dependencyCount = 1;
2 renderPassInfo.pDependencies = &dependency;
Nous fournissons enfin à la structure ayant trait à la render pass un tableau de
configurations pour les subpass dependencies.
Présentation
La dernière étape pour l’affichage consiste à envoyer le résultat à la swap chain.
La présentation est configurée avec une structure de type VkPresentInfoKHR,
et nous ferons cela à la fin de la fonction drawFrame.
1 VkPresentInfoKHR presentInfo{};
2 presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
3
4 presentInfo.waitSemaphoreCount = 1;
5 presentInfo.pWaitSemaphores = signalSemaphores;
Les deux premiers paramètres permettent d’indiquer les sémaphores devant sig-
naler que la présentation peut se dérouler.
127
1 VkSwapchainKHR swapChains[] = {swapChain};
2 presentInfo.swapchainCount = 1;
3 presentInfo.pSwapchains = swapChains;
4 presentInfo.pImageIndices = &imageIndex;
Les deux paramètres suivants fournissent un tableau contenant notre unique
swap chain qui présentera les images et l’indice de l’image pour celle-ci.
1 presentInfo.pResults = nullptr; // Optionnel
Ce dernier paramètre est optionnel. Il vous permet de fournir un tableau de
VkResult que vous pourrez consulter pour vérifier que toutes les swap chain ont
bien présenté leur image sans problème. Cela n’est pas nécessaire dans notre
cas, car n’utilisant qu’une seule swap chain nous pouvons simplement regarder
la valeur de retour de la fonction de présentation.
1 vkQueuePresentKHR(presentQueue, &presentInfo);
La fonction vkQueuePresentKHR émet la requête de présentation d’une
image par la swap chain. Nous ajouterons la gestion des erreurs pour
vkAcquireNextImageKHR et vkQueuePresentKHR dans le prochain chapitre car
une erreur à ces étapes n’implique pas forcément que le programme doit se
terminer, mais plutôt qu’il doit s’adapter à des changements.
Si vous avez fait tout ça correctement vous devriez avoir quelque chose comme
cela à l’écran quand vous lancez votre programme :
128
Enfin! Malheureusement si vous essayez de quitter proprement le programme
vous obtiendrez un crash et un message semblable à ceci :
N’oubliez pas que puisque les opérations dans drawFrame sont asynchrones il est
quasiment certain que lorsque vous quittez le programme, celui-ci exécute encore
des instructions et cela implique que vous essayez de libérer des ressources en
train d’être utilisées. Ce qui est rarement une bonne idée, surtout avec du bas
niveau comme Vulkan.
Pour régler ce problème nous devons attendre que le logical device finisse
l’opération qu’il est en train de réaliser avant de quitter mainLoop et de
détruire la fenêtre :
1 void mainLoop() {
2 while (!glfwWindowShouldClose(window)) {
3 glfwPollEvents();
4 drawFrame();
5 }
129
6
7 vkDeviceWaitIdle(device);
8 }
Vous pouvez également attendre la fin d’une opération quelconque depuis une
queue spécifique à l’aide de la fonction vkQueueWaitIdle. Ces fonction peuvent
par ailleurs être utilisées pour réaliser une synchronisation très basique, mais
très inefficace. Le programme devrait maintenant se terminer sans problème
quand vous fermez la fenêtre.
Frames en vol
Si vous lancez l’application avec les validation layers maintenant, vous pou-
vez soit avoir des erreurs soit vous remarquerez que l’utilisation de la mémoire
augmente, lentement mais sûrement. La raison est que l’application soumet
rapidement du travail dans la fonction drawframe, mais que l’on ne vérifie pas
si ces rendus sont effectivement terminés. Si le CPU envoie plus de comman-
des que le GPU ne peut en exécuter, ce qui est le cas car nous envoyons nos
command buffers de manière totalement débridée, la queue de graphismes va
progressivement se remplir de travail à effectuer. Pire encore, nous utilisons
imageAvailableSemaphore et renderFinishedSemaphore ainsi que nos com-
mand buffers pour plusieurs frames en même temps.
Le plus simple est d’attendre que le logical device n’aie plus de travail à effectuer
avant de lui en envoyer de nouveau, par exemple à l’aide de vkQueueIdle :
1 void drawFrame() {
2 ...
3
4 vkQueuePresentKHR(presentQueue, &presentInfo);
5
6 vkQueueWaitIdle(presentQueue);
7 }
Cependant cette méthode n’est clairement pas optimale pour le GPU car la
pipeline peut en général gérer plusieurs images à la fois grâce aux architectures
massivement parallèles. Les étapes que l’image a déjà passées (par exemple le
vertex shader quand elle en est au fragment shader) peuvent tout à fait être
utilisées pour l’image suivante. Nous allons améliorer notre programme pour
qu’il puisse supporter plusieurs images en vol (ou in flight) tout en limitant la
quantité de commandes dans la queue.
Commencez par ajouter une constante en haut du programme qui définit le
nombre de frames à traiter concurentiellement :
1 const int MAX_FRAMES_IN_FLIGHT = 2;
Chaque frame aura ses propres sémaphores :
130
1 std::vector<VkSemaphore> imageAvailableSemaphores;
2 std::vector<VkSemaphore> renderFinishedSemaphores;
La fonction createSemaphores doit être améliorée pour gérer la création de
tout ceux-là :
1 void createSemaphores() {
2 imageAvailableSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
3 renderFinishedSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
4
5 VkSemaphoreCreateInfo semaphoreInfo{};
6 semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
7
8 for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
9 if (vkCreateSemaphore(device, &semaphoreInfo, nullptr,
&imageAvailableSemaphores[i]) != VK_SUCCESS ||
10 vkCreateSemaphore(device, &semaphoreInfo, nullptr,
&renderFinishedSemaphores[i]) != VK_SUCCESS) {
11
12 throw std::runtime_error("échec de la création des
sémaphores d'une frame!");
13 }
14 }
Ils doivent également être libérés à la fin du programme :
1 void cleanup() {
2 for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
3 vkDestroySemaphore(device, renderFinishedSemaphores[i],
nullptr);
4 vkDestroySemaphore(device, imageAvailableSemaphores[i],
nullptr);
5 }
6
7 ...
8 }
Pour utiliser la bonne paire de sémaphores à chaque fois nous devons garder à
portée de main l’indice de la frame en cours.
1 size_t currentFrame = 0;
La fonction drawFrame peut maintenant être modifiée pour utiliser les bons
objets :
1 void drawFrame() {
2 vkAcquireNextImageKHR(device, swapChain, UINT64_MAX,
imageAvailableSemaphores[currentFrame], VK_NULL_HANDLE,
&imageIndex);
131
3
4 ...
5
6 VkSemaphore waitSemaphores[] =
{imageAvailableSemaphores[currentFrame]};
7
8 ...
9
10 VkSemaphore signalSemaphores[] =
{renderFinishedSemaphores[currentFrame]};
11
12 ...
13 }
Nous ne devons bien sûr pas oublier d’avancer à la frame suivante à chaque fois
:
1 void drawFrame() {
2 ...
3
4 currentFrame = (currentFrame + 1) % MAX_FRAMES_IN_FLIGHT;
5 }
En utilisant l’opérateur de modulo % nous pouvons nous assurer que l’indice
boucle à chaque fois que MAX_FRAMES_IN_FLIGHT est atteint.
Bien que nous ayons pas en place les objets facilitant le traitement de
plusieurs frames simultanément, encore maintenant le GPU traite plus de
MAX_FRAMES_IN_FLIGHT à la fois. Nous n’avons en effet qu’une synchronisation
GPU-GPU mais pas de synchronisation CPU-GPU. Nous n’avons pas de
moyen de savoir que le travail sur telle ou telle frame est fini, ce qui a pour
conséquence que nous pouvons nous retrouver à afficher une frame alors qu’elle
est encore en traitement.
Pour la synchronisation CPU-GPU nous allons utiliser l’autre moyen fourni
par Vulkan que nous avons déjà évoqué : les fences. Au lieu d’informer une
certaine opération que tel signal devra être attendu avant de continuer, ce
que les sémaphores permettent, les fences permettent au programme d’attendre
l’exécution complète d’une opération. Nous allons créer une fence pour chaque
frame :
1 std::vector<VkSemaphore> imageAvailableSemaphores;
2 std::vector<VkSemaphore> renderFinishedSemaphores;
3 std::vector<VkFence> inFlightFences;
4 size_t currentFrame = 0;
J’ai choisi de créer les fences avec les sémaphores et de renommer la fonction
createSemaphores en createSyncObjects :
132
1 void createSyncObjects() {
2 imageAvailableSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
3 renderFinishedSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
4 inFlightFences.resize(MAX_FRAMES_IN_FLIGHT);
5
6 VkSemaphoreCreateInfo semaphoreInfo{};
7 semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
8
9 VkFenceCreateInfo fenceInfo{};
10 fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
11
12 for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
13 if (vkCreateSemaphore(device, &semaphoreInfo, nullptr,
&imageAvailableSemaphores[i]) != VK_SUCCESS ||
14 vkCreateSemaphore(device, &semaphoreInfo, nullptr,
&renderFinishedSemaphores[i]) != VK_SUCCESS ||
15 vkCreateFence(device, &fenceInfo, nullptr,
&inFlightFences[i]) != VK_SUCCESS) {
16
17 throw std::runtime_error("échec de la création des
objets de synchronisation pour une frame!");
18 }
19 }
20 }
La création d’une VkFence est très similaire à la création d’un sémaphore.
N’oubliez pas de libérer les fences :
1 void cleanup() {
2 for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
3 vkDestroySemaphore(device, renderFinishedSemaphores[i],
nullptr);
4 vkDestroySemaphore(device, imageAvailableSemaphores[i],
nullptr);
5 vkDestroyFence(device, inFlightFences[i], nullptr);
6 }
7
8 ...
9 }
Nous voulons maintenant que drawFrame utilise les fences pour la synchronisa-
tion. L’appel à vkQueueSubmit inclut un paramètre optionnel qui permet de
passer une fence. Celle-ci sera informée de la fin de l’exécution du command
buffer. Nous pouvons interpréter ce signal comme la fin du rendu sur la frame.
1 void drawFrame() {
2 ...
133
3
4 if (vkQueueSubmit(graphicsQueue, 1, &submitInfo,
inFlightFences[currentFrame]) != VK_SUCCESS) {
5 throw std::runtime_error("échec de l'envoi d'un command
buffer!");
6 }
7 ...
8 }
La dernière chose qui nous reste à faire est de changer le début de drawFrame
pour que la fonction attende le rendu de la frame précédente :
1 void drawFrame() {
2 vkWaitForFences(device, 1, &inFlightFences[currentFrame],
VK_TRUE, UINT64_MAX);
3 vkResetFences(device, 1, &inFlightFences[currentFrame]);
4
5 ...
6 }
La fonction vkWaitForFences prend en argument un tableau de fences. Elle
attend soit qu’une seule fence soit que toutes les fences déclarent être signalées
avant de retourner. Le choix du mode d’attente se fait selon la valeur du qua-
trième paramètre. Avec VK_TRUE nous demandons d’attendre toutes les fences,
même si cela ne fait bien sûr pas de différence vu que nous n’avons qu’une seule
fence. Comme la fonction vkAcquireNextImageKHR cette fonction prend une
durée en argument, que nous ignorons. Nous devons ensuite réinitialiser les
fences manuellement à l’aide d’un appel à la fonction vkResetFences.
Si vous lancez le programme maintenant vous allez constater un comportement
étrange. Plus rien ne se passe. Nous attendons qu’une fence soit signalée alors
qu’elle n’a jamais été envoyée à aucune fonction. En effet les fences sont par
défaut crées dans le mode non signalé. Comme nous appelons vkWaitForFences
avant vkQueueSubmit notre première fence va créer une pause infinie. Pour
empêcher cela nous devons initialiser les fences dans le mode signalé, et ce dès
leur création :
1 void createSyncObjects() {
2 ...
3
4 VkFenceCreateInfo fenceInfo{};
5 fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
6 fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT;
7
8 ...
9 }
134
La fuite de mémoire n’est plus, mais le programme ne fonctionne pas encore
correctement. Si MAX_FRAMES_IN_FLIGHT est plus grand que le nombre d’images
de la swapchain ou que vkAcquireNextImageKHR ne retourne pas les images
dans l’ordre, alors il est possible que nous lancions le rendu dans une image qui
est déjà en vol. Pour éviter ça, nous devons pour chaque image de la swapchain si
une frame en vol est en train d’utiliser celle-ci. Cette correspondance permettra
de suivre les images en vol par leur fences respective, de cette façon nous aurons
immédiatement un objet de synchronisation à attendre avant qu’une nouvelle
frame puisse utiliser cette image.
Tout d’abord, ajoutez une nouvelle liste nommée imagesInFlight:
1 std::vector<VkFence> inFlightFences;
2 std::vector<VkFence> imagesInFlight;
3 size_t currentFrame = 0;
Préparez-la dans createSyncObjects:
1 void createSyncObjects() {
2 imageAvailableSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
3 renderFinishedSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
4 inFlightFences.resize(MAX_FRAMES_IN_FLIGHT);
5 imagesInFlight.resize(swapChainImages.size(), VK_NULL_HANDLE);
6
7 ...
8 }
Initialement aucune frame n’utilise d’image, donc on peut explicitement
l’initialiser à pas de fence. Maintenant, nous allons modifier drawFrame pour
attendre la fin de n’importe quelle frame qui serait en train d’utiliser l’image
qu’on nous assigné pour la nouvelle frame.
1 void drawFrame() {
2 ...
3
4 vkAcquireNextImageKHR(device, swapChain, UINT64_MAX,
imageAvailableSemaphores[currentFrame], VK_NULL_HANDLE,
&imageIndex);
5
6 // Vérifier si une frame précédente est en train d'utiliser
cette image (il y a une fence à attendre)
7 if (imagesInFlight[imageIndex] != VK_NULL_HANDLE) {
8 vkWaitForFences(device, 1, &imagesInFlight[imageIndex],
VK_TRUE, UINT64_MAX);
9 }
10 // Marque l'image comme étant à nouveau utilisée par cette frame
11 imagesInFlight[imageIndex] = inFlightFences[currentFrame];
12
135
13 ...
14 }
Parce que nous avons maintenant plus d’appels à vkWaitForFences, les appels à
vkResetFences doivent être déplacés. Le mieux reste de simplement l’appeler
juste avant d’utiliser la fence:
1 void drawFrame() {
2 ...
3
4 vkResetFences(device, 1, &inFlightFences[currentFrame]);
5
6 if (vkQueueSubmit(graphicsQueue, 1, &submitInfo,
inFlightFences[currentFrame]) != VK_SUCCESS) {
7 throw std::runtime_error("échec de l'envoi d'un command
buffer!");
8 }
9
10 ...
11 }
Nous avons implémenté tout ce qui est nécessaire à la synchronisation pour
certifier qu’il n’y a pas plus de deux frames de travail dans la queue et que ces
frames n’utilise pas accidentellement la même image. Notez qu’il est tout à fait
normal pour d’autre parties du code, comme le nettoyage final, de se reposer sur
des mécanismes de synchronisation plus durs comme vkDeviceWaitIdle. Vous
devriez décider de la bonne approche à utiliser en vous basant sur vos besoins
de performances.
Pour en apprendre plus sur la synchronisation rendez vous sur ces exemples
complets par Khronos.
Conclusion
Un peu plus de 900 lignes plus tard nous avons enfin atteint le niveau où nous
voyons des résultats à l’écran!! Créer un programme avec Vulkan est clairement
un énorme travail, mais grâce au contrôle que cet API vous offre vous pouvez
obtenir des performances énormes. Je ne peux que vous recommander de re-
lire tout ce code et de vous assurer que vous visualisez bien tout les éléments
mis en jeu. Nous allons maintenant construire sur ces acquis pour étendre les
fonctionnalités de ce programme.
Dans le prochain chapitre nous allons voir une autre petite chose nécessaire à
tout bon programme Vulkan.
Code C++ / Vertex shader / Fragment shader
136
Recréation de la swap chain
Introduction
Notre application nous permet maintenant d’afficher correctement un triangle,
mais certains cas de figures ne sont pas encore correctement gérés. Il est possible
que la surface d’affichage soit redimensionnée par l’utilisateur et que la swap
chain ne soit plus parfaitement compatible. Nous devons faire en sorte d’être
informés de tels changements pour pouvoir recréer la swap chain.
Recréer la swap chain
Créez la fonction recreateSwapChain qui appelle createSwapChain et toutes
les fonctions de création d’objets dépendants de la swap chain ou de la taille de
la fenêtre.
1 void recreateSwapChain() {
2 vkDeviceWaitIdle(device);
3
4 createSwapChain();
5 createImageViews();
6 createRenderPass();
7 createGraphicsPipeline();
8 createFramebuffers();
9 createCommandBuffers();
10 }
Nous appelons d’abord vkDeviceIdle car nous ne devons surtout pas toucher
à des ressources en cours d’utilisation. La première chose à faire est bien sûr de
recréer la swap chain. Les image views doivent être recrées également car elles
dépendent des images de la swap chain. La render pass doit être recrée car elle
dépend du format des images de la swap chain. Il est rare que le format des
images de la swap chain soit altéré mais il n’est pas officiellement garanti qu’il
reste le même, donc nous gérerons ce cas là. La pipeline dépend de la taille des
images pour la configuration des rectangles de viewport et de ciseau, donc nous
devons recréer la pipeline graphique. Il est possible d’éviter cela en faisant de
la taille de ces rectangles des états dynamiques. Finalement, les framebuffers et
les command buffers dépendent des images de la swap chain.
Pour être certains que les anciens objets sont bien détruits avant d’en créer de
nouveaux, nous devrions créer une fonction dédiée à cela et que nous appellerons
depuis recreateSwapChain. Créez donc cleanupSwapChain :
1 void cleanupSwapChain() {
2
3 }
4
5 void recreateSwapChain() {
137
6 vkDeviceWaitIdle(device);
7
8 cleanupSwapChain();
9
10 createSwapChain();
11 createImageViews();
12 createRenderPass();
13 createGraphicsPipeline();
14 createFramebuffers();
15 createCommandBuffers();
16 }
Nous allons déplacer le code de suppression depuis cleanup jusqu’à
cleanupSwapChain :
1 void cleanupSwapChain() {
2 for (size_t i = 0; i < swapChainFramebuffers.size(); i++) {
3 vkDestroyFramebuffer(device, swapChainFramebuffers[i],
nullptr);
4 }
5
6 vkFreeCommandBuffers(device, commandPool,
static_cast<uint32_t>(commandBuffers.size()),
commandBuffers.data());
7
8 vkDestroyPipeline(device, graphicsPipeline, nullptr);
9 vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
10 vkDestroyRenderPass(device, renderPass, nullptr);
11
12 for (size_t i = 0; i < swapChainImageViews.size(); i++) {
13 vkDestroyImageView(device, swapChainImageViews[i], nullptr);
14 }
15
16 vkDestroySwapchainKHR(device, swapChain, nullptr);
17 }
Nous pouvons ensuite appeler cette nouvelle fonction depuis cleanup pour éviter
la redondance de code :
1 void cleanup() {
2 cleanupSwapChain();
3
4 for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
5 vkDestroySemaphore(device, renderFinishedSemaphores[i],
nullptr);
6 vkDestroySemaphore(device, imageAvailableSemaphores[i],
nullptr);
138
7 vkDestroyFence(device, inFlightFences[i], nullptr);
8 }
9
10 vkDestroyCommandPool(device, commandPool, nullptr);
11
12 vkDestroyDevice(device, nullptr);
13
14 if (enableValidationLayers) {
15 DestroyDebugReportCallbackEXT(instance, callback, nullptr);
16 }
17
18 vkDestroySurfaceKHR(instance, surface, nullptr);
19 vkDestroyInstance(instance, nullptr);
20
21 glfwDestroyWindow(window);
22
23 glfwTerminate();
24 }
Nous pourrions recréer la command pool à partir de rien mais ce serait du
gâchis. J’ai préféré libérer les command buffers existants à l’aide de la fonction
vkFreeCommandBuffers. Nous pouvons de cette manière réutiliser la même
command pool mais changer les command buffers.
Pour bien gérer le redimensionnement de la fenêtre nous devons récupérer la
taille actuelle du framebuffer qui lui est associé pour s’assurer que les images de
la swap chain ont bien la nouvelle taille. Pour cela changez chooseSwapExtent
afin que cette fonction prenne en compte la nouvelle taille réelle :
1 VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR&
capabilities) {
2 if (capabilities.currentExtent.width !=
std::numeric_limits<uint32_t>::max()) {
3 return capabilities.currentExtent;
4 } else {
5 int width, height;
6 glfwGetFramebufferSize(window, &width, &height);
7
8 VkExtent2D actualExtent = {
9 static_cast<uint32_t>(width),
10 static_cast<uint32_t>(height)
11 };
12
13 ...
14 }
15 }
139
C’est tout ce que nous avons à faire pour recréer la swap chain! Le problème
cependant est que nous devons arrêter complètement l’affichage pendant la re-
création alors que nous pourrions éviter que les frames en vol soient perdues.
Pour cela vous devez passer l’ancienne swap chain en paramètre à oldSwapChain
dans la structure VkSwapchainCreateInfoKHR et détruire cette ancienne swap
chain dès que vous ne l’utilisez plus.
Swap chain non-optimales ou dépassées
Nous devons maintenant déterminer quand recréer la swap chain et donc quand
appeler recreateSwapChain. Heureusement pour nous Vulkan nous indiquera
quand la swap chain n’est plus adéquate au moment de la présentation. Les
fonctions vkAcquireNextImageKHR et vkQueuePresentKHR peuvent pour cela
retourner les valeurs suivantes :
• VK_ERROR_OUT_OF_DATE_KHR : la swap chain n’est plus compatible avec
la surface de fenêtre et ne peut plus être utilisée pour l’affichage, ce qui
arrive en général avec un redimensionnement de la fenêtre
• VK_SUBOPTIMAL_KHR : la swap chain peut toujours être utilisée pour
présenter des images avec succès, mais les caractéristiques de la surface
de fenêtre ne correspondent plus à celles de la swap chain
1 VkResult result = vkAcquireNextImageKHR(device, swapChain,
UINT64_MAX, imageAvailableSemaphores[currentFrame],
VK_NULL_HANDLE, &imageIndex);
2
3 if (result == VK_ERROR_OUT_OF_DATE_KHR) {
4 recreateSwapChain();
5 return;
6 } else if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) {
7 throw std::runtime_error("échec de la présentation d'une image à
la swap chain!");
8 }
Si la swap chain se trouve être dépassée quand nous essayons d’acquérir une
nouvelle image il ne nous est plus possible de présenter un quelconque résultat.
Nous devons de ce fait aussitôt recréer la swap chain et tenter la présentation
avec la frame suivante.
Vous pouvez aussi décider de recréer la swap chain si sa configuration n’est plus
optimale, mais j’ai choisi de ne pas le faire ici car nous avons de toute façon déjà
acquis l’image. Ainsi VK_SUCCES et VK_SUBOPTIMAL_KHR sont considérés comme
des indicateurs de succès.
1 result = vkQueuePresentKHR(presentQueue, &presentInfo);
2
3 if (result == VK_ERROR_OUT_OF_DATE_KHR || result ==
VK_SUBOPTIMAL_KHR) {
140
4 recreateSwapChain();
5 } else if (result != VK_SUCCESS) {
6 throw std::runtime_error("échec de la présentation d'une
image!");
7 }
8
9 currentFrame = (currentFrame + 1) % MAX_FRAMES_IN_FLIGHT;
La fonction vkQueuePresentKHR retourne les mêmes valeurs avec la même sig-
nification. Dans ce cas nous recréons la swap chain si elle n’est plus optimale
car nous voulons les meilleurs résultats possibles.
Explicitement gérer les redimensionnements
Bien que la plupart des drivers émettent automatiquement le code
VK_ERROR_OUT_OF_DATE_KHR après qu’une fenêtre est redimensionnée, cela
n’est pas garanti par le standard. Par conséquent nous devons explictement
gérer ces cas de figure. Ajoutez une nouvelle variable qui indiquera que la
fenêtre a été redimensionnée :
1 std::vector<VkFence> inFlightFences;
2 size_t currentFrame = 0;
3
4 bool framebufferResized = false;
La fonction drawFrame doit ensuite être modifiée pour prendre en compte cette
nouvelle variable :
1 if (result == VK_ERROR_OUT_OF_DATE_KHR || result ==
VK_SUBOPTIMAL_KHR || framebufferResized) {
2 framebufferResized = false;
3 recreateSwapChain();
4 } else if (result != VK_SUCCESS) {
5 ...
6 }
Il est important de faire cela après vkQueuePresentKHR pour que les sémaphores
soient dans un état correct. Pour détecter les redimensionnements de la fenêtre
nous n’avons qu’à mettre en place glfwSetFrameBufferSizeCallback qui nous
informera d’un changement de la taille associée à la fenêtre :
1 void initWindow() {
2 glfwInit();
3
4 glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
5
6 window = glfwCreateWindow(WIDTH, HEIGHT, "Vulkan", nullptr,
nullptr);
141
7 glfwSetFramebufferSizeCallback(window,
framebufferResizeCallback);
8 }
9
10 static void framebufferResizeCallback(GLFWwindow* window, int width,
int height) {
11
12 }
Nous devons utiliser une fonction statique car GLFW ne sait pas correctement
appeler une fonction membre d’une classe avec this.
Nous récupérons une référence à la GLFWwindow dans la fonction de rappel que
nous fournissons. De plus nous pouvons paramétrer un pointeur de notre choix
qui sera accessible à toutes nos fonctions de rappel. Nous pouvons y mettre la
classe elle-même.
1 window = glfwCreateWindow(WIDTH, HEIGHT, "Vulkan", nullptr, nullptr);
2 glfwSetWindowUserPointer(window, this);
3 glfwSetFramebufferSizeCallback(window, framebufferResizeCallback);
De cette manière nous pouvons changer la valeur de la variable servant
d’indicateur des redimensionnements :
1 static void framebufferResizeCallback(GLFWwindow* window, int width,
int height) {
2 auto app =
reinterpret_cast<HelloTriangleApplication*>(glfwGetWindowUserPointer(window));
3 app->framebufferResized = true;
4 }
Lancez maintenant le programme et changez la taille de la fenêtre pour voir si
tout se passe comme prévu.
Gestion de la minimisation de la fenêtre
Il existe un autre cas important où la swap chain peut devenir invalide : si la
fenêtre est minimisée. Ce cas est particulier car il résulte en un framebuffer de
taille 0. Dans ce tutoriel nous mettrons en pause le programme jusqu’à ce que
la fenêtre soit remise en avant-plan. À ce moment-là nous recréerons la swap
chain.
1 void recreateSwapChain() {
2 int width = 0, height = 0;
3 glfwGetFramebufferSize(window, &width, &height);
4 while (width == 0 || height == 0) {
5 glfwGetFramebufferSize(window, &width, &height);
6 glfwWaitEvents();
142
7 }
8
9 vkDeviceWaitIdle(device);
10
11 ...
12 }
L’appel initial à glfwGetFramebufferSize prend en charge le cas où la taille
est déjà correcte et glfwWaitEvents n’aurait rien à attendre.
Félicitations, vous avez codé un programme fonctionnel avec Vulkan! Dans le
prochain chapitre nous allons supprimer les sommets du vertex shader et mettre
en place un vertex buffer.
Code C++ / Vertex shader / Fragment shader
143
Vertex buffers
Description des entrées des sommets
Introduction
Dans les quatre 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 som-
mets 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.
1 #version 450
2
3 layout(location = 0) in vec2 inPosition;
4 layout(location = 1) in vec3 inColor;
5
6 layout(location = 0) out vec3 fragColor;
7
8 out gl_PerVertex {
9 vec4 gl_Position;
10 };
11
12 void main() {
13 gl_Position = vec4(inPosition, 0.0, 1.0);
14 fragColor = inColor;
15 }
144
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 :
1 layout(location = 0) in dvec3 inPosition;
2 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.
1 #include <glm/glm.hpp>
Créez une nouvelle structure appelée Vertex. Elle possède deux attributs que
nous utiliserons pour le vertex shader :
1 struct Vertex {
2 glm::vec2 pos;
3 glm::vec3 color;
4 };
GLM nous fournit des types très pratiques simulant les types utilisés par GLSL.
1 const std::vector<Vertex> vertices = {
2 {{0.0f, -0.5f}, {1.0f, 0.0f, 0.0f}},
3 {{0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}},
4 {{-0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}
5 };
Nous utiliserons ensuite un tableau de structures pour représenter un ensem-
ble 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.
145
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.
1 struct Vertex {
2 glm::vec2 pos;
3 glm::vec3 color;
4
5 static VkVertexInputBindingDescription getBindingDescription() {
6 VkVertexInputBindingDescription bindingDescription{};
7
8 return bindingDescription;
9 }
10 };
Un vertex binding décrit la lecture des données stockées en mémoire. Elle four-
nit 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.
1 VkVertexInputBindingDescription bindingDescription{};
2 bindingDescription.binding = 0;
3 bindingDescription.stride = sizeof(Vertex);
4 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.
146
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 :
1 #include <array>
2
3 ...
4
5 static std::array<VkVertexInputAttributeDescription, 2>
getAttributeDescriptions() {
6 std::array<VkVertexInputAttributeDescription, 2>
attributeDescriptions{};
7
8 return attributeDescriptions;
9 }
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.
1 attributeDescriptions[0].binding = 0;
2 attributeDescriptions[0].location = 0;
3 attributeDescriptions[0].format = VK_FORMAT_R32G32_SFLOAT;
4 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,
147
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.
1 attributeDescriptions[1].binding = 0;
2 attributeDescriptions[1].location = 1;
3 attributeDescriptions[1].format = VK_FORMAT_R32G32B32_SFLOAT;
4 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 :
1 auto bindingDescription = Vertex::getBindingDescription();
2 auto attributeDescriptions = Vertex::getAttributeDescriptions();
3
4 vertexInputInfo.vertexBindingDescriptionCount = 1;
5 vertexInputInfo.vertexAttributeDescriptionCount =
static_cast<uint32_t>(attributeDescriptions.size());
6 vertexInputInfo.pVertexBindingDescriptions = &bindingDescription;
7 vertexInputInfo.pVertexAttributeDescriptions =
attributeDescriptions.data();
148
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
Création de vertex buffers
Introduction
Les buffers sont pour Vulkan des emplacements mémoire qui peuvent permettre
de stocker des données quelconques sur la carte graphique. Nous pouvons en
particulier y placer les données représentant les sommets, et c’est ce que nous
allons faire dans ce chapitre. Nous verrons plus tard d’autres utilisations ré-
pandues. Au contraire des autres objets que nous avons rencontré les buffers
n’allouent pas eux-mêmes de mémoire. Il nous faudra gérer la mémoire à la
main.
Création d’un buffer
Créez la fonction createVertexBuffer et appelez-la depuis initVulkan juste
avant createCommandBuffers.
1 void initVulkan() {
2 createInstance();
3 setupDebugMessenger();
4 createSurface();
5 pickPhysicalDevice();
6 createLogicalDevice();
7 createSwapChain();
8 createImageViews();
9 createRenderPass();
10 createGraphicsPipeline();
11 createFramebuffers();
12 createCommandPool();
13 createVertexBuffer();
14 createCommandBuffers();
15 createSyncObjects();
16 }
17
18 ...
19
20 void createVertexBuffer() {
21
22 }
149
Pour créer un buffer nous allons devoir remplir une structure de type
VkBufferCreateInfo.
1 VkBufferCreateInfo bufferInfo{};
2 bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
3 bufferInfo.size = sizeof(vertices[0]) * vertices.size();
Le premier champ de cette structure s’appelle size. Il spécifie la taille du
buffer en octets. Nous pouvons utiliser sizeof pour déterminer la taille de
notre tableau de valeur.
1 bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
Le deuxième champ, appelé usage, correspond à l’utilisation type du buffer.
Nous pouvons indiquer plusieurs valeurs représentant les utilisations possibles.
Dans notre cas nous ne mettons que la valeur qui correspond à un vertex buffer.
1 bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
De la même manière que les images de la swap chain, les buffers peuvent soit
être gérés par une queue family, ou bien être partagés entre plusieurs queue
families. Notre buffer ne sera utilisé que par la queue des graphismes, nous
pouvons donc rester en mode exclusif.
Le paramètre flags permet de configurer le buffer tel qu’il puisse être constitué
de plusieurs emplacements distincts dans la mémoire. Nous n’utiliserons pas
cette fonctionnalité, laissez flags à 0.
Nous pouvons maintenant créer le buffer en appelant vkCreateBuffer. Définis-
sez un membre donnée pour stocker ce buffer :
1 VkBuffer vertexBuffer;
2
3 ...
4
5 void createVertexBuffer() {
6 VkBufferCreateInfo bufferInfo{};
7 bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
8 bufferInfo.size = sizeof(vertices[0]) * vertices.size();
9 bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
10 bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
11
12 if (vkCreateBuffer(device, &bufferInfo, nullptr, &vertexBuffer)
!= VK_SUCCESS) {
13 throw std::runtime_error("echec de la creation d'un vertex
buffer!");
14 }
15 }
150
Le buffer doit être disponible pour toutes les opérations de rendu, nous ne
pouvons donc le détruire qu’à la fin du programme, et ce dans cleanup car il
ne dépend pas de la swap chain.
1 void cleanup() {
2 cleanupSwapChain();
3
4 vkDestroyBuffer(device, vertexBuffer, nullptr);
5
6 ...
7 }
Fonctionnalités nécessaires de la mémoire
Le buffer a été créé mais il n’est lié à aucune forme de mémoire. La première
étape de l’allocation de mémoire consiste à récupérer les fonctionnalités dont le
buffer a besoin à l’aide de la fonction vkGetBufferMemoryRequirements.
1 VkMemoryRequirements memRequirements;
2 vkGetBufferMemoryRequirements(device, vertexBuffer,
&memRequirements);
La structure que la fonction nous remplit possède trois membres :
• size : le nombre d’octets dont le buffer a besoin, ce qui peut différer de
ce que nous avons écrit en préparant le buffer
• alignment : le décalage en octets entre le début de la mémoire allouée
pour lui et le début des données du buffer, ce que le driver détermine avec
les valeurs que nous avons fournies dans usage et flags
• memoryTypeBits : champs de bits combinant les types de mémoire qui
conviennent au buffer
Les cartes graphiques offrent plusieurs types de mémoire. Ils diffèrent en per-
formance et en opérations disponibles. Nous devons considérer ce dont le buffer
a besoin en même temps que ce dont nous avons besoin pour sélectionner le
meilleur type de mémoire possible. Créons une fonction findMemoryType pour
y isoler cette logique.
1 uint32_t findMemoryType(uint32_t typeFilter, VkMemoryPropertyFlags
properties) {
2
3 }
Nous allons commencer cette fonction en récupérant les différents types de mé-
moire que la carte graphique peut nous offrir.
1 VkPhysicalDeviceMemoryProperties memProperties;
2 vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties);
151
La structure VkPhysicalDeviceMemoryProperties comprend deux tableaux
appelés memoryHeaps et memoryTypes. Une pile de mémoire (memory heap
en anglais) correspond aux types physiques de mémoire. Par exemple la VRAM
est une pile, de même que la RAM utilisée comme zone de swap si la VRAM
est pleine en est une autre. Tous les autres types de mémoire stockés dans
memoryTypes sont répartis dans ces piles. Nous n’allons pas utiliser la pile
comme facteur de choix, mais vous pouvez imaginer l’impact sur la performance
que cette distinction peut avoir.
Trouvons d’abord un type de mémoire correspondant au buffer :
1 for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
2 if (typeFilter & (1 << i)) {
3 return i;
4 }
5 }
6
7 throw std::runtime_error("aucun type de memoire ne satisfait le
buffer!");
Le paramètre typeFilter nous permettra d’indiquer les types de mémoire néces-
saires au buffer lors de l’appel à la fonction. Ce champ de bit voit son n-ième
bit mis à 1 si le n-ième type de mémoire disponible lui convient. Ainsi nous
pouvons itérer sur les bits de typeFilter pour trouver les types de mémoire
qui lui correspondent.
Cependant cette vérification ne nous est pas suffisante. Nous devons vérifier
que la mémoire est accesible depuis le CPU afin de pouvoir y écrire les données
des vertices. Nous devons pour cela vérifier que le champ de bits properyFlags
comprend au moins VK_MEMORY_PROPERTY_HOSY_VISIBLE_BIT, de même que
VK_MEMORY_PROPERTY_HOSY_COHERENT_BIT. Nous verrons pourquoi cette deux-
ième valeur est nécessaire quand nous lierons de la mémoire au buffer.
Nous placerons ces deux valeurs dans le paramètre properties. Nous pouvons
changer la boucle pour qu’elle prenne en compte le champ de bits :
1 for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
2 if ((typeFilter & (1 << i)) &&
(memProperties.memoryTypes[i].propertyFlags & properties) ==
properties) {
3 return i;
4 }
5 }
Le ET bit à bit fournit une valeur non nulle si et seulement si au moins l’une
des propriétés est supportée. Nous ne pouvons nous satisfaire de cela, c’est
pourquoi il est nécessaire de comparer le résultat au champ de bits complet.
Si ce résultat nous convient, nous pouvons retourner l’indice de la mémoire
152
et utiliser cet emplacement. Si aucune mémoire ne convient nous levons une
exception.
Allocation de mémoire
Maintenant que nous pouvons déterminer un type de mémoire nous convenant,
nous pouvons y allouer de la mémoire. Nous devons pour cela remplir la struc-
ture VkMemoryAllocateInfo.
1 VkMemoryAllocateInfo allocInfo{};
2 allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
3 allocInfo.allocationSize = memRequirements.size;
4 allocInfo.memoryTypeIndex =
findMemoryType(memRequirements.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
Pour allouer de la mémoire il nous suffit d’indiquer une taille et un type, ce que
nous avons déjà déterminé. Créez un membre donnée pour contenir la référence
à l’espace mémoire et allouez-le à l’aide de vkAllocateMemory.
1 VkBuffer vertexBuffer;
2 VkDeviceMemory vertexBufferMemory;
3
4 ...
5 if (vkAllocateMemory(device, &allocInfo, nullptr,
&vertexBufferMemory) != VK_SUCCESS) {
6 throw std::runtime_error("echec d'une allocation de memoire!");
7 }
Si l’allocation a réussi, nous pouvons associer cette mémoire au buffer avec la
fonction vkBindBufferMemory :
1 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 buffer. Nous avons alloué cette mé-
moire spécialement pour ce buffer, nous pouvons donc mettre 0. Si vous décidez
d’allouer un grand espace mémoire pour y mettre plusieurs buffers, sachez qu’il
faut que ce nombre soit divisible par memRequirements.alignement. Notez
que cette stratégie est la manière recommandée de gérer la mémoire des GPUs
(voyez cet article).
Il est évident que cette allocation dynamique de mémoire nécessite que nous
libérions l’emplacement nous-mêmes. Comme la mémoire est liée au buffer, et
que le buffer sera nécessaire à toutes les opérations de rendu, nous ne devons la
libérer qu’à la fin du programme.
153
1 void cleanup() {
2 cleanupSwapChain();
3
4 vkDestroyBuffer(device, vertexBuffer, nullptr);
5 vkFreeMemory(device, vertexBufferMemory, nullptr);
Remplissage du vertex buffer
Il est maintenant temps de placer les données des vertices dans le buffer. Nous
allons mapper la mémoire dans un emplacement accessible par le CPU à l’aide
de la fonction vkMapMemory.
1 void* data;
2 vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0,
&data);
Cette fonction nous permet d’accéder à une région spécifique d’une ressource.
Nous devons pour cela indiquer un décalage et une taille. Nous mettons ici
respectivement 0 et bufferInfo.size. Il est également possible de fournir
la valeur VK_WHOLE_SIZE pour mapper d’un coup toute la ressource. L’avant-
dernier paramètre est un champ de bits pour l’instant non implémenté par
Vulkan. Il est impératif de la laisser à 0. Enfin, le dernier paramètre permet de
fournir un pointeur vers la mémoire ainsi mappée.
1 void* data;
2 vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0,
&data);
3 memcpy(data, vertices.data(), (size_t) bufferInfo.size);
4 vkUnmapMemory(device, vertexBufferMemory);
Vous pouvez maintenant utiliser memcpy pour copier les vertices dans la mémoire,
puis démapper le buffer à l’aide de vkUnmapMemory. Malheureusement le driver
peut décider de cacher les données avant de les copier dans le buffer. Il est
aussi possible que les données soient copiées mais que ce changement ne soit pas
visible immédiatement. Il y a deux manières de régler ce problème :
• Utiliser une pile de mémoire cohérente avec la RAM, ce qui est indiqué
par VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
• Appeler vkFlushMappedMemoryRanges après avoir copié les données, puis
appeler vkInvalidateMappedMemory avant d’accéder à la mémoire
Nous utiliserons la première approche qui nous assure une cohérence permanente.
Cette méthode est moins performante que le flushing explicite, mais nous verrons
dès le prochain chapitre que cela n’a aucune importance car nous changerons
complètement de stratégie.
Par ailleurs, notez que l’utilisation d’une mémoire cohérente ou le flushing de
la mémoire ne garantissent que le fait que le driver soit au courant des modifi-
154
cations de la mémoire. La seule garantie est que le déplacement se finisse d’ici
le prochain appel à vkQueueSubmit.
Remarquez également l’utilisation de memcpy qui indique la compatibilité bit-à-
bit des structures avec la représentation sur la carte graphique.
Lier le vertex buffer
Il ne nous reste qu’à lier le vertex buffer pour les opérations de rendu. Nous
allons pour cela compléter la fonction createCommandBuffers.
1 vkCmdBindPipeline(commandBuffers[i],
VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);
2
3 VkBuffer vertexBuffers[] = {vertexBuffer};
4 VkDeviceSize offsets[] = {0};
5 vkCmdBindVertexBuffers(commandBuffers[i], 0, 1, vertexBuffers,
offsets);
6
7 vkCmdDraw(commandBuffers[i], static_cast<uint32_t>(vertices.size()),
1, 0, 0);
La fonction vkCmdBindVertexBuffers lie des vertex buffers aux bindings. Les
deuxième et troisième paramètres indiquent l’indice du premier binding auquel
le buffer correspond et le nombre de bindings qu’il contiendra. L’avant-dernier
paramètre est le tableau de vertex buffers à lier, et le dernier est un tableau de
décalages en octets entre le début d’un buffer et le début des données. Il est
d’ailleurs préférable d’appeler vkCmdDraw avec la taille du tableau de vertices
plutôt qu’avec un nombre écrit à la main.
Lancez maintenant le programme; vous devriez voir le triangle habituel appa-
raître à l’écran.
155
Essayez de colorer le vertex du haut en blanc et relancez le programme :
1 const std::vector<Vertex> vertices = {
2 {{0.0f, -0.5f}, {1.0f, 1.0f, 1.0f}},
3 {{0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}},
4 {{-0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}
5 };
156
Dans le prochain chapitre nous verrons une autre manière de copier les données
vers un buffer. Elle est plus performante mais nécessite plus de travail.
Code C++ / Vertex shader / Fragment shader
Buffer intermédiaire
Introduction
Nous avons maintenant un vertex buffer fonctionnel. Par contre il n’est pas dans
la mémoire la plus optimale posible pour la carte graphique. Il serait préférable
d’utiliser une mémoire VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, mais de telles
mémoires ne sont pas accessibles depuis le CPU. Dans ce chapitre nous allons
créer deux vertex buffers. Le premier, un buffer intermédiaire (staging buffer),
sera stocké dans de la mémoire accessible depuis le CPU, et nous y mettrons
nos données. Le second sera directement dans la carte graphique, et nous y
copierons les données des vertices depuis le buffer intermédiaire.
Queue de transfert
La commande de copie des buffers provient d’une queue family qui supporte
les opérations de transfert, ce qui est indiqué par VK_QUEUE_TRANFER_BIT. Une
bonne nouvelle : toute queue qui supporte les graphismes ou le calcul doit
157
supporter les transferts. Par contre il n’est pas obligatoire pour ces queues de
l’indiquer dans le champ de bit qui les décrit.
Si vous aimez la difficulté, vous pouvez préférer l’utilisation d’une queue spéci-
fique aux opérations de transfert. Vous aurez alors ceci à changer :
• Modifier la structure QueueFamilyIndices et la fonction findQueueFamilies
pour obtenir une queue family dont la description comprend VK_QUEUE_TRANSFER_BIT
mais pas VK_QUEUE_GRAPHICS_BIT
• Modifier createLogicalDevice pour y récupérer une référence à une
queue de transfert
• Créer une command pool pour les command buffers envoyés à la queue de
transfert
• Changer la valeur de sharingMode pour les ressources qui le demandent
à VK_SHARING_MODE_CONCURRENT, et indiquer à la fois la queue des
graphismes et la queue ds transferts
• Émettre toutes les commandes de transfert telles vkCmdCopyBuffer - nous
allons l’utiliser dans ce chapitre - à la queue de transfert au lieu de la queue
des graphismes
Cela représente pas mal de travail, mais vous en apprendrez beaucoup sur la
gestion des resources entre les queue families.
Abstraction de la création des buffers
Comme nous allons créer plusieurs buffers, il serait judicieux de placer la logique
dans une fonction. Appelez-la createBuffer et déplacez-y le code suivant :
1 void createBuffer(VkDeviceSize size, VkBufferUsageFlags usage,
VkMemoryPropertyFlags properties, VkBuffer& buffer,
VkDeviceMemory& bufferMemory) {
2 VkBufferCreateInfo bufferInfo{};
3 bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
4 bufferInfo.size = size;
5 bufferInfo.usage = usage;
6 bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
7
8 if (vkCreateBuffer(device, &bufferInfo, nullptr, &buffer) !=
VK_SUCCESS) {
9 throw std::runtime_error("echec de la creation d'un
buffer!");
10 }
11
12 VkMemoryRequirements memRequirements;
13 vkGetBufferMemoryRequirements(device, buffer, &memRequirements);
14
15 VkMemoryAllocateInfo allocInfo{};
16 allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
158
17 allocInfo.allocationSize = memRequirements.size;
18 allocInfo.memoryTypeIndex =
findMemoryType(memRequirements.memoryTypeBits, properties);
19
20 if (vkAllocateMemory(device, &allocInfo, nullptr, &bufferMemory)
!= VK_SUCCESS) {
21 throw std::runtime_error("echec de l'allocation de
memoire!");
22 }
23
24 vkBindBufferMemory(device, buffer, bufferMemory, 0);
25 }
Cette fonction nécessite plusieurs paramètres, tels que la taille du buffer, les
propriétés dont nous avons besoin et l’utilisation type du buffer. La fonction
a deux résultats, elle fonctionne donc en modifiant la valeur des deux derniers
paramètres, dans lesquels elle place les référernces aux objets créés.
Vous pouvez maintenant supprimer la création du buffer et l’allocation de la
mémoire de createVertexBuffer et remplacer tout ça par un appel à votre
nouvelle fonction :
1 void createVertexBuffer() {
2 VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
3 createBuffer(bufferSize, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, vertexBuffer,
vertexBufferMemory);
4
5 void* data;
6 vkMapMemory(device, vertexBufferMemory, 0, bufferSize, 0, &data);
7 memcpy(data, vertices.data(), (size_t) bufferSize);
8 vkUnmapMemory(device, vertexBufferMemory);
9 }
Lancez votre programme et assurez-vous que tout fonctionne toujours aussi bien.
Utiliser un buffer intermédiaire
Nous allons maintenant faire en sorte que createVertexBuffer utilise d’abord
un buffer visible pour copier les données sur la carte graphique, puis qu’il utilise
de la mémoire locale à la carte graphique pour le véritable buffer.
1 void createVertexBuffer() {
2 VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
3
4 VkBuffer stagingBuffer;
159
5 VkDeviceMemory stagingBufferMemory;
6 createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer,
stagingBufferMemory);
7
8 void* data;
9 vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0,
&data);
10 memcpy(data, vertices.data(), (size_t) bufferSize);
11 vkUnmapMemory(device, stagingBufferMemory);
12
13 createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT |
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, vertexBuffer,
vertexBufferMemory);
14 }
Nous utilisons ainsi un nouveau stagingBuffer lié à la stagingBufferMemory
pour transmettre les données à la carte graphique. Dans ce chapitre nous allons
utiliser deux nouvelles valeurs pour les utilisations des buffers :
• VK_BUFFER_USAGE_TRANSFER_SCR_BIT : le buffer peut être utilisé comme
source pour un transfert de mémoire
• VK_BUFFER_USAGE_TRANSFER_DST_BIT : le buffer peut être utilisé comme
destination pour un transfert de mémoire
Le vertexBuffer est maintenant alloué à partir d’un type de mémoire lo-
cal au device, ce qui implique en général que nous ne pouvons pas utiliser
vkMapMemory. Nous pouvons cependant bien sûr y copier les données depuis
le buffer intermédiaire. Nous pouvons indiquer que nous voulons transmet-
tre des données entre ces buffers à l’aide des valeurs que nous avons vues
juste au-dessus. Nous pouvons combiner ces informations avec par exemple
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT.
Nous allons maintenant écrire la fonction copyBuffer, qui servira à recopier le
contenu du buffer intermédiaire dans le véritable buffer.
1 void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize
size) {
2
3 }
Les opérations de transfert de mémoire sont réalisées à travers un com-
mand buffer, comme pour l’affichage. Nous devons commencer par al-
louer des command buffers temporaires. Vous devriez d’ailleurs utiliser
une autre command pool pour tous ces command buffer temporaires,
afin de fournir à l’implémentation une occasion d’optimiser la gestion
160
de la mémoire séparément des graphismes. Si vous le faites, utilisez
VK_COMMAND_POOL_CREATE_TRANSIENT_BIT pendant la création de la command
pool, car les commands buffers ne seront utilisés qu’une seule fois.
1 void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize
size) {
2 VkCommandBufferAllocateInfo allocInfo{};
3 allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
4 allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
5 allocInfo.commandPool = commandPool;
6 allocInfo.commandBufferCount = 1;
7
8 VkCommandBuffer commandBuffer;
9 vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
10 }
Enregistrez ensuite le command buffer :
1 VkCommandBufferBeginInfo beginInfo{};
2 beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
3 beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
4
5 vkBeginCommandBuffer(commandBuffer, &beginInfo);
Nous allons utiliser le command buffer une fois seulement, et attendre que
la copie soit terminée avant de sortir de la fonction. Il est alors préférable
d’informer le driver de cela à l’aide de VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT.
1 VkBufferCopy copyRegion{};
2 copyRegion.srcOffset = 0; // Optionnel
3 copyRegion.dstOffset = 0; // Optionnel
4 copyRegion.size = size;
5 vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer, 1, ©Region);
La copie est réalisée à l’aide de la commande vkCmdCopyBuffer. Elle prend les
buffers de source et d’arrivée comme arguments, et un tableau des régions à
copier. Ces régions sont décrites dans des structures de type VkBufferCopy, qui
consistent en un décalage dans le buffer source, le nombre d’octets à copier
et le décalage dans le buffer d’arrivée. Il n’est ici pas possible d’indiquer
VK_WHOLE_SIZE.
1 vkEndCommandBuffer(commandBuffer);
Ce command buffer ne sert qu’à réaliser les copies des buffers, nous pouvons
donc arrêter l’enregistrement dès maintenant. Exécutez le command buffer pour
compléter le transfert :
1 VkSubmitInfo submitInfo{};
161
2 submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
3 submitInfo.commandBufferCount = 1;
4 submitInfo.pCommandBuffers = &commandBuffer;
5
6 vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);
7 vkQueueWaitIdle(graphicsQueue);
Au contraire des commandes d’affichage très complexes, il n’y a pas de synchro-
nisation particulière à mettre en place. Nous voulons simplement nous assurer
que le transfert se réalise immédiatement. Deux possibilités s’offrent alors à
nous : utiliser une fence et l’attendre avec vkWaitForFences, ou simplement
attendre avec vkQueueWaitIdle que la queue des transfert soit au repos. Les
fences permettent de préparer de nombreux transferts pour qu’ils s’exécutent
concurentiellement, et offrent au driver encore une manière d’optimiser le tra-
vail. L’autre méthode a l’avantage de la simplicité. Implémentez le système de
fence si vous le désirez, mais cela vous obligera à modifier l’organisation de ce
module.
1 vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);
N’oubliez pas de libérer le command buffer utilisé pour l’opération de transfert.
Nous pouvons maintenant appeler copyBuffer depuis la fonction createVertexBuffer
pour que les sommets soient enfin stockées dans la mémoire locale.
1 createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT |
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, vertexBuffer,
vertexBufferMemory);
2
3 copyBuffer(stagingBuffer, vertexBuffer, bufferSize);
Maintenant que les données sont dans la carte graphique, nous n’avons plus
besoin du buffer intermédiaire, et devons donc le détruire.
1 ...
2
3 copyBuffer(stagingBuffer, vertexBuffer, bufferSize);
4
5 vkDestroyBuffer(device, stagingBuffer, nullptr);
6 vkFreeMemory(device, stagingBufferMemory, nullptr);
7 }
Lancez votre programme pour vérifier que vous voyez toujours le même triangle.
L’amélioration n’est peut-être pas flagrante, mais il est clair que la mémoire per-
met d’améliorer les performances, préparant ainsi le terrain pour le chargement
de géométrie plus complexe.
162
Conclusion
Notez que dans une application réelle, vous ne devez pas allouer de la mé-
moire avec vkAllocateMemory pour chaque buffer. De toute façon le nombre
d’appel à cette fonction est limité, par exemple à 4096, et ce même sur des
cartes graphiques comme les GTX 1080. La bonne pratique consiste à allouer
une grande zone de mémoire et d’utiliser un gestionnaire pour créer des dé-
calages pour chacun des buffers. Il est même préférable d’utiliser un buffer pour
plusieurs types de données (sommets et uniformes par exemple) et de séparer
ces types grâce à des indices dans le buffer (voyez encore ce même article).
Vous pouvez implémenter votre propre solution, ou bien utiliser la librairie
VulkanMemoryAllocator crée par GPUOpen. Pour ce tutoriel, ne vous inquiétez
pas pour cela car nous n’atteindrons pas cette limite.
Code C++ / Vertex shader / Fragment shader
Index buffer
Introduction
Les modèles 3D que vous serez susceptibles d’utiliser dans des applications
réelles partagerons le plus souvent des vertices communs à plusieurs triangles.
Cela est d’ailleurs le cas avec un simple rectangle :
Un rectangle est composé de triangles, ce qui signifie que nous aurions besoin
d’un vertex buffer avec 6 vertices. Mais nous dupliquerions alors des vertices,
aboutissant à un gachis de mémoire. Dans des modèles plus complexes, les
vertices sont en moyenne en contact avec 3 triangles, ce qui serait encore pire.
La solution consiste à utiliser un index buffer.
163
Un index buffer est essentiellement un tableau de références vers le vertex buffer.
Il vous permet de réordonner ou de dupliquer les données de ce buffer. L’image
ci-dessus démontre l’utilité de cette méthode.
Création d’un index buffer
Dans ce chapitre, nous allons ajouter les données nécessaires à l’affichage d’un
rectangle. Nous allons ainsi rajouter une coordonnée dans le vertex buffer et
créer un index buffer. Voici les données des sommets au complet :
1 const std::vector<Vertex> vertices = {
2 {{-0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}},
3 {{0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}},
4 {{0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}},
5 {{-0.5f, 0.5f}, {1.0f, 1.0f, 1.0f}}
6 };
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. Les couleurs seront
dégradées par l’interpolation du rasterizer. Nous allons maintenant créer le
tableau indices pour représenter l’index buffer. Son contenu correspond à ce
qui est présenté dans l’illustration.
1 const std::vector<uint16_t> indices = {
2 0, 1, 2, 2, 3, 0
3 };
Il est possible d’utiliser uint16_t ou uint32_t pour les valeurs de l’index buffer,
en fonction du nombre d’éléments dans vertices. Nous pouvons nous contenter
de uint16_t car nous n’utilisons pas plus de 65535 sommets différents.
Comme les données des sommets, nous devons placer les indices dans un
VkBuffer pour que le GPU puisse y avoir accès. Créez deux membres donnée
pour référencer les ressources du futur index buffer :
1 VkBuffer vertexBuffer;
2 VkDeviceMemory vertexBufferMemory;
3 VkBuffer indexBuffer;
4 VkDeviceMemory indexBufferMemory;
La fonction createIndexBuffer est quasiment identique à createVertexBuffer
:
1 void initVulkan() {
2 ...
3 createVertexBuffer();
4 createIndexBuffer();
5 ...
164
6 }
7
8 void createIndexBuffer() {
9 VkDeviceSize bufferSize = sizeof(indices[0]) * indices.size();
10
11 VkBuffer stagingBuffer;
12 VkDeviceMemory stagingBufferMemory;
13 createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer,
stagingBufferMemory);
14
15 void* data;
16 vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0,
&data);
17 memcpy(data, indices.data(), (size_t) bufferSize);
18 vkUnmapMemory(device, stagingBufferMemory);
19
20 createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT |
VK_BUFFER_USAGE_INDEX_BUFFER_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, indexBuffer,
indexBufferMemory);
21
22 copyBuffer(stagingBuffer, indexBuffer, bufferSize);
23
24 vkDestroyBuffer(device, stagingBuffer, nullptr);
25 vkFreeMemory(device, stagingBufferMemory, nullptr);
26 }
Il n’y a que deux différences : bufferSize correspond à la taille du tableau
multiplié par sizeof(uint16_t), et VK_BUFFER_USAGE_VERTEX_BUFFER_BIT est
remplacé par VK_BUFFER_USAGE_INDEX_BUFFER_BIT. À part ça tout est iden-
tique : nous créons un buffer intermédiaire puis le copions dans le buffer final
local au GPU.
L’index buffer doit être libéré à la fin du programme depuis cleanup.
1 void cleanup() {
2 cleanupSwapChain();
3
4 vkDestroyBuffer(device, indexBuffer, nullptr);
5 vkFreeMemory(device, indexBufferMemory, nullptr);
6
7 vkDestroyBuffer(device, vertexBuffer, nullptr);
8 vkFreeMemory(device, vertexBufferMemory, nullptr);
9
10 ...
165
11 }
Utilisation d’un index buffer
Pour utiliser l’index buffer lors des opérations de rendu nous devons modifier
un petit peu createCommandBuffers. Tout d’abord il nous faut lier l’index
buffer. La différence est qu’il n’est pas possible d’avoir plusieurs index buffers.
De plus il n’est pas possible de subdiviser les sommets en leurs coordonnées, ce
qui implique que la modification d’une seule coordonnée nécessite de créer un
autre sommet le vertex buffer.
1 vkCmdBindVertexBuffers(commandBuffers[i], 0, 1, vertexBuffers,
offsets);
2
3 vkCmdBindIndexBuffer(commandBuffers[i], indexBuffer, 0,
VK_INDEX_TYPE_UINT16);
Un index buffer est lié par la fonction vkCmdBindIndexBuffer. Elle prend en
paramètres le buffer, le décalage dans ce buffer et le type de donnée. Pour nous
ce dernier sera VK_INDEX_TYPE_UINT16.
Simplement lier le vertex buffer ne change en fait rien. Il nous faut aussi mettre
à jour les commandes d’affichage pour indiquer à Vulkan comment utiliser le
buffer. Supprimez l’appel à vkCmdDraw, et remplacez-le par vkCmdDrawIndexed
:
1 vkCmdDrawIndexed(commandBuffers[i],
static_cast<uint32_t>(indices.size()), 1, 0, 0, 0);
Le deuxième paramètre indique le nombre d’indices. Le troisième est le nom-
bre d’instances à invoquer (ici 1 car nous n’utilisons par cette technique). Le
paramètre suivant est un décalage dans l’index buffer, sachant qu’ici il ne fonc-
tionne pas en octets mais en indices. L’avant-dernier paramètre permet de
fournir une valeur qui sera ajoutée à tous les indices juste avant de les faire
correspondre aux vertices. Enfin, le dernier paramètre est un décalage pour le
rendu instancié.
Lancez le programme et vous devriez avoir ceci :
166
Vous savez maintenant économiser la mémoire en réutilisant les vertices à l’aide
d’un index buffer. Cela deviendra crucial pour les chapitres suivants dans
lesquels vous allez apprendre à charger des modèles complexes.
Nous avons déjà évoqué le fait que le plus de buffers possibles devraient
être stockés dans un seul emplacement mémoire. Il faudrait dans l’idéal
allez encore plus loin : les développeurs des drivers recommandent également
que vous placiez plusieurs buffers dans un seul et même VkBuffer, et que
vous utilisiez des décalages pour les différencier dans les fonctions comme
vkCmdBindVertexBuffers. Cela simplifie la mise des données dans des caches
car elles sont regroupées en un bloc. Il devient même possible d’utiliser la même
mémoire pour plusieurs ressources si elles ne sont pas utilisées en même temps
et si elles sont proprement mises à jour. Cette pratique s’appelle d’ailleurs
aliasing, et certaines fonctions Vulkan possèdent un paramètre qui permet au
développeur d’indiquer s’il veut utiliser la technique.
Code C++ / Vertex shader / Fragment shader
167
Uniform buffers
Descriptor layout et buffer
Introduction
Nous pouvons maintenant passer des données à chaque groupe d’invocation
de vertex shaders. Mais qu’en est-il des variables globales? Nous allons enfin
passer à la 3D, et nous avons besoin d’une matrice model-view-projection. Nous
pourrions la transmettre avec les vertices, mais cela serait un gachis de mémoire
et, de plus, nous devrions mettre à jour le vertex buffer à chaque frame, alors
qu’il est très bien rangé dans se mémoire à hautes performances.
La solution fournie par Vulkan consiste à utiliser des descripteurs de ressource
(ou resource descriptors), qui font correspondre des données en mémoire à une
variable shader. Un descripteur permet à des shaders d’accéder librement à
des ressources telles que les buffers ou les images. Attention, Vulkan donne un
sens particulier au terme image. Nous verrons cela bientôt. Nous allons pour
l’instant créer un buffer qui contiendra les matrices de transformation. Nous
ferons en sorte que le vertex shader puisse y accéder. Il y a trois parties à
l’utilisation d’un descripteur de ressources :
• Spécifier l’organisation des descripteurs durant la création de la pipeline
• Allouer un set de descripteurs depuis une pool de descripteurs (encore un
objet de gestion de mémoire)
• Lier le descripteur pour les opérations de rendu
L’organisation du descripteur (descriptor layout) indique le type de ressources
qui seront accédées par la pipeline. Cela ressemble sur le principe à indiquer les
attachements accédés. Un set de descripteurs (descriptor set) spécifie le buffer
ou l’image qui sera lié à ce descripteur, de la même manière qu’un framebuffer
doit indiquer les ressources qui le composent.
Il existe plusieurs types de descripteurs, mais dans ce chapitre nous ne verrons
que les uniform buffer objects (UBO). Nous en verrons d’autres plus tard, et
leur utilisation sera très similaire. Rentrons dans le vif du sujet et supposons
maintenant que nous voulons que toutes les invocations du vertex shader que
168
nous avons codé accèdent à la structure C suivante :
1 struct UniformBufferObject {
2 glm::mat4 model;
3 glm::mat4 view;
4 glm::mat4 proj;
5 };
Nous devons la copier dans un VkBuffer pour pouvoir y accéder à l’aide d’un
descripteur UBO depuis le vertex shader. De son côté le vertex shader y fait
référence ainsi :
1 layout(binding = 0) uniform UniformBufferObject {
2 mat4 model;
3 mat4 view;
4 mat4 proj;
5 } ubo;
6
7 void main() {
8 gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition,
0.0, 1.0);
9 fragColor = inColor;
10 }
Nous allons mettre à jour les matrices model, view et projection à chaque frame
pour que le rectangle tourne sur lui-même et donne un effet 3D à la scène.
Vertex shader
Modifiez le vertex shader pour qu’il inclue l’UBO comme dans l’exemple ci-
dessous. Je pars du principe que vous connaissez les transformations MVP. Si
ce n’est pourtant pas le cas, vous pouvez vous rendre sur ce site déjà mentionné
dans le premier chapitre.
1 #version 450
2
3 layout(binding = 0) uniform UniformBufferObject {
4 mat4 model;
5 mat4 view;
6 mat4 proj;
7 } ubo;
8
9 layout(location = 0) in vec2 inPosition;
10 layout(location = 1) in vec3 inColor;
11
12 layout(location = 0) out vec3 fragColor;
13
169
14 out gl_PerVertex {
15 vec4 gl_Position;
16 };
17
18 void main() {
19 gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition,
0.0, 1.0);
20 fragColor = inColor;
21 }
L’ordre des variables in, out et uniform n’a aucune importance. La direc-
tive binding est assez semblable à location ; elle permet de fournir l’indice
du binding. Nous allons l’indiquer dans l’organisation du descripteur. Notez
le changement dans la ligne calculant gl_Position, qui prend maintenant en
compte la matrice MVP. La dernière composante du vecteur ne sera plus à 0,
car elle sert à diviser les autres coordonnées en fonction de leur distance à la
caméra pour créer un effet de profondeur.
Organisation du set de descripteurs
La prochaine étape consiste à définir l’UBO côté C++. Nous devons aussi
informer Vulkan que nous voulons l’utiliser dans le vertex shader.
1 struct UniformBufferObject {
2 glm::mat4 model;
3 glm::mat4 view;
4 glm::mat4 proj;
5 };
Nous pouvons faire correspondre parfaitement la déclaration en C++ avec celle
dans le shader grâce à GLM. De plus les matrices sont stockées d’une manière
compatible bit à bit avec l’interprétation de ces données par les shaders. Nous
pouvons ainsi utiliser memcpy sur une structure UniformBufferObject vers un
VkBuffer.
Nous devons fournir des informations sur chacun des descripteurs utilisés
par les shaders lors de la création de la pipeline, similairement aux entrées
du vertex shader. Nous allons créer une fonction pour gérer toute cette
information, et ainsi pour créer le set de descripteurs. Elle s’appelera
createDescriptorSetLayout et sera appelée juste avant la finalisation de la
création de la pipeline.
1 void initVulkan() {
2 ...
3 createDescriptorSetLayout();
4 createGraphicsPipeline();
5 ...
170
6 }
7
8 ...
9
10 void createDescriptorSetLayout() {
11
12 }
Chaque binding doit être décrit à l’aide d’une structure de type VkDescriptorSetLayoutBinding.
1 void createDescriptorSetLayout() {
2 VkDescriptorSetLayoutBinding uboLayoutBinding{};
3 uboLayoutBinding.binding = 0;
4 uboLayoutBinding.descriptorType =
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
5 uboLayoutBinding.descriptorCount = 1;
6 }
Les deux premiers champs permettent de fournir la valeur indiquée dans le
shader avec binding et le type de descripteur auquel il correspond. Il est pos-
sible que la variable côté shader soit un tableau d’UBO, et dans ce cas il faut
indiquer le nombre d’éléments qu’il contient dans le membre descriptorCount.
Cette possibilité pourrait être utilisée pour transmettre d’un coup toutes les
transformations spécifiques aux différents éléments d’une structure hiérarchique.
Nous n’utilisons pas cette possiblité et indiquons donc 1.
1 uboLayoutBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
Nous devons aussi informer Vulkan des étapes shaders qui accèderont
à cette ressource. Le champ de bits stageFlags permet de combiner
toutes les étapes shader concernées. Vous pouvez aussi fournir la
valeur VK_SHADER_STAGE_ALL_GRAPHICS. Nous mettons uniquement
VK_SHADER_STAGE_VERTEX_BIT.
1 uboLayoutBinding.pImmutableSamplers = nullptr; // Optionnel
Le champ pImmutableSamplers n’a de sens que pour les descripteurs liés aux
samplers d’images. Nous nous attaquerons à ce sujet plus tard. Vous pouvez le
mettre à nullptr.
Tous les liens des descripteurs sont ensuite combinés en un seul objet
VkDescriptorSetLayout. Créez pour cela un nouveau membre donnée :
1 VkDescriptorSetLayout descriptorSetLayout;
2 VkPipelineLayout pipelineLayout;
Nous pouvons créer cet objet à l’aide de la fonction vkCreateDescriptorSetLayout.
Cette fonction prend en argument une structure de type VkDescriptorSetLayoutCreateInfo.
Elle contient un tableau contenant les structures qui décrivent les bindings :
171
1 VkDescriptorSetLayoutCreateInfo layoutInfo{};
2 layoutInfo.sType =
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
3 layoutInfo.bindingCount = 1;
4 layoutInfo.pBindings = &uboLayoutBinding;
5
6 if (vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr,
&descriptorSetLayout) != VK_SUCCESS) {
7 throw std::runtime_error("echec de la creation d'un set de
descripteurs!");
8 }
Nous devons fournir cette structure à Vulkan durant la création de la pipeline
graphique. Ils sont transmis par la structure VkPipelineLayoutCreateInfo.
Modifiez ainsi la création de cette structure :
1 VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
2 pipelineLayoutInfo.sType =
VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
3 pipelineLayoutInfo.setLayoutCount = 1;
4 pipelineLayoutInfo.pSetLayouts = &descriptorSetLayout;
Vous vous demandez peut-être pourquoi il est possible de spécifier plusieurs set
de descripteurs dans cette structure, dans la mesure où un seul inclut tous les
bindings d’une pipeline. Nous y reviendrons dans le chapitre suivant, quand
nous nous intéresserons aux pools de descripteurs.
L’objet que nous avons créé ne doit être détruit que lorsque le programme se
termine.
1 void cleanup() {
2 cleanupSwapChain();
3
4 vkDestroyDescriptorSetLayout(device, descriptorSetLayout,
nullptr);
5
6 ...
7 }
Uniform buffer
Dans le prochain chapitre nous référencerons le buffer qui contient les données de
l’UBO. Mais nous devons bien sûr d’abord créer ce buffer. Comme nous allons
accéder et modifier les données du buffer à chaque frame, il est assez inutile
d’utiliser un buffer intermédiaire. Ce serait même en fait contre-productif en
terme de performances.
172
Comme des frames peuvent être “in flight” pendant que nous essayons de mod-
ifier le contenu du buffer, nous allons avoir besoin de plusieurs buffers. Nous
pouvons soit en avoir un par frame, soit un par image de la swap chain. Comme
nous avons un command buffer par image nous allons utiliser cette seconde
méthode.
Pour cela créez les membres données uniformBuffers et uniformBuffersMemory
:
1 VkBuffer indexBuffer;
2 VkDeviceMemory indexBufferMemory;
3
4 std::vector<VkBuffer> uniformBuffers;
5 std::vector<VkDeviceMemory> uniformBuffersMemory;
Créez ensuite une nouvelle fonction appelée createUniformBuffers et appelez-
la après createIndexBuffers. Elle allouera les buffers :
1 void initVulkan() {
2 ...
3 createVertexBuffer();
4 createIndexBuffer();
5 createUniformBuffers();
6 ...
7 }
8
9 ...
10
11 void createUniformBuffers() {
12 VkDeviceSize bufferSize = sizeof(UniformBufferObject);
13
14 uniformBuffers.resize(swapChainImages.size());
15 uniformBuffersMemory.resize(swapChainImages.size());
16
17 for (size_t i = 0; i < swapChainImages.size(); i++) {
18 createBuffer(bufferSize, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, uniformBuffers[i],
uniformBuffersMemory[i]);
19 }
20 }
Nous allons créer une autre fonction qui mettra à jour le buffer en appliquant
à son contenu une transformation à chaque frame. Nous n’utiliserons donc pas
vkMapMemory ici. Le buffer doit être détruit à la fin du programme. Mais comme
il dépend du nombre d’images de la swap chain, et que ce nombre peut évoluer
lors d’une reécration, nous devons le supprimer depuis cleanupSwapChain :
173
1 void cleanupSwapChain() {
2 ...
3
4 for (size_t i = 0; i < swapChainImages.size(); i++) {
5 vkDestroyBuffer(device, uniformBuffers[i], nullptr);
6 vkFreeMemory(device, uniformBuffersMemory[i], nullptr);
7 }
8
9 ...
10 }
Nous devons également le recréer depuis recreateSwapChain :
1 void recreateSwapChain() {
2 ...
3 createFramebuffers();
4 createUniformBuffers();
5 createCommandBuffers();
6 }
Mise à jour des données uniformes
Créez la fonction updateUniformBuffer et appelez-la dans drawFrame, juste
après que nous avons déterminé l’image de la swap chain que nous devons ac-
quérir :
1 void drawFrame() {
2 ...
3
4 uint32_t imageIndex;
5 VkResult result = vkAcquireNextImageKHR(device, swapChain,
UINT64_MAX, imageAvailableSemaphores[currentFrame],
VK_NULL_HANDLE, &imageIndex);
6
7 ...
8
9 updateUniformBuffer(imageIndex);
10
11 VkSubmitInfo submitInfo{};
12 submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
13
14 ...
15 }
16
17 ...
18
174
19 void updateUniformBuffer(uint32_t currentImage) {
20
21 }
Cette fonction générera une rotation à chaque frame pour que la géométrie
tourne sur elle-même. Pour ces fonctionnalités mathématiques nous devons
inclure deux en-têtes :
1 #define GLM_FORCE_RADIANS
2 #include <glm/glm.hpp>
3 #include <glm/gtc/matrix_transform.hpp>
4
5 #include <chrono>
Le header <glm/gtc/matrix_transform.hpp> expose des fonctions comme
glm::rotate, glm:lookAt ou glm::perspective, dont nous avons besoin
pour implémenter la 3D. La macro GLM_FORCE_RADIANS permet d’éviter toute
confusion sur la représentation des angles.
Pour que la rotation s’exécute à une vitesse indépendante du FPS, nous allons
utiliser les fonctionnalités de mesure précise de la librairie standrarde C++.
Incluez donc <chrono> :
1 void updateUniformBuffer(uint32_t currentImage) {
2 static auto startTime =
std::chrono::high_resolution_clock::now();
3
4 auto currentTime = std::chrono::high_resolution_clock::now();
5 float time = std::chrono::duration<float,
std::chrono::seconds::period>(currentTime -
startTime).count();
6 }
Nous commençons donc par écrire la logique de calcul du temps écoulé, mesuré
en secondes et stocké dans un float.
Nous allons ensuite définir les matrices model, view et projection stockées dans
l’UBO. La rotation sera implémentée comme une simple rotation autour de l’axe
Z en fonction de la variable time :
1 UniformBufferObject ubo{};
2 ubo.model = glm::rotate(glm::mat4(1.0f), time * glm::radians(90.0f),
glm::vec3(0.0f, 0.0f, 1.0f));
La fonction glm::rotate accepte en argument une matrice déjà existante, un
angle de rotation et un axe de rotation. Le constructeur glm::mat4(1.0) crée
une matrice identité. Avec la multiplication time * glm::radians(90.0f) la
géométrie tournera de 90 degrés par seconde.
175
1 ubo.view = glm::lookAt(glm::vec3(2.0f, 2.0f, 2.0f), glm::vec3(0.0f,
0.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f));
Pour la matrice view, j’ai décidé de la générer de telle sorte que nous regar-
dions le rectangle par dessus avec une inclinaison de 45 degrés. La fonction
glm::lookAt prend en arguments la position de l’oeil, la cible du regard et
l’axe servant de référence pour le haut.
1 ubo.proj = glm::perspective(glm::radians(45.0f),
swapChainExtent.width / (float) swapChainExtent.height, 0.1f,
10.0f);
J’ai opté pour un champ de vision de 45 degrés. Les autres paramètres de
glm::perspective sont le ratio et les plans near et far. Il est important
d’utiliser l’étendue actuelle de la swap chain pour calculer le ratio, afin d’utiliser
les valeurs qui prennent en compte les redimensionnements de la fenêtre.
1 ubo.proj[1][1] *= -1;
GLM a été conçue pour OpenGL, qui utilise les coordonnées de clip et de l’axe
Y à l’envers. La manière la plus simple de compenser cela consiste à changer le
signe de l’axe Y dans la matrice de projection.
Maintenant que toutes les transformations sont définies nous pouvons copier les
données dans le buffer uniform actuel. Nous utilisons la première technique que
nous avons vue pour la copie de données dans un buffer.
1 void* data;
2 vkMapMemory(device, uniformBuffersMemory[currentImage], 0,
sizeof(ubo), 0, &data);
3 memcpy(data, &ubo, sizeof(ubo));
4 vkUnmapMemory(device, uniformBuffersMemory[currentImage]);
Utiliser un UBO de cette manière n’est pas le plus efficace pour transmettre des
données fréquemment mises à jour. Une meilleure pratique consiste à utiliser
les push constants, que nous aborderons peut-être dans un futur chapitre.
Dans un avenir plus proche nous allons lier les sets de descripteurs au VkBuffer
contenant les données des matrices, afin que le vertex shader puisse y avoir
accès.
Code C++ / Vertex shader / Fragment shader
Descriptor pool et sets
Introduction
L’objet VkDescriptorSetLayout que nous avons créé dans le chapitre précédent
décrit les descripteurs que nous devons lier pour les opérations de rendu. Dans
176
ce chapitre nous allons créer les véritables sets de descripteurs, un pour chaque
VkBuffer, afin que nous puissions chacun les lier au descripteur de l’UBO côté
shader.
Pool de descripteurs
Les sets de descripteurs ne peuvent pas être crées directement. Il faut les allouer
depuis une pool, comme les command buffers. Nous allons créer la fonction
createDescriptorPool pour générer une pool de descripteurs.
1 void initVulkan() {
2 ...
3 createUniformBuffer();
4 createDescriptorPool();
5 ...
6 }
7
8 ...
9
10 void createDescriptorPool() {
11
12 }
Nous devons d’abord indiquer les types de descripteurs et combien sont
compris dans les sets. Nous utilisons pour cela une structure du type
VkDescriptorPoolSize :
1 VkDescriptorPoolSize poolSize{};
2 poolSize.type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
3 poolSize.descriptorCount =
static_cast<uint32_t>(swapChainImages.size());
Nous allons allouer un descripteur par frame. Cette structure doit maintenant
être référencée dans la structure principale VkDescriptorPoolCreateInfo.
1 VkDescriptorPoolCreateInfo poolInfo{};
2 poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
3 poolInfo.poolSizeCount = 1;
4 poolInfo.pPoolSizes = &poolSize;
Nous devons aussi spécifier le nombre maximum de sets de descripteurs que
nous sommes susceptibles d’allouer.
1 poolInfo.maxSets = static_cast<uint32_t>(swapChainImages.size());
La structure possède un membre optionnel également présent pour les command
pools. Il permet d’indiquer que les sets peuvent être libérés indépendemment les
uns des autres avec la valeur VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT.
177
Comme nous n’allons pas toucher aux descripteurs pendant que le programme
s’exécute, nous n’avons pas besoin de l’utiliser. Indiquez 0 pour ce champ.
1 VkDescriptorPool descriptorPool;
2
3 ...
4
5 if (vkCreateDescriptorPool(device, &poolInfo, nullptr,
&descriptorPool) != VK_SUCCESS) {
6 throw std::runtime_error("echec de la creation de la pool de
descripteurs!");
7 }
Créez un nouveau membre donnée pour référencer la pool, puis appelez
vkCreateDescriptorPool. La pool doit être recrée avec la swap chain..
1 void cleanupSwapChain() {
2 ...
3 for (size_t i = 0; i < swapChainImages.size(); i++) {
4 vkDestroyBuffer(device, uniformBuffers[i], nullptr);
5 vkFreeMemory(device, uniformBuffersMemory[i], nullptr);
6 }
7
8 vkDestroyDescriptorPool(device, descriptorPool, nullptr);
9
10 ...
11 }
Et recréée dans recreateSwapChain :
1 void recreateSwapChain() {
2 ...
3 createUniformBuffers();
4 createDescriptorPool();
5 createCommandBuffers();
6 }
Set de descripteurs
Nous pouvons maintenant allouer les sets de descripteurs. Créez pour cela la
fonction createDescriptorSets :
1 void initVulkan() {
2 ...
3 createDescriptorPool();
4 createDescriptorSets();
5 ...
6 }
178
7
8 ...
9
10 void createDescriptorSets() {
11
12 }
L’allocation de cette ressource passe par la création d’une structure de type
VkDescriptorSetAllocateInfo. Vous devez bien sûr y indiquer la pool d’où
les allouer, de même que le nombre de sets à créer et l’organisation qu’ils doivent
suivre.
1 std::vector<VkDescriptorSetLayout> layouts(swapChainImages.size(),
descriptorSetLayout);
2 VkDescriptorSetAllocateInfo allocInfo{};
3 allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
4 allocInfo.descriptorPool = descriptorPool;
5 allocInfo.descriptorSetCount =
static_cast<uint32_t>(swapChainImages.size());
6 allocInfo.pSetLayouts = layouts.data();
Dans notre cas nous allons créer autant de sets qu’il y a d’images dans la swap
chain. Ils auront tous la même organisation. Malheureusement nous devons
copier la structure plusieurs fois car la fonction que nous allons utiliser prend
en argument un tableau, dont le contenu doit correspondre indice à indice aux
objets à créer.
Ajoutez un membre donnée pour garder une référence aux sets, et allouez-les
avec vkAllocateDescriptorSets :
1 VkDescriptorPool descriptorPool;
2 std::vector<VkDescriptorSet> descriptorSets;
3
4 ...
5
6 descriptorSets.resize(swapChainImages.size());
7 if (vkAllocateDescriptorSets(device, &allocInfo,
descriptorSets.data()) != VK_SUCCESS) {
8 throw std::runtime_error("echec de l'allocation d'un set de
descripteurs!");
9 }
Il n’est pas nécessaire de détruire les sets de descripteurs explicitement,
car leur libération est induite par la destruction de la pool. L’appel à
vkAllocateDescriptorSets alloue donc tous les sets, chacun possédant un
unique descripteur d’UBO.
1 void cleanup() {
179
2 ...
3 vkDestroyDescriptorPool(device, descriptorPool, nullptr);
4
5 vkDestroyDescriptorSetLayout(device, descriptorSetLayout,
nullptr);
6 ...
7 }
Nous avons créé les sets mais nous n’avons pas paramétré les descripteurs. Nous
allons maintenant créer une boucle pour rectifier ce problème :
1 for (size_t i = 0; i < swapChainImages.size(); i++) {
2
3 }
Les descripteurs référant à un buffer doivent être configurés avec une structure
de type VkDescriptorBufferInfo. Elle indique le buffer contenant les données,
et où les données y sont stockées.
1 for (size_t i = 0; i < swapChainImages.size(); i++) {
2 VkDescriptorBufferInfo bufferInfo{};
3 bufferInfo.buffer = uniformBuffers[i];
4 bufferInfo.offset = 0;
5 bufferInfo.range = sizeof(UniformBufferObject);
6 }
Nous allons utiliser tout le buffer, il est donc aussi possible d’indiquer
VK_WHOLE_SIZE. La configuration des descripteurs est maintenant mise à
jour avec la fonction vkUpdateDescriptorSets. Elle prend un tableau de
VkWriteDescriptorSet en paramètre.
1 VkWriteDescriptorSet descriptorWrite{};
2 descriptorWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
3 descriptorWrite.dstSet = descriptorSets[i];
4 descriptorWrite.dstBinding = 0;
5 descriptorWrite.dstArrayElement = 0;
Les deux premiers champs spécifient le set à mettre à jour et l’indice du binding
auquel il correspond. Nous avons donné à notre unique descripteur l’indice 0.
Souvenez-vous que les descripteurs peuvent être des tableaux ; nous devons donc
aussi indiquer le premier élément du tableau que nous voulons modifier. Nous
n’en n’avons qu’un.
1 descriptorWrite.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
2 descriptorWrite.descriptorCount = 1;
Nous devons encore indiquer le type du descripteur. Il est possible de mettre
à jour plusieurs descripteurs d’un même type en même temps. La fonction
commence à dstArrayElement et s’étend sur descriptorCount descripteurs.
180
1 descriptorWrite.pBufferInfo = &bufferInfo;
2 descriptorWrite.pImageInfo = nullptr; // Optionnel
3 descriptorWrite.pTexelBufferView = nullptr; // Optionnel
Le dernier champ que nous allons utiliser est pBufferInfo. Il permet de
fournir descriptorCount structures qui configureront les descripteurs. Les
autres champs correspondent aux structures qui peuvent configurer des descrip-
teurs d’autres types. Ainsi il y aura pImageInfo pour les descripteurs liés aux
images, et pTexelBufferInfo pour les descripteurs liés aux buffer views.
1 vkUpdateDescriptorSets(device, 1, &descriptorWrite, 0, nullptr);
Les mises à jour sont appliquées quand nous appelons vkUpdateDescriptorSets.
La fonction accepte deux tableaux, un de VkWriteDesciptorSets et un de
VkCopyDescriptorSet. Le second permet de copier des descripteurs.
Utiliser des sets de descripteurs
Nous devons maintenant étendre createCommandBuffers pour qu’elle
lie les sets de descripteurs aux descripteurs des shaders avec la com-
mande vkCmdBindDescriptorSets. Il faut invoquer cette commande dans
l’enregistrement des command buffers avant l’appel à vkCmdDrawIndexed.
1 vkCmdBindDescriptorSets(commandBuffers[i],
VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1,
&descriptorSets[i], 0, nullptr);
2 vkCmdDrawIndexed(commandBuffers[i],
static_cast<uint32_t>(indices.size()), 1, 0, 0, 0);
Au contraire des buffers de vertices et d’indices, les sets de descripteurs ne
sont pas spécifiques aux pipelines graphiques. Nous devons donc spécifier que
nous travaillons sur une pipeline graphique et non pas une pipeline de calcul.
Le troisième paramètre correspond à l’organisation des descripteurs. Viennent
ensuite l’indice du premier descripteur, la quantité à évaluer et bien sûr le set
d’où ils proviennent. Nous y reviendrons. Les deux derniers paramètres sont des
décalages utilisés pour les descripteurs dynamiques. Nous y reviendrons aussi
dans un futur chapitre.
Si vous lanciez le programme vous verrez que rien ne s’affiche. Le problème
est que l’inversion de la coordonnée Y dans la matrice induit l’évaluation
des vertices dans le sens inverse des aiguilles d’une montre (counter-clockwise
en anglais), alors que nous voudrions le contraire. En effet, les systèmes
actuels utilisent ce sens de rotation pour détermnier la face de devant. La face
de derrière est ensuite simplement ignorée. C’est pourquoi notre géométrie
n’est pas rendue. C’est le backface culling. Changez le champ frontface
de la structure VkPipelineRasterizationStateCreateInfo dans la fonction
createGraphicsPipeline de la manière suivante :
181
1 rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
2 rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
Maintenant vous devriez voir ceci en lançant votre programme :
Le rectangle est maintenant un carré car la matrice de projection corrige son
aspect. La fonction updateUniformBuffer inclut d’office les redimension-
nements d’écran, il n’est donc pas nécessaire de recréer les descripteurs dans
recreateSwapChain.
Alignement
Jusqu’à présent nous avons évité la question de la compatibilité des types côté
C++ avec la définition des types pour les variables uniformes. Il semble évident
d’utiliser des types au même nom des deux côtés :
1 struct UniformBufferObject {
2 glm::mat4 model;
3 glm::mat4 view;
4 glm::mat4 proj;
5 };
6 layout(binding = 0) uniform UniformBufferObject {
7 mat4 model;
182
8 mat4 view;
9 mat4 proj;
10 } ubo;
Pourtant ce n’est pas aussi simple. Essayez la modification suivante :
1 struct UniformBufferObject {
2 glm::vec2 foo;
3 glm::mat4 model;
4 glm::mat4 view;
5 glm::mat4 proj;
6 };
7 layout(binding = 0) uniform UniformBufferObject {
8 vec2 foo;
9 mat4 model;
10 mat4 view;
11 mat4 proj;
12 } ubo;
Recompilez les shaders et relancez le programme. Le carré coloré a disparu! La
raison réside dans cette question de l’alignement.
Vulkan s’attend à un certain alignement des données en mémoire pour chaque
type. Par exemple :
• Les scalaires doivent être alignés sur leur nombre d’octets N (float de 32
bits donne un alognement de 4 octets)
• Un vec2 doit être aligné sur 2N (8 octets)
• Les vec3 et vec4 doivent être alignés sur 4N (16 octets)
• Une structure imbriquée doit être alignée sur la somme des alignements
de ses membres arrondie sur le multiple de 16 octets au-dessus
• Une mat4 doit avoir le même alignement qu’un vec4
Les alignemenents imposés peuvent être trouvés dans la spécification
Notre shader original et ses trois mat4 était bien aligné. model a un décalage
de 0, view de 64 et proj de 128, ce qui sont des multiples de 16.
La nouvelle structure commence avec un membre de 8 octets, ce qui décale tout
ce qui suit. Les décalages sont augmentés de 8 et ne sont alors plus multiples
de 16. Nous pouvons fixer ce problème avec le mot-clef alignas :
1 struct UniformBufferObject {
2 glm::vec2 foo;
3 alignas(16) glm::mat4 model;
4 glm::mat4 view;
5 glm::mat4 proj;
6 };
183
Si vous recompilez et relancez, le programme devrait fonctionner à nouveau.
Heureusement pour nous, GLM inclue un moyen qui nous permet de plus penser
à ce souci d’alignement :
1 #define GLM_FORCE_RADIANS
2 #define GLM_FORCE_DEFAULT_ALIGNED_GENTYPES
3 #include <glm/glm.hpp>
La ligne #define GLM_FORCE_DEFAULT_ALIGNED_GENTYPES force GLM a
s’assurer de l’alignement des types qu’elle expose. La limite de cette méthode
s’atteint en utilisant des structures imbriquées. Prenons l’exemple suivant :
1 struct Foo {
2 glm::vec2 v;
3 };
4 struct UniformBufferObject {
5 Foo f1;
6 Foo f2;
7 };
Et côté shader mettons :
1 struct Foo {
2 vec2 v;
3 };
4 layout(binding = 0) uniform UniformBufferObject {
5 Foo f1;
6 Foo f2;
7 } ubo;
Nous nous retrouvons avec un décalage de 8 pour f2 alors qu’il lui faudrait un
décalage de 16. Il faut dans ce cas de figure utiliser alignas :
1 struct UniformBufferObject {
2 Foo f1;
3 alignas(16) Foo f2;
4 };
Pour cette raison il est préférable de toujours être explicite à propos de
l’alignement de données que l’on envoie aux shaders. Vous ne serez pas supris
par des problèmes d’alignement imprévus.
1 struct UniformBufferObject {
2 alignas(16) glm::mat4 model;
3 alignas(16) glm::mat4 view;
4 alignas(16) glm::mat4 proj;
5 };
Recompilez le shader avant de continuer la lecture.
184
Plusieurs sets de descripteurs
Comme on a pu le voir dans les en-têtes de certaines fonctions, il est possible
de lier plusieurs sets de descripteurs en même temps. Vous devez fournir une
organisation pour chacun des sets pendant la mise en place de l’organisation de
la pipeline. Les shaders peuvent alors accéder aux descripteurs de la manière
suivante :
1 layout(set = 0, binding = 0) uniform UniformBufferObject { ... }
Vous pouvez utiliser cette possibilité pour placer dans différents sets les descrip-
teurs dépendant d’objets et les descripteurs partagés. De cette manière vous
éviter de relier constemment une partie des descripteurs, ce qui peut être plus
performant.
Code C++ / Vertex shader / Fragment shader
185
Texture mapping
Images
Introduction
Jusqu’à présent nous avons écrit les couleurs dans les données de chaque sommet,
pratique peu efficace. Nous allons maintenant implémenter l’échantillonnage
(sampling) des textures, afin que le rendu soit plus intéressant. Nous pourrons
ensuite passer à l’affichage de modèles 3D dans de futurs chapitres.
L’ajout d’une texture comprend les étapes suivantes :
• Créer un objet image stocké sur la mémoire de la carte graphique
• La remplir avec les pixels extraits d’un fichier image
• Créer un sampler
• Ajouter un descripteur pour l’échantillonnage de l’image
Nous avons déjà travaillé avec des images, mais nous n’en avons jamais créé.
Celles que nous avons manipulées avaient été automatiquement crées par la
swap chain. Créer une image et la remplir de pixels ressemble à la création d’un
vertex buffer. Nous allons donc commencer par créer une ressource intermédiaire
pour y faire transiter les données que nous voulons retrouver dans l’image. Bien
qu’il soit possible d’utiliser une image comme intermédiaire, il est aussi autorisé
de créer un VkBuffer comme intermédiaire vers l’image, et cette méthode est
plus rapide sur certaines plateformes. Nous allons donc d’abord créer un buffer
et y mettre les données relatives aux pixels. Pour l’image nous devrons nous
enquérir des spécificités de la mémoire, allouer la mémoire nécessaire et y copier
les pixels. Cette procédure est très similaire à la création de buffers.
La grande différence - il en fallait une tout de même - réside dans l’organisation
des données à l’intérieur même des pixels. Leur organisation affecte la manière
dont les données brutes de la mémoire sont interprétées. De plus, stocker les
pixels ligne par ligne n’est pas forcément ce qui se fait de plus efficace, et cela
est dû à la manière dont les cartes graphiques fonctionnent. Nous devrons donc
faire en sorte que les images soient organisées de la meilleure manière possible.
Nous avons déjà croisé certaines organisation lors de la création de la passe de
186
rendu :
• VK_IMAGE_LAYOUT_PRESENT_SCR_KHR : optimal pour la présentation
• VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL : optimal pour être
l’attachement cible du fragment shader donc en tant que cible de rendu
• VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : optimal pour être la source
d’un transfert comme vkCmdCopyImageToBuffer
• VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL : optimal pour être la cible
d’un transfert comme vkCmdCopyBufferToImage
• VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL : optimal pour être
échantillonné depuis un shader
La plus commune des méthode spour réaliser une transition entre différentes or-
ganisations est la barrière pipeline. Celles-ci sont principalement utilisées pour
synchroniser l’accès à une ressource, mais peuvent aussi permettre la transition
d’un état à un autre. Dans ce chapitre nous allons utiliser cette seconde pos-
sibilité. Les barrières peuvent enfin être utilisées pour changer la queue family
qui possède une ressource.
Librairie de chargement d’image
De nombreuses librairies de chargement d’images existent ; vous pouvez même
écrire la vôtre pour des formats simples comme BMP ou PPM. Nous allons
utiliser stb_image, de la collection stb. Elle possède l’avantage d’être écrite en
un seul fichier. Téléchargez donc stb_image.h et placez-la ou vous voulez, par
exemple dans le dossier où sont stockés GLFW et GLM.
Visual Studio
Ajoutez le dossier comprenant stb_image.h dans Additional Include
Directories.
Makefile
Ajoutez le dossier comprenant stb_image.h aux chemins parcourus par GCC :
1 VULKAN_SDK_PATH = /home/user/VulkanSDK/x.x.x.x/x86_64
2 STB_INCLUDE_PATH = /home/user/libraries/stb
3
187
4 ...
5
6 CFLAGS = -std=c++17 -I$(VULKAN_SDK_PATH)/include
-I$(STB_INCLUDE_PATH)
Charger une image
Incluez la librairie de cette manière :
1 #define STB_IMAGE_IMPLEMENTATION
2 #include <stb_image.h>
Le header simple ne fournit que les prototypes des fonctions. Nous devons
demander les implémentations avec la define STB_IMAGE_IMPLEMENTATION pour
ne pas avoir d’erreurs à l’édition des liens.
1 void initVulkan() {
2 ...
3 createCommandPool();
4 createTextureImage();
5 createVertexBuffer();
6 ...
7 }
8
9 ...
10
11 void createTextureImage() {
12
13 }
Créez la fonction createTextureImage, depuis laquelle nous chargerons une
image et la placerons dans un objet Vulkan représentant une image. Nous
allons avoir besoin de command buffers, il faut donc appeler cette fonction
après createCommandPool.
Créez un dossier textures au même endroit que shaders pour y placer les tex-
tures. Nous allons y mettre un fichier appelé texture.jpg pour l’utiliser dans
notre programme. J’ai choisi d’utiliser cette image de license CC0 redimension-
née à 512x512, mais vous pouvez bien sûr en utiliser une autre. La librairie
supporte des formats tels que JPEG, PNG, BMP ou GIF.
188
Le chargement d’une image est très facile avec cette librairie :
1 void createTextureImage() {
2 int texWidth, texHeight, texChannels;
3 stbi_uc* pixels = stbi_load("textures/texture.jpg", &texWidth,
&texHeight, &texChannels, STBI_rgb_alpha);
4 VkDeviceSize imageSize = texWidth * texHeight * 4;
5
6 if (!pixels) {
7 throw std::runtime_error("échec du chargement d'une image!");
8 }
9 }
La fonction stbi_load prend en argument le chemin de l’image et les différentes
canaux à charger. L’argument STBI_rgb_alpha force la fonction à créer un canal
alpha même si l’image originale n’en possède pas. Cela simplifie le travail en
homogénéisant les situations. Les trois arguments transmis en addresse servent
189
de résultats pour stocker des informations sur l’image. Les pixels sont retournés
sous forme du pointeur stbi_uc *pixels. Ils sont organisés ligne par ligne et
ont chacun 4 octets, ce qui représente texWidth * texHeight * 4 octets au
total pour l’image.
Buffer intermédiaire
Nous allons maintenant créer un buffer en mémoire accessible pour que nous
puissions utiliser vkMapMemory et y placer les pixels. Ajoutez les variables suiv-
antes à la fonction pour contenir ce buffer temporaire :
1 VkBuffer stagingBuffer;
2 VkDeviceMemory stagingBufferMemory;
Le buffer doit être en mémoire visible pour que nous puissions le mapper, et
il doit être utilisable comme source d’un transfert vers une image, d’où l’appel
suivant :
1 createBuffer(imageSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer,
stagingBufferMemory);
Nous pouvons placer tel quels les pixels que nous avons récupérés dans le buffer
:
1 void* data;
2 vkMapMemory(device, stagingBufferMemory, 0, imageSize, 0, &data);
3 memcpy(data, pixels, static_cast<size_t>(imageSize));
4 vkUnmapMemory(device, stagingBufferMemory);
Il ne faut surtout pas oublier de libérer le tableau de pixels après cette opération
:
1 stbi_image_free(pixels);
Texture d’image
Bien qu’il nous soit possible de paramétrer le shader afin qu’il utilise le buffer
comme source de pixels, il est bien plus efficace d’utiliser un objet image. Ils
rendent plus pratique, mais surtout plus rapide, l’accès aux données de l’image
en nous permettant d’utiliser des coordonnées 2D. Les pixels sont appelés texels
dans le contexte du shading, et nous utiliserons ce terme à partir de maintenant.
Ajoutez les membres données suivants :
1 VkImage textureImage;
2 VkDeviceMemory textureImageMemory;
190
Les paramètres pour la création d’une image sont indiqués dans une structure
de type VkImageCreateInfo :
1 VkImageCreateInfo imageInfo{};
2 imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
3 imageInfo.imageType = VK_IMAGE_TYPE_2D;
4 imageInfo.extent.width = static_cast<uint32_t>(texWidth);
5 imageInfo.extent.height = static_cast<uint32_t>(texHeight);
6 imageInfo.extent.depth = 1;
7 imageInfo.mipLevels = 1;
8 imageInfo.arrayLayers = 1;
Le type d’image contenu dans imageType indique à Vulkan le repère dans
lesquels les texels sont placés. Il est possible de créer des repères 1D, 2D et
3D. Les images 1D peuvent être utilisés comme des tableaux ou des gradients.
Les images 2D sont majoritairement utilisés comme textures. Certaines tech-
niques les utilisent pour stocker autre chose que des couleur, par exemple des
vecteurs. Les images 3D peuvent être utilisées pour stocker des voxels par ex-
emple. Le champ extent indique la taille de l’image, en terme de texels par
axe. Comme notre texture fonctionne comme un plan dans un espace en 3D,
nous devons indiquer 1 au champ depth. Finalement, notre texture n’est pas
un tableau, et nous verrons le mipmapping plus tard.
1 imageInfo.format = VK_FORMAT_R8G8B8A8_SRGB;
Vulkan supporte de nombreux formats, mais nous devons utiliser le même format
que les données présentes dans le buffer.
1 imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
Le champ tiling peut prendre deux valeurs :
• VK_IMAGE_TILING_LINEAR : les texels sont organisés ligne par ligne
• VK_IMAGE_TILING_OPTIMAL : les texels sont organisés de la manière la
plus optimale pour l’implémentation
Le mode mis dans tiling ne peut pas être changé, au contraire de l’organisation
de l’image. Par conséquent, si vous voulez pouvoir directement accéder aux tex-
els, comme il faut qu’il soient organisés d’une manière logique, il vous faut
indiquer VK_IMAGE_TILING_LINEAR. Comme nous utilisons un buffer intermé-
diaire et non une image intermédiaire, nous pouvons utiliser le mode le plus
efficace.
1 imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
Idem, il n’existe que deux valeurs pour initialLayout :
• VK_IMAGE_LAYOUT_UNDEFINED : inutilisable par le GPU, son contenu sera
éliminé à la première transition
191
• VK_IMAGE_LAYOUT_PREINITIALIZED : inutilisable par le GPU, mais la
première transition conservera les texels
Il n’existe que quelques situations où il est nécessaire de préserver les texels
pendant la première transition. L’une d’elle consiste à utiliser l’image comme
ressource intermédiaire en combinaison avec VK_IMAGE_TILING_LINEAR. Il
faudrait dans ce cas la faire transitionner vers un état source de transfert,
sans perte de données. Cependant nous utilisons un buffer comme ressource
intermédiaire, et l’image transitionne d’abord vers cible de transfert. À ce
moment-là elle n’a pas de donnée intéressante.
1 imageInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT |
VK_IMAGE_USAGE_SAMPLED_BIT;
Le champ de bits usage fonctionne de la même manière que pour la création des
buffers. L’image sera destination d’un transfert, et sera utilisée par les shaders,
d’où les deux indications ci-dessus.
1 imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
L’image ne sera utilisée que par une famille de queues : celle des graphismes (qui
rappelons-le supporte implicitement les transferts). Si vous avez choisi d’utiliser
une queue spécifique vous devrez mettre VK_SHARING_MODE_CONCURENT.
1 imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
2 imageInfo.flags = 0; // Optionnel
Le membre sample se réfère au multisampling. Il n’a de sens que pour les
images utilisées comme attachements d’un framebuffer, nous devons donc mettre
1, traduit par VK_SAMPLE_COUNT_1_BIT. Finalement, certaines informations se
réfèrent aux images étendues. Ces image étendues sont des images dont seule
une partie est stockée dans la mémoire. Voici une exemple d’utilisation : si
vous utilisiez une image 3D pour représenter un terrain à l’aide de voxels, vous
pourriez utiliser cette fonctionnalité pour éviter d’utiliser de la mémoire qui au
final ne contiendrait que de l’air. Nous ne verrons pas cette fonctionnalité dans
ce tutoriel, donnez à flags la valeur 0.
1 if (vkCreateImage(device, &imageInfo, nullptr, &textureImage) !=
VK_SUCCESS) {
2 throw std::runtime_error("echec de la creation d'une image!");
3 }
L’image est créée par la fonction vkCreateImage, qui ne possède pas
d’argument particulièrement intéressant. Il est possible que le format
VK_FORMAT_R8G8B8A8_SRGB ne soit pas supporté par la carte graphique, mais
c’est tellement peu probable que nous ne verrons pas comment y remédier.
En effet utiliser un autre format demanderait de réaliser plusieurs conversions
compliquées. Nous reviendrons sur ces conversions dans le chapitre sur le buffer
de profondeur.
192
1 VkMemoryRequirements memRequirements;
2 vkGetImageMemoryRequirements(device, textureImage, &memRequirements);
3
4 VkMemoryAllocateInfo allocInfo{};
5 allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
6 allocInfo.allocationSize = memRequirements.size;
7 allocInfo.memoryTypeIndex =
findMemoryType(memRequirements.memoryTypeBits,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
8
9 if (vkAllocateMemory(device, &allocInfo, nullptr,
&textureImageMemory) != VK_SUCCESS) {
10 throw std::runtime_error("echec de l'allocation de la mémoire
pour l'image!");
11 }
12
13 vkBindImageMemory(device, textureImage, textureImageMemory, 0);
L’allocation de la mémoire nécessaire à une image fonctionne également de la
même façon que pour un buffer. Seuls les noms de deux fonctions changent
: vkGetBufferMemoryRequirements devient vkGetImageMemoryRequirements
et vkBindBufferMemory devient vkBindImageMemory.
Cette fonction est déjà assez grande ainsi, et comme nous aurons besoin d’autres
images dans de futurs chapitres, il est judicieux de déplacer la logique de leur
création dans une fonction, comme nous l’avons fait pour les buffers. Voici donc
la fonction createImage :
1 void createImage(uint32_t width, uint32_t height, VkFormat format,
VkImageTiling tiling, VkImageUsageFlags usage,
VkMemoryPropertyFlags properties, VkImage& image,
VkDeviceMemory& imageMemory) {
2 VkImageCreateInfo imageInfo{};
3 imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
4 imageInfo.imageType = VK_IMAGE_TYPE_2D;
5 imageInfo.extent.width = width;
6 imageInfo.extent.height = height;
7 imageInfo.extent.depth = 1;
8 imageInfo.mipLevels = 1;
9 imageInfo.arrayLayers = 1;
10 imageInfo.format = format;
11 imageInfo.tiling = tiling;
12 imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
13 imageInfo.usage = usage;
14 imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
15 imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
16
193
17 if (vkCreateImage(device, &imageInfo, nullptr, &image) !=
VK_SUCCESS) {
18 throw std::runtime_error("echec de la creation d'une
image!");
19 }
20
21 VkMemoryRequirements memRequirements;
22 vkGetImageMemoryRequirements(device, image, &memRequirements);
23
24 VkMemoryAllocateInfo allocInfo{};
25 allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
26 allocInfo.allocationSize = memRequirements.size;
27 allocInfo.memoryTypeIndex =
findMemoryType(memRequirements.memoryTypeBits, properties);
28
29 if (vkAllocateMemory(device, &allocInfo, nullptr, &imageMemory)
!= VK_SUCCESS) {
30 throw std::runtime_error("echec de l'allocation de la
memoire d'une image!");
31 }
32
33 vkBindImageMemory(device, image, imageMemory, 0);
34 }
La largeur, la hauteur, le mode de tiling, l’usage et les propriétés de la mémoire
sont des paramètres car ils varierons toujours entre les différentes images que
nous créerons dans ce tutoriel.
La fonction createTextureImage peut maintenant être réduite à ceci :
1 void createTextureImage() {
2 int texWidth, texHeight, texChannels;
3 stbi_uc* pixels = stbi_load("textures/texture.jpg", &texWidth,
&texHeight, &texChannels, STBI_rgb_alpha);
4 VkDeviceSize imageSize = texWidth * texHeight * 4;
5
6 if (!pixels) {
7 throw std::runtime_error("échec du chargement de l'image!");
8 }
9
10 VkBuffer stagingBuffer;
11 VkDeviceMemory stagingBufferMemory;
12 createBuffer(imageSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer,
stagingBufferMemory);
13
194
14 void* data;
15 vkMapMemory(device, stagingBufferMemory, 0, imageSize, 0, &data);
16 memcpy(data, pixels, static_cast<size_t>(imageSize));
17 vkUnmapMemory(device, stagingBufferMemory);
18
19 stbi_image_free(pixels);
20
21 createImage(texWidth, texHeight, VK_FORMAT_R8G8B8A8_SRGB,
VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSFER_DST_BIT |
VK_IMAGE_USAGE_SAMPLED_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, textureImage,
textureImageMemory);
22 }
Transitions de l’organisation
La fonction que nous allons écrire inclut l’enregistrement et l’exécution de com-
mand buffers. Il est donc également judicieux de placer cette logique dans une
autre fonction :
1 VkCommandBuffer beginSingleTimeCommands() {
2 VkCommandBufferAllocateInfo allocInfo{};
3 allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
4 allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
5 allocInfo.commandPool = commandPool;
6 allocInfo.commandBufferCount = 1;
7
8 VkCommandBuffer commandBuffer;
9 vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
10
11 VkCommandBufferBeginInfo beginInfo{};
12 beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
13 beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
14
15 vkBeginCommandBuffer(commandBuffer, &beginInfo);
16
17 return commandBuffer;
18 }
19
20 void endSingleTimeCommands(VkCommandBuffer commandBuffer) {
21 vkEndCommandBuffer(commandBuffer);
22
23 VkSubmitInfo submitInfo{};
24 submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
25 submitInfo.commandBufferCount = 1;
26 submitInfo.pCommandBuffers = &commandBuffer;
195
27
28 vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);
29 vkQueueWaitIdle(graphicsQueue);
30
31 vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);
32 }
Le code de ces fonctions est basé sur celui de copyBuffer. Vous pouvez main-
tenant réduire copyBuffer à :
1 void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize
size) {
2 VkCommandBuffer commandBuffer = beginSingleTimeCommands();
3
4 VkBufferCopy copyRegion{};
5 copyRegion.size = size;
6 vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer, 1,
©Region);
7
8 endSingleTimeCommands(commandBuffer);
9 }
Si nous utilisions de simples buffers nous pourrions nous contenter d’écrire
une fonction qui enregistre l’appel à vkCmdCopyBufferToImage. Mais comme
cette fonction utilse une image comme cible nous devons changer l’organisation
de l’image avant l’appel. Créez une nouvelle fonction pour gérer de manière
générique les transitions :
1 void transitionImageLayout(VkImage image, VkFormat format,
VkImageLayout oldLayout, VkImageLayout newLayout) {
2 VkCommandBuffer commandBuffer = beginSingleTimeCommands();
3
4 endSingleTimeCommands(commandBuffer);
5 }
L’une des manières de réaliser une transition consiste à utiliser une barrière
pour mémoire d’image. Une telle barrière de pipeline est en général utilisée
pour synchroniser l’accès à une ressource, mais nous avons déjà évoqué ce sujet.
Il existe au passage un équivalent pour les buffers : une barrière pour mémoire
de buffer.
1 VkImageMemoryBarrier barrier{};
2 barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
3 barrier.oldLayout = oldLayout;
4 barrier.newLayout = newLayout;
196
Les deux premiers champs indiquent la transition à réaliser. Il est possible
d’utiliser VK_IMAGE_LAYOUT_UNDEFINED pour oldLayout si le contenu de l’image
ne vous intéresse pas.
1 barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
2 barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
Ces deux paramètres sont utilisés pour transmettre la possession d’une
queue à une autre. Il faut leur indiquer les indices des familles de queues
correspondantes. Comme nous ne les utilisons pas, nous devons les mettre à
VK_QUEUE_FAMILY_IGNORED.
1 barrier.image = image;
2 barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
3 barrier.subresourceRange.baseMipLevel = 0;
4 barrier.subresourceRange.levelCount = 1;
5 barrier.subresourceRange.baseArrayLayer = 0;
6 barrier.subresourceRange.layerCount = 1;
Les paramètres image et subresourceRange servent à indiquer l’image, puis la
partie de l’image concernées par les changements. Comme notre image n’est pas
un tableau, et que nous n’avons pas mis en place de mipmapping, les paramètres
sont tous mis au minimum.
1 barrier.srcAccessMask = 0; // TODO
2 barrier.dstAccessMask = 0; // TODO
Comme les barrières sont avant tout des objets de synchronisation, nous devons
indiquer les opérations utilisant la ressource avant et après l’exécution de cette
barrière. Pour pouvoir remplir les champs ci-dessus nous devons déterminer ces
opérations, ce que nous ferons plus tard.
1 vkCmdPipelineBarrier(
2 commandBuffer,
3 0 /* TODO */, 0 /* TODO */,
4 0,
5 0, nullptr,
6 0, nullptr,
7 1, &barrier
8 );
Tous les types de barrière sont mis en place à l’aide de la même fonction. Le
paramètre qui suit le command buffer indique une étape de la pipeline. Durant
celle-ci seront réalisées les opération devant précéder la barrière. Le paramètre
d’après indique également une étape de la pipeline. Cette fois les opérations
exécutées durant cette étape attendront la barrière. Les étapes que vous pouvez
fournir comme avant- et après-barrière dépendent de l’utilisation des ressources
197
qui y sont utilisées. Les valeurs autorisées sont listées dans ce tableau. Par ex-
emple, si vous voulez lire des données présentes dans un UBO après une barrière
qui s’applique au buffer, vous devrez indiquer VK_ACCESS_UNIFORM_READ_BIT
comme usage, et si le premier shader à utiliser l’uniform est le fragment shader il
vous faudra indiquer VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT comme étape.
Dans ce cas de figure, spécifier une autre étape qu’une étape shader n’aurait au-
cun sens, et les validation layers vous le feraient remarquer.
Le paramètre sur la troisième ligne peut être soit 0 soit VK_DEPENDENCY_BY_REGION_BIT.
Dans ce second cas la barrière devient une condition spécifique d’une région
de la ressource. Cela signifie entre autres que l’implémentation peut lire une
région aussitôt que le transfert y est terminé, sans considération pour les autres
régions. Cela permet d’augmenter encore les performances en permettant
d’utiliser les optimisations des architectures actuelles.
Les trois dernières paires de paramètres sont des tableaux de barrières pour
chacun des trois types existants : barrière mémorielle, barrière de buffer et
barrière d’image.
Copier un buffer dans une image
Avant de compléter vkCreateTextureImage nous allons écrire une dernière fonc-
tion appelée copyBufferToImage :
1 void copyBufferToImage(VkBuffer buffer, VkImage image, uint32_t
width, uint32_t height) {
2 VkCommandBuffer commandBuffer = beginSingleTimeCommands();
3
4 endSingleTimeCommands(commandBuffer);
5 }
Comme avec les recopies de buffers, nous devons indiquer les parties du buffer à
copier et les parties de l’image où écrire. Ces données doivent être placées dans
une structure de type VkBufferImageCopy.
1 VkBufferImageCopy region{};
2 region.bufferOffset = 0;
3 region.bufferRowLength = 0;
4 region.bufferImageHeight = 0;
5
6 region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
7 region.imageSubresource.mipLevel = 0;
8 region.imageSubresource.baseArrayLayer = 0;
9 region.imageSubresource.layerCount = 1;
10
11 region.imageOffset = {0, 0, 0};
12 region.imageExtent = {
13 width,
198
14 height,
15 1
16 };
La plupart de ces champs sont évidents. bufferOffset indique l’octet à partir
duquel les données des pixels commencent dans le buffer. L’organisation des pix-
els doit être indiquée dans les champs bufferRowLenght et bufferImageHeight.
Il pourrait en effet avoir un espace entre les lignes de l’image. Comme notre
image est en un seul bloc, nous devons mettre ces paramètres à 0. Enfin, les
membres imageSubResource, imageOffset et imageExtent indiquent les par-
ties de l’image qui receveront les données.
Les copies buffer vers image sont envoyées à la queue avec la fonction
vkCmdCopyBufferToImage.
1 vkCmdCopyBufferToImage(
2 commandBuffer,
3 buffer,
4 image,
5 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
6 1,
7 ®ion
8 );
Le quatrième paramètre indique l’organisation de l’image au moment de la copie.
Normalement l’image doit être dans l’organisation optimale pour la réception de
données. Nous avons paramétré la copie pour qu’un seul command buffer soit
à l’origine de la copie successive de tous les pixels. Nous aurions aussi pu créer
un tableau de VkBufferImageCopy pour que le command buffer soit à l’origine
de plusieurs copies simultanées.
Préparer la texture d’image
Nous avons maintenant tous les outils nécessaires pour compléter la mise
en place de la texture d’image. Nous pouvons retourner à la fonction
createTextureImage. La dernière chose que nous y avions fait consistait à
créer l’image texture. Notre prochaine étape est donc d’y placer les pixels en
les copiant depuis le buffer intermédiaire. Il y a deux étapes pour cela :
• Transitionner l’organisation de l’image vers VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL
• Exécuter le buffer de copie
C’est simple à réaliser avec les fonctions que nous venons de créer :
1 transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB,
VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
2 copyBufferToImage(stagingBuffer, textureImage,
static_cast<uint32_t>(texWidth),
static_cast<uint32_t>(texHeight));
199
Nous avons créé l’image avec une organisation VK_LAYOUT_UNDEFINED, car le
contenu initial ne nous intéresse pas.
Pour ensuite pouvoir échantillonner la texture depuis le fragment shader nous
devons réaliser une dernière transition, qui la préparera à être accédée depuis
un shader :
1 transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
Derniers champs de la barrière de transition
Si vous lanciez le programme vous verrez que les validation layers vous indiquent
que les champs d’accès et d’étapes shader sont invalides. C’est normal, nous ne
les avons pas remplis.
Nous sommes pour le moment interessés par deux transitions :
• Non défini → cible d’un transfert : écritures par transfert qui n’ont pas
besoin d’être synchronisées
• Cible d’un transfert → lecture par un shader : la lecture par le shader
doit attendre la fin du transfert
Ces règles sont indiquées en utilisant les valeurs suivantes pour l’accès et les
étapes shader :
1 VkPipelineStageFlags sourceStage;
2 VkPipelineStageFlags destinationStage;
3
4 if (oldLayout == VK_IMAGE_LAYOUT_UNDEFINED && newLayout ==
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) {
5 barrier.srcAccessMask = 0;
6 barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
7
8 sourceStage = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
9 destinationStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
10 } else if (oldLayout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL &&
newLayout == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) {
11 barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
12 barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
13
14 sourceStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
15 destinationStage = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
16 } else {
17 throw std::invalid_argument("transition d'orgisation non
supportée!");
18 }
200
19
20 vkCmdPipelineBarrier(
21 commandBuffer,
22 sourceStage, destinationStage,
23 0,
24 0, nullptr,
25 0, nullptr,
26 1, &barrier
27 );
Comme vous avez pu le voir dans le tableau mentionné plus haut, l’écriture
dans l’image doit se réaliser à l’étape pipeline de transfert. Mais cette opération
d’écriture ne dépend d’aucune autre opération. Nous pouvons donc fournir
une condition d’accès nulle et VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT comme
opération pré-barrière. Cette valeur correspond au début de la pipeline, mais ne
représente pas vraiment une étape. Elle désigne plutôt le moment où la pipeline
se prépare, et donc sert communément aux transferts. Voyez la documentation
pour de plus amples informations sur les pseudo-étapes.
L’image sera écrite puis lue dans la même passe, c’est pourquoi nous devons
indiquer que le fragment shader aura accès à la mémoire de l’image.
Quand nous aurons besoin de plus de transitions, nous compléterons la fonction
de transition pour qu’elle les prenne en compte. L’application devrait main-
tenant tourner sans problème, bien qu’il n’y aie aucune différence visible.
Un point intéressant est que l’émission du command buffer génère implicitement
une synchronisation de type VK_ACCESS_HOST_WRITE_BIT. Comme la fonction
transitionImageLayout exécute un command buffer ne comprenant qu’une
seule commande, il est possbile d’utiliser cette synchronisation. Cela signifie que
vous pourriez alors mettre srcAccessMask à 0 dans le cas d’une transition vers
VK_ACCESS_HOST_WRITE_BIT. C’est à vous de voir si vous voulez être explicites à
ce sujet. Personnellement je n’aime pas du tout faire dépendre mon application
sur des opérations cachées, que je trouve dangereusement proche d’OpenGL.
Autre chose intéressante à savoir, il existe une organisation qui supporte toutes
les opérations. Elle s’appelle VK_IMAGE_LAYOUT_GENERAL. Le problème est
qu’elle est évidemment moins optimisée. Elle est cependant utile dans certains
cas, comme quand une image doit être utilisée comme cible et comme source,
ou pour pouvoir lire l’image juste après qu’elle aie quittée l’organisation préini-
tialisée.
Enfin, il important de noter que les fonctions que nous avons mises en place exé-
cutent les commandes de manière synchronisées et attendent que la queue soit en
pause. Pour de véritables applications il est bien sûr recommandé de combiner
toutes ces opérations dans un seul command buffer pour qu’elles soient exécutées
de manière asynchrones. Les commandes de transitions et de copie pourraient
grandement bénéficier d’une telle pratique. Essayez par exemple de créer une
201
fonction setupCommandBuffer, puis d’enregistrer les commandes nécessaires
depuis les fonctions actuelles. Appelez ensuite une autre fonction nommée
par exemple flushSetupCommands qui exécutera le command buffer. Avant
d’implémenter ceci attendez que nous ayons fait fonctionner l’échantillonage.
Nettoyage
Complétez la fonction createImageTexture en libérant le buffer intermédiaire
et en libérant la mémoire :
1 transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
2
3 vkDestroyBuffer(device, stagingBuffer, nullptr);
4 vkFreeMemory(device, stagingBufferMemory, nullptr);
5 }
L’image texture est utilisée jusqu’à la fin du programme, nous devons donc la
libérer dans cleanup :
1 void cleanup() {
2 cleanupSwapChain();
3
4 vkDestroyImage(device, textureImage, nullptr);
5 vkFreeMemory(device, textureImageMemory, nullptr);
6
7 ...
8 }
L’image contient maintenant la texture, mais nous n’avons toujours pas mis en
place de quoi y accéder depuis la pipeline. Nous y travaillerons dans le prochain
chapitre.
C++ code / Vertex shader / Fragment shader
Vue sur image et sampler
Dans ce chapitre nous allons créer deux nouvelles ressources dont nous aurons
besoin pour pouvoir échantillonner une image depuis la pipeline graphique. Nous
avons déjà vu la première en travaillant avec la swap chain, mais la seconde est
nouvelle, et est liée à la manière dont le shader accédera aux texels de l’image.
Vue sur une image texture
Nous avons vu précédemment que les images ne peuvent être accédées qu’à
travers une vue. Nous aurons donc besoin de créer une vue sur notre nouvelle
image texture.
202
Ajoutez un membre donnée pour stocker la référence à la vue de type
VkImageView. Ajoutez ensuite la fonction createTextureImageView qui créera
cette vue.
1 VkImageView textureImageView;
2
3 ...
4
5 void initVulkan() {
6 ...
7 createTextureImage();
8 createTextureImageView();
9 createVertexBuffer();
10 ...
11 }
12
13 ...
14
15 void createTextureImageView() {
16
17 }
Le code de cette fonction peut être basé sur createImageViews. Les deux seuls
changements sont dans format et image :
1 VkImageViewCreateInfo viewInfo{};
2 viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
3 viewInfo.image = textureImage;
4 viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
5 viewInfo.format = VK_FORMAT_R8G8B8A8_SRGB;
6 viewInfo.components = VK_COMPONENT_SWIZZLE_IDENTITY;
7 viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
8 viewInfo.subresourceRange.baseMipLevel = 0;
9 viewInfo.subresourceRange.levelCount = 1;
10 viewInfo.subresourceRange.baseArrayLayer = 0;
11 viewInfo.subresourceRange.layerCount = 1;
Appellons vkCreateImageView pour finaliser la création de la vue :
1 if (vkCreateImageView(device, &viewInfo, nullptr, &textureImageView)
!= VK_SUCCESS) {
2 throw std::runtime_error("échec de la création d'une vue sur
l'image texture!");
3 }
Comme la logique est similaire à celle de createImageViews, nous ferions bien
de la déplacer dans une fonction. Créez donc createImageView :
203
1 VkImageView createImageView(VkImage image, VkFormat format) {
2 VkImageViewCreateInfo viewInfo{};
3 viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
4 viewInfo.image = image;
5 viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
6 viewInfo.format = format;
7 viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
8 viewInfo.subresourceRange.baseMipLevel = 0;
9 viewInfo.subresourceRange.levelCount = 1;
10 viewInfo.subresourceRange.baseArrayLayer = 0;
11 viewInfo.subresourceRange.layerCount = 1;
12
13 VkImageView imageView;
14 if (vkCreateImageView(device, &viewInfo, nullptr, &imageView) !=
VK_SUCCESS) {
15 throw std::runtime_error("échec de la creation de la vue sur
une image!");
16 }
17
18 return imageView;
19 }
Et ainsi createTextureImageView peut être réduite à :
1 void createTextureImageView() {
2 textureImageView = createImageView(textureImage,
VK_FORMAT_R8G8B8A8_SRGB);
3 }
Et de même createImageView se résume à :
1 void createImageViews() {
2 swapChainImageViews.resize(swapChainImages.size());
3
4 for (uint32_t i = 0; i < swapChainImages.size(); i++) {
5 swapChainImageViews[i] = createImageView(swapChainImages[i],
swapChainImageFormat);
6 }
7 }
Préparons dès maintenant la libération de la vue sur l’image à la fin du pro-
gramme, juste avant la destruction de l’image elle-même.
1 void cleanup() {
2 cleanupSwapChain();
3
4 vkDestroyImageView(device, textureImageView, nullptr);
204
5
6 vkDestroyImage(device, textureImage, nullptr);
7 vkFreeMemory(device, textureImageMemory, nullptr);
Samplers
Il est possible pour les shaders de directement lire les texels de l’image. Ce n’est
cependant pas la technique communément utilisée. Les textures sont générale-
ment accédées à travers un sampler (ou échantillonneur) qui filtrera et/ou trans-
formera les données afin de calculer la couleur la plus désirable pour le pixel.
Ces filtres sont utiles pour résoudre des problèmes tels que l’oversampling. Imag-
inez une texture que l’on veut mettre sur de la géométrie possédant plus de
fragments que la texture n’a de texels. Si le sampler se contentait de prendre le
pixel le plus proche, une pixellisation apparaît :
En combinant les 4 texels les plus proches il est possible d’obtenir un rendu lisse
comme présenté sur l’image de droite. Bien sûr il est possible que votre appli-
cation cherche plutôt à obtenir le premier résultat (Minecraft), mais la seconde
option est en général préférée. Un sampler applique alors automatiquement ce
type d’opérations.
L’undersampling est le problème inverse. Cela crée des artefacts particulière-
ment visibles dans le cas de textures répétées vues à un angle aigu :
205
Comme vous pouvez le voir sur l’image de droite, la texture devient d’autant
plus floue que l’angle de vision se réduit. La solution à ce problème peut aussi
être réalisée par le sampler et s’appelle anisotropic filtering. Elle est par contre
plus gourmande en ressources.
Au delà de ces filtres le sampler peut aussi s’occuper de transformations. Il
évalue ce qui doit se passer quand le fragment shader essaie d’accéder à une
partie de l’image qui dépasse sa propre taille. Il se base sur le addressing mode
fourni lors de sa configuration. L’image suivante présente les différentes possib-
lités :
Nous allons maintenant créer la fonction createTextureSampler pour mettre
en place un sampler simple. Nous l’utiliserons pour lire les couleurs de la texture.
1 void initVulkan() {
2 ...
3 createTextureImage();
4 createTextureImageView();
5 createTextureSampler();
6 ...
7 }
8
9 ...
10
11 void createTextureSampler() {
12
13 }
206
Les samplers se configurent avec une structure de type VkSamplerCreateInfo.
Elle permet d’indiquer les filtres et les transformations à appliquer.
1 VkSamplerCreateInfo samplerInfo{};
2 samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
3 samplerInfo.magFilter = VK_FILTER_LINEAR;
4 samplerInfo.minFilter = VK_FILTER_LINEAR;
Les membres magFilter et minFilter indiquent comment interpoler les texels
respectivement magnifiés et minifiés, ce qui correspond respectivement aux prob-
lèmes évoqués plus haut. Nous avons choisi VK_FILTER_LINEAR, qui indiquent
l’utilisation des méthodes pour régler les problèmes vus plus haut.
1 samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT;
2 samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT;
3 samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT;
Le addressing mode peut être configuré pour chaque axe. Les axes disponibles
sont indiqués ci-dessus ; notez l’utilisation de U, V et W au lieu de X, Y et Z.
C’est une convention dans le contexte des textures. Voilà les différents modes
possibles :
• VK_SAMPLER_ADDRESS_MODE_REPEAT : répète le texture
• VK_SAMPLER_ADDRESS_MODE_MIRRORED_REPEAT : répète en inversant les
coordonnées pour réaliser un effet miroir
• VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE : prend la couleur du pixel
de bordure le plus proche
• VK_SAMPLER_ADDRESS_MODE_MIRROR_CLAMP_TO_EDGE : prend la couleur
de l’opposé du plus proche côté de l’image
• VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER : utilise une couleur fixée
Le mode que nous utilisons n’est pas très important car nous ne dépasserons
pas les coordonnées dans ce tutoriel. Cependant le mode de répétition est le
plus commun car il est infiniment plus efficace que d’envoyer plusieurs fois le
même carré à la pipeline, pour dessiner un pavage au sol par exemple.
1 samplerInfo.anisotropyEnable = VK_TRUE;
2 samplerInfo.maxAnisotropy = 16.0f;
Ces deux membres paramètrent l’utilisation de l’anistropic filtering. Il n’y a pas
vraiment de raison de ne pas l’utiliser, sauf si vous manquez de performances.
Le champ maxAnistropy est le nombre maximal de texels utilisés pour calculer
la couleur finale. Une plus petite valeur permet d’augmenter les performances,
mais résulte évidemment en une qualité réduite. Il n’existe à ce jour aucune carte
graphique pouvant utiliser plus de 16 texels car la qualité ne change quasiment
plus.
1 samplerInfo.borderColor = VK_BORDER_COLOR_INT_OPAQUE_BLACK;
207
Le paramètre borderColor indique la couleur utilisée pour le sampling qui
dépasse les coordonnées, si tel est le mode choisi. Il est possible d’indiquer du
noir, du blanc ou du transparent, mais vous ne pouvez pas indiquer une couleur
quelconque.
1 samplerInfo.unnormalizedCoordinates = VK_FALSE;
Le champ unnomalizedCoordinates indique le système de coordonnées que
vous voulez utiliser pour accéder aux texels de l’image. Avec VK_TRUE, vous pou-
vez utiliser des coordonnées dans [0, texWidth) et [0, texHeight). Sinon,
les valeurs sont accédées avec des coordonnées dans [0, 1). Dans la plupart
des cas les coordonnées sont utilisées normalisées car cela permet d’utiliser un
même shader pour des textures de résolution différentes.
1 samplerInfo.compareEnable = VK_FALSE;
2 samplerInfo.compareOp = VK_COMPARE_OP_ALWAYS;
Si une fonction de comparaison est activée, les texels seront comparés à une
valeur. Le résultat de la comparaison est ensuite utilisé pour une opération
de filtrage. Cette fonctionnalité est principalement utilisée pour réaliser un
percentage-closer filtering sur les shadow maps. Nous verrons cela dans un
futur chapitre.
1 samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
2 samplerInfo.mipLodBias = 0.0f;
3 samplerInfo.minLod = 0.0f;
4 samplerInfo.maxLod = 0.0f;
Tous ces champs sont liés au mipmapping. Nous y reviendrons dans un prochain
chapitre, mais pour faire simple, c’est encore un autre type de filtre.
Nous avons maintenant paramétré toutes les fonctionnalités du sampler.
Ajoutez un membre donnée pour stocker la référence à ce sampler, puis créez-le
avec vkCreateSampler :
1 VkImageView textureImageView;
2 VkSampler textureSampler;
3
4 ...
5
6 void createTextureSampler() {
7 ...
8
9 if (vkCreateSampler(device, &samplerInfo, nullptr,
&textureSampler) != VK_SUCCESS) {
10 throw std::runtime_error("échec de la creation d'un
sampler!");
11 }
12 }
208
Remarquez que le sampler n’est pas lié à une quelconque VkImage. Il ne con-
stitue qu’un objet distinct qui représente une interface avec les images. Il peut
être appliqué à n’importe quelle image 1D, 2D ou 3D. Cela diffère d’anciens
APIs, qui combinaient la texture et son filtrage.
Préparons la destruction du sampler à la fin du programme :
1 void cleanup() {
2 cleanupSwapChain();
3
4 vkDestroySampler(device, textureSampler, nullptr);
5 vkDestroyImageView(device, textureImageView, nullptr);
6
7 ...
8 }
Capacité du device à supporter l’anistropie
Si vous lancez le programme, vous verrez que les validation layers vous envoient
un message comme celui-ci :
En effet, l’anistropic filtering est une fonctionnalité du device qui doit être ac-
tivée. Nous devons donc mettre à jour la fonction createLogicalDevice :
1 VkPhysicalDeviceFeatures deviceFeatures{};
2 deviceFeatures.samplerAnisotropy = VK_TRUE;
Et bien qu’il soit très peu probable qu’une carte graphique moderne ne supporte
pas cette fonctionnalité, nous devrions aussi adapter isDeviceSuitable pour
en être sûr.
1 bool isDeviceSuitable(VkPhysicalDevice device) {
2 ...
3
4 VkPhysicalDeviceFeatures supportedFeatures;
5 vkGetPhysicalDeviceFeatures(device, &supportedFeatures);
6
7 return indices.isComplete() && extensionsSupported &&
swapChainAdequate && supportedFeatures.samplerAnisotropy;
8 }
209
La structure VkPhysicalDeviceFeatures permet d’indiquer les capacités sup-
portées quand elle est utilisée avec la fonction VkPhysicalDeviceFeatures,
plutôt que de fournir ce dont nous avons besoin.
Au lieu de simplement obliger le client à posséder une carte graphique sup-
portant l’anistropic filtering, nous pourrions conditionnellement activer ou pas
l’anistropic filtering :
1 samplerInfo.anisotropyEnable = VK_FALSE;
2 samplerInfo.maxAnisotropy = 1.0f;
Dans le prochain chapitre nous exposerons l’image et le sampler au fragment
shader pour qu’il puisse utiliser la texture sur le carré.
C++ code / Vertex shader / Fragment shader
Sampler d’image combiné
Introduction
Nous avons déjà évoqué les descripteurs dans la partie sur les buffers d’uniformes.
Dans ce chapitre nous en verrons un nouveau type : les samplers d’image com-
binés (combined image sampler). Ceux-ci permettent aux shaders d’accéder au
contenu d’images, à travers un sampler.
Nous allons d’abord modifier l’organisation des descripteurs, la pool de descrip-
teurs et le set de descripteurs pour qu’ils incluent le sampler d’image combiné.
Ensuite nous ajouterons des coordonnées de texture à la structure Vertex et
modifierons le vertex shader et le fragment shader pour qu’il utilisent les couleurs
de la texture.
Modifier les descripteurs
Trouvez la fonction createDescriptorSetLayout et créez une instance de
VkDescriptorSetLayoutBinding. Cette structure correspond aux descripteurs
d’image combinés. Nous n’avons quasiment que l’indice du binding à y mettre :
1 VkDescriptorSetLayoutBinding samplerLayoutBinding{};
2 samplerLayoutBinding.binding = 1;
3 samplerLayoutBinding.descriptorCount = 1;
4 samplerLayoutBinding.descriptorType =
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
5 samplerLayoutBinding.pImmutableSamplers = nullptr;
6 samplerLayoutBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
7
8 std::array<VkDescriptorSetLayoutBinding, 2> bindings =
{uboLayoutBinding, samplerLayoutBinding};
9 VkDescriptorSetLayoutCreateInfo layoutInfo{};
210
10 layoutInfo.sType =
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
11 layoutInfo.bindingCount = static_cast<uint32_t>(bindings.size());
12 layoutInfo.pBindings = bindings.data();
Assurez-vous également de bien indiquer le fragment shader dans le champ
stageFlags. Ce sera à cette étape que la couleur sera extraite de la texture. Il
est également possible d’utiliser le sampler pour échantilloner une texture dans
le vertex shader. Cela permet par exemple de déformer dynamiquement une
grille de vertices pour réaliser une heightmap à partir d’une texture de vecteurs.
Si vous lancez l’application, vous verrez que la pool de descripteurs ne peut
pas allouer de set avec l’organisation que nous avons préparée, car elle ne
comprend aucun descripteur de sampler d’image combiné. Il nous faut donc
modifier la fonction createDescriptorPool pour qu’elle inclue une structure
VkDesciptorPoolSize qui corresponde à ce type de descripteur :
1 std::array<VkDescriptorPoolSize, 2> poolSizes{};
2 poolSizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
3 poolSizes[0].descriptorCount =
static_cast<uint32_t>(swapChainImages.size());
4 poolSizes[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
5 poolSizes[1].descriptorCount =
static_cast<uint32_t>(swapChainImages.size());
6
7 VkDescriptorPoolCreateInfo poolInfo{};
8 poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
9 poolInfo.poolSizeCount = static_cast<uint32_t>(poolSizes.size());
10 poolInfo.pPoolSizes = poolSizes.data();
11 poolInfo.maxSets = static_cast<uint32_t>(swapChainImages.size());
La dernière étape consiste à lier l’image et le sampler aux descripteurs du set
de descripteurs. Allez à la fonction createDescriptorSets.
1 for (size_t i = 0; i < swapChainImages.size(); i++) {
2 VkDescriptorBufferInfo bufferInfo{};
3 bufferInfo.buffer = uniformBuffers[i];
4 bufferInfo.offset = 0;
5 bufferInfo.range = sizeof(UniformBufferObject);
6
7 VkDescriptorImageInfo imageInfo{};
8 imageInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
9 imageInfo.imageView = textureImageView;
10 imageInfo.sampler = textureSampler;
11
12 ...
13 }
211
Les ressources nécessaires à la structure paramétrant un descripteur d’image
combiné doivent être fournies dans une structure de type VkDescriptorImageInfo.
Cela est similaire à la création d’un descripteur pour buffer. Les objets que
nous avons créés dans les chapitres précédents s’assemblent enfin!
1 std::array<VkWriteDescriptorSet, 2> descriptorWrites{};
2
3 descriptorWrites[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
4 descriptorWrites[0].dstSet = descriptorSets[i];
5 descriptorWrites[0].dstBinding = 0;
6 descriptorWrites[0].dstArrayElement = 0;
7 descriptorWrites[0].descriptorType =
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
8 descriptorWrites[0].descriptorCount = 1;
9 descriptorWrites[0].pBufferInfo = &bufferInfo;
10
11 descriptorWrites[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
12 descriptorWrites[1].dstSet = descriptorSets[i];
13 descriptorWrites[1].dstBinding = 1;
14 descriptorWrites[1].dstArrayElement = 0;
15 descriptorWrites[1].descriptorType =
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
16 descriptorWrites[1].descriptorCount = 1;
17 descriptorWrites[1].pImageInfo = &imageInfo;
18
19 vkUpdateDescriptorSets(device,
static_cast<uint32_t>(descriptorWrites.size()),
descriptorWrites.data(), 0, nullptr);
Les descripteurs doivent être mis à jour avec des informations sur l’image,
comme pour les buffers. Cette fois nous allons utiliser le tableau pImageInfo
plutôt que pBufferInfo. Les descripteurs sont maintenant prêts à l’emploi.
Coordonnées de texture
Il manque encore un élément au mapping de textures. Ce sont les coordonnées
spécifiques aux sommets. Ce sont elles qui déterminent les coordonnées de la
texture à lier à la géométrie.
1 struct Vertex {
2 glm::vec2 pos;
3 glm::vec3 color;
4 glm::vec2 texCoord;
5
6 static VkVertexInputBindingDescription getBindingDescription() {
7 VkVertexInputBindingDescription bindingDescription{};
8 bindingDescription.binding = 0;
212
9 bindingDescription.stride = sizeof(Vertex);
10 bindingDescription.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
11
12 return bindingDescription;
13 }
14
15 static std::array<VkVertexInputAttributeDescription, 3>
getAttributeDescriptions() {
16 std::array<VkVertexInputAttributeDescription, 3>
attributeDescriptions{};
17
18 attributeDescriptions[0].binding = 0;
19 attributeDescriptions[0].location = 0;
20 attributeDescriptions[0].format = VK_FORMAT_R32G32_SFLOAT;
21 attributeDescriptions[0].offset = offsetof(Vertex, pos);
22
23 attributeDescriptions[1].binding = 0;
24 attributeDescriptions[1].location = 1;
25 attributeDescriptions[1].format = VK_FORMAT_R32G32B32_SFLOAT;
26 attributeDescriptions[1].offset = offsetof(Vertex, color);
27
28 attributeDescriptions[2].binding = 0;
29 attributeDescriptions[2].location = 2;
30 attributeDescriptions[2].format = VK_FORMAT_R32G32_SFLOAT;
31 attributeDescriptions[2].offset = offsetof(Vertex, texCoord);
32
33 return attributeDescriptions;
34 }
35 };
Modifiez la structure Vertex pour qu’elle comprenne un vec2, qui
servira à contenir les coordonnées de texture. Ajoutez également un
VkVertexInputAttributeDescription afin que ces coordonnées puissent être
accédées en entrée du vertex shader. Il est nécessaire de les passer du vertex
shader vers le fragment shader afin que l’interpolation les transforment en un
gradient.
1 const std::vector<Vertex> vertices = {
2 {{-0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}, {1.0f, 0.0f}},
3 {{0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f}},
4 {{0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}, {0.0f, 1.0f}},
5 {{-0.5f, 0.5f}, {1.0f, 1.0f, 1.0f}, {1.0f, 1.0f}}
6 };
Dans ce tutoriel nous nous contenterons de mettre une texture sur le carré en
utilisant des coordonnées normalisées. Nous mettrons le 0, 0 en haut à gauche
213
et le 1, 1 en bas à droite. Essayez de mettre des valeurs sous 0 ou au-delà de
1 pour voir l’addressing mode en action. Vous pourrez également changer le
mode dans la création du sampler pour voir comment ils se comportent.
Shaders
La dernière étape consiste à modifier les shaders pour qu’ils utilisent la texture
et non les couleurs. Commençons par le vertex shader :
1 layout(location = 0) in vec2 inPosition;
2 layout(location = 1) in vec3 inColor;
3 layout(location = 2) in vec2 inTexCoord;
4
5 layout(location = 0) out vec3 fragColor;
6 layout(location = 1) out vec2 fragTexCoord;
7
8 void main() {
9 gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition,
0.0, 1.0);
10 fragColor = inColor;
11 fragTexCoord = inTexCoord;
12 }
Comme pour les couleurs spécifiques aux vertices, les valeurs fragTexCoord
seront interpolées dans le carré par le rasterizer pour créer un gradient lisse. Le
résultat de l’interpolation peut être visualisé en utilisant les coordonnées comme
couleurs :
1 #version 450
2
3 layout(location = 0) in vec3 fragColor;
4 layout(location = 1) in vec2 fragTexCoord;
5
6 layout(location = 0) out vec4 outColor;
7
8 void main() {
9 outColor = vec4(fragTexCoord, 0.0, 1.0);
10 }
Vous devriez avoir un résultat similaire à l’image suivante. N’oubliez pas de
recompiler les shader!
214
Le vert représente l’horizontale et le rouge la verticale. Les coins noirs et jaunes
confirment la normalisation des valeurs de 0, 0 à 1, 1. Utiliser les couleurs
pour visualiser les valeurs et déboguer est similaire à utiliser printf. C’est peu
pratique mais il n’y a pas vraiment d’autre option.
Un descripteur de sampler d’image combiné est représenté dans les shaders par
un objet de type sampler placé dans une variable uniforme. Créez donc une
variable texSampler :
1 layout(binding = 1) uniform sampler2D texSampler;
Il existe des équivalents 1D et 3D pour de telles textures.
1 void main() {
2 outColor = texture(texSampler, fragTexCoord);
3 }
Les textures sont échantillonées à l’aide de la fonction texture. Elle prend
en argument un objet sampler et des coordonnées. Le sampler exécute les
transformations et le filtrage en arrière-plan. Vous devriez voir la texture sur le
carré maintenant!
215
Expérimentez avec l’addressing mode en fournissant des valeurs dépassant 1, et
vous verrez la répétition de texture à l’oeuvre :
1 void main() {
2 outColor = texture(texSampler, fragTexCoord * 2.0);
3 }
216
Vous pouvez aussi combiner les couleurs avec celles écrites à la main :
1 void main() {
2 outColor = vec4(fragColor * texture(texSampler,
fragTexCoord).rgb, 1.0);
3 }
J’ai séparé l’alpha du reste pour ne pas altérer la transparence.
217
Nous pouvons désormais utiliser des textures dans notre programme! Cette
technique est extrêmement puissante et permet beaucoup plus que juste afficher
des couleurs. Vous pouvez même utiliser les images de la swap chain comme
textures et y appliquer des effets post-processing.
Code C++ / Vertex shader / Fragment shader
218
Buffer de profondeur
Introduction
Jusqu’à présent nous avons projeté notre géométrie en 3D, mais elle n’est tou-
jours définie qu’en 2D. Nous allons ajouter l’axe Z dans ce chapitre pour per-
mettre l’utilisation de modèles 3D. Nous placerons un carré au-dessus ce celui
que nous avons déjà, et nous verrons ce qui se passe si la géométrie n’est pas
organisée par profondeur.
Géométrie en 3D
Mettez à jour la structure Vertex pour que les coordonnées soient des vecteurs
à 3 dimensions. Il faut également changer le champ format dans la structure
VkVertexInputAttributeDescription correspondant aux coordonnées :
1 struct Vertex {
2 glm::vec3 pos;
3 glm::vec3 color;
4 glm::vec2 texCoord;
5
6 ...
7
8 static std::array<VkVertexInputAttributeDescription, 3>
getAttributeDescriptions() {
9 std::array<VkVertexInputAttributeDescription, 3>
attributeDescriptions{};
10
11 attributeDescriptions[0].binding = 0;
12 attributeDescriptions[0].location = 0;
13 attributeDescriptions[0].format = VK_FORMAT_R32G32B32_SFLOAT;
14 attributeDescriptions[0].offset = offsetof(Vertex, pos);
15
16 ...
17 }
219
18 };
Mettez également à jour l’entrée du vertex shader qui correspond aux coordon-
nées. Recompilez le shader.
1 layout(location = 0) in vec3 inPosition;
2
3 ...
4
5 void main() {
6 gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition,
1.0);
7 fragColor = inColor;
8 fragTexCoord = inTexCoord;
9 }
Enfin, il nous faut ajouter la profondeur là où nous créons les instances de
Vertex.
1 const std::vector<Vertex> vertices = {
2 {{-0.5f, -0.5f, 0.0f}, {1.0f, 0.0f, 0.0f}, {0.0f, 0.0f}},
3 {{0.5f, -0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}, {1.0f, 0.0f}},
4 {{0.5f, 0.5f, 0.0f}, {0.0f, 0.0f, 1.0f}, {1.0f, 1.0f}},
5 {{-0.5f, 0.5f, 0.0f}, {1.0f, 1.0f, 1.0f}, {0.0f, 1.0f}}
6 };
Si vous lancez l’application vous verrez exactement le même résultat. Il est
maintenant temps d’ajouter de la géométrie pour rendre la scène plus intéres-
sante, et pour montrer le problème évoqué plus haut. Dupliquez les vertices afin
qu’un second carré soit rendu au-dessus de celui que nous avons maintenant :
Nous allons utiliser -0.5f comme coordonnée Z.
220
1 const std::vector<Vertex> vertices = {
2 {{-0.5f, -0.5f, 0.0f}, {1.0f, 0.0f, 0.0f}, {0.0f, 0.0f}},
3 {{0.5f, -0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}, {1.0f, 0.0f}},
4 {{0.5f, 0.5f, 0.0f}, {0.0f, 0.0f, 1.0f}, {1.0f, 1.0f}},
5 {{-0.5f, 0.5f, 0.0f}, {1.0f, 1.0f, 1.0f}, {0.0f, 1.0f}},
6
7 {{-0.5f, -0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}, {0.0f, 0.0f}},
8 {{0.5f, -0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}, {1.0f, 0.0f}},
9 {{0.5f, 0.5f, -0.5f}, {0.0f, 0.0f, 1.0f}, {1.0f, 1.0f}},
10 {{-0.5f, 0.5f, -0.5f}, {1.0f, 1.0f, 1.0f}, {0.0f, 1.0f}}
11 };
12
13 const std::vector<uint16_t> indices = {
14 0, 1, 2, 2, 3, 0,
15 4, 5, 6, 6, 7, 4
16 };
Si vous lancez le programme maintenant vous verrez que le carré d’en-dessous
est rendu au-dessus de l’autre :
Ce problème est simplement dû au fait que le carré d’en-dessous est placé après
dans le tableau des vertices. Il y a deux manières de régler ce problème :
221
• Trier tous les appels en fonction de la profondeur
• Utiliser un buffer de profondeur
La première approche est communément utilisée pour l’affichage d’objets trans-
parents, car la transparence non ordonnée est un problème difficile à résoudre.
Cependant, pour la géométrie sans transparence, le buffer de profondeur est un
très bonne solution. Il consiste en un attachement supplémentaire au frame-
buffer, qui stocke les profondeurs. La profondeur de chaque fragment produit
par le rasterizer est comparée à la valeur déjà présente dans le buffer. Si le
fragment est plus distant que celui déjà traité, il est simplement éliminé. Il est
possible de manipuler cette valeur de la même manière que la couleur.
1 #define GLM_FORCE_RADIANS
2 #define GLM_FORCE_DEPTH_ZERO_TO_ONE
3 #include <glm/glm.hpp>
4 #include <glm/gtc/matrix_transform.hpp>
La matrice de perspective générée par GLM utilise par défaut la pro-
fondeur OpenGL comprise en -1 et 1. Nous pouvons configurer GLM avec
GLM_FORCE_DEPTH_ZERO_TO_ONE pour qu’elle utilise des valeurs correspondant
à Vulkan.
Image de pronfondeur et views sur cette image
L’attachement de profondeur est une image. La différence est que celle-ci n’est
pas créée par la swap chain. Nous n’avons besoin que d’un seul attachement
de profondeur, car les opérations sont séquentielles. L’attachement aura encore
besoin des trois mêmes ressources : une image, de la mémoire et une image
view.
1 VkImage depthImage;
2 VkDeviceMemory depthImageMemory;
3 VkImageView depthImageView;
Créez une nouvelle fonction createDepthResources pour mettre en place ces
ressources :
1 void initVulkan() {
2 ...
3 createCommandPool();
4 createDepthResources();
5 createTextureImage();
6 ...
7 }
8
9 ...
10
222
11 void createDepthResources() {
12
13 }
La création d’une image de profondeur est assez simple. Elle doit avoir la
même résolution que l’attachement de couleur, définie par l’étendue de la swap
chain. Elle doit aussi être configurée comme image de profondeur, avoir un tiling
optimal et une mémoire placée sur la carte graphique. Une question persiste
: quelle est l’organisation optimale pour une image de profondeur? Le format
contient un composant de profondeur, indiqué par _Dxx_ dans les valeurs de
type VK_FORMAT.
Au contraire de l’image de texture, nous n’avons pas besoin de déterminer le
format requis car nous n’accéderons pas à cette texture nous-mêmes. Nous
n’avons besoin que d’une précision suffisante, en général un minimum de 24 bits.
Il y a plusieurs formats qui satisfont cette nécéssité :
• VK_FORMAT_D32_SFLOAT : float signé de 32 bits pour la profondeur
• VK_FORMAT_D32_SFLOAT_S8_UINT : float signé de 32 bits pour la pro-
fondeur et int non signé de 8 bits pour le stencil
• VK_FORMAT_D24_UNORM_S8_UINT : float signé de 24 bits pour la profondeur
et int non signé de 8 bits pour le stencil
Le composant de stencil est utilisé pour le test de stencil. C’est un test addi-
tionnel qui peut être combiné avec le test de profondeur. Nous y reviendrons
dans un futur chapitre.
Nous pourrions nous contenter d’utiliser VK_FORMAT_D32_SFLOAT car son sup-
port est pratiquement assuré, mais il est préférable d’utiliser une fonction pour
déterminer le meilleur format localement supporté. Créez pour cela la fonction
findSupportedFormat. Elle vérifiera que les formats en argument sont sup-
portés et choisira le meilleur en se basant sur leur ordre dans le vecteurs des
formats acceptables fourni en argument :
1 VkFormat findSupportedFormat(const std::vector<VkFormat>&
candidates, VkImageTiling tiling, VkFormatFeatureFlags features)
{
2
3 }
Leur support dépend du mode de tiling et de l’usage, nous devons donc les
transmettre en argument. Le support des formats peut ensuite être demandé à
l’aide de la fonction vkGetPhysicalDeviceFormatProperties :
1 for (VkFormat format : candidates) {
2 VkFormatProperties props;
3 vkGetPhysicalDeviceFormatProperties(physicalDevice, format,
&props);
4 }
223
La structure VkFormatProperties contient trois champs :
• linearTilingFeatures : utilisations supportées avec le tiling linéaire
• optimalTilingFeatures : utilisations supportées avec le tiling optimal
• bufferFeatures : utilisations supportées avec les buffers
Seuls les deux premiers cas nous intéressent ici, et celui que nous vérifierons
dépendra du mode de tiling fourni en paramètre.
1 if (tiling == VK_IMAGE_TILING_LINEAR && (props.linearTilingFeatures
& features) == features) {
2 return format;
3 } else if (tiling == VK_IMAGE_TILING_OPTIMAL &&
(props.optimalTilingFeatures & features) == features) {
4 return format;
5 }
Si aucun des candidats ne supporte l’utilisation désirée, nous pouvons lever une
exception.
1 VkFormat findSupportedFormat(const std::vector<VkFormat>&
candidates, VkImageTiling tiling, VkFormatFeatureFlags features)
{
2 for (VkFormat format : candidates) {
3 VkFormatProperties props;
4 vkGetPhysicalDeviceFormatProperties(physicalDevice, format,
&props);
5
6 if (tiling == VK_IMAGE_TILING_LINEAR &&
(props.linearTilingFeatures & features) == features) {
7 return format;
8 } else if (tiling == VK_IMAGE_TILING_OPTIMAL &&
(props.optimalTilingFeatures & features) == features) {
9 return format;
10 }
11 }
12
13 throw std::runtime_error("aucun des formats demandés n'est
supporté!");
14 }
Nous allons utiliser cette fonction depuis une autre fonction findDepthFormat.
Elle sélectionnera un format avec un composant de profondeur qui supporte
d’être un attachement de profondeur :
1 VkFormat findDepthFormat() {
2 return findSupportedFormat(
3 {VK_FORMAT_D32_SFLOAT, VK_FORMAT_D32_SFLOAT_S8_UINT,
VK_FORMAT_D24_UNORM_S8_UINT},
224
4 VK_IMAGE_TILING_OPTIMAL,
5 VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT
6 );
7 }
Utilisez bien VK_FORMAT_FEATURE_ au lieu de VK_IMAGE_USAGE_. Tous les can-
didats contiennent la profondeur, mais certains ont le stencil en plus. Ainsi il
est important de voir que dans ce cas, la profondeur n’est qu’une capacité et
non un usage exclusif. Autre point, nous devons prendre cela en compte pour
les transitions d’organisation. Ajoutez une fonction pour determiner si le format
contient un composant de stencil ou non :
1 bool hasStencilComponent(VkFormat format) {
2 return format == VK_FORMAT_D32_SFLOAT_S8_UINT || format ==
VK_FORMAT_D24_UNORM_S8_UINT;
3 }
Appelez cette fonction depuis createDepthResources pour déterminer le for-
mat de profondeur :
1 VkFormat depthFormat = findDepthFormat();
Nous avons maintenant toutes les informations nécessaires pour invoquer
createImage et createImageView.
1 createImage(swapChainExtent.width, swapChainExtent.height,
depthFormat, VK_IMAGE_TILING_OPTIMAL,
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, depthImage,
depthImageMemory);
2 depthImageView = createImageView(depthImage, depthFormat);
Cependant cette fonction part du principe que la subresource est toujours
VK_IMAGE_ASPECT_COLOR_BIT, il nous faut donc en faire un paramètre.
1 VkImageView createImageView(VkImage image, VkFormat format,
VkImageAspectFlags aspectFlags) {
2 ...
3 viewInfo.subresourceRange.aspectMask = aspectFlags;
4 ...
5 }
Changez également les appels à cette fonction pour prendre en compte ce change-
ment :
1 swapChainImageViews[i] = createImageView(swapChainImages[i],
swapChainImageFormat, VK_IMAGE_ASPECT_COLOR_BIT);
2 ...
225
3 depthImageView = createImageView(depthImage, depthFormat,
VK_IMAGE_ASPECT_DEPTH_BIT);
4 ...
5 textureImageView = createImageView(textureImage,
VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_ASPECT_COLOR_BIT);
Voilà tout pour la création de l’image de profondeur. Nous n’avons pas besoin
d’y envoyer de données ou quoi que ce soit de ce genre, car nous allons l’initialiser
au début de la render pass tout comme l’attachement de couleur.
Explicitement transitionner l’image de profondeur
Nous n’avons pas besoin de faire explicitement la transition du layout de l’image
vers un attachement de profondeur parce qu’on s’en occupe directement dans la
render pass. En revanche, pour l’exhaustivité je vais quand même vous décrire le
processus dans cette section. Vous pouvez sauter cette étape si vous le souhaitez.
Faites un appel à transitionImageLayout à la fin de createDepthResources
comme ceci:
1 transitionImageLayout(depthImage, depthFormat,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL);
L’organisation indéfinie peut être utilisée comme organisation intiale, dans la
mesure où aucun contenu d’origine n’a d’importance. Nous devons faire éval-
uer la logique de transitionImageLayout pour qu’elle puisse utiliser la bonne
subresource.
1 if (newLayout == VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL) {
2 barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;
3
4 if (hasStencilComponent(format)) {
5 barrier.subresourceRange.aspectMask |=
VK_IMAGE_ASPECT_STENCIL_BIT;
6 }
7 } else {
8 barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
9 }
Même si nous n’utilisons pas le composant de stencil, nous devons nous en
occuper dans les transitions de l’image de profondeur.
Ajoutez enfin le bon accès et les bonnes étapes pipeline :
1 if (oldLayout == VK_IMAGE_LAYOUT_UNDEFINED && newLayout ==
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) {
2 barrier.srcAccessMask = 0;
226
3 barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
4
5 sourceStage = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
6 destinationStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
7 } else if (oldLayout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL &&
newLayout == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) {
8 barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
9 barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
10
11 sourceStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
12 destinationStage = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
13 } else if (oldLayout == VK_IMAGE_LAYOUT_UNDEFINED && newLayout ==
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL) {
14 barrier.srcAccessMask = 0;
15 barrier.dstAccessMask =
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT |
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
16
17 sourceStage = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
18 destinationStage = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT;
19 } else {
20 throw std::invalid_argument("transition d'organisation non
supportée!");
21 }
Le buffer de profondeur sera lu avant d’écrire un fragment, et écrit après qu’un
fragment valide soit traité. La lecture se passe en VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT
et l’écriture en VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT. Vous devriez
choisir la première des étapes correspondant à l’opération correspondante, afin
que tout soit prêt pour l’utilisation de l’attachement de profondeur.
Render pass
Nous allons modifier createRenderPass pour inclure l’attachement de pro-
fondeur. Spécifiez d’abord un VkAttachementDescription :
1 VkAttachmentDescription depthAttachment{};
2 depthAttachment.format = findDepthFormat();
3 depthAttachment.samples = VK_SAMPLE_COUNT_1_BIT;
4 depthAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
5 depthAttachment.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
6 depthAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
7 depthAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
8 depthAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
9 depthAttachment.finalLayout =
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
227
Le format doit être celui de l’image de profondeur. Pour cette fois nous ne
garderons pas les données de profondeur, car nous n’en avons plus besoin après
le rendu. Encore une fois le hardware pourra réaliser des optimisations. Et de
même nous n’avons pas besoin des valeurs du rendu précédent pour le début
du rendu de la frame, nous pouvons donc mettre VK_IMAGE_LAYOUT_UNDEFINED
comme valeur pour initialLayout.
1 VkAttachmentReference depthAttachmentRef{};
2 depthAttachmentRef.attachment = 1;
3 depthAttachmentRef.layout =
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
Ajoutez une référence à l’attachement dans notre seule et unique subpasse :
1 VkSubpassDescription subpass{};
2 subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
3 subpass.colorAttachmentCount = 1;
4 subpass.pColorAttachments = &colorAttachmentRef;
5 subpass.pDepthStencilAttachment = &depthAttachmentRef;
Les subpasses ne peuvent utiliser qu’un seul attachement de profondeur (et de
stencil). Réaliser le test de profondeur sur plusieurs buffers n’a de toute façon
pas beaucoup de sens.
1 std::array<VkAttachmentDescription, 2> attachments =
{colorAttachment, depthAttachment};
2 VkRenderPassCreateInfo renderPassInfo{};
3 renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
4 renderPassInfo.attachmentCount =
static_cast<uint32_t>(attachments.size());
5 renderPassInfo.pAttachments = attachments.data();
6 renderPassInfo.subpassCount = 1;
7 renderPassInfo.pSubpasses = &subpass;
8 renderPassInfo.dependencyCount = 1;
9 renderPassInfo.pDependencies = &dependency;
Changez enfin la structure VkRenderPassCreateInfo pour qu’elle se réfère aux
deux attachements.
Framebuffer
L’étape suivante va consister à modifier la création du framebuffer pour
lier notre image de profondeur à l’attachement de profondeur. Trouvez
createFramebuffers et indiquez la view sur l’image de profondeur comme
second attachement :
1 std::array<VkImageView, 2> attachments = {
228
2 swapChainImageViews[i],
3 depthImageView
4 };
5
6 VkFramebufferCreateInfo framebufferInfo{};
7 framebufferInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
8 framebufferInfo.renderPass = renderPass;
9 framebufferInfo.attachmentCount =
static_cast<uint32_t>(attachments.size());
10 framebufferInfo.pAttachments = attachments.data();
11 framebufferInfo.width = swapChainExtent.width;
12 framebufferInfo.height = swapChainExtent.height;
13 framebufferInfo.layers = 1;
L’attachement de couleur doit différer pour chaque image de la swap chain, mais
l’attachement de profondeur peut être le même pour toutes, car il n’est utilisé
que par la subpasse, et la synchronisation que nous avons mise en place ne
permet pas l’exécution de plusieurs subpasses en même temps.
Nous devons également déplacer l’appel à createFramebuffers pour que la
fonction ne soit appelée qu’après la création de l’image de profondeur :
1 void initVulkan() {
2 ...
3 createDepthResources();
4 createFramebuffers();
5 ...
6 }
Supprimer les valeurs
Comme nous avons plusieurs attachements avec VK_ATTACHMENT_LOAD_OP_CLEAR,
nous devons spécifier plusieurs valeurs de suppression. Allez à createCommandBuffers
et créez un tableau de VkClearValue :
1 std::array<VkClearValue, 2> clearValues{};
2 clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
3 clearValues[1].depthStencil = {1.0f, 0};
4
5 renderPassInfo.clearValueCount =
static_cast<uint32_t>(clearValues.size());
6 renderPassInfo.pClearValues = clearValues.data();
Avec Vulkan, 0.0 correspond au plan near et 1.0 au plan far. La valeur initiale
doit donc être 1.0, afin que tout fragment puisse s’y afficher. Notez que l’ordre
des clearValues correspond à l’ordre des attachements auquelles les couleurs
correspondent.
229
État de profondeur et de stencil
L’attachement de profondeur est prêt à être utilisé, mais le test de profondeur
n’a pas encore été activé. Il est configuré à l’aide d’une structure de type
VkPipelineDepthStencilStateCreateInfo.
1 VkPipelineDepthStencilStateCreateInfo depthStencil{};
2 depthStencil.sType =
VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
3 depthStencil.depthTestEnable = VK_TRUE;
4 depthStencil.depthWriteEnable = VK_TRUE;
Le champ depthTestEnable permet d’activer la comparaison de la profondeur
des fragments. Le champ depthWriteEnable indique si la nouvelle profondeur
des fragments qui passent le test doivent être écrite dans le tampon de pro-
fondeur.
1 depthStencil.depthCompareOp = VK_COMPARE_OP_LESS;
Le champ depthCompareOp permet de fournir le test de comparaison utilisé
pour conserver ou éliminer les fragments. Nous gardons le < car il correspond
le mieux à la convention employée par Vulkan.
1 depthStencil.depthBoundsTestEnable = VK_FALSE;
2 depthStencil.minDepthBounds = 0.0f; // Optionnel
3 depthStencil.maxDepthBounds = 1.0f; // Optionnel
Les champs depthBoundsTestEnable, minDepthBounds et maxDepthBounds
sont utilisés pour des tests optionnels d’encadrement de profondeur. Ils
permettent de ne garder que des fragments dont la profondeur est comprise
entre deux valeurs fournies ici. Nous n’utiliserons pas cette fonctionnalité.
1 depthStencil.stencilTestEnable = VK_FALSE;
2 depthStencil.front = {}; // Optionnel
3 depthStencil.back = {}; // Optionnel
Les trois derniers champs configurent les opérations du buffer de stencil, que
nous n’utiliserons pas non plus dans ce tutoriel. Si vous voulez l’utiliser, vous
devrez vous assurer que le format sélectionné pour la profondeur contient aussi
un composant pour le stencil.
1 pipelineInfo.pDepthStencilState = &depthStencil;
Mettez à jour la création d’une instance de VkGraphicsPipelineCreateInfo
pour référencer l’état de profondeur et de stencil que nous venons de créer. Un
tel état doit être spécifié si la passe contient au moins l’une de ces fonctionnalités.
Si vous lancez le programme, vous verrez que la géométrie est maintenant cor-
rectement rendue :
230
Gestion des redimensionnements de la fenêtre
La résolution du buffer de profondeur doit changer avec la fenêtre quand elle
redimensionnée, pour pouvoir correspondre à la taille de l’attachement. Étendez
recreateSwapChain pour régénérer les ressources :
1 void recreateSwapChain() {
2 int width = 0, height = 0;
3 while (width == 0 || height == 0) {
4 glfwGetFramebufferSize(window, &width, &height);
5 glfwWaitEvents();
6 }
7
8 vkDeviceWaitIdle(device);
9
10 cleanupSwapChain();
11
12 createSwapChain();
13 createImageViews();
14 createRenderPass();
15 createGraphicsPipeline();
16 createDepthResources();
231
17 createFramebuffers();
18 createUniformBuffers();
19 createDescriptorPool();
20 createDescriptorSets();
21 createCommandBuffers();
22 }
La libération des ressources doit avoir lieu dans la fonction de libération de la
swap chain.
1 void cleanupSwapChain() {
2 vkDestroyImageView(device, depthImageView, nullptr);
3 vkDestroyImage(device, depthImage, nullptr);
4 vkFreeMemory(device, depthImageMemory, nullptr);
5
6 ...
7 }
Votre application est maintenant capable de rendre correctement de la géométrie
3D! Nous allons utiliser cette fonctionnalité pour afficher un modèle dans le
prohain chapitre.
Code C++ / Vertex shader / Fragment shader
232
Charger des modèles
Introduction
Votre programme peut maintenant réaliser des rendus 3D, mais la géométrie que
nous utilisons n’est pas très intéressante. Nous allons maintenant étendre notre
programme pour charger les sommets depuis des fichiers. Votre carte graphique
aura enfin un peu de travail sérieux à faire.
Beaucoup de tutoriels sur les APIs graphiques font implémenter par le lecteur un
système pour charger les modèle OBJ. Le problème est que ce type de fichier est
limité. Nous allons charger des modèles en OBJ, mais nous nous concentrerons
plus sur l’intégration des sommets dans le programme, plutôt que sur les aspects
spécifiques de ce format de fichier.
Une librairie
Nous utiliserons la librairie tinyobjloader pour charger les vertices et les faces
depuis un fichier OBJ. Elle est facile à utiliser et à intégrer, car elle est contenue
dans un seul fichier. Téléchargez-la depuis le lien GitHub, elle est contenue dans
le fichier tiny_obj_loader.h.
Visual Studio
Ajoutez dans Additional Include Directories le dossier dans lequel est con-
tenu tiny_obj_loader.h.
233
Makefile
Ajoutez le dossier contenant tiny_obj_loader.h aux dossiers d’inclusions de
GCC :
1 VULKAN_SDK_PATH = /home/user/VulkanSDK/x.x.x.x/x86_64
2 STB_INCLUDE_PATH = /home/user/libraries/stb
3 TINYOBJ_INCLUDE_PATH = /home/user/libraries/tinyobjloader
4
5 ...
6
7 CFLAGS = -std=c++17 -I$(VULKAN_SDK_PATH)/include
-I$(STB_INCLUDE_PATH) -I$(TINYOBJ_INCLUDE_PATH)
Exemple de modèle
Nous n’allons pas utiliser de lumières pour l’instant. Il est donc préférable de
charger un modèle qui comprend les ombres pour que nous ayons un rendu plus
intéressant. Vous pouvez trouver de tels modèles sur Sketchfab.
Pour ce tutoriel j’ai choisi d’utiliser le Viking room créé par nigelgoh (CC BY
4.0). J’en ai changé la taille et l’orientation pour l’utiliser comme remplacement
de notre géométrie actuelle :
• viking_room.obj
• viking_room.png
Il possède un demi-million de triangles, ce qui fera un bon test pour notre
application. Vous pouvez utiliser un autre modèle si vous le désirez, mais
assurez-vous qu’il ne comprend qu’un seul matériau et que ses dimensions sont
d’approximativement 1.5 x 1.5 x 1.5. Si il est plus grand vous devrez changer
la matrice view. Mettez le modèle dans un dossier appelé models, et placez
l’image dans le dossier textures.
Ajoutez deux variables de configuration pour la localisation du modèle et de la
texture :
234
1 const uint32_t WIDTH = 800;
2 const uint32_t HEIGHT = 600;
3
4 const std::string MODEL_PATH = "models/viking_room.obj";
5 const std::string TEXTURE_PATH = "textures/viking_room.png";
Changez la fonction createTextureImage pour qu’elle utilise cette seconde con-
stante pour charger la texture.
1 stbi_uc* pixels = stbi_load(TEXTURE_PATH.c_str(), &texWidth,
&texHeight, &texChannels, STBI_rgb_alpha);
Charger les vertices et les indices
Nous allons maintenant charger les vertices et les indices depuis le fichier OBJ.
Supprimez donc les tableaux vertices et indices, et remplacez-les par des
vecteurs dynamiques :
1 std::vector<Vertex> vertices;
2 std::vector<uint32_t> indices;
3 VkBuffer vertexBuffer;
4 VkDeviceMemory vertexBufferMemory;
Il faut aussi que le type des indices soit maintenant un uint32_t car nous allons
avoir plus que 65535 sommets. Changez également le paramètre de type dans
l’appel à vkCmdBindIndexBuffer.
1 vkCmdBindIndexBuffer(commandBuffers[i], indexBuffer, 0,
VK_INDEX_TYPE_UINT32);
La librairie que nous utilisons s’inclue de la même manière que les librairies
STB. Il faut définir la macro TINYOBJLOADER_IMLEMENTATION pour que le fichier
comprenne les définitions des fonctions.
1 #define TINYOBJLOADER_IMPLEMENTATION
2 #include <tiny_obj_loader.h>
Nous allons ensuite écrire la fonction loadModel pour remplir le tableau de
vertices et d’indices depuis le fichier OBJ. Nous devons l’appeler avant que les
buffers de vertices et d’indices soient créés.
1 void initVulkan() {
2 ...
3 loadModel();
4 createVertexBuffer();
5 createIndexBuffer();
6 ...
235
7 }
8
9 ...
10
11 void loadModel() {
12
13 }
Un modèle se charge dans la librairie avec la fonction tinyobj::LoadObj :
1 void loadModel() {
2 tinyobj::attrib_t attrib;
3 std::vector<tinyobj::shape_t> shapes;
4 std::vector<tinyobj::material_t> materials;
5 std::string warn, err;
6
7 if (!tinyobj::LoadObj(&attrib, &shapes, &materials, &warn, &err,
MODEL_PATH.c_str())) {
8 throw std::runtime_error(warn + err);
9 }
10 }
Dans un fichier OBJ on trouve des positions, des normales, des coordonnées
de textures et des faces. Ces dernières sont une collection de vertices, avec
chaque vertex lié à une position, une normale et/ou un coordonnée de texture
à l’aide d’un indice. Il est ainsi possible de réutiliser les attributs de manière
indépendante.
Le conteneur attrib contient les positions, les normales et les coordon-
nées de texture dans les vecteurs attrib.vertices, attrib.normals et
attrib.texcoords. Le conteneur shapes contient tous les objets et leurs faces.
Ces dernières se réfèrent donc aux données stockées dans attrib. Les modèles
peuvent aussi définir un matériau et une texture par face, mais nous ignorerons
ces attributs pour le moment.
La chaîne de caractères err contient les erreurs et les messages générés pendant
le chargement du fichier. Le chargement des fichiers ne rate réellement que
quand LoadObj retourne false. Les faces peuvent être constitués d’un nombre
quelconque de vertices, alors que notre application ne peut dessiner que des
triangles. Heureusement, la fonction possède la capacité - activée par défaut -
de triangulariser les faces.
Nous allons combiner toutes les faces du fichier en un seul modèle. Commençons
par itérer sur ces faces.
1 for (const auto& shape : shapes) {
2
3 }
236
Grâce à la triangularisation nous sommes sûrs que les faces n’ont que trois
vertices. Nous pouvons donc simplement les copier vers le vecteur des vertices
finales :
1 for (const auto& shape : shapes) {
2 for (const auto& index : shape.mesh.indices) {
3 Vertex vertex{};
4
5 vertices.push_back(vertex);
6 indices.push_back(indices.size());
7 }
8 }
Pour faire simple nous allons partir du principe que les sommets sont uniques.
La variable index est du type tinyobj::index_t, et contient vertex_index,
normal_index et texcoord_index. Nous devons traiter ces données pour les
relier aux données contenues dans les tableaux attrib :
1 vertex.pos = {
2 attrib.vertices[3 * index.vertex_index + 0],
3 attrib.vertices[3 * index.vertex_index + 1],
4 attrib.vertices[3 * index.vertex_index + 2]
5 };
6
7 vertex.texCoord = {
8 attrib.texcoords[2 * index.texcoord_index + 0],
9 attrib.texcoords[2 * index.texcoord_index + 1]
10 };
11
12 vertex.color = {1.0f, 1.0f, 1.0f};
Le tableau attrib.vertices est constitués de floats et non de vecteurs à trois
composants comme glm::vec3. Il faut donc multiplier les indices par 3. De
même on trouve deux coordonnées de texture par entrée. Les décalages 0, 1 et
2 permettent ensuite d’accéder aux composant X, Y et Z, ou aux U et V dans
le cas des textures.
Lancez le programme avec les optimisation activées (Release avec Visual Stu-
dio ou avec l’argument -03 pour GCC). Vous pourriez le faire sans mais le
chargement du modèle sera très long. Vous devriez voir ceci :
237
La géométrie est correcte! Par contre les textures sont quelque peu… étranges.
En effet le format OBJ part d’en bas à gauche pour les coordonnées de texture,
alors que Vulkan part d’en haut à gauche. Il suffit de changer cela pendant le
chargement du modèle :
1 vertex.texCoord = {
2 attrib.texcoords[2 * index.texcoord_index + 0],
3 1.0f - attrib.texcoords[2 * index.texcoord_index + 1]
4 };
Vous pouvez lancer à nouveau le programme. Le rendu devrait être correct :
238
Déduplication des vertices
Pour le moment nous n’utilisons pas l’index buffer, et le vecteur vertices con-
tient beaucoup de vertices dupliquées. Nous ne devrions les inclure qu’une seule
fois dans ce conteneur et utiliser leurs indices pour s’y référer. Une manière sim-
ple de procéder consiste à utiliser une unoredered_map pour suivre les vertices
multiples et leurs indices.
1 #include <unordered_map>
2
3 ...
4
5 std::unordered_map<Vertex, uint32_t> uniqueVertices{};
6
7 for (const auto& shape : shapes) {
8 for (const auto& index : shape.mesh.indices) {
9 Vertex vertex{};
10
11 ...
12
13 if (uniqueVertices.count(vertex) == 0) {
239
14 uniqueVertices[vertex] =
static_cast<uint32_t>(vertices.size());
15 vertices.push_back(vertex);
16 }
17
18 indices.push_back(uniqueVertices[vertex]);
19 }
20 }
Chaque fois que l’on extrait un vertex du fichier, nous devons vérifier si nous
avons déjà manipulé un vertex possédant les mêmes attributs. Si il est nouveau,
nous le stockerons dans vertices et placerons son indice dans uniqueVertices
et dans indices. Si nous avons déjà un tel vertex nous regarderons son indice
depuis uniqueVertices et copierons cette valeur dans indices.
Pour l’instant le programme ne peut pas compiler, car nous devons implémenter
une fonction de hachage et l’opérateur d’égalité pour utiliser la structure Vertex
comme clé dans une table de hachage. L’opérateur est simple à surcharger :
1 bool operator==(const Vertex& other) const {
2 return pos == other.pos && color == other.color && texCoord ==
other.texCoord;
3 }
Nous devons définir une spécialisation du patron de classe std::hash<T> pour la
fonction de hachage. Le hachage est un sujet compliqué, mais cppreference.com
recommande l’approche suivante pour combiner correctement les champs d’une
structure :
1 namespace std {
2 template<> struct hash<Vertex> {
3 size_t operator()(Vertex const& vertex) const {
4 return ((hash<glm::vec3>()(vertex.pos) ^
5 (hash<glm::vec3>()(vertex.color) << 1)) >> 1) ^
6 (hash<glm::vec2>()(vertex.texCoord) << 1);
7 }
8 };
9 }
Ce code doit être placé hors de la définition de Vertex. Les fonctions de hashage
des type GLM sont activés avec la définition et l’inclusion suivantes :
1 #define GLM_ENABLE_EXPERIMENTAL
2 #include <glm/gtx/hash.hpp>
Le dossier glm/gtx/ contient les extensions expérimentales de GLM. L’API peut
changer dans le futur, mais la librairie a toujours été très stable.
240
Vous devriez pouvoir compiler et lancer le programme maintenant. Si vous
regardez la taille de vertices vous verrez qu’elle est passée d’un million et
demi vertices à seulement 265645! Les vertices sont utilisés pour six triangles
en moyenne, ce qui représente une optimisation conséquente.
Code C++ / Vertex shader / Fragment shader
241
Générer des mipmaps
Introduction
Notre programme peut maintenant charger et afficher des modèles 3D. Dans
ce chapitre nous allons ajouter une nouvelle fonctionnalité : celle de générer
et d’utiliser des mipmaps. Elles sont utilisées dans tous les applications 3D.
Vulkan laisse au programmeur un control quasiment total sur leur génération.
Les mipmaps sont des versions de qualité réduite précalculées d’une texture.
Chacune de ces versions est deux fois moins haute et large que l’originale. Les
objets plus distants de la caméra peuvent utiliser ces versions pour le sampling
de la texture. Le rendu est alors plus rapide et plus lisse. Voici un exemple de
mipmaps :
242
Création des images
Avec Vulkan, chaque niveau de mipmap est stocké dans les différents niveaux de
mipmap de l’image originale. Le niveau 0 correspond à l’image originale. Les
images suivantes sont souvent appelées mip chain.
Le nombre de niveaux de mipmap doit être fourni lors de la création de l’image.
Jusqu’à présent nous avons indiqué la valeur 1. Nous devons ainsi calculer le
nombre de mipmaps à générer à partir de la taille de l’image. Créez un membre
donnée pour contenir cette valeur :
1 ...
2 uint32_t mipLevels;
3 VkImage textureImage;
4 ...
La valeur pour mipLevels peut être déterminée une fois que nous avons chargé
la texture dans createTextureImage :
1 int texWidth, texHeight, texChannels;
2 stbi_uc* pixels = stbi_load(TEXTURE_PATH.c_str(), &texWidth,
&texHeight, &texChannels, STBI_rgb_alpha);
3 ...
4 mipLevels =
static_cast<uint32_t>(std::floor(std::log2(std::max(texWidth,
texHeight)))) + 1;
La troisième ligne ci-dessus calcule le nombre de niveaux de mipmaps. La
fonction max chosit la plus grande des dimensions, bien que dans la pratique
les textures seront toujours carrées. Ensuite, log2 donne le nombre de fois que
les dimensions peuvent être divisées par deux. La fonction floor gère le cas
où la dimension n’est pas un multiple de deux (ce qui est déconseillé). 1 est
finalement rajouté pour que l’image originale soit aussi comptée.
Pour utiliser cette valeur nous devons changer les fonctions createImage,
createImageView et transitionImageLayout. Nous devrons y indiquer le
nombre de mipmaps. Ajoutez donc cette donnée en paramètre à toutes ces
fonctions :
1 void createImage(uint32_t width, uint32_t height, uint32_t
mipLevels, VkFormat format, VkImageTiling tiling,
VkImageUsageFlags usage, VkMemoryPropertyFlags properties,
VkImage& image, VkDeviceMemory& imageMemory) {
2 ...
3 imageInfo.mipLevels = mipLevels;
4 ...
5 }
243
1 VkImageView createImageView(VkImage image, VkFormat format,
VkImageAspectFlags aspectFlags, uint32_t mipLevels) {
2 ...
3 viewInfo.subresourceRange.levelCount = mipLevels;
4 ...
1 void transitionImageLayout(VkImage image, VkFormat format,
VkImageLayout oldLayout, VkImageLayout newLayout, uint32_t
mipLevels) {
2 ...
3 barrier.subresourceRange.levelCount = mipLevels;
4 ...
Il nous faut aussi mettre à jour les appels.
1 createImage(swapChainExtent.width, swapChainExtent.height, 1,
depthFormat, VK_IMAGE_TILING_OPTIMAL,
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, depthImage,
depthImageMemory);
2 ...
3 createImage(texWidth, texHeight, mipLevels, VK_FORMAT_R8G8B8A8_SRGB,
VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSFER_DST_BIT |
VK_IMAGE_USAGE_SAMPLED_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
textureImage, textureImageMemory);
1 swapChainImageViews[i] = createImageView(swapChainImages[i],
swapChainImageFormat, VK_IMAGE_ASPECT_COLOR_BIT, 1);
2 ...
3 depthImageView = createImageView(depthImage, depthFormat,
VK_IMAGE_ASPECT_DEPTH_BIT, 1);
4 ...
5 textureImageView = createImageView(textureImage,
VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_ASPECT_COLOR_BIT, mipLevels);
1 transitionImageLayout(depthImage, depthFormat,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, 1);
2 ...
3 transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB,
VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
mipLevels);
244
Génération des mipmaps
Notre texture a plusieurs niveaux de mipmaps, mais le buffer intermédiaire ne
peut pas gérer cela. Les niveaux autres que 0 sont indéfinis. Pour les remplir
nous devons générer les mipmaps à partir du seul niveau que nous avons. Nous
allons faire cela du côté de la carte graphique. Nous allons pour cela utiliser la
commande vkCmdBlitImage. Elle effectue une copie, une mise à l’échelle et un
filtrage. Nous allons l’appeler une fois par niveau.
Cette commande est considérée comme une opération de transfert. Nous
devons donc indiquer que la mémoire de l’image sera utilisée à la
fois comme source et comme destination de la commande. Ajoutez
VK_IMAGE_USAGE_TRANSFER_SRC_BIT à la création de l’image.
1 ...
2 createImage(texWidth, texHeight, mipLevels, VK_FORMAT_R8G8B8A8_SRGB,
VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, textureImage,
textureImageMemory);
3 ...
Comme pour les autres opérations sur les images, la commande vkCmdBlitImage
dépend de l’organisation de l’image sur laquelle elle opère. Nous pourrions
transitionner l’image vers VK_IMAGE_LAYOUT_GENERAL, mais les opérations
prendraient beaucoup de temps. En fait il est possible de transitionner les
niveaux de mipmaps indépendemment les uns des autres. Nous pouvons
donc mettre l’image initiale à VK_IMAGE_LAYOUT_TRANSFER_SCR_OPTIMAL et la
chaîne de mipmaps à VK_IMAGE_LAYOUT_DST_OPTIMAL. Nous pourrons réaliser
les transitions à la fin de chaque opération.
La fonction transitionImageLayout ne peut réaliser une transition
d’organisation que sur l’image entière. Nous allons donc devoir écrire quelque
commandes liées aux barrières de pipeline. Supprimez la transition vers
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL dans createTextureImage :
1 ...
2 transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB,
VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
mipLevels);
3 copyBufferToImage(stagingBuffer, textureImage,
static_cast<uint32_t>(texWidth),
static_cast<uint32_t>(texHeight));
4 //transitionné vers VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL lors de
la generation des mipmaps
5 ...
245
Tous les niveaux de l’image seront ainsi en VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL.
Chaque niveau sera ensuite transitionné vers VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
après l’exécution de la commande.
Nous allons maintenant écrire la fonction qui génèrera les mipmaps.
1 void generateMipmaps(VkImage image, int32_t texWidth, int32_t
texHeight, uint32_t mipLevels) {
2 VkCommandBuffer commandBuffer = beginSingleTimeCommands();
3
4 VkImageMemoryBarrier barrier{};
5 barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
6 barrier.image = image;
7 barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
8 barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
9 barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
10 barrier.subresourceRange.baseArrayLayer = 0;
11 barrier.subresourceRange.layerCount = 1;
12 barrier.subresourceRange.levelCount = 1;
13
14 endSingleTimeCommands(commandBuffer);
15 }
Nous allons réaliser plusieurs transitions, et pour cela nous réutiliserons cette
structure VkImageMemoryBarrier. Les champs remplis ci-dessus seront valides
pour tous les niveaux, et nous allons changer les champs manquant au fur et à
mesure de la génération des mipmaps.
1 int32_t mipWidth = texWidth;
2 int32_t mipHeight = texHeight;
3
4 for (uint32_t i = 1; i < mipLevels; i++) {
5
6 }
Cette boucle va enregistrer toutes les commandes VkCmdBlitImage. Remarquez
que la boucle commence à 1, et pas à 0.
1 barrier.subresourceRange.baseMipLevel = i - 1;
2 barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
3 barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
4 barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
5 barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
6
7 vkCmdPipelineBarrier(commandBuffer,
8 VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0,
9 0, nullptr,
246
10 0, nullptr,
11 1, &barrier);
Tout d’abord nous transitionnons le i-1ième niveau vers VK_IMAGE_LAYOUT_TRANSFER_SCR_OPTIMAL.
Cette transition attendra que le niveau de mipmap soit prêt, que ce soit par
copie depuis le buffer pour l’image originale, ou bien par vkCmdBlitImage. La
commande de génération de la mipmap suivante attendra donc la fin de la
précédente.
1 VkImageBlit blit{};
2 blit.srcOffsets[0] = { 0, 0, 0 };
3 blit.srcOffsets[1] = { mipWidth, mipHeight, 1 };
4 blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
5 blit.srcSubresource.mipLevel = i - 1;
6 blit.srcSubresource.baseArrayLayer = 0;
7 blit.srcSubresource.layerCount = 1;
8 blit.dstOffsets[0] = { 0, 0, 0 };
9 blit.dstOffsets[1] = { mipWidth > 1 ? mipWidth / 2 : 1, mipHeight >
1 ? mipHeight / 2 : 1, 1 };
10 blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
11 blit.dstSubresource.mipLevel = i;
12 blit.dstSubresource.baseArrayLayer = 0;
13 blit.dstSubresource.layerCount = 1;
Nous devons maintenant indiquer les régions concernées par la commande.
Le niveau de mipmap source est i-1 et le niveau destination est i. Les
deux éléments du tableau scrOffsets déterminent en 3D la région source, et
dstOffsets la région cible. Les coordonnées X et Y sont à chaque fois divisées
par deux pour réduire la taille des mipmaps. La coordonnée Z doit être mise à
la profondeur de l’image, c’est à dire 1.
1 vkCmdBlitImage(commandBuffer,
2 image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
3 image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
4 1, &blit,
5 VK_FILTER_LINEAR);
Nous enregistrons maintenant les commandes. Remarquez que textureImage
est utilisé à la fois comme source et comme cible, car la commande s’applique
à plusieurs niveaux de l’image. Le niveau de mipmap source vient d’être tran-
sitionné vers VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, et le niveau cible est
resté en destination depuis sa création.
Attention au cas où vous utilisez une queue de transfert dédiée (comme suggéré
dans Vertex buffers) : la fonction vkCmdBlitImage doit être envoyée dans une
queue graphique.
247
Le dernier paramètre permet de fournir un VkFilter. Nous voulons le même
filtre que pour le sampler, nous pouvons donc mettre VK_FILTER_LINEAR.
1 barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
2 barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
3 barrier.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
4 barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
5
6 vkCmdPipelineBarrier(commandBuffer,
7 VK_PIPELINE_STAGE_TRANSFER_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0,
8 0, nullptr,
9 0, nullptr,
10 1, &barrier);
Ensuite, la boucle transtionne le i-1ième niveau de mipmap vers l’organisation
optimale pour la lecture par shader. La transition attendra la fin de la com-
mande, de même que les opérations de sampling.
1 ...
2 if (mipWidth > 1) mipWidth /= 2;
3 if (mipHeight > 1) mipHeight /= 2;
4 }
Les tailles de la mipmap sont ensuite divisées par deux. Nous vérifions quand
même que ces dimensions sont bien supérieures à 1, ce qui peut arriver dans le
cas d’une image qui n’est pas carrée.
1 barrier.subresourceRange.baseMipLevel = mipLevels - 1;
2 barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
3 barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
4 barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
5 barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
6
7 vkCmdPipelineBarrier(commandBuffer,
8 VK_PIPELINE_STAGE_TRANSFER_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0,
9 0, nullptr,
10 0, nullptr,
11 1, &barrier);
12
13 endSingleTimeCommands(commandBuffer);
14 }
Avant de terminer avec le command buffer, nous devons ajouter une
dernière barrière. Elle transitionne le dernier niveau de mipmap vers
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL. Ce cas n’avait pas été géré
par la boucle, car elle n’a jamais servie de source à une copie.
248
Appelez finalement cette fonction depuis createTextureImage :
1 transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB,
VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
mipLevels);
2 copyBufferToImage(stagingBuffer, textureImage,
static_cast<uint32_t>(texWidth),
static_cast<uint32_t>(texHeight));
3 //transions vers VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL pendant la
génération des mipmaps
4 ...
5 generateMipmaps(textureImage, texWidth, texHeight, mipLevels);
Les mipmaps de notre image sont maintenant complètement remplies.
Support pour le filtrage linéaire
La fonction vkCmdBlitImage est extrêmement pratique. Malheureusement il
n’est pas garanti qu’elle soit disponible. Elle nécessite que le format de l’image
texture supporte ce type de filtrage, ce que nous pouvons vérifier avec la fonction
vkGetPhysicalDeviceFormatProperties. Nous allons vérifier sa disponibilité
dans generateMipmaps.
Ajoutez d’abord un paramètre qui indique le format de l’image :
1 void createTextureImage() {
2 ...
3
4 generateMipmaps(textureImage, VK_FORMAT_R8G8B8A8_SRGB, texWidth,
texHeight, mipLevels);
5 }
6
7 void generateMipmaps(VkImage image, VkFormat imageFormat, int32_t
texWidth, int32_t texHeight, uint32_t mipLevels) {
8
9 ...
10 }
Utilisez vkGetPhysicalDeviceFormatProperties dans generateMipmaps pour
récupérer les propriétés liés au format :
1 void generateMipmaps(VkImage image, VkFormat imageFormat, int32_t
texWidth, int32_t texHeight, uint32_t mipLevels) {
2
3 // Vérifions si l'image supporte le filtrage linéaire
4 VkFormatProperties formatProperties;
5 vkGetPhysicalDeviceFormatProperties(physicalDevice, imageFormat,
&formatProperties);
249
6
7 ...
La structure VkFormatProperties possède les trois champs linearTilingFeatures,
optimalTilingFeature et bufferFeaetures. Ils décrivent chacun l’utilisation
possible d’images de ce format dans certains contextes. Nous avons créé
l’image avec le format optimal, les informations qui nous concernent sont donc
dans optimalTilingFeatures. Le support pour le filtrage linéaire est ensuite
indiqué par VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT.
1 if (!(formatProperties.optimalTilingFeatures &
VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT)) {
2 throw std::runtime_error("le format de l'image texture ne
supporte pas le filtrage lineaire!");
3 }
Il y a deux alternatives si le format ne permet pas l’utilisation de
vkCmdBlitImage. Vous pouvez créer une fonction pour essayer de trou-
ver un format supportant la commande, ou vous pouvez utiliser une librairie
pour générer les mipmaps comme stb_image_resize. Chaque niveau de
mipmap peut ensuite être chargé de la même manière que vous avez chargé
l’image.
Souvenez-vous qu’il est rare de générer les mipmaps pendant l’exécution. Elles
sont généralement prégénérées et stockées dans le fichier avec l’image de base.
Le chargement de mipmaps prégénérées est laissé comme exercice au lecteur.
Sampler
Un objet VkImage contient les données de l’image et un objet VkSampler contrôle
la lecture des données pendant le rendu. Vulkan nous permet de spécifier les
valeurs minLod, maxLod, mipLodBias et mipmapMode, où “Lod” signifie level of
detail (niveau de détail). Pendant l’échantillonnage d’une texture, le sampler
sélectionne le niveau de mipmap à utiliser suivant ce pseudo-code :
1 lod = getLodLevelFromScreenSize(); //plus petit quand l'objet est
proche, peut être negatif
2 lod = clamp(lod + mipLodBias, minLod, maxLod);
3
4 level = clamp(floor(lod), 0, texture.mipLevels - 1); //limité par
le nombre de niveaux de mipmaps dans le texture
5
6 if (mipmapMode == VK_SAMPLER_MIPMAP_MODE_NEAREST) {
7 color = sample(level);
8 } else {
9 color = blend(sample(level), sample(level + 1));
10 }
250
Si samplerInfo.mipmapMode est VK_SAMPLER_MIPMAP_MODE_NEAREST, la vari-
able lod correspond au niveau de mipmap à échantillonner. Sinon, si il vaut
VK_SAMPLER_MIPMAP_MODE_LINEAR, deux niveaux de mipmaps sont samplés,
puis interpolés linéairement.
L’opération d’échantillonnage est aussi affectée par lod :
1 if (lod <= 0) {
2 color = readTexture(uv, magFilter);
3 } else {
4 color = readTexture(uv, minFilter);
5 }
Si l’objet est proche de la caméra, magFilter est utilisé comme filtre. Si l’objet
est plus distant, minFilter sera utilisé. Normalement lod est positif, est devient
nul au niveau de la caméra. mipLodBias permet de forcer Vulkan à utiliser un
lod plus petit et donc un noveau de mipmap plus élevé.
Pour voir les résultats de ce chapitre, nous devons choisir les valeurs pour
textureSampler. Nous avons déjà fourni minFilter et magFilter. Il nous
reste les valeurs minLod, maxLod, mipLodBias et mipmapMode.
1 void createTextureSampler() {
2 ...
3 samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
4 samplerInfo.minLod = 0.0f;
5 samplerInfo.maxLod = static_cast<float>(mipLevels);
6 samplerInfo.mipLodBias = 0.0f; // Optionnel
7 ...
8 }
Pour utiliser la totalité des niveaux de mipmaps, nous mettons minLod à 0.0f
et maxLod au nombre de niveaux de mipmaps. Nous n’avons aucune raison
d’altérer lod avec mipLodBias, alors nous pouvons le mettre à 0.0f.
Lancez votre programme et vous devriez voir ceci :
251
Notre scène est si simple qu’il n’y a pas de différence majeure. En comparant
précisement on peut voir quelques différences.
La différence la plus évidente est l’écriture sur le paneau, plus lisse avec les
mipmaps.
252
Vous pouvez modifier les paramètres du sampler pour voir l’impact sur le rendu.
Par exemple vous pouvez empêcher le sampler d’utiliser le plus haut nivau de
mipmap en ne lui indiquant pas le niveau le plus bas :
1 samplerInfo.minLod = static_cast<float>(mipLevels / 2);
Ce paramètre produira ce rendu :
Code C++ / Vertex shader / Fragment shader
253
Multisampling
Introduction
Notre programme peut maintenant générer plusieurs niveaux de détails pour les
textures qu’il utilise. Ces images sont plus lisses quand vues de loin. Cependant
on peut voir des motifs en dent de scie si on regarde les textures de plus près.
Ceci est particulièrement visible sur le rendu de carrés :
Cet effet indésirable s’appelle “aliasing”. Il est dû au manque de pixels pour
afficher tous les détails de la géométrie. Il sera toujours visible, par contre nous
pouvons utiliser des techniques pour le réduire considérablement. Nous allons
254
ici implémenter le multisample anti-aliasing, terme condensé en MSAA.
Dans un rendu standard, la couleur d’un pixel est déterminée à partir d’un
unique sample, en général le centre du pixel. Si une ligne passe partiellement
par un pixel sans en toucher le centre, sa contribution à la couleur sera nulle.
Nous voudrions plutôt qu’il y contribue partiellement.
Le MSAA consiste à utiliser plusieurs points dans un pixel pour déterminer
la couleur d’un pixel. Comme on peut s’y attendre, plus de points offrent un
meilleur résultat, mais consomment plus de ressources.
Nous allons utiliser le maximum de points possible. Si votre application nécessite
plus de performances, il vous suffira de réduire ce nombre.
255
Récupération du nombre maximal de samples
Commençons par déterminer le nombre maximal de samples que la carte
graphique supporte. Les GPUs modernes supportent au moins 8 points, mais il
peut tout de même différer entre modèles. Nous allons stocker ce nombre dans
un membre donnée :
1 ...
2 VkSampleCountFlagBits msaaSamples = VK_SAMPLE_COUNT_1_BIT;
3 ...
Par défaut nous n’utilisons qu’un point, ce qui correspond à ne pas utiliser
de multisampling. Le nombre maximal est inscrit dans la structure de type
VkPhysicalDeviceProperties associée au GPU. Comme nous utilisons un
buffer de profondeur, nous devons prendre en compte le nombre de samples
pour la couleur et pour la profondeur. Le plus haut taux de samples supporté
par les deux (&) sera celui que nous utiliserons. Créez une fonction dans
laquelle les informations seront récupérées :
1 VkSampleCountFlagBits getMaxUsableSampleCount() {
2 VkPhysicalDeviceProperties physicalDeviceProperties;
3 vkGetPhysicalDeviceProperties(physicalDevice,
&physicalDeviceProperties);
4
5 VkSampleCountFlags counts =
physicalDeviceProperties.limits.framebufferColorSampleCounts
&
physicalDeviceProperties.limits.framebufferDepthSampleCounts;
6 if (counts & VK_SAMPLE_COUNT_64_BIT) { return
VK_SAMPLE_COUNT_64_BIT; }
7 if (counts & VK_SAMPLE_COUNT_32_BIT) { return
VK_SAMPLE_COUNT_32_BIT; }
8 if (counts & VK_SAMPLE_COUNT_16_BIT) { return
VK_SAMPLE_COUNT_16_BIT; }
9 if (counts & VK_SAMPLE_COUNT_8_BIT) { return
VK_SAMPLE_COUNT_8_BIT; }
10 if (counts & VK_SAMPLE_COUNT_4_BIT) { return
VK_SAMPLE_COUNT_4_BIT; }
11 if (counts & VK_SAMPLE_COUNT_2_BIT) { return
VK_SAMPLE_COUNT_2_BIT; }
12
13 return VK_SAMPLE_COUNT_1_BIT;
14 }
Nous allons maintenant utiliser cette fonction pour donner une valeur à
msaaSamples pendant la sélection du GPU. Nous devons modifier la fonction
pickPhysicalDevice :
256
1 void pickPhysicalDevice() {
2 ...
3 for (const auto& device : devices) {
4 if (isDeviceSuitable(device)) {
5 physicalDevice = device;
6 msaaSamples = getMaxUsableSampleCount();
7 break;
8 }
9 }
10 ...
11 }
Mettre en place une cible de rendu
Le MSAA consiste à écrire chaque pixel dans un buffer indépendant de
l’affichage, dont le contenu est ensuite rendu en le résolvant à un framebuffer
standard. Cette étape est nécessaire car le premier buffer est une image
particulière : elle doit supporter plus d’un échantillon par pixel. Il ne peut pas
être utilisé comme framebuffer dans la swap chain. Nous allons donc devoir
changer notre rendu. Nous n’aurons besoin que d’une cible de rendu, car seule
une opération de rendu n’est autorisée à s’exécuter à un instant donné. Créez
les membres données suivants :
1 ...
2 VkImage colorImage;
3 VkDeviceMemory colorImageMemory;
4 VkImageView colorImageView;
5 ...
Cette image doit supporter le nombre de samples déterminé auparavant, nous
devons donc le lui fournir durant sa création. Ajoutez un paramètre numSamples
à la fonction createImage :
1 void createImage(uint32_t width, uint32_t height, uint32_t
mipLevels, VkSampleCountFlagBits numSamples, VkFormat format,
VkImageTiling tiling, VkImageUsageFlags usage,
VkMemoryPropertyFlags properties, VkImage& image,
VkDeviceMemory& imageMemory) {
2 ...
3 imageInfo.samples = numSamples;
4 ...
Mettez à jour tous les appels avec VK_SAMPLE_COUNT_1_BIT. Nous changerons
cette valeur pour la nouvelle image.
1 createImage(swapChainExtent.width, swapChainExtent.height, 1,
VK_SAMPLE_COUNT_1_BIT, depthFormat, VK_IMAGE_TILING_OPTIMAL,
257
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, depthImage,
depthImageMemory);
2 ...
3 createImage(texWidth, texHeight, mipLevels, VK_SAMPLE_COUNT_1_BIT,
VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_TILING_OPTIMAL,
VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, textureImage,
textureImageMemory);
Nous allons maintenant créer un buffer de couleur à plusieurs samples. Créez la
fonction createColorResources, et passez msaaSamples à createImage depuis
cette fonction. Nous n’utilisons également qu’un niveau de mipmap, ce qui est
nécessaire pour conformer à la spécification de Vulkan. Mais de toute façon
cette image n’a pas besoin de mipmaps.
1 void createColorResources() {
2 VkFormat colorFormat = swapChainImageFormat;
3
4 createImage(swapChainExtent.width, swapChainExtent.height, 1,
msaaSamples, colorFormat, VK_IMAGE_TILING_OPTIMAL,
VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT |
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, colorImage,
colorImageMemory);
5 colorImageView = createImageView(colorImage, colorFormat,
VK_IMAGE_ASPECT_COLOR_BIT, 1);
6 }
Pour une question de cohérence mettons cette fonction juste avant
createDepthResource.
1 void initVulkan() {
2 ...
3 createColorResources();
4 createDepthResources();
5 ...
6 }
Nous avons maintenant un buffer de couleurs qui utilise le multisampling.
Occupons-nous maintenant de la profondeur. Modifiez createDepthResources
et changez le nombre de samples utilisé :
1 void createDepthResources() {
2 ...
258
3 createImage(swapChainExtent.width, swapChainExtent.height, 1,
msaaSamples, depthFormat, VK_IMAGE_TILING_OPTIMAL,
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, depthImage,
depthImageMemory);
4 ...
5 }
Comme nous avons créé quelques ressources, nous devons les libérer :
1 void cleanupSwapChain() {
2 vkDestroyImageView(device, colorImageView, nullptr);
3 vkDestroyImage(device, colorImage, nullptr);
4 vkFreeMemory(device, colorImageMemory, nullptr);
5 ...
6 }
Mettez également à jour recreateSwapChain pour prendre en charge les recréa-
tions de l’image couleur.
1 void recreateSwapChain() {
2 ...
3 createGraphicsPipeline();
4 createColorResources();
5 createDepthResources();
6 ...
7 }
Nous avons fini le paramétrage initial du MSAA. Nous devons maintenant
utiliser ces ressources dans la pipeline, le framebuffer et la render pass!
Ajouter de nouveaux attachements
Gérons d’abord la render pass. Modifiez createRenderPass et changez-y la
création des attachements de couleur et de profondeur.
1 void createRenderPass() {
2 ...
3 colorAttachment.samples = msaaSamples;
4 colorAttachment.finalLayout =
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
5 ...
6 depthAttachment.samples = msaaSamples;
7 ...
Nous avons changé l’organisation finale à VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
car les images qui utilisent le multisampling ne peuvent être présentées directe-
ment. Nous devons la convertir en une image plus classique. Nous n’aurons pas
259
à convertir le buffer de profondeur, dans la mesure où il ne sera jamais présenté.
Nous avons donc besoin d’un nouvel attachement pour la couleur, dans lequel
les pixels seront résolus.
1 ...
2 VkAttachmentDescription colorAttachmentResolve{};
3 colorAttachmentResolve.format = swapChainImageFormat;
4 colorAttachmentResolve.samples = VK_SAMPLE_COUNT_1_BIT;
5 colorAttachmentResolve.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
6 colorAttachmentResolve.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
7 colorAttachmentResolve.stencilLoadOp =
VK_ATTACHMENT_LOAD_OP_DONT_CARE;
8 colorAttachmentResolve.stencilStoreOp =
VK_ATTACHMENT_STORE_OP_DONT_CARE;
9 colorAttachmentResolve.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
10 colorAttachmentResolve.finalLayout =
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
11 ...
La render pass doit maintenant être configurée pour résoudre l’attachement
multisamplé en un attachement simple. Créez une nouvelle référence au futur
attachement qui contiendra le buffer de pixels résolus :
1 ...
2 VkAttachmentReference colorAttachmentResolveRef{};
3 colorAttachmentResolveRef.attachment = 2;
4 colorAttachmentResolveRef.layout =
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
5 ...
Ajoutez la référence à l’attachement dans le membre pResolveAttachments de
la structure de création de la subpasse. La subpasse n’a besoin que de cela pour
déterminer l’opération de résolution du multisampling :
1 ...
2 subpass.pResolveAttachments = &colorAttachmentResolveRef;
3 ...
Fournissez ensuite l’attachement de couleur à la structure de création de la
render pass.
1 ...
2 std::array<VkAttachmentDescription, 3> attachments =
{colorAttachment, depthAttachment, colorAttachmentResolve};
3 ...
Modifiez ensuite createFramebuffer afin de d’ajouter une image view de
couleur à la liste :
260
1 void createFrameBuffers() {
2 ...
3 std::array<VkImageView, 3> attachments = {
4 colorImageView,
5 depthImageView,
6 swapChainImageViews[i]
7 };
8 ...
9 }
Il ne reste plus qu’à informer la pipeline du nombre de samples à utiliser pour
les opérations de rendu.
1 void createGraphicsPipeline() {
2 ...
3 multisampling.rasterizationSamples = msaaSamples;
4 ...
5 }
Lancez votre programme et vous devriez voir ceci :
Comme pour le mipmapping, la différence n’est pas forcément visible immédi-
atement. En y regardant de plus près, vous pouvez normalement voir que, par
exemple, les bords sont beaucoup plus lisses qu’avant.
261
La différence est encore plus visible en zoomant sur un bord :
Amélioration de la qualité
Notre implémentation du MSAA est limitée, et ces limitations impactent la
qualité. Il existe un autre problème d’aliasing dû aux shaders qui n’est pas
résolu par le MSAA. En effet cette technique ne permet que de lisser les bords de
la géométrie, mais pas les lignes contenus dans les textures. Ces bords internes
sont particulièrement visibles dans le cas de couleurs qui contrastent beaucoup.
Pour résoudre ce problème nous pouvons activer le sample shading, qui améliore
262
encore la qualité de l’image au prix de performances encore réduites.
1 void createLogicalDevice() {
2 ...
3 deviceFeatures.sampleRateShading = VK_TRUE; // Activation du
sample shading pour le device
4 ...
5 }
6
7 void createGraphicsPipeline() {
8 ...
9 multisampling.sampleShadingEnable = VK_TRUE; // Activation du
sample shading dans la pipeline
10 multisampling.minSampleShading = .2f; // Fraction minimale pour
le sample shading; plus proche de 1 lisse d'autant plus
11 ...
12 }
Dans notre tutoriel nous désactiverons le sample shading, mais dans certain cas
son activation permet une nette amélioration de la qualité du rendu :
Conclusion
Il nous a fallu beaucoup de travail pour en arriver là, mais vous avez main-
tenant une bonne connaissances des bases de Vulkan. Ces connaissances vous
permettent maintenant d’explorer d’autres fonctionnalités, comme :
• Push constants
• Instanced rendering
• Uniforms dynamiques
263
• Descripteurs d’images et de samplers séparés
• Pipeline caching
• Génération des command buffers depuis plusieurs threads
• Multiples subpasses
• Compute shaders
Le programme actuel peut être grandement étendu, par exemple en ajoutant
l’éclairage Blinn-Phong, des effets en post-processing et du shadow mapping.
Vous devriez pouvoir apprendre ces techniques depuis des tutoriels conçus pour
d’autres APIs, car la plupart des concepts sont applicables à Vulkan.
Code C++ / Vertex shader / Fragment shader
264
FAQ
Cette page liste quelques problèmes que vous pourriez rencontrer lors du
développement d’une application Vulkan.
• J’obtiens un erreur de violation d’accès dans les validations lay-
ers : assurez-vous que MSI Afterburner / RivaTuner Statistics Server
ne tournent pas, car ils possèdent des problèmes de compatibilité avec
Vulkan.
• Je ne vois aucun message provenant des validation layers / les
validation layers ne sont pas disponibles : assurez-vous d’abord que
les validation layers peuvent écrire leurs message en laissant le terminal
ouvert après l’exécution. Avec Visual Studio, lancez le programme avec
Ctrl-F5. Sous Linux, lancez le programme depuis un terminal. S’il n’y
a toujours pas de message, revoyez l’installation du SDK en suivant les
instructions de cette page (section “Verify the Installation”). Assurez-vous
également que le SDK est au moins de la version 1.1.106.0 pour le support
de VK_LAYER_KHRONOS_validation.
• vkCreateSwapchainKHR induit une erreur dans SteamOver-
layVulkanLayer64.dll : Il semble qu’il y ait un problème de compat-
ibilité avec la version beta du client Steam. Il y a quelques moyens de
régler le conflit :
– Désinstaller Steam
– Mettre la variable d’environnement DISABLE_VK_LAYER_VALVE_steam_overlay_1
à1
– Supprimer la layer de Steam dans le répertoire sous HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulka
Exemple pour la variable :
265
266