前面介绍了光照基础内容,以及材质和lighting maps,和光源类型,我们对使用光照增强场景真实感有了一定了解。但是到目前为止,我们通过在程序中指定的立方体数据,绘制立方体,看起来还是很乏味。本节开始介绍模型加载,通过加载丰富的模型,能够丰富我们的场景,变得好玩。本节的示例代码均可以在我的github下载

加载模型可以使用比较好的库,例如obj模型加载的库Assimp加载库。本节作为入门篇,我们一开始不使用这些库加载很酷的模型,而是熟悉下模型以及模型加载的概念,然后我们封装一个简单的obj模型加载类,加载一个简单的立方体模型。 之后我们会使用Assimp库会加载一个酷炫的3d模型,但是首先还是注重多感受下模型加载的基础

通过本节可以了解到

  • Mesh的概念
  • Obj模型数据格式
  • Obj模型简单的加载类和加载实验
  • AssImp模型加载实验

模型的表达

在3d图形处理中,一个模型(model)通常由一个或者多个Mesh(网格)组成,一个Mesh是可绘制的独立实体。例如复杂的人物模型,可以分别划分为头部,四肢,服饰,武器等各个部分来建模,这些Mesh组合在一起最终形成人物模型。

Mesh由顶点、边、面Faces组成的,它包含绘制所需的数据,例如顶点位置、纹理坐标、法向量,材质属性等内容,它是OpenGL用来绘制的最小实体。Mesh的概念示意如下图所示(来自:What is a mesh in OpenGL?):

Mesh        Mesh2

Mesh可以包含多个Face,一个Face是Mesh中一个可绘制的基本图元,例如三角形,多边形,点。要想模型更加逼真,一般需要增加更多图元使Mesh更加精细,当然这也会受到硬件处理能力的限制,例如PC游戏的处理能力要强于移动设备。由于多边形都可以划分为三角形,而三角形是图形处理器中都支持的基本图元,因此使用得较多的就是三角形网格来建模。例如下面的图(来自:What is a mesh in OpenGL?)表达了使用越来越复杂的Mesh建模一只兔子的过程:

随着增加三角形个数,兔子模型变得越来越真实。

目前模型存储的格式很丰富,比较常用的,例如Wavefront .obj fileCOLLADA等,要了解各个格式的特点,可以参考wiki 3D graphics file formats。在众多的格式中以obj格式比较通用,它内部是以文本形式表达的,接下来我们通过熟悉下obj格式,了解模型是如何定义的,以及如何加载到OpenGL中来渲染模型。

Obj模型数据格式

obj模型内部以文本存储,例如从Model loading处获取的一个立方体模型cube.obj的数据如下:

# Blender3D v249 OBJ File: untitled.blend
# www.blender3d.org
mtllib cube.mtl
v 1.000000 -1.000000 -1.000000
v 1.000000 -1.000000 1.000000
v -1.000000 -1.000000 1.000000
...
vt 0.748573 0.750412
vt 0.749279 0.501284
vt 0.999110 0.501077
...
vn 0.000000 0.000000 -1.000000
vn -1.000000 -0.000000 -0.000000
vn -0.000000 -0.000000 1.000000
...
usemtl Material_ray.png
s off
f 5/1/1 1/2/1 4/3/1
f 5/1/1 4/3/1 8/4/1
f 3/5/2 7/6/2 8/7/2
...
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48

对这个文本格式做一个简要说明:

  • usemtl和mtllib表示的材质相关数据,解析材质数据稍微繁琐,本节我们只是为了说明加载模型的原理,不做讨论。
  • o 引入一个新的object
  • v 表示顶点位置
  • vt 表示顶点纹理坐标
  • vn 表示顶点法向量
  • f 表示一个面,面使用1/2/8这样格式,表示顶点位置/纹理坐标/法向量的索引,这里索引的是前面用v,vt,vn定义的数据 注意这里Obj的索引是从1开始的,而不是0

模型一般通过3d建模软件,例如Blender3DS Max 或者 Maya等工具建模,导出时的数据格式变化较大,:将一种模型数据文件表示的模型,转换为OpenGL可以利用的数据。例如上面的Obj文件中,我们需要解析顶点位置,纹理坐标等数据,构成OpenGL可以渲染的Mesh对象。

从Obj到OpenGL可以理解的Mesh

上面说明了Obj的数据格式,那么在OpenGL中我们怎么表达Mesh呢?首先定义顶点属性数据如下所示:

 // 表示一个顶点属性
struct Vertex
{
    glm::vec3 position;  // 顶点位置
    glm::vec2 texCoords; // 纹理坐标
    glm::vec3 normal;  // 法向量
};
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

Mesh中包含顶点属性,纹理对象等信息,本节我们定义Mesh数据结构如下所示:


// 表示一个OpenGL渲染的最小实体
class Mesh
{
public:
    void draw(Shader& shader) // 绘制Mesh
    Mesh(const std::vector<Vertex>& vertData, 
        GLint textureId) // 构造一个Mesh
private:
    std::vector<Vertex> vertData;// 顶点数据
    GLuint VAOId, VBOId; // 缓存对象
    GLint textureId; // 纹理对象id
    void setupMesh();  // 建立VAO,VBO等缓冲区
};
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

载入obj模型的过程,就是读取obj文件,并转换为上面Mesh对象的过程。这个过程的思路大致是这样的,读取文件的每一行,根据行首部的指示,确定数据类型,然后加载到mesh的vertData里面去,这个框架是这样:

std::ifstream file(objFilePath);
while (getline(file, line))
{
    if (line.substr(0, 2) == "vt") // 顶点纹理坐标数据
    {
        // 解析顶点纹理数据
    }
    else if (line.substr(0, 2) == "vn") // 顶点法向量数据
    {
        // 解析法向量数据
    }
    else if (line.substr(0, 1) == "v") // 顶点位置数据
    {
        // 解析顶点位置数据
    }
    else if (line.substr(0, 1) == "f") // 面数据
    {
        // 解析面数据
    }
    else if (line[0] == '#') // 注释忽略
    { }
    else  
    {
        // 其余内容 暂时不处理
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

上面提供了一个读取obj文件格式的框架,例如解析纹理坐标数据如下:

if (line.substr(0, 2) == "vt") // 顶点纹理坐标数据
{
    std::istringstream s(line.substr(2));
    glm::vec2 v;
    s >> v.x; 
    s >> v.y;
    v.y = -v.y;  // 注意这里加载的dds纹理 要对y进行反转
    temp_textCoords.push_back(v);
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

其余的也类似处理。读取到数据后,在Mesh对象里面需要向前面绘制物体时一样建立缓冲数据,如下:

void setupMesh()  // 建立VAO,VBO等缓冲区
{
    glGenVertexArrays(1, &this->VAOId);
    glGenBuffers(1, &this->VBOId);

    glBindVertexArray(this->VAOId);
    glBindBuffer(GL_ARRAY_BUFFER, this->VBOId);
    glBufferData(GL_ARRAY_BUFFER, sizeof(Vertex)* this->vertData.size(),
        &this->vertData[0], GL_STATIC_DRAW);
    // 顶点位置属性
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
        sizeof(Vertex), (GLvoid*)0);
    glEnableVertexAttribArray(0);
    // 顶点纹理坐标
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE,
        sizeof(Vertex), (GLvoid*)(3 * sizeof(GL_FLOAT)));
    glEnableVertexAttribArray(1);
    // 顶点法向量属性
    glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE,
        sizeof(Vertex), (GLvoid*)(5 * sizeof(GL_FLOAT)));
    glEnableVertexAttribArray(2);
    glBindBuffer(GL_ARRAY_BUFFER, 0);
    glBindVertexArray(0);
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

建立缓冲区的同时,本节我们使用的立方体模型cube.dds纹理如下图所示: 
dds

这与以前使用的png纹理不一样,这里我用C++重新改编了Model loading处的加载dds纹理的函数,加载纹理不是本节的重点,具体可以查看github代码。加载纹理后,可以渲染这个obj表达的立方体模型,整个过程如下:

//Section1 从obj文件加载数据
std::vector<Vertex> vertData;
ObjLoader::loadFromFile("cube.obj", vertData)

// Section2 准备纹理
GLint textureId = TextureHelper::loadDDS("cube.dds");

// Section3 建立Mesh对象
Mesh mesh(vertData, textureId);

// Section4 准备着色器程序
Shader shader("cube.vertex", "cube.frag");

// 在游戏主循环中渲染立方体
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

这里我们可以看到,与以往在程序中通过数值指定立方体模型相比,我们的代码更简洁,后面介绍使用Assimp加载库后,可以加载更多丰富的模型,当然要比这个立方体好看。但是本节还是看一下最终立方体的效果吧,如下: 
obj加载

说明

在使用dds纹理的时候,要注意纹理的y轴相对于OpenGL是进行反转的,因此需要使用( coord.u, 1.0-coord.v) 来访问,这可以在加载obj时做,也可以在着色器里面做。没有使用反转的v坐标将导致,无法正常渲染,这也是困住我的一个地方。后来使用数据比对格式发现了这个错误,如下图,左边是反转了的数据,右边是未反转的数据:

v导致的错误

在使用blender软件导出模型时,即使勾选了includ UVs,输出时仍然没有纹理坐标,这是因为除了勾选这些选项外,还需要一个uv map操作,关于这一点也是容易产生错误的,详细可以参考Add UV Mapped texture coordinates to OBJ file?。uv mappring这个操作的过程比较繁琐,就不再这里介绍了,感兴趣地可以参考UV Mapping a Mesh

最后本节的加载obj程序只是一个示例,并没有解析材质mtl部分。当没有使用纹理数据绘制经典的Suzanne 模型如下图所示: 
Suzanne

这里缺少了纹理和光照,所以模型看起来不真实,下面节介绍使用Assimp加载库时将会改善这一点。

下载和安装AssImp

AssImp是一个模型加载库,它将不同格式的模型数据转换为统一的抽象的数据类型,因而支持较多的模型文件格式。下载和编译这个库的过程,你可以参考官方文档。在linux下可以直接apt-get安装: apt-get install libassimp-dev。

OpenGL需要的数据结构

加载模型的任务就是将抽象的模型数据转换为OpenGL可以处理的VBO,EBO,纹理数据。在程序内部我们定义了Mesh,Model结构来作为内部格式。Mesh表达是绘制的最小实体,它包含顶点属性数据、材质数据;Model则是包含1个或者多个Mesh的模型。定义Mesh结构如下:

// 表示一个顶点属性
struct Vertex
{
    glm::vec3 position;
    glm::vec2 texCoords;
    glm::vec3 normal;
};
// 表示一个Texture
struct Texture
{
    GLuint id;
    aiTextureType type;
    std::string path;
};
// 表示一个用于渲染的最小实体
class Mesh
{
public:
    void draw(const Shader& shader) const;// 绘制Mesh
    Mesh():VAOId(0), VBOId(0), EBOId(0){}
    Mesh(const std::vector<Vertex>& vertData, 
        const std::vector<Texture> & textures,
        const std::vector<GLuint>& indices); // 构造一个Mesh
    void final() const; // 释放VBO等空间
private:
    std::vector<Vertex> vertData;  // 顶点属性数据
    std::vector<GLuint> indices;    // 索引数据 
    std::vector<Texture> textures;  // 纹理数据
    GLuint VAOId, VBOId, EBOId;
    void setupMesh();  // 建立VAO,VBO等缓冲区
};
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33

为了简化程序,这里我们只处理了材质中的纹理数据。Model则是一个包含多个Mesh的类,定义如下:

// 代表一个模型 模型可以包含一个或多个Mesh
class Model
{
public:
    void draw(const Shader& shader) const
    {
        for (mesh in meshes)
        {
            mesh->draw(shader);
        }
    }
    bool loadModel(const std::string& filePath);
    ~Model()
    {
        for (mesh in meshes)
        {
            mesh->final();
        }
    }
private:
    bool processNode(const aiNode* node, const aiScene* sceneObjPtr); 
    bool processMesh(const aiMesh* meshPtr, const aiScene* sceneObjPtr, Mesh& meshObj);
    bool processMaterial(const aiMaterial* matPtr, 
        const aiScene* sceneObjPtr, Material& material);
private:
    std::vector<Mesh> meshes; // 保存Mesh
    std::string modelFileDir; // 保存模型文件的文件夹路径
    typedef std::map<std::string, Texture> LoadedTextMapType; // key = texture file path
    LoadedTextMapType loadedTextureMap; // 保存已经加载的纹理
};
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

需要注意的是模型文件所在文件路径我们通过modelFileDir保存起来,因为模型中纹理数据可能使用相对路径来表示纹理,通过modelFileDir加上这个相对路径才能找到纹理图片的正确路径。

AssImp加载模型

加载模型时首先创建 Assimp::Importer的示例,然后通过它的l Assimp::Importer::ReadFile()方法加载模型,如下所示:

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

Assimp::Importer importer;
const aiScene* sceneObjPtr = importer.ReadFile(filePath, 
            aiProcess_Triangulate | aiProcess_FlipUVs);
if (!sceneObjPtr
    || sceneObjPtr->mFlags == AI_SCENE_FLAGS_INCOMPLETE
    || !sceneObjPtr->mRootNode)
{
    std::cerr << "Error:Model::loadModel, description: " 
        << importer.GetErrorString() << std::endl;
    return false;
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

ReadFile函数中第二个参数就是后处理选项,它是一个枚举类型aiPostProcessSteps,可以使用位或操作包含多个选项,例如选项aiProcess_MakeLeftHanded表示将默认的右手系坐标数据转换为左手系坐标数据,aiProcess_Triangulate选项将索引数据多余3个的多边形划分为多个三角形,方便我们使用三角形进行绘制。完整的后处理选项列表,可以参考官方文档

通过上面的加载我们获取到了模型的根结构数据aiScene,接下来的工作就是:从aiScene获取OpenGL所需要的VBO,EBO,纹理数据

AssImp中数据通过aiNode组织父子结点,包含了层次信息,我们可以忽略这些信息,直接读取所有我们需要的VBO,EBO,纹理数据,但是这种父子结构信息在后面制作骨骼动画时会再次用到,因此这里还是按照层次的方式来解析aiScene数据。

所谓结点就是包含一个多个Mesh的部位,例如一个人物角色,可能包含头部,颈部,手臂,胸部等多个结点,每个结点也可以包含更多的细化结点。解析aiScene这种父子结点的层次数据,直观的方法就是使用递归,递归就是一个函数直接调用自己,一层一层调用下去,当遇到一个合适条件时终止调用,函数一层层返回。从aiScene解析模型数据获取OpenGL所需数据的框架大概是这样的:

bool loadModel(const std::string& filePath)
{
    // 加载模型 得到aiScene
const aiScene* sceneObjPtr = importer.ReadFile(filePath, 
    aiProcess_Triangulate | aiProcess_FlipUVs);
    // 递归处理结点
    return this->processNode(sceneObjPtr->mRootNode, sceneObjPtr);
}
bool processNode(const aiNode* node, const aiScene* sceneObjPtr)
{
    for (size_t i = 0; i < node->mNumMeshes; ++i) // 先处理自身结点
    {
        // 注意node中的mesh是对sceneObject中mesh的索引
        const aiMesh* meshPtr = sceneObjPtr->mMeshes[node->mMeshes[i]]; 
        this->processMesh(meshPtr, sceneObjPtr, meshObj); // 处理Mesh
    }
    for (size_t i = 0; i < node->mNumChildren; ++i) // 再处理孩子结点
    {
        this->processNode(node->mChildren[i], sceneObjPtr);
    }
    return true;
}
bool processMesh(const aiMesh* meshPtr, const aiScene* sceneObjPtr, Mesh& meshObj)
{
    // 从Mesh得到顶点数据、法向量、纹理数据
    for (size_t i = 0; i < meshPtr->mNumVertices; ++i){...}
    // 获取索引数据
    for (size_t i = 0; i < meshPtr->mNumFaces; ++i){...}
     // 获取纹理数据
    if (meshPtr->mMaterialIndex >= 0)
    {
        this->processMaterial(materialPtr, sceneObjPtr, aiTextureType_DIFFUSE, diffuseTexture);
        this->processMaterial(materialPtr, sceneObjPtr, aiTextureType_SPECULAR, specularTexture);
    }
    return true;
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39

上面的框架给出了从aiScene获取数据,建立内部格式Model和Mesh的思路,具体实现细节可以参考程序源码

加载纳米战斗服模型

到这里,我们可以来欣赏酷炫的模型了,首先加载一个从learnopengl获取的纳米战斗服模型nanosuit,效果如下图左所示: 
有光照效果没有光照效果

这里没有使用光照,上图右是实现了一个点光源的效果. 可以从机器人胸部的高光部分看到,实现光照时的区别。

加载模型需要注意的地方

1.加载模型后,需要适当设置模型变换矩阵,否则模型显示在奇怪的位置。这个模型变换矩阵,目前还没找到合适的方法从模型数据中获取。 
2.下载的模型,有些路径是不正确的,本文统一采用绝对路径方式。路径不正确或者文件缺失时的错误提示 
3.部分纹理图片的格式,模型的格式目前并未处理,不支持加载。

还需要改进的地方

上面加载的模型,已经让人很兴奋了,但是还不够真实,高效。在实验过程中,思考还需要通过以下方面进行改进:

1.我们这里的材质只处理了纹理部分,实际上模型中如果没有通过纹理定义材质,还需要获取ambient等颜色表示的材质。而且纹理可能不止一个,本文目前只处理了一个纹理(主要原因是下载的素材里面没有找不到更多的纹理坐标)。可以通过定义下面的材质结构体,并处理这个材质数据来丰富场景:

struct Material // 表示材质属性
{
    glm::vec3 ambient;
    glm::vec3 diffuse;
    glm::vec3 specular;
    float shininess;
    std::vector<Texture> textures;
};
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

2.模型中要通过光源和相机加以改善。目前在模型中通过以下方式:

if (sceneObjPtr->HasLights() 
&& !this->processLightSource(sceneObjPtr))
{
std::cerr 
<< "Error:Model::loadModel, process lights failed."
 << std::endl;
    return false;
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

获取光源数据时,大量从网络上下载的模型中并没有找到光源数据,比较可惜。

3.实际模型的材质中包含了map_Bump数据,但目前还未学习处理方法。 
4.目前通过Model加载模型时耗时非常多,效率不高,需要进一步提高模型加载和渲染的速度


Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐