VII. Application des textures▲
VII-A. Images▲
VII-A-1. Introduction▲
Jusqu’à présent, les couleurs de la géométrie sont déterminées grâce aux données envoyées pour chaque sommet. Le résultat est plutôt limité. Dans ce chapitre, nous allons appliquer une texture afin de rendre la géométrie plus intéressante. Nous aurons alors le nécessaire pour charger et dessiner des modèles 3D.
L’ajout d’une texture comprend les étapes suivantes :
- créer un objet image/texture stocké sur la mémoire de la carte graphique ;
- remplir l’objet avec les pixels provenant d’un fichier image ;
- créer un échantillonneur d’image (sampler) ;
- ajouter un descripteur associé à l’échantillonneur afin d’accéder aux pixels de la texture depuis le shader.
Nous avons déjà travaillé avec des images, mais ces dernières étaient créées par l’extension de la « swap chain ». La création d’une image et l’envoi des données dans cette image ressemblent à ce que nous avons déjà fait pour le tampon de sommets. Nous allons donc commencer par créer une ressource intermédiaire à laquelle nous allons envoyer les données des pixels, puis copier cette ressource dans l’objet image final utilisé pour le rendu. Bien qu’il soit possible de créer une image intermédiaire pour cela, Vulkan permet de copier les pixels depuis un objet VkBuffer vers l’image. De plus, cette méthode est plus rapide sur certaines plateformes. Nous allons donc d’abord créer un tampon et le remplir des valeurs des pixels, puis nous allons créer une image pour y copier les pixels. La création d’une image n’est pas très différente de la création d’un tampon. Cela nécessite de récupérer les exigences de la mémoire, d’allouer la mémoire du périphérique et de lier la ressource. Nous avons déjà vu tout cela.
Toutefois, le fonctionnement d’une image induit une différence. Les images peuvent avoir des agencements différents impactant l’organisation des pixels en mémoire. Le fonctionnement des cartes graphiques fait que le simple stockage des pixels ligne par ligne ne permet pas toujours d’obtenir les meilleures performances. Nous devons nous assurer que l’agencement est optimal pour les opérations que nous souhaitons effectuer. Nous avons déjà rencontré certains de ces agencements lorsque nous avons configuré la passe de rendu :
- VK_IMAGE_LAYOUT_PRESENT_SCR_KHR : optimal pour la présentation ;
- VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL : optimal pour une attache pour l’écriture des couleurs par le fragment shader ;
- VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : optimal pour être la source d’un transfert comme avec la fonction vkCmdCopyImageToBuffer() ;
- VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL : optimal pour être la cible d’un transfert comme avec la fonction vkCmdCopyBufferToImage() ;
- VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL : optimal pour être échantillonné depuis un shader.
La méthode la plus utilisée pour réaliser un changement d’agencement d’une image est d’utiliser une barrière de pipeline (pipeline barrier). Les barrières de pipeline sont principalement utilisées pour synchroniser l’accès à une ressource : par exemple, pour s’assurer qu’une image a été écrite avant de la lire. Les barrières peuvent aussi être utilisées pour effectuer une transition d’agencement. Dans ce chapitre, nous verrons comment utiliser une barrière pour cela. Les barrières peuvent également être employées pour changer le propriétaire d’une famille de queues lorsque vous avez utilisé l’option VK_SHARING_MODE_EXCLUSIVE.
VII-A-2. Bibliothèque de chargement d’image▲
De nombreuses bibliothèques permettent le chargement d’une image. Vous pouvez même écrire le code pour lire des formats simples comme le BMP ou PPM. Dans ce tutoriel, nous utilisons la bibliothèque stb_image provenant de la collection stb. Elle possède l’avantage que tout son code est contenu dans un seul fichier. Téléchargez le fichier stb_image.h et placez-le dans un emplacement adéquat, par exemple dans le dossier où sont stockées les bibliothèques GLFW et GLM.
VII-A-2-a. Visual Studio▲
Ajoutez le dossier comprenant stb_image.h dans « Autres répertoires Include » (Additional Include Directories).
VII-A-2-b. Makefile▲
Ajoutez le dossier comprenant stb_image.h aux chemins d’inclusion de GCC :
VULKAN_SDK_PATH = /home/user/VulkanSDK/x.x.x.x/x86_64
STB_INCLUDE_PATH = /home/user/libraries/stb
...
CFLAGS = -std=c++17 -I$(VULKAN_SDK_PATH)/include -I$(STB_INCLUDE_PATH)
VII-A-3. Chargement d’une image▲
Incluez la bibliothèque de cette manière :
#define STB_IMAGE_IMPLEMENTATION
#include
<stb_image.h>
De base, le fichier d’en-tête ne contient que les prototypes des fonctions. Pour aussi avoir le code des fonctions, vous devez ajouter la macro STB_IMAGE_IMPLEMENTATION. Sans cela, vous obtiendrez des erreurs lors de l’édition des liens.
void
initVulkan() {
...
createCommandPool();
createTextureImage();
createVertexBuffer();
...
}
...
void
createTextureImage() {
}
Créez une nouvelle fonction nommée createTextureImage, dans laquelle nous chargerons une image et l’enverrons dans un objet image de Vulkan. Nous allons utiliser des tampons de commandes, donc la fonction doit être appelée après la fonction createCommandPool().
Créez un nouveau dossier nommé textures au même endroit que le dossier shaders pour y placer les textures. Nous chargerons une image appelée texture.jpg qui sera placée dans le nouveau dossier. J’ai choisi d’utiliser cette image sous licence CC0 redimensionnée à une taille de 512 x 512. Vous pouvez utiliser l’image que vous voulez. La bibliothèque supporte des formats tels que JPEG, PNG, BMP ou GIF.
Il est très facile de charger une image avec la bibliothèque stb_image :
void
createTextureImage() {
int
texWidth, texHeight, texChannels;
stbi_uc*
pixels =
stbi_load("textures/texture.jpg"
, &
texWidth, &
texHeight, &
texChannels, STBI_rgb_alpha);
VkDeviceSize imageSize =
texWidth *
texHeight *
4
;
if
(!
pixels) {
throw
std::
runtime_error("Échec lors du chargement de la texture !"
);
}
}
La fonction stbi_load() prend en argument le chemin de l’image et le nombre de canaux à charger. L’argument STBI_rgb_alpha force la présence d’un canal alpha, même si l’image d’origine n’en a pas. Cela simplifie le travail en homogénéisant les situations. Les trois paramètres au milieu permettent de récupérer la largeur, la hauteur et le nombre de canaux réellement présents dans l’image. Le pointeur retourné pointe sur un tableau des pixels de l’image. Les pixels sont agencés ligne par ligne avec quatre octets par pixel (grâce à STBI_rgb_alpha). Il y a donc texWidth * texHeight * 4 pixels.
VII-A-4. Tampon intermédiaire▲
Nous allons maintenant créer un tampon accessible par le CPU afin d’utiliser la fonction vkMapMemory() et y copier les pixels. Ajoutez les variables pour le tampon intermédiaire dans la fonction createTextureImage() :
VkBuffer stagingBuffer;
VkDeviceMemory stagingBufferMemory;
Le tampon doit être accessible par le CPU et il doit être utilisable comme source de transfert afin de copier les données vers l’image :
createBuffer(imageSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer, stagingBufferMemory);
Nous pouvons directement copier les pixels provenant de l’image dans le tampon :
void
*
data;
vkMapMemory(device, stagingBufferMemory, 0
, imageSize, 0
, &
data);
memcpy(data, pixels, static_cast
<
size_t>
(imageSize));
vkUnmapMemory(device, stagingBufferMemory);
N’oubliez pas de libérer la mémoire du tableau de pixels :
stbi_image_free(pixels);
VII-A-5. Texture d’image▲
Bien qu’il soit possible de paramétrer le shader afin qu’il utilise le tampon comme source pour les pixels, il est préférable d’utiliser l’objet Vulkan dédié à cette utilisation. Les images accélèrent et facilitent la récupération des pixels en utilisant un système de coordonnées 2D. Les pixels d’une image se nomment texels et nous utiliserons ce terme à partir de maintenant. Ajoutez les variables membres suivantes :
VkImage textureImage;
VkDeviceMemory textureImageMemory;
Les paramètres pour la création d’une image sont spécifiés dans une structure de type VkImageCreateInfo :
VkImageCreateInfo imageInfo{}
;
imageInfo.sType =
VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageInfo.imageType =
VK_IMAGE_TYPE_2D;
imageInfo.extent.width =
static_cast
<
uint32_t>
(texWidth);
imageInfo.extent.height =
static_cast
<
uint32_t>
(texHeight);
imageInfo.extent.depth =
1
;
imageInfo.mipLevels =
1
;
imageInfo.arrayLayers =
1
;
Le type d’image défini par le champ imageType indique à Vulkan quel système de coordonnées utiliser pour accéder aux texels. Il est possible de créer des images 1D, 2D et 3D. Les images 1D peuvent être utilisées comme tableaux pour stocker des données ou pour les dégradés. Les images 2D sont majoritairement utilisées comme textures et les images 3D peuvent être utilisées pour stocker des volumes en voxel. Le champ extent indique la taille de l’image, c’est-à-dire combien il y a de texels sur chaque axe. C’est pourquoi nous indiquons 1 et non 0 pour le champ depth. Notre texture n’est pas un tableau et nous n’utilisons pas le mipmapping pour le moment.
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 tampon. Sans quoi, l’opération de copie échouera.
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 comme pour notre tableau de pixels ;
- VK_IMAGE_TILING_OPTIMAL : l’organisation des texels est déterminée par l’implémentation afin d’optimiser les accès ;
Contrairement à l’agencement de l’image, le mode défini par le champ tiling ne peut pas être changé par la suite. Si vous voulez directement accéder aux texels à partir de la mémoire de l’image, vous devez utiliser VK_IMAGE_TILING_LINEAR. Comme nous utilisons un tampon intermédiaire et non une image intermédiaire, nous pouvons utiliser le mode le plus efficace.
imageInfo.initialLayout =
VK_IMAGE_LAYOUT_UNDEFINED;
Le champ initialLayout peut prendre deux valeurs :
- VK_IMAGE_LAYOUT_UNDEFINED : inutilisable par le GPU, son contenu sera éliminé à la première transition ;
- VK_IMAGE_LAYOUT_PREINITIALIZED : inutilisable par le GPU, mais la première transition conservera les texels.
Il n’existe que quelques rares situations où il est nécessaire de conserver les texels pendant la première transition. L’une d’elles consiste à utiliser l’image comme ressource intermédiaire en combinaison avec l’agencement VK_IMAGE_TILING_LINEAR. Dans ce cas, vous voudriez envoyer les données des texels dans l’image intermédiaire puis effectuer une transition de l’image pour qu’elle devienne source d’un transfert, et ce, sans perdre les données contenues. Dans notre cas, nous allons faire une transition pour définir l’image comme destination d’un transfert, puis y copier les texels provenant de l’objet tampon. Par conséquent, nous n’avons pas besoin de cette propriété et nous pouvons utiliser VK_IMAGE_LAYOUT_UNDEFINED sans crainte.
imageInfo.usage =
VK_IMAGE_USAGE_TRANSFER_DST_BIT |
VK_IMAGE_USAGE_SAMPLED_BIT;
Le champ usage est identique à celui utilisé pour la création d’un tampon. L’image devra être utilisée comme destination d’une opération de copie. Nous souhaitons aussi pouvoir accéder à l’image dans le shader afin de colorier notre modèle, donc nous devons utiliser VK_IMAGE_USAGE_SAMPLED_BIT.
imageInfo.sharingMode =
VK_SHARING_MODE_EXCLUSIVE;
L’image sera uniquement utilisée dans une famille de queues : la famille pour les opérations graphiques et implicitement pour les opérations de transfert.
imageInfo.samples =
VK_SAMPLE_COUNT_1_BIT;
imageInfo.flags =
0
; // Optionnel
La propriété sample est liée au multiéchantillonnage. Il n’a d’utilité que pour les images qui seront utilisées comme attache. Nous pouvons donc laisser un échantillon. Il y a aussi des indicateurs optionnels pour les images liées aux images distinctes. Ces images ont la particularité d’être partiellement contenues en mémoire. Si vous utilisez une image 3D pour représenter un terrain en voxels, vous pouvez éviter d’allouer de la mémoire pour de grandes zones ne contenant que de l’air. Nous n’utilisons pas cette fonctionnalité dans ce tutoriel, laissez donc la valeur par défaut (0).
if
(vkCreateImage(device, &
imageInfo, nullptr
, &
textureImage) !=
VK_SUCCESS) {
throw
std::
runtime_error("Échec lors de la création de l’image !"
);
}
L’image est créée par la fonction vkCreateImage(), qui ne possède pas de paramètres particuliers. Il est possible que le format VK_FORMAT_R8G8B8A8_SRGB ne soit pas supporté par la carte graphique. Vous devez trouver une liste d’autres possibilités et choisir la meilleure supportée. Toutefois, comme le format est communément supporté, nous allons passer cette étape. L’utilisation d’autres formats demande de pénibles conversions. Nous reviendrons sur ce point dans le chapitre sur le tampon de profondeur, dans lequel nous allons implémenter un tel mécanisme.
VkMemoryRequirements memRequirements;
vkGetImageMemoryRequirements(device, textureImage, &
memRequirements);
VkMemoryAllocateInfo allocInfo{}
;
allocInfo.sType =
VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize =
memRequirements.size;
allocInfo.memoryTypeIndex =
findMemoryType(memRequirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
if
(vkAllocateMemory(device, &
allocInfo, nullptr
, &
textureImageMemory) !=
VK_SUCCESS) {
throw
std::
runtime_error("Échec lors de l’allocation de la mémoire pour l’image !"
);
}
vkBindImageMemory(device, textureImage, textureImageMemory, 0
);
L’allocation de la mémoire pour l’image fonctionne exactement comme une allocation pour un tampon. Il suffit d’utiliser la fonction vkGetImageMemoryRequirements() à la place de vkGetBufferMemoryRequirements() et vkBindImageMemory() à la place de vkBindBufferMemory().
Cette fonction est déjà longue et nous allons devoir créer plus d’images dans les prochains chapitres. Nous devons donc abstraire la création d’une image dans une fonction dédiée nommée createImage. Créez la fonction et déplacez le code lié à la création de l’objet image et de l’allocation mémoire :
void
createImage(uint32_t width, uint32_t height, VkFormat format, VkImageTiling tiling, VkImageUsageFlags usage, VkMemoryPropertyFlags properties, VkImage&
image, VkDeviceMemory&
imageMemory) {
VkImageCreateInfo imageInfo{}
;
imageInfo.sType =
VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageInfo.imageType =
VK_IMAGE_TYPE_2D;
imageInfo.extent.width =
width;
imageInfo.extent.height =
height;
imageInfo.extent.depth =
1
;
imageInfo.mipLevels =
1
;
imageInfo.arrayLayers =
1
;
imageInfo.format =
format;
imageInfo.tiling =
tiling;
imageInfo.initialLayout =
VK_IMAGE_LAYOUT_UNDEFINED;
imageInfo.usage =
usage;
imageInfo.samples =
VK_SAMPLE_COUNT_1_BIT;
imageInfo.sharingMode =
VK_SHARING_MODE_EXCLUSIVE;
if
(vkCreateImage(device, &
imageInfo, nullptr
, &
image) !=
VK_SUCCESS) {
throw
std::
runtime_error("Échec de création de l’image !"
);
}
VkMemoryRequirements memRequirements;
vkGetImageMemoryRequirements(device, image, &
memRequirements);
VkMemoryAllocateInfo allocInfo{}
;
allocInfo.sType =
VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize =
memRequirements.size;
allocInfo.memoryTypeIndex =
findMemoryType(memRequirements.memoryTypeBits, properties);
if
(vkAllocateMemory(device, &
allocInfo, nullptr
, &
imageMemory) !=
VK_SUCCESS) {
throw
std::
runtime_error("Échec lors de l’allocation de la mémoire pour l’image !"
);
}
vkBindImageMemory(device, image, imageMemory, 0
);
}
La largeur, la hauteur, le tiling, l’utilisation et les propriétés de la mémoire deviennent des paramètres, car ils varient suivant les images que nous allons créer dans ce tutoriel.
La fonction createTextureImage() peut maintenant être simplifiée :
void
createTextureImage() {
int
texWidth, texHeight, texChannels;
stbi_uc*
pixels =
stbi_load("textures/texture.jpg"
, &
texWidth, &
texHeight, &
texChannels, STBI_rgb_alpha);
VkDeviceSize imageSize =
texWidth *
texHeight *
4
;
if
(!
pixels) {
throw
std::
runtime_error("Échec du chargement de la texture !"
);
}
VkBuffer stagingBuffer;
VkDeviceMemory stagingBufferMemory;
createBuffer(imageSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer, stagingBufferMemory);
void
*
data;
vkMapMemory(device, stagingBufferMemory, 0
, imageSize, 0
, &
data);
memcpy(data, pixels, static_cast
<
size_t>
(imageSize));
vkUnmapMemory(device, stagingBufferMemory);
stbi_image_free(pixels);
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);
}
VII-A-6. Transitions de l’agencement▲
La fonction que nous allons écrire inclut l’enregistrement et l’exécution du tampon de commandes. C’est donc l’occasion de déplacer cette logique dans d’autres fonctions :
VkCommandBuffer beginSingleTimeCommands() {
VkCommandBufferAllocateInfo allocInfo{}
;
allocInfo.sType =
VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.level =
VK_COMMAND_BUFFER_LEVEL_PRIMARY;
allocInfo.commandPool =
commandPool;
allocInfo.commandBufferCount =
1
;
VkCommandBuffer commandBuffer;
vkAllocateCommandBuffers(device, &
allocInfo, &
commandBuffer);
VkCommandBufferBeginInfo beginInfo{}
;
beginInfo.sType =
VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags =
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
vkBeginCommandBuffer(commandBuffer, &
beginInfo);
return
commandBuffer;
}
void
endSingleTimeCommands(VkCommandBuffer commandBuffer) {
vkEndCommandBuffer(commandBuffer);
VkSubmitInfo submitInfo{}
;
submitInfo.sType =
VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount =
1
;
submitInfo.pCommandBuffers =
&
commandBuffer;
vkQueueSubmit(graphicsQueue, 1
, &
submitInfo, VK_NULL_HANDLE);
vkQueueWaitIdle(graphicsQueue);
vkFreeCommandBuffers(device, commandPool, 1
, &
commandBuffer);
}
Le code de ces fonctions est basé sur celui de la fonction copyBuffer(). Vous pouvez maintenant simplifier la fonction copyBuffer() :
void
copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {
VkCommandBuffer commandBuffer =
beginSingleTimeCommands();
VkBufferCopy copyRegion{}
;
copyRegion.size =
size;
vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer, 1
, &
copyRegion);
endSingleTimeCommands(commandBuffer);
}
Si nous utilisions toujours des tampons, nous aurions pu écrire une fonction pour enregistrer et exécuter la fonction vkCmdCopyBufferToImage(). Toutefois, la fonction vkCmdCopyBufferToImage() nécessite d’avoir une image agencée correctement. Créez une nouvelle fonction pour gérer les transitions :
void
transitionImageLayout(VkImage image, VkFormat format, VkImageLayout oldLayout, VkImageLayout newLayout) {
VkCommandBuffer commandBuffer =
beginSingleTimeCommands();
endSingleTimeCommands(commandBuffer);
}
L’une des manières de réaliser une transition consiste à utiliser une barrière mémoire d’image. Une telle barrière de pipeline est en général utilisée pour synchroniser l’accès aux ressources, notamment pour s’assurer que l’écriture d’un tampon se termine avant que nous puissions le lire. Mais il est aussi possible de l’utiliser pour les transitions d’agencement d’images ou pour changer le propriétaire d’une famille de queues lorsque VK_SHARING_MODE_EXCLUSIVE a été utilisé. Il existe un équivalent pour les tampons : une barrière mémoire de tampon.
VkImageMemoryBarrier barrier{}
;
barrier.sType =
VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
barrier.oldLayout =
oldLayout;
barrier.newLayout =
newLayout;
Les deux premières propriétés indiquent la transition à réaliser. Il est possible d’utiliser VK_IMAGE_LAYOUT_UNDEFINED pour le champ oldLayout si le contenu de l’image ne vous intéresse pas.
barrier.srcQueueFamilyIndex =
VK_QUEUE_FAMILY_IGNORED;
barrier.dstQueueFamilyIndex =
VK_QUEUE_FAMILY_IGNORED;
Ces deux paramètres permettent d’indiquer l’indice des familles de queues lors du transfert de propriété d’une famille. Si vous ne faites pas un tel transfert, il faut utiliser la valeur VK_QUEUE_FAMILY_IGNORED.
barrier.image =
image;
barrier.subresourceRange.aspectMask =
VK_IMAGE_ASPECT_COLOR_BIT;
barrier.subresourceRange.baseMipLevel =
0
;
barrier.subresourceRange.levelCount =
1
;
barrier.subresourceRange.baseArrayLayer =
0
;
barrier.subresourceRange.layerCount =
1
;
Les paramètres image et subresourceRange servent à indiquer l’image et quelle partie de l’image doit être affectée par le changement. Notre image n’est pas un tableau et n’a pas de niveau de mipmapping, donc il n’y a qu’un niveau et qu’une couche.
barrier.srcAccessMask =
0
; // TODO
barrier.dstAccessMask =
0
; // TODO
Comme les barrières sont avant tout des objets de synchronisation, nous devons indiquer quels types d’opérations en relation avec la ressource doivent être réalisés avant la barrière et quelles opérations doivent attendre la barrière. Nous devons faire cela même si nous utilisons la fonction vkQueueWaitIdle(). Les bonnes valeurs dépendent de l’ancien et du nouvel agencement, donc nous reviendrons sur ces propriétés une fois que nous aurons déterminé les transitions à utiliser.
vkCmdPipelineBarrier(
commandBuffer,
0
/* TODO */
, 0
/* TODO */
,
0
,
0
, nullptr
,
0
, nullptr
,
1
, &
barrier
);
Tous les types de barrières de pipeline sont envoyés par la même fonction. Le premier paramètre après le tampon de commandes indique dans quelle étape du rendu sont les opérations devant survenir avant la barrière. Le deuxième paramètre indique dans quelle étape sont les opérations qui vont attendre la barrière. Les étapes du pipeline que vous pouvez spécifier avant et après la barrière dépendent de comment vous utilisez la ressource avant et après la barrière. Les valeurs permises sont listées dans ce tableau provenant de la spécification. Par exemple, si vous allez lire une variable uniforme après la barrière, vous devez indiquer VK_ACCESS_UNIFORM_READ_BIT comme usage ainsi que le premier shader à utiliser la variable uniforme. Cela peut être le fragment shader. Dans un tel cas, l’étape à spécifier est VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT. Dans ce cas de figure, spécifier une autre étape qu’une étape programmable n’aurait aucun sens, et les couches de validation vous avertissent si vous spécifiez une étape du pipeline qui ne correspond pas à l’usage indiqué.
Le troisième paramètre peut être soit 0 soit VK_DEPENDENCY_BY_REGION_BIT. Ce dernier transforme la barrière en une condition par région. Cela signifie que l’implémentation peut déjà commencer la lecture pour les parties de la ressource qui ont déjà été écrites.
Les trois dernières paires de paramètres sont des tableaux de barrières de pipeline pour chacun des trois types existants : barrière mémoire, barrière de tampon et barrière d’image (celle que nous utilisons ici). Notez que nous n’avons pas utilisé le paramètre VkFormat pour le moment, mais nous allons l’utiliser pour des transitions spécifiques dans le chapitre du tampon de profondeur.
VII-A-7. Copie d’un tampon dans une image▲
Avant de revenir à la fonction createTextureImage(), nous allons écrire une nouvelle fonction nommée copyBufferToImage :
void
copyBufferToImage(VkBuffer buffer, VkImage image, uint32_t width, uint32_t height) {
VkCommandBuffer commandBuffer =
beginSingleTimeCommands();
endSingleTimeCommands(commandBuffer);
}
Comme pour les copies de tampons, nous devons spécifier quelles parties du tampon seront copiées dans quelles parties de l’image. Cela se spécifie grâce à une structure de type VkBufferImageCopy :
VkBufferImageCopy region{}
;
region.bufferOffset =
0
;
region.bufferRowLength =
0
;
region.bufferImageHeight =
0
;
region.imageSubresource.aspectMask =
VK_IMAGE_ASPECT_COLOR_BIT;
region.imageSubresource.mipLevel =
0
;
region.imageSubresource.baseArrayLayer =
0
;
region.imageSubresource.layerCount =
1
;
region.imageOffset =
{
0
, 0
, 0
}
;
region.imageExtent =
{
width,
height,
1
}
;
La plupart de ces champs sont évidents. La propriété bufferOffset indique l’octet à partir duquel les données des pixels commencent dans le tampon. Les propriétés bufferRowLenght et bufferImageHeight indiquent l’agencement des pixels en mémoire. Par exemple, vous pourriez avoir des octets de remplissage entre les lignes d’une image. En spécifiant 0 pour les deux champs, nous indiquons que les pixels se suivent. Les propriétés imageSubResource, imageOffset et imageExtent indiquent quelle partie de l’image recevra les données.
Les opérations de copie d’un tampon vers une image sont envoyées à la queue avec la fonction vkCmdCopyBufferToImage().
vkCmdCopyBufferToImage(
commandBuffer,
buffer,
image,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
1
,
&
region
);
Le quatrième paramètre indique l’organisation de l’image au moment de la copie. Je suppose que l’image a déjà été réorganisée pour avoir un agencement optimal pour effectuer une copie des pixels. Pour le moment, nous copions tous les pixels de l’image en une opération, mais par la suite, il est possible de spécifier un tableau de VkBufferImageCopy pour effectuer plusieurs copies différentes du tampon vers l’image.
VII-A-8. Préparer la texture▲
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 avons ajoutée dans cette fonction est la création de la texture. La prochaine étape est de copier le tampon intermédiaire vers la texture. Cela s’effectue en deux étapes :
- passer la texture à l’agencement VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL ;
- exécuter la copie du tampon vers l’image.
Ces opérations sont simples grâce aux fonctions que nous venons de créer :
transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
copyBufferToImage(stagingBuffer, textureImage, static_cast
<
uint32_t>
(texWidth), static_cast
<
uint32_t>
(texHeight));
Nous avons créé l’image avec un agencement VK_LAYOUT_UNDEFINED. Nous devons donc spécifier cette valeur comme ancien agencement de textureImage. Souvenez-vous que vous devez faire cela, car nous ne sommes pas intéressés par le contenu avant la copie.
Pour pouvoir échantillonner la texture depuis le fragment shader, nous devons réaliser une dernière transition afin de la préparer pour que le shader y accède :
transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
VII-A-9. Masque de barrière de transition▲
Si vous lanciez le programme avec les couches de validation, vous verriez des messages à propos de masque d’accès et des étapes du pipeline invalides dans la fonction transitionImageLayout(). Nous devons les définir selon les agencements dans la transition.
Nous devons gérer deux transitions :
- indéfini → destination d’un transfert : les écritures du transfert n’ont pas besoin d’attendre quoi que ce soit ;
- destination d’un transfert → lecture par un shader : la lecture par le shader doit attendre la fin du transfert. Plus précisément, ce sont les lectures effectuées depuis le fragment shader qui doivent attendre pour la fin du transfert, car c’est dans celui-ci que nous utilisons la texture.
Ces règles sont indiquées en utilisant les valeurs suivantes pour le masque d’accès et les étapes du pipeline :
VkPipelineStageFlags sourceStage;
VkPipelineStageFlags destinationStage;
if
(oldLayout ==
VK_IMAGE_LAYOUT_UNDEFINED &&
newLayout ==
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) {
barrier.srcAccessMask =
0
;
barrier.dstAccessMask =
VK_ACCESS_TRANSFER_WRITE_BIT;
sourceStage =
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
destinationStage =
VK_PIPELINE_STAGE_TRANSFER_BIT;
}
else
if
(oldLayout ==
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL &&
newLayout ==
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) {
barrier.srcAccessMask =
VK_ACCESS_TRANSFER_WRITE_BIT;
barrier.dstAccessMask =
VK_ACCESS_SHADER_READ_BIT;
sourceStage =
VK_PIPELINE_STAGE_TRANSFER_BIT;
destinationStage =
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
}
else
{
throw
std::
invalid_argument("Transition de l’agencement non supportée !"
);
}
vkCmdPipelineBarrier(
commandBuffer,
sourceStage, destinationStage,
0
,
0
, nullptr
,
0
, nullptr
,
1
, &
barrier
);
Comme vous avez pu le voir dans le tableau mentionné plus haut, les écritures dans l’image doivent se réaliser à l’étape de transfert du pipeline. Comme l’écriture ne dépend d’aucune autre opération, nous pouvons spécifier un masque d’accès vide et la première étape du pipeline : VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT comme opérations avant la barrière. La valeur VK_PIPELINE_STAGE_TRANSFERT_BIT ne référence pas une étape existante dans les pipelines graphiques ou de calcul. C’est plutôt une pseudo-étape où les transferts sont effectués. Lisez la documentation pour de plus amples informations et d’autres exemples de pseudo-étapes.
L’image sera écrite dans la même étape de pipeline puis lue par le fragment shader. C’est pourquoi nous spécifions un accès en lecture pour le shader dans l’étape du pipeline liée au fragment shader.
Nous étendrons cette fonction dans le futur pour prendre en compte plus de transitions. L’application doit maintenant fonctionner sans problème, bien qu’il n’y ait aucune différence visible.
Il est à noter que la soumission du tampon de commandes génère une synchronisation implicite de type VK_ACCESS_HOST_WRITE_BIT. Comme la fonction transitionImageLayout() exécute un tampon de commandes avec une seule commande, vous pouvez utiliser cette synchronisation implicite et définir srcAccessMask à 0 si vous avez besoin d’une dépendance à VK_ACCESS_HOST_WRITE_BIT dans la transition d’agencement. C’est à vous de voir si vous voulez être explicite ou non. Personnellement, je n’aime pas reposer sur ce type d’opérations « cachées » rappelant OpenGL.
Il existe un type d’agencement d’image supportant toutes les opérations : VK_IMAGE_LAYOUT_GENERAL. Le problème est qu’il est évidemment moins optimisé. Il est cependant utile dans certains cas, notamment lorsqu’une image est à la fois une entrée et une sortie ou pour lire une image après qu’elle a quitté l’agencement avec initialisation.
Toutes les fonctions envoyant des commandes ont été configurées pour s’exécuter de manière synchrone et attendre que la queue n’ait plus de travail. Dans les applications réelles, il est recommandé d’assembler ces opérations dans un unique tampon de commandes et de les exécuter de manière asynchrone afin d’obtenir un meilleur débit, notamment pour les transitions et la copie effectuées dans la fonction createTextureImage(). Essayez de le faire en créant une fonction nommée setupCommandBuffer qui sera utilisée pour enregistrer les commandes des fonctions utilitaires et ajoutez une fonction flushSetupCommands() pour exécuter les commandes enregistrées jusqu’alors. Une telle transformation du programme doit être faite après avoir finalisé l’implémentation de l’application des textures afin de vérifier que tout fonctionne.
VII-A-10. Nettoyage▲
Complétez la fonction createImageTexture() en libérant le tampon intermédiaire et la mémoire allouée pour celui-ci ;
transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
vkDestroyBuffer(device, stagingBuffer, nullptr
);
vkFreeMemory(device, stagingBufferMemory, nullptr
);
}
La texture est utilisée jusqu’à la fin du programme, nous devons donc la libérer dans la fonction cleanup() :
void
cleanup() {
cleanupSwapChain();
vkDestroyImage(device, textureImage, nullptr
);
vkFreeMemory(device, textureImageMemory, nullptr
);
...
}
L’image contient maintenant la texture, mais nous n’avons toujours pas mis en place une méthode pour y accéder depuis le pipeline graphique. C’est l’objectif du prochain chapitre.
VII-B. Vue d’image et échantillonneur▲
Dans ce chapitre, nous allons créer deux nouvelles ressources nécessaires pour pouvoir échantillonner une image depuis le pipeline graphique. Nous avons déjà travaillé avec la première ressource, notamment en conjonction avec les images de la « swap chain ». La seconde ressource est nouvelle. Elle est liée à la manière selon laquelle le shader accédera aux texels de l’image.
VII-B-1. Vue sur une texture▲
Comme nous l’avons vu précédemment avec les images de la « swap chain » et le tampon d’images, les images ne peuvent être accédées qu’au travers de vues. Nous aurons donc besoin de créer une vue pour la texture.
Ajoutez une variable membre pour stocker la référence à l’objet de type VkImageView. Ajoutez aussi une fonction nommée createTextureImageView() dans laquelle nous créerons la vue :
VkImageView textureImageView;
...
void
initVulkan() {
...
createTextureImage();
createTextureImageView();
createVertexBuffer();
...
}
...
void
createTextureImageView() {
}
Le code de cette fonction peut être basé sur la fonction createImageViews(). Les deux seuls changements sont dans les propriétés format et image :
VkImageViewCreateInfo viewInfo{}
;
viewInfo.sType =
VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
viewInfo.image =
textureImage;
viewInfo.viewType =
VK_IMAGE_VIEW_TYPE_2D;
viewInfo.format =
VK_FORMAT_R8G8B8A8_SRGB;
viewInfo.subresourceRange.aspectMask =
VK_IMAGE_ASPECT_COLOR_BIT;
viewInfo.subresourceRange.baseMipLevel =
0
;
viewInfo.subresourceRange.levelCount =
1
;
viewInfo.subresourceRange.baseArrayLayer =
0
;
viewInfo.subresourceRange.layerCount =
1
;
Je n’ai pas initialisé explicitement la propriété viewInfo.components, la valeur de VK_COMPONENT_SWIZZLE_IDENTITY est 0. Finissez la création de la vue en appelant la fonction vkCreateImageView() :
if
(vkCreateImageView(device, &
viewInfo, nullptr
, &
textureImageView) !=
VK_SUCCESS) {
throw
std::
runtime_error("Échec de création de la vue pour la texture !"
);
}
Comme la logique est similaire à celle de la fonction createImageViews(), vous pourriez vouloir abstraire la création d’une vue dans une nouvelle fonction createImageView() :
VkImageView createImageView(VkImage image, VkFormat format) {
VkImageViewCreateInfo viewInfo{}
;
viewInfo.sType =
VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
viewInfo.image =
image;
viewInfo.viewType =
VK_IMAGE_VIEW_TYPE_2D;
viewInfo.format =
format;
viewInfo.subresourceRange.aspectMask =
VK_IMAGE_ASPECT_COLOR_BIT;
viewInfo.subresourceRange.baseMipLevel =
0
;
viewInfo.subresourceRange.levelCount =
1
;
viewInfo.subresourceRange.baseArrayLayer =
0
;
viewInfo.subresourceRange.layerCount =
1
;
VkImageView imageView;
if
(vkCreateImageView(device, &
viewInfo, nullptr
, &
imageView) !=
VK_SUCCESS) {
throw
std::
runtime_error("Échec de création de la vue !"
);
}
return
imageView;
}
La fonction createTextureImageView() peut maintenant être simplifiée :
void
createTextureImageView() {
textureImageView =
createImageView(textureImage, VK_FORMAT_R8G8B8A8_SRGB);
}
Ainsi que la fonction createImageView() :
void
createImageViews() {
swapChainImageViews.resize(swapChainImages.size());
for
(uint32_t i =
0
; i <
swapChainImages.size(); i++
) {
swapChainImageViews[i] =
createImageView(swapChainImages[i], swapChainImageFormat);
}
}
Assurons-nous de détruire la vue à la fin du programme, juste avant la destruction de l’image elle-même :
void
cleanup() {
cleanupSwapChain();
vkDestroyImageView(device, textureImageView, nullptr
);
vkDestroyImage(device, textureImage, nullptr
);
vkFreeMemory(device, textureImageMemory, nullptr
);
VII-B-2. Échantillonneurs▲
Les shaders peuvent lire les texels directement à partir des images. Toutefois, ce n’est pas la technique habituelle pour les textures. Les textures sont généralement accédées à travers un échantillonneur (sampler) qui filtrera et/ou transformera les données afin de calculer la couleur finale à retourner au shader.
Ces filtres sont utiles pour résoudre des problèmes tels que le suréchantillonnage. Imaginez une texture appliquée sur une géométrie possédant plus de fragments que la texture n’a de texels. Si l’échantillonneur se contentait de prendre le pixel le plus proche, une pixellisation apparaîtrait :
En combinant les quatre texels les plus proches grâce à une interpolation linéaire, il est possible d’obtenir un rendu lisse comme présenté sur l’image de droite. Bien sûr, il est possible que votre application cherche plutôt à obtenir le premier résultat (comme Minecraft), mais la seconde option est plus couramment utilisée. Un échantillonneur applique alors automatiquement ce type d’opérations pour vous lors de la lecture d’une couleur à partir de la texture.
Le sous-échantillonnage est le problème inverse : vous avez plus de texels que de fragments. Cela crée des artefacts particulièrement visibles dans le cas de textures ayant des motifs répétés (comme un damier) vues à un angle aigu :
Comme vous pouvez le voir sur l’image de gauche, la texture devient floue au loin. La solution à ce problème est le filtrage anisotrope, qui peut aussi être automatiquement appliqué par l’échantillonneur.
Au-delà de ces filtres, l’échantillonneur peut aussi s’occuper de transformations. Il détermine ce qui se passe lorsque vous essayez de lire des texels en dehors des limites de l’image. L’image ci-dessous montre quelques exemples de configurations :
Nous allons maintenant créer la fonction nommée createTextureSampler pour mettre en place un échantillonneur simple. Nous l’utiliserons pour lire les couleurs de la texture à partir du shader.
void
initVulkan() {
...
createTextureImage();
createTextureImageView();
createTextureSampler();
...
}
...
void
createTextureSampler() {
}
Les échantillonneurs se configurent par le biais d’une structure de type VkSamplerCreateInfo. Ce type de structure permet d’indiquer les filtres et les transformations à appliquer.
VkSamplerCreateInfo samplerInfo{}
;
samplerInfo.sType =
VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
samplerInfo.magFilter =
VK_FILTER_LINEAR;
samplerInfo.minFilter =
VK_FILTER_LINEAR;
Les champs magFilter et minFilter indiquent comment interpoler les texels dans les cas respectivement d’un grossissement et d’une réduction. Cela permet de gérer les cas vus plus haut. Nous avons le choix entre VK_FILTER_NEAREST et VK_FILTER_LINEAR dont le rendu correspond aux images gauche et droite du premier exemple.
samplerInfo.addressModeU =
VK_SAMPLER_ADDRESS_MODE_REPEAT;
samplerInfo.addressModeV =
VK_SAMPLER_ADDRESS_MODE_REPEAT;
samplerInfo.addressModeW =
VK_SAMPLER_ADDRESS_MODE_REPEAT;
Le mode d’adressage peut être configuré pour chaque axe grâce aux champs addressMode. Les valeurs possibles sont listées ci-dessous. Notez que les axes sont nommés U, V et W à la place de X, Y et Z. C’est la convention pour les coordonnées de texture.
- VK_SAMPLER_ADDRESS_MODE_REPEAT : répète la texture lors d’accès hors des dimensions de l’image ;
- VK_SAMPLER_ADDRESS_MODE_MIRRORED_REPEAT : similaire à la répétition, mais inverse les coordonnées pour faire une image miroir lors d’un accès hors des dimensions de l’image ;
- VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE : prend la couleur du pixel de la bordure la plus proche ;
- VK_SAMPLER_ADDRESS_MODE_MIRROR_CLAMP_TO_EDGE : similaire au mode précédent, mais en utilisant la bordure opposée à la bordure la plus proche ;
- VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER : retourne une couleur déterminée lors d’un accès hors des dimensions de l’image.
Le mode d’adressage que nous utilisons n’est pas très important, car nous ne dépasserons pas les dimensions de l’image dans ce tutoriel. Cependant, le mode de répétition est le plus commun, car il permet de paver les sols et les murs.
samplerInfo.anisotropyEnable =
VK_TRUE;
samplerInfo.maxAnisotropy =
16.0
f;
Ces deux propriétés paramètrent l’utilisation du filtrage anisotrope. 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 pouvant être utilisés pour calculer la couleur finale. Une valeur plus faible donne de meilleures performances, mais une qualité moindre. Il n’existe à ce jour aucune carte graphique pouvant utiliser plus de 16 texels, car au-delà, l’amélioration est négligeable.
samplerInfo.borderColor =
VK_BORDER_COLOR_INT_OPAQUE_BLACK;
Le paramètre borderColor indique la couleur à retourner lors d’un accès hors des dimensions de l’image pour le mode d’adressage VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER. Il est possible d’indiquer du noir, du blanc ou du transparent par le biais d’un nombre à virgule flottante ou d’un nombre entier. Vous ne pouvez pas indiquer une couleur arbitraire.
samplerInfo.unnormalizedCoordinates =
VK_FALSE;
Le champ unnomalizedCoordinates indique le système de coordonnées que vous souhaitez utiliser pour accéder aux texels dans une image. Avec la valeur VK_TRUE, vous pouvez utiliser des coordonnées comprises entre [0, texWidth] et [0, texHeight]. Sinon, les valeurs sont accessibles avec des coordonnées comprises dans l’intervalle [0, 1] pour tous les axes. La majorité des applications utilise des coordonnées normalisées. Ainsi, il est possible d’utiliser des textures avec des résolutions différentes tout en conservant les mêmes coordonnées.
samplerInfo.compareEnable =
VK_FALSE;
samplerInfo.compareOp =
VK_COMPARE_OP_ALWAYS;
Si une fonction de comparaison est activée, les texels seront d’abord comparés à une valeur. Le résultat de la comparaison est ensuite utilisé dans les opérations de filtrage. Cette fonctionnalité est principalement utilisée pour réaliser un filtrage au pourcentage le plus proche sur les textures d’ombrages. Nous verrons cela dans un futur chapitre.
samplerInfo.mipmapMode =
VK_SAMPLER_MIPMAP_MODE_LINEAR;
samplerInfo.mipLodBias =
0.0
f;
samplerInfo.minLod =
0.0
f;
samplerInfo.maxLod =
0.0
f;
Tous ces champs sont liés au mipmapping. Nous y reviendrons dans un prochain chapitreGénération de mipmaps, mais pour faire simple, c’est encore un autre type de filtrage.
L’échantillonneur est maintenant complètement configuré. Ajoutez une variable membre pour stocker la référence à cet échantillonneur, puis créez-le avec la fonction vkCreateSampler() :
VkImageView textureImageView;
VkSampler textureSampler;
...
void
createTextureSampler() {
...
if
(vkCreateSampler(device, &
samplerInfo, nullptr
, &
textureSampler) !=
VK_SUCCESS) {
throw
std::
runtime_error("Échec lors de la création de l’échantillonneur !"
);
}
}
Remarquez que l’échantillonneur ne référence aucun objet de type VkImage. Il constitue un objet distinct fournissant une interface pour extraire les couleurs d’une texture. Il peut être utilisé avec n’importe quelle image 1D, 2D ou 3D. Cela diffère des anciennes bibliothèques qui combinaient la texture et son filtrage en un seul état.
Détruisons l’échantillonneur à la fin du programme, là où nous n’accéderons plus à l’image :
void
cleanup() {
cleanupSwapChain();
vkDestroySampler(device, textureSampler, nullptr
);
vkDestroyImageView(device, textureImageView, nullptr
);
...
}
VII-B-3. Fonctionnalités de filtrage anisotrope du périphérique▲
Si vous lancez le programme, vous verrez que les couches de validation vous envoient un message similaire à celui-ci :
En effet, le filtrage anisotrope est une fonctionnalité optionnelle du périphérique. Nous devons donc mettre à jour la fonction createLogicalDevice() pour obtenir l’accès à cette fonctionnalité :
VkPhysicalDeviceFeatures deviceFeatures{}
;
deviceFeatures.samplerAnisotropy =
VK_TRUE;
Et bien qu’il soit très peu probable qu’une carte graphique moderne ne supporte pas cette fonctionnalité, nous devons aussi mettre à jour la fonction isDeviceSuitable() pour vérifier la présence de la fonctionnalité :
bool
isDeviceSuitable(VkPhysicalDevice device) {
...
VkPhysicalDeviceFeatures supportedFeatures;
vkGetPhysicalDeviceFeatures(device, &
supportedFeatures);
return
indices.isComplete() &&
extensionsSupported &&
swapChainAdequate &&
supportedFeatures.samplerAnisotropy;
}
La fonction vkGetPhysicalDeviceFeatures() réutilise la structure VkPhysicalDeviceFeatures pour indiquer quelles sont les fonctionnalités supportées plutôt que de les récupérer en utilisant des valeurs booléennes.
Au lieu d’obliger le client à posséder une carte graphique supportant le filtrage anisotrope, il est aussi possible de ne pas l’utiliser :
samplerInfo.anisotropyEnable =
VK_FALSE;
samplerInfo.maxAnisotropy =
1
;
Dans le prochain chapitre, nous exposerons l’image et l’échantillonneur au fragment shader pour dessiner la texture sur le carré.
VII-C. Association d’échantillonneur et d’image▲
VII-C-1. Introduction▲
Nous avons vu, pour la première fois, les descripteurs dans le tutoriel sur les tampons de variables uniformes. Dans ce chapitre, nous verrons un nouveau type de descripteurs : l’échantillonneur combiné d’image (combined image sampler). Ce descripteur permet aux shaders d’accéder à une image à l’aide d’un échantillonneur comme celui que nous avons créé dans le chapitre précédent.
Nous allons d’abord modifier l’agencement du descripteur, le groupe de descripteurs et l’ensemble de descripteurs pour inclure un descripteur d’échantillonneur et d’image associés. Ensuite, nous ajouterons des coordonnées de texture à la structure Vertex et modifierons le fragment shader pour qu’il lise les couleurs à partir de la texture et non plus à partir des couleurs de sommets interpolés.
VII-C-2. Modifier les descripteurs▲
Trouvez la fonction createDescriptorSetLayout() et ajoutez une instance à la structure VkDescriptorSetLayoutBinding pour le descripteur d’échantillonneur combiné d’image. Nous allons placer la fonction dans la liaison après le tampon uniforme :
VkDescriptorSetLayoutBinding samplerLayoutBinding{}
;
samplerLayoutBinding.binding =
1
;
samplerLayoutBinding.descriptorCount =
1
;
samplerLayoutBinding.descriptorType =
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
samplerLayoutBinding.pImmutableSamplers =
nullptr
;
samplerLayoutBinding.stageFlags =
VK_SHADER_STAGE_FRAGMENT_BIT;
std::
array<
VkDescriptorSetLayoutBinding, 2
>
bindings =
{
uboLayoutBinding, samplerLayoutBinding}
;
VkDescriptorSetLayoutCreateInfo layoutInfo{}
;
layoutInfo.sType =
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutInfo.bindingCount =
static_cast
<
uint32_t>
(bindings.size());
layoutInfo.pBindings =
bindings.data();
Assurez-vous de définir le champ stageFlags pour indiquer notre intention d’utiliser un descripteur d’échantillonneur combiné d’image dans le fragment shader. C’est ici que la couleur du fragment sera déterminée. Il est possible d’échantillonner une texture dans le vertex shader, notamment pour déformer dynamiquement une grille de sommets dans un champ de hauteur.
Si vous lancez l’application avec les couches de validation, vous verrez des messages indiquant que le groupe de descripteur ne peut pas allouer d’ensemble pour cet agencement. En effet, elle ne comprend aucun descripteur d’échantillonneur combiné d’image. Allez à la fonction createDescriptorPool() pour inclure une structure VkDesciptorPoolSize pour ce descripteur :
std::
array<
VkDescriptorPoolSize, 2
>
poolSizes{}
;
poolSizes[0
].type =
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
poolSizes[0
].descriptorCount =
static_cast
<
uint32_t>
(swapChainImages.size());
poolSizes[1
].type =
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
poolSizes[1
].descriptorCount =
static_cast
<
uint32_t>
(swapChainImages.size());
VkDescriptorPoolCreateInfo poolInfo{}
;
poolInfo.sType =
VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
poolInfo.poolSizeCount =
static_cast
<
uint32_t>
(poolSizes.size());
poolInfo.pPoolSizes =
poolSizes.data();
poolInfo.maxSets =
static_cast
<
uint32_t>
(swapChainImages.size());
La dernière étape consiste à lier l’image et l’échantillonneur aux descripteurs de l’ensemble de descripteurs. Allez à la fonction createDescriptorSets().
for
(size_t i =
0
; i <
swapChainImages.size(); i++
) {
VkDescriptorBufferInfo bufferInfo{}
;
bufferInfo.buffer =
uniformBuffers[i];
bufferInfo.offset =
0
;
bufferInfo.range =
sizeof
(UniformBufferObject);
VkDescriptorImageInfo imageInfo{}
;
imageInfo.imageLayout =
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
imageInfo.imageView =
textureImageView;
imageInfo.sampler =
textureSampler;
...
}
Les ressources nécessaires à la structure paramétrant un descripteur d’échantillonneur combiné d’image doivent être fournies dans une structure de type VkDescriptorImageInfo. C’est similaire à la structure de type VkDescriptorBufferInfo utilisée pour les tampons. C’est à cet endroit que les objets du chapitre précédents s’assemblent.
std::
array<
VkWriteDescriptorSet, 2
>
descriptorWrites{}
;
descriptorWrites[0
].sType =
VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrites[0
].dstSet =
descriptorSets[i];
descriptorWrites[0
].dstBinding =
0
;
descriptorWrites[0
].dstArrayElement =
0
;
descriptorWrites[0
].descriptorType =
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
descriptorWrites[0
].descriptorCount =
1
;
descriptorWrites[0
].pBufferInfo =
&
bufferInfo;
descriptorWrites[1
].sType =
VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrites[1
].dstSet =
descriptorSets[i];
descriptorWrites[1
].dstBinding =
1
;
descriptorWrites[1
].dstArrayElement =
0
;
descriptorWrites[1
].descriptorType =
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
descriptorWrites[1
].descriptorCount =
1
;
descriptorWrites[1
].pImageInfo =
&
imageInfo;
vkUpdateDescriptorSets(device, static_cast
<
uint32_t>
(descriptorWrites.size()), descriptorWrites.data(), 0
, nullptr
);
Les descripteurs doivent être mis à jour avec les informations sur l’image, comme pour le tampon. Cette fois, nous allons utiliser le tableau pImageInfo au lieu du tableau pBufferInfo. Les descripteurs sont maintenant prêts à être utilisés dans les shaders !
VII-C-3. Coordonnées de texture▲
Il manque encore un élément important pour appliquer une texture : les coordonnées de texture de chaque sommet. Les coordonnées déterminent comment appliquer l’image sur la géométrie.
struct
Vertex {
glm::
vec2 pos;
glm::
vec3 color;
glm::
vec2 texCoord;
static
VkVertexInputBindingDescription getBindingDescription() {
VkVertexInputBindingDescription bindingDescription{}
;
bindingDescription.binding =
0
;
bindingDescription.stride =
sizeof
(Vertex);
bindingDescription.inputRate =
VK_VERTEX_INPUT_RATE_VERTEX;
return
bindingDescription;
}
static
std::
array<
VkVertexInputAttributeDescription, 3
>
getAttributeDescriptions() {
std::
array<
VkVertexInputAttributeDescription, 3
>
attributeDescriptions{}
;
attributeDescriptions[0
].binding =
0
;
attributeDescriptions[0
].location =
0
;
attributeDescriptions[0
].format =
VK_FORMAT_R32G32_SFLOAT;
attributeDescriptions[0
].offset =
offsetof
(Vertex, pos);
attributeDescriptions[1
].binding =
0
;
attributeDescriptions[1
].location =
1
;
attributeDescriptions[1
].format =
VK_FORMAT_R32G32B32_SFLOAT;
attributeDescriptions[1
].offset =
offsetof
(Vertex, color);
attributeDescriptions[2
].binding =
0
;
attributeDescriptions[2
].location =
2
;
attributeDescriptions[2
].format =
VK_FORMAT_R32G32_SFLOAT;
attributeDescriptions[2
].offset =
offsetof
(Vertex, texCoord);
return
attributeDescriptions;
}
}
;
Modifiez la structure Vertex pour ajouter une variable de type vec2, pour stocker 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 au fragment shader afin que les coordonnées soient interpolées sur la surface du carré.
const
std::
vector<
Vertex>
vertices =
{
{{-
0.5
f, -
0.5
f}
, {
1.0
f, 0.0
f, 0.0
f}
, {
0.0
f, 0.0
f}}
,
{{
0.5
f, -
0.5
f}
, {
0.0
f, 1.0
f, 0.0
f}
, {
1.0
f, 0.0
f}}
,
{{
0.5
f, 0.5
f}
, {
0.0
f, 0.0
f, 1.0
f}
, {
1.0
f, 1.0
f}}
,
{{-
0.5
f, 0.5
f}
, {
1.0
f, 1.0
f, 1.0
f}
, {
0.0
f, 1.0
f}}
}
;
Dans ce tutoriel, je vais simplement remplir le carré avec la texture en utilisant la coordonnée (0, 0) pour le coin haut gauche et la coordonnée (1, 1) pour le coin bas droit. Essayez des coordonnées différentes ! Essayez aussi des coordonnées inférieures à 0 et supérieures à 1 pour constater les implications des modes d’adressage.
VII-C-4. Shaders▲
La dernière étape consiste à modifier les shaders pour récupérer les couleurs de la texture. Nous devons d’abord modifier le vertex shader pour copier les coordonnées de texture en sortie à destination du fragment shader :
layout
(
location =
0
) in
vec2
inPosition;
layout
(
location =
1
) in
vec3
inColor;
layout
(
location =
2
) in
vec2
inTexCoord;
layout
(
location =
0
) out
vec3
fragColor;
layout
(
location =
1
) out
vec2
fragTexCoord;
void
main
(
) {
gl_Position
=
ubo.proj *
ubo.view *
ubo.model *
vec4
(
inPosition, 0
.0
, 1
.0
);
fragColor =
inColor;
fragTexCoord =
inTexCoord;
}
Comme pour les couleurs définies par sommet, les valeurs de fragTexCoord seront interpolées sur la surface du carré par le rastériseur. Nous pouvons visualiser cela en écrivant un fragment shader qui retourne comme couleurs les coordonnées de texture :
#
version
450
#
extension
GL_ARB_separate_shader_objects : enable
layout
(
location =
0
) in
vec3
fragColor;
layout
(
location =
1
) in
vec2
fragTexCoord;
layout
(
location =
0
) out
vec4
outColor;
void
main
(
) {
outColor =
vec4
(
fragTexCoord, 0
.0
, 1
.0
);
}
Vous devriez avoir un résultat similaire à l’image suivante. N’oubliez pas de recompiler les shader !
Le vert représente les coordonnées horizontales et le rouge les coordonnées verticales. Le coin noir et le coin jaune confirment que les coordonnées sont interpolées correctement de (0, 0) à (1, 1) sur la surface du carré. L’utilisation des couleurs pour visualiser les valeurs est l’équivalent du débogage à l’aide de printf pour les shaders. Il n’y a pas vraiment d’autre option !
Un descripteur d’échantillonneur combiné d’image correspond à une variable uniforme de type échantillonneur (sampler) en GLSL. Ajoutez-y une référence dans le fragment shader :
layout
(
binding =
1
) uniform
sampler2D
texSampler;
Il existe des échantillonneurs 1D (sampler1D) et 3D (sampler3D) pour les autres types d’images. Aussi, assurez-vous d’utiliser la bonne liaison.
void
main
(
) {
outColor =
texture
(
texSampler, fragTexCoord);
}
Les textures sont échantillonnées à l’aide de la fonction texture() fournie par le GLSL. La fonction texture() prend en argument un objet sampler et des coordonnées. L’échantillonneur prend en charge automatiquement le filtrage et les transformations. Vous devriez maintenant voir la texture sur le carré :
Expérimentez avec les modes d’adressage en modifiant les valeurs des coordonnées de texture. Par exemple, le fragment shader suivant produit l’image ci-dessous avec le mode d’adressage VK_SAMPLER_ADDRESS_MODE_REPEAT ;
void
main
(
) {
outColor =
texture
(
texSampler, fragTexCoord *
2
.0
);
}
Vous pouvez aussi combiner les couleurs de la texture avec celles des sommets :
void
main
(
) {
outColor =
vec4
(
fragColor *
texture
(
texSampler, fragTexCoord).rgb, 1
.0
);
}
J’ai séparé l’alpha du reste pour ne pas altérer le canal alpha.
Nous savons désormais comment accéder aux images à partir de nos shaders ! C’est une technique très puissante, lorsqu’associée à des images provenant du rendu d’un tampon d’image. Vous pouvez utiliser les images comme entrées pour implémenter des effets sympas comme des effets de post-traitement ou pour afficher le retour d’une caméra dans votre monde 3D.