您的位置:首页 > 运维架构

OpenGL学习:模型加载-obj模型和AssImp模型

2017-09-26 16:16 411 查看
前面介绍了光照基础内容,以及材质和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可以包含多个Face,一个Face是Mesh中一个可绘制的基本图元,例如三角形,多边形,点。要想模型更加逼真,一般需要增加更多图元使Mesh更加精细,当然这也会受到硬件处理能力的限制,例如PC游戏的处理能力要强于移动设备。由于多边形都可以划分为三角形,而三角形是图形处理器中都支持的基本图元,因此使用得较多的就是三角形网格来建模。例如下面的图(来自:What
is a mesh in OpenGL?)表达了使用越来越复杂的Mesh建模一只兔子的过程:

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

目前模型存储的格式很丰富,比较常用的,例如Wavefront
.obj file,COLLADA等,要了解各个格式的特点,可以参考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
[/code]

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

usemtl和mtllib表示的材质相关数据,解析材质数据稍微繁琐,本节我们只是为了说明加载模型的原理,不做讨论。

o 引入一个新的object

v 表示顶点位置

vt 表示顶点纹理坐标

vn 表示顶点法向量

f 表示一个面,面使用1/2/8这样格式,表示顶点位置/纹理坐标/法向量的索引,这里索引的是前面用v,vt,vn定义的数据 注意这里Obj的索引是从1开始的,而不是0

模型一般通过3d建模软件,例如Blender, 3DS
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
[/code]

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
[/code]

载入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
[/code]

上面提供了一个读取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
[/code]

其余的也类似处理。读取到数据后,在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
[/code]

建立缓冲区的同时,本节我们使用的立方体模型cube.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
[/code]

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




说明

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



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

最后本节的加载obj程序只是一个示例,并没有解析材质mtl部分。当没有使用纹理数据绘制经典的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
[/code]

为了简化程序,这里我们只处理了材质中的纹理数据。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
[/code]

需要注意的是模型文件所在文件路径我们通过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
[/code]

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
[/code]

上面的框架给出了从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
[/code]

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
[/code]

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

3.实际模型的材质中包含了map_Bump数据,但目前还未学习处理方法。

4.目前通过Model加载模型时耗时非常多,效率不高,需要进一步提高模型加载和渲染的速度
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  OpenGL