1
0
mirror of https://github.com/LearnOpenGL-CN/LearnOpenGL-CN.git synced 2025-08-22 20:25:28 +08:00

Rewrite 03-03

This commit is contained in:
Meow J
2017-06-27 01:32:46 +08:00
parent f51e461a05
commit 6420ffbd97
4 changed files with 123 additions and 127 deletions

View File

@@ -3,21 +3,22 @@
原文 | [Model](http://learnopengl.com/#!Model-Loading/Model) 原文 | [Model](http://learnopengl.com/#!Model-Loading/Model)
---|--- ---|---
作者 | JoeyDeVries 作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/) 翻译 | Meow J
校对 | [Geequlim](http://geequlim.com) 校对 | 暂未校对
现在是时候着手启用Assimp,并开始创建实际的加载和转换代码了。教程的目标是创建另一个能够完整表示模型(Model)的类,更确切的说,是一个包含多个网格(Mesh),并且可能带有多个对象的模型。也就是说,一个包含木制阳台、塔楼、甚至游泳池的房子,仍然可以被加载为单独的一个模型。我们将通过Assimp加载模型把它们转变为多个在[上一节](02 Mesh.md)中创建的<fun>Mesh</fun>对象。 现在是时候接触Assimp创建实际的加载和转换代码了。这个教程的目标是创建另一个类来完整表示一个模型,或者说是包含多个网格,甚至是多个物体的模型。一个包含木制阳台、塔楼、甚至游泳池的房子可能仍会被加载为一个模型。我们会使用Assimp加载模型,并将它转换(Translate)至多个在[上一节](02 Mesh.md)中创建的<var>Mesh</var>对象。
闲话少说,我会先把<fun>Model</fun>类的结构给你:
事不宜迟,我会先把<fun>Model</fun>类的结构给你:
```c++ ```c++
class Model class Model
{ {
public: public:
/* 函数 */ /* 函数 */
Model(GLchar* path) Model(char *path)
{ {
this->loadModel(path); loadModel(path);
} }
void Draw(Shader shader); void Draw(Shader shader);
private: private:
@@ -26,30 +27,28 @@ class Model
string directory; string directory;
/* 函数 */ /* 函数 */
void loadModel(string path); void loadModel(string path);
void processNode(aiNode* node, const aiScene* scene); void processNode(aiNode *node, const aiScene *scene);
Mesh processMesh(aiMesh* mesh, const aiScene* scene); Mesh processMesh(aiMesh *mesh, const aiScene *scene);
vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type, vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type,
string typeName); string typeName);
}; };
``` ```
`Model`类包含一个`Mesh`对象的向量,我们需要在构造函数中给出文件的位置。之后,在构造中,它通过`loadModel`函数加载文件。私有方法都被设计为处理一部分的Assimp导入的常规动作我们会简单讲讲它们。同样我们储存文件路径的目录,这样稍后加载纹理的时候会用到。 <fun>Model</fun>类包含一个<fun>Mesh</fun>对象的vector译注这里指的是C++中的vector模板类之后遇到均不译构造器需要我们给它一个文件路径。在构造中,它会直接通过<fun>loadModel</fun>来加载文件。私有函数将会处理Assimp导入过程中的一部分我们很快就会介绍它们。我们还将储存文件路径的目录,在之后加载纹理的时候会用到
函数`Draw`没有什么特别之处基本上是循环每个网格调用各自的Draw函数。
<fun>Draw</fun>函数没有什么特别之处,基本上就是遍历了所有网格,并调用它们各自的<fun>Draw</fun>函数。
```c++ ```c++
void Draw(Shader shader) void Draw(Shader shader)
{ {
for(GLuint i = 0; i < this->meshes.size(); i++) for(unsigned int i = 0; i < meshes.size(); i++)
this->meshes[i].Draw(shader); meshes[i].Draw(shader);
} }
``` ```
## 导入3D模型到OpenGL ## 导入3D模型到OpenGL
为了导入一个模型,并它转换我们自己的数据结构,第一件需要做的事是包含合适的Assimp头文件这样编译器就不会对我们抱怨了。 要想导入一个模型,并它转换我们自己的数据结构中的话,首先我们需要包含Assimp对应的头文件,这样编译器就不会抱怨我们了。
```c++ ```c++
#include <assimp/Importer.hpp> #include <assimp/Importer.hpp>
@@ -57,117 +56,111 @@ void Draw(Shader shader)
#include <assimp/postprocess.h> #include <assimp/postprocess.h>
``` ```
我们将要调用的第一个函数是`loadModel`,它被构造函数直接调用。在`loadModel`函数里面我们使用Assimp加载模型Assimp中被称为scene对象的数据结构。你可能还记得模型加载系列的第一个教程中这是Assimp数据结构的根对象。一旦我们有了场景对象,我们就能从已加载模型中获取所有所需数据了。 首先需要调用的函数是<fun>loadModel</fun>,它会从构造器中直接调用。在<fun>loadModel</fun>中我们使用Assimp加载模型Assimp的一个叫做<u>scene</u>的数据结构。你可能还记得模型加载章节的[第一节](01 Assimp.md)教程中这是Assimp数据接口的根对象。一旦我们有了这个场景对象,我们就能访问到加载后的模型中所有所需数据了。
Assimp最大优点是它简约的抽象了所加载所有不同格式文件的技术细节用一行可以做到这一切
Assimp很棒的一点在于它抽象掉了加载不同文件格式的所有技术细节只需要一行代码就能完成所有的工作
```c++ ```c++
Assimp::Importer importer; Assimp::Importer importer;
const aiScene* scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs); const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
``` ```
我们先来声明一个`Importer`对象,它的名字空间是`Assimp`,然后调用它的`ReadFile`函数。这个函数需要一个文件路径,第二个参数是后处理post-processing选项。除了可以简单加载文件外Assimp允许我们定义几个选项来强制Assimp去对导入数据做一些额外的计算或操作。通过设置`aiProcess_Triangulate`我们告诉Assimp如果模型不是全部由三角形组成应该转换所有的模型的原始几何形状为三角形。`aiProcess_FlipUVs`基于y轴翻转纹理坐标在处理的时候是必须的(你可能记得我们在纹理教程中,我们说过在OpenGL大多数图像会被沿着y轴反转所以这个小小的后处理选项会为我们修正这个。一少部分其他有用的选项如下 我们首先声明了Assimp命名空间内的一个<fun>Importer</fun>,之后调用它的<var>ReadFile</var>函数。这个函数需要一个文件路径,它的第二个参数是一些<def>后期处理</def>(Post-processing)的选项。除了加载文件Assimp允许我们设定一些选项来强制对导入数据做一些额外的计算或操作。通过设定<var>aiProcess_Triangulate</var>我们告诉Assimp如果模型不是(全部)由三角形组成,它需要将模型所有的图元形状变换为三角形。<var>aiProcess_FlipUVs</var>将在处理的时候翻转y轴的纹理坐标(你可能记得我们在[纹理](../01 Getting started/06 Textures.md)教程中说过在OpenGL中大部分的图像的y轴都是反的所以这个后期处理选项将会修复这个。其它一些比较有用的选项
* `aiProcess_GenNormals` : 如果模型没有包含法线向量,就为每个顶点创建法线。 - <var>aiProcess_GenNormals</var>如果模型包含法向量的话,就为每个顶点创建法线。
* `aiProcess_SplitLargeMeshes` : 把大的网格分成几个小的的下级网格,当你渲染有一个最大数量顶点限制时或者只能处理小块网格时很有用。 - <var>aiProcess_SplitLargeMeshes</var>:将比较大的网格分割成更小的子网格,如果你的渲染有最大顶点限制,只能渲染较小的网格,那么它会非常有用。
* `aiProcess_OptimizeMeshes` : 和上个选项相反,它把几个网格结合为一个大的网格。以减少绘制函数调用的次数的方式来优化。 - <var>aiProcess_OptimizeMeshes</var>和上个选项相反,它会将多个小网格拼接为一个大的网格减少绘制调用从而进行优化。
Assimp提供了后处理说明,你可以[这里](http://assimp.sourceforge.net/lib_html/postprocess_8h.html)找到所有内容。事实上通过Assimp加载一个模型超级简单。困难的是使用返回的场景对象加载的数据换到一个Mesh对象的数组。 Assimp提供了很多有用的后期处理指令,你可以[这里](http://assimp.sourceforge.net/lib_html/postprocess_8h.html)找到全部的指令。实际上使用Assimp加载模型是非常容易的你也可以看到。困难的是之后使用返回的场景对象加载的数据换到一个<fun>Mesh</fun>对象的数组。
完整的`loadModel`函数在这里列出:
完整的<fun>loadModel</fun>函数将会是这样的:
```c++ ```c++
void loadModel(string path) void loadModel(string path)
{ {
Assimp::Importer import; Assimp::Importer import;
const aiScene* scene = import.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs); const aiScene *scene = import.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
if(!scene || scene->mFlags == AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode) if(!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
{ {
cout << "ERROR::ASSIMP::" << import.GetErrorString() << endl; cout << "ERROR::ASSIMP::" << import.GetErrorString() << endl;
return; return;
} }
this->directory = path.substr(0, path.find_last_of('/')); directory = path.substr(0, path.find_last_of('/'));
this->processNode(scene->mRootNode, scene); processNode(scene->mRootNode, scene);
} }
``` ```
在我们加载了模型之后,我们检验是否场景和场景的根节点为空,查看这些标记中的一个来看看返回的数据是完整。如果发生了任何一个错误,我们通过导入器impoter的`GetErrorString`函数返回错误报告。我们同样重新获取文件的目录路径。 在我们加载了模型之后,我们会检查场景和其根节点不为null并且检查了它的一个标记(Flag),来查看返回的数据是不是不完整。如果遇到了任何错误,我们都会通过导入器的<fun>GetErrorString</fun>函数来报告错误并返回。我们也获取文件路径的目录路径。
如果什么错误发生,我们希望处理所有的场景节点,所以我们传递第一个节点(根节点)到递归函数`processNode`。因为每个节点(可能)包含多个子节点,我们希望先处理父节点再处理子节点,以此类推。这符合递归结构,所以我们定义一个递归函数。递归函数就是一个做一些什么处理之后,用不同的参数调用它自身的函数,此种循环不会停止,直到一个特定条件发生。在我们的例子里,特定条件是所有的节点都被处理。 如果什么错误都没有发生,我们希望处理场景中的所有节点,所以我们第一个节点(根节点)传入了递归的<fun>processNode</fun>函数。因为每个节点(可能)包含多个子节点,我们希望先处理参数中的节点,再继续处理该节点所有的子节点,以此类推。这符合一个递归结构,所以我们定义一个递归函数。递归函数做一些处理之后,使用不同的参数<def>递归</def>调用这个函数自身,直到某个条件被满足停止递归。在我们的例子中<def>退出条件</def>(Exit Condition)是所有的节点都被处理完毕
也许你记得Assimp的结构每个节点包含一个网格集合的索引每个索引指向一个在场景对象中特定的网格位置。我们希望获取这些网格索引获取每个网格处理每个网格然后对其他的节点的子节点做同样的处理。`processNode`函数的内容如下:
你可能还记得Assimp的结构中每个节点包含了一系列的网格索引每个索引指向场景对象中的那个特定网格。我们接下来就想去获取这些网格索引获取每个网格处理每个网格接着对每个节点的子节点重复这一过程。<fun>processNode</fun>函数的内容如下:
```c++ ```c++
void processNode(aiNode* node, const aiScene* scene) void processNode(aiNode *node, const aiScene *scene)
{ {
// 添加当前节点中的所有Mesh // 处理节点所有的网格(如果有的话)
for(GLuint i = 0; i < node->mNumMeshes; i++) for(unsigned int i = 0; i < node->mNumMeshes; i++)
{ {
aiMesh* mesh = scene->mMeshes[node->mMeshes[i]]; aiMesh *mesh = scene->mMeshes[node->mMeshes[i]];
this->meshes.push_back(this->processMesh(mesh, scene)); meshes.push_back(processMesh(mesh, scene));
} }
// 递归处理该节点的子节点 // 接下来对它的子节点重复这一过程
for(GLuint i = 0; i < node->mNumChildren; i++) for(unsigned int i = 0; i < node->mNumChildren; i++)
{ {
this->processNode(node->mChildren[i], scene); processNode(node->mChildren[i], scene);
} }
} }
``` ```
我们首先利用场景的`mMeshes`数组来检查每个节点的网格索引以获取应的网格。返回的网格被传递给`processMesh`函数,它返回一个网格对象,我们可以把它储存在`meshes`的list或vectorSTL里的两种实现链表的数据结构 我们首先检查每个节点的网格索引,并索引场景的<var>mMeshes</var>数组来获取应的网格。返回的网格将会传递到<fun>processMesh</fun>函数,它返回一个<fun>Mesh</fun>对象,我们可以将它存储在<var>meshes</var>列表/vector
一旦所有网格都被处理,我们遍历所有子节点,同样调用processNode函数。一旦一个节点不再有任何子节点函数会停止执行。 所有网格都被处理之后,我们遍历节点的所有子节点,并对它们调用相同的<fun>processMesh</fun>函数。一个节点不再有任何子节点之后,这个函数会停止执行。
!!! Important !!! Important
认真的读者会注意到,我们可基本忘记处理任何的节点,简单循环出场景所有网格,而不是用索引做这复杂的。我们这么做的原因是,使用这种节点的原始的想法是,在网格之间定义一个父-子关系。通过递归遍历这关系,我们可以真正定义特定的网格作为其他网格的父(节点)。 认真的读者可能会发现,我们可基本上忘掉处理任何的节点,只需要遍历场景对象的所有网格,就不需要为了索引做这一堆复杂的东西了。我们这么做的原因是,使用节点的最初想法是网格之间定义一个父子关系。通过这样递归遍历这关系,我们就能将某个网格定义为另一个网格的父网格了。
这个系统的一个使用案例是,当你想位移一个汽车的网格时,你可以保证它的所有子网格(比如引擎网格、方向盘网格、轮胎网格)都会随着一起位移。这样的系统能够用父子关系很容易地创建出来。
关于这个系统的一个有用的例子是,当你想要平移一个汽车网格需要确保把它的子(节点)比如,引擎网格,方向盘网格和轮胎网格都进行平移;使用父-子关系这样的系统很容易被创建出来。
现在我们没用这种系统,但是无论何时你想要对你的网格数据进行额外的控制,这通常是一种坚持被推荐的做法。这些模型毕竟是那些定义了这些节点风格的关系的艺术家所创建的。
下一步是用上个教程创建的`Mesh`类开始真正处理Assimp的数据 然而,现在我们并没有使用这样一种系统,但如果你想对你的网格数据有更多的控制,通常都是建议使用这一种方法的。这种类节点的关系毕竟是由创建了这个模型的艺术家所定义
下一步就是将Assimp的数据解析到上一节中创建的<fun>Mesh</fun>类中。
### 从Assimp到网格 ### 从Assimp到网格
一个`aiMesh`对象转换为一个我们自己定义的网格对象不难。我们要做的全部是获取每个网格相关属性并把这些属性储存到我们自己的对象。通常`processMesh`函数的结构会是这样 一个`aiMesh`对象转化为我们自己的网格对象不是那么困难。我们要做的只是访问网格相关属性并将它们储存到我们自己的对象中。<fun>processMesh</fun>函数的大体结构如下
```c++ ```c++
Mesh processMesh(aiMesh* mesh, const aiScene* scene) Mesh processMesh(aiMesh *mesh, const aiScene *scene)
{ {
vector<Vertex> vertices; vector<Vertex> vertices;
vector<GLuint> indices; vector<unsigned int> indices;
vector<Texture> textures; vector<Texture> textures;
for(GLuint i = 0; i < mesh->mNumVertices; i++) for(unsigned int i = 0; i < mesh->mNumVertices; i++)
{ {
Vertex vertex; Vertex vertex;
// 处理顶点坐标、法线和纹理坐标 // 处理顶点位置、法线和纹理坐标
... ...
vertices.push_back(vertex); vertices.push_back(vertex);
} }
// 处理顶点索引 // 处理索引
... ...
// 处理材质 // 处理材质
if(mesh->mMaterialIndex >= 0) if(mesh->mMaterialIndex >= 0)
{ {
... ...
} }
return Mesh(vertices, indices, textures); return Mesh(vertices, indices, textures);
} }
``` ```
处理一个网格基本由三部分组成:获取所有顶点数据,获取网格索引,获取相关材质数据。处理的数据储存在3个向量其中之一里面一个Mesh被以这些数据创建返回到函数的调用者。 处理网格的过程主要有三部分:获取所有顶点数据,获取它们的网格索引,获取相关材质数据。处理的数据将会储存在三个vector当中我们会利用它们构建一个<fun>Mesh</fun>对象,并返回到函数的调用者那里
获取顶点数据很简单:我们定义一个`Vertex`结构体,在每次遍历后我们把这个结构体添加到`Vertices`数组。我们为存在于网格中的众多顶点循环(通过`mesh->mNumVertices`获取)。在遍历的过程中,我们希望用所有相关数据填充这个结构体。每个顶点位置会像这样被处理:
获取顶点数据非常简单,我们定义了一个<fun>Vertex</fun>结构体,我们将在每个迭代之后将它加到<var>vertices</var>数组中。我们会遍历网格中的所有顶点(使用`mesh->mNumVertices`来获取)。在每个迭代中,我们希望使用所有的相关数据填充这个结构体。顶点的位置是这样处理的:
```c++ ```c++
glm::vec3 vector; glm::vec3 vector;
@@ -177,14 +170,13 @@ vector.z = mesh->mVertices[i].z;
vertex.Position = vector; vertex.Position = vector;
``` ```
注意为了传输Assimp的数据我们定义一个`vec3`的宿主我们需要它是因为Assimp维持它自己的数据类型这些类型用于向量、材质、字符串等。这些数据类型转换到glm的数据类型时通常效果不佳 注意我们为了传输Assimp的数据我们定义一个`vec3`的临时变量。使用这样一个临时变量的原因是Assimp对向量、矩阵、字符串等都有自己的一套数据类型它们并不能完美地转换到GLM的数据类型中
!!! Important !!! Important
Assimp将它的顶点位置数组称为<var>mVertices</var>,这其实并不是非常直观。 Assimp将它的顶点位置数组叫做<var>mVertices</var>,这其实并不是那么直观。
对应法线的步骤毫无疑问是这样的:
处理法线的步骤也是差不多的:
```c++ ```c++
vector.x = mesh->mNormals[i].x; vector.x = mesh->mNormals[i].x;
@@ -193,11 +185,10 @@ vector.z = mesh->mNormals[i].z;
vertex.Normal = vector; vertex.Normal = vector;
``` ```
纹理坐标也基本一样,但Assimp允许一个模型的每个顶点8个不同的纹理坐标我们可能用不到,所以我们只关第一组纹理坐标。我们也希望检查网格是否真的包含纹理坐标(可能并不总是如此): 纹理坐标的处理也大体相似但Assimp允许一个模型在一个顶点上有最多8个不同的纹理坐标我们不会用到那么多,我们只关第一组纹理坐标。我们同样也想检查网格是否真的包含纹理坐标(可能并不会一直如此)
```c++ ```c++
if(mesh->mTextureCoords[0]) // Does the mesh contain texture coordinates? if(mesh->mTextureCoords[0]) // 网格是否有纹理坐标?
{ {
glm::vec2 vec; glm::vec2 vec;
vec.x = mesh->mTextureCoords[0][i].x; vec.x = mesh->mTextureCoords[0][i].x;
@@ -208,58 +199,54 @@ else
vertex.TexCoords = glm::vec2(0.0f, 0.0f); vertex.TexCoords = glm::vec2(0.0f, 0.0f);
``` ```
`Vertex`结构体现在完全被所需的顶点属性填充了,我们能把它添加到`vertices`向量的尾部。要对每个网格的顶点做相同的处理 <var>vertex</var>结构体现在已经填充好了需要的顶点属性,我们会在迭代的最后将它压入<var>vertices</var>这个vector的尾部。这个过程会对每个网格的顶点都重复一遍
### 顶点 ### 索引
Assimp的接口定义每个网格有一个以面faces为单位的数组每个面代表一个单独的图元在我们的例子中由于`aiProcess_Triangulate`选项)总是三角形,一个面包含索引,这些索引定义我们需要绘制的顶点以在那样的顺序提供给每个图元,所以如果我们遍历所有面,把所有面的索引储存到`indices`向量,我们需要这么做:
Assimp的接口定义了每个网格都有一个面(Face)数组,每个面代表了一个图元,在我们的例子中(由于使用了<var>aiProcess_Triangulate</var>选项)它总是三角形。一个面包含了多个索引,它们定义了在每个图元中,我们应该绘制哪个顶点,并以什么顺序绘制,所以如果我们遍历了所有的面,并储存了面的索引到<var>indices</var>这个vector中就可以了。
```c++ ```c++
for(GLuint i = 0; i < mesh->mNumFaces; i++) for(unsigned int i = 0; i < mesh->mNumFaces; i++)
{ {
aiFace face = mesh->mFaces[i]; aiFace face = mesh->mFaces[i];
for(GLuint j = 0; j < face.mNumIndices; j++) for(unsigned int j = 0; j < face.mNumIndices; j++)
indices.push_back(face.mIndices[j]); indices.push_back(face.mIndices[j]);
} }
``` ```
所有外部循环结束,我们现在有了一个完整点的顶点和索引数据来绘制网格,这要调用`glDrawElements`函数。可是,为了结束这个讨论,并向网格提供一些细节,我们同样希望处理网格的材质。 所有外部循环结束,我们现在有了一系列的顶点和索引数据,它们可以用来通过<fun>glDrawElements</fun>函数来绘制网格。然而,为了结束这个话题,并且对网格提供一些细节,我们还需要处理网格的材质。
### 材质 ### 材质
如同节点,一个网格只一个指向材质对象的索引获取网格实际的材质,我们需要索引场景的`mMaterials`数组。网格材质索引被设置在`mMaterialIndex`属性中,通过这个属性我们同样能够检验一个网格是否包含一个材质: 和节点一样,一个网格只包含了一个指向材质对象的索引。如果想要获取网格真正的材质,我们需要索引场景的<var>mMaterials</var>数组。网格材质索引位于它的<var>mMaterialIndex</var>属性中,我们同样可以用它来检测一个网格是否包含材质:
```c++ ```c++
if(mesh->mMaterialIndex >= 0) if(mesh->mMaterialIndex >= 0)
{ {
aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex]; aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
vector<Texture> diffuseMaps = this->loadMaterialTextures(material, vector<Texture> diffuseMaps = loadMaterialTextures(material,
aiTextureType_DIFFUSE, "texture_diffuse"); aiTextureType_DIFFUSE, "texture_diffuse");
textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end()); textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
vector<Texture> specularMaps = this->loadMaterialTextures(material, vector<Texture> specularMaps = loadMaterialTextures(material,
aiTextureType_SPECULAR, "texture_specular"); aiTextureType_SPECULAR, "texture_specular");
textures.insert(textures.end(), specularMaps.begin(), specularMaps.end()); textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
} }
``` ```
先从场景的`mMaterials`数组获取`aimaterial`对象,然后,我们希望加载网格的漫反射贴图和(或者)镜面贴图。一个材质储存了一个数组,这个数组为每个纹理类型提供纹理位置。不同的纹理类型都以`aiTextureType_`为前缀。我们使用一个帮助函数:`loadMaterialTextures`来从材质获取纹理。这个函数返回一个`Texture`结构体的向量,我们在之后储存在模型的`textures`坐标的后面 们首先从场景的<var>mMaterials</var>数组获取`aiMaterial`对象。接下来我们希望加载网格的漫反射和/或镜面贴图。一个材质对象的内部对每种纹理类型都存储了一个纹理位置数组。不同的纹理类型都以`aiTextureType_`为前缀。我们使用一个叫做<fun>loadMaterialTextures</fun>的工具函数来从材质获取纹理。这个函数将会返回一个<fun>Texture</fun>结构体的vector我们将在模型的<var>textures</var> vector的尾部之后存储它
`loadMaterialTextures`函数遍历所有给定纹理类型的纹理位置,获取纹理的文件位置,然后加载生成纹理,把信息储存到`Vertex`结构体。看起来像这样:
<fun>loadMaterialTextures</fun>函数遍历了给定纹理类型的所有纹理位置,获取了纹理的文件位置,并加载并和生成了纹理,将信息储存在了一个<fun>Vertex</fun>结构体中。它看起来会像这样:
```c++ ```c++
vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type, string typeName) vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{ {
vector<Texture> textures; vector<Texture> textures;
for(GLuint i = 0; i < mat->GetTextureCount(type); i++) for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
{ {
aiString str; aiString str;
mat->GetTexture(type, i, &str); mat->GetTexture(type, i, &str);
Texture texture; Texture texture;
texture.id = TextureFromFile(str.C_Str(), this->directory); texture.id = TextureFromFile(str.C_Str(), directory);
texture.type = typeName; texture.type = typeName;
texture.path = str; texture.path = str;
textures.push_back(texture); textures.push_back(texture);
@@ -268,53 +255,50 @@ vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type, string
} }
``` ```
我们先通过`GetTextureCount`函数检验材质中储存的纹理,以期得到我们希望得到的纹理类型。然后我们通过`GetTexture`函数获取每个纹理的文件位置,这个位置以`aiString`类型储存。然后我们使用另一个帮助函数,它被命名为:`TextureFromFile`加载一个纹理使用SOIL返回纹理的ID。可以查看列在最后的完整代码,如果你不知道这个函数应该怎样写出来的话 我们先通过<fun>GetTextureCount</fun>函数检查储存在材质中纹理的数量,这个函数需要一个纹理类型。我们会使用<fun>GetTexture</fun>获取每个纹理的文件位置,它会将结果储存在一个`aiString`中。我们接下来使用另一个叫做<fun>TextureFromFile</fun>的工具函数,它将会(用`stb_image.h`加载一个纹理返回纹理的ID。如果你不确定这样的代码是如何写出来的话,可以查看最后的完整代码。
!!! Important !!! Important
注意,我们假设纹理文件与模型是在相同的目录。我们可以简单的链接纹理位置字符串和之前获取的目录字符串(在`loadModel`函数中得到的)来获完整的纹理路径(这是为什么`GetTexture`函数同样需要目录字符串)。 注意,我们假设了模型文件中纹理文件的路径是相对于模型文件的本地(Local)路径,比如说与模型文件处于同一目录。我们可以纹理位置字符串拼接到之前(在<fun>loadModel</fun>中)获取的目录字符串上,来获完整的纹理路径(这是为什么<fun>GetTexture</fun>函数也需要一个目录字符串)。
有些在互联网上找到的模型使用绝对路径,它们的纹理位置就不会在每台机器上都有效了。例子里,你可能希望手工编辑这个文件来使用本地路径为纹理所使用(如果可能的话)。
这就是使用Assimp来导入一个模型的全部了。你可以在这里找到[Model类的代码](http://learnopengl.com/code_viewer.php?code=model_loading/model_unoptimized) 在网络上找到的某些模型会对纹理位置使用绝对(Absolute)路径,这就不能在每台机器上都工作了。在这种情况下,你可能会需要手动修改这个文件,来让它对纹理使用本地路径(如果可能的话)
这就是使用Assimp导入模型的全部了。
# 重大优化 # 重大优化
我们现在还没做完。因为我们还想做一个重大的优化(但不是必须的)。大多数场景重用若干纹理,把它们应用到网格;还是思考那个别墅,它有个花岗岩的纹理作为墙面。这个纹理也可应用到地板、天花板楼梯,或者一张桌子、一个附近的小物件。加载纹理需要不少操作,当前的实现中一个新的纹理被加载和生成,来为每个网格使用,即使同样的纹理之前已经被加载了好几次。这很快转变为你的模型加载实现的瓶颈。 这还没有完全结束,因为我们还想做一个重大的(但不是完全必须的)优化。大多数场景都会在多个网格中重用部分纹理。还是想想一个房子,它的墙壁有着花岗岩的纹理。这个纹理也可以被应用到地板、天花板楼梯、桌子,甚至是附近的一口井上。加载纹理并不是一个开销不大的操作,在我们当前的实现中,即便同样的纹理已经被加载过很多遍了,对每个网格仍会加载并生成一个新的纹理。这很快就会变成模型加载实现的性能瓶颈。
所以我们打算添加一个小小的微调,把模型的代码改成,储存所有的已加载纹理到全局。无论在哪儿我们都要先检查这个纹理是否已经被加载过了。如果加载过了,我们就直接使用这个纹理并跳过整个加载流程来节省处理能力。为了对比纹理我们同样需要储存它们的路径:
所以我们会对模型的代码进行调整,将所有加载过的纹理全局储存,每当我们想加载一个纹理的时候,首先去检查它有没有被加载过。如果有的话,我们会直接使用那个纹理,并跳过整个加载流程,来为我们省下很多处理能力。为了能够比较纹理,我们还需要储存它们的路径:
```c++ ```c++
struct Texture { struct Texture {
GLuint id; unsigned int id;
string type; string type;
aiString path; // We store the path of the texture to compare with other textures aiString path; // 我们储存纹理的路径用于与其它纹理进行比较
}; };
``` ```
然后我们所有加载过的纹理储存另一个向量中,它是作为一个私有变量声明在模型类的顶部 接下来我们所有加载过的纹理储存另一个vector中在模型类的顶部声明为一个私有变量
```c++ ```c++
vector<Texture> textures_loaded; vector<Texture> textures_loaded;
``` ```
后,在`loadMaterialTextures`函数中,我们希望纹理路径和所有`texture_loaded`向量对比,看看是否当前纹理路径和其中任何一个是否相同如果是,我们跳过纹理加载/生成部分,简单的使用已加载纹理结构体为网格纹理。这个函数如下所示 后,在<fun>loadMaterialTextures</fun>函数中,我们希望纹理路径与储存在<var>textures_loaded</var>这个vector中的所有纹理进行比较,看看当前纹理路径是否与其中的一个相同如果是的话,则跳过纹理加载/生成部分,直接使用定位到的纹理结构体为网格纹理。更新后的函数如下:
```c++ ```c++
vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type, string typeName) vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{ {
vector<Texture> textures; vector<Texture> textures;
for(GLuint i = 0; i < mat->GetTextureCount(type); i++) for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
{ {
aiString str; aiString str;
mat->GetTexture(type, i, &str); mat->GetTexture(type, i, &str);
GLboolean skip = false; bool skip = false;
for(GLuint j = 0; j < textures_loaded.size(); j++) for(unsigned int j = 0; j < textures_loaded.size(); j++)
{ {
if(textures_loaded[j].path == str) if(std::strcmp(textures_loaded[j].path.C_Str(), str.C_Str()) == 0)
{ {
textures.push_back(textures_loaded[j]); textures.push_back(textures_loaded[j]);
skip = true; skip = true;
@@ -322,47 +306,43 @@ vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type, string
} }
} }
if(!skip) if(!skip)
{ // 如果纹理没有被加载,加载 { // 如果纹理没有被加载,加载
Texture texture; Texture texture;
texture.id = TextureFromFile(str.C_Str(), this->directory); texture.id = TextureFromFile(str.C_Str(), directory);
texture.type = typeName; texture.type = typeName;
texture.path = str; texture.path = str;
textures.push_back(texture); textures.push_back(texture);
this->textures_loaded.push_back(texture); // 添加到纹理列表 textures textures_loaded.push_back(texture); // 添加到已加载的纹理中
} }
} }
return textures; return textures;
} }
``` ```
所以现在我们不仅有了一个通用模型加载系统,同时我们也得了一个能使加载对象快的优化版本。 所以现在我们不仅有了个灵活的模型加载系统,我们也得了一个加载对象快的优化版本。
!!! Attention !!! Attention
有些版本的Assimp使用调试版或/和使用你的IDE的调试模式时,模型加载模型实在慢,所以确保在当你加载得很慢的时候用发布版再测试 有些版本的Assimp使用调试版本或者使用IDE的调试模式下加载模型会非常缓慢,所以在你遇到缓慢的加载速度时,可以试试使用发布版
你可以从这里获得优化的[Model类的完整源代码](http://learnopengl.com/code_viewer.php?code=model&type=header) 你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=includes/learnopengl/model.h)找到优化后<fun>Model</fun>类的完整源代码
# 和箱子模型告别 # 和箱子模型告别
现在给我们导入一个天才艺术家创建的模型看看效果,不是我这个天才做的(你不得不承认,这箱子也许是你过的最漂亮的立方体)。因为我不想过于自夸,所以我会时不时的给其他艺术家进入这个行列的机会这次我们会加载Crytek的原版孤岛危机游戏中的纳米铠甲。这个模型被输出为obj和mtl文件mtl包含模型的漫反射贴图,镜面贴图以及法线贴图(后面会讲)。你可以[下载这个模型](http://learnopengl.com/data/models/nanosuit.rar),注意所有的纹理和模型文件应该放在同一个目录,以便载入纹理。 所以,让我们导入一个由真正的艺术家所创造的模型,替代我这个天才的作品(你要承认,这箱子可能是你过的最漂亮的立方体了),测试一下我们的实现吧。由于我不想让我占太多的功劳,我会偶尔让别的艺术家也加入我们,这次我们会加载Crytek的游戏孤岛危机(Crysis)中的原版[纳米装](http://tf3dm.com/3d-model/crysis-2-nanosuit-2-97837.html)(Nanosuit)。这个模型被输出为一个`.obj`文件以及一个`.mtl`文件,`.mtl`文件包含模型的漫反射、镜面光和法线贴图(这个会在后面学习到),你可以[这里](../data/nanosuit.rar)下载到(稍微修改之后的)模型,注意所有的纹理和模型文件应该位于同一个目录,以供加载纹理。
!!! Important !!! Important
你从这个站点下载的版本是修改过的版本,每个纹理文件路径已经修改改为本地相对目录,原来的资源是绝对目录。
现在在代码中声明一个Model对象把它模型的文件位置传递给它。模型应该自动加载如果没有错误的话在游戏循环中使用它的Draw函数绘制这个对象。没有更多的缓冲配置属性指针和渲染命令仅仅简单的一行。如果你创建几个简单的着色器像素着色器只输出对象的漫反射贴图颜色结果看上去会有点像这样 你从本网站中下载到的版本是修改过的版本,每个纹理的路径都被修改为了一个本地的相对路径,而不是原资源的绝对路径。
现在在代码中,声明一个<fun>Model</fun>对象,将模型的文件位置传入。接下来模型应该会自动加载并(如果没有错误的话)在渲染循环中使用它的<fun>Draw</fun>函数来绘制物体,这样就可以了。不再需要缓冲分配、属性指针和渲染指令,只需要一行代码就可以了。接下来如果你创建一系列着色器,其中片段着色器仅仅输出物体的漫反射纹理颜色,最终的结果看上去会是这样的:
![](../img/03/03/model_diffuse.png) ![](../img/03/03/model_diffuse.png)
你可以从这里找到带有[顶点](http://learnopengl.com/code_viewer.php?code=model_loading/model&type=vertex)和[片段](http://learnopengl.com/code_viewer.php?code=model_loading/model&type=fragment)着色器的[完整的源码](http://learnopengl.com/code_viewer.php?code=model_loading/model_diffuse) 你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/3.model_loading/1.model_loading/model_loading.cpp)找到完整的源码
因为我们之前学习过光照教程,可以更加富有创造性的引入两个点光源渲染方程,结合镜面贴图获得惊艳效果 我们可以变得更有创造力一点,根据我们之前在[光照](../02 Lighting/05 Light casters.md)教程中学过的知识,引入两个点光源渲染方程,结合镜面贴图,我们能得到很惊人的效果
![](../img/03/03/model_lighting.png) ![](../img/03/03/model_lighting.png)
甚至我不得不承认这个相比之前用过的容器酷炫多了。使用Assimp可以载入无数在互联网上找到的模型。有相当多可以以多种文件格式下载免费3D模型的资源网站。一定注意有些模型仍然不能很好的载入,纹理路径无效或者这种格式Assimp不能读取 甚至我都必须要承认这个可能是比一直使用的箱子要好看多了。使用Assimp能够加载互联网上的无数模型。有很多资源网站都提供了多种格式的免费3D模型供你下载。但还是要注意有些模型会不能正常地载入,纹理路径会出现问题或者Assimp并不支持它的格式
## 练习
你可以使用两个点光源重建上个场景吗?[方案](http://learnopengl.com/code_viewer.php?code=model_loading/model-exercise1)[着色器](http://learnopengl.com/code_viewer.php?code=model_loading/model-exercise1-shaders)。

BIN
docs/data/nanosuit.rar Normal file

Binary file not shown.

View File

@@ -253,6 +253,21 @@
- Sequential连续 - Sequential连续
- Byte Offset字节偏移量 - Byte Offset字节偏移量
- Translate转化
- Concatenate拼接
- CrytekCrytek
- Crysis孤岛危机
- Nanosuit纳米装
## 03-03
- vector模板类vector
- Post-processing后期处理
- Recursive Fuction递归函数
- Exit Condition退出条件
- Local Path本地路径
- Relative Path相对路径
- Absolute Path绝对路径
## 06-01 ## 06-01

View File

@@ -157,6 +157,7 @@ $$
- 定义:`<def>Definition</def>` - 定义:`<def>Definition</def>`
- 函数及类名:`<fun>Program logic</fun>` - 函数及类名:`<fun>Program logic</fun>`
- 变量:`<var>Variables</var>` - 变量:`<var>Variables</var>`
- 下划线:`<u>Underline</u>`
- 标记的时候记得不要把中文翻译标记进去了 - 标记的时候记得不要把中文翻译标记进去了
- 原文中的斜体如果译成中文一律用加粗表示(中文并不存在斜体),如果仍留为英文,请在单词两侧都添加空格与中文分开再改为斜体,粗体也用粗体表示,注意不要自己违反原文添加粗体 - 原文中的斜体如果译成中文一律用加粗表示(中文并不存在斜体),如果仍留为英文,请在单词两侧都添加空格与中文分开再改为斜体,粗体也用粗体表示,注意不要自己违反原文添加粗体