diff --git a/03 Model Loading/01 Assimp.md b/03 Model Loading/01 Assimp.md index 662cc93..cba5dd9 100644 --- a/03 Model Loading/01 Assimp.md +++ b/03 Model Loading/01 Assimp.md @@ -1,12 +1,13 @@ - +# Assimp (Assimp开源模型导入库) -本文作者JoeyDeVries,由Cocoonshu翻译自[http://learnopengl.com](http://learnopengl.com/#!Model-Loading/Assimp) +原文 | [Assimp](http://learnopengl.com/#!Model-Loading/Assimp) + ---|--- +作者 | JoeyDeVries +翻译 | Cocoonshu +校对 | [Geequlim](http://geequlim.com) -## Assimp (Assimp开源模型导入库) -到目前为止,我们已经在所有的场景中大面积滥用了我们的容器盒小盆友,但就是容器盒是我们的好朋友,时间久了我们也会喜新厌旧。一些图形应用里经常会使用很多复杂且好玩儿的模型,它们看起来比静态的容器盒可爱多了。但是,我们无法像定义容器盒一样手动地去指定房子、货车或人形角色这些复杂模型的顶点、法线和纹理坐标。我们需要做的也是应该要做的,是把这些模型导入到应用程序中,而设计制作这些3D模型的工作应该交给像Blender、3DS Max或者Maya这样的工具软件。 +到目前为止,我们已经在所有的场景中大面积滥用了我们的容器盒小盆友,但就是容器盒是我们的好朋友,时间久了我们也会喜新厌旧。一些图形应用里经常会使用很多复杂且好玩儿的模型,它们看起来比静态的容器盒可爱多了。但是,我们无法像定义容器盒一样手动地去指定房子、货车或人形角色这些复杂模型的顶点、法线和纹理坐标。我们需要做的也是应该要做的,是把这些模型导入到应用程序中,而设计制作这些3D模型的工作应该交给像[Blender](http://www.blender.org/)、[3DS Max](http://www.autodesk.nl/products/3ds-max/overview)或者[Maya](http://www.autodesk.com/products/autodesk-maya/overview)这样的工具软件。 那些3D建模工具,可以让美工们构建一些复杂的形状,并将贴图应用到形状上去,即纹理映射。然后,在导出模型文件时,建模工具会自己生成所有的顶点坐标、顶点法线和纹理坐标。这样,美工们可以不用了解大量的图像技术细节,就能有大量的工具集去随心地构建高品质的模型。所有的技术细节内容都隐藏在里导出的模型文件里。而我们,这些图形开发者,就必须得去关注这些技术细节了。 @@ -22,16 +23,19 @@ - - 所有的模型、场景数据都包含在scene对象中,如所有的材质和模型点云。同样,场景的根节点引用也包含在这个scene对象中 + - 所有的模型、场景数据都包含在scene对象中,如所有的材质和Mesh。同样,场景的根节点引用也包含在这个scene对象中 - 场景的根节点可能也会包含很多子节点和一个指向保存模型点云数据mMeshes[]的索引集合。根节点上的mMeshes[]里保存了实际了Mesh对象,而每个子节点上的mMesshes[]都只是指向根节点中的mMeshes[]的一个引用(译者注:C/C++称为指针,Java/C#称为引用) - - 一个Mesh(模型点云)对象本身包含渲染所需的所有相关数据,比如顶点位置、法线向量、纹理坐标、面片及物体的材质 - - 一个Mesh(模型点云)会包含多个面片。一个Face(面片)表示渲染中的一个最基本的形状单位,即图元(基本图元有点、线、三角面片、矩形面片)。一个面片记录了一个图元的顶点索引,通过这个索引,可以在mMeshes[]中寻找到对应的顶点位置数据。顶点数据和索引分开存放,可以便于我们使用缓存(VBO、NBO、TBO、IBO)来高速渲染物体。(详见[Hello Triangle](http://www.learnopengl.com/#!Getting-started/Hello-Triangle)) - - 一个Mesh(模型点云)还会包含一个Material(材质)对象用于指定物体的一些材质属性。如颜色、纹理贴图(漫反射贴图、高光贴图等) + - 一个Mesh对象本身包含渲染所需的所有相关数据,比如顶点位置、法线向量、纹理坐标、面片及物体的材质 + - 一个Mesh会包含多个面片。一个Face(面片)表示渲染中的一个最基本的形状单位,即图元(基本图元有点、线、三角面片、矩形面片)。一个面片记录了一个图元的顶点索引,通过这个索引,可以在mMeshes[]中寻找到对应的顶点位置数据。顶点数据和索引分开存放,可以便于我们使用缓存(VBO、NBO、TBO、IBO)来高速渲染物体。(详见[Hello Triangle](http://www.learnopengl.com/#!Getting-started/Hello-Triangle)) + - 一个Mesh还会包含一个Material(材质)对象用于指定物体的一些材质属性。如颜色、纹理贴图(漫反射贴图、高光贴图等) 所以我们要做的第一件事,就是加载一个模型文件为scene对象,然后获取每个节点对应的Mesh对象(我们需要递归搜索每个节点的子节点来获取所有的节点),并处理每个Mesh对象对应的顶点数据、索引以及它的材质属性。最终我们得到一个只包含我们需要的数据的Mesh集合。 -> ##Mesh(模型点云) -> 用建模工具构建物体时,美工通常不会直接使用单个形状来构建一个完整的模型。一般来说,一个模型会由几个子模型/形状组合拼接而成。而模型中的那些子模型/形状就是我们所说的一个Mesh。例如一个人形模型,美工通常会把头、四肢、衣服、武器这些组件都分别构建出来,然后在把所有的组件拼合在一起,形成最终的完整模型。一个Mesh(包含顶点、索引和材质属性)是我们在OpenGL中绘制物体的最小单位。一个模型通常有多个Mesh组成。 +!!! Important + + **Mesh(网格,或被译为“模型点云”)** + + 用建模工具构建物体时,美工通常不会直接使用单个形状来构建一个完整的模型。一般来说,一个模型会由几个子模型/形状组合拼接而成。而模型中的那些子模型/形状就是我们所说的一个Mesh。例如一个人形模型,美工通常会把头、四肢、衣服、武器这些组件都分别构建出来,然后在把所有的组件拼合在一起,形成最终的完整模型。一个Mesh(包含顶点、索引和材质属性)是我们在OpenGL中绘制物体的最小单位。一个模型通常有多个Mesh组成。 下一节教程中,我们将用上述描述的数据结构来创建我们自己的Model类和Mesh类,用于加载和保存那些导入的模型。如果我们想要绘制一个模型,我们不会去渲染整个模型,而是去渲染这个模型所包含的所有独立的Mesh。不管怎样,我们开始导入模型之前,我们需要先把Assimp导入到我们的工程中。 @@ -42,10 +46,14 @@ 这里我们列出一些编译Assimp时可能遇到的问题,以便大家参考和排除: - CMake在读取配置列表时,报出与DirectX库丢失相关的一些错误。报错如下: -
 Could not locate DirecX
-	 CMake Error at cmake-modules/FindPkgMacros.cmake:110 (message):
-	 Required library DirectX not found! Install the library (including dev packages) and try again. If the library is already installed, set the missing variables manually in cmake. 
- 这个问题的解决方案:如果你之前没有安装过DirectX SDK,那么请安装。下载地址:[DirectX SDK](http://www.microsoft.com/en-us/download/details.aspx?id=6812) + +``` +Could not locate DirecX +CMake Error at cmake-modules/FindPkgMacros.cmake:110 (message): +Required library DirectX not found! Install the library (including dev packages) and try again. If the library is already installed, set the missing variables manually in cmake. +``` + +这个问题的解决方案:如果你之前没有安装过DirectX SDK,那么请安装。下载地址:[DirectX SDK](http://www.microsoft.com/en-us/download/details.aspx?id=6812) - 安装DirectX SDK时,可以遇到一个错误码为S1023的错误。遇到这个问题,请在安装DirectX SDK前,先安装C++ Redistributable package(s)。 问题解释:[已知问题:DirectX SDK (June 2010) 安装及S1023错误](Known Issue: DirectX SDK (June 2010) Setup and the S1023 error) - 一旦配置完成,你就可以生成解决方案文件了,打开解决方案文件并编译Assimp库(编译为Debug版本还是Release版本,根据你的需要和心情来定吧) @@ -54,6 +62,9 @@ - 把编译好的LIB文件和DLL文件拷贝到工程的相应目录下,并链接到你的解决方案中。同时还好记得把Assimp的头文件也拷贝到工程里去(Assimp的头文件可以在include目录里找到) 如果你还遇到了其他问题,可以在下面给出的链接里获取帮助。 - >如果你想要让Assimp使用多线程支持来提高性能,你可以使用Boost库来编译 Assimp。在[Boost安装页面](http://assimp.sourceforge.net/lib_html/install.html),你能找到关于Boost的完整安装介绍。 -现在,你应该已经能够编译Assimp库,并链接Assimp到你的工程里去了。下一节内容:[导入完美的3D物件!](http://www.learnopengl.com/#!Model-Loading/Mesh) +!!! Important + + 如果你想要让Assimp使用多线程支持来提高性能,你可以使用Boost库来编译 Assimp。在[Boost安装页面](http://assimp.sourceforge.net/lib_html/install.html),你能找到关于Boost的完整安装介绍。 + +现在,你应该已经能够编译Assimp库,并链接Assimp到你的工程里去了。下一节内容:[导入完美的3D物件!](http://learnopengl-cn.readthedocs.org/zh/latest/03%20Model%20Loading/02%20Mesh/) diff --git a/03 Model Loading/02 Mesh.md b/03 Model Loading/02 Mesh.md index 35b4d8c..0e4fccc 100644 --- a/03 Model Loading/02 Mesh.md +++ b/03 Model Loading/02 Mesh.md @@ -1,8 +1,12 @@ -本文作者JoeyDeVries,由[Django](http://bullteacher.com/20-mesh.html)翻译自[http://learnopengl.com](http://learnopengl.com/) - # Mesh -使用Assimp可以把多种不同格式的模型加载到程序里,但是一旦载入,它们就都被储存为Assimp自己的数据结构。我们最终的想法是把这些数据转变为OpenGL可读的数据,用来渲染物体。我们从前面的教程了解到,一个网格代表一个可绘制实体,现在我们就定义一个自己的网格类。 +原文 | [Mesh](http://learnopengl.com/#!Model-Loading/Mesh) + ---|--- +作者 | JoeyDeVries +翻译 | [Django](http://bullteacher.com/) +校对 | [Geequlim](http://geequlim.com) + +使用Assimp可以把多种不同格式的模型加载到程序中,但是一旦载入,它们就都被储存为Assimp自己的数据结构。我们最终的目的是把这些数据转变为OpenGL可读的数据,才能用OpenGL来渲染物体。我们从前面的教程了解到,一个网格(Mesh)代表一个可绘制实体,现在我们就定义一个自己的网格类。 先来复习一点目前学到知识,考虑一个网格最少需要哪些数据。一个网格应该至少需要一组顶点,每个顶点包含一个位置向量,一个法线向量,一个纹理坐标向量。一个网格也应该包含一个索引绘制用的索引,以纹理(diffuse/specular map)形式表现的材质数据。 @@ -17,7 +21,8 @@ struct Vertex glm::vec2 TexCoords; }; ``` -我们把每个需要的向量储存到一个叫做Vertex的结构体中,它被用来索引每个顶点属性。另外除了Vertex结构体外,我们也希望组织纹理数据,所以我们定义一个Texture结构体: + +我们把每个需要的向量储存到一个叫做`Vertex`的结构体中,它被用来索引每个顶点属性。另外除了`Vertex`结构体外,我们也希望组织纹理数据,所以我们定义一个`Texture`结构体: ```c++ @@ -27,7 +32,8 @@ struct Texture String type; }; ``` -我们储存纹理的id和它的类型,比如diffuse纹理或者specular纹理。 + +我们储存纹理的id和它的类型,比如`diffuse`纹理或者`specular`纹理。 知道了顶点和纹理的实际表达,我们可以开始定义网格类的结构: @@ -47,9 +53,10 @@ private: void setupMesh(); } ``` -如你所见这个类一点都不复杂,构造方法里我们初始化网格所有必须数据。在setupMesh函数里初始化缓冲。最后通过Draw函数绘制网格。注意,我们把shader传递给Draw函数。通过把shader传递给mesh,在绘制之前我们设置几个uniform(就像链接采样器到纹理单元)。 -构造函数的内容非常直接。我们简单设置类的公有变量,使用的是构造函数相应的参数。我们在构造函数中也调用setupMesh函数: +如你所见这个类一点都不复杂,构造方法里我们初始化网格所有必须数据。在`setupMesh`函数里初始化缓冲。最后通过`Draw`函数绘制网格。注意,我们把`shader`传递给`Draw`函数。通过把`shader`传递给Mesh,在绘制之前我们设置几个uniform(就像链接采样器到纹理单元)。 + +构造函数的内容非常直接。我们简单设置类的公有变量,使用的是构造函数相应的参数。我们在构造函数中也调用`setupMesh`函数: ```c++ @@ -62,11 +69,11 @@ Mesh(vector vertices, vector indices, vector textures) this->setupMesh(); } ``` -这里没什么特别的,现在让我们研究一下setupMesh函数。 + +这里没什么特别的,现在让我们研究一下`setupMesh`函数。 - -##初始化 +## 初始化 现在我们有一大列的网格数据可用于渲染,这要感谢构造函数。我们确实需要设置合适的缓冲,通过顶点属性指针(vertex attribute pointers)定义顶点着色器layout。现在你应该对这些概念很熟悉,但是我们我们通过介绍了结构体中使用顶点数据,所以稍微有点不一样: @@ -88,15 +95,15 @@ void setupMesh() glBufferData(GL_ELEMENT_ARRAY_BUFFER, this->indices.size() * sizeof(GLuint), &this->indices[0], GL_STATIC_DRAW); - // Vertex Positions + // 设置顶点坐标指针 glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (GLvoid*)0); - // Vertex Normals + // 设置法线指针 glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (GLvoid*)offsetof(Vertex, Normal)); - // Vertex Texture Coords + // 设置顶点的纹理坐标 glEnableVertexAttribArray(2); glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (GLvoid*)offsetof(Vertex, TexCoords)); @@ -104,9 +111,10 @@ void setupMesh() glBindVertexArray(0); } ``` -如你所想代码没什么特别不同的地方,在Vertex结构体的帮助下有了一些小把戏。 -C++的结构体有一个重要的属性,那就是在内存中它们是连续的。如果我们用结构体表示一列数据,这个结构体只包含结构体的连续的变量,它就会直接转变为一个float(实际上是byte)数组,我们就能用于一个数组缓冲(array buffer)中了。比如,如果我们填充一个Vertex结构体,它在内存中的排布等于: +如你所想代码没什么特别不同的地方,在`Vertex`结构体的帮助下有了一些小把戏。 + +C++的结构体有一个重要的属性,那就是在内存中它们是连续的。如果我们用结构体表示一列数据,这个结构体只包含结构体的连续的变量,它就会直接转变为一个`float`(实际上是byte)数组,我们就能用于一个数组缓冲(array buffer)中了。比如,如果我们填充一个`Vertex`结构体,它在内存中的排布等于: ```c++ @@ -116,33 +124,33 @@ 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]; ``` -感谢这个有用的特性,我们能直接把一个作为缓冲数据的一大列Vertex结构体的指针传递过去,它们会翻译成glBufferData能用的参数: + +感谢这个有用的特性,我们能直接把一个作为缓冲数据的一大列`Vertex`结构体的指针传递过去,它们会翻译成`glBufferData`能用的参数: ```c++ glBufferData(GL_ARRAY_BUFFER, this->vertices.size() * sizeof(Vertex), &this->vertices[0], GL_STATIC_DRAW); ``` -自然地,sizeof函数也可以使用于结构体来计算字节类型的大小。它应该是32字节(8float * 4)。 -一个预处理指令叫做offsetof(s, m)把结构体作为它的第一个参数,第二个参数是这个结构体名字的变量。这是结构体另外的一个重要用途。函数返回这个变量从结构体开始的字节偏移量(offset)。这对于定义glVertexAttribPointer函数偏移量参数效果很好: +自然地,`sizeof`函数也可以使用于结构体来计算字节类型的大小。它应该是32字节(8float * 4)。 + +一个预处理指令叫做`offsetof(s,m)`把结构体作为它的第一个参数,第二个参数是这个结构体名字的变量。这是结构体另外的一个重要用途。函数返回这个变量从结构体开始的字节偏移量(offset)。这对于定义`glVertexAttribPointer`函数偏移量参数效果很好: ```c++ glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (GLvoid*)offsetof(Vertex, Normal)); ``` -偏移量现在使用offsetof函数定义了,在这个例子里,设置法线向量的字节偏移量等于法线向量在结构体的字节偏移量,它是3float,也就是12字节(一个float占4字节)。注意,我们同样设置步长参数等于Vertex结构体的大小。 +偏移量现在使用`offsetof`函数定义了,在这个例子里,设置法线向量的字节偏移量等于法线向量在结构体的字节偏移量,它是`3float`,也就是12字节(一个float占4字节)。注意,我们同样设置步长参数等于`Vertex`结构体的大小。 使用一个像这样的结构体,不仅能提供可读性更高的代码同时也是我们可以轻松的扩展结构体。如果我们想要增加另一个顶点属性,我们把它可以简单的添加到结构体中,由于它的可扩展性,渲染代码不会被破坏。 - +## 渲染 -##渲染 +我们需要为`Mesh`类定义的最后一个函数,是它的Draw函数。在真正渲染前我们希望绑定合适的纹理,然后调用`glDrawElements`。可因为我们从一开始不知道这个网格有多少纹理以及它们应该是什么类型的,所以这件事变得很困难。所以我们该怎样在着色器中设置纹理单元和采样器呢? -我们需要为Mesh类定义的最后一个函数,是它的Draw函数。在真正渲染前我们希望绑定合适的纹理,然后调用glDrawElements。可因为我们从一开始不知道这个网格有多少纹理以及它们应该是什么类型的,所以这件事变得很困难。所以我们该怎样在着色器中设置纹理单元和采样器呢? - -解决这个问题,我们需要假设一个特定的名称惯例:每个diffuse纹理被命名为texture_diffuseN,每个specular纹理应该被命名为texture_specularN.N是一个从1到纹理才抢其允许使用的最大值之间的数。可以说,在一个网格中我们有3个diffuse纹理和2个specular纹理,它们的纹理采样器应该这样被调用: +解决这个问题,我们需要假设一个特定的名称惯例:每个`diffuse`纹理被命名为`texture_diffuseN`,每个`specular`纹理应该被命名为`texture_specularN`。N是一个从1到纹理才抢其允许使用的最大值之间的数。可以说,在一个网格中我们有3个`diffuse`纹理和2个`specular`纹理,它们的纹理采样器应该这样被调用: ```c++ @@ -152,6 +160,7 @@ uniform sampler2D texture_diffuse3; uniform sampler2D texture_specular1; uniform sampler2D texture_specular2; ``` + 使用这样的惯例,我们能定义我们在着色器中需要的纹理采样器的数量。如果一个网格真的有(这么多)纹理,我们就知道它们的名字应该是什么。这个惯例也使我们能够处理一个网格上的任何数量的纹理,通过定义合适的采样器开发者可以自由使用希望使用的数量(虽然定义少的话就会有点浪费绑定和uniform调用了)。 像这样的问题有很多不同的解决方案,如果你不喜欢这个方案,你可以自己创造一个你自己的方案。 @@ -165,15 +174,15 @@ void Draw(Shader shader) GLuint specularNr = 1; for(GLuint i = 0; i < this->textures.size(); i++) { - glActiveTexture(GL_TEXTURE0 + i); // Activate proper texture unit before binding - // Retrieve texture number (the N in diffuse_textureN) + glActiveTexture(GL_TEXTURE0 + i); // 在绑定纹理前需要激活适当的纹理单元 + // 检索纹理序列号 (N in diffuse_textureN) stringstream ss; string number; string name = this->textures[i].type; if(name == "texture_diffuse") - ss << diffuseNr++; // Transfer GLuint to stream + ss << diffuseNr++; // 将GLuin输入到string stream else if(name == "texture_specular") - ss << specularNr++; // Transfer GLuint to stream + ss << specularNr++; // 将GLuin输入到string stream number = ss.str(); glUniform1f(glGetUniformLocation(shader.Program, ("material." + name + number).c_str()), i); @@ -181,15 +190,19 @@ void Draw(Shader shader) } glActiveTexture(GL_TEXTURE0); - // Draw mesh + // 绘制Mesh glBindVertexArray(this->VAO); glDrawElements(GL_TRIANGLES, this->indices.size(), GL_UNSIGNED_INT, 0); glBindVertexArray(0); } ``` -这不是最漂亮的代码,但是这主要归咎于C++转换类型时的丑陋,比如int转string时。我们首先计算N-元素每个纹理类型,把它链接到纹理类型字符串来获取合适的uniform名。然后查找合适的采样器位置,给它位置值对应当前激活纹理单元,绑定纹理。这也是我们需要在Draw方法是用shader的原因。我们添加”material.”到作为结果的uniform名,因为我们通常把纹理储存进材质结构体(对于每个实现也许会有不同)。 -注意,当我们把diffuse和specular传递到字符串流(stringstream)的时候,计数器会增加,在C++自增叫做:变量++,它会先返回自身然后加1,而++变量,先加1再返回自身,我们的例子里,我们先传递原来的计数器值到字符串流,然后再加1,下一轮生效。 -你可以从这里得到Mesh类的源码。 +这不是最漂亮的代码,但是这主要归咎于C++转换类型时的丑陋,比如`int`转`string`时。我们首先计算N-元素每个纹理类型,把它链接到纹理类型字符串来获取合适的uniform名。然后查找合适的采样器位置,给它位置值对应当前激活纹理单元,绑定纹理。这也是我们需要在`Draw`方法是用`shader`的原因。我们添加`material.`到作为结果的uniform名,因为我们通常把纹理储存进材质结构体(对于每个实现也许会有不同)。 + +!!! Important + + 注意,当我们把`diffuse`和`specular`传递到字符串流(`stringstream`)的时候,计数器会增加,在C++自增叫做:变量++,它会先返回自身然后加1,而++变量,先加1再返回自身,我们的例子里,我们先传递原来的计数器值到字符串流,然后再加1,下一轮生效。 + +你可以从这里得到[Mesh类的源码](http://learnopengl.com/code_viewer.php?code=mesh&type=header)。 Mesh类是对我们前面的教程里讨论的很多话题的的简洁的抽象在下面的教程里,我们会创建一个模型,它用作乘放多个网格物体的容器,真正的实现Assimp的加载接口。 \ No newline at end of file diff --git a/03 Model Loading/03 Model.md b/03 Model Loading/03 Model.md index 97e31d9..c2ac74a 100644 --- a/03 Model Loading/03 Model.md +++ b/03 Model Loading/03 Model.md @@ -1,36 +1,41 @@ -本文作者JoeyDeVries,由[Django](http://bullteacher.com/)翻译自[http://learnopengl.com](http://learnopengl.com/) - # 模型(Model) -现在是时候着手启用Assimp,并开始创建实际的加载和转换代码了。本教程的目标是创建另一个类,这个类可以表达模型的全部。更确切的说,一个模型包含多个网格,一个网格可能带有多个对象。一个别墅,包含一个木制阳台,一个尖顶或许也有一个游泳池,它仍然被加载为一个单一模型。我们通过Assimp加载模型,把它们转变为多个网格(Mesh)对象,这些对象是是先前教程里创建的。 +原文 | [Model](http://learnopengl.com/#!Model-Loading/Model) + ---|--- +作者 | JoeyDeVries +翻译 | [Django](http://bullteacher.com/) +校对 | [Geequlim](http://geequlim.com) + +现在是时候着手启用Assimp,并开始创建实际的加载和转换代码了。本教程的目标是创建另一个类,这个类可以表达模型的全部。更确切的说,一个模型包含多个网格(Mesh),一个网格可能带有多个对象。一个别墅,包含一个木制阳台,一个尖顶或许也有一个游泳池,它仍然被加载为一个单一模型。我们通过Assimp加载模型,把它们转变为多个网格(Mesh)对象,这些对象是是先前教程里创建的。 闲话少说,我把Model类的结构呈现给你: - ```c++ class Model { public: - /* Functions */ + /* 成员函数 */ Model(GLchar* path) { this->loadModel(path); } void Draw(Shader shader); private: - /* Model Data */ + /* 模型数据 */ vector meshes; string directory; - /* Functions */ + + /* 私有成员函数 */ 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); }; ``` -Model类包含一个Mesh对象的向量,我们需要在构造函数中给出文件的位置。之后,在构造其中,它通过loadModel函数加载文件。私有方法都被设计为处理一部分的Assimp导入的常规动作,我们会简单讲讲它们。同样,我们储存文件路径的目录,这样稍后加载纹理的时候会用到。 -函数Draw没有什么特别之处,基本上是循环每个网格,调用各自的Draw函数。 +`Model`类包含一个`Mesh`对象的向量,我们需要在构造函数中给出文件的位置。之后,在构造其中,它通过`loadModel`函数加载文件。私有方法都被设计为处理一部分的Assimp导入的常规动作,我们会简单讲讲它们。同样,我们储存文件路径的目录,这样稍后加载纹理的时候会用到。 + +函数`Draw`没有什么特别之处,基本上是循环每个网格,调用各自的Draw函数。 ```c++ @@ -41,7 +46,7 @@ void Draw(Shader shader) } ``` -##把一个3D模型导入到OpenGL +## 把一个3D模型导入到OpenGL 为了导入一个模型,并把它转换为我们自己的数据结构,第一件需要做的事是包含合适的Assimp头文件,这样编译器就不会对我们抱怨了。 @@ -51,7 +56,8 @@ void Draw(Shader shader) #include #include ``` -我们将要调用的第一个函数是loadModel,它被构造函数直接调用。在loadModel函数里面,我们使用Assimp加载模型到Assimp中被称为scene对象的数据结构。你可能还记得模型加载系列的第一个教程中,这是Assimp的数据结构的根对象。一旦我们有了场景对象,我们就能从已加载模型中获取所有所需数据了。 + +我们将要调用的第一个函数是`loadModel`,它被构造函数直接调用。在`loadModel`函数里面,我们使用Assimp加载模型到Assimp中被称为scene对象的数据结构。你可能还记得模型加载系列的第一个教程中,这是Assimp的数据结构的根对象。一旦我们有了场景对象,我们就能从已加载模型中获取所有所需数据了。 Assimp最大优点是,它简约的抽象了所加载所有不同格式文件的技术细节,用一行可以做到这一切: @@ -60,14 +66,16 @@ Assimp最大优点是,它简约的抽象了所加载所有不同格式文件 Assimp::Importer importer; 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轴反转,所以这个小小的后处理选项会为我们修正这个)。一少部分其他有用的选项如下: -aiProcess_GenNormals:如果模型没有包含法线向量,就为每个顶点创建法线。 -aiProcess_SplitLargeMeshes:把大的网格成几个小的的下级网格,当你渲染有一个最大数量顶点的限制时或者只能处理小块网格时很有用。 -aiProcess_OptimizeMeshes:和上个选项相反,它把几个网格结合为一个更大的网格。以减少绘制函数调用的次数的方式来优化。 +我们先来声明一个`Importer`对象,它的名字空间是`Assimp`,然后调用它的`ReadFile`函数。这个函数需要一个文件路径,第二个参数是后处理(post-processing)选项。除了可以简单加载文件外,Assimp允许我们定义几个选项来强制Assimp去对导入数据做一些额外的计算或操作。通过设置`aiProcess_Triangulate`,我们告诉Assimp如果模型不是(全部)由三角形组成,应该转换所有的模型的原始几何形状为三角形。`aiProcess_FlipUVs`基于y轴翻转纹理坐标,在处理的时候是必须的(你可能记得,我们在纹理教程中,我们说过在OpenGL大多数图像会被沿着y轴反转,所以这个小小的后处理选项会为我们修正这个)。一少部分其他有用的选项如下: + +* `aiProcess_GenNormals` : 如果模型没有包含法线向量,就为每个顶点创建法线。 +* `aiProcess_SplitLargeMeshes` : 把大的网格成几个小的的下级网格,当你渲染有一个最大数量顶点的限制时或者只能处理小块网格时很有用。 +* `aiProcess_OptimizeMeshes` : 和上个选项相反,它把几个网格结合为一个更大的网格。以减少绘制函数调用的次数的方式来优化。 + Assimp提供了后处理说明,你可以从这里找到所有内容。事实上通过Assimp加载一个模型超级简单。困难的是使用返回的场景对象把加载的数据变换到一个Mesh对象的数组。 -完整的loadModel函数在这里列出: +完整的`loadModel`函数在这里列出: ```c++ @@ -86,45 +94,48 @@ void loadModel(string path) this->processNode(scene->mRootNode, scene); } ``` -在我们加载了模型之后,我们检验是否场景和场景的根节点为空,查看这些标记中的一个来看看返回的数据是否完整。如果发生了任何一个错误,我们通过导入器(impoter)的GetErrorString函数返回错误报告。我们同样重新获取文件的目录路径。 -如果没什么错误发生,我们希望处理所有的场景节点,所以我们传递第一个节点(根节点)到递归函数processNode。因为每个节点(可能)包含多个子节点,我们希望先处理父节点再处理子节点,以此类推。这符合递归结构,所以我们定义一个递归函数。递归函数就是一个做一些什么处理之后,用不同的参数调用它自身的函数,此种循环不会停止,直到一个特定条件发生。在我们的例子里,特定条件是所有的节点都被处理。 +在我们加载了模型之后,我们检验是否场景和场景的根节点为空,查看这些标记中的一个来看看返回的数据是否完整。如果发生了任何一个错误,我们通过导入器(impoter)的`GetErrorString`函数返回错误报告。我们同样重新获取文件的目录路径。 -也许你记得,Assimp的结构,每个节点包含一个网格集合的索引,每个索引指向一个在场景对象中特定的网格位置。我们希望获取这些网格索引,获取每个网格,处理每个网格,然后对其他的节点的子节点做同样的处理。processNode函数的内容如下: +如果没什么错误发生,我们希望处理所有的场景节点,所以我们传递第一个节点(根节点)到递归函数`processNode`。因为每个节点(可能)包含多个子节点,我们希望先处理父节点再处理子节点,以此类推。这符合递归结构,所以我们定义一个递归函数。递归函数就是一个做一些什么处理之后,用不同的参数调用它自身的函数,此种循环不会停止,直到一个特定条件发生。在我们的例子里,特定条件是所有的节点都被处理。 + +也许你记得,Assimp的结构,每个节点包含一个网格集合的索引,每个索引指向一个在场景对象中特定的网格位置。我们希望获取这些网格索引,获取每个网格,处理每个网格,然后对其他的节点的子节点做同样的处理。`processNode`函数的内容如下: ```c++ void processNode(aiNode* node, const aiScene* scene) { - // Process all the node's meshes (if any) + // 添加当前节点中的所有Mesh for(GLuint i = 0; i < node->mNumMeshes; i++) { aiMesh* mesh = scene->mMeshes[node->mMeshes[i]]; this->meshes.push_back(this->processMesh(mesh, scene)); } - // Then do the same for each of its children + // 递归处理该节点的子孙节点 for(GLuint i = 0; i < node->mNumChildren; i++) { this->processNode(node->mChildren[i], scene); } } ``` -我们首先利用场景的mMeshes数组来检查每个节点的网格索引以获取相应的网格。被返回的网格被传递给processMesh函数,它返回一个网格对象,我们可以把它储存在meshes的list或vector(STL里的两种实现链表的数据结构)中。 + +我们首先利用场景的`mMeshes`数组来检查每个节点的网格索引以获取相应的网格。被返回的网格被传递给`processMesh`函数,它返回一个网格对象,我们可以把它储存在`meshes`的list或vector(STL里的两种实现链表的数据结构)中。 一旦所有的网格都被处理,我们遍历所有子节点,同样调用processNode函数。一旦一个节点不再拥有任何子节点,函数就会停止执行。 -认真的读者会注意到,我们可能基本忘记处理任何的节点,简单循环出场景所有的网格,而不是用索引做这件复杂的事。我们这么做的原因是,使用这种节点的原始的想法是,在网格之间定义一个父-子关系。通过递归遍历这些关系,我们可以真正定义特定的网格作为其他网格的父(节点)。 +!!! Important -关于这个系统的一个有用的例子是,当你想要平移一个汽车网格需要确保把它的子(节点)比如,引擎网格,方向盘网格和轮胎网格都进行平移;使用父-子关系这样的系统很容易被创建出来。 + 认真的读者会注意到,我们可能基本忘记处理任何的节点,简单循环出场景所有的网格,而不是用索引做这件复杂的事。我们这么做的原因是,使用这种节点的原始的想法是,在网格之间定义一个父-子关系。通过递归遍历这些关系,我们可以真正定义特定的网格作为其他网格的父(节点)。 + + 关于这个系统的一个有用的例子是,当你想要平移一个汽车网格需要确保把它的子(节点)比如,引擎网格,方向盘网格和轮胎网格都进行平移;使用父-子关系这样的系统很容易被创建出来。 + + 现在我们没用这种系统,但是无论何时你想要对你的网格数据进行额外的控制,这通常是一种坚持被推荐的做法。这些模型毕竟是那些定义了这些节点风格的关系的艺术家所创建的。 -现在我们没用这种系统,但是无论何时你想要对你的网格数据进行额外的控制,这通常是一种坚持被推荐的做法。这些模型毕竟是那些定义了这些节点风格的关系的艺术家所创建的。 -下一步是用上个教程创建的Mesh类开始真正处理Assimp的数据。 +下一步是用上个教程创建的`Mesh`类开始真正处理Assimp的数据。 - +## 从Assimp到网格 -##从Assimp到网格 - -把一个aiMesh对象转换为一个我们自己定义的网格对象并不难。我们所要做的全部是获取每个网格相关的属性并把这些属性储存到我们自己的对象。通常processMesh函数的结构会是这样: +把一个`aiMesh`对象转换为一个我们自己定义的网格对象并不难。我们所要做的全部是获取每个网格相关的属性并把这些属性储存到我们自己的对象。通常`processMesh`函数的结构会是这样: ```c++ @@ -137,13 +148,13 @@ Mesh processMesh(aiMesh* mesh, const aiScene* scene) for(GLuint i = 0; i < mesh->mNumVertices; i++) { Vertex vertex; - // Process vertex positions, normals and texture coordinates + // 处理顶点坐标、法线和纹理坐标 ... vertices.push_back(vertex); } - // Process indices + // 处理顶点索引 ... - // Process material + // 处理材质 if(mesh->mMaterialIndex >= 0) { ... @@ -152,9 +163,10 @@ Mesh processMesh(aiMesh* mesh, const aiScene* scene) return Mesh(vertices, indices, textures); } ``` + 处理一个网格基本由三部分组成:获取所有顶点数据,获取网格的索引,获取相关材质数据。处理过的数据被储存在3个向量其中之一里面,一个Mesh被以这些数据创建,返回到函数的调用者。 -获取顶点数据很简单:我们定义一个Vertex结构体,在每次遍历后我们把这个结构体添加到Vertices数组。我们为存在于网格中的众多顶点循环(通过mesh->mNumVertices获取)。在遍历的过程中,我们希望用所有相关数据填充这个结构体。每个顶点位置会像这样被处理: +获取顶点数据很简单:我们定义一个`Vertex`结构体,在每次遍历后我们把这个结构体添加到`Vertices`数组。我们为存在于网格中的众多顶点循环(通过`mesh->mNumVertices`获取)。在遍历的过程中,我们希望用所有相关数据填充这个结构体。每个顶点位置会像这样被处理: ```c++ @@ -164,9 +176,13 @@ vector.y = mesh->mVertices[i].y; vector.z = mesh->mVertices[i].z; vertex.Position = vector; ``` -注意,为了传输Assimp的数据,我们定义一个vec3的宿主,我们需要它是因为Assimp维持它自己的数据类型,这些类型用于向量、材质、字符串等。这些数据类型转换到glm的数据类型时通常效果不佳。 -Assimp调用他们的顶点位置数组mVertices真有点违反直觉。 +注意,为了传输Assimp的数据,我们定义一个`vec3`的宿主,我们需要它是因为Assimp维持它自己的数据类型,这些类型用于向量、材质、字符串等。这些数据类型转换到glm的数据类型时通常效果不佳。 + +!!! Important + + Assimp调用他们的顶点位置数组`mVertices`真有点违反直觉。 + 对应法线的步骤毫无疑问是这样的: @@ -176,6 +192,7 @@ vector.y = mesh->mNormals[i].y; vector.z = mesh->mNormals[i].z; vertex.Normal = vector; ``` + 纹理坐标也基本一样,但是Assimp允许一个模型的每个顶点有8个不同的纹理坐标,我们可能用不到,所以我们只关系第一组纹理坐标。我们也希望检查网格是否真的包含纹理坐标(可能并不总是如此): @@ -190,13 +207,12 @@ if(mesh->mTextureCoords[0]) // Does the mesh contain texture coordinates? else vertex.TexCoords = glm::vec2(0.0f, 0.0f); ``` -Vertex结构体现在完全被所需的顶点属性填充了,我们能把它推到vertices向量的尾部。要对每个网格的顶点做相同的处理。 - +`Vertex`结构体现在完全被所需的顶点属性填充了,我们能把它添加到`vertices`向量的尾部。要对每个网格的顶点做相同的处理。 -##顶点 +## 顶点 -Assimp的接口定义每个网格有一个以面(faces)为单位的数组,每个面代表一个单独的图元,在我们的例子中(由于aiProcess_Triangulate选项)总是三角形,一个面包含索引,这些索引定义我们需要绘制的顶点以在那样的顺序提供给每个图元,所以如果我们遍历所有面,把所有面的索引储存到indices向量,我们需要这么做: +Assimp的接口定义每个网格有一个以面(faces)为单位的数组,每个面代表一个单独的图元,在我们的例子中(由于`aiProcess_Triangulate`选项)总是三角形,一个面包含索引,这些索引定义我们需要绘制的顶点以在那样的顺序提供给每个图元,所以如果我们遍历所有面,把所有面的索引储存到`indices`向量,我们需要这么做: ```c++ @@ -207,14 +223,14 @@ for(GLuint i = 0; i < mesh->mNumFaces; i++) indices.push_back(face.mIndices[j]); } ``` -所有外部循环结束后,我们现在有了一个完整点的顶点和索引数据来绘制网格,这要调用glDrawElements函数。可是,为了结束这个讨论,并向网格提供一些细节,我们同样希望处理网格的材质。 + +所有外部循环结束后,我们现在有了一个完整点的顶点和索引数据来绘制网格,这要调用`glDrawElements`函数。可是,为了结束这个讨论,并向网格提供一些细节,我们同样希望处理网格的材质。 -##材质 - -如同节点,一个网格只有一个指向材质对象的索引,获取网格实际的材质,我们需要索引场景的mMaterials数组。网格的材质索引被设置在mMaterialIndex属性中,通过这个属性我们同样能够检验一个网格是否包含一个材质: +## 材质 +如同节点,一个网格只有一个指向材质对象的索引,获取网格实际的材质,我们需要索引场景的`mMaterials`数组。网格的材质索引被设置在`mMaterialIndex`属性中,通过这个属性我们同样能够检验一个网格是否包含一个材质: ```c++ if(mesh->mMaterialIndex >= 0) @@ -228,9 +244,10 @@ if(mesh->mMaterialIndex >= 0) textures.insert(textures.end(), specularMaps.begin(), specularMaps.end()); } ``` -我么先从场景的mMaterials数组获取aimaterial对象,然后,我们希望加载网格的diffuse或/和specular纹理。一个材质储存了一个数组,这个数组为每个纹理类型提供纹理位置。不同的纹理类型都以aiTextureType_为前缀。我们使用一个帮助函数:loadMaterialTextures来从材质获取纹理。这个函数返回一个Texture结构体的向量,我们在之后储存在模型的textures坐标的后面。 -loadMaterialTextures函数遍历所有给定纹理类型的纹理位置,获取纹理的文件位置,然后加载生成纹理,把信息储存到Vertex结构体。看起来像这样: +我么先从场景的`mMaterials`数组获取`aimaterial`对象,然后,我们希望加载网格的diffuse或/和specular纹理。一个材质储存了一个数组,这个数组为每个纹理类型提供纹理位置。不同的纹理类型都以`aiTextureType_`为前缀。我们使用一个帮助函数:`loadMaterialTextures`来从材质获取纹理。这个函数返回一个`Texture`结构体的向量,我们在之后储存在模型的`textures`坐标的后面。 + +`loadMaterialTextures`函数遍历所有给定纹理类型的纹理位置,获取纹理的文件位置,然后加载生成纹理,把信息储存到`Vertex`结构体。看起来像这样: ```c++ @@ -250,16 +267,18 @@ vector loadMaterialTextures(aiMaterial* mat, aiTextureType type, string return textures; } ``` -我们先通过GetTextureCount函数检验材质中储存的纹理,以期得到我们希望得到的纹理类型。然后我们通过GetTexture函数获取每个纹理的文件位置,这个位置以aiString类型储存。然后我们使用另一个帮助函数,它被命名为:TextureFromFile加载一个纹理(使用SOIL),返回纹理的ID。你可以查看列在最后的完整的代码,如果你不知道这个函数应该怎样写出来的话。 -注意,我们假设纹理文件与模型是在相同的目录里。我们可以简单的链接纹理位置字符串和之前获取的目录字符串(在loadModel函数中得到的)来获得完整的纹理路径(这就是为什么GetTexture函数同样需要目录字符串)。 +我们先通过`GetTextureCount`函数检验材质中储存的纹理,以期得到我们希望得到的纹理类型。然后我们通过`GetTexture`函数获取每个纹理的文件位置,这个位置以`aiString`类型储存。然后我们使用另一个帮助函数,它被命名为:`TextureFromFile`加载一个纹理(使用SOIL),返回纹理的ID。你可以查看列在最后的完整的代码,如果你不知道这个函数应该怎样写出来的话。 -有些在互联网上找到的模型使用绝对路径,它们的纹理位置就不会在每天机器上都有效了。例子里,你可能希望手工编辑这个文件来使用本地路径为纹理所使用(如果可能的话)。 -这就是使用Assimp来导入一个模型的全部了。你可以在这里找到Model类的全部。 +!!! Important - + 注意,我们假设纹理文件与模型是在相同的目录里。我们可以简单的链接纹理位置字符串和之前获取的目录字符串(在`loadModel`函数中得到的)来获得完整的纹理路径(这就是为什么`GetTexture`函数同样需要目录字符串)。 + + 有些在互联网上找到的模型使用绝对路径,它们的纹理位置就不会在每天机器上都有效了。例子里,你可能希望手工编辑这个文件来使用本地路径为纹理所使用(如果可能的话)。 -##重大优化 +这就是使用Assimp来导入一个模型的全部了。你可以在这里找到[Model类的代码](http://learnopengl.com/code_viewer.php?code=model_loading/model_unoptimized)。 + +## 重大优化 我们现在还没做完。因为我们还想做一个重大的优化(但是不是必须的)。大多数场景重用若干纹理,把它们应用到网格;还是思考那个别墅,它有个花岗岩的纹理作为墙面。这个纹理也可能应用到地板、天花板,楼梯,或者一张桌子、一个附近的小物件。加载纹理需要不少操作,当前的实现中一个新的纹理被加载和生成,来为每个网格使用,即使同样的纹理之前已经被加载了好几次。这会很快转变为你的模型加载实现的瓶颈。 @@ -273,13 +292,15 @@ struct Texture { aiString path; // We store the path of the texture to compare with other textures }; ``` + 然后我们把所有家在过的纹理储存到另一个向量中,它是作为一个私有变量声明在模型类的顶部: ```c++ vector textures_loaded; ``` -然后,在loadMaterialTextures函数中,我们希望把纹理路径和所有texture_loaded向量对比,看看是否当前纹理路径和其中任何一个是否相同,如果是,我们跳过纹理加载/生成部分,简单的使用已加载纹理结构体作为网格纹理。这个函数如下所示: + +然后,在`loadMaterialTextures`函数中,我们希望把纹理路径和所有`texture_loaded`向量对比,看看是否当前纹理路径和其中任何一个是否相同,如果是,我们跳过纹理加载/生成部分,简单的使用已加载纹理结构体作为网格纹理。这个函数如下所示: ```c++ @@ -301,35 +322,40 @@ vector loadMaterialTextures(aiMaterial* mat, aiTextureType type, string } } if(!skip) - { // If texture hasn't been loaded already, load it + { // 如果纹理没有被加载过,加载之 Texture texture; texture.id = TextureFromFile(str.C_Str(), this->directory); texture.type = typeName; texture.path = str; textures.push_back(texture); - this->textures_loaded.push_back(texture); // Add to loaded textures + this->textures_loaded.push_back(texture); // 添加到纹理列表 textures } } return textures; } ``` + 所以现在我们不仅有了一个通用模型加载系统,同时我们也得到了一个能使加载对象更快的优化版本。 -有些版本的Assimp当使用调试版或/和使用你的IDE的调试模式时,模型加载模型实在慢,所以确保在当你加载得很慢的时候用发布版再测试。 -你可以从这里获得优化的Model类的完整源代码。 +!!! Attention - + 有些版本的Assimp当使用调试版或/和使用你的IDE的调试模式时,模型加载模型实在慢,所以确保在当你加载得很慢的时候用发布版再测试。 -##和箱子模型告别! +你可以从这里获得优化的[Model类的完整源代码](http://learnopengl.com/code_viewer.php?code=model&type=header)。 + +## 和箱子模型告别! 现在给我们导入一个天才艺术家创建的模型看看效果,不是我这个天才做的(你不得不承认,这个箱子也许是你见过的最漂亮的立体图形)。因为我不想过于自夸,所以我会时不时的给其他艺术家进入这个行列的机会,这次我们会加载Crytek原版的孤岛危机游戏中的纳米铠甲。这个模型被输出为obj和mtl文件,mtl包含模型的diffuse和specular以及法线贴图(后面会讲)。你可以下载这个模型,注意,所有的纹理和模型文件都应该放在同一个目录,以便载入纹理。 -你从这个站点下载的版本是修改过的版本,每个纹理文件路径已经修改改为本地相对目录,原来的资源是绝对目录。 +!!! Important + + 你从这个站点下载的版本是修改过的版本,每个纹理文件路径已经修改改为本地相对目录,原来的资源是绝对目录。 + 现在在代码中,声明一个Model对象,把它模型的文件位置传递给它。模型应该自动加载(如果没有错误的话)在游戏循环中使用它的Draw函数绘制这个对象。没有更多的缓冲配置,属性指针和渲染命令,仅仅简单的一行。如果你创建几个简单的着色器,像素着色器只输出对象的diffuse纹理颜色,结果看上去会有点像这样: ![](http://www.learnopengl.com/img/model_loading/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)。 我们也可以变得更加有创造力,引入两个点光源到我们之前从光照教程学过的渲染等式,结合高光贴图获得惊艳效果: @@ -337,8 +363,6 @@ vector loadMaterialTextures(aiMaterial* mat, aiTextureType type, string 虽然我不得不承认这个相比之前用过的容器也太炫了。使用Assimp,你可以载入无数在互联网上找到的模型。只有很少的资源网站提供多种格式的免费3D模型给你下载。一定注意,有些模型仍然不能很好的载入,纹理路径无效或者这种格式Assimp不能读。 - +## 练习 -##练习 - -你可以使用两个点光源重建上个场景吗?方案,着色器。 \ No newline at end of file +你可以使用两个点光源重建上个场景吗?[方案](http://learnopengl.com/code_viewer.php?code=model_loading/model-exercise1),[着色器](http://learnopengl.com/code_viewer.php?code=model_loading/model-exercise1-shaders)。 \ No newline at end of file