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

Tutoriel Vulkan complet


précédentsommairesuivant

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).

[ALT-PASTOUCHE]
VII-A-2-b. Makefile

Ajoutez le dossier comprenant stb_image.h aux chemins d’inclusion de GCC :

 
Sélectionnez
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 :

 
Sélectionnez
#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.

 
Sélectionnez
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.

La texture pour nos tests

Il est très facile de charger une image avec la bibliothèque stb_image :

 
Sélectionnez
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() :

 
Sélectionnez
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 :

 
Sélectionnez
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 :

 
Sélectionnez
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 :

 
Sélectionnez
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 :

 
Sélectionnez
VkImage textureImage;
VkDeviceMemory textureImageMemory;

Les paramètres pour la création d’une image sont spécifiés dans une structure de type VkImageCreateInfo :

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
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).

 
Sélectionnez
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.

 
Sélectionnez
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 :

 
Sélectionnez
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 :

 
Sélectionnez
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 :

 
Sélectionnez
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() :

 
Sélectionnez
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 :

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
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 :

 
Sélectionnez
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 :

 
Sélectionnez
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().

 
Sélectionnez
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 :

 
Sélectionnez
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 :

 
Sélectionnez
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 :

 
Sélectionnez
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 ;

 
Sélectionnez
    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() :

 
Sélectionnez
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.

C++ code / Vertex shader / Fragment shader

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 :

 
Sélectionnez
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 :

 
Sélectionnez
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() :

 
Sélectionnez
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() :

 
Sélectionnez
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 :

 
Sélectionnez
void createTextureImageView() {
    textureImageView = createImageView(textureImage, VK_FORMAT_R8G8B8A8_SRGB);
}

Ainsi que la fonction createImageView() :

 
Sélectionnez
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 :

 
Sélectionnez
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 :

[ALT-PASTOUCHE]

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 :

[ALT-PASTOUCHE]

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 :

[ALT-PASTOUCHE]

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.

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
samplerInfo.anisotropyEnable = VK_TRUE;
samplerInfo.maxAnisotropy = 16.0f;

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.

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
samplerInfo.mipLodBias = 0.0f;
samplerInfo.minLod = 0.0f;
samplerInfo.maxLod = 0.0f;

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

 
Sélectionnez
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 :

 
Sélectionnez
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 :

[ALT-PASTOUCHE]

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é :

 
Sélectionnez
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é :

 
Sélectionnez
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 :

 
Sélectionnez
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é.

C++ code / Vertex shader / Fragment shader

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 :

 
Sélectionnez
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 :

 
Sélectionnez
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().

 
Sélectionnez
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.

 
Sélectionnez
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.

 
Sélectionnez
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é.

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

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 :

 
Sélectionnez
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 :

 
Sélectionnez
#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 !

[ALT-PASTOUCHE]

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 :

 
Sélectionnez
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.

 
Sélectionnez
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é :

[ALT-PASTOUCHE]

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 ;

 
Sélectionnez
void main() {
    outColor = texture(texSampler, fragTexCoord * 2.0);
}
[ALT-PASTOUCHE]

Vous pouvez aussi combiner les couleurs de la texture avec celles des sommets :

 
Sélectionnez
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.

[ALT-PASTOUCHE]

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.

Code C++ / Vertex shader / Fragment shader


précédentsommairesuivant

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