前面的教程中,我们都是使用手工指定三维模型,渲染一些简单的物体,比如,正方体、四面体金字塔等等。如果要渲染复杂的物体,该物体包含很多的顶点,每个顶点除了位置,还有很多的属性,比如一张人脸,那么通过在程序中指定顶点缓冲来渲染的话,几乎是不可能的事情,因为模型太复杂了。通常在三维游戏或者一些商业三维应用中,都是艺术家通过一些专用的建模软件,比如Blender, Maya 或者 3ds Max来进行物体建模,模型完成后,然后导出一定的模型文件格式,最后游戏引擎或者别的应用程序,可以读取这些模型文件,产生顶点缓冲、索引缓冲以及一些其它的设置,从而完成复杂模型渲染。本篇教程中,我们将学习如何解析模型文件,并在我们的程序中使用。

      几乎每种游戏引擎或者建模软件都有自己的模型格式,开发一个自己的解析器,来兼容大部分的模型格式,是件费力费时的工作。本篇教程中,我们使用一个第三方开源库Open Asset Import Library来导入模型文件,Assimp开源库能处理很多模型文件格式,比如D3D的x文件,静态的obj文件等等,而且Assimp库是用c++写的,很容易集成到我们的程序里。

      本教程中,我们不会详细介绍Assimp库的原理,感兴趣的朋友可以去它的网站看看,里面有很多介绍,或者你也可以研究它内部的代码,看它是如何解析模型文件的,本文中,只是介绍了如何在我们的程序中通过Assimp库装入三维模型。

(注意:开始编写程序前,你要确保安装了Assimp库,可以从上面给出的链接处下载)

主要代码:
mesh.h
class Mesh 
{ 
public: 
Mesh(); 
~Mesh(); 
bool LoadMesh(const std::string& Filename); 
void Render(); 
private: 
bool InitFromScene(const aiScene* pScene, const std::string& Filename); 
void InitMesh(unsigned int Index, const aiMesh* paiMesh); 
bool InitMaterials(const aiScene* pScene, const std::string& Filename); 
void Clear(); 
#define INVALID_MATERIAL 0xFFFFFFFF 
struct MeshEntry { 
MeshEntry(); 
~MeshEntry(); 
bool Init(const std::vector& Vertices, 
const std::vector& Indices); 
GLuint VB; 
GLuint IB; 
unsigned int NumIndices; 
unsigned int MaterialIndex; 
}; 
std::vector m_Entries; 
std::vector m_Textures; 
};

      Mesh类是Assimp库和我们OpenGL程序的接口, 该类会通过LoadMesh函数从一个模型文件中装入数据,用来产生顶点缓冲,索引缓冲,纹理对象等等。为了渲染三维模型,我们也在该类中增加了Render函数。Mesh类的内部数据结构是和Assimp库装入模型的方式相匹配的, Assimp库用了一个aiScene对象来表示装入的模型,aiScene包含了各种各样模型数据的mesh结构。在aiScene对象中,至少会有一种mesh结构,复杂的模型中,可能包含多种mesh结构。m_Entries是一个MeshEntry类型的向量,每个MeshEntry都对应aiScene对象中的一个mesh结构,这些mesh结构包含顶点缓冲,索引缓冲,纹理索引等等。 现在我们的材质只是一个简单的纹理,因为MeshEntries之间可能会共享纹理,所以我们的Mesh类的包含一个单独的向量m_Texures, MeshEntry::MaterialIndex会指向该MeshEntry在m_Textures中对应的纹理。

mesh.cpp

bool Mesh::LoadMesh(const std::string& Filename) 
{ 
// 释放掉以前装入的模型数据
Clear(); 
bool Ret = false; 
Assimp::Importer Importer; 
const aiScene* pScene = Importer.ReadFile(Filename.c_str(), aiProcess_Triangulate | aiProcess_GenSmoothNormals | aiProcess_FlipUVs); 
if (pScene) { 
Ret = InitFromScene(pScene, Filename); 
} 
else { 
printf("Error parsing '%s': '%s'\n", Filename.c_str(), Importer.GetErrorString()); 
} 
return Ret; 
}

      我们在LoadMesh函数中装入模型文件。首先,我们会创建一个Assimp::Importer类实例,并调用它的成员函数 ReadFile来装入模型文件,该函数的参数有2个,第一个是要装入模型文件的全路径名称,第二个是模型数据后处理选项 。Assimp在装入模型时候,可以进行很多有用的操作,比如,如果模型缺少法向数据,我们可以指定后处理选项让 Assimp 为Mesh自动计算法向,Assimp还可以执行一些优化操作以便改进性能,等等诸如此类的操作。我们通过下面的链接去产看所有的后处理选项, 点击这儿。

注意:我们这些后处理选项是可以通过或操作叠加的] 。模型装入成功后,我们会得到一个指向aiScene 对象的指针,该对象中会包含以aiMesh结构分类的所有模型数据。最后,我们会调用InitFromScene函数,初始化mesh对象。

mesh.cpp

bool Mesh::InitFromScene(const aiScene* pScene, const std::string& Filename) 
{ 
m_Entries.resize(pScene->mNumMeshes); 
m_Textures.resize(pScene->mNumMaterials); 
//逐个初始化场景中的mesh对象
for (unsigned int i = 0 ; i < m_Entries.size() ; i++) { 
const aiMesh* paiMesh = pScene->mMeshes[i]; 
InitMesh(i, paiMesh); 
} 
return InitMaterials(pScene, Filename); 
}
     在初始化三维渲染场景函数中,我们首先为mesh entries和texture vectors两个成员变量分配空间,它们的大小分别为aiScene对象中的mesh和材质数量。接着,我们会遍历aiScene对象中的mesh数组,来逐个初始化mesh entries成员变量。 
void Mesh::InitMesh(unsigned int Index, const aiMesh* paiMesh) 
{ 
    m_Entries[Index].MaterialIndex = paiMesh->mMaterialIndex; 
    std::vector Vertices; 
    std::vector Indices; 
    ... 

      在初始化mesh时候,我们首先会保存材质索引,在渲染过程中,该值用来绑定正确的纹理,接下来,我们会创建2个STL向量,用来存储顶点缓冲和索引缓冲。STL向量通常会被数据存在连续的缓冲中,而且使用方便,我们很容易把向量中的数据装入到opengl buffer中去[通过glBufferData函数]。
    const aiVector3D Zero3D(0.0f, 0.0f, 0.0f); 
    for (unsigned int i = 0 ; i < paiMesh->mNumVertices ; i++) { 
        const aiVector3D* pPos = &(paiMesh->mVertices[i]); 
        const aiVector3D* pNormal = &(paiMesh->mNormals[i]) : &Zero3D; 
        const aiVector3D* pTexCoord = paiMesh->HasTextureCoords(0) ? &(paiMesh->mTextureCoords[0][i]) : &Zero3D; 
        Vertex v(Vector3f(pPos->x, pPos->y, pPos->z), 
                Vector2f(pTexCoord->x, pTexCoord->y), 
                Vector3f(pNormal->x, pNormal->y, pNormal->z)); 
        Vertices.push_back(v); 
    } 
    ...

在上面的代码中,我们生成顶点缓冲的数据(放在Vertices向量中)。

我们使用了aiMesh类的下列属性:

mNumVertices - 顶点数量

mVertices - 顶点位置向量mNumVertices

mNormals - 顶点法向向量 mNormals

mTextureCoords - 顶点纹理坐标向量 mTextureCoords ,注意一个顶点可能包含多个纹理坐标,所以该变量是一个二维数组。

      我们把mesh的顶点,法向,纹理分别放在三个数组中,最终我们会用这三个数组构建顶点属性结构,并把顶点属性结构变量v保存到顶点缓冲变量Vertices中。注意:一些模型可能没有纹理,也不存在纹理坐标,所以我们从aiMesh对象中取纹理时候,要先调用HasTextureCoords(0)函数进行判断,另外一个顶点可能有多个纹理坐标,但在本教程中,我们只用了一个纹理坐标,所以使用paiMesh->mTextureCoords[0][i],0表示第一个纹理坐标,当不在纹理坐标时候,我们只是简单的把纹理坐标负值为0。

    for (unsigned int i = 0 ; i < paiMesh->mNumFaces ; i++) { 
        const aiFace& Face = paiMesh->mFaces[i]; 
        assert(Face.mNumIndices == 3); 
        Indices.push_back(Face.mIndices[0]); 
        Indices.push_back(Face.mIndices[1]); 
        Indices.push_back(Face.mIndices[2]); 
    } 
    ... 

      上面的代码中,我们生成索引缓冲:aiMesh类的成员变量mNumFaces指定了每个mesh中包含多少个多边形(三角形),mFaces成员变量包含具体的索引数据。我们首先会判断每个多边形的顶点数是否为3,不为3的话会产生异常(前面装入模型时候,我们已经旋转了三角形化),接着我们会把三角形的索引数据保存到Indices向量中去。 
    m_Entries[Index].Init(Vertices, Indices); 
}
      最后,我们会用顶点和索引向量初始化MeshEntry变量。在Init函数中,会用glGenBuffer(), glBindBuffer() and glBufferData()几个函数产生顶点和索引缓冲。 
bool Mesh::InitMaterials(const aiScene* pScene, const std::string& Filename) 
{ 
    for (unsigned int i = 0 ; i < pScene->mNumMaterials ; i++) { 
        const aiMaterial* pMaterial = pScene->mMaterials[i]; 
       ...

      该函数会装入模型所用的所有纹理。aiScene对象的成员变量mNumMaterials中有材质的数量,mMaterials则是一个指向aiMaterials结构的数组。aiMaterial是一个很庞大,复杂的类,通常材质被组织成纹理栈的形式,在两个连续的纹理之间,我们需要配置blend和strength函数,blend函数用来决定2个纹理颜色如何相加操作,而strength函数决定两个纹理颜色如何相乘操作,这两个函数都是aiMaterial的一部分。在本教程中,为了和前面的光照shader一致,我们将忽略这两个函数。

        m_Textures[i] = NULL; 
        if (pMaterial->GetTextureCount(aiTextureType_DIFFUSE) > 0) { 
            aiString Path; 
            if (pMaterial->GetTexture(aiTextureType_DIFFUSE, 0, &Path, NULL, NULL, NULL, NULL, NULL) == AI_SUCCESS) { 
                std::string FullPath = Dir + "/" + Path.data; 
                m_Textures[i] = new Texture(GL_TEXTURE_2D, FullPath.c_str()); 
                if (!m_Textures[i]->Load()) { 
                    printf("Error loading texture '%s'\n", FullPath.c_str()); 
                    delete m_Textures[i]; 
                    m_Textures[i] = NULL; 
                    Ret = false; 
                } 
            } 
        } 
        ...
        一个材质可能包含多个纹理,并不是其中的每个纹理都有颜色,比如有的纹理表示高度图,有的纹理表示法向图,偏移图等等。我们光照模型现在只用了一个单纹理来对应所有的光照类型,所以我们只关注漫反射光材质,因此,我们会aiMaterial::GetTextureCount() 函数检测有多少个材质存在,这个函数用纹理类型作为参数,返回值该指定类型纹理的数量。该函数第一个参数即为纹理类型,第二个参数是索引,我们总是指定为0,第三个参数指定纹理文件名字,后面的5个参数是各种各样的纹理配置,比如blend因子,map模式,纹理操作等等,这些参数是可选的,在我们程序中,总是被指定为NULL。我们会把纹理文件名字和目录名字连接起来,我们会假设模型文件和纹理文件在同一个目录。
       if (!m_Textures[i]) { 
          m_Textures[i] = new Texture(GL_TEXTURE_2D, "./white.png"); 
          Ret = m_Textures[i]->Load(); 
       } 
    } 
    return Ret; 
}
      有时候,在模型目录,纹理文件并不存在,此时渲染的结果可能是一片漆黑,所以我们会增加上面的一段代码,当在模型目录找不到纹理时候,我们会装入一个默认的纹理文件,该文件是一副白色的png图片。
void Mesh::Render() 
{ 
    glEnableVertexAttribArray(0); 
    glEnableVertexAttribArray(1); 
    glEnableVertexAttribArray(2); 
    for (unsigned int i = 0 ; i < m_Entries.size() ; i++) { 
        glBindBuffer(GL_ARRAY_BUFFER, m_Entries[i].VB); 
        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 0); 
        glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)12); 
        glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)20); 
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_Entries[i].IB); 
        const unsigned int MaterialIndex = m_Entries[i].MaterialIndex; 
        if (MaterialIndex < m_Textures.size() && m_Textures[MaterialIndex]) { 
            m_Textures[MaterialIndex]->Bind(GL_TEXTURE0); 
        } 
        glDrawElements(GL_TRIANGLES, m_Entries[i].NumIndices, GL_UNSIGNED_INT, 0); 
    } 
    glDisableVertexAttribArray(0); 
    glDisableVertexAttribArray(1); 
    glDisableVertexAttribArray(2); 
}

    在前面教程中,我们都把渲染函数放在主cpp中,本篇教程代码中,我们会把Render函数分离出来。我们会遍历m_Entries,指定顶点缓冲,索引缓冲,以及材质,最后调用draw函数进行gpu渲染操作,这样我们就可以在场景中渲染多个物体了。

glut_backend.cpp

glEnable(GL_DEPTH_TEST);
最后我们在程序初始化开启深度测试,以保证前后遮挡的物体渲染正确。开启深度测试的代码在GLUTBackendRun函数中。
glutInitDisplayMode(GLUT_DOUBLE|GLUT_RGBA|GLUT_DEPTH);
      我们还要初始化深度缓冲,通常深度缓冲初始化时,每个像素深度值都是1.0,和颜色缓冲相似,所有像素在深度缓冲中都有一个对应的单元。
tutorial22.cpp
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

      在每帧渲染前,我们都要清除深度缓冲和颜色缓冲,如果不做这个操作,可能深度缓冲和颜色缓冲中的值还是上一帧的结果,这可能会使得渲染结果不正确。

程序执行后界面如下:

blender 通过图片建立 blender导入图片建模_游戏