OpenGL学习笔记20-Model

Model

Model-Loading/Model

现在是时候着手使用Assimp并开始创建实际的加载和转换代码了。本章的目标是创建另一个类来代表一个完整的模型,也就是说,一个包含多个网格,可能有多个纹理的模型。一所房子,包括一个木制阳台,一个塔,也许还有一个游泳池,仍然可以作为一个单一的模型加载。我们将通过Assimp加载模型,并将其转换为我们在前一章中创建的多个网格对象。

闲话少说,我给你展示了模型类的类结构:


class Model 
{
    public:
        Model(char *path)
        {
            loadModel(path);
        }
        void Draw(Shader &shader);	
    private:
        // model data
        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);
};

模型类包含网格对象的向量,需要我们在它的构造函数中给它一个文件位置。然后,它通过在构造函数中调用的loadModel函数立即加载文件。私有函数都是为处理Assimp的导入例程的一部分而设计的,我们将很快介绍它们。我们还存储了文件路径的目录,稍后在加载纹理时会用到。

绘制函数没有什么特别的,基本上循环在每个网格调用各自的绘制函数:


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

Importing a 3D model into OpenGL

要导入一个模型并将其转换为我们自己的结构,我们首先需要包含适当的Assimp头:


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

我们调用的第一个函数是loadModel,它直接从构造函数调用。在loadModel中,我们使用Assimp将模型加载到称为场景对象的Assimp数据结构中。您可能还记得,在模型加载系列的第一章中,它是Assimp数据接口的根对象。一旦我们有了场景对象,我们就可以从加载的模型中访问所需的所有数据。

Assimp的伟大之处在于,它巧妙地从加载所有不同文件格式的所有技术细节中抽象出来,并用一个单行程序完成所有这些工作:


Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs); 

我们首先从Assimp的名称空间声明导入器对象,然后调用它的ReadFile函数。函数的第二个参数是文件路径和几个后处理选项。Assimp允许我们指定几个选项,强制Assimp对导入的数据执行额外的计算/操作。通过设置aiProcess_Triangulate,我们告诉Assimp,如果模型不(完全)由三角形组成,它应该首先将模型的所有原始形状转换为三角形。在处理过程中,aiProcess_FlipUVs在必要的时候在y轴上翻转纹理坐标(你可能记得在纹理章节中OpenGL中的大多数图像都是在y轴上翻转的;这个小的后处理选项为我们修复了这个问题)。其他一些有用的选择是:

  • aiProcess_GenNormals:如果模型不包含法向量,为每个顶点创建法向量。
  • aiProcess_SplitLargeMeshes:将大的网格分割成较小的子网格,这在你的渲染有最大的顶点数量并且只能处理较小的网格时很有用。
  • aiProcess_OptimizeMeshes:通过尝试将几个网格加入到一个更大的网格中来实现相反的效果,减少了优化所需的绘图请求。

Assimp提供了一组很好的后处理选项,您可以在这里找到所有这些选项。通过Assimp加载模型(如您所见)非常简单。难点在于使用返回的场景对象将加载的数据转换为网格对象数组。

 

完整的loadModel函数在这里列出:


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

加载模型之后,我们检查场景和场景的根节点是否为非null,并检查它的一个标志,以查看返回的数据是否不完整。如果满足了这些错误条件中的任何一个,我们将报告从导入器的GetErrorString函数检索到的错误并返回。我们还检索给定文件路径的目录路径。

如果没有出错,我们希望处理场景的所有节点。我们将第一个节点(根节点)传递给递归processNode函数。因为每个节点(可能)都包含一组子节点,所以我们希望首先处理相关节点,然后继续处理节点的所有子节点,依此类推。这符合一个递归结构,所以我们将定义一个递归函数。递归函数是这样一种函数,它进行一些处理,并递归地使用不同的参数调用相同的函数,直到满足特定的条件。在我们的例子中,当所有节点都被处理完时,退出条件就满足了。

你可能还记得Assimp的结构,每个节点包含一组网格索引,其中每个索引指向场景对象中的一个特定网格。因此,我们希望检索这些网格索引,检索每个网格,处理每个网格,然后对每个节点的子节点再次执行这些操作。processNode函数的内容如下所示:


void processNode(aiNode *node, const aiScene *scene)
{
    // process all the node's meshes (if any)
    for(unsigned int i = 0; i < node->mNumMeshes; i++)
    {
        aiMesh *mesh = scene->mMeshes[node->mMeshes[i]]; 
        meshes.push_back(processMesh(mesh, scene));			
    }
    // then do the same for each of its children
    for(unsigned int i = 0; i < node->mNumChildren; i++)
    {
        processNode(node->mChildren[i], scene);
    }
}  

我们首先检查每个节点的网格索引,并通过索引场景的mMeshes数组来检索相应的网格。返回的网格然后被传递给processMesh函数,该函数返回一个网格对象,我们可以将其存储在网格列表/向量中。

处理完所有网格后,我们遍历节点的所有子节点,并为每个子节点调用相同的processNode函数。一旦一个节点不再有子节点,递归就会停止。

细心的读者可能已经注意到,我们可以忘记处理任何节点,而直接循环遍历场景的所有网格,而不用使用索引来完成所有这些复杂的工作。我们这样做的原因是,使用这样的节点的最初想法是,它定义了网格之间的父子关系。通过递归地迭代这些关系,我们可以将某些网格定义为其他网格的父网格。

这样一个系统的一个示例用例是,当你想转换一个汽车网格并确保它的所有子网格(如引擎网格、方向盘网格和轮胎网格)也转换;使用父-子关系很容易创建这样的系统。

 

不过,现在我们还没有使用这样的系统,但通常建议在需要额外控制网格数据时使用这种方法。这些类似节点的关系毕竟是由创建模型的艺术家定义的。

下一步是将Assimp的数据处理到上一章中的Mesh类中。

Assimp to Mesh

将一个aiMesh对象转换为我们自己的mesh对象并不太难。我们所需要做的,就是访问网格的每个相关属性,并将它们存储在我们自己的对象中。processMesh函数的一般结构就变成:


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;
        // process vertex positions, normals and texture coordinates
        [...]
        vertices.push_back(vertex);
    }
    // process indices
    [...]
    // process material
    if(mesh->mMaterialIndex >= 0)
    {
        [...]
    }

    return Mesh(vertices, indices, textures);
}  

处理一个网格是一个3部分的过程:检索所有的顶点数据,检索网格的索引,最后检索相关的材料数据。处理后的数据存储在3个向量中的一个,从中创建一个网格并返回给函数的调用者。

获取顶点数据非常简单:我们定义一个顶点结构,并在每次循环迭代后添加到顶点数组中。我们循环网格中存在的所有顶点(通过mesh->mNumVertices检索)。在迭代中,我们希望用所有相关数据填充这个结构。对于顶点位置,如下所示:


glm::vec3 vector; 
vector.x = mesh->mVertices[i].x;
vector.y = mesh->mVertices[i].y;
vector.z = mesh->mVertices[i].z; 
vertex.Position = vector;

注意,我们定义了一个临时的vec3来将Assimp的数据传输到。这是必要的,因为Assimp维护自己的向量、矩阵、字符串等数据类型,而它们不能很好地转换为glm的数据类型。

Assimp调用他们的顶点位置数组mVertices,这不是最直观的名称。

现在,对常态的处理程序应该不足为奇了:


vector.x = mesh->mNormals[i].x;
vector.y = mesh->mNormals[i].y;
vector.z = mesh->mNormals[i].z;
vertex.Normal = vector;  

纹理坐标大致相同,但是Assimp允许模型每个顶点最多有8个不同的纹理坐标。我们不会用8,我们只关心第一组纹理坐标。我们还想检查是否网格实际上包含纹理坐标(可能不总是这样):


if(mesh->mTextureCoords[0]) // does the mesh contain texture coordinates?
{
    glm::vec2 vec;
    vec.x = mesh->mTextureCoords[0][i].x; 
    vec.y = mesh->mTextureCoords[0][i].y;
    vertex.TexCoords = vec;
}
else
    vertex.TexCoords = glm::vec2(0.0f, 0.0f);  

顶点结构现在已经完全填充了所需的顶点属性,我们可以在迭代结束时将它推到顶点向量的后面。这个过程对网格的每个顶点都重复。

Indices 指数

Assimp的接口将每个网格定义为拥有一个面数组,其中每个面代表一个单一的原语,在我们的例子中(由于aiProcess_Triangulate选项)总是三角形。一个面包含了我们需要绘制的顶点的索引,按照它的原语的顺序。如果我们遍历所有的面并将所有面的下标存储在下标向量中我们就得到了:


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

在外部循环完成后,我们现在有了一组完整的顶点和索引数据,用于通过glDrawElements绘制网格。然而,为了完成这个讨论并给网格添加一些细节,我们也想处理网格的材质。

Material

与节点类似,网格只包含一个实体对象的索引。为了获取一个网格的材质,我们需要索引场景的mMaterials数组。网格的材质索引设置在它的mMaterialIndex属性中,我们也可以通过查询来检查网格是否包含材质:


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

我们首先从场景的mMaterials数组中检索aiMaterial对象。然后我们想要加载网格的漫反射和/或镜面纹理。material对象内部存储每种纹理类型的纹理位置数组。不同的纹理类型都以aiTextureType_作为前缀。我们使用一个名为loadMaterialTextures的辅助函数来从材质中检索、加载和初始化纹理。这个函数返回一个纹理结构的向量,我们存储在模型的纹理向量的末尾。

loadMaterialTextures函数迭代给定纹理类型的所有纹理位置,获取纹理的文件位置,然后加载并生成纹理并将信息存储在一个顶点结构中。它是这样的:


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

我们首先通过它的gettextu重新计数函数来检查存储在材质中的纹理数量,这个函数期望得到我们已经给出的纹理类型之一。我们通过GetTexture函数检索每个纹理的文件位置,该函数将结果存储在一个aiString中。然后我们使用另一个名为TextureFromFile的辅助函数,它为我们加载一个纹理(带有stb_image.h)并返回纹理的ID。如果你不确定这样一个函数是如何编写的,你可以在最后检查完整的代码列表来查看它的内容。

注意,我们假设模型文件中的纹理文件路径是实际模型对象的本地路径,例如与模型本身的位置在同一个目录中。然后,我们可以简单地将纹理位置字符串和之前检索到的目录字符串(在loadModel函数中)连接起来,以获得完整的纹理路径(这就是GetTexture函数也需要目录字符串的原因)。

 

在互联网上发现的一些模型使用绝对路径作为它们的纹理位置,这并不适用于每台机器。在这种情况下,你可能想要手动编辑文件使用纹理的本地路径(如果可能的话)。

这就是使用Assimp导入模型的全部内容。

An optimization 优化

我们还没有完全完成,因为我们还需要进行大规模(但不是完全必要的)优化。大多数场景重复使用他们的几个纹理到几个网格;再想想一所墙壁采用花岗岩纹理的房子。这种纹理也可以应用到地板、天花板、楼梯,也许还有桌子,甚至是附近的一口小井。加载纹理不是一个便宜的操作,在我们当前的实现中,为每个网格加载和生成一个新的纹理,即使完全相同的纹理可能已经加载了好几次。这很快就会成为模型加载实现的瓶颈。

我们会在模型代码中添加一个小调整通过全局存储所有加载的纹理。无论我们想要加载一个纹理,我们首先检查它是否还没有被加载。如果是这样的话,我们使用这个纹理并跳过整个加载程序,为我们节省了很多处理能力。为了能够比较纹理,我们需要存储它们的路径以及:


struct Texture {
    unsigned int id;
    string type;
    string path;  // we store the path of the texture to compare with other textures
};

然后我们将所有加载的纹理存储在另一个向量中,这个向量在模型的类文件的顶部声明为一个私有变量:


vector<Texture> textures_loaded; 

在loadMaterialTextures函数中,我们想要比较纹理路径和textures_loaded矢量中的所有纹理,看看当前纹理路径是否等于其中任何一个。如果是这样,我们就跳过纹理加载/生成部分,而简单地使用定位的纹理结构作为网格的纹理。(更新后)函数如下所示:


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.data(), str.C_Str()) == 0)
            {
                textures.push_back(textures_loaded[j]);
                skip = true; 
                break;
            }
        }
        if(!skip)
        {   // if texture hasn't been loaded already, load it
            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); // add to loaded textures
        }
    }
    return textures;
}  

在使用调试版本和/或IDE的调试模式时,Assimp的一些版本倾向于非常缓慢地加载模型,所以如果您遇到缓慢的加载时间,一定要在发布版本中测试它。

您可以在这里here. 找到模型类的完整源代码。

No more containers!

让我们通过导入一个由真正的艺术家创建的模型来推动实现,而不是像我这样有创造力的天才做的事情。因为我不想给自己太多的赞扬,我偶尔会允许一些其他的艺术家加入这个行列,这次我们将装载这个令人惊奇的Berk Gedik的生存吉他背包Survival Guitar Backpack 。我已经修改了一些材质和路径,这样它就可以直接和我们加载模型的方式一起工作了。模型导出为.obj文件和.mtl文件,后者链接到模型的漫反射、高光和法线贴图(我们稍后会讲到)。你可以在这里here. 下载调整后的模型。注意,有一些额外的纹理类型我们还不会使用,并且所有的纹理和模型文件应该位于相同的目录为纹理加载。

背包的修改版本使用了局部相对纹理路径,并将反照纹理和金属纹理分别命名为漫射纹理和高光纹理。

现在,声明一个模型对象并传递模型的文件位置。然后模型应该自动加载并(如果没有错误)在渲染循环中使用它的绘制函数渲染对象,就是这样。不再需要缓冲区分配、属性指针和呈现命令,只需要一个简单的一行程序。如果你创建了一组简单的着色器,其中碎片着色器只输出对象的漫射纹理,结果看起来有点像这样:

 

您可以在这里here. 找到完整的源代码。注意,我们告诉stb_image.h在我们加载模型之前垂直翻转纹理(如果您还没有这样做的话)。否则纹理看起来会一团糟。

我们还可以更有创意地在渲染公式中引入点光,就像我们在照明章节中学习到的那样,与高光贴图一起得到惊人的效果:

 

尽管我不得不承认这可能比我们目前用的容器更花哨一点。使用Assimp,你可以加载大量的模型在互联网上发现。有相当多的资源网站提供免费的3D模型,以多种文件格式供你下载。要注意的是,有些模型仍然不能正确加载,纹理路径不能工作,或者只是以Assimp无法读取的格式导出。

Further reading

  • How-To Texture Wavefront (.obj) Models for OpenGL: great video guide by Matthew Early on how to set up 3D models in Blender so they directly work with the current model loader (as the texture setup we've chosen doesn't always work out of the box).
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值