IX. Chargement de modèles▲
IX-A. Introduction▲
Votre programme peut maintenant afficher des modèles 3D texturés, mais l’objet défini par les sommets des tableaux vertices et indices n’est pas ce qu’il y a de plus intéressant. Dans ce chapitre, nous allons modifier le programme pour charger les sommets et les indices depuis un fichier afin de réellement exploiter la carte graphique.
Beaucoup de tutoriels sur les bibliothèques graphiques décrivent l’implémentation pour charger les modèles au format OBJ. Le problème est que n’importe quelle application 3D intéressante nécessite des fonctionnalités non supportées par ce format, notamment les animations de squelette. Nous allons, nous aussi, charger un modèle au format OBJ, mais nous nous concentrerons sur l’intégration des données du modèle dans le programme plutôt que sur les détails liés au chargement du fichier.
IX-B. Une bibliothèque▲
Nous utiliserons la bibliothèque tinyobjloader pour charger les sommets et les faces depuis un fichier OBJ. C’est rapide et facile à intégrer, car, comme pour la bibliothèque stb_image, il n’y a qu’un fichier à ajouter au projet. Suivez le lien ci-dessus et téléchargez le fichier tiny_obj_loader.h. Placez-le dans votre dossier dédié aux bibliothèques. Assurez-vous d’utiliser la version du fichier provenant de la branche master, car la dernière version publiée n’est pas à jour.
IX-B-1. Visual Studio▲
Ajoutez dans la section « Autres répertoires Include » (Additional Include Directories) le dossier dans lequel est contenu tiny_obj_loader.h.
IX-B-2. Makefile▲
Ajoutez le dossier contenant tiny_obj_loader.h aux dossiers d'inclusions de GCC :
VULKAN_SDK_PATH = /home/user/VulkanSDK/x.x.x.x/x86_64
STB_INCLUDE_PATH = /home/user/libraries/stb
TINYOBJ_INCLUDE_PATH = /home/user/libraries/tinyobjloader
...
CFLAGS = -std=c++17 -I$(VULKAN_SDK_PATH)/include -I$(STB_INCLUDE_PATH) -I$(TINYOBJ_INCLUDE_PATH)
IX-C. Modèle d’exemple▲
Dans ce chapitre, nous n’activons pas l’éclairage. Il est donc préférable de charger un modèle intégrant les effets de lumière dans la texture. Il est facile de trouver de tels modèles en explorant la section des scans 3D de Sketchfab. Vous pouvez y trouver de nombreux modèles au format OBJ ayant une licence permissive.
Pour ce tutoriel, j'ai choisi d'utiliser la maison viking créée par nigelgoh (CC BY 4.0). J'ai modifié la taille et l'orientation pour l'utiliser comme remplacement de notre objet actuel :
N’hésitez pas à utiliser votre propre modèle. Simplement, assurez-vous qu’il ne comprend qu'un seul matériau et que ses dimensions sont de 1.5 x 1. x 1.5 unités. S’il est plus grand, vous devrez changer la matrice de vue. Placez le modèle dans un dossier appelé models et l’image dans le dossier textures.
Ajoutez deux variables de configuration pour indiquer l’emplacement du modèle et de la texture :
const
uint32_t WIDTH =
800
;
const
uint32_t HEIGHT =
600
;
const
std::
string MODEL_PATH =
"models/viking_room.obj"
;
const
std::
string TEXTURE_PATH =
"textures/viking_room.png"
;
Modifiez la fonction createTextureImage() pour charger la texture du modèle :
stbi_uc*
pixels =
stbi_load(TEXTURE_PATH.c_str(), &
texWidth, &
texHeight, &
texChannels, STBI_rgb_alpha);
IX-D. Chargement des sommets et des indices▲
Nous allons charger les sommets et les indices depuis le fichier du modèle. Remplacez les tableaux vertices et indices par des vecteurs dynamiques membres de notre classe :
std::
vector<
Vertex>
vertices;
std::
vector<
uint32_t>
indices;
VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;
Il faut aussi changer le type des indices afin d’utiliser les uint32_t, car nous allons dépasser les 65 535 sommets. Changez également le paramètre de type dans l'appel à la fonction vkCmdBindIndexBuffer() :
vkCmdBindIndexBuffer(commandBuffers[i], indexBuffer, 0
, VK_INDEX_TYPE_UINT32);
La bibliothèque tinyobjloader s’inclut de la même façon que toutes les bibliothèques STB. Assurez-vous de définir la macro TINYOBJLOADER_IMLEMENTATION afin d’avoir la définition des fonctions (et ainsi, éviter des erreurs lors de l’édition des liens) :
#define TINYOBJLOADER_IMPLEMENTATION
#include
<tiny_obj_loader.h>
Nous allons ensuite écrire une fonction nommée loadModel() pour remplir le tableau de sommets et d'indices avec les données du modèle. Nous devons l'appeler avant la création des tampons de sommets et d’indices :
void
initVulkan() {
...
loadModel();
createVertexBuffer();
createIndexBuffer();
...
}
...
void
loadModel() {
}
Le chargement du modèle s’effectue avec la fonction tinyobj::LoadObj() :
void
loadModel() {
tinyobj::
attrib_t attrib;
std::
vector<
tinyobj::
shape_t>
shapes;
std::
vector<
tinyobj::
material_t>
materials;
std::
string warn, err;
if
(!
tinyobj::
LoadObj(&
attrib, &
shapes, &
materials, &
warn, &
err, MODEL_PATH.c_str())) {
throw
std::
runtime_error(warn +
err);
}
}
Un fichier OBJ contient des positions, des normales, des coordonnées de textures et des faces. Ces dernières consistent en un nombre arbitraire de sommets, dont la position, la normale et/ou la coordonnée de texture sont référencées par un index. Chaque sommet est constitué d’une position, une normale et/ou une coordonnée de texture. Ainsi, il est possible de réutiliser des attributs spécifiques et non l’intégralité d’un sommet.
Le conteneur attrib contient les positions, les normales et les coordonnées de texture dans les propriétés attrib.vertices, attrib.normals et attrib.texcoords. Le conteneur shapes contient tous les objets séparément et leurs faces. Ces dernières contiennent un tableau de sommets, où chaque sommet contient les indices pour la position, pour la normale et pour la coordonnée de texture. Les modèles OBJ peuvent aussi définir un matériel et une texture par face, mais nous ignorons cette particularité.
La chaîne de caractères err contient les erreurs et la chaîne warn contient les messages d’avertissements liés au chargement du fichier. Notamment, ces variables peuvent indiquer l’absence de la définition d’un matériel. Le chargement a échoué si la fonction LoadObj() retourne false. Comme indiqué précédemment, les faces dans les fichiers OBJ peuvent contenir un nombre arbitraire de sommets alors que l’application n’est capable que de dessiner des triangles. Heureusement, la fonction LoadObj() possède un paramètre optionnel pour déterminer les triangles à partir des faces. Cette option est activée par défaut.
Nous allons combiner toutes les faces du fichier en un seul modèle. Il n’y a donc qu’à parcourir les formes :
for
(const
auto
&
shape : shapes) {
}
Grâce à la triangularisation, nous sommes sûrs que les faces n'ont que trois sommets. Nous pouvons donc simplement parcourir les sommets et les copier directement dans notre tableau vertices :
for
(const
auto
&
shape : shapes) {
for
(const
auto
&
index : shape.mesh.indices) {
Vertex vertex =
{}
;
vertices.push_back(vertex);
indices.push_back(indices.size());
}
}
Pour faire simple, nous allons partir du principe que tous les sommets sont uniques. Ainsi, nous pouvons définir les indices par un simple auto-incrément. La variable index est du type tinyobj::index_t, et contient les propriétés vertex_index, normal_index et texcoord_index. Nous devons utiliser ces indices pour trouver les attributs du sommet à utiliser se trouvant dans les tableaux attrib :
vertex.pos =
{
attrib.vertices[3
*
index.vertex_index +
0
],
attrib.vertices[3
*
index.vertex_index +
1
],
attrib.vertices[3
*
index.vertex_index +
2
]
}
;
vertex.texCoord =
{
attrib.texcoords[2
*
index.texcoord_index +
0
],
attrib.texcoords[2
*
index.texcoord_index +
1
]
}
;
vertex.color =
{
1.0
f, 1.0
f, 1.0
f}
;
Le tableau attrib.vertices contient des valeurs flottantes et non pas un type similaire à glm::vec3. Il faut donc multiplier les indices par 3. De même, il y a deux composants par élément pour les coordonnées de texture. Les décalages 0, 1 et 2 permettent d'accéder aux composants X, Y et Z, ou aux composants U et V dans le cas des textures.
Lancez le programme en activant les optimisations (compilation Release avec Visual Studio ou avec l'option -03 pour GCC). Vous êtes obligé de faire ainsi, sans quoi le chargement du modèle sera très lent. Vous devriez obtenir le résultat suivant :
Génial, la géométrie semble correcte ! Par contre, que se passe-t-il avec les textures ? Le format OBJ contient des coordonnées de texture où la coordonnée 0 est placée en bas de l’image. Dans notre cas, nous avons envoyé à Vulkan l’image où la coordonnée 0 indique le haut de l’image. Il suffit d’inverser la composante verticale des coordonnées de texture pour régler le problème :
vertex.texCoord =
{
attrib.texcoords[2
*
index.texcoord_index +
0
],
1.0
f -
attrib.texcoords[2
*
index.texcoord_index +
1
]
}
;
Vous pouvez lancer à nouveau le programme. Le rendu devrait maintenant être correct :
Notre long travail commence enfin à porter ses fruits !
IX-E. Déduplication des sommets▲
Malheureusement, nous ne profitons pas du tampon d’indices. Le tableau vertices contient énormément de sommets dupliqués, car beaucoup d’entre eux sont utilisés dans plusieurs triangles. Nous ne devrions inclure que des sommets uniques et utiliser le tampon d’indice pour les réutiliser lorsque possible. Une approche simple est d’implémenter une map ou une unordered_map pour garder une trace des sommets uniques et de leur indice :
#include
<unordered_map>
...
std::
unordered_map<
Vertex, uint32_t>
uniqueVertices{}
;
for
(const
auto
&
shape : shapes) {
for
(const
auto
&
index : shape.mesh.indices) {
Vertex vertex{}
;
...
if
(uniqueVertices.count(vertex) ==
0
) {
uniqueVertices[vertex] =
static_cast
<
uint32_t>
(vertices.size());
vertices.push_back(vertex);
}
indices.push_back(uniqueVertices[vertex]);
}
}
Chaque fois que l'on extrait un sommet du fichier OBJ, nous devons vérifier si nous avons déjà rencontré un sommet possédant exactement la même position et la même coordonnée de texture. Si ce n’est pas le cas, nous l’ajoutons dans vertices et nous stockons son index dans uniqueVertices. Ensuite, nous ajoutons l’indice au nouveau tableau indices. Si le sommet est connu, il suffit de récupérer son indice à partir de uniqueVertices et de le stocker dans indices.
Pour l'instant, le programme ne peut pas compiler, car nous utilisons notre structure Vertex comme clé de la table de hachage. Dans un tel cas, nous devons implémenter deux fonctions : un test d’égalité et une fonction de hachage. Le test d’égalité est facile à implémenter en surchargeant l’opérateur == de la structure :
bool
operator
==
(const
Vertex&
other) const
{
return
pos ==
other.pos &&
color ==
other.color &&
texCoord ==
other.texCoord;
}
Nous devons spécialiser le template std::hash<T> pour obtenir un hachage pour la structure Vertex. L’écriture d’une fonction de hachage est compliquée, mais cppreference.com recommande l'approche suivante pour obtenir une fonction de bonne qualité : combiner le hachage des champs de la structure.
namespace
std {
template
<>
struct
hash<
Vertex>
{
size_t operator
()(Vertex const
&
vertex) const
{
return
((hash<
glm::
vec3>
()(vertex.pos) ^
(hash<
glm::
vec3>
()(vertex.color) <<
1
)) >>
1
) ^
(hash<
glm::
vec2>
()(vertex.texCoord) <<
1
);
}
}
;
}
Ce code doit être placé hors de la définition de Vertex. Les fonctions de hachage des types provenant de GLM s’activent avec la définition et l'inclusion suivantes :
#define GLM_ENABLE_EXPERIMENTAL
#include
<glm/gtx/hash.hpp>
Les fonctions de hachage sont définies dans le dossier gtx et proviennent donc d’une extension expérimentale de GLM. C’est pourquoi vous devez définir GLM_ENABLE_EXPERIMENTAL pour les utiliser. Cela signifie que la bibliothèque peut être modifiée dans les prochaines versions de GLM, mais en pratique, la bibliothèque est très stable.
Vous devriez maintenant pouvoir compiler et lancer le programme. Si vous vérifiez la taille de vertices, vous verrez qu'elle est passée de 1 500 000 à 265 645 éléments ! Cela signifie que les sommets sont réutilisés dans six triangles différents en moyenne. Cela permet d’économiser beaucoup de mémoire GPU.