Support me!
If you enjoy these webpages and you want to show your gratitude, feel free to support me in anyway!
Like Me On Facebook! Megabyte Softworks Facebook
Like Me On Facebook! Megabyte Softworks Patreon
Donate $1
Donate $2
Donate $5
Donate $10
Donate Custom Amount
019.) Assimp Model Loading
<< back to OpenGL 4 series

Hi fellow internet friends! I am very happy that you’re reading lines of another tutorial yet again! In this one we will finally learn how to read 3D models and how to display them, making our scenes a lot more interesting than just some mathematically defined objects like torus. We will do this using Assimp library, which supports most of the common 3D model formats. So let’s not waste words anymore and let’s begin!

Prerequisities

To be able to compile this in VS2017 included solution, you will have to run a provided script in the root of the repository called download_prerequisities.sh. It's a shell script, which might make you think it won't work on Windows, but if you run it in Git Bash shell or PowerShell, it will work just fine! What this script does is that it updates all subrepositories and downloads pre-built libraries for Assimp (from my server). They were too huge to be added to repository so I have pre-built them on my machine and uploaded to my server. Hope it works on all machines, so far I've tested on three machines and it worked. If you have any problems compiling it from the solution file, let me know.

Or you can use CMake and build it yourself, that should work as well .

Short introduction

At first, this task might look like a difficult one. There are lots of 3D model formats and every one of them has some specifics. And that’s where Assimp library comes in, because Assimp library loads most of common 3D model file formats in an unified way, so that we don’t have to care about their specifics. The most common file formats you can find (as free 3D models) throughout internet include:

  • OBJ – Wavefront object file, it’s a very simple text format containing lists of vertices / texture coordinates, normal and faces
  • 3DS – originally used by Autodesk 3ds Max, it’s a binary file format and is commonly used to transfer 3D models between different softwares
  • OFF – another text file format, this one is super simple to read

Of course there are many more, but I usually download and work with those formats. Now that we have learned a bit about model formats, let’s proceed with creating a class for 3D models.

AssimpModel class

Before we do anything else, let’s have an overview of the class to make a big picture:

class AssimpModel : public StaticMesh3D
{
public:
	AssimpModel(const std::string& filePath, const std::string& defaultTextureName,
		bool withPositions, bool withTextureCoordinates, bool withNormals);
	AssimpModel(const std::string& filePath, bool withPositions = true, bool withTextureCoordinates = true, bool withNormals = true);

	bool loadModelFromFile(const std::string& filePath, const std::string& defaultTextureName = "");

	void render() const override;
	void renderPoints() const override;

protected:
	void loadMaterialTexture(const int materialIndex, const std::string& textureFileName);
	static std::string aiStringToStdString(const aiString& aiStringStruct);

	std::string _modelRootDirectoryPath;
	std::vector _meshStartIndices;
	std::vector _meshVerticesCount;
	std::vector _meshMaterialIndices;
	std::map _materialTextureKeys;
};

Because this class is inherited from StaticMesh3D, most of the common properties are simply inherited and the only new function there is loadModelFromFile, which takes two parameters. One parameter is simply path to a 3D model file and second is called defaultTextureName. What is that and why is that? The reason is Assimp sometimes doesn’t extract material data from 3D models (usually from OBJ files) and it doesn’t retrieve name of a texture used. If that is the case, we simply provide the name of a texture by ourselves. This is a very simplified solution for now, but it works for most simple models that we will load .

Let’s examine the loading method now step-by-step:

bool AssimpModel::loadModelFromFile(const std::string& filePath, const std::string& defaultTextureName)
{
	if (_isInitialized) {
		deleteMesh();
	}

	Assimp::Importer importer;
	const aiScene* scene = importer.ReadFile(filePath,
		aiProcess_CalcTangentSpace |
		aiProcess_GenSmoothNormals |
		aiProcess_Triangulate |
		aiProcess_JoinIdenticalVertices |
		aiProcess_SortByPType);

	if (!scene) {
		return false;
	}

	// ...
}

The beginning of the function tries to import a 3D model using Assimp::Importer class. We also provide many flags for loading, I will list them here:

  • aiProcess_CalcTangentSpace – Calculates the tangents and bitangents for the imported meshes (we will make use of this in future tutorials)
  • aiProcess_GenSmoothNormals – Generates smooth normals for the model, if the normals are not already present in the file
  • aiProcess_Triangulate – Splits faces with more indices to faces with 3 indices – triangles
  • aiProcess_JoinIdenticalVertices – Identifies and joins identical vertex data sets within all imported meshes, so that we don’t have duplicated data
  • aiProcess_SortByPType – splits meshes with more than one primitive type into homogenous sub-meshes. This way you can easily ignore non-triangle meshes (lines / points etc.)

For a complete list of flags, see Assimp's official documentation of post-processing. These flags are just something I am using and I've been using forever and it works. But they also make sense - we want the normals to be generated when they're missing and we want to work with triangles only. We also want to optimize data by removing duplicated vertices. I don't make use of aiProcess_SortByPType at the moment, but maybe I will do someday, then I update this article .

Going further, we have to make use of the loaded data now. Apart from standard stuff like creating VAO and VBO, we have to read vertices positions. That's what we're gonna do exactly:

bool AssimpModel::loadModelFromFile(const std::string& filePath, const std::string& defaultTextureName)
{
	// ...

	_modelRootDirectoryPath = string_utils::getDirectoryPath(filePath);

	glGenVertexArrays(1, &_vao);
	glBindVertexArray(_vao);

	_vbo.createVBO();
	_vbo.bindVBO();

	const auto vertexByteSize = sizeof(aiVector3D) * 2 + sizeof(aiVector2D);
	auto vertexCount = 0;

	if (hasPositions())
	{
		for (auto i = 0; i < scene->mNumMeshes; i++)
		{
			const auto meshPtr = scene->mMeshes[i];
			auto vertexCountMesh = 0;
			_meshStartIndices.push_back(vertexCount);
			_meshMaterialIndices.push_back(meshPtr->mMaterialIndex);

			for (auto j = 0; j < meshPtr->mNumFaces; j++)
			{
				const auto& face = meshPtr->mFaces[j];
				if (face.mNumIndices != 3) {
					continue; // Skip non-triangle faces for now
				}

				for (auto k = 0; k < face.mNumIndices; k++)
				{
					const auto& position = meshPtr->mVertices[face.mIndices[k]];
					_vbo.addData(&position, sizeof(aiVector3D));
				}

				vertexCountMesh += face.mNumIndices;
			}

			vertexCount += vertexCountMesh;
			_meshVerticesCount.push_back(vertexCountMesh);
		}
	}

	// ...
}

You might already see that we have three loops there. The reason for that is how Assimp loads models. Whole model file is organized into several meshes. Mesh is simply a group of faces (in our case triangles). Usually in models meshes represent some whole parts, like if you have a human model, meshes might be separate for head, left arm, right arm etc. But the whole body can be a mesh too, it's up to the creator of the model .

As I have mentioned, one mesh contains several faces. In our case, every face of the mesh should be a triangle now, because we chose a post-process flag to triangulate everything. I still do check if the face contains 3 vertices, but this should not be necessary (but there is no harm doing this additional check). What we're doing here is simply extracting vertex positions and putting them into our VBO structure. Moreover, we internally keep count of vertices for every mesh and index of first vertex where it starts (this will be needed for rendering).

The last important thing here is that for every mesh, we also keep the index of its material. In Assimp's structure, the model has several materials (indexed from 0) and every mesh has one material referenced by index. Each material has some attributes, but the only important attribute of material that we care about for now is a texture used.

We repeat the same process for texture coordinates and normals. We have to do it like this because of how the structure of StaticMesh3D requires it. There is no need to explain this, the workflow is same, just look the code up by yourself .

Materials

After we have loaded vertices, texture coordinates and normals, we have to examine materials of the model:

bool AssimpModel::loadModelFromFile(const std::string& filePath, const std::string& defaultTextureName)
{
	// ...

	for(auto i = 0; i < scene->mNumMaterials; i++)
	{
		const auto materialPtr = scene->mMaterials[i];
		aiString aiTexturePath;
		if (defaultTextureName.empty() && materialPtr->GetTextureCount(aiTextureType_DIFFUSE) > 0)
		{
			if (materialPtr->GetTexture(aiTextureType_DIFFUSE, 0, &aiTexturePath) == AI_SUCCESS)
			{
				const std::string textureFileName = aiStringToStdString(aiTexturePath);
				loadMaterialTexture(i, textureFileName);
			}
		}
	}

	// ...
}

As I said, the only thing we care about is the used texture of the material. Basically that is the only thing we're trying to get out of the model - diffuse texture and its path. Basically if user does not define defaultTextureName and Assimp reports non-zero diffuse texture count, we can read it out. If we successfully read out texture name, we load it from the same directory as model is located in and store it under a key same as path (path serves as a key very well in this case).

The only problem I had here was that Assimp has read out texture file path out of model correctly, but with some limitations. In 64-bit version, the string was somehow prefixed with four 0 bytes, thus making std::string think that it's an empty string. I had to overcome this by creating a helper method aiStringtoStdString, that skips zero-bytes at the beginning until it encounters some non-zero byte. This way we extract the path correctly. It's definitely a bug in Assimp and I'm gonna create an issue on GitHub for that. In 32-bit version, all works just fine even without this hack.

Rendering model

Now that we have successfully loaded a model, we can proceed with rendering. Let's see how render method looks like:

void AssimpModel::render() const
{
	if (!_isInitialized) {
		return;
	}

	glBindVertexArray(_vao);

	std::string lastUsedTextureKey = "";
	for(auto i = 0; i < _meshStartIndices.size(); i++)
	{
		const auto usedMaterialIndex = _meshMaterialIndices[i];
		if (_materialTextureKeys.count(usedMaterialIndex) > 0)
		{
			const auto textureKey = _materialTextureKeys.at(usedMaterialIndex);
			if (textureKey != lastUsedTextureKey) {
				TextureManager::getInstance().getTexture(textureKey).bind();
			}
		}

		glDrawArrays(GL_TRIANGLES, _meshStartIndices[i], _meshVerticesCount[i]);
	}
}

It's pretty straightforward. We have to go through every mesh loaded, apply its material and render the triangles. A simple optimization I have programmed there is that I bind texture only if the texture key has changed in between meshes (which probably won't happen that often anyway). renderPoints method is very similar, the only difference is that we don't care about the textures.

Result

You can have a sneak peek at what we have achieved today:

As you can see, the results are really impressive! We are able to load almost any models and display them! So from now on, the tutorials will only get more and more interesting and versatile . I hope that this helps you and that you can utilize that in your projects as well .

Download 10.72 MB (1078 downloads)