Les anciens APIs définissaient des configurations par défaut pour toutes les étapes à fonction fixée de la pipeline graphique. Avec Vulkan vous devez être explicite dans ce domaine également et devrez donc configurer la fonction de mélange par exemple. Dans ce chapitre nous remplirons toutes les structures nécessaires à la configuration des étapes à fonction fixée.

Entrée des sommets

La structure VkPipelineVertexInputStateCreateInfo décrit le format des sommets envoyés au vertex shader. Elle fait cela de deux manières :

  • Liens (bindings) : espace entre les données et information sur ces données; sont-elles par sommet ou par instance? (voyez l'instanciation)
  • Descriptions d'attributs : types d'attributs passés au vertex shader, de quels bindings les charger et avec quel décalage entre eux.

Dans la mesure où nous avons écrit les coordonnées directement dans le vertex shader, nous remplirons cette structure en indiquant qu'il n'y a aucune donnée à charger. Nous y reviendrons dans le chapitre sur les vertex buffers.

VkPipelineVertexInputStateCreateInfo vertexInputInfo{};
vertexInputInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
vertexInputInfo.vertexBindingDescriptionCount = 0;
vertexInputInfo.pVertexBindingDescriptions = nullptr; // Optionnel
vertexInputInfo.vertexAttributeDescriptionCount = 0;
vertexInputInfo.pVertexAttributeDescriptions = nullptr; // Optionnel

Les membres pVertexBindingDescriptions et pVertexAttributeDescriptions pointent vers un tableau de structures décrivant les détails du chargement des données des sommets. Ajoutez cette structure à la fonction createGraphicsPipeline juste après le tableau shaderStages.

Input assembly

La structure VkPipelineInputAssemblyStateCreateInfo décrit la nature de la géométrie voulue quand les sommets sont reliés, et permet d'activer ou non la réévaluation des vertices. La première information est décrite dans le membre topology et peut prendre ces valeurs :

  • VK_PRIMITIVE_TOPOLOGY_POINT_LIST : chaque sommet est un point
  • VK_PRIMITIVE_TOPOLOGY_LINE_LIST : dessine une ligne liant deux sommet en n'utilisant ces derniers qu'une seule fois
  • VK_PRIMITIVE_TOPOLOGY_LINE_STRIP : le dernier sommet de chaque ligne est utilisée comme premier sommet pour la ligne suivante
  • VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST : dessine un triangle en utilisant trois sommets, sans en réutiliser pour le triangle suivant
  • VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP : le deuxième et troisième sommets sont utilisées comme les deux premiers pour le triangle suivant

Les sommets sont normalement chargés séquentiellement depuis le vertex buffer. Avec un element buffer vous pouvez cependant choisir vous-même les indices à charger. Vous pouvez ainsi réaliser des optimisations, comme n'utiliser une combinaison de sommet qu'une seule fois au lieu de d'avoir les mêmes données plusieurs fois dans le buffer. Si vous mettez le membre primitiveRestartEnable à la valeur VK_TRUE, il devient alors possible d'interrompre les liaisons des vertices pour les modes _STRIP en utilisant l'index spécial 0xFFFF ou 0xFFFFFFFF.

Nous n'afficherons que des triangles dans ce tutoriel, nous nous contenterons donc de remplir la structure de cette manière :

VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
inputAssembly.primitiveRestartEnable = VK_FALSE;

Viewports et ciseaux

Un viewport décrit simplement la région d'un framebuffer sur laquelle le rendu sera effectué. Il couvrira dans la pratique quasiment toujours la totalité du framebuffer, et ce sera le cas dans ce tutoriel.

VkViewport viewport{};
viewport.x = 0.0f;
viewport.y = 0.0f;
viewport.width = (float) swapChainExtent.width;
viewport.height = (float) swapChainExtent.height;
viewport.minDepth = 0.0f;
viewport.maxDepth = 1.0f;

N'oubliez pas que la taille des images de la swap chain peut différer des macros WIDTH et HEIGHT. Les images de la swap chain seront plus tard les framebuffers sur lesquels la pipeline opérera, ce que nous devons prendre en compte en donnant les dimensions dynamiquement acquises.

Les valeurs minDepth et maxDepth indiquent l'étendue des valeurs de profondeur à utiliser pour le frambuffer. Ces valeurs doivent être dans [0.0f, 1.0f] mais minDepth peut être supérieure à maxDepth. Si vous ne faites rien de particulier contentez-vous des valeurs 0.0f et 1.0f.

Alors que les viewports définissent la transformation de l'image vers le framebuffer, les rectangles de ciseaux définissent la région de pixels qui sera conservée. Tout pixel en dehors des rectangles de ciseaux seront éliminés par le rasterizer. Ils fonctionnent plus comme un filtre que comme une transformation. Les différence sont illustrée ci-dessous. Notez que le rectangle de ciseau dessiné sous l'image de gauche n'est qu'une des possibilités : tout rectangle plus grand que le viewport aurait fonctionné.

Dans ce tutoriel nous voulons dessiner sur la totalité du framebuffer, et ce sans transformation. Nous définirons donc un rectangle de ciseaux couvrant tout le frambuffer :

VkRect2D scissor{};
scissor.offset = {0, 0};
scissor.extent = swapChainExtent;

Le viewport et le rectangle de ciseau se combinent en un viewport state à l'aide de la structure VkPipelineViewportStateCreateInfo. Il est possible sur certaines cartes graphiques d'utiliser plusieurs viewports et rectangles de ciseaux, c'est pourquoi la structure permet d'envoyer des tableaux de ces deux données. L'utilisation de cette possibilité nécessite de l'activer au préalable lors de la création du logical device.

VkPipelineViewportStateCreateInfo viewportState{};
viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
viewportState.viewportCount = 1;
viewportState.pViewports = &viewport;
viewportState.scissorCount = 1;
viewportState.pScissors = &scissor;

Rasterizer

Le rasterizer récupère la géométrie définie par des sommets et calcule les fragments qu'elle recouvre. Ils sont ensuite traités par le fragment shaders. Il réalise également un test de profondeur, le face culling et le test de ciseau pour vérifier si le fragment doit effectivement être traité ou non. Il peut être configuré pour émettre des fragments remplissant tous les polygones ou bien ne remplissant que les cotés (wireframe rendering). Tout cela se configure dans la structure VkPipelineRasterizationStateCreateInfo.

VkPipelineRasterizationStateCreateInfo rasterizer{};
rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
rasterizer.depthClampEnable = VK_FALSE;

Si le membre depthClampEnable est mis à VK_TRUE, les fragments au-delà des plans near et far ne pas supprimés mais affichés à cette distance. Cela est utile dans quelques situations telles que les shadow maps. Cela aussi doit être explicitement activé lors de la mise en place du logical device.

rasterizer.rasterizerDiscardEnable = VK_FALSE;

Si le membre rasterizerDiscardEnable est mis à VK_TRUE, aucune géométrie ne passe l'étape du rasterizer, ce qui désactive purement et simplement toute émission de donnée vers le frambuffer.

rasterizer.polygonMode = VK_POLYGON_MODE_FILL;

Le membre polygonMode définit la génération des fragments pour la géométrie. Les modes suivants sont disponibles :

  • VK_POLYGON_MODE_FILL : remplit les polygones de fragments
  • VK_POLYGON_MODE_LINE : les côtés des polygones sont dessinés comme des lignes
  • VK_POLYGON_MODE_POINT : les sommets sont dessinées comme des points

Tout autre mode que fill doit être activé lors de la mise en place du logical device.

rasterizer.lineWidth = 1.0f;

Le membre lineWidth définit la largeur des lignes en terme de fragments. La taille maximale supportée dépend du GPU et pour toute valeur autre que 1.0f l'extension wideLines doit être activée.

rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
rasterizer.frontFace = VK_FRONT_FACE_CLOCKWISE;

Le membre cullMode détermine quel type de face culling utiliser. Vous pouvez désactiver tout ce filtrage, n'éliminer que les faces de devant, que celles de derrière ou éliminer toutes les faces. Le membre frontFace indique l'ordre d'évaluation des vertices pour dire que la face est devant ou derrière, qui est le sens des aiguilles d'une montre ou le contraire.

rasterizer.depthBiasEnable = VK_FALSE;
rasterizer.depthBiasConstantFactor = 0.0f; // Optionnel
rasterizer.depthBiasClamp = 0.0f; // Optionnel
rasterizer.depthBiasSlopeFactor = 0.0f; // Optionnel

Le rasterizer peut altérer la profondeur en y ajoutant une valeur constante ou en la modifiant selon l'inclinaison du fragment. Ces possibilités sont parfois exploitées pour le shadow mapping mais nous ne les utiliserons pas. Laissez depthBiasEnabled à la valeur VK_FALSE.

Multisampling

La structure VkPipelineMultisampleCreateInfo configure le multisampling, l'un des outils permettant de réaliser l'anti-aliasing. Le multisampling combine les résultats d'invocations du fragment shader sur des fragments de différents polygones qui résultent au même pixel. Cette superposition arrive plutôt sur les limites entre les géométries, et c'est aussi là que les problèmes visuels de hachage arrivent le plus. Dans la mesure où le fragment shader n'a pas besoin d'être invoqué plusieurs fois si seul un polygone correspond à un pixel, cette approche est beaucoup plus efficace que d'augmenter la résolution de la texture. Son utilisation nécessite son activation au niveau du GPU.

VkPipelineMultisampleStateCreateInfo multisampling{};
multisampling.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
multisampling.sampleShadingEnable = VK_FALSE;
multisampling.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
multisampling.minSampleShading = 1.0f; // Optionnel
multisampling.pSampleMask = nullptr; // Optionnel
multisampling.alphaToCoverageEnable = VK_FALSE; // Optionnel
multisampling.alphaToOneEnable = VK_FALSE; // Optionnel

Nous reverrons le multisampling plus tard, pour l'instant laissez-le désactivé.

Tests de profondeur et de pochoir

Si vous utilisez un buffer de profondeur (depth buffer) et/ou de pochoir (stencil buffer) vous devez configurer les tests de profondeur et de pochoir avec la structure VkPipelineDepthStencilStateCreateInfo. Nous n'avons aucun de ces buffers donc nous indiquerons nullptr à la place d'une structure. Nous y reviendrons au chapitre sur le depth buffering.

Color blending

La couleur donnée par un fragment shader doit être combinée avec la couleur déjà présente dans le framebuffer. Cette opération s'appelle color blending et il y a deux manières de la réaliser :

  • Mélanger linéairement l'ancienne et la nouvelle couleur pour créer la couleur finale
  • Combiner l'ancienne et la nouvelle couleur à l'aide d'une opération bit à bit

Il y a deux types de structures pour configurer le color blending. La première, VkPipelineColorBlendAttachmentState, contient une configuration pour chaque framebuffer et la seconde, VkPipelineColorBlendStateCreateInfo contient les paramètres globaux pour ce color blending. Dans notre cas nous n'avons qu'un seul framebuffer :

VkPipelineColorBlendAttachmentState colorBlendAttachment{};
colorBlendAttachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
colorBlendAttachment.blendEnable = VK_FALSE;
colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_ONE; // Optionnel
colorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ZERO; // Optionnel
colorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD; // Optionnel
colorBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; // Optionnel
colorBlendAttachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; // Optionnel
colorBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD; // Optionnel

Cette structure spécifique de chaque framebuffer vous permet de configurer le color blending. L'opération sera effectuée à peu près comme ce pseudocode le montre :

if (blendEnable) {
    finalColor.rgb = (srcColorBlendFactor * newColor.rgb) <colorBlendOp> (dstColorBlendFactor * oldColor.rgb);
    finalColor.a = (srcAlphaBlendFactor * newColor.a) <alphaBlendOp> (dstAlphaBlendFactor * oldColor.a);
} else {
    finalColor = newColor;
}

finalColor = finalColor & colorWriteMask;

Si blendEnable vaut VK_FALSE la nouvelle couleur du fragment shader est inscrite dans le framebuffer sans modification et sans considération de la valeur déjà présente dans le framebuffer. Sinon les deux opérations de mélange sont exécutées pour former une nouvelle couleur. Un AND binaire lui est appliquée avec colorWriteMask pour déterminer les canaux devant passer.

L'utilisation la plus commune du mélange de couleurs utilise le canal alpha pour déterminer l'opacité du matériau et donc le mélange lui-même. La couleur finale devrait alors être calculée ainsi :

finalColor.rgb = newAlpha * newColor + (1 - newAlpha) * oldColor;
finalColor.a = newAlpha.a;

Avec cette méthode la valeur alpha correspond à une pondération pour la nouvelle valeur par rapport à l'ancienne. Les paramètres suivants permettent de faire exécuter ce calcul :

colorBlendAttachment.blendEnable = VK_TRUE;
colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA;
colorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
colorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD;
colorBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE;
colorBlendAttachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO;
colorBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD;

Vous pouvez trouver toutes les opérations possibles dans les énumérations VkBlendFactor et VkBlendOp dans la spécification.

La seconde structure doit posséder une référence aux structures spécifiques des framebuffers. Vous pouvez également y indiquer des constantes utilisables lors des opérations de mélange que nous venons de voir.

VkPipelineColorBlendStateCreateInfo colorBlending{};
colorBlending.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
colorBlending.logicOpEnable = VK_FALSE;
colorBlending.logicOp = VK_LOGIC_OP_COPY; // Optionnel
colorBlending.attachmentCount = 1;
colorBlending.pAttachments = &colorBlendAttachment;
colorBlending.blendConstants[0] = 0.0f; // Optionnel
colorBlending.blendConstants[1] = 0.0f; // Optionnel
colorBlending.blendConstants[2] = 0.0f; // Optionnel
colorBlending.blendConstants[3] = 0.0f; // Optionnel

Si vous voulez utiliser la seconde méthode de mélange (la combinaison bit à bit) vous devez indiquer VK_TRUE au membre logicOpEnable et déterminer l'opération dans logicOp. Activer ce mode de mélange désactive automatiquement la première méthode aussi radicalement que si vous aviez indiqué VK_FALSE au membre blendEnable de la précédente structure pour chaque framebuffer. Le membre colorWriteMask sera également utilisé dans ce second mode pour déterminer les canaux affectés. Il est aussi possible de désactiver les deux modes comme nous l'avons fait ici. Dans ce cas les résultats des invocations du fragment shader seront écrits directement dans le framebuffer.

États dynamiques

Un petit nombre d'états que nous avons spécifiés dans les structures précédentes peuvent en fait être altérés sans avoir à recréer la pipeline. On y trouve la taille du viewport, la largeur des lignes et les constantes de mélange. Pour cela vous devrez remplir la structure VkPipelineDynamicStateCreateInfo comme suit :

std::vector<VkDynamicState> dynamicStates = {
    VK_DYNAMIC_STATE_VIEWPORT,
    VK_DYNAMIC_STATE_LINE_WIDTH
};

VkPipelineDynamicStateCreateInfo dynamicState{};
dynamicState.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
dynamicState.dynamicStateCount = static_cast<uint32_t>(dynamicStates.size());
dynamicState.pDynamicStates = dynamicStates.data();

Les valeurs données lors de la configuration seront ignorées et vous devrez en fournir au moment du rendu. Nous y reviendrons plus tard. Cette structure peut être remplacée par nullptr si vous ne voulez pas utiliser de dynamisme sur ces états.

Pipeline layout

Les variables uniform dans les shaders sont des données globales similaires aux états dynamiques. Elles doivent être déterminées lors du rendu pour altérer les calculs des shaders sans avoir à les recréer. Elles sont très utilisées pour fournir les matrices de transformation au vertex shader et pour créer des samplers de texture dans les fragment shaders.

Ces variables doivent être configurées lors de la création de la pipeline en créant une variable de type VkPipelineLayout. Même si nous n'en utilisons pas dans nos shaders actuels nous devons en créer un vide.

Créez un membre donnée pour stocker la structure car nous en aurons besoin plus tard.

VkPipelineLayout pipelineLayout;

Créons maintenant l'objet dans la fonction createGraphicsPipline :

VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 0;            // Optionnel
pipelineLayoutInfo.pSetLayouts = nullptr;         // Optionnel
pipelineLayoutInfo.pushConstantRangeCount = 0;    // Optionnel
pipelineLayoutInfo.pPushConstantRanges = nullptr; // Optionnel

if (vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &pipelineLayout) != VK_SUCCESS) {
    throw std::runtime_error("échec de la création du pipeline layout!");
}

Cette structure informe également sur les push constants, une autre manière de passer des valeurs dynamiques au shaders que nous verrons dans un futur chapitre. Le pipeline layout sera utilisé pendant toute la durée du programme, nous devons donc le détruire dans la fonction cleanup :

void cleanup() {
    vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
    ...
}

Conclusion

Voila tout ce qu'il y a à savoir sur les étapes à fonction fixée! Leur configuration représente un gros travail mais nous sommes au courant de tout ce qui se passe dans la pipeline graphique, ce qui réduit les chances de comportement imprévu à cause d'un paramètre par défaut oublié.

Il reste cependant encore un objet à créer avant du finaliser la pipeline graphique. Cet objet s'appelle passe de rendu.

Code C++ / Vertex shader / Fragment shader