Générer des mipmaps
Introduction
Notre programme peut maintenant charger et afficher des modèles 3D. Dans ce chapitre nous allons ajouter une nouvelle fonctionnalité : celle de générer et d'utiliser des mipmaps. Elles sont utilisées dans tous les applications 3D. Vulkan laisse au programmeur un control quasiment total sur leur génération.
Les mipmaps sont des versions de qualité réduite précalculées d'une texture. Chacune de ces versions est deux fois moins haute et large que l'originale. Les objets plus distants de la caméra peuvent utiliser ces versions pour le sampling de la texture. Le rendu est alors plus rapide et plus lisse. Voici un exemple de mipmaps :
Création des images
Avec Vulkan, chaque niveau de mipmap est stocké dans les différents niveaux de mipmap de l'image originale. Le niveau 0 correspond à l'image originale. Les images suivantes sont souvent appelées mip chain.
Le nombre de niveaux de mipmap doit être fourni lors de la création de l'image. Jusqu'à présent nous avons indiqué la
valeur 1
. Nous devons ainsi calculer le nombre de mipmaps à générer à partir de la taille de l'image. Créez un membre
donnée pour contenir cette valeur :
...
uint32_t mipLevels;
VkImage textureImage;
...
La valeur pour mipLevels
peut être déterminée une fois que nous avons chargé la texture dans createTextureImage
:
int texWidth, texHeight, texChannels;
stbi_uc* pixels = stbi_load(TEXTURE_PATH.c_str(), &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);
...
mipLevels = static_cast<uint32_t>(std::floor(std::log2(std::max(texWidth, texHeight)))) + 1;
La troisième ligne ci-dessus calcule le nombre de niveaux de mipmaps. La fonction max
chosit la plus grande des
dimensions, bien que dans la pratique les textures seront toujours carrées. Ensuite, log2
donne le nombre de fois que
les dimensions peuvent être divisées par deux. La fonction floor
gère le cas où la dimension n'est pas un multiple
de deux (ce qui est déconseillé). 1
est finalement rajouté pour que l'image originale soit aussi comptée.
Pour utiliser cette valeur nous devons changer les fonctions createImage
, createImageView
et
transitionImageLayout
. Nous devrons y indiquer le nombre de mipmaps. Ajoutez donc cette donnée en paramètre à toutes
ces fonctions :
void createImage(uint32_t width, uint32_t height, uint32_t mipLevels, VkFormat format, VkImageTiling tiling, VkImageUsageFlags usage, VkMemoryPropertyFlags properties, VkImage& image, VkDeviceMemory& imageMemory) {
...
imageInfo.mipLevels = mipLevels;
...
}
VkImageView createImageView(VkImage image, VkFormat format, VkImageAspectFlags aspectFlags, uint32_t mipLevels) {
...
viewInfo.subresourceRange.levelCount = mipLevels;
...
void transitionImageLayout(VkImage image, VkFormat format, VkImageLayout oldLayout, VkImageLayout newLayout, uint32_t mipLevels) {
...
barrier.subresourceRange.levelCount = mipLevels;
...
Il nous faut aussi mettre à jour les appels.
createImage(swapChainExtent.width, swapChainExtent.height, 1, depthFormat, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, depthImage, depthImageMemory);
...
createImage(texWidth, texHeight, mipLevels, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, textureImage, textureImageMemory);
swapChainImageViews[i] = createImageView(swapChainImages[i], swapChainImageFormat, VK_IMAGE_ASPECT_COLOR_BIT, 1);
...
depthImageView = createImageView(depthImage, depthFormat, VK_IMAGE_ASPECT_DEPTH_BIT, 1);
...
textureImageView = createImageView(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_ASPECT_COLOR_BIT, mipLevels);
transitionImageLayout(depthImage, depthFormat, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, 1);
...
transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, mipLevels);
Génération des mipmaps
Notre texture a plusieurs niveaux de mipmaps, mais le buffer intermédiaire ne peut pas gérer cela. Les niveaux
autres que 0 sont indéfinis. Pour les remplir nous devons générer les mipmaps à partir du seul niveau que nous avons.
Nous allons faire cela du côté de la carte graphique. Nous allons pour cela utiliser la commande vkCmdBlitImage
.
Elle effectue une copie, une mise à l'échelle et un filtrage. Nous allons l'appeler une fois par niveau.
Cette commande est considérée comme une opération de transfert. Nous devons donc indiquer que la mémoire de l'image sera
utilisée à la fois comme source et comme destination de la commande. Ajoutez VK_IMAGE_USAGE_TRANSFER_SRC_BIT
à la
création de l'image.
...
createImage(texWidth, texHeight, mipLevels, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, textureImage, textureImageMemory);
...
Comme pour les autres opérations sur les images, la commande vkCmdBlitImage
dépend de l'organisation de l'image sur
laquelle elle opère. Nous pourrions transitionner l'image vers VK_IMAGE_LAYOUT_GENERAL
, mais les opérations
prendraient beaucoup de temps. En fait il est possible de transitionner les niveaux de mipmaps indépendemment les uns
des autres. Nous pouvons donc mettre l'image initiale à VK_IMAGE_LAYOUT_TRANSFER_SCR_OPTIMAL
et la chaîne de mipmaps
à VK_IMAGE_LAYOUT_DST_OPTIMAL
. Nous pourrons réaliser les transitions à la fin de chaque opération.
La fonction transitionImageLayout
ne peut réaliser une transition d'organisation que sur l'image entière. Nous allons
donc devoir écrire quelque commandes liées aux barrières de pipeline. Supprimez la transition vers
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
dans createTextureImage
:
...
transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, mipLevels);
copyBufferToImage(stagingBuffer, textureImage, static_cast<uint32_t>(texWidth), static_cast<uint32_t>(texHeight));
//transitionné vers VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL lors de la generation des mipmaps
...
Tous les niveaux de l'image seront ainsi en VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL
. Chaque niveau sera ensuite
transitionné vers VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
après l'exécution de la commande.
Nous allons maintenant écrire la fonction qui génèrera les mipmaps.
void generateMipmaps(VkImage image, int32_t texWidth, int32_t texHeight, uint32_t mipLevels) {
VkCommandBuffer commandBuffer = beginSingleTimeCommands();
VkImageMemoryBarrier barrier{};
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
barrier.image = image;
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
barrier.subresourceRange.baseArrayLayer = 0;
barrier.subresourceRange.layerCount = 1;
barrier.subresourceRange.levelCount = 1;
endSingleTimeCommands(commandBuffer);
}
Nous allons réaliser plusieurs transitions, et pour cela nous réutiliserons cette structure VkImageMemoryBarrier
. Les
champs remplis ci-dessus seront valides pour tous les niveaux, et nous allons changer les champs manquant au fur et à
mesure de la génération des mipmaps.
int32_t mipWidth = texWidth;
int32_t mipHeight = texHeight;
for (uint32_t i = 1; i < mipLevels; i++) {
}
Cette boucle va enregistrer toutes les commandes VkCmdBlitImage
. Remarquez que la boucle commence à 1, et pas à 0.
barrier.subresourceRange.baseMipLevel = i - 1;
barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
vkCmdPipelineBarrier(commandBuffer,
VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0,
0, nullptr,
0, nullptr,
1, &barrier);
Tout d'abord nous transitionnons le i-1
ième niveau vers VK_IMAGE_LAYOUT_TRANSFER_SCR_OPTIMAL
. Cette transition
attendra que le niveau de mipmap soit prêt, que ce soit par copie depuis le buffer pour l'image originale, ou bien par
vkCmdBlitImage
. La commande de génération de la mipmap suivante attendra donc la fin de la précédente.
VkImageBlit blit{};
blit.srcOffsets[0] = { 0, 0, 0 };
blit.srcOffsets[1] = { mipWidth, mipHeight, 1 };
blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.srcSubresource.mipLevel = i - 1;
blit.srcSubresource.baseArrayLayer = 0;
blit.srcSubresource.layerCount = 1;
blit.dstOffsets[0] = { 0, 0, 0 };
blit.dstOffsets[1] = { mipWidth > 1 ? mipWidth / 2 : 1, mipHeight > 1 ? mipHeight / 2 : 1, 1 };
blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.dstSubresource.mipLevel = i;
blit.dstSubresource.baseArrayLayer = 0;
blit.dstSubresource.layerCount = 1;
Nous devons maintenant indiquer les régions concernées par la commande. Le niveau de mipmap source est i-1
et le
niveau destination est i
. Les deux éléments du tableau scrOffsets
déterminent en 3D la région source, et
dstOffsets
la région cible. Les coordonnées X et Y sont à chaque fois divisées par deux pour réduire la taille des
mipmaps. La coordonnée Z doit être mise à la profondeur de l'image, c'est à dire 1.
vkCmdBlitImage(commandBuffer,
image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
1, &blit,
VK_FILTER_LINEAR);
Nous enregistrons maintenant les commandes. Remarquez que textureImage
est utilisé à la fois comme source et comme
cible, car la commande s'applique à plusieurs niveaux de l'image. Le niveau de mipmap source vient d'être transitionné
vers VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL
, et le niveau cible est resté en destination depuis sa création.
Attention au cas où vous utilisez une queue de transfert dédiée (comme suggéré dans Vertex buffers) : la fonction vkCmdBlitImage
doit être envoyée dans une queue graphique.
Le dernier paramètre permet de fournir un VkFilter
. Nous voulons le même filtre que pour le sampler, nous pouvons donc
mettre VK_FILTER_LINEAR
.
barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
barrier.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
vkCmdPipelineBarrier(commandBuffer,
VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0,
0, nullptr,
0, nullptr,
1, &barrier);
Ensuite, la boucle transtionne le i-1
ième niveau de mipmap vers l'organisation optimale pour la lecture par shader.
La transition attendra la fin de la commande, de même que les opérations de sampling.
...
if (mipWidth > 1) mipWidth /= 2;
if (mipHeight > 1) mipHeight /= 2;
}
Les tailles de la mipmap sont ensuite divisées par deux. Nous vérifions quand même que ces dimensions sont bien supérieures à 1, ce qui peut arriver dans le cas d'une image qui n'est pas carrée.
barrier.subresourceRange.baseMipLevel = mipLevels - 1;
barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
vkCmdPipelineBarrier(commandBuffer,
VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0,
0, nullptr,
0, nullptr,
1, &barrier);
endSingleTimeCommands(commandBuffer);
}
Avant de terminer avec le command buffer, nous devons ajouter une dernière barrière. Elle transitionne le dernier
niveau de mipmap vers VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
. Ce cas n'avait pas été géré par la boucle, car elle
n'a jamais servie de source à une copie.
Appelez finalement cette fonction depuis createTextureImage
:
transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, mipLevels);
copyBufferToImage(stagingBuffer, textureImage, static_cast<uint32_t>(texWidth), static_cast<uint32_t>(texHeight));
//transions vers VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL pendant la génération des mipmaps
...
generateMipmaps(textureImage, texWidth, texHeight, mipLevels);
Les mipmaps de notre image sont maintenant complètement remplies.
Support pour le filtrage linéaire
La fonction vkCmdBlitImage
est extrêmement pratique. Malheureusement il n'est pas garanti qu'elle soit disponible. Elle
nécessite que le format de l'image texture supporte ce type de filtrage, ce que nous pouvons vérifier avec la fonction
vkGetPhysicalDeviceFormatProperties
. Nous allons vérifier sa disponibilité dans generateMipmaps
.
Ajoutez d'abord un paramètre qui indique le format de l'image :
void createTextureImage() {
...
generateMipmaps(textureImage, VK_FORMAT_R8G8B8A8_SRGB, texWidth, texHeight, mipLevels);
}
void generateMipmaps(VkImage image, VkFormat imageFormat, int32_t texWidth, int32_t texHeight, uint32_t mipLevels) {
...
}
Utilisez vkGetPhysicalDeviceFormatProperties
dans generateMipmaps
pour récupérer les propriétés liés au format :
void generateMipmaps(VkImage image, VkFormat imageFormat, int32_t texWidth, int32_t texHeight, uint32_t mipLevels) {
// Vérifions si l'image supporte le filtrage linéaire
VkFormatProperties formatProperties;
vkGetPhysicalDeviceFormatProperties(physicalDevice, imageFormat, &formatProperties);
...
La structure VkFormatProperties
possède les trois champs linearTilingFeatures
, optimalTilingFeature
et
bufferFeaetures
. Ils décrivent chacun l'utilisation possible d'images de ce format dans certains contextes. Nous avons
créé l'image avec le format optimal, les informations qui nous concernent sont donc dans optimalTilingFeatures
. Le
support pour le filtrage linéaire est ensuite indiqué par VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT
.
if (!(formatProperties.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT)) {
throw std::runtime_error("le format de l'image texture ne supporte pas le filtrage lineaire!");
}
Il y a deux alternatives si le format ne permet pas l'utilisation de vkCmdBlitImage
. Vous pouvez créer une fonction
pour essayer de trouver un format supportant la commande, ou vous pouvez utiliser une librairie pour générer les
mipmaps comme stb_image_resize. Chaque niveau de
mipmap peut ensuite être chargé de la même manière que vous avez chargé l'image.
Souvenez-vous qu'il est rare de générer les mipmaps pendant l'exécution. Elles sont généralement prégénérées et stockées dans le fichier avec l'image de base. Le chargement de mipmaps prégénérées est laissé comme exercice au lecteur.
Sampler
Un objet VkImage
contient les données de l'image et un objet VkSampler
contrôle la lecture des données pendant le
rendu. Vulkan nous permet de spécifier les valeurs minLod
, maxLod
, mipLodBias
et mipmapMode
, où "Lod" signifie
level of detail (niveau de détail). Pendant l'échantillonnage d'une texture, le sampler sélectionne le niveau de
mipmap à utiliser suivant ce pseudo-code :
lod = getLodLevelFromScreenSize(); //plus petit quand l'objet est proche, peut être negatif
lod = clamp(lod + mipLodBias, minLod, maxLod);
level = clamp(floor(lod), 0, texture.mipLevels - 1); //limité par le nombre de niveaux de mipmaps dans le texture
if (mipmapMode == VK_SAMPLER_MIPMAP_MODE_NEAREST) {
color = sample(level);
} else {
color = blend(sample(level), sample(level + 1));
}
Si samplerInfo.mipmapMode
est VK_SAMPLER_MIPMAP_MODE_NEAREST
, la variable lod
correspond au niveau de mipmap à
échantillonner. Sinon, si il vaut VK_SAMPLER_MIPMAP_MODE_LINEAR
, deux niveaux de mipmaps sont samplés, puis interpolés
linéairement.
L'opération d'échantillonnage est aussi affectée par lod
:
if (lod <= 0) {
color = readTexture(uv, magFilter);
} else {
color = readTexture(uv, minFilter);
}
Si l'objet est proche de la caméra, magFilter
est utilisé comme filtre. Si l'objet est plus distant, minFilter
sera
utilisé. Normalement lod
est positif, est devient nul au niveau de la caméra. mipLodBias
permet de forcer Vulkan à
utiliser un lod
plus petit et donc un noveau de mipmap plus élevé.
Pour voir les résultats de ce chapitre, nous devons choisir les valeurs pour textureSampler
. Nous avons déjà fourni
minFilter
et magFilter
. Il nous reste les valeurs minLod
, maxLod
, mipLodBias
et mipmapMode
.
void createTextureSampler() {
...
samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
samplerInfo.minLod = 0.0f;
samplerInfo.maxLod = static_cast<float>(mipLevels);
samplerInfo.mipLodBias = 0.0f; // Optionnel
...
}
Pour utiliser la totalité des niveaux de mipmaps, nous mettons minLod
à 0.0f
et maxLod
au nombre de niveaux de
mipmaps. Nous n'avons aucune raison d'altérer lod
avec mipLodBias
, alors nous pouvons le mettre à 0.0f
.
Lancez votre programme et vous devriez voir ceci :
Notre scène est si simple qu'il n'y a pas de différence majeure. En comparant précisement on peut voir quelques différences.
La différence la plus évidente est l'écriture sur le paneau, plus lisse avec les mipmaps.
Vous pouvez modifier les paramètres du sampler pour voir l'impact sur le rendu. Par exemple vous pouvez empêcher le sampler d'utiliser le plus haut nivau de mipmap en ne lui indiquant pas le niveau le plus bas :
samplerInfo.minLod = static_cast<float>(mipLevels / 2);
Ce paramètre produira ce rendu :