Navigation▲
Tutoriel précédent : projecteurs |
Tutoriel suivant : shadow mapping (première partie) |
I. Contexte▲
Nous sommes arrivés jusqu'ici en utilisant des modèles générés manuellement. Comme vous vous en doutez, la méthode de spécifier manuellement la position et autres attributs pour chaque sommet d'un objet n'est pas très adaptée aux objets complexes. Ça va bien pour une boîte, une pyramide, et une simple surface en tuiles, mais qu'en est-il pour un visage humain ? Dans le monde des jeux vidéo et des applications commerciales, le procédé de création d'un maillage est pris en charge par des artistes, qui utilisent des applications telles que Blender, Maya et 3ds Max. Ces applications fournissent des outils avancés qui aident les artistes à créer des modèles extrêmement sophistiqués. Lorsque le modèle est achevé, il est enregistré dans un fichier, dans un des multiples formats disponibles. Le fichier contient la complète définition de la géométrie du modèle. Il peut maintenant être chargé dans un moteur de jeu (si tant est que le moteur supporte ce format particulier), et son contenu peut être utilisé pour remplir les tampons de sommets et d'indices, pour le rendu. Connaître la manière d'analyser le format de définition de la géométrie, et charger des modèles professionnels est crucial pour porter votre programmation 3D à un autre niveau.
Développer un analyseur par vous-même peut prendre beaucoup de votre temps. Si vous voulez être capable de charger des modèles depuis différentes sources, vous devez étudier chaque format et développer un analyseur spécifique à chaque fois. Certains formats sont simples, mais d'autres sont très complexes et vous pourriez finir en passant beaucoup trop de temps sur quelque chose qui s'éloigne du cœur de la programmation 3D. Par conséquent, l'approche décrite dans ce tutoriel est d'utiliser une bibliothèque externe pour prendre en charge l'analyse et le chargement des modèles à partir de fichiers.
La bibliothèque Open Asset Import, ou Assimp, est une bibliothèque open source, qui peut gérer de nombreux formats 3D, incluant les plus populaires. Elle est portable et disponible à la fois sous Windows et Linux. Elle est très simple à utiliser et s'intègre dans des applications écrites en C/C++.
Il n'y a pas beaucoup de théorie dans ce tutoriel. Voyons directement comment intégrer Assimp dans nos applications 3D. (Avant de débuter, vérifiez que vous avez installé Assimp à partir du lien ci-dessus.)
II. Explication du code▲
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
class
Mesh
{
public
:
Mesh();
~
Mesh();
bool
LoadMesh(const
std::
string&
Filename);
void
Render();
private
:
bool
InitFromScene(const
aiScene*
pScene, const
std::
string&
Filename);
void
InitMesh(unsigned
int
Index, const
aiMesh*
paiMesh);
bool
InitMaterials(const
aiScene*
pScene, const
std::
string&
Filename);
void
Clear();
#define INVALID_MATERIAL 0xFFFFFFFF
struct
MeshEntry {
MeshEntry();
~
MeshEntry();
bool
Init(const
std::
vector&
Vertices,
const
std::
vector&
Indices);
GLuint VB;
GLuint IB;
unsigned
int
NumIndices;
unsigned
int
MaterialIndex;
}
;
std::
vector m_Entries;
std::
vector m_Textures;
}
;
La classe Mesh représente l'interface entre Assimp et notre application OpenGL. Un objet de cette classe prend un nom de fichier comme paramètre à la méthode LoadMesh(), utilise Assimp pour charger le modèle, puis crée les tampons de sommets et d'indices et les objets Texture, qui contiennent les données du modèle, dans un format que notre programme comprend. Afin de dessiner le maillage, nous utiliserons la méthode Render(). La structure interne de la classe Mesh correspond à la manière dont Assimp charge les modèles. Assimp utilise un objet aiScene pour représenter le maillage chargé. Cet objet aiScene contient les structures de maillage qui encapsulent des parties du modèle. Il doit y avoir au moins une structure de maillage dans l'objet aiScene. Les modèles complexes peuvent contenir de multiples maillages. L'entrée m_Entries de la classe Mesh est un vecteur de la structure MeshEntry, où chaque structure correspond à un maillage de l'objet aiScene. Cette structure contient le tampon de sommets, le tampon d'indices, et l'indice du matériau. Pour l'instant, un matériau est simplement une texture, et comme les maillages peuvent partager des matériaux, nous avons un vecteur séparé pour ceux-ci (m_Textures). MeshEntry::MaterialIndex pointe sur une des textures de m_Textures.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
bool
Mesh::
LoadMesh(const
std::
string&
Filename)
{
// Release the previously loaded mesh (if it exists)
Clear();
bool
Ret =
false
;
Assimp::
Importer Importer;
const
aiScene*
pScene =
Importer.ReadFile(Filename.c_str(), aiProcess_Triangulate |
aiProcess_GenSmoothNormals |
aiProcess_FlipUVs);
if
(pScene) {
Ret =
InitFromScene(pScene, Filename);
}
else
{
printf("Error parsing '%s': '%s'
\n
"
, Filename.c_str(), Importer.GetErrorString());
}
return
Ret;
}
Cette méthode est le point d'entrée du chargement du maillage. Nous créons une instance de la classe Assimp::Importer sur la pile et appelons sa méthode ReadFile(). Cette méthode prend deux paramètres : le chemin complet d'accès au fichier du modèle, et un masque d'actions en post-traitement. Assimp est capable d'effectuer de nombreuses actions utiles sur les modèles chargés. Par exemple, il peut générer les normales pour les modèles qui n'en ont pas, optimiser la structure du modèle pour améliorer les performances, etc. La liste complète des options est disponible ici. Dans ce tutoriel, nous utilisons trois options :
- aiProcess_Triangulate, qui convertit les maillages faits de polygones non triangles en maillages constitués uniquement de polygones triangles. Par exemple, un maillage fait de rectangles peut être converti en maillage de triangles en créant deux triangles par rectangle ;
- la seconde option, aiProcess_GenSmoothNormals, génère des normales aux sommets, dans le cas où le modèle original n'en contiendrait pas encore. Notez que les options de post-traitement sont des masques de bits, vous permettant d'appliquer diverses options en les combinant avec un OU logique ;
- la dernière option, aiProcess_FlipUVs, inverse les coordonnées de texture le long de l'axe Y. Cette option est requise pour dessiner correctement le modèle de Quake utilisé pour la démonstration. Vous devrez probablement vérifier les options que vous allez utiliser, en fonction des modèles d'entrée. Si le maillage a été chargé correctement, vous obtenez un pointeur sur un objet aiScene. Cet objet contient toutes les données du modèle, divisées en aiMesh. Ensuite nous appelons la méthode InitFromScene(), pour initialiser l'objet Mesh.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
bool
Mesh::
InitFromScene(const
aiScene*
pScene, const
std::
string&
Filename)
{
m_Entries.resize(pScene->
mNumMeshes);
m_Textures.resize(pScene->
mNumMaterials);
// Initialise les maillages de la scène, un par un
for
(unsigned
int
i =
0
; i <
m_Entries.size() ; i++
) {
const
aiMesh*
paiMesh =
pScene->
mMeshes[i];
InitMesh(i, paiMesh);
}
return
InitMaterials(pScene, Filename);
}
Nous commençons l'initialisation de l'objet Mesh, en définissant l'espace occupé par les vecteurs de maillages et de textures, pour tous les maillages et matériaux dont nous allons avoir besoin. Les nombres sont donnés par les membres mNumMeshes et mNumMaterials de l'objet aiScene, respectivement. Ensuite, nous parcourons le tableau mMeshes de l'objet aiScene, et initialisons les entrées de maillage une à une. Enfin, nous initialisons les matériaux.
112.
113.
114.
115.
116.
117.
void
Mesh::
InitMesh(unsigned
int
Index, const
aiMesh*
paiMesh)
{
m_Entries[Index].MaterialIndex =
paiMesh->
mMaterialIndex;
std::
vector Vertices;
std::
vector Indices;
...
Nous commençons l'initialisation du maillage, en stockant l'indice de son matériau. Il sera utilisé lors du rendu, pour utiliser la bonne texture. Ensuite, nous créons deux vecteurs STL, pour stocker le contenu des tampons de sommets et d'indices. Le vecteur STL possède la caractéristique intéressante de stocker son contenu dans un tampon continu en mémoire. Cela facilite le chargement des données dans un tampon OpenGL (en utilisant la fonction glBufferData()).
119.
120.
121.
122.
123.
124.
125.
126.
127.
128.
129.
130.
131.
const
aiVector3D Zero3D(0.0
f, 0.0
f, 0.0
f);
for
(unsigned
int
i =
0
; i <
paiMesh->
mNumVertices ; i++
) {
const
aiVector3D*
pPos =
&
(paiMesh->
mVertices[i]);
const
aiVector3D*
pNormal =
&
(paiMesh->
mNormals[i]) : &
Zero3D;
const
aiVector3D*
pTexCoord =
paiMesh->
HasTextureCoords(0
) ? &
(paiMesh->
mTextureCoords[0
][i]) : &
Zero3D;
Vertex v(Vector3f(pPos->
x, pPos->
y, pPos->
z),
Vector2f(pTexCoord->
x, pTexCoord->
y),
Vector3f(pNormal->
x, pNormal->
y, pNormal->
z));
Vertices.push_back(v);
}
...
Ici, nous préparons le contenu du tampon de sommets, en remplissant le vecteur de Vertex. Nous utilisons les attributs suivants de la classe aiMesh :
- mNumVertices : le nombre de sommets ;
- mVertices : un tableau de mNumVertices aiVector3D contenant les positions ;
- mNormals : un tableau de mNumVertices aiVector3D contenant les normales ;
- mTextureCoords : un tableau de mNumVertices aiVector3D contenant les coordonnées de texture. C'est en fait un tableau à deux dimensions, car chaque sommet peut avoir plusieurs coordonnées de texture.
Donc, nous avons en fait trois tableaux séparés, contenant tout ce dont nous avons besoin pour les sommets, et nous devons prendre chaque attribut dans le tableau correspondant, afin de remplir une structure Vertex. Cette structure est ensuite mise en fin du vecteur de sommets (en gardant donc le même indice que dans les trois tableaux de aiMesh). Notez que certains modèles n'ont pas de coordonnées de texture, donc, avant d'accéder au tableau mTextureCoords (et par la même occasion, risquer de causer une erreur de segmentation), nous vérifions si les coordonnées de texture existent, en appelant la méthode HasTextureCoords(). En plus de cela, un maillage peut contenir plusieurs coordonnées de texture par sommet. Dans ce tutoriel, nous prenons le chemin facile, en n'utilisant que la première coordonnée de texture. Ainsi, on n'accède qu'à la première ligne du tableau mTextureCoords. Par conséquent, la méthode HasTextureCoords() est toujours appelée pour la première ligne. Si une coordonnée de texture n'existe pas, la structure Vertex sera initialisée avec le vecteur zéro.
133.
134.
135.
136.
137.
138.
139.
for
(unsigned
int
i =
0
; i <
paiMesh->
mNumFaces ; i++
) {
const
aiFace&
Face =
paiMesh->
mFaces[i];
assert(Face.mNumIndices ==
3
);
Indices.push_back(Face.mIndices[0
]);
Indices.push_back(Face.mIndices[1
]);
Indices.push_back(Face.mIndices[2
]);
}
...
Ensuite nous créons le tampon d'indices. Le membre mNumFaces de la classe aiMesh nous indique le nombre de polygones existant, et le tableau mFaces contient leurs données (qui sont les indices des sommets). Nous vérifions que le nombre d'indices dans le polygone est bien trois (en chargeant le modèle, nous avons demandé à ce qu'il soit converti en triangles, mais c'est toujours bon de vérifier). Ensuite, nous extrayons les indices du tableau mIndices et les mettons en fin du vecteur d'indices.
Enfin, la structure MeshEntry est initialisée, en utilisant les vecteurs de sommets et d'indices. Il n'y a rien de nouveau dans la méthode MeshEntry::Init(), qui n'est donc pas listée ici. Elle utilise glGenBuffers(), glBindBuffer(), et glBufferData(), pour créer et remplir les tampons d'indices et de sommets. Regardez le fichier source pour plus de détails.
144.
145.
146.
147.
bool
Mesh::
InitMaterials(const
aiScene*
pScene, const
std::
string&
Filename)
{
for
(unsigned
int
i =
0
; i <
pScene->
mNumMaterials ; i++
) {
const
aiMaterial*
pMaterial =
pScene->
mMaterials[i];
...
Cette méthode charge toutes les textures utilisées par le modèle. L'attribut mNumMaterials de l'objet aiScene contient le nombre de matériaux, et mMaterials est un tableau de pointeurs sur des structures aiMaterialaiMaterialaiMaterial (de cette taille). La structure aiMaterial est une bestiole complexe, mais cache sa complexité derrière un nombre réduit de méthodes. En général, un matériau est organisé comme une pile de textures, et entre deux textures consécutives les fonctions de mélange et de puissance doivent être appliquées. Par exemple, la fonction de mélange peut nous dire d'ajouter la couleur de deux textures, et la fonction de puissance peut nous dire de diviser le résultat par deux. Les fonctions de mélange et de force font partie de la structure aiMaterial, et peuvent être récupérées. Pour nous faciliter la vie, et rester en accord avec la manière de fonctionner de notre shader d'éclairage, nous ignorons les fonctions de mélange et de puissance, et utilisons la texture telle quelle.
166.
167.
168.
169.
170.
171.
172.
173.
174.
175.
176.
177.
178.
179.
180.
181.
m_Textures[i] =
NULL
;
if
(pMaterial->
GetTextureCount(aiTextureType_DIFFUSE) >
0
) {
aiString Path;
if
(pMaterial->
GetTexture(aiTextureType_DIFFUSE, 0
, &
Path, NULL
, NULL
, NULL
, NULL
, NULL
) ==
AI_SUCCESS) {
std::
string FullPath =
Dir +
"/"
+
Path.data;
m_Textures[i] =
new
Texture(GL_TEXTURE_2D, FullPath.c_str());
if
(!
m_Textures[i]->
Load()) {
printf("Error loading texture '%s'
\n
"
, FullPath.c_str());
delete
m_Textures[i];
m_Textures[i] =
NULL
;
Ret =
false
;
}
}
}
...
Un matériau peut contenir plusieurs textures, et toutes ne contiennent pas des couleurs. Par exemple, une texture peut définir un relief, contenir des normales ou encore des déplacements, etc. Comme notre shader d'éclairage n'utilise pour l'instant qu'une seule texture pour tous les types de lumières, nous ne sommes intéressés que par la texture diffuse. Par conséquent, nous vérifions combien de textures diffuses existent, en utilisant la méthode aiMaterial::GetTextureCount(). Cette méthode prend le type de texture en paramètre, et retourne le nombre de textures de ce type spécifique. Si au moins une texture diffuse est disponible, nous la récupérons en utilisant la méthode aiMaterial::GetTexture(). Le premier paramètre de cette méthode est le type. Ensuite vient l'indice et nous utilisons toujours 0. Après cela, nous devons spécifier l'adresse d'une chaîne de caractères, où le nom du fichier de cette texture va être récupéré. Enfin, il y a cinq paramètres d'adresses, qui permettent de récupérer diverses configurations de la texture, telles que le facteur de mélange, le mode d'application, l'opération de texture, etc. Ceux-ci sont optionnels, et nous les ignorons pour l'instant, en passant NULL à chacun. Nous ne sommes intéressés que par le nom du fichier de la texture, et nous le concaténons au dossier où le modèle se trouve. Le dossier avait été récupéré au début de la fonction (non listée ici), et nous supposons que la texture et le modèle se trouvent dans le même dossier. Si la structure du dossier est plus compliquée, vous devrez chercher la texture autre part. Nous créons notre objet texture et le chargeons.
188.
189.
190.
191.
192.
193.
194.
if
(!
m_Textures[i]) {
m_Textures[i] =
new
Texture(GL_TEXTURE_2D, "../Content/white.png"
);
Ret =
m_Textures[i]->
Load();
}
}
return
Ret;
}
Le morceau de code ci-dessus est un petit contournement d'un problème que vous pourrez rencontrer, si vous commencez à charger des modèles trouvés sur Internet. Parfois, le modèle n'inclut pas de texture et dans ce cas, vous ne verrez rien, car la couleur qui est échantillonnée à partir d'une texture inexistante est le noir, par défaut. Une manière de gérer cela est de détecter le cas, et de le traiter comme un cas spécial dans le shader ou dans un shader dédié. Ce tutoriel applique une approche plus simple, en chargeant une texture contenant un simple texel blanc (vous trouverez cette texture avec les sources attachées). Cela fera que la couleur par défaut des pixels sera le blanc. Ce ne sera probablement pas très joli par contre, au moins, vous verrez quelque chose. Cette texture prend vraiment très peu de place, et nous permet d'utiliser le même shader dans tous les cas.
198.
199.
200.
201.
202.
203.
204.
205.
206.
207.
208.
209.
210.
211.
212.
213.
214.
215.
216.
217.
218.
219.
220.
221.
222.
223.
void
Mesh::
Render()
{
glEnableVertexAttribArray(0
);
glEnableVertexAttribArray(1
);
glEnableVertexAttribArray(2
);
for
(unsigned
int
i =
0
; i <
m_Entries.size() ; i++
) {
glBindBuffer(GL_ARRAY_BUFFER, m_Entries[i].VB);
glVertexAttribPointer(0
, 3
, GL_FLOAT, GL_FALSE, sizeof
(Vertex), 0
);
glVertexAttribPointer(1
, 2
, GL_FLOAT, GL_FALSE, sizeof
(Vertex), (const
GLvoid*
)12
);
glVertexAttribPointer(2
, 3
, GL_FLOAT, GL_FALSE, sizeof
(Vertex), (const
GLvoid*
)20
);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_Entries[i].IB);
const
unsigned
int
MaterialIndex =
m_Entries[i].MaterialIndex;
if
(MaterialIndex <
m_Textures.size() &&
m_Textures[MaterialIndex]) {
m_Textures[MaterialIndex]->
Bind(GL_TEXTURE0);
}
glDrawElements(GL_TRIANGLES, m_Entries[i].NumIndices, GL_UNSIGNED_INT, 0
);
}
glDisableVertexAttribArray(0
);
glDisableVertexAttribArray(1
);
glDisableVertexAttribArray(2
);
}
Cette fonction encapsule le dessin d'un maillage, et le détache de l'application principale (dans les précédents tutoriels, il faisait partie du code de l'application elle-même). Le tableau m_Entries est parcouru, et les tampons de sommets et d'indices de chaque nœud sont liés au contexte OpenGL. L'indice du matériau utilisé par le nœud permet de récupérer l'objet texture, à partir du tableau m_Textures, et la texture est liée elle aussi. Enfin la commande de dessin est exécutée. Maintenant vous pouvez avoir plusieurs objets Mesh chargés depuis des fichiers, et les dessiner un par un, en appelant la méthode Mesh::Render.
La dernière chose qu'il nous reste à étudier est quelque chose que nous avons laissé de côté lors des tutoriels précédents. Si vous continuez, et chargez des modèles en utilisant le code ci-dessus, vous observerez probablement des anomalies visuelles dans votre scène. La raison est que les triangles qui sont plus éloignés de la caméra sont dessinés au-dessus de ceux qui sont plus proches. Afin de prévenir cela, nous devons activer le fameux test de profondeur (aussi appelé Z-test). Lorsque le test de profondeur est activé, le rasterizer compare, avant son rendu, la profondeur de chaque pixel, par rapport à celle du pixel déjà dessiné à la même position sur l'écran. Le pixel dont la couleur est finalement choisie est celui qui a « gagné » le test de profondeur (c'est-à-dire le plus proche de la caméra). Le test de profondeur n'est pas activé par défaut, et la ligne ci-dessus s'occupe de cela (comme part de l'initialisation d'OpenGL dans la fonction GLUTBackendRun()). C'est une des trois portions de code nécessaires pour le test profondeur (voir plus bas).
La seconde portion est l'initialisation du tampon de profondeur. Afin de pouvoir comparer la profondeur entre deux pixels, la profondeur de « l'ancien » pixel doit être stockée quelque part (le tampon du « nouveau » pixel est disponible, car passé au vertex shader). Dans ce but, nous avons un tampon spécial connu sous le nom de tampon de profondeur (ou Z buffer). Il a les mêmes dimensions que l'écran, afin que chaque pixel du tampon de couleurs ait un emplacement correspondant dans le tampon de profondeur. Cet emplacement stocke toujours la profondeur du pixel le plus proche et il est utilisé dans le test de profondeur pour la comparaison.
La dernière chose que nous devons faire est de vider le tampon de profondeur au début de chaque image rendue. Si nous ne le faisons pas, le tampon contiendra les vieilles valeurs de l'image précédente, et la profondeur des pixels de la nouvelle image sera comparée à la profondeur des pixels de l'image précédente. Comme vous pouvez l'imaginer, cela va causer de sérieuses corruptions (essayez !). La fonction glClear() prend un masque de bits des tampons sur lesquels elle doit opérer. Jusqu'ici, nous n'avions vidé que le tampon de couleurs. Il est maintenant temps de vider aussi le tampon de profondeur.
III. Sources▲
Vous pouvez télécharger les sources de ce projet en suivant ce lien :
IV. Remerciements▲
Merci à Etay Meiri de nous permettre de traduire son tutoriel.
Merci à LittleWhite pour ses corrections et à milkoseck et ClaudeLELOUP pour leur relecture.
Navigation▲
Tutoriel précédent : projecteurs |
Tutoriel suivant : shadow mapping (première partie) |