Introduction

Jusqu'à présent nous avons écrit les couleurs dans les données de chaque sommet, pratique peu efficace. Nous allons maintenant implémenter l'échantillonnage (sampling) des textures, afin que le rendu soit plus intéressant. Nous pourrons ensuite passer à l'affichage de modèles 3D dans de futurs chapitres.

L'ajout d'une texture comprend les étapes suivantes :

  • Créer un objet image stocké sur la mémoire de la carte graphique
  • La remplir avec les pixels extraits d'un fichier image
  • Créer un sampler
  • Ajouter un descripteur pour l'échantillonnage de l'image

Nous avons déjà travaillé avec des images, mais nous n'en avons jamais créé. Celles que nous avons manipulées avaient été automatiquement crées par la swap chain. Créer une image et la remplir de pixels ressemble à la création d'un vertex buffer. Nous allons donc commencer par créer une ressource intermédiaire pour y faire transiter les données que nous voulons retrouver dans l'image. Bien qu'il soit possible d'utiliser une image comme intermédiaire, il est aussi autorisé de créer un VkBuffer comme intermédiaire vers l'image, et cette méthode est plus rapide sur certaines plateformes. Nous allons donc d'abord créer un buffer et y mettre les données relatives aux pixels. Pour l'image nous devrons nous enquérir des spécificités de la mémoire, allouer la mémoire nécessaire et y copier les pixels. Cette procédure est très similaire à la création de buffers.

La grande différence - il en fallait une tout de même - réside dans l'organisation des données à l'intérieur même des pixels. Leur organisation affecte la manière dont les données brutes de la mémoire sont interprétées. De plus, stocker les pixels ligne par ligne n'est pas forcément ce qui se fait de plus efficace, et cela est dû à la manière dont les cartes graphiques fonctionnent. Nous devrons donc faire en sorte que les images soient organisées de la meilleure manière possible. Nous avons déjà croisé certaines organisation lors de la création de la passe de rendu :

  • VK_IMAGE_LAYOUT_PRESENT_SCR_KHR : optimal pour la présentation
  • VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL : optimal pour être l'attachement cible du fragment shader donc en tant que cible de rendu
  • VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : optimal pour être la source d'un transfert comme vkCmdCopyImageToBuffer
  • VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL : optimal pour être la cible d'un transfer comme vkCmdCopyBufferToImage
  • VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL : optimal pour être échantillonné depuis un shader

La plus commune des méthode spour réaliser une transition entre différentes organisations est la barrière pipeline. Celles-ci sont principalement utilisées pour synchroniser l'accès à une ressource, mais peuvent aussi permettre la transition d'un état à un autre. Dans ce chapitre nous allons utiliser cette seconde possibilité. Les barrières peuvent enfin être utilisées pour changer la queue family qui possède une ressource.

Librairie de chargement d'image

De nombreuses librairies de chargement d'images existent ; vous pouvez même écrire la vôtre pour des formats simples comme BMP ou PPM. Nous allons utiliser stb_image, de la collecetion stb. Elle possède l'avantage d'être écrite en un seul fichier. Téléchargez donc stb_image.h et placez-la ou vous voulez, par exemple dans le dossier où sont stockés GLFW et GLM.

Visual Studio

Ajoutez le dossier comprenant stb_image.h dans Additional Include Directories.

Makefile

Ajoutez le dossier comprenant stb_image.h aux chemins parcourus par GCC :

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)

Charger une image

Incluez la librairie de cette manière :

#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>

Le header simple ne fournit que les prototypes des fonctions. Nous devons demander les implémentations avec la define STB_IMAGE_IMPLEMENTATION pour ne pas avoir d'erreurs à l'édition des liens.

void initVulkan() {
    ...
    createCommandPool();
    createTextureImage();
    createVertexBuffer();
    ...
}

...

void createTextureImage() {

}

Créez la fonction createTextureImage, depuis laquelle nous chargerons une image et la placerons dans un objet Vulkan représentant une image. Nous allons avoir besoin de command buffers, il faut donc appeler cette fonction après createCommandPool.

Créez un dossier textures au même endroit que shaders pour y placer les textures. Nous allons y mettre un fichier appelé texture.jpg pour l'utiliser dans notre programme. J'ai choisi d'utiliser cette image de license CC0 redimensionnée à 512x512, mais vous pouvez bien sûr en utiliser une autre. La librairie supporte des formats tels que JPEG, PNG, BMP ou GIF.

Le chargement d'une image est très facile avec cette librairie :

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 d'une image!");
    }
}

La fonction stbi_load prend en argument le chemin de l'image et les différentes canaux à charger. L'argument STBI_rgb_alpha force la fonction à créer un canal alpha même si l'image originale n'en possède pas. Cela simplifie le travail en homogénéisant les situations. Les trois arguments transmis en addresse servent de résultats pour stocker des informations sur l'image. Les pixels sont retournés sous forme du pointeur stbi_uc *pixels. Ils sont organisés ligne par ligne et ont chacun 4 octets, ce qui représente texWidth * texHeight * 4 octets au total pour l'image.

Buffer intermédiaire

Nous allons maintenant créer un buffer en mémoire accessible pour que nous puissions utiliser vkMapMemory et y placer les pixels. Ajoutez les variables suivantes à la fonction pour contenir ce buffer temporaire :

VkBuffer stagingBuffer;
VkDeviceMemory stagingBufferMemory;

Le buffer doit être en mémoire visible pour que nous puissions le mapper, et il doit être utilisable comme source d'un transfert vers une image, d'où l'appel suivant :

createBuffer(imageSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer, stagingBufferMemory);

Nous pouvons placer tel quels les pixels que nous avons récupérés dans le buffer :

void* data;
vkMapMemory(device, stagingBufferMemory, 0, imageSize, 0, &data);
    memcpy(data, pixels, static_cast<size_t>(imageSize));
vkUnmapMemory(device, stagingBufferMemory);

Il ne faut surtout pas oublier de libérer le tableau de pixels après cette opération :

stbi_image_free(pixels);

Texture d'image

Bien qu'il nous soit possible de paramétrer le shader afin qu'il utiliser le buffer comme source de pixels, il est bien plus efficace d'utiliser un objet image. Ils rendent plus pratique, mais surtout plus rapide, l'accès aux données de l'image en nous permettant d'utiliser des coordonnées 2D. Les pixels sont appelés texels dans le contexte du shading, et nous utiliserons ce terme à partir de maintenant. Ajoutez les membres données suivants :

VkImage textureImage;
VkDeviceMemory textureImageMemory;

Les paramètre pour la création d'une image sont indiqué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 contenu dans imageType indique à Vulkan le repère dans lesquels les texels sont placés. Il est possible de créer des repères 1D, 2D et 3D. Les images 1D peuvent être utilisés comme des tableaux ou des gradients. Les images 2D sont majoritairement utilisés comme textures. Certaines techniques les utilisent pour stocker autre chose que des couleur, par exemple des vecteurs. Les images 3D peuvent être utilisées pour stocker des voxels par exemple. Le champ extent indique la taille de l'image, en terme de texels par axe. Comme notre texture fonctionne comme un plan dans un espace en 3D, nous devons indiquer 1 au champ depth. Finalement, notre texture n'est pas un tableau, et nous verrons le mipmapping plus tard.

imageInfo.format = VK_FORMAT_R8G8B8A8_UNORM;

Vulkan supporte de nombreux formats, mais nous devons utiliser le même format que les données présentes dans le buffer.

imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;

Le champ tiling peut prendre deux valeurs :

  • VK_IMAGE_TILING_LINEAR : les texels sont organisés ligne par ligne
  • VK_IMAGE_TILING_OPTIMAL : les texels sont organisés de la manière la plus optimale pour l'implémentation

Le mode mis dans tiling ne peut pas être changé, au contraire de l'organisation de l'image. Par conséquent, si vous voulez pouvoir directement accéder aux texels, comme il faut qu'il soient organisés d'une manière logique, il vous faut indiquer VK_IMAGE_TILING_LINEAR. Comme nous utilisons un buffer intermédiaire et non une image intermédiaire, nous pouvons utiliser le mode le plus efficace.

imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;

Idem, il n'existe que deux valeurs pour initialLayout :

  • VK_IMAGE_LAYOUT_UNDEFINED : inutilisable par le GPU, son contenu sera éliminé à la première transition
  • VK_IMAGE_LAYOUT_PREINITIALIZED : inutilisable par le GPU, mais la première transition conservera les texels

Il n'existe que quelques situations où il est nécessaire de préserver les texels pendant la première transition. L'une d'elle consiste à utiliser l'image comme ressource intermédiaire en combinaison avec VK_IMAGE_TILING_LINEAR. Il faudrait dans ce cas la faire transitionner vers un état source de transfert, sans perte de données. Cependant nous utilisons un buffer comme ressource intermédiaire, et l'image transitionne d'abord vers cible de transfert. À ce moment-là elle n'a pas de donnée intéressante.

imageInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;

Le champ de bits usage fonctionne de la même manière que pour la création des buffers. L'image sera destination d'un transfert, et sera utilisée par les shaders, d'où les deux indications ci-dessus.

imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

L'image ne sera utilisée que par une famille de queues : celle des graphismes (qui rappelons-le supporte implcitement les transferts). Si vous avez choisi d'utiliser une queue spécifique vous devrez mettre VK_SHARING_MODE_CONCURENT.

imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
imageInfo.flags = 0; // Optionel

Le membre sample se réfère au multisampling. Il n'a de sens que pour les images utilisées comme attachements d'un framebuffer, nous devons donc mettre 1, traduit par VK_SAMPLE_COUNT_1_BIT. Finalement, certaines informations se réfèrent aux images étendues. Ces image étendues sont des images dont seule une partie est stockée dans la mémoire. Voici une exemple d'utilisation : si vous utilisiez une image 3D pour représenter un terrain à l'aide de voxels, vous pourriez utiliser cette fonctionnalité pour éviter d'utiliser de la mémoire qui au final ne contiendrait que de l'air. Nous ne verrons pas cette fonctionnalité dans ce tutoriel, donnez à flags la valeur 0.

if (vkCreateImage(device, &imageInfo, nullptr, &textureImage) != VK_SUCCESS) {
    throw std::runtime_error("echec de la creation d'une image!");
}

L'image est créée par la fonction vkCreateImage, qui ne possède pas d'argument particulièrement intéressant. Il est possible que le format VK_FORMAT_R8G8B8A8_UNORM ne soit pas supporté par la carte graphique, mais c'est tellement peu probable que nous ne verrons pas comment y remédier. En effet utiliser un autre format demanderait de réaliser plusieurs conversions compliquées. Nous reviendrons sur ces conversions dans le chapitre sur le buffer de profondeur.

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("echec de l'allocation de la mémoire pour l'image!");
}

vkBindImageMemory(device, textureImage, textureImageMemory, 0);

L'allocation de la mémoire nécessaire à une image fonctionne également de la même façon que pour un buffer. Seuls les noms de deux fonctions changent : vkGetBufferMemoryRequirements devient vkGetImageMemoryRequirements et vkBingBuffer devient vkBindImage.

Cette fonction est déjà assez grande ainsi, et comme nous aurons besoin d'autres images dans de futurs chapitres, il est judicieux de déplacer la logique de leur création dans une fonction, comme nous l'avons fait pour les buffers. Voici donc la fonction createImage :

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("echec de la creation d'une 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("echec de l'allocation de la memoire d'une image!");
    }

    vkBindImageMemory(device, image, imageMemory, 0);
}

La largeur, la hauteur, le mode de tiling, l'uage et les propriétés de la mémoire sont des paramètres car ils varierons toujours entre les différentes images que nous créerons dans ce tutoriel.

La fonction createTextureImage peut maintenant être réduite à ceci :

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 l'image!");
    }

    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_UNORM, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, textureImage, textureImageMemory);
}

Transitions de l'organisation

La fonction que nous allons écrire inclut l'enregistrement et l'exécution de command buffers. Il est donc également judicieux de placer cette logique dans une autre fonction :

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 copyBuffer. Vous pouvez maintenant réduire 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 de simples buffers nous pourrions nous contenter d'écrire une fonction qui enregistre l'appel à vkCmdCopyBufferToImage. Mais comme cette fonction utilse une image comme cible nous devons changer l'organisation de l'image avant l'appel. Créez une nouvelle fonction pour gérer de manière générique les transitions :

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 un barrière pour mémoire d'image. Une telle barrière de pipeline est en général utilisée pour synchroniser l'accès à une ressource, mais nous avons déjà évoqué ce sujet. Il existe au passage un équivalent pour les buffers : une barrière pour mémoire de buffer.

VkImageMemoryBarrier barrier = {};
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
barrier.oldLayout = oldLayout;
barrier.newLayout = newLayout;

Les deux premiers champs indiquent la transition à réaliser. Il est possible d'utiliser VK_IMAGE_LAYOUT_UNDEFINED pour oldLayout si le contenu de l'image ne vous intéresse pas.

barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;

Ces deux paramètres sont utilisés pour transmettre la possession d'une queue à une autre. Il faut leur indiquer les indices des familles de queues correspondantes. Comme nous ne les utilisons pas, nous devons les mettre à VK_QUEUE_FAMILY_IGNORED.

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, puis la partie de l'image concernées par les changements. Comme notre image n'est pas un tableau, et que nous n'avons pas mis en place de mipmapping, les paramètres sont tous mis au minimum.

barrier.srcAccessMask = 0; // TODO
barrier.dstAccessMask = 0; // TODO

Comme les barrières sont avant tout des objets de synchronisation, nous devons indiquer les opérations utilisant la ressource avant et après l'exécution de cette barrière. Pour pouvoir remplir les champs ci-dessus nous devons déterminer ces opérations, ce que nous ferons plus tard.

vkCmdPipelineBarrier(
    commandBuffer,
    0 /* TODO */, 0 /* TODO */,
    0,
    0, nullptr,
    0, nullptr,
    1, &barrier
);

Tous les types de barrière sont mis en place à l'aide de la même fonction. Le paramètre qui suit le command buffer indique une étape de la pipeline. Durant celle-ci seront réalisées les opération devant précéder la barrière. Le paramètre d'après indique également une étape de la pipeline. Cette fois les opérations exécutées durant cette étape attendront la barrière. Les étapes que vous pouvez fournir comme avant- et après-barrière dépendent de l'utilisation des ressources qui y sont utilisées. Les valeurs autorisées sont listées dans ce tableau. Par exemple, si vous voulez lire des données présentes dans un UBO après une barrière qui s'applique au buffer, vous devrez indiquer VK_ACCESS_UNIFORM_READ_BIT comme usage, et si le premier shader à utiliser l'uniform est le fragment shader il vous faudra indiquer VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT comme étape. Dans ce cas de figure, spécifier une autre étape qu'une étape shader n'aurait aucun sens, et les validation layers vous le feraient remarquer.

Le paramètre sur la troisième ligne peut être soit 0 soit VK_DEPENDENCY_BY_REGION_BIT. Dans ce second cas la barrière devient une condition spécifique d'une région de la ressource. Cela signifie entre autres que l'implémentation peut lire une région aussitôt que le transfert y est terminé, sans considération pour les autres régions. Cela permet d'augmenter encore les performances en permettant d'utiliser les optimisations des architectures actuelles.

Les trois dernières paires de paramètres sont des tableaux de barrières pour chacun des trois types existants : barrière mémorielle, barrière de buffer et barrière d'image.

Copier un buffer dans une image

Avant de compléter vkCreateTextureImage nous allons écrire une dernière fonction appelée copyBufferToImage :

void copyBufferToImage(VkBuffer buffer, VkImage image, uint32_t width, uint32_t height) {
    VkCommandBuffer commandBuffer = beginSingleTimeCommands();

    endSingleTimeCommands(commandBuffer);
}

Comme avec les recopies de buffers, nous devons indiquer les parties du buffer à copier et les parties de l'image où écrire. Ces données doivent être placées dans une structure de type VkBufferImageCopy.

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. bufferOffset indique l'octet à partir duquel les données des pixels commencent dans le buffer. L'organisation des pixels doit être indiquée dans les champs bufferRowLenght et bufferImageHeight. Il pourrait en effet avoir un espace entre les lignes de l'image. Comme notre image est en un seul bloc, nous devons mettre ces paramètres à 0. Enfin, les membres imageSubResource, imageOffset et imageExtent indiquent les parties de l'image qui receveront les données.

Les copies buffer vers 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. Normalement l'image doit être dans l'organisation optimale pour la réception de données. Nous avons paramétré la copie pour qu'un seul command buffer soit à l'origine de la copie successive de tous les pixels. Nous aurions aussi pu créer un tableau de VkBufferImageCopy pour que le command buffer soit à l'origine de plusieurs copies simultanées.

Préparer la texture d'image

Nous avons maintenant tous les outils nécessaires pour compléter la mise en place de la texture d'image. Nous pouvons retourner à la fonction createTextureImage. La dernière chose que nous y avions fait consistait à créer l'image texture. Notre prochaine étape est donc d'y placer les pixels en les copiant depuis le buffer intermédiaire. Il y a deux étapes pour cela :

  • Transitionner l'organisation de l'image vers VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL
  • Exécuter le buffer de copie

C'est simple à réaliser avec les fonctions que nous venons de créer :

transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_UNORM, 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 une organisation VK_LAYOUT_UNDEFINED, car le contenu initial ne nous intéresse pas.

Pour ensuite pouvoir échantilloner la texture depuis le framgment shader nous devons réaliser une dernière transition, qui la préparera à être accédée depuis un shader :

transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_UNORM, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);

Derniers champs de la barrière de transition

Si vous lanciez le programme vous verrez que les validation layers vous indiquent que les champs d'accès et d'étapes shader sont invalides. C'est normal, nous ne les avons pas remplis.

Nous sommes pour le moment interessés par deux transitions :

  • Non défini → cible d'un transfert : écritures par transfert qui n'ont pas besoin d'être synchronisées
  • Cible d'un transfert → lecture par un shader : la lecture par le shader doit attendre la fin du transfert

Ces règles sont indiquées en utilisant les valeurs suivantes pour l'accès et les étapes shader :

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 d'orgisation 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, l'écriture dans l'image doit se réaliser à l'étape pipeline de transfert. Mais cette opération d'écriture ne dépend d'aucune autre opération. Nous pouvons donc fournir une condition d'accès nulle et VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT comme opération pré-barrière. Cette valeur correspond au début de la pipeline, mais ne représente pas vraiment une étape. Elle désigne plutôt le moment où la pipeline se prépare, et donc sert communément aux transferts. Voyez la documentation pour de plus amples informations sur les pseudo-étapes.

L'image sera écrite puis lue dans la même passe, c'est pourquoi nous devons indiquer que le fragment shader aura accès à la mémoire de l'image.

Quand nous aurons besoin de plus de transitions, nous compléterons la fonction de transition pour qu'elle les prenne en compte. L'application devrait maintenant tourner sans problème, bien qu'il n'y aie aucune différence visible.

Un point intéressant est que l'émission du command buffer génère implicitement une synchronisation de type VK_ACCESS_HOST_WRITE_BIT. Comme la fonction transitionImageLayout exécute un command buffer ne comprenant qu'une seule commande, il est possbile d'utiliser cette synchronisation. Cela signifie que vous pourriez alors mettre srcAccessMask à 0 dans le cas d'une transition vers VK_ACCESS_HOST_WRITE_BIT. C'est à vous de voir si vous voulez être explicites à ce sujet. Personnellement je n'aime pas du tout faire dépendre mon application sur des opérations cachées, que je trouve dangereusement proche d'OpenGL.

Autre chose intéressante à savoir, il existe une organisation qui supporte toutes les opérations. Elle s'appelle VK_IMAGE_LAYOUT_GENERAL. Le problème est qu'elle est évidemment moins optimisée. Elle est cependant utile dans certains cas, comme quand une image doit être utilisée comme cible et comme source, ou pour pouvoir lire l'image juste après qu'elle aie quittée l'organisation préinitialisée.

Enfin, il important de noter que les fonctions que nous avons mises en place exécutent les commandes de manière synchronisées et attendent que la queue soit en pause. Pour de véritables applications il est bien sûr recommandé de combiner toutes ces opérations dans un seul command buffer pour qu'elles soient exécutées de manière asynchrones. Les commandes de transitions et de copie pourraient grandement bénéficier d'une telle pratique. Essayez par exemple de créer une fonction setupCommandBuffer, puis d'enregistrer les commandes nécessaires depuis les fonctions actuelles. Appelez ensuite une autre fonction nommée par exemple flushSetupCommands qui exécutera le command buffer. Avant d'implémenter ceci attendez que nous ayons fait fonctionner l'échantillonage.

Nettoyage

Complétez la fonction createImageTexture en libérant le buffer intermédiaire et en libérant la mémoire :

    transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_UNORM, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);

    vkDestroyBuffer(device, stagingBuffer, nullptr);
    vkFreeMemory(device, stagingBufferMemory, nullptr);
}

L'image texture est utilisée jusqu'à la fin du programme, nous devons donc la libérer dans 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 de quoi y accéder depuis la pipeline. Nous y travaillerons dans le prochain chapitre.

C++ code / Vertex shader / Fragment shader