Introduction

Jusqu'à présent nous avons projeté notre géométrie en 3D, mais elle n'est toujours définie qu'en 2D. Nous allons ajouter l'axe Z dans ce chapitre pour permettre l'utilisation de modèles 3D. Nous placerons un carré au-dessus ce celui que nous avons déjà, et nous verrons ce qui se passe si la géométrie n'est pas organisée par profondeur.

Géométrie en 3D

Mettez à jour la structure Vertex pour que les coordonnées soient des vecteurs à 3 dimensions. Il faut également changer le champ format dans la structure VkVertexInputAttributeDescription correspondant aux coordonnées :

struct Vertex {
    glm::vec3 pos;
    glm::vec3 color;
    glm::vec2 texCoord;

    ...

    static std::array<VkVertexInputAttributeDescription, 3> getAttributeDescriptions() {
        std::array<VkVertexInputAttributeDescription, 3> attributeDescriptions{};

        attributeDescriptions[0].binding = 0;
        attributeDescriptions[0].location = 0;
        attributeDescriptions[0].format = VK_FORMAT_R32G32B32_SFLOAT;
        attributeDescriptions[0].offset = offsetof(Vertex, pos);

        ...
    }
};

Mettez également à jour l'entrée du vertex shader qui correspond aux coordonnées. Recompilez le shader.

layout(location = 0) in vec3 inPosition;

...

void main() {
    gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 1.0);
    fragColor = inColor;
    fragTexCoord = inTexCoord;
}

Enfin, il nous faut ajouter la profondeur là où nous créons les instances de Vertex.

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

Si vous lancez l'application vous verrez exactement le même résultat. Il est maintenant temps d'ajouter de la géométrie pour rendre la scène plus intéressante, et pour montrer le problème évoqué plus haut. Dupliquez les vertices afin qu'un second carré soit rendu au-dessus de celui que nous avons maintenant :

Nous allons utiliser -0.5f comme coordonnée Z.

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

    {{-0.5f, -0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}, {0.0f, 0.0f}},
    {{0.5f, -0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}, {1.0f, 0.0f}},
    {{0.5f, 0.5f, -0.5f}, {0.0f, 0.0f, 1.0f}, {1.0f, 1.0f}},
    {{-0.5f, 0.5f, -0.5f}, {1.0f, 1.0f, 1.0f}, {0.0f, 1.0f}}
};

const std::vector<uint16_t> indices = {
    0, 1, 2, 2, 3, 0,
    4, 5, 6, 6, 7, 4
};

Si vous lancez le programme maintenant vous verrez que le carré d'en-dessous est rendu au-dessus de l'autre :

Ce problème est simplement dû au fait que le carré d'en-dessous est placé après dans le tableau des vertices. Il y a deux manières de régler ce problème :

  • Trier tous les appels en fonction de la profondeur
  • Utiliser un buffer de profondeur

La première approche est communément utilisée pour l'affichage d'objets transparents, car la transparence non ordonnée est un problème difficile à résoudre. Cependant, pour la géométrie sans transparence, le buffer de profondeur est un très bonne solution. Il consiste en un attachement supplémentaire au framebuffer, qui stocke les profondeurs. La profondeur de chaque fragment produit par le rasterizer est comparée à la valeur déjà présente dans le buffer. Si le fragment est plus distant que celui déjà traité, il est simplement éliminé. Il est possible de manipuler cette valeur de la même manière que la couleur.

#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>

La matrice de perspective générée par GLM utilise par défaut la profondeur OpenGL comprise en -1 et 1. Nous pouvons configurer GLM avec GLM_FORCE_DEPTH_ZERO_TO_ONE pour qu'elle utilise des valeurs correspondant à Vulkan.

Image de pronfondeur et views sur cette image

L'attachement de profondeur est une image. La différence est que celle-ci n'est pas créée par la swap chain. Nous n'avons besoin que d'un seul attachement de profondeur, car les opérations sont séquentielles. L'attachement aura encore besoin des trois mêmes ressources : une image, de la mémoire et une image view.

VkImage depthImage;
VkDeviceMemory depthImageMemory;
VkImageView depthImageView;

Créez une nouvelle fonction createDepthResources pour mettre en place ces ressources :

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

...

void createDepthResources() {

}

La création d'une image de profondeur est assez simple. Elle doit avoir la même résolution que l'attachement de couleur, définie par l'étendue de la swap chain. Elle doit aussi être configurée comme image de profondeur, avoir un tiling optimal et une mémoire placée sur la carte graphique. Une question persiste : quelle est l'organisation optimale pour une image de profondeur? Le format contient un composant de profondeur, indiqué par _Dxx_ dans les valeurs de type VK_FORMAT.

Au contraire de l'image de texture, nous n'avons pas besoin de déterminer le format requis car nous n'accéderons pas à cette texture nous-mêmes. Nous n'avons besoin que d'une précision suffisante, en général un minimum de 24 bits. Il y a plusieurs formats qui satisfont cette nécéssité :

  • VK_FORMAT_D32_SFLOAT : float signé de 32 bits pour la profondeur
  • VK_FORMAT_D32_SFLOAT_S8_UINT : float signé de 32 bits pour la profondeur et int non signé de 8 bits pour le stencil
  • VK_FORMAT_D24_UNORM_S8_UINT : float signé de 24 bits pour la profondeur et int non signé de 8 bits pour le stencil

Le composant de stencil est utilisé pour le test de stencil. C'est un test additionnel qui peut être combiné avec le test de profondeur. Nous y reviendrons dans un futur chapitre.

Nous pourrions nous contenter d'utiliser VK_FORMAT_D32_SFLOAT car son support est pratiquement assuré, mais il est préférable d'utiliser une fonction pour déterminer le meilleur format localement supporté. Créez pour cela la fonction findSupportedFormat. Elle vérifiera que les formats en argument sont supportés et choisira le meilleur en se basant sur leur ordre dans le vecteurs des formats acceptables fourni en argument :

VkFormat findSupportedFormat(const std::vector<VkFormat>& candidates, VkImageTiling tiling, VkFormatFeatureFlags features) {

}

Leur support dépend du mode de tiling et de l'usage, nous devons donc les transmettre en argument. Le support des formats peut ensuite être demandé à l'aide de la fonction vkGetPhysicalDeviceFormatProperties :

for (VkFormat format : candidates) {
    VkFormatProperties props;
    vkGetPhysicalDeviceFormatProperties(physicalDevice, format, &props);
}

La structure VkFormatProperties contient trois champs :

  • linearTilingFeatures : utilisations supportées avec le tiling linéaire
  • optimalTilingFeatures : utilisations supportées avec le tiling optimal
  • bufferFeatures : utilisations supportées avec les buffers

Seuls les deux premiers cas nous intéressent ici, et celui que nous vérifierons dépendra du mode de tiling fourni en paramètre.

if (tiling == VK_IMAGE_TILING_LINEAR && (props.linearTilingFeatures & features) == features) {
    return format;
} else if (tiling == VK_IMAGE_TILING_OPTIMAL && (props.optimalTilingFeatures & features) == features) {
    return format;
}

Si aucun des candidats ne supporte l'utilisation désirée, nous pouvons lever une exception.

VkFormat findSupportedFormat(const std::vector<VkFormat>& candidates, VkImageTiling tiling, VkFormatFeatureFlags features) {
    for (VkFormat format : candidates) {
        VkFormatProperties props;
        vkGetPhysicalDeviceFormatProperties(physicalDevice, format, &props);

        if (tiling == VK_IMAGE_TILING_LINEAR && (props.linearTilingFeatures & features) == features) {
            return format;
        } else if (tiling == VK_IMAGE_TILING_OPTIMAL && (props.optimalTilingFeatures & features) == features) {
            return format;
        }
    }

    throw std::runtime_error("aucun des formats demandés n'est supporté!");
}

Nous allons utiliser cette fonction depuis une autre fonction findDepthFormat. Elle sélectionnera un format avec un composant de profondeur qui supporte d'être un attachement de profondeur :

VkFormat findDepthFormat() {
    return findSupportedFormat(
        {VK_FORMAT_D32_SFLOAT, VK_FORMAT_D32_SFLOAT_S8_UINT, VK_FORMAT_D24_UNORM_S8_UINT},
        VK_IMAGE_TILING_OPTIMAL,
        VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT
    );
}

Utilisez bien VK_FORMAT_FEATURE_ au lieu de VK_IMAGE_USAGE_. Tous les candidats contiennent la profondeur, mais certains ont le stencil en plus. Ainsi il est important de voir que dans ce cas, la profondeur n'est qu'une capacité et non un usage exclusif. Autre point, nous devons prendre cela en compte pour les transitions d'organisation. Ajoutez une fonction pour determiner si le format contient un composant de stencil ou non :

bool hasStencilComponent(VkFormat format) {
    return format == VK_FORMAT_D32_SFLOAT_S8_UINT || format == VK_FORMAT_D24_UNORM_S8_UINT;
}

Appelez cette fonction depuis createDepthResources pour déterminer le format de profondeur :

VkFormat depthFormat = findDepthFormat();

Nous avons maintenant toutes les informations nécessaires pour invoquer createImage et createImageView.

createImage(swapChainExtent.width, swapChainExtent.height, depthFormat, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, depthImage, depthImageMemory);
depthImageView = createImageView(depthImage, depthFormat);

Cependant cette fonction part du principe que la subresource est toujours VK_IMAGE_ASPECT_COLOR_BIT, il nous faut donc en faire un paramètre.

VkImageView createImageView(VkImage image, VkFormat format, VkImageAspectFlags aspectFlags) {
    ...
    viewInfo.subresourceRange.aspectMask = aspectFlags;
    ...
}

Changez également les appels à cette fonction pour prendre en compte ce changement :

swapChainImageViews[i] = createImageView(swapChainImages[i], swapChainImageFormat, VK_IMAGE_ASPECT_COLOR_BIT);
...
depthImageView = createImageView(depthImage, depthFormat, VK_IMAGE_ASPECT_DEPTH_BIT);
...
textureImageView = createImageView(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_ASPECT_COLOR_BIT);

Voilà tout pour la création de l'image de profondeur. Nous n'avons pas besoin d'y envoyer de données ou quoi que ce soit de ce genre, car nous allons l'initialiser au début de la render pass tout comme l'attachement de couleur.

Explicitement transitionner l'image de profondeur

Nous n'avons pas besoin de faire explicitement la transition du layout de l'image vers un attachement de profondeur parce qu'on s'en occupe directement dans la render pass. En revanche, pour l'exhaustivité je vais quand même vous décrire le processus dans cette section. Vous pouvez sauter cette étape si vous le souhaitez.

Faites un appel à transitionImageLayout à la fin de createDepthResources comme ceci:

transitionImageLayout(depthImage, depthFormat, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL);

L'organisation indéfinie peut être utilisée comme organisation intiale, dans la mesure où aucun contenu d'origine n'a d'importance. Nous devons faire évaluer la logique de transitionImageLayout pour qu'elle puisse utiliser la bonne subresource.

if (newLayout == VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL) {
    barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;

    if (hasStencilComponent(format)) {
        barrier.subresourceRange.aspectMask |= VK_IMAGE_ASPECT_STENCIL_BIT;
    }
} else {
    barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
}

Même si nous n'utilisons pas le composant de stencil, nous devons nous en occuper dans les transitions de l'image de profondeur.

Ajoutez enfin le bon accès et les bonnes étapes pipeline :

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 if (oldLayout == VK_IMAGE_LAYOUT_UNDEFINED && newLayout == VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL) {
    barrier.srcAccessMask = 0;
    barrier.dstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;

    sourceStage = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
    destinationStage = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT;
} else {
    throw std::invalid_argument("transition d'organisation non supportée!");
}

Le buffer de profondeur sera lu avant d'écrire un fragment, et écrit après qu'un fragment valide soit traité. La lecture se passe en VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT et l'écriture en VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT. Vous devriez choisir la première des étapes correspondant à l'opération correspondante, afin que tout soit prêt pour l'utilisation de l'attachement de profondeur.

Render pass

Nous allons modifier createRenderPass pour inclure l'attachement de profondeur. Spécifiez d'abord un VkAttachementDescription :

VkAttachmentDescription depthAttachment{};
depthAttachment.format = findDepthFormat();
depthAttachment.samples = VK_SAMPLE_COUNT_1_BIT;
depthAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
depthAttachment.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
depthAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
depthAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
depthAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
depthAttachment.finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;

Le format doit être celui de l'image de profondeur. Pour cette fois nous ne garderons pas les données de profondeur, car nous n'en avons plus besoin après le rendu. Encore une fois le hardware pourra réaliser des optimisations. Et de même nous n'avons pas besoin des valeurs du rendu précédent pour le début du rendu de la frame, nous pouvons donc mettre VK_IMAGE_LAYOUT_UNDEFINED comme valeur pour initialLayout.

VkAttachmentReference depthAttachmentRef{};
depthAttachmentRef.attachment = 1;
depthAttachmentRef.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;

Ajoutez une référence à l'attachement dans notre seule et unique subpasse :

VkSubpassDescription subpass{};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpass.colorAttachmentCount = 1;
subpass.pColorAttachments = &colorAttachmentRef;
subpass.pDepthStencilAttachment = &depthAttachmentRef;

Les subpasses ne peuvent utiliser qu'un seul attachement de profondeur (et de stencil). Réaliser le test de profondeur sur plusieurs buffers n'a de toute façon pas beaucoup de sens.

std::array<VkAttachmentDescription, 2> attachments = {colorAttachment, depthAttachment};
VkRenderPassCreateInfo renderPassInfo{};
renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
renderPassInfo.attachmentCount = static_cast<uint32_t>(attachments.size());
renderPassInfo.pAttachments = attachments.data();
renderPassInfo.subpassCount = 1;
renderPassInfo.pSubpasses = &subpass;
renderPassInfo.dependencyCount = 1;
renderPassInfo.pDependencies = &dependency;

Changez enfin la structure VkRenderPassCreateInfo pour qu'elle se réfère aux deux attachements.

Framebuffer

L'étape suivante va consister à modifier la création du framebuffer pour lier notre image de profondeur à l'attachement de profondeur. Trouvez createFramebuffers et indiquez la view sur l'image de profondeur comme second attachement :

std::array<VkImageView, 2> attachments = {
    swapChainImageViews[i],
    depthImageView
};

VkFramebufferCreateInfo framebufferInfo{};
framebufferInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
framebufferInfo.renderPass = renderPass;
framebufferInfo.attachmentCount = static_cast<uint32_t>(attachments.size());
framebufferInfo.pAttachments = attachments.data();
framebufferInfo.width = swapChainExtent.width;
framebufferInfo.height = swapChainExtent.height;
framebufferInfo.layers = 1;

L'attachement de couleur doit différer pour chaque image de la swap chain, mais l'attachement de profondeur peut être le même pour toutes, car il n'est utilisé que par la subpasse, et la synchronisation que nous avons mise en place ne permet pas l'exécution de plusieurs subpasses en même temps.

Nous devons également déplacer l'appel à createFramebuffers pour que la fonction ne soit appelée qu'après la création de l'image de profondeur :

void initVulkan() {
    ...
    createDepthResources();
    createFramebuffers();
    ...
}

Supprimer les valeurs

Comme nous avons plusieurs attachements avec VK_ATTACHMENT_LOAD_OP_CLEAR, nous devons spécifier plusieurs valeurs de suppression. Allez à createCommandBuffers et créez un tableau de VkClearValue :

std::array<VkClearValue, 2> clearValues{};
clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
clearValues[1].depthStencil = {1.0f, 0};

renderPassInfo.clearValueCount = static_cast<uint32_t>(clearValues.size());
renderPassInfo.pClearValues = clearValues.data();

Avec Vulkan, 0.0 correspond au plan near et 1.0 au plan far. La valeur initiale doit donc être 1.0, afin que tout fragment puisse s'y afficher. Notez que l'ordre des clearValues correspond à l'ordre des attachements auquelles les couleurs correspondent.

État de profondeur et de stencil

L'attachement de profondeur est prêt à être utilisé, mais le test de profondeur n'a pas encore été activé. Il est configuré à l'aide d'une structure de type VkPipelineDepthStencilStateCreateInfo.

VkPipelineDepthStencilStateCreateInfo depthStencil{};
depthStencil.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
depthStencil.depthTestEnable = VK_TRUE;
depthStencil.depthWriteEnable = VK_TRUE;

Le champ depthTestEnable permet d'activer la comparaison de la profondeur des fragments. Le champ depthWriteEnable indique si la nouvelle profondeur des fragments qui passent le test doivent être écrite dans le tampon de profondeur.

depthStencil.depthCompareOp = VK_COMPARE_OP_LESS;

Le champ depthCompareOp permet de fournir le test de comparaison utilisé pour conserver ou éliminer les fragments. Nous gardons le < car il correspond le mieux à la convention employée par Vulkan.

depthStencil.depthBoundsTestEnable = VK_FALSE;
depthStencil.minDepthBounds = 0.0f; // Optionnel
depthStencil.maxDepthBounds = 1.0f; // Optionnel

Les champs depthBoundsTestEnable, minDepthBounds et maxDepthBounds sont utilisés pour des tests optionnels d'encadrement de profondeur. Ils permettent de ne garder que des fragments dont la profondeur est comprise entre deux valeurs fournies ici. Nous n'utiliserons pas cette fonctionnalité.

depthStencil.stencilTestEnable = VK_FALSE;
depthStencil.front = {}; // Optionnel
depthStencil.back = {}; // Optionnel

Les trois derniers champs configurent les opérations du buffer de stencil, que nous n'utiliserons pas non plus dans ce tutoriel. Si vous voulez l'utiliser, vous devrez vous assurer que le format sélectionné pour la profondeur contient aussi un composant pour le stencil.

pipelineInfo.pDepthStencilState = &depthStencil;

Mettez à jour la création d'une instance de VkGraphicsPipelineCreateInfo pour référencer l'état de profondeur et de stencil que nous venons de créer. Un tel état doit être spécifié si la passe contient au moins l'une de ces fonctionnalités.

Si vous lancez le programme, vous verrez que la géométrie est maintenant correctement rendue :

Gestion des redimensionnements de la fenêtre

La résolution du buffer de profondeur doit changer avec la fenêtre quand elle redimensionnée, pour pouvoir correspondre à la taille de l'attachement. Étendez recreateSwapChain pour régénérer les ressources :

void recreateSwapChain() {
    int width = 0, height = 0;
    while (width == 0 || height == 0) {
        glfwGetFramebufferSize(window, &width, &height);
        glfwWaitEvents();
    }
    
    vkDeviceWaitIdle(device);

    cleanupSwapChain();

    createSwapChain();
    createImageViews();
    createRenderPass();
    createGraphicsPipeline();
    createDepthResources();
    createFramebuffers();
    createUniformBuffers();
    createDescriptorPool();
    createDescriptorSets();
    createCommandBuffers();
}

La libération des ressources doit avoir lieu dans la fonction de libération de la swap chain.

void cleanupSwapChain() {
    vkDestroyImageView(device, depthImageView, nullptr);
    vkDestroyImage(device, depthImage, nullptr);
    vkFreeMemory(device, depthImageMemory, nullptr);

    ...
}

Votre application est maintenant capable de rendre correctement de la géométrie 3D! Nous allons utiliser cette fonctionnalité pour afficher un modèle dans le prohain chapitre.

Code C++ / Vertex shader / Fragment shader