II. Maillage▲
En utilisant Assimp, on peut charger beaucoup de modèles différents, et une fois chargés, ils sont accessibles dans les structures de données d’Assimp. Nous souhaitons ensuite transformer ces données dans un format utilisable par OpenGL pour afficher ces objets. Nous avons vu dans le chapitre précédent qu’une maille représente une entité affichable, commençons donc par définir une classe pour représenter une maille.
Revenons sur ce que nous avons vu jusqu’ici et réfléchissons à la structure de données minimale pour une maille. Une maille doit comprendre au moins un ensemble de sommets, chaque sommet étant composé d’un vecteur de position, un vecteur normal, ainsi qu’un vecteur de coordonnées de texture. Une maille devrait aussi comprendre les indices pour un rendu indexé et des données de matériau sous la forme de textures spéculaires ou diffuses.
Maintenant que nous avons défini un contenu minimal pour la classe de mailles, nous pouvons définir un sommet dans OpenGL :
struct
Vertex {
glm::
vec3 Position;
glm::
vec3 Normal;
glm::
vec2 TexCoords;
}
;
Nous mémorisons chacun des sommets dans une structure nommée Vertex, que nous pourrons utiliser pour indexer chacun de nos attributs de sommet. En plus de cette structure Vertex, nous utiliserons aussi une structure Texture pour les textures :
struct
Texture {
unsigned
int
id;
std::
string type;
}
;
Nous mémorisons l’identifiant de la texture et son type (diffuse ou spéculaire).
Nous pouvons maintenant commencer à définir une structure pour la classe Mesh :
class
Mesh {
public
:
/* Données du modèle */
std::
vector<
Vertex>
vertices;
std::
vector<
unsigned
int
>
indices;
std::
vector<
Texture>
textures;
/* Fonctions */
Mesh(std::
vector<
Vertex>
vertices, std::
vector<
unsigned
int
>
indices, std::
vector<
Texture>
textures);
void
Draw(Shader shader);
private
:
/* Render data */
unsigned
int
VAO, VBO, EBO;
/* Functions */
void
setupMesh();
}
;
Cette classe de mailles n’est pas très complexe. Nous placerons dans le constructeur les données utiles, nous initialiserons les tampons dans la fonction setupMesh() et finalement nous afficherons la maille avec la fonction Draw(). Noter que nous passons un shader à la fonction Draw() ; en passant le shader à la maille, on peut affecter différentes variables uniformes avant d’effectuer l’affichage (par exemple pour lier les échantillonneurs aux unités de textures).
Le corps du constructeur est assez évident. On initialise les variables publiques de la classe avec les arguments du constructeur. On appelle également la fonction setupMesh() dans le constructeur :
Mesh(std::
vector<
Vertex>
vertices, std::
vector<
unsigned
int
>
indices, std::
vector<
Texture>
textures)
{
this
->
vertices =
vertices;
this
->
indices =
indices;
this
->
textures =
textures;
setupMesh();
}
Rien de particulier à signaler, détaillons la fonction setupMesh().
II-A. Initialisation▲
Grâce au constructeur, nous disposons d’une longue liste de données de maillage que nous pouvons utiliser pour l’affichage. Nous devons encore initialiser les tampons et préciser la disposition des données pour le vertex shader au moyen des pointeurs d’attributs de sommets. Cela ne devrait pas vous poser de problèmes, mais nous allons corser un peu les choses en introduisant les données des sommets dans une structure :
void
setupMesh()
{
glGenVertexArrays(1
, &
VAO);
glGenBuffers(1
, &
VBO);
glGenBuffers(1
, &
EBO);
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, vertices.size() *
sizeof
(Vertex), &
vertices[0
], GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() *
sizeof
(unsigned
int
), &
indices[0
], GL_STATIC_DRAW);
// position des sommets
glEnableVertexAttribArray(0
);
glVertexAttribPointer(0
, 3
, GL_FLOAT, GL_FALSE, sizeof
(Vertex), (void
*
)0
);
// normales
glEnableVertexAttribArray(1
);
glVertexAttribPointer(1
, 3
, GL_FLOAT, GL_FALSE, sizeof
(Vertex), (void
*
)offsetof
(Vertex, Normal));
// coordonnées de texture
glEnableVertexAttribArray(2
);
glVertexAttribPointer(2
, 2
, GL_FLOAT, GL_FALSE, sizeof
(Vertex), (void
*
)offsetof
(Vertex, TexCoords));
glBindVertexArray(0
);
}
Le code n’est pas très différent de ce qu’on attendait, mais il contient quelques astuces liées à la structure Vertex.
Les structures en C++ ont une propriété intéressante : les données sont organisées séquentiellement. Si nous représentions les données d’une structure comme un tableau de données, il ne contiendrait que les variables de la structure en ordre séquentiel, ce qui se transpose directement en un tableau de réels comme nous le souhaitons pour les tampons. Par exemple, si nous avions une structure complète initialisée ainsi :
Vertex vertex;
vertex.Position =
glm::
vec3(0.2
f, 0.4
f, 0.6
f);
vertex.Normal =
glm::
vec3(0.0
f, 1.0
f, 0.0
f);
vertex.TexCoords =
glm::
vec2(1.0
f, 0.0
f);
le contenu mémoire serait celui-ci :
[0.2f, 0.4f, 0.6f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f];
Grâce à cette propriété, on peut passer directement l’adresse d’une grande liste de structures pour initialiser les données de tampon, et ces données seront transmises sans problème :
glBufferData(GL_ARRAY_BUFFER, vertices.size() *
sizeof
(Vertex), vertices[0
], GL_STATIC_DRAW);
Naturellement, l’opérateur sizeof peut aussi être utilisé sur la structure pour obtenir la taille correcte en octets. Ici, cela donne 32 octets (8 réels de 4 octets chacun).
Une autre propriété des structures est la directive du préprocesseur appelée offsetof(s,m), qui prend en argument une structure et le nom d’un champ de cette structure. La macro retourne le décalage en octets de ce champ à compter du début de la structure. Cela est parfait pour définir le paramètre de décalage de la fonction glVertexAttribPointer() :
glVertexAttribPointer(1
, 3
, GL_FLOAT, GL_FALSE, sizeof
(Vertex), (void
*
)offsetof
(Vertex, Normal));
Le décalage est ainsi défini par la macro offsetof qui dans ce cas initialise le décalage du vecteur normal dans le tampon avec le décalage du vecteur normal dans la structure, qui vaut 3 réels, donc 12 octets. Noter que nous donnons au paramètre stride la taille de la structure Vertex.
Utiliser une structure comme celle-ci permet non seulement de produire un code plus lisible, mais nous permet aussi de modifier la structure. Si nous voulons ajouter un autre attribut, il suffit de l’ajouter à la structure sans avoir besoin de modifier le code.
II-B. Affichage▲
La dernière fonction à définir pour compléter la classe Mesh est la fonction Draw(). Mais pour afficher notre maille, il nous faut lier les textures avant d’appeler glDrawElements(). Cependant, cela n’est pas très facile, car nous ne savons pas combien de textures la maille utilise et de quel type elles sont. Comment initialiser les unités de textures et les échantillonneurs dans les shaders ?
Pour résoudre ce problème, nous allons choisir une convention pour les noms des textures : chaque texture diffuse sera nommée texture_diffuseN et chaque texture spéculaire texture_specularN, N étant un nombre variant de 1 jusqu’au nombre maximum de textures admises. Supposons que nous ayons trois textures diffuses et deux textures spéculaires, les échantillonneurs de textures s’appelleraient ainsi :
uniform
sampler2D
texture_diffuse1;
uniform
sampler2D
texture_diffuse2;
uniform
sampler2D
texture_diffuse3;
uniform
sampler2D
texture_specular1;
uniform
sampler2D
texture_specular2;
Avec cette convention, nous pouvons définir dans les shaders autant d’échantillonneurs de textures que nous voulons, et si une maille contient beaucoup de textures, nous saurons comment elles s’appellent. Le développeur pourra en utiliser autant qu’il le souhaite en définissant ses propres échantillonneurs (en définir moins conduirait à perdre un peu de temps avec les appels pour les lier).
Il existe beaucoup de solutions pour ce genre de problèmes et si vous n’aimez pas celle-ci, vous pouvez très bien en imaginer une autre.
Le code pour l’affichage devient donc :
void
Draw(Shader shader)
{
unsigned
int
diffuseNr =
1
;
unsigned
int
specularNr =
1
;
for
(unsigned
int
i =
0
; i <
textures.size(); i++
)
{
glActiveTexture(GL_TEXTURE0 +
i); // Activation de l’unité de texture adéquate avant liaison
// récupère le numéro de la texture (le N dans diffuse_textureN)
std::
string number;
std::
string name =
textures[i].type;
if
(name ==
"texture_diffuse"
)
number =
std::
to_string(diffuseNr++
);
else
if
(name ==
"texture_specular"
)
number =
std::
to_string(specularNr++
);
shader.setFloat(("material."
+
name +
number).c_str(), i);
glBindTexture(GL_TEXTURE_2D, textures[i].id);
}
glActiveTexture(GL_TEXTURE0);
// affiche le mesh
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0
);
glBindVertexArray(0
);
}
On commence par calculer la valeur de N par type de texture et on la concatène à la chaîne du type de texture pour obtenir le nom correct de la variable uniforme. On retrouve l’échantillonneur correspondant, on lui passe l’emplacement de l’unité de texture active et on lie cette texture.
On a aussi ajouté « material » au nom de la variable uniforme, car nous mémorisons en général les textures dans une structure material (cela peut changer selon les implémentations).
Notons que nous incrémentons les compteurs lors de leur conversion en chaîne de caractères. En C++, l’instruction i++ retourne la valeur de i, puis incrémente i, tandis que ++i incrémente d’abord i, puis retourne la valeur incrémentée. Dans notre cas, la valeur passée est la valeur avant incrémentation.
Vous trouverez le code source complet de la classe Mesh ici.
La classe Mesh que nous avons définie est une encapsulation de beaucoup de concepts discutés dans les premiers chapitres. Dans le prochain chapitre, nous créerons un modèle de conteneur pour plusieurs mailles et qui implémente complètement l’interface de chargement d’Assimp.
II-C. 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.