Apprendre OpenGL moderne

Troisième partie : chargement de modèle 3D


précédentsommaire

III. Le Modèle

Il est temps de se plonger dans le cœur d’Assimp et de commencer à créer le code de chargement d’un modèle. Le but de ce tutoriel est de créer une autre classe pour représenter un modèle dans son intégralité : un modèle ayant plusieurs mailles et éventuellement plusieurs objets. Une maison, qui comprend un balcon en bois, une tour et même une piscine pourra être ainsi chargée dans un seul modèle. Nous chargerons le modèle grâce à Assimp et le traduirons en plusieurs objets Mesh selon le schéma créé dans le tutoriel précédent.

Sans plus attendre, je vous présente la structure de la classe Model :

 
Sélectionnez
Class Model
{
    public:
        /*  Fonctions   */
        Model(char *path)
        {
            loadModel(path);
        }
        void Draw(Shader shader);
    private:
        /*  Données du modèle  */
        std::vector<Mesh> meshes;
        std::string directory;
        /*  Fonctions   */
        void loadModel(std::string path);
        void processNode(aiNode *node, const aiScene *scene);
        Mesh processMesh(aiMesh *mesh, const aiScene *scene);
        std::vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, std::string typeName);
};

La classe Model contient un vecteur d’objets Mesh et nous oblige à passer, à son constructeur, un chemin pointant sur le fichier à charger. On peut ainsi charger le fichier directement par la fonction loadModel(), appelée dans le constructeur. Les fonctions privées sont destinées à traiter une partie de la tâche d’importation d’Assimp et nous les examinerons brièvement. Nous mémorisons aussi le répertoire qui nous servira plus tard pour le chargement des textures.

La fonction Draw() n’a rien de particulier et ne fait qu’afficher chacune des mailles au moyen de leur fonction Draw() respective :

 
Sélectionnez
void Draw(Shader shader)
{
    for(unsigned int i = 0; i < meshes.size(); i++)
        meshes[i].Draw(shader);
}

III-A. Importation d’un modèle 3D dans OpenGL

Pour importer un modèle et le transposer dans nos propres structures, nous devons d’abord inclure les fichiers d’en-tête d’Assimp :

 
Sélectionnez
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>

La première fonction que nous appelons est loadModel(), cela est effectué dans le constructeur. Avec cette fonction loadModel(), nous utilisons Assimp pour charger le modèle dans une structure de données qu’Assimp appelle l’objet scene. Souvenez-vous du chapitre 19 (Assimp), cet objet est à la racine de l’interface de données d’Assimp. À partir de cet objet scene, nous pouvons accéder à toutes les données du modèle importé.

L’atout majeur d’Assimp est de pouvoir s’abstraire de tous les détails techniques des différents formats de fichiers, et ceci grâce à une seule ligne de code :

 
Sélectionnez
Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);

On commence par déclarer un objet Importer provenant de l’espace de données d’Assimp puis on appelle la méthode ReadFile() de cet objet. Cette méthode requiert un nom de fichier et des options en second argument. Non seulement cette méthode charge le fichier, mais elle permet de spécifier différentes options pour qu’Assimp effectue des opérations supplémentaires sur les données importées. L’option aiProcess_Triangulate a pour effet de transformer toutes les primitives du modèle en triangles si ce n’est pas déjà le cas. L’option aiProcess_FlipUVs inverse les coordonnées de textures sur l’axe y si nécessaire (on se rappelle, comme expliqué dans le chapitre sur les textures, que la plupart des images dans OpenGL sont inversées par rapport à l’axe y). D’autres options sont disponibles :

  • aiProcess_GenNormals : crée les normales pour chaque sommet si le modèle n’en contient pas.
  • aiProcess_SplitLargeMeshes : découpe les grandes mailles en mailles plus petites, ce qui est utile si votre système de rendu est limité en nombre de sommets et ne peut afficher que des petites mailles.
  • aiProcess_OptimizeMeshes : réunit plusieurs mailles si possible, réduisant les appels d’affichage pour optimisation.

Assimp propose un large ensemble d’instructions de post-traitements, vous les trouverez ici. Charger un modèle avec Assimp est donc vraiment facile. La partie délicate est l’utilisation de l’objet scene obtenu pour traduire les données chargées en un tableau de Mesh.

La fonction loadModel() complète est donnée ci-après :

 
Sélectionnez
void loadModel(std::string path)
{
    Assimp::Importer import;
    const aiScene *scene = import.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
       if(!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
    {
        cout << "ERROR::ASSIMP::" << import.GetErrorString() << endl;
        return;
    }
    directory = path.substr(0, path.find_last_of('/'));
    processNode(scene->mRootNode, scene);
}

Après avoir chargé le modèle, on vérifie que la scène et le nœud racine de la scène ne sont pas nuls et l’on teste un des indicateurs pour voir si les données sont complètes. Si l’une de ces erreurs se produit, on affiche un message d’erreur au moyen de la méthode GetErrorString() et on termine la fonction. On récupère ensuite le nom du répertoire contenant le fichier.

Si tout s’est bien passé, on traite tous les nœuds de la scène en passant le nœud racine à la fonction récursive processNode(). Chaque nœud pouvant contenir des nœuds fils, nous traitons d’abord le nœud en question, et nous continuons en traitant les nœuds fils. Cette structure récursive se traite naturellement par une fonction récursive, une fonction qui effectue un certain traitement puis s’appelle elle-même avec des paramètres différents si une certaine condition est satisfaite. Dans notre cas, la condition d’arrêt sera le traitement de tous les nœuds fils.

On se rappelle que dans la structure d’Assimp, chaque nœud contient un ensemble d’indices de mailles, chaque indice pointant vers une maille particulière de l’objet scène. Nous voulons donc retrouver ces indices de mailles, puis chaque maille, et enfin traiter chacune de ces mailles, et cela pour chacun des nœuds fils du nœud principal. Le contenu de cette fonction processNode() est exposé ci-après :

 
Sélectionnez
void processNode(aiNode *node, const aiScene *scene)
{
    // traitement de toutes les mailles du nœud
    for(unsigned int i = 0; i < node->mNumMeshes; i++)
    {
        aiMesh *mesh = scene->mMeshes[node->mMeshes[i]];
        meshes.push_back(processMesh(mesh, scene));
     }
    // effectuer la même opération pour chaque nœud fils
    for(unsigned int i = 0; i < node->mNumChildren; i++)
    {
        processNode(node->mChildren[i], scene);
    }
}

D’abord, pour chacun des indices de maille du nœud, on retrouve la maille correspondante en indexant le tableau mMeshes de la scène. La maille obtenue est ensuite passée à la fonction processMesh() qui retourne un objet Mesh que l’on mémorise dans le vecteur meshes.

Une fois que toutes les mailles ont été traitées, on itère sur tous les nœuds fils en appelant la même fonction pour chacun d’entre eux. Lorsqu’un nœud n’a plus de nœud fils, la fonction s’arrête.

Un lecteur attentif aura noté que l’on pourrait oublier les nœuds et simplement itérer sur chacune des mailles de la scène directement sans compliquer les choses avec les indices. La raison pour utiliser notre méthode tient en la définition d’une relation parent-fils entre les mailles. En traitant récursivement ces relations, on peut définir certaines mailles comme parentes d’autres mailles (par exemple, une maille machine sera parente d’une maille roue et de son fils pneu). Cette organisation des données est naturellement récursive.
Cependant, pour l’instant, nous n’utiliserons pas un tel système, mais cette approche est recommandée si vous souhaitez un contrôle important sur les mailles. Après tout, ces relations parent-fils sont définies par les concepteurs de modèles.

L’étape suivante est le traitement effectif des données Assimp dans la classe Mesh créée dans le chapitre précédent.

III-A-1. D’Assimp à la classe Mesh

Transcrire un objet aiMesh en un objet maille de notre cru n’est pas trop compliqué. Il suffit d’accéder à chacune des propriétés de la maille et de la mémoriser dans notre objet. La structure générale de la fonction processMesh() devient donc :

 
Sélectionnez
Mesh processMesh(aiMesh *mesh, const aiScene *scene)
{
    std::vector<Vertex> vertices;
    std::vector<unsigned int> indices;
    std::vector<Texture> textures;
    for(unsigned int i = 0; i < mesh->mNumVertices; i++)
    {
        Vertex vertex;
        // traitement des positions, normales et coordonnées de textures des sommets
        …
        vertices.push_back(vertex);
    }
    // Traitement des indices// Traitement des matériaux
    if(mesh->mMaterialIndex >= 0)
    {}
    return Mesh(vertices, indices, textures);
}

Traiter une maille consiste en trois parties : retrouver tous les sommets, retrouver les indices de la maille et finalement retrouver le matériau correspondant. Les données sont mémorisées dans l’un des trois vecteurs et ainsi un objet Mesh est créé et renvoyé par la fonction.

Retrouver les données de sommets est assez simple : on définit une structure Vertex que l’on ajoute au tableau vertices après chaque itération. On boucle sur les sommets de la maille (leur nombre est donné par mesh->mNumVertices). Dans la boucle, on remplit cette structure avec les données correspondantes. Pour la position des sommets, cela est fait ainsi :

 
Sélectionnez
glm::vec3 vector;
vector.x = mesh->mVertices[i].x;
vector.y = mesh->mVertices[i].y;
vector.z = mesh->mVertices[i].z;
vertex.Position = vector;

Nous définissons un vecteur vec3 pour y transférer les données d’Assimp. Ce vecteur est nécessaire, car Assimp possède son propre type de données pour les vecteurs, les matrices, les chaînes de caractères, etc., et leur conversion ne se fait pas facilement dans les types de données de glm.

Assimp nomme les positions de sommets mVertices, ce qui n’est pas très intuitif.

Le traitement des normales est sans surprise :

 
Sélectionnez
vector.x = mesh->mNormals[i].x;
vector.y = mesh->mNormals[i].y;
vector.z = mesh->mNormals[i].z;
vertex.Normal = vector;

Les coordonnées de textures sont traitées pareillement, mais Assimp autorise jusqu’à huit coordonnées de textures différentes pour un sommet, ce que nous n’utiliserons pas, en se limitant au premier ensemble de ces coordonnées. Nous vérifierons aussi que la maille contient des coordonnées de textures (ce qui n’est pas toujours le cas) :

 
Sélectionnez
if(mesh->mTextureCoords[0]) // y a-t-il des coordonnées de textures ?
{
    glm::vec2 vec;
    vec.x = mesh->mTextureCoords[0][i].x;
    vec.y = mesh->mTextureCoords[0][i].y;
    vertex.TexCoords = vec;
}
else
    vertex.TexCoords = glm::vec2(0.0f, 0.0f);

La structure vertex est maintenant complétée avec les attributs de sommets et l’on peut la placer dans le vecteur vertices à la fin de la boucle. Ce processus est répété pour chacun des sommets de la maille.

III-A-2. Les indices

L’interface d’Assimp définit chaque maille comme ayant un tableau de faces où chaque face représente une primitive, ce qui dans notre cas est toujours un triangle (du fait de l’option aiProcess_Triangulate). Une face contient les indices qui définissent quels sommets doivent être affichés et dans quel ordre pour chaque primitive, donc nous itérerons sur les faces et nous mémoriserons les indices de faces dans le vecteur indices :

 
Sélectionnez
for(unsigned int i = 0; i < mesh->mNumFaces; i++)
{
    aiFace face = mesh->mFaces[i];
    for(unsigned int j = 0; j < face.mNumIndices; j++)
        indices.push_back(face.mIndices[j]);
}

Une fois la boucle extérieure terminée, nous disposons d’un ensemble complet de sommets et des indices pour afficher la maille avec glDrawElements(). Cependant, pour terminer et ajouter certains détails à la maille, il nous faut traiter aussi les matériaux de la maille.

III-A-3. Les matériaux

De même que les nœuds, une maille ne contient qu’un indice pointant vers un matériau, et pour retrouver le matériau d’une maille, il faut utiliser le tableau mMaterials de la scène. Cet indice de matériau est disponible grâce à la propriété mMaterialIndex que nous pouvons aussi utiliser pour savoir si un matériau a été utilisé ou non :

 
Sélectionnez
if(mesh->mMaterialIndex >= 0)
{
    aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];

    std::vector<Texture> diffuseMaps = loadMaterialTextures(material,
    aiTextureType_DIFFUSE, "texture_diffuse");
    textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());

    std::vector<Texture> specularMaps = loadMaterialTextures(material, aiTextureType_SPECULAR, "texture_specular");
    textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
}

On retrouve d’abord l’objet aiMaterial du tableau mMaterials de la scène. Ensuite, nous voulons charger les textures diffuse et/ou spéculaire. Un objet matériau contient un tableau des emplacements alloués pour chaque type de texture. Les différents types de texture sont préfixés avec aiTextureType_. On utilise la fonction loadMaterialTextures() pour retrouver les textures à partir du matériau. La fonction retourne un vecteur de structures Texture que l’on peut ensuite placer à la fin du vecteur textures du modèle.

La fonction loadMaterialTextures() boucle sur toutes les textures d’un type donné, retrouve l’emplacement des fichiers texture et charge puis génère la texture et place l’information dans une structure Vertex. Le code ressemble à ceci :

 
Sélectionnez
std::vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, std::string typeName)
{
    std::vector<Texture> textures;
    for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
    {
        aiString str;
        mat->GetTexture(type, i, &str);
        Texture texture;
        texture.id = TextureFromFile(str.C_Str(), directory);
        texture.type = typeName;
        texture.path = str;
        textures.push_back(texture);
    }
    return textures;
}

On commence par vérifier le nombre de textures du type spécifié contenues dans le matériau avec la fonction GetTextureCount(). On retrouve ensuite chaque emplacement de texture grâce à la fonction GetTexture() qui mémorise le résultat dans une chaîne aiString. La fonction utilitaire TextureFromFile() est ensuite appelée pour charger une texture (avec la bibliothèque SOIL) et retourne l’identifiant de la texture. Vous pouvez étudier le code complet à la fin si vous n’êtes pas sûr de la façon dont cette fonction est écrite.

Noter que nous avons supposé que les chemins d’accès aux fichiers textures sont locaux au modèle de l’objet, donc dans le même répertoire que le modèle lui-même. On peut donc simplement concaténer la chaîne contenant le nom de la texture et le nom du répertoire que nous avons trouvé précédemment (dans la fonction loadModel()) pour obtenir le chemin d’accès complet de la texture.

Certains modèles disponibles sur internet utilisent des chemins d’accès absolus pour leurs textures, ce qui ne fonctionnera pas sur votre machine. Dans ce cas, il vous faudra éditer manuellement le fichier (si cela est possible) pour obtenir des chemins d’accès locaux pour les textures.

Et c’est tout pour importer un modèle en utilisant Assimp.

III-B. Une optimisation importante

Nous n’avons pas tout à fait fini, car il est possible d’optimiser ce travail (ce n’est pas indispensable toutefois). La plupart des scènes utilisent une même texture pour plusieurs mailles ; pensez à une maison qui utilise une texture de granit pour les murs. Cette texture peut aussi être utilisée pour le sol, le plafond, les escaliers, peut être une table… Charger une texture prend du temps et dans notre implémentation, une nouvelle texture est chargée et générée pour chaque maille même si elle a déjà été chargée auparavant. Cela forme un goulot d’étranglement pour notre mise en œuvre du chargement d’un modèle.

Nous allons modifier un peu le code en mémorisant globalement les textures chargées et lorsqu’on aura besoin d’une texture, on vérifiera si elle n’a pas été déjà chargée. Si c’est le cas, nous économiserons du temps de calcul en retrouvant cette texture. Pour comparer les textures, nous utiliserons leur chemin d’accès, qu’il faudra donc mémoriser :

 
Sélectionnez
struct Texture {
    unsigned int id;
    std::string type;
    std::string path;  // nous mémorisons le chemin d’accès pour comparaison avec d’autres textures
};

Nous archivons toutes les textures chargées dans un autre vecteur déclaré dans la classe du modèle au moyen d’une variable privée :

 
Sélectionnez
std::vector<Texture> textures_loaded;

Dans la fonction loadMaterialTextures() nous comparons le chemin d’accès avec tous ceux déjà archivés dans le vecteur textures_loaded. Si on le trouve, on saute la partie chargement et génération de la texture et on utilise simplement la texture déjà chargée. La fonction modifiée devient donc :

 
Sélectionnez
std::vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, std::string typeName)
{
    std::vector<Texture> textures;
    for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
    {
        aiString str;
        mat->GetTexture(type, i, &str);
        bool skip = false;
        for(unsigned int j = 0; j < textures_loaded.size(); j++)
        {
            if(std::strcmp(textures_loaded[j].path.data(), str.C_Str()) == 0)
            {
                textures.push_back(textures_loaded[j]);
                skip = true;
                break;
            }
        }
        if(!skip)
        {   // si la texture n’a pas été déjà chargée, on le fait
            Texture texture;
            texture.id = TextureFromFile(str.C_Str(), directory);
            texture.type = typeName;
            texture.path = str.C_Str();
            textures.push_back(texture);
            textures_loaded.push_back(texture); // ajouter aux textures connues
        }
    }
    return textures;
}

Dès lors, nous avons non seulement un système universel de chargement de modèle, mais qui de plus est optimisé pour charger les modèles très rapidement.

Certaines versions d’Assimp sont vraiment lentes compilées en version debug, ainsi assurez-vous de compiler une version release si vos temps de chargement sont longs.

Vous trouverez le code complet de la classe optimisée ici.

III-C. Fini les conteneurs !

Bien, améliorons notre application en chargeant un modèle créé par des artistes, et pas un truc créé par le génie que je suis (vous admettrez que mes conteneurs sont certainement les plus beaux cubes que vous ayez connus). Ne voulant pas me donner trop d’importance, je fais quelquefois appel à d’autres graphistes et cette fois, nous allons utiliser la nanosuit originale utilisée par Crytek dans Crysis (et téléchargé depuis tf3dm.com pour cet exemple). Le modèle est exporté dans un fichier .obj avec un fichier .mtl contenant les textures diffuses et spéculaires ainsi que les textures de normales (des précisions plus loin). Vous pouvez télécharger ce modèle (un peu modifié) ici. Notez que les textures et le fichier modèle doivent se trouver dans le même répertoire.

La version que vous pouvez télécharger depuis ce site est une version modifiée dans laquelle chaque chemin d’accès aux textures a été modifié pour devenir relatif alors que dans l’original ce sont des chemins absolus.

Dans le code, déclarez un objet Model et passez l’emplacement du fichier modèle. Le modèle devrait se charger automatiquement et (sauf erreur) s’afficher dans le jeu avec la fonction Draw(). Plus besoin d’allouer des tampons mémoire, des pointeurs d’attributs et des commandes de rendu, une simple ligne suffit. Ensuite, si vous créez un simple ensemble de shaders où le fragment shader ne fait que produire la couleur des textures diffuses, le résultat sera à peu près celui-là :

Image non disponible

Le code complet se trouve ici.

Vous pouvez aussi être plus créatif et introduire deux lampes ponctuelles dans la scène comme nous l’avons vu dans le chapitre sur les éclairages et avec les textures spéculaires vous obtiendrez cela :

Image non disponible

Même moi, je dois admettre que c’est un peu plus sympa que mes conteneurs. Avec Assimp, vous pourrez charger quantité de modèles trouvés sur internet. Certains sites offrent gratuitement des modèles 3D à télécharger dans différents formats. Certains ne se chargent pas correctement, ont des textures qui ne fonctionnent pas ou encore utilisent des formats inconnus d’Assimp.

III-D. Remerciements

Ce tutoriel est une traduction réalisée par Jean-Michel Fray dont l’original a été écrit par Joey de Vries et qui est disponible sur le site Learn OpenGL.


précédentsommaire

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2018 Joey de Vries. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.