
3D model class
Well, it's time to roll up your sleeves and plunge into the wilds of working with the code to download and convert Assimp data! The goal of the lesson is to create another class, which is an entire model containing a multitude of polygonal meshes, and also, possibly, consisting of several sub-objects. A building with a wooden balcony, a tower and, for example, a swimming pool will still be loaded as a single model. With the help of Assimp, we will load the data and convert it into a set of Mesh objects from the last lesson.
Let's not pull the cat by the tail - let's see the Model class structure:
class Model { public: Model(char *path) { loadModel(path); } void Draw(Shader shader); private: vector<Mesh> meshes; string directory; void loadModel(string path); void processNode(aiNode *node, const aiScene *scene); Mesh processMesh(aiMesh *mesh, const aiScene *scene); vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName); };
As you can see, the class contains a vector of
Mesh type objects and requires specifying the path to the model file in the constructor. Loading takes place directly in the constructor and uses the auxiliary method
loadModel . All private methods are responsible for working with some part of the Assimp data import process and we will discuss them in more detail below.
The
Draw method is trivial: here we iterate through the list of polygonal meshes and call their
Draw methods.
void Draw(Shader shader) { for(unsigned int i = 0; i < meshes.size(); i++) meshes[i].Draw(shader); }
Import 3D model in OpenGL
First, turn on the required Assimp header files:
#include <assimp/Importer.hpp> #include <assimp/scene.h> #include <assimp/postprocess.h>
The first method that will be invoked in the constructor is
loadModel , which uses the library to load the model into a structure called the scene object in Assimp terminology. Remember the
first lesson of the section — we know that the scene object is the root object of the data hierarchy in Assimp. As soon as we get the finished object of the scene we will be able to access all the necessary model data.
A great feature of the API Assimp is its abstraction from particulars and technical details of loading various formats. All download goes to one call:
Assimp::Importer importer; const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
An instance of the
Importer class is created first, then there is a call to
ReadFile with the parameters for the model file path and a list of post-processing flags. In addition to simply loading the data, the API allows you to specify some flags that force Assimp to do some additional processing on the imported data. The
aiProcess_Triangulate setting indicates that if there are objects in the model that are not composed of triangles, the library will convert such objects into a grid of triangles. The
aiProcess_FlipUVs flag activates the inversion of the texture coordinates along the oY axis where necessary (as you learned from the
texturing lesson - in OpenGL, almost all images were inverted along the oY axis, so this flag helps to correct everything as it should). There are some more useful options:
- aiProcess_GenNormals : calculates the normals for the vertices, if they are absent in the source data;
- aiProcess_SplitLargeMeshes : splits large polygonal grids into smaller grids, which is useful if your render has a limit on the number of processed values;
- aiProcess_OptimizeMeshes : conducts the reverse action - trying to sew a lot of grids into one large one to optimize the number of calls per drawing.
The library API contains
many more
processing options . The very same loading model is surprisingly simple. A little harder is the work involved in extracting data from the resulting scene object and converting it into
Mesh objects.
Full listing of the
loadModel method:
void loadModel(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); }
After loading, we check for zero pointers to the scene object and the root node of the scene, as well as a status flag indicating that the returned data is incomplete. Any of these events results in an error message with a description obtained from the method of the import object object
GetErrorString , and a further exit from the function. Also from the full path to the file we extract the path to the directory with it.
If everything passed without errors, then we proceed to processing the scene nodes by passing the root node to the recursive
processNode method. The recursive processing form is chosen as obvious for the scene hierarchy, where after processing the current node we must process each of its descendants, if any. Let me remind you that the recursive function performs some processing and then calls itself with modified parameters until a certain condition is violated - the exit condition. In our case, this condition is the lack of processing new nodes.
As you remember, the Assimp data structure assumes that each node stores a set of polygonal mesh indices that are actually stored in the scene object. Accordingly, for each node and its descendants, we must sample the data of polygonal meshes using a list of grid indices stored in the node. Listing of the
processNode method
is shown below:
void processNode(aiNode *node, const aiScene *scene) {
At the beginning, we get a pointer to an Assimp mesh object by sampling the
scene object's
mMeshes array using the indices of the current node. Next, the
aiMesh object
is converted using the
processMesh method into an instance of our
Mesh class and stored in the
meshes list.
After receiving all the polygonal grids of the node, we go through the list of descendants, performing the same
processNode already for them. After the list of descendants has dried up, the method ends.
The attentive reader might have noticed that the list of grids could have been obtained simply by walking through their array stored in the object of the scene, without all this commotion with indexes in the node. However, a more complicated method that was used is justified by the possibility of establishing parent-child relationships between polygonal grids. Recursive passage allows you to establish similar relationships between certain objects.
Example of use: any multi-component moving model, for example, a car. When moving it, you would like all dependent parts (engine, steering wheel, tires, etc.) to move as well. Such a system of objects is easily created as a parent-child hierarchy.
At the moment, such a system is not used by us, however, it is recommended to adhere to this approach, if in the future you want to add more control capabilities of polygonal meshes. Ultimately, these relationships are established by the model artists who created this model.
The next step is to convert the Assimp data to the
Mesh class format that we created earlier.
Convert Assimp to Mesh
Directly converting an
aiMesh object to our internal format is not too burdensome. It is enough to consider the attributes of the polygonal mesh object that we need and save in the
Mesh type object. The skeleton of the
processMesh method is shown below:
Mesh processMesh(aiMesh *mesh, const aiScene *scene) { vector<Vertex> vertices; vector<unsigned int> indices; vector<Texture> textures; for(unsigned int i = 0; i < mesh->mNumVertices; i++) { Vertex vertex;
Processing is reduced to three steps: reading the vertices, indexes and obtaining material data. The obtained data is stored in one of the three declared vectors, which are used in the creation of the
Mesh object, which will be returned.
Obtaining vertex data is trivial: we declare a
Vertex structure, an instance of which we add to the
vertices array at each processing step. The loop runs until the stored vertex data runs out (determined by the value of
mesh-> mNumVertices ). In the body of the loop, we fill the structure fields with relevant data, for example, for the position of the vertex:
glm::vec3 vector; vector.x = mesh->mVertices[i].x; vector.y = mesh->mVertices[i].y; vector.z = mesh->mVertices[i].z; vertex.Position = vector;
Note that we declare an auxiliary object of type
vec3 , for Assimp stores information in its internal data types, which are not directly converted to the types declared by glm.
The array of vertex positions in Assimp is simply called mVertices, which is somewhat unintuitive.
For normals, the process is similar:
vector.x = mesh->mNormals[i].x; vector.y = mesh->mNormals[i].y; vector.z = mesh->mNormals[i].z; vertex.Normal = vector;
Have you guessed how the texture coordinates are read? It was not there: Assimp assumes that vertices can have up to 8 sets of texture coordinates. We do not need such wealth, it is enough to obtain the data of the first set. And, at the same time, it would be nice to check whether the grid in question has texture coordinates in principle:
if(mesh->mTextureCoords[0])
An instance of the Vertex structure is now fully equipped with the required vertex attributes and can be sent for storage in the vertices array. This process will be repeated by the number of vertices in the polygonal mesh.
Indices
The Assimp library defines each polygonal mesh as containing an array of faces, where each face is represented by a certain primitive. In our case, these are always triangles (thanks to the
aiProcess_Triangulate import
option ). The face itself contains a list of indices that indicate which vertices and in what order are used to draw the primitive of this face. Accordingly, we can go through the list of faces and read all the index data:
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]); }
After the end of the outer loop, we
’ll have a full list of indices in our hands, enough to display the grid using the OpenGL
glDrawElements procedure. However, in order for our lesson to be complete, we will sort out the material to add details to our model.
Material
The grid object only refers to the index of the object material actually stored in the
mMaterials array of the
scene object. Accordingly, the material data can be obtained at this index, if the material is, of course, assigned to the grid:
if(mesh->mMaterialIndex >= 0) { aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex]; vector<Texture> diffuseMaps = loadMaterialTextures(material, aiTextureType_DIFFUSE, "texture_diffuse"); textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end()); vector<Texture> specularMaps = loadMaterialTextures(material, aiTextureType_SPECULAR, "texture_specular"); textures.insert(textures.end(), specularMaps.begin(), specularMaps.end()); }
First we get a pointer to the
aiMaterial object from the object mMaterials array. Next, we load diffuse textures and / or specular textures. The material object contains an array of paths to the textures of each type. Each type of texture has its own identifier with
aiTextureType_ prefix. The helper method
loadMaterialTextures returns a vector of
Texture objects containing textures of the appropriate type, extracted from the material object. The data of these vectors is stored in a common array of textures of the model object.
The
loadMaterialTextures function
itself in a loop goes through all the textures of the specified type, reads the paths to the files, loads and generates textures of the OpenGL format, saving the necessary information into an instance of the
Texture structure:
vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName) { 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; }
First, the number of textures is checked by calling
GetTextureCount , which is passed to the texture type of interest. Next, the path to the texture file is read using the
GetTexture method, which returns the result as a string of type
aiString . Another auxiliary function,
TextureFromFile , directly loads the file and generates a texture (using the SOIL library) with the return of its identifier. If you are not sure how exactly this function should look, then its full text can be found in the full program code at the end of the article.
Note that in our code there is an assumption about the nature of the texture file paths stored in the model file. We believe that these paths are relative to the folder containing the model file. In this case, the full path to the texture file can be obtained by merging the previously saved path to the folder with the model (done in the loadModel method) and the path to the texture (therefore, the path to the folder is also passed to GetTexture ).
Some models available for download on the web store texture paths in an absolute form, which will obviously cause problems on your machine. You will probably need to use an editor to get the textures in order.
Well, it seems, and all that concerns the import model, using Assimp?
Significant optimization
Not really. The opportunity for optimization remains open (although it is not necessary). In many scenes, some objects can reuse a certain amount of textures. Imagine a house where granite texture is used for walls. But the same texture is perfect for the floor, ceilings, stairs, and maybe for a table or a small well at a distance. Loading a texture from a file is not the most lightweight procedure, but the current implementation loads and creates texture objects for each grid separately, even if such a file has already been loaded. Such an oversight can easily become a bottleneck in the implementation of loading a model file.
As an optimization, we will create a separate list of already loaded textures, which we will store in the scope of the
Model object, and when loading a regular texture we will check for its presence in the list of loaded textures. This will help save decent processing power on duplicates. But in order to check for duplicates, you will have to store the path to the texture in the
Texture structure:
struct Texture { unsigned int id; string type; aiString path;
The list of already loaded textures will be formatted as a vector declared as a closed variable of the
Model class:
vector<Texture> textures_loaded;
And in the
loadMaterialTextures method,
we will search for the occurrence of the path of the loaded texture in this vector and, if there is a copy, skip the load, substituting the identifier of the already loaded texture into the array of textures of the current polygonal mesh:
vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName) { 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.C_Str(), str.C_Str()) == 0) { textures.push_back(textures_loaded[j]); skip = true; break; } } if(!skip) {
Done! We have in our hands not only a universal, but also an optimized system for loading models, coping with its task rather quickly.
Some versions of Assimp become noticeably slow, being compiled as a debug version or used in the debug build of your project. If you come across this behavior, you should try working with release assemblies.
The complete source code, including the optimization, is located
here .
Containers - down!
Well, it's time to test our system in a battle by feeding it a real model, developed by a professional 3D artist, and not marvelously draped by Kulibin on his knee miracle-yudo (although admit, those containers were one of the most beautiful representatives of cubes you have seen ). Not wanting to win all the glory alone, I’ll turn to someone else’s creativity: the experimental model will be a
nanotechnological costume from the Crysis shooter (in this case, downloaded from tf3dm.com, any other model will do). The model is prepared as an .obj file and an auxiliary .mtl, which contains data on diffuse, specular textures and normal maps). You can download the model
here (slightly modified). I also recall that all the textures should be in the folder with the model.
Modifying the model file consists in changing the paths to textures to relative, instead of absolute, which is stored in the original file.
In the code, we declare a Model instance and pass the path to the model file to the constructor. Upon successful loading, the model will be displayed by calling its Draw method in the main program loop. Actually, everything! No hassle with allocating buffers, assigning pointers to vertex attributes and drawing calls through OpenGL procedures - now one line is enough. It remains only to create a simple set of shaders, where the fragment will only produce the diffuse texture color of the model. The result will be something like this:

The full source code is
here .
By introducing two light sources, connecting the use of the specular reflection maps and the lighting calculation model from the
relevant lesson you can achieve remarkable results:

Even I will have to admit that this is the result of the harshness of my darling containers. With the help of Assimp you have the opportunity to download almost any of the thousands of models available on the network. There is no one resource that provides free download of models in several formats. Of course, not all models will be successfully loaded, have incorrect texture paths or, in principle, be in a format not supported by Assimp.
Note per. - I apologize for the link to the original, driven through the MC (o) rotator links. Habroparser refused to recognize a normal link in the original URL.