计算机图形学(OPENGL):模型导入

本文同时发布在我的个人博客上:https://dragon_boy.gitee.io

模型

  在实际工作中,例如做游戏和作动画,我们并不会在程序中手动定义顶点、法线和纹理坐标,我们往往通过专门用来三维造型的软件来构建需要模型,例如maya,blender,3dmax。这些三维造型软件允许我们建立复杂的模型,并通过UV贴图赋予纹理,软件会自动生成顶点坐标、法线和纹理坐标,之后我们可以将所有的信息到处到一个文件中。市面上有许多主流的3d模型文件格式,比如obj,只存储模型的信息,不保留颜色信息,当然还有fbx,abc,collada等,每种格式的存储模型的信息和方式都是不同的。接下来我们会学习解析这种模型文件来导入模型。

Assimp

  一个非常有名的模型导入库为Assimp(open asset import library)。Assimp可以导入许多格式的模型文件并将数据存储在Assimp生成的数据结构中,然后我们就可以从该数据结构中获取我们需要的信息。当通过Assimp导入模型后,整个模型被导入名为scene的对象中。Assimp有一系列节点,用于存储scene对象中的不同数据的索引,每个节点又有任意数量的子节点。Assimp最简单的模型结构如下:


  • 所有的数据被存储在Scene对象中,例如材质和网格,同时也包含对所有根节点的引用。
  • 场景的根节点包含许多子节点,并包含由点构成网格的顺序索引,索引存储在mMeshes数组中。Scene对象的mMeshes数组包含真正的Mesh对象,而节点中的mMeshes数组只包含索引。
  • 一个Mesh对象包含所有渲染需要用到的数据,如顶点位置,法线向量,纹理坐标,面,材质。
  • 一个网格包含有几个面,而一个面代表一个可渲染的基本几何体(三角形,四边形,点)。一个面包含构成基本几何体的点的索引。由于点和它的索引是分离的,我们可以使用EBO来绘制基本几何体。
  • 最后一个网格也与一个Material对象链接,包含针对Scene对象的材质的索引。Material对象提供一些方法来重建一个对象的材质属性。
      整理一下思路,我们需要做的是:首先,将模型文件的数据导入到Scene对象中,再通过递归的方式检索每个节点相关的Mesh对象,通过处理Mesh对象我们获取它的顶点数据,索引和材质属性。最后我们得到一个模型对象,其中包含我们需要的所有网格数据。
      注意:在建模时,我们往往不会直接建立一个整体模型,而是分组件建立,例如一个人的模型,我们会将头和身子分开,然后创建头发,服装,小道具,最后组合起来。而这其中的每一个组件即一个网格,一个模型会包含多个网格。

编译Assimp

  和我们使用的其它的第三方库一样,我们会编译官方提供的源码来保证能够符合本机的环境。官网:http://assimp.org/index.php/downloads。如有编译错误请参考官网:https://learnopengl.com/Model-Loading/Assimp。

网格类

  通过Assimp,我们可以导入模型文件并将数据保存在Assimp特有的数据结构中,但我们需要将其转化为OpenGL可以使用的格式。上面我们说过,一个网格代表一个可绘制的实体,接下来我们创建自己的Mesh类。
  一个网格至少包含一系列的顶点,每个顶点包含一个坐标向量,一个法线向量,一个纹理坐标向量,一个网格同时也包含绘制的索引,包含纹理信息的材质数据。
  首先定义一个顶点的结构体,包含位置,法线,纹理坐标的属性:

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

  同样,,我们想管理纹理数据,我们定义一个纹理的结构体:

struct Texture {
    unsigned int id;
    string type;
};  

  接下来构建我们的网格类:

class Mesh{
public:
  // 网格数据
  vector vertices;
  vector indices;
  vector textures;

  //构造方法
  Mesh(vector vertices, vector indices, vector textures);
  // 绘制网格
  void Draw(Shader shader);
private:
  // 缓冲对象
  unsigned int VAO, VBO, EBO;
  //初始化缓冲对象
  void setupMesh();
};

  实现构造方法:

Mesh(vector vertices, vector indices, vector textures)
{
    this->vertices = vertices;
    this->indices = indices;
    this->textures = textures;

    setupMesh();
}

初始化

  还是那么一套对于VAO,VBO,EBO的配置,我们来实现setupMesh方法:

void setupMesh()
{
    glGenVertexArrays(1, &VAO);
    glGenBuffers(1, &VBO);
    glGenBuffers(1, &EBO);
  
    glBindVertexArray(VAO);
    glBindBuffer(GL_ARRAY_BUFFER, VBO);

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

    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int), 
                 &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*)offsetof(Vertex, Normal));
    // 顶点纹理
    glEnableVertexAttribArray(2);   
    glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));

    glBindVertexArray(0);
}  

  注意上面的代码,由于结构体的特性,属性在结构体中的排列是有顺序的,同时可以很方便的转化为数组来方便传入数组缓冲。比如我们定义一个顶点,并赋予属性,那么顺序如下:

Vertex vertex;
vertex.Position  = glm::vec3(0.2f, 0.4f, 0.6f);
vertex.Normal    = glm::vec3(0.0f, 1.0f, 0.0f);
vertex.TexCoords = glm::vec2(1.0f, 0.0f);
// = [0.2f, 0.4f, 0.6f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f];

  因为这种特性,我们可以直接传入结构体指针作为缓冲数据:

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

  结构体的另一个特性是我们可以使用offset函数来获取偏移量。offset(s,m)的将结构体作为第一个参数, 第二个参数传入属性名。这个方法返回从结构体的初始位置到属性的位置计算的字节偏移量,我们在glVertexAttribPointer使用:

glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));

渲染

  接下来我们来实现Draw方法。由于我们不清除一个网格拥有多少种贴图,以及每种贴图有多少张,所以我们可以为纹理编号。如每个漫反射纹理被定义为:texture_diffuseN,高光纹理为:texture_specularN(N的最大值由OpenGL限制)。这样我们可以很方便的在着色器中这样定义纹理:

uniform sampler2D texture_diffuse1;
uniform sampler2D texture_diffuse2;
uniform sampler2D texture_diffuse3;
uniform sampler2D texture_specular1;
uniform sampler2D texture_specular2;

  接着就可以在Draw方法中为这些纹理赋予编号并激活和绑定纹理,然后绘制三角形:

void Draw(Shader shader) 
{
    unsigned int diffuseNr = 1;
    unsigned int specularNr = 1;
    for(unsigned int i = 0; i < textures.size(); i++)
    {
        glActiveTexture(GL_TEXTURE0 + i); // 在绑定前激活纹理
        // 检索纹理编号
        string number;
        string name = textures[i].type;
        if(name == "texture_diffuse")
            number = std::to_string(diffuseNr++);
        else if(name == "texture_specular")
            number = std::to_string(specularNr++);

        shader.setFloat(("material." + name + number).c_str(), i);
        glBindTexture(GL_TEXTURE_2D, textures[i].id);
    }

    // 绘制网格
    glBindVertexArray(VAO);
    glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
    glBindVertexArray(0);
}  

  在上述的方法中我们加入了material结构体来管理纹理。
  Mesh类的代码:Mesh.h。

模型类

  接下来我们构建模型导入类。就像之前说的,一个模型包含若干网格,在模型类中我们会使用多个网格对象。
  下面是Model类的基础定义:

class Model 
{
    public:
        // 构造方法
        Model(char *path)
        {
            loadModel(path);
        }
        // 绘制模型
        void Draw(Shader shader);   
    private:
        // 模型数据(多个网格)
        vector meshes;
        string directory;
        
        // 导入模型
        void loadModel(string path);
        // 处理节点
        void processNode(aiNode *node, const aiScene *scene);
        // 处理网格对象
        Mesh processMesh(aiMesh *mesh, const aiScene *scene);
        // 导入材质纹理
        vector loadMaterialTextures(aiMaterial *mat, aiTextureType type, 
                                             string typeName);
};

  一个模型类包含一系列网格对象,构造方法要求文件的路径,接着通过构造方法中的loadModel方法导入模型。所有的private方法用来处理模型数据。
  Draw方法很简单,绘制所有的网格:

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

导入3D模型到OpenGL

  接下来我们要使用Assimp库:

#include 
#include 
#include 

  我们来实现loadModel方法。就像之前说的,我们先将模型文件中的数据存储在Scene对象中:

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

  我们创建一个Importer对象来使用读取文件的ReadFile方法。该方法的一个参数要求一个文件路径,第二个参数要求一些后置处理选项。aiProcess_Trianglulate代表我们强制将所有的基本几何体转化为三角形。aiProcess_FlipUVs代表我们要反转y轴的纹理坐标,这是由于图片的原点在左上角,而OpenGL存储纹理的坐标原点在左下角。当然,还有以下几种后置处理命令:

  • aiProcess_GenNoemals:如果模型不包含法线信息将为每个顶点创建法线。
  • aiProcess_SplitLargeMeshes:将大的网格分割为若干个小的网格。如果网格的顶点数超出了渲染要求的最大顶点数,可以使用这个命令。
  • aiProcess_OptimizeMeshes:这个命令尝试将一些网格合并为大的网格,来减少绘制次数。
      这里还有许多其它的assimp提供的后置处理命令:post-processing。
      完整的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);
}  

  在导入模型后我们检查场景和根节点是否为空,同时检查返回的数据是否不完整。我们接着检索文件所在的路径。如果没有问题的话我们就开始处理场景的节点,我们将根节点作为processNode的第一个参数,接着递归整个场景。
  注意,每个节点包含一系列网格索引,每个索引指向场景中的一个网格对象。因此我们检索这些网格索引,检索每个网格,处理每个网格,接着处理每个节点的子节点。在处理完所有节点后结束递归。下面是processNode的实现:

void processNode(aiNode *node, const aiScene *scene)
{
    // 处理所有节点的网格
    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);
    }
}  

  我们首先检索每个节点的网格索引,然后检索相关的网格对象,并传入porcessMesh进行处理,并将结果存储到我们定义的成员变量meshes中。接着对所有的子节点做同样的操作,所有的节点被处理后结束递归。

将aiMesh对象转化为我们定义的Mesh对象

  下面我们实现processMesh方法:

Mesh processMesh(aiMesh *mesh, const aiScene *scene)
{
    vector vertices;
    vector indices;
    vector textures;

    for(unsigned int i = 0; i < mesh->mNumVertices; i++)
    {
        Vertex vertex;
        // 处理顶点位置,法线,纹理坐标
        ...
        vertices.push_back(vertex);
    }
    // 处理索引
    ...
    // 处理材质
    if(mesh->mMaterialIndex >= 0)
    {
        ...
    }

    return Mesh(vertices, indices, textures);
}  

  处理过程包含3部分,检索所有的顶点数据,检索索引,检索材质。检索数据存储在我们定义的三个变量中,我们组装为Mesh对象并作为返回值。
  处理顶点数据很简单,位置通过mesh->nVertices检索,法线通过mesh->mNormals检索,纹理通过mesh->mTextureCoords检索:
  顶点位置:

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

  顶点法线:

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

  纹理坐标,注意,Assimp允许模型至多由8种不同的纹理坐标,但我们只关心纹理集合的首元素是否含有纹理坐标:

if(mesh->mTextureCoords[0]) // 判断纹理集的首元素是否由纹理坐标
{
    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);  

索引

  接着我们处理索引。Assimp种,每个网包含一系列面,每个面代表一个基本几何体,在这里我们设置的是三角形。一个面包含绘制面的点的索引。所以我们检索所有的面然后存储每个面的索引:

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

材质

  和节点一样,一个网格只包含对材质对象的索引。为了检索网格的材质,我们需要场景mMaterials数组的索引,这被存储在mMaterialIndex属性中。我们首先确定网格是否包含材质信息:

if(mesh->mMaterialIndex >= 0)
{
    aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
    vector diffuseMaps = loadMaterialTextures(material, 
                                        aiTextureType_DIFFUSE, "texture_diffuse");
    textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
    vector specularMaps = loadMaterialTextures(material, 
                                        aiTextureType_SPECULAR, "texture_specular");
    textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
}  

  我们通过索引从场景的mMaterials数组中获取aiMaterial对象,其中包含网格的材质信息,即每种纹理的位置。接着我们从aiMaterial对象中加载纹理。我们通过loadMaterialTextures方法来实现。
  loadMaterialTextures基于纹理类型检索所有的纹理位置,并检索所有的纹理文件位置,然后加载和生成纹理并保存在Vertex结构体中:

vector loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
    vector 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。
  注意,这里我们假设模型的纹理文件保存在和模型的相同目录中,我们可以改变directory来获取任意路径下的纹理文件。

优化

  现在仍存在一个问题,即一张纹理可能会有多个网格对象使用。所以我们这样优化一下代码:我们全局存储所有导入的纹理,接着每次导入纹理的时候,我们先检测纹理是否已经导入;如果是,我们跳过所有的导入步骤并使用对应的纹理。
  我们先为Texture结构体添加文件路径属性:

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

  接着,定义一个变量存储所有导入了的纹理:

vector textures_loaded; 

  在loadMaterialTextures方法中,我们比较纹理文件的路径来判断纹理是否复用。如果是,则跳过纹理导入和生成步骤,并使用对应已生成的纹理。修改过的方法如下:

vector loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
    vector 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;
}  

  最终模型类的代码参考在这里:Model.h。
  最后,我们就可以使用模型类导入复杂的模型来丰富我们的场景了。请多多关注原文:https://learnopengl.com/Model-Loading/Model。

你可能感兴趣的:(计算机图形学(OPENGL):模型导入)