LearnOpenGL Note - Model Loading

OpenGL利用Assimp加载模型

Posted by Tao on Friday, April 1, 2022

0 Preface

在日常的图形程序中,通常都会使用非常复杂且好玩的模型。我们想要的是将这些模型(Model)导入(Import)到程序当中。模型通常都由3D艺术家在Blender、3DS Max或者Maya这样的工具中精心制作。
我们的工作就是解析这些导出的模型文件以及提取所有有用的信息,将它们储存为OpenGL能够理解的格式。一个很常见的问题是,模型的文件格式有很多种,每一种都会以它们自己的方式来导出模型数据。所以如果我们想从这些文件格式中导入模型的话,我们必须要去自己对每一种需要导入的文件格式写一个导入器。很幸运的是,正好有一个库Assimp专门处理这个问题。

1 Assimp

一个非常流行的模型导入库是Assimp,它是Open Asset Import Library(开放的资产导入库)的缩写。Assimp能够导入很多种不同的模型文件格式(并也能够导出部分的格式),它会将所有的模型数据加载至Assimp的通用数据结构中。
Assimp数据结构的(简化)模型如下:

assimp_structure.png

关于Assimp的详细使用介绍及结构分析请参考How to load and draw 3D models with ASSIMP一文。

  • 和材质和网格(Mesh)一样,所有的场景/模型数据都包含在Scene对象中。Scene对象也包含了场景根节点的引用。
  • 场景的Root node(根节点)可能包含子节点(和其它的节点一样),它会有一系列指向场景对象中mMeshes数组中储存的网格数据的索引。Scene下的mMeshes数组储存了真正的Mesh对象,节点中的mMeshes数组保存的只是场景中网格数组的索引。
  • 一个Mesh对象本身包含了渲染所需要的所有相关数据,像是顶点位置、法向量、纹理坐标、面(Face)和物体的材质。
  • 一个网格包含了多个面。Face代表的是物体的渲染图元(Primitive)(三角形、方形、点)。一个面包含了组成图元的顶点的索引。由于顶点和索引是分开的,使用一个索引缓冲来渲染是非常简单的(见LearnOpenGL note - Getting started:Hello Triangle)。
  • 最后,一个网格也包含了一个Material对象,它包含了一些函数能让我们获取物体的材质属性,比如说颜色和纹理贴图(比如漫反射和镜面光贴图)。

所以,我们需要做的第一件事是将一个物体加载到Scene对象中,遍历节点,获取对应的Mesh对象(我们需要递归搜索每个节点的子节点),并处理每个Mesh对象来获取顶点数据、索引以及它的材质属性。最终的结果是一系列的网格数据,我们会将它们包含在一个Model对象中。

网格
当使用建模工具对物体建模的时候,艺术家通常不会用单个形状创建出整个模型。通常每个模型都由几个子模型/形状组合而成。组合模型的每个单独的形状就叫做一个网格(Mesh)。比如说有一个人形的角色:艺术家通常会将头部、四肢、衣服、武器建模为分开的组件,并将这些网格组合而成的结果表现为最终的模型。一个网格是我们在OpenGL中绘制物体所需的最小单位(顶点数据、索引和材质属性)。一个模型(通常)会包括多个网格。

2 Mesh 网格类

通过使用Assimp,我们可以加载不同的模型到程序中,但是载入后它们都被储存为Assimp的数据结构。我们最终仍要将这些数据转换为OpenGL能够理解的格式,这样才能渲染这个物体。
网格(Mesh)代表的是单个的可绘制实体,我们现在先来定义一个我们自己的网格类。组成一个网格应该至少需要一系列的顶点,每个顶点包含一个位置向量、一个法向量和一个纹理坐标向量。一个网格还应该包含用于索引绘制的索引以及纹理形式的材质数据(漫反射/镜面光贴图)。

顶点不可或缺,我们先定义顶点。将所有需要的向量储存到一个叫做Vertex的结构体中,我们可以用它来索引每个顶点属性。

struct Vertex {
    glm::vec3 Position;
    glm::vec3 Normal;
    glm::vec2 TexCoords;
};

将纹理的id、纹理类型(漫反射贴图或是镜面光贴图)以及纹理文件路径存储到Texture结构体中。

struct Texture
{
    unsigned int id;
    std::string type;
    std::string path;
};

然后我们来定义Mesh网格类:

class Mesh
{
public:
    Mesh(float vertices[]);
    Mesh(std::vector<Vertex> vertices, std::vector<unsigned int> indices, std::vector<Texture> textures);
    ~Mesh();
    std::vector<Vertex> vertices;
    std::vector<unsigned int> indices;
    std::vector<Texture> textures;
    void Draw(Shader *shader);

private:
    unsigned int VAO, VBO, EBO;
    void setupMesh();
};

本例中有两个构造函数,第一个为测试顶点数据,第二个传入了所必须的所有数据。在setupMesh函数中初始化网格,并最终使用Draw函数来绘制网格。注意我们将一个着色器传入了Draw函数中,将着色器传入网格类中可以让我们在绘制之前设置一些uniform(比如链接采样器到纹理单元)。

构造函数中的内容非常好理解。只要用构造函数的参数设置类的公有变量就可以了。我们在构造函数中还调用了setupMesh函数:

Mesh::Mesh(std::vector<Vertex> vertices, std::vector<unsigned int> indices, std::vector<Texture> textures)
{
    this->vertices = vertices;
    this->indices = indices;
    this->textures = textures;

    setupMesh();
}

setupMesh 初始化网格

在setupMesh中配置正确的缓冲:

void Mesh::setupMesh()
{
    glGenVertexArrays(1, &VAO);
    glBindVertexArray(VAO);

    glGenBuffers(1, &VBO);
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(Vertex) * vertices.size(), &vertices[0], GL_STATIC_DRAW);

    glGenBuffers(1, &EBO);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(unsigned int) * indices.size(), &indices[0], GL_STATIC_DRAW);

    // 顶点数据
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void *)0);
    // 顶点法线
    glEnableVertexAttribArray(1);
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void *)(3 * sizeof(float)));
    // 顶点纹理坐标
    glEnableVertexAttribArray(2);
    glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void *)(6 * sizeof(float)));

    glBindVertexArray(0);
}

使用这样的一个结构体不仅能够提供可读性更高的代码,也允许我们很容易地拓展这个结构。如果我们希望添加另一个顶点属性,我们只需要将它添加到结构体中就可以了。由于它的灵活性,渲染的代码不会被破坏。

Draw 绘制渲染

我们需要为Mesh类定义最后一个函数,它的Draw函数。在真正渲染这个网格之前,我们需要在调用glDrawElements函数之前先绑定相应的纹理。
最终渲染代码如下:

void Mesh::Draw(Shader *shader)
{
    for (unsigned int i = 0; i < textures.size(); i++)
    {
        if (textures[i].type == "texture_diffuse")
        {
            glActiveTexture(GL_TEXTURE0);
            glBindTexture(GL_TEXTURE_2D, textures[i].id);
            shader->SetUniform1f("material.diffuse", 0);
        }
        else if (textures[i].type == "texture_specular")
        {
            glActiveTexture(GL_TEXTURE1);
            glBindTexture(GL_TEXTURE_2D, textures[i].id);
            shader->SetUniform1f("material.specular", 1);
        }
    }
    glBindVertexArray(VAO);
    // glDrawArrays(GL_TRIANGLES, 0, 36);
    glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
    glBindVertexArray(0);
    glActiveTexture(GL_TEXTURE0);
}

3 Model 模型类

Model类结构如下:

class Model
{
public:
    Model(std::string path);
    ~Model();

    void Draw(Shader *shader);

private:
    std::vector<Mesh> meshes;
    std::string directory;
    std::vector<Texture> textures_loaded;

    void loadModel(std::string path);
    void processNode(aiNode *node, const aiScene *scene);
    Mesh processMesh(aiMesh *mesh, const aiScene *scene);
    std::vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, std::string typeName);
    unsigned int TextureFromFile(const char *path, const std::string &directory);
};

该构造函数包含了一个Mesh对象的vector,参数为文件路径。

Draw函数遍历所有网格,并调用它们各自的Draw函数。

void Model::Draw(Shader *shader)
{
    for (unsigned int i = 0; i < meshes.size(); i++)
    {
        meshes[i].Draw(shader);
    }
}

Importing a 3D model into OpenGL 导入模型

首先调用函数loadModel函数加载模型。在loadModel中,我们使用Assimp来加载模型至Assimp的一个叫做scene的数据结构中。

完整的loadModel函数如下:

void Model::loadModel(std::string path)
{
    Assimp::Importer importer;
    const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace);
    if (!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
    {
        std::cout << "Assimp error" << std::endl;
        return;
    }
    directory = path.substr(0, path.find_last_of("\\"));
    // std::cout << "Success! " << directory << std::endl;
    processNode(scene->mRootNode, scene);
}

ReadFile函数传入一个文件路径,第二个参数是一些后期处理(Post-processing)的选项。除了加载文件之外,Assimp允许我们设定一些选项来强制它对导入的数据做一些额外的计算或操作。

  • aiProcess_Triangulate:如果模型不是(全部)由三角形组成,它需要将模型所有的图元形状变换为三角形;
  • aiProcess_FlipUVs:翻转y轴的纹理坐标(你可能还记得我们在纹理教程中说过,在OpenGL中大部分的图像的y轴都是反的,在这里修复这个问题);
  • aiProcess_CalcTangentSpace:Assimp会为每个加载的顶点计算出切线和副切线向量;
  • aiProcess_GenNormals:如果模型不包含法向量的话,就为每个顶点创建法线;
  • aiProcess_SplitLargeMeshes:将比较大的网格分割成更小的子网格,如果你的渲染有最大顶点数限制,只能渲染较小的网格,那么它会非常有用;
  • aiProcess_OptimizeMeshes:和上个选项相反,它会将多个小网格拼接为一个大的网格,减少绘制调用从而进行优化。

Assimp提供了很多有用的后期处理指令,您可以参考下面的文章

如果导入没有错误,我们再来处理scene中的所有节点Node,将第一个节点(根节点)传入了递归的processNode函数。因为每个节点可能包含有多个子节点,我们先处理参数中的节点(处理节点中的网格Mesh),再处理该节点所有的子节点,以此类推。这正符合一个递归结构,所以我们定义一个递归函数。递归函数在做一些处理之后,使用不同的参数递归调用这个函数自身,直到某个条件被满足停止递归。在我们的例子中退出条件(Exit Condition)是所有的节点都被处理完毕。

每个节点Node包含了一系列的网格索引(在mMeshes中),每个索引指向场景对象中的那个特定网格。我们接下来就想去获取这些网格索引,获取每个网格,处理每个网格,接着对每个节点的子节点重复这一过程。processNode函数的内容如下:

void Model::processNode(aiNode *node, const aiScene *scene)
{
    // std::cout << node->mName.data << std::endl;
    for (unsigned int i = 0; i < node->mNumMeshes; i++)
    {
        aiMesh *curMesh = scene->mMeshes[node->mMeshes[i]];
        meshes.push_back(processMesh(curMesh, scene));
    }

    for (unsigned int i = 0; i < node->mNumChildren; i++)
    {
        processNode(node->mChildren[i], scene);
    }
}

我们通过获取的每个节点的网格索引,来索引场景的mMeshes数组来获取对应的网格。返回的网格将会传递到processMesh函数中,它会返回一个Mesh对象,我们可以将它存储在meshes列表/vector。

Assimp to Mesh

将aiMesh对象转化为我们自己的网格对象,只需访问网格的相关属性并将它们储存到我们自己的对象中。processMesh函数的大体结构如下:

Mesh Model::processMesh(aiMesh *mesh, const aiScene *scene)
{
    std::vector<Vertex> tempVertices;
    std::vector<unsigned int> tempIndices;
    std::vector<Texture> tempTextures;

    for (unsigned int i = 0; i < mesh->mNumVertices; i++)
    {
        Vertex tempVertex;
        glm::vec3 tempVec;

        tempVec.x = mesh->mVertices[i].x;
        tempVec.y = mesh->mVertices[i].y;
        tempVec.z = mesh->mVertices[i].z;
        tempVertex.Position = tempVec;

        tempVec.x = mesh->mNormals[i].x;
        tempVec.y = mesh->mNormals[i].y;
        tempVec.z = mesh->mNormals[i].z;
        tempVertex.Normal = tempVec;

        if (mesh->mTextureCoords[0])
        {
            glm::vec2 vec;
            vec.x = mesh->mTextureCoords[0][i].x;
            vec.y = mesh->mTextureCoords[0][i].y;
            tempVertex.TexCoords = vec;
        }
        else
        {
            tempVertex.TexCoords = glm::vec2(0, 0);
        }
        tempVertices.push_back(tempVertex);
    }

    for (unsigned int i = 0; i < mesh->mNumFaces; i++)
    {
        for (unsigned int j = 0; j < mesh->mFaces[i].mNumIndices; j++)
        {
            tempIndices.push_back(mesh->mFaces[i].mIndices[j]);
        }
    }

    if (mesh->mMaterialIndex >= 0)
    {
        aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
        std::vector<Texture> diffuseMaps = loadMaterialTextures(material, aiTextureType_DIFFUSE, "texture_diffuse");
        tempTextures.insert(tempTextures.end(), diffuseMaps.begin(), diffuseMaps.end());

        std::vector<Texture> specularMaps = loadMaterialTextures(material, aiTextureType_SPECULAR, "texture_specular");
        tempTextures.insert(tempTextures.end(), specularMaps.begin(), specularMaps.end());
    }

    return Mesh(tempVertices, tempIndices, tempTextures);
}

处理网格的过程主要有三部分:获取所有的顶点数据,获取它们的网格索引,并获取相关的材质数据。处理后的数据将会储存在三个vector当中,我们会利用它们构建一个Mesh对象,并返回。

获取顶点数据非常简单,我们定义了一个Vertex结构体,我们将在每个迭代之后将它加到vertices数组中。我们会遍历网格中的所有顶点(使用mesh->mNumVertices来获取)。在每个迭代中,我们希望使用所有的相关数据填充这个结构体。顶点的位置是这样处理的:

    Vertex tempVertex;
    glm::vec3 tempVec;

    tempVec.x = mesh->mVertices[i].x;
    tempVec.y = mesh->mVertices[i].y;
    tempVec.z = mesh->mVertices[i].z;
    tempVertex.Position = tempVec;

处理法线的步骤类似:

    tempVec.x = mesh->mNormals[i].x;
    tempVec.y = mesh->mNormals[i].y;
    tempVec.z = mesh->mNormals[i].z;
    tempVertex.Normal = tempVec;

纹理坐标的处理也大体相似。Assimp允许一个模型在一个顶点上有最多8个不同的纹理坐标,我们不会用到那么多,我们只关心第一组纹理坐标。

    if (mesh->mTextureCoords[0])
    {
        glm::vec2 vec;
        vec.x = mesh->mTextureCoords[0][i].x;
        vec.y = mesh->mTextureCoords[0][i].y;
        tempVertex.TexCoords = vec;
    }
    else
    {
        tempVertex.TexCoords = glm::vec2(0, 0);
    }

vertex结构体现在已经填充好了需要的顶点属性,我们会在迭代的最后将它压入tempVertices这个vector的尾部。这个过程会对每个网格的顶点都重复一遍。

Indices 面索引

Assimp的接口定义了每个网格都有一个面(Face)数组,每个面代表了一个图元,在我们的例子中(由于使用了aiProcess_Triangulate选项)它总是三角形。一个面包含了多个索引,这些索引定义了在每个图元中,我们应该绘制哪个顶点,并以什么顺序绘制。我们要遍历所有的面,并将面的索引存储到tempIndices这个vector中。

    for (unsigned int i = 0; i < mesh->mNumFaces; i++)
    {
        for (unsigned int j = 0; j < mesh->mFaces[i].mNumIndices; j++)
        {
            tempIndices.push_back(mesh->mFaces[i].mIndices[j]);
        }
    }

最后来处理一下网格的材质。

Material 材质

一个网格只包含了一个指向材质对象的索引。如果想要获取网格真正的材质,我们还需要索引场景的mMaterials数组。网格材质索引位于它的mMaterialIndex属性中,我们同样可以用它来检测一个网格是否包含有材质:

    if (mesh->mMaterialIndex >= 0)
    {
        aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
        std::vector<Texture> diffuseMaps = loadMaterialTextures(material, aiTextureType_DIFFUSE, "texture_diffuse");
        tempTextures.insert(tempTextures.end(), diffuseMaps.begin(), diffuseMaps.end());

        std::vector<Texture> specularMaps = loadMaterialTextures(material, aiTextureType_SPECULAR, "texture_specular");
        tempTextures.insert(tempTextures.end(), specularMaps.begin(), specularMaps.end());
    }

我们首先从场景的mMaterials数组中获取aiMaterial对象。接下来我们希望加载网格的漫反射或镜面光贴图。一个材质对象的内部对每种纹理类型都存储了一个纹理位置数组。不同的纹理类型都以aiTextureType_为前缀。我们使用一个叫做loadMaterialTextures的工具函数来从材质中获取纹理。这个函数将会返回一个Texture结构体的vector,我们将其储存在tempTextures中。

loadMaterialTextures函数遍历了给定纹理类型的所有纹理,获取纹理的文件位置,然后加载并生成纹理并将信息存储在Texture结构体中。

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;
}

我们首先通过GetTextureCount函数检查储存在材质中纹理的数量,这个函数需要一个纹理类型参数。我们会使用GetTexture获取每个纹理的文件位置并将结果储存在一个aiString中。我们接下来使用另外一个叫做TextureFromFile的工具函数,它利用stb_image.h加载纹理并返回该纹理的ID。

An optimization 优化

大多数场景都会在多个网格中重用部分纹理。还是想想一个房子,它的墙壁有着花岗岩的纹理。这个纹理也可以被应用到地板、天花板、楼梯、桌子,甚至是附近的一口井上。加载纹理并不是一个开销不大的操作,在我们当前的实现中,即便同样的纹理已经被加载过很多遍了,对每个网格仍会加载并生成一个新的纹理。这很快就会变成模型加载实现的性能瓶颈。

所以我们会对模型的代码进行调整,将所有加载过的纹理全局储存,每当我们想加载一个纹理的时候,首先去检查它有没有被加载过。如果有的话,我们会直接使用那个纹理,并跳过整个加载流程,来为我们省下很多处理能力。我们将所有加载过的纹理储存在另一个vector中,在模型类的顶部声明为一个私有变量:

std::vector<Texture> textures_loaded;

之后,在loadMaterialTextures函数中,我们希望将纹理的路径与储存在textures_loaded这个vector中的所有纹理进行比较,看看当前纹理的路径是否与其中的一个相同。如果是的话,则跳过纹理加载/生成的部分,直接使用匹配到的纹理结构体为网格的纹理。更新后的函数如下:

std::vector<Texture> Model::loadMaterialTextures(aiMaterial *mat, aiTextureType type, std::string typeName)
{
    std::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.data(), str.C_Str()) == 0)
            {
                textures.push_back(textures_loaded[j]);
                skip = true;
                break;
            }
        }
        if (!skip)
        {
            Texture texture;
            texture.id = TextureFromFile(str.C_Str(), directory);
            texture.type = typeName;
            texture.path = str.C_Str();
            textures.push_back(texture);
            textures_loaded.push_back(texture);
        }
    }
    return textures;
}

完整代码请参考我的github仓库

No more containers! 加载新模型

我们将会加载游戏孤岛危机(Crysis)中的Nanosuit模型。这个模型被输出为一个.obj文件以及一个.mtl文件,你可以在这里下载到,注意所有的纹理和模型文件应该位于同一个目录下,以供加载纹理。

最终加载的结果如下:

printscreen.png

4 Reference

「如果这篇文章对你有用,请随意打赏」

Heisenberg Blog

如果这篇文章对你有用,请随意打赏

使用微信扫描二维码完成支付


comments powered by Disqus