Qt and OpenGL

Qt and OpenGL: Loading a 3D Model with Open Asset Import Library (Assimp)

By ICS Development Team

This blog post is the first in a series that will cover using OpenGL with Qt. In this installment, we will look at how to use Open Asset Import Library (Assimp) (1) to load 3D models from some common 3D model formats. The example code requires Assimp version 3.0. The code also uses Qt for several convenience classes (QString, QVector, QSharedPointer, etc...).

Read Part 2 Qt and OpenGL: Loading a 3D Model with Open Asset Import Library (ASSIMP)

Introduction

First, we will create some simpl e classes for holding the data for the model. The structure MaterialInfo will contain information about how a material will look. We will be shading using the Phong shading model (2).

struct MaterialInfo
{
    QString Name;
    QVector3D Ambient;
    QVector3D Diffuse;
    QVector3D Specular;
    float Shininess;
};

The LightInfo structure will contain information about the light source:

struct LightInfo
{
    QVector4D Position;
    QVector3D Intensity;
};

The Mesh class will give us information about the mesh. It does not actually contain vertex data for the mesh, but has the information we need to get it from the vertex buffers. Mesh::indexCount is the number of vertices in the mesh, Mesh::indexOffset is the position in the buffer where the vertex data begins and Mesh::material is the material information for the mesh.

struct Mesh
{
    QString name;
    unsigned int indexCount;
    unsigned int indexOffset;
    QSharedPointer<MaterialInfo> material;
};

A single model may have many different meshes. A Node class will contain the meshes as well as a transformation matrix that will position them in the scene. Each node may also have child nodes. We could store all meshes in a single array, but storing them in a tree structure will allow us to animate the objects more easily if we wish to. Think of this like a human body, as if the body was the root node, the upper arms would be child nodes of the root node, lower arms would be children of the upper arm nodes and hands would be children of the lower arm nodes.

struct Node
{
    QString name;
    QMatrix4x4 transformation;
    QVector<QSharedPointer<Mesh> > meshes;
    QVector<Node> nodes;
};

The ModelLoader class will be used to load the information into a single root node:

class ModelLoader
{
public:
    ModelLoader();
    bool Load(QString pathToFile);
    void getBufferData(QVector<float> **vertices, QVector<float> **normals,
                        QVector<unsigned int> **indices);

    QSharedPointer<Node> getNodeData() { return m_rootNode; }

The usage of this class will be straightforward. ModelLoader::Load() accepts the path to the 3D model file and will trigger the actual loading of the model. ModelLoader::getBufferData() is used for retrieving vertex positions and normals and indices for indexed drawing. ModelLoader::getNodeData() will return the root node. Here are the private ModelLoader functions and variables:

    QSharedPointer<MaterialInfo> processMaterial(aiMaterial *mater);
    QSharedPointer<Mesh> processMesh(aiMesh *mesh);
    void processNode(const aiScene *scene, aiNode *node, Node *parentNode, Node &newNode);

    void transformToUnitCoordinates();
    void findObjectDimensions(Node *node, QMatrix4x4 transformation, QVector3D &minDimension, QVector3D &maxDimension);

    QVector<float> m_vertices;
    QVector<float> m_normals;
    QVector<unsigned int> m_indices;

    QVector<QSharedPointer<MaterialInfo> > m_materials;
    QVector<QSharedPointer<Mesh> > m_meshes;
    QSharedPointer<Node> m_rootNode;

The next step is to load the model. If it is not already present, you must install Assimp 3.0. Note that Assimp 2.0 will not work for this example. First, we include the necessary Assimp headers:

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

Here is the code for the ModelLoader::Load() function:

bool ModelLoader::Load(QString pathToFile)
{
    Assimp::Importer importer;

    const aiScene* scene = importer.ReadFile(pathToFile.toStdString(),
            aiProcess_GenSmoothNormals |
            aiProcess_CalcTangentSpace |
            aiProcess_Triangulate |
            aiProcess_JoinIdenticalVertices |
            aiProcess_SortByPType
            );

    if (!scene)
    {
        qDebug() << "Error loading file: (assimp:) " << importer.GetErrorString();
        return false;
    }

Assimp stores all information about the model in the aiScene instance created here. The importer object retains ownership of the aiScene object, so we don't have to worry about deleting it later. The returned scene object will be null if there was an error, so we check here and return false from the function if there was an error.

More details about the flags passed to the importer are in Assimp's postprocess.h file. Here is a summary of each:

  • GenSmoothNormals generates normals if they are not already in the model.
  • CalcTangentSpace calculates tangent space, only necessary if doing normal mapping.
  • Triangulate splits up primitives with more than three vertices to triangles.
  • JoinIdenticalVertices joins identical vertex data, improves performance with indexed drawing.

If scene is not null, then we can assume the model was loaded correctly and start copying the data we need. The data will be read in this order:

  1. Materials
  2. Meshes
  3. Nodes

Materials must be loaded before meshes, and meshes must be loaded before nodes.

Loading Materials

The next step is to load materials:

    if (scene->HasMaterials())
    {
        for (unsigned int ii = 0; ii < scene->mNumMaterials; ++ii)
        {
            QSharedPointer<MaterialInfo> mater = processMaterial(scene->mMaterials[ii]);
            m_materials.push_back(mater);
        }
    }

All materials are stored in the aiScene::mMaterials array, and the array size is aiScene::nNumMaterials. We iterate over each one and pass it to our processMaterial function, which returns to us a new MaterialInfo object. Variable m_materials will then contain material information for all meshes in the scene (if available).

Let's take a closer look at the ModelLoader::processMaterial implementation we will be using:

QSharedPointer<MaterialInfo> ModelLoader::processMaterial(aiMaterial *material)
{
    QSharedPointer<MaterialInfo> mater(new MaterialInfo);

    aiString mname;
    material->Get(AI_MATKEY_NAME, mname);
    if (mname.length > 0)
        mater->Name = mname.C_Str();

    int shadingModel;
    material->Get(AI_MATKEY_SHADING_MODEL, shadingModel);

    if (shadingModel != aiShadingMode_Phong && shadingModel != aiShadingMode_Gouraud)
    {
        qDebug() << "This mesh's shading model is not implemented in this loader, setting to default material";
        mater->Name = "DefaultMaterial";
    }
    else
        ...

The aiMaterial class uses key-value pairs to store material data. We copy the name and then check the lighting model of this material. We are only going to worry about Phong or Gouraud shading models in this tutorial, so if it is not one of those we set the name to "DefaultMaterial" to indicate that rendering should use it's own material values.

Continuing the above code:

    ...
    }
    else
    {
        aiColor3D dif(0.f,0.f,0.f);
        aiColor3D amb(0.f,0.f,0.f);
        aiColor3D spec(0.f,0.f,0.f);
        float shine = 0.0;

        material->Get(AI_MATKEY_COLOR_AMBIENT, amb);
        material->Get(AI_MATKEY_COLOR_DIFFUSE, dif);
        material->Get(AI_MATKEY_COLOR_SPECULAR, spec);
        material->Get(AI_MATKEY_SHININESS, shine);

        mater->Ambient = QVector3D(amb.r, amb.g, amb.b);
        mater->Diffuse = QVector3D(dif.r, dif.g, dif.b);
        mater->Specular = QVector3D(spec.r, spec.g, spec.b);
        mater->Shininess = shine;

        mater->Ambient *= .2;
        if (mater->Shininess == 0.0)
            mater->Shininess = 30;
    }

    return mater;
}

We are only interested in the ambient, diffuse, specular and shininess properties. You can see a longer list of available properties here (3). Calling aiMaterial::Get(key, value) gets us the values we need, then we copy them to our MaterialInfo object.

Notice we scale down the ambient values here. This is because the OpenGL shader we use to render this will only be using one lighting intensity vector for ambient, diffuse and specular incoming light (LightInfo::Intensity). Alternatively, our shader could use a separate vector for the light source ambient, diffuse and specular components for greater control. We also check if a shininess value was given for the model, if not we set a default of 30.

Loading Meshes

Back in the Load() function:

    if (scene->HasMeshes())
    {
        for (unsigned int ii = 0; ii < scene->mNumMeshes; ++ii)
        {
            m_meshes.push_back(processMesh(scene->mMeshes[ii]));
        }
    }
    else
    {
        qDebug() << "Error: No meshes found";
        return false;
    }

All meshes are stored in the aiScene::mMeshes array, and the array size is aiScene::nNumMeshes. We iterate over each one and pass it to our ModelLoader::processMesh function, which returns to us a new Mesh object. Variable m_meshes will then contain all the meshes in the scene.

At this point, each mesh will be associated with a material. If no material was specified in the model, it will have a default material with MaterialInfo::Name set to "DefaultMaterial". To load the mesh we need to do these things:

  1. Calculate the index offset (Mesh::indexOffset). This will tell us where in the buffer the data for this mesh begins.
  2. Copy all vertex data from aiMesh::mVertices[] to our vertex buffer (ModelLoader::m_vertices).
  3. Copy all normal data from aiMesh::mNormals[] to our normal buffer (ModelLoader::m_normals).
  4. (Optional and not covered in this tutorial) Copy texture related data.
  5. Calculate indexing data and add to our index buffer (ModelLoader::m_indices).
  6. Set the mesh's index count (Mesh::indexCount), this is the number of vertices in the mesh.
  7. Set the mesh's material (Mesh::material).
QSharedPointer<Mesh> ModelLoader::processMesh(aiMesh *mesh)
{
    QSharedPointer<Mesh> newMesh(new Mesh);
    newMesh->name = mesh->mName.length != 0 ? mesh->mName.C_Str() : "";
    newMesh->indexOffset = m_indices.size();
    unsigned int indexCountBefore = m_indices.size();
    int vertindexoffset = m_vertices.size()/3;

    // Get Vertices
    if (mesh->mNumVertices > 0)
    {
        for (uint ii = 0; ii < mesh->mNumVertices; ++ii)
        {
            aiVector3D &vec = mesh->mVertices[ii];

            m_vertices.push_back(vec.x);
            m_vertices.push_back(vec.y);
            m_vertices.push_back(vec.z);
        }
    }

    // Get Normals
    if (mesh->HasNormals())
    {
        for (uint ii = 0; ii < mesh->mNumVertices; ++ii)
        {
            aiVector3D &vec = mesh->mNormals[ii];
            m_normals.push_back(vec.x);
            m_normals.push_back(vec.y);
            m_normals.push_back(vec.z);
        };
    }

    // Get mesh indexes
    for (uint t = 0; t < mesh->mNumFaces; ++t)
    {
        aiFace* face = &mesh->mFaces[t];
        if (face->mNumIndices != 3)
        {
            qDebug() << "Warning: Mesh face with not exactly 3 indices, ignoring this primitive.";
            continue;
        }

        m_indices.push_back(face->mIndices[0]+vertindexoffset);
        m_indices.push_back(face->mIndices[1]+vertindexoffset);
        m_indices.push_back(face->mIndices[2]+vertindexoffset);
    }

    newMesh->indexCount = m_indices.size() - indexCountBefore;
    newMesh->material = m_materials.at(mesh->mMaterialIndex);

    return newMesh;
}

Most of this is straightforward. Since we are using only one buffer for each vertex (Assimp has one for each mesh), we need to add the offset to the index value.

aiMesh stores index data in an array of aiFace objects. aiFace represents a primitive. If the number of indices in the face is not equal to three, then it is not a triangle and we will ignore it for this tutorial.

If the face is a triangle, then add the index value to m_indices. Remember to add the vertex offset value to these since the indexes given by Assimp are relative to the mesh, whereas we are storing indexes for all meshes in one buffer.

Since we processed all the indices of this mesh, we now calculate the index count for the mesh, and set the material of the mesh.

We are only focusing on vertices, normals and indices for this tutorial, but you could load other information here such as vertex texture coordinates or tangents. The downloadable sample code (4) contains functionality to get those as well.

Loading Nodes

Next, we must process the nodes in the aiScene, starting with the root node. Nodes define where the meshes are drawn in relation to each other. Make sure the aiScene's root node is not null and then pass it to processNode(), which will implement filling ModelLoader::m_rootNode with all the model data.

    if (scene->mRootNode != NULL)
    {
        Node *rootNode = new Node;
        processNode(scene, scene->mRootNode, 0, *rootNode);
        m_rootNode.reset(rootNode);
    }
    else
    {
        qDebug() << "Error loading model";
        return false;
    }

    return true;
}

Now here are the steps for the processNode implementation. The steps we need are the following:

  1. (Optional) Set the node's name.
  2. Set the node's transformation matrix.
  3. Copy a pointer to each mesh for this node.
  4. Add child nodes and call ModelLoader::processNode for each child. This will recursively process all children.
void ModelLoader::processNode(const aiScene *scene, aiNode *node, Node *parentNode, Node &newNode)
{
    newNode.name = node->mName.length != 0 ? node->mName.C_Str() : "";

    newNode.transformation = QMatrix4x4(node->mTransformation[0]);

    newNode.meshes.resize(node->mNumMeshes);
    for (uint imesh = 0; imesh < node->mNumMeshes; ++imesh)
    {
        QSharedPointer<Mesh> mesh = m_meshes[node->mMeshes[imesh]];
        newNode.meshes[imesh] = mesh;
    }

    for (uint ich = 0; ich < node->mNumChildren; ++ich)
    {
        newNode.nodes.push_back(Node());
        processNode(scene, node->mChildren[ich], parentNode, newNode.nodes[ich]);
    }
}

Finishing Touches

The class can be used as follows:

    ModelLoader model;

    if (!model.Load("head.3ds"))
    {
        m_error = true;
        return;
    }

    QVector<float> *vertices;
    QVector<float> *normals;
    QVector<unsigned int> *indices;

    model.getBufferData(&vertices, &normals, &indices);

    m_rootNode = model.getNodeData();

At this point, you have all the data you need to display the model using OpenGL.

The full source code for the example (4), including qmake project file, is available for download. It should work on any platform if the Assimp 3 library is present. You may need to adjust the paths in the project file. On recent versions of Linux such as Ubuntu, suitable versions of Assimp are available as part of the Linux distribution. On Mac and Windows, you may need to build Assimp from source. There are two sets of shaders and scene classes, one for OpenGL 3.3 and another for OpenGL 2.1/OpenGL ES 2. It will attempt to run the 3.3 version, but should automatically fall back to the GL 2 version if necessary.

Summary

This blog post demonstrated how to use Qt and the Assimp library to load a 3D model. 

Read Part 2 Qt and OpenGL: Loading a 3D Model with Open Asset Import Library (ASSIMP)

References

  1. Open Asset Import Library, accessed April 30, 2014, assimp.sourceforge.net
  2. Phong Shading, Wikipedia article, accessed April 30, 2014, en.wikipedia.org/wiki/Phong_shading
  3. Assimp Material System, accessed April 30, 2014, assimp.sourceforge.net/lib_html/materials.html
  4. Downloadable code for this blog post: OpenGL Blog Post Files