XI. Multiéchantillonnage▲
XI-A. Introduction▲
Notre programme peut maintenant générer plusieurs niveaux de détails pour les textures et ainsi supprimer quelques artefacts lors du rendu d’objets lointains. L’image est plus nette, mais si vous faites attention, vous remarquerez des motifs en dents de scie sur les bordures des objets dessinés. C’est d’autant plus vrai dans nos premiers programmes où nous affichions un carré :
Cet effet indésirable s'appelle crénelage (aliasing). Il est la conséquence du nombre limité de pixels lors du rendu. Comme il n’existe aucun écran ayant une résolution illimitée, il sera toujours visible. Il existe plusieurs méthodes pour corriger cela et ce chapitre se concentrera sur l’une des plus populaires : l’anticrénelage par multiéchantillonnage (multisample anti-aliasing (MSAA)).
Dans un rendu classique, la couleur d'un pixel est déterminée à partir d'un unique échantillon, en général le centre du pixel. Si une ligne passe à travers certains pixels sans couvrir le point utilisé par l’échantillon, alors le pixel restera blanc, provoquant cet effet d’escalier.
Le MSAA consiste à utiliser plusieurs points pour échantillonner un pixel et ainsi déterminer sa couleur. Comme on peut s'y attendre, plus on utilise de points, meilleur est le résultat, mais cela consomme aussi plus de ressources.
Pour notre implémentation, nous allons utiliser le maximum de points possible. Selon votre application, cela peut ne pas être la meilleure approche et il peut être judicieux d’utiliser moins d’échantillons afin d’obtenir de meilleures performances.
XI-B. Récupération du nombre maximal d’échantillons▲
Commençons par déterminer le nombre maximal d’échantillons supporté par la carte graphique. Les cartes graphiques modernes supportent au moins huit échantillons, mais ce nombre n’est pas une norme. Nous allons stocker ce nombre dans une variable membre :
...
VkSampleCountFlagBits msaaSamples =
VK_SAMPLE_COUNT_1_BIT;
...
Par défaut nous n'utilisons qu'un échantillon par pixel, ce qui correspond à ne pas utiliser de multiéchantillonnage. Dans un tel cas, l’image finale ne sera pas impactée. Le nombre d’échantillons maximum supporté par la carte peut être obtenu à partir de la structure de type VkPhysicalDeviceProperties associée au périphérique choisi. Nous utilisons aussi un tampon de profondeur. Nous devons donc prendre en compte le nombre d’échantillons pour la couleur et pour la profondeur. Le plus haut nombre d’échantillons supporté par les deux (&) sera le maximum supporté. Ajoutez une fonction pour récupérer cette information :
VkSampleCountFlagBits getMaxUsableSampleCount() {
VkPhysicalDeviceProperties physicalDeviceProperties;
vkGetPhysicalDeviceProperties(physicalDevice, &
physicalDeviceProperties);
VkSampleCountFlags counts =
physicalDeviceProperties.limits.framebufferColorSampleCounts &
physicalDeviceProperties.limits.framebufferDepthSampleCounts;
if
(counts &
VK_SAMPLE_COUNT_64_BIT) {
return
VK_SAMPLE_COUNT_64_BIT; }
if
(counts &
VK_SAMPLE_COUNT_32_BIT) {
return
VK_SAMPLE_COUNT_32_BIT; }
if
(counts &
VK_SAMPLE_COUNT_16_BIT) {
return
VK_SAMPLE_COUNT_16_BIT; }
if
(counts &
VK_SAMPLE_COUNT_8_BIT) {
return
VK_SAMPLE_COUNT_8_BIT; }
if
(counts &
VK_SAMPLE_COUNT_4_BIT) {
return
VK_SAMPLE_COUNT_4_BIT; }
if
(counts &
VK_SAMPLE_COUNT_2_BIT) {
return
VK_SAMPLE_COUNT_2_BIT; }
return
VK_SAMPLE_COUNT_1_BIT;
}
Nous allons utiliser cette fonction pour définir la variable msaaSamples pendant le processus de la sélection du GPU. Nous devons modifier la fonction pickPhysicalDevice() :
void
pickPhysicalDevice() {
...
for
(const
auto
&
device : devices) {
if
(isDeviceSuitable(device)) {
physicalDevice =
device;
msaaSamples =
getMaxUsableSampleCount();
break
;
}
}
...
}
XI-C. Configurer une cible de rendu▲
Avec le MSAA, chaque pixel est échantillonné à partir d’un tampon hors écran qui est ensuite affiché à l’écran. Ce nouveau tampon est légèrement différent des images sur lesquelles nous avons dessiné jusqu’à présent : il a la possibilité de stocker plus d’un échantillon par pixel. Une fois le tampon multiéchantillonné créé, il doit être utilisé pour déterminer le tampon d’image classique (qui stocke uniquement un échantillon par pixel). C’est pourquoi nous devons créer une cible de rendu supplémentaire et modifier le processus de rendu. Nous n'avons besoin que d’une cible de rendu, car seule une opération de rendu s’effectue à la fois, tout comme pour le tampon de profondeur. Ajoutez les variables membres suivantes :
...
VkImage colorImage;
VkDeviceMemory colorImageMemory;
VkImageView colorImageView;
...
Cette nouvelle image doit pouvoir stocker le nombre d’échantillons voulus par pixel. Nous devons donc passer ce nombre à la structure VkImageCreateInfo lors de sa création. Modifiez la fonction createImage() pour ajouter le paramètre numSamples :
void
createImage(uint32_t width, uint32_t height, uint32_t mipLevels, VkSampleCountFlagBits numSamples, VkFormat format, VkImageTiling tiling, VkImageUsageFlags usage, VkMemoryPropertyFlags properties, VkImage&
image, VkDeviceMemory&
imageMemory) {
...
imageInfo.samples =
numSamples;
...
Pour le moment, mettez à jour tous les appels à cette fonction avec VK_SAMPLE_COUNT_1_BIT. Nous utiliserons la valeur adéquate par la suite.
createImage(swapChainExtent.width, swapChainExtent.height, 1
, VK_SAMPLE_COUNT_1_BIT, 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_SAMPLE_COUNT_1_BIT, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
VK_IMAGE_USAGE_TRANSFER_DST_BIT |
VK_IMAGE_USAGE_SAMPLED_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, textureImage, textureImageMemory);
Nous allons maintenant créer un tampon de couleurs multiéchantillonné. Ajoutez une fonction nommée createColorResources et notez que nous passons la variable msaaSamples à la fonction createImage(). Nous n'utilisons qu'un seul niveau de mipmap, ce qui est forcé par la spécification de Vulkan pour les images multiéchantillonnées. De plus, ce tampon de couleurs n’a pas besoin de mipmap, car elle n’est pas utilisée comme texture.
void
createColorResources() {
VkFormat colorFormat =
swapChainImageFormat;
createImage(swapChainExtent.width, swapChainExtent.height, 1
, msaaSamples, colorFormat, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT |
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, colorImage, colorImageMemory);
colorImageView =
createImageView(colorImage, colorFormat, VK_IMAGE_ASPECT_COLOR_BIT, 1
);
}
Pour une question de cohérence, nous appelons cette fonction juste avant la fonction createDepthResource() :
void
initVulkan() {
...
createColorResources();
createDepthResources();
...
}
Nous avons maintenant un tampon de couleurs multiéchantillonné. Occupons-nous de la profondeur. Modifiez la fonction createDepthResources() et mettez à jour le nombre d’échantillons utilisés :
void
createDepthResources() {
...
createImage(swapChainExtent.width, swapChainExtent.height, 1
, msaaSamples, depthFormat, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, depthImage, depthImageMemory);
...
}
Comme nous avons créé de nouvelles ressources, nous devons les libérer :
void
cleanupSwapChain() {
vkDestroyImageView(device, colorImageView, nullptr
);
vkDestroyImage(device, colorImage, nullptr
);
vkFreeMemory(device, colorImageMemory, nullptr
);
...
}
Mettez également à jour la fonction recreateSwapChain() afin de reconstruire une nouvelle image pour les couleurs avec la bonne résolution lorsque la fenêtre est redimensionnée :
void
recreateSwapChain() {
...
createGraphicsPipeline();
createColorResources();
createDepthResources();
...
}
Nous avons fini le paramétrage initial du MSAA. Nous devons maintenant utiliser ces nouvelles ressources dans le pipeline, le tampon d’images et la passe de rendu !
XI-D. Ajout de nouvelles attaches▲
Commençons par la passe de rendu. Modifiez la fonction createRenderPass() et mettez à jour les attaches de couleur et de profondeur dans la structure de création de la passe de rendu :
void
createRenderPass() {
...
colorAttachment.samples =
msaaSamples;
colorAttachment.finalLayout =
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
...
depthAttachment.samples =
msaaSamples;
...
Remarquez que nous avons changé l'agencement final (finalLayout) de VK_IMAGE_LAYOUT_PRESENT_SRC_KHR pour VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL. En effet, les images multiéchantillonnées ne peuvent être directement présentées. Nous devons les convertir en une image plus classique. Cette contrainte ne s’applique pas au tampon de profondeur, car nous ne l’affichons dans aucun cas. Par conséquent, nous devons ajouter une unique attache pour les couleurs que nous appelons attache de conversion :
...
VkAttachmentDescription colorAttachmentResolve{}
;
colorAttachmentResolve.format =
swapChainImageFormat;
colorAttachmentResolve.samples =
VK_SAMPLE_COUNT_1_BIT;
colorAttachmentResolve.loadOp =
VK_ATTACHMENT_LOAD_OP_DONT_CARE;
colorAttachmentResolve.storeOp =
VK_ATTACHMENT_STORE_OP_STORE;
colorAttachmentResolve.stencilLoadOp =
VK_ATTACHMENT_LOAD_OP_DONT_CARE;
colorAttachmentResolve.stencilStoreOp =
VK_ATTACHMENT_STORE_OP_DONT_CARE;
colorAttachmentResolve.initialLayout =
VK_IMAGE_LAYOUT_UNDEFINED;
colorAttachmentResolve.finalLayout =
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
...
La passe de rendu doit maintenant être configurée pour convertir l’image multiéchantillonnée en attache classique. Créez une nouvelle référence d’attache pointant sur le tampon de couleurs qui nous servira de destination :
...
VkAttachmentReference colorAttachmentResolveRef{}
;
colorAttachmentResolveRef.attachment =
2
;
colorAttachmentResolveRef.layout =
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
...
Modifiez la variable membre de la structure de sous-passe pResolveAttachments pour indiquer la nouvelle référence d’attache. C’est suffisant pour que la passe de rendu effectue une conversion, ce qui nous permet d’afficher l’image à l’écran :
...
subpass.pResolveAttachments = &colorAttachmentResolveRef;
...
Fournissez ensuite la nouvelle attache de couleurs à la structure de création de la passe de rendu.
...
std::
array<
VkAttachmentDescription, 3
>
attachments =
{
colorAttachment, depthAttachment, colorAttachmentResolve}
;
...
Modifiez ensuite la fonction createFramebuffer() et ajoutez la nouvelle vue d’image à la liste :
void
createFrameBuffers() {
...
std::
array<
VkImageView, 3
>
attachments =
{
colorImageView,
depthImageView,
swapChainImageViews[i]
}
;
...
}
Enfin, il ne reste plus qu’à indiquer (dans la fonction createGraphicsPipeline()) au nouveau pipeline d’utiliser plusieurs échantillons :
void
createGraphicsPipeline() {
...
multisampling.rasterizationSamples =
msaaSamples;
...
}
Lancez votre programme et vous devriez voir ceci :
Comme pour le mipmapping, la différence n'est pas immédiatement visible. En regardant de plus près, vous remarquerez que les bordures sont moins crénelées et que l’image est plus lisse qu’avant.
La différence est encore plus visible en zoomant sur un bord :
XI-E. Amélioration de la qualité▲
Notre implémentation du MSAA est limitée et cela peut impacter la qualité de l’image affichée dans des scènes plus détaillées. Par exemple, nous ne corrigeons pas les problèmes potentiels liés au crénelage causé par les shaders : c’est-à-dire que le MSAA n’adoucit que les bordures de la géométrie, mais pas son remplissage. Cela est marquant, lorsque vous affichez un polygone lisse alors que la texture le remplissant sera crénelée, car elle contient des hauts contrastes. Une façon de résoudre ce problème est d’activer l’échantillonnage des fragments (sample shading), qui améliore encore la qualité de l'image au prix de performances encore réduites.
void
createLogicalDevice() {
...
deviceFeatures.sampleRateShading =
VK_TRUE; // active la fonctionnalité d'échantillonnage des fragments pour ce périphérique
...
}
void
createGraphicsPipeline() {
...
multisampling.sampleShadingEnable =
VK_TRUE; // active l'échantillonnage des fragments dans le pipeline
multisampling.minSampleShading =
.2
f; // fraction minimale pour l'échantillonnage des fragments ; plus la valeur est proche de 1, plus le résultat est doux
...
}
Nous n’activons pas l’échantillonnage des fragments dans notre application, mais dans certains cas son activation permet une nette amélioration de la qualité du rendu :
XI-F. Conclusion▲
Il nous a fallu beaucoup de travail pour en arriver là, mais vous avez maintenant une bonne connaissance des bases de Vulkan. Ces connaissances vous permettent maintenant d'explorer d'autres fonctionnalités, comme :
- les constantes poussées (push constants) ;
- le rendu instancié ;
- les variables uniformes dynamiques ;
- les descripteurs d'images et d’échantillonneurs séparés ;
- la mise en cache de pipeline ;
- la génération des tampons de commandes depuis plusieurs threads :
- les sous-passes multiples ;
- les shaders de calcul (compute shaders).
Le programme actuel peut être grandement étendu, par exemple en ajoutant l'éclairage Blinn-Phong, des effets de post-traitement et l’application des ombres. Vous devriez pouvoir apprendre ces techniques depuis des tutoriels conçus pour d'autres bibliothèques, car ces algorithmes fonctionnent de la même façon, quelle que soit la bibliothèque.