@[toc]

Assimp

Assimp数据结构的(简化)模型如下:
在这里插入图片描述

  • 和材质和网格(Mesh)一样,所有的场景/模型数据都包含在Scene对象中.Scene对象也包含了场景根节点的引用.
  • 场景的Root node(根节点)可能包含子节点(和其它的节点一样),它会有一系列指向场景对象中mMeshes数组中储存的网格数据的索引.Scene下的mMeshes数组储存了真正的Mesh对象,节点中的mMeshes数组保存的只是场景中网格数组的索引.
  • 一个Mesh对象本身包含了渲染所需要的所有相关数据,像是顶点位置、法向量、纹理坐标、面(Face)和物体的材质.
  • 一个网格包含了多个面.Face代表的是物体的渲染图元(Primitive)(三角形、方形、点).一个面包含了组成图元的顶点的索引.由于顶点和索引是分开的,使用一个索引缓冲来渲染是非常简单的.
  • 最后,一个网格也包含了一个Material对象,它包含了一些函数能让我们获取物体的材质属性,比如说颜色和纹理贴图(比如漫反射和镜面光贴图).

所以,我们需要做的第一件事是将一个物体加载到Scene对象中,遍历节点,获取对应的Mesh对象(我们需要递归搜索每个节点的子节点),并处理每个Mesh对象来获取顶点数据、索引以及它的材质属性.最终的结果是一系列的网格数据,我们会将它们包含在一个Model对象中.

网格
当使用建模工具对物体建模的时候,艺术家通常不会用单个形状创建出整个模型.通常每个模型都由几个子模型/形状组合而成.组合模型的每个单独的形状就叫做一个网格(Mesh).比如说有一个人形的角色:艺术家通常会将头部、四肢、衣服、武器建模为分开的组件,并将这些网格组合而成的结果表现为最终的模型。一个网格是我们在OpenGL中绘制物体所需的最小单位(顶点数据、索引和材质属性).一个模型(通常)会包括多个网格.

网格

首先我们来回顾一下我们目前学到的知识,想想一个网格最少需要什么数据.一个网格应该至少需要一系列的顶点,每个顶点包含一个位置向量、一个法向量和一个纹理坐标向量.一个网格还应该包含用于索引绘制的索引以及纹理形式的材质数据(漫反射/镜面光贴图).

既然我们有了一个网格类的最低需求,我们可以在OpenGL中定义一个顶点了:

struct Vertex {
glm::vec3 Position;
glm::vec3 Normal;
glm::vec2 TexCoords;
};

我们将所有需要的向量储存到一个叫做Vertex的结构体中,我们可以用它来索引每个顶点属性.除了Vertex结构体之外,我们还需要将纹理数据整理到一个Texture结构体中.

struct Texture {
unsigned int id;
string type;
};

我们储存了纹理的id以及它的类型,比如是漫反射贴图或者是镜面光贴图.

知道了顶点和纹理的实现,我们可以开始定义网格类的结构了:

class Mesh {
public:
/* 网格数据 */
vector<Vertex> vertices;
vector<unsigned int> indices;
vector<Texture> textures;
/* 函数 */
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures);
void Draw(Shader shader);
private:
/* 渲染数据 */
unsigned int VAO, VBO, EBO;
/* 函数 */
void setupMesh();
};

我们将所有必须的数据赋予了网格,我们在setupMesh函数中初始化缓冲,并最终使用Draw函数来绘制网格.注意我们将一个着色器传入了Draw函数中,将着色器传入网格类中可以让我们在绘制之前设置一些uniform(像是链接采样器到纹理单元).

Mesh构造函数:

Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures)
{
this->vertices = vertices;
this->indices = indices;
this->textures = textures;

setupMesh();
}

初始化

由于有了构造器,我们现在有一大列的网格数据用于渲染.在此之前我们还必须配置正确的缓冲,并通过顶点属性指针定义顶点着色器的布局.现在你应该对这些概念都很熟悉了,但我们这次会稍微有一点变动,使用结构体中的顶点数据:

void setupMesh()
{
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);

glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);

glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),
&indices[0], GL_STATIC_DRAW);

// 顶点位置
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
// 顶点法线
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
// 顶点纹理坐标
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));

glBindVertexArray(0);
}

C++结构体有一个很棒的特性,它们的内存布局是连续的(Sequential).也就是说,如果我们将结构体作为一个数据数组使用,那么它将会以顺序排列结构体的变量,这将会直接转换为我们在数组缓冲中所需要的float(实际上是字节)数组.比如说,如果我们有一个填充后的Vertex结构体,那么它的内存布局将会等于:

Vertex vertex;
vertex.Position = glm::vec3(0.2f, 0.4f, 0.6f);
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所能用的参数:

glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);

自然sizeof运算也可以用在结构体上来计算它的字节大小.这个应该是32字节的(8个float * 每个4字节).

结构体的另外一个很好的用途是它的预处理指令offsetof(s, m),它的第一个参数是一个结构体,第二个参数是这个结构体中变量的名字.这个宏会返回那个变量距结构体头部的字节偏移量(Byte Offset).这正好可以用在定义glVertexAttribPointer函数中的偏移参数:

glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));

偏移量现在是使用offsetof来定义了,在这里它会将法向量的字节偏移量设置为结构体中法向量的偏移量,也就是3个float,即12字节.注意,我们同样将步长参数设置为了Vertex结构体的大小.

使用这样的一个结构体不仅能够提供可读性更高的代码,也允许我们很容易地拓展这个结构.如果我们希望添加另一个顶点属性,我们只需要将它添加到结构体中就可以了.由于它的灵活性,渲染的代码不会被破坏.

渲染

我们需要为Mesh类定义最后一个函数,它的Draw函数.在真正渲染这个网格之前,我们需要在调用glDrawElements函数之前先绑定相应的纹理.然而,这实际上有些困难,我们一开始并不知道这个网格(如果有的话)有多少纹理、纹理是什么类型的.所以我们该如何在着色器中设置纹理单元和采样器呢?

为了解决这个问题,我们需要设定一个命名标准:每个漫反射纹理被命名为texture_diffuseN,每个镜面光纹理应该被命名为texture_specularN,其中N的范围是1到纹理采样器最大允许的数字.比如说我们对某一个网格有3个漫反射纹理,2个镜面光纹理,它们的纹理采样器应该之后会被调用:

uniform sampler2D texture_diffuse1;
uniform sampler2D texture_diffuse2;
uniform sampler2D texture_diffuse3;
uniform sampler2D texture_specular1;
uniform sampler2D texture_specular2;

根据这个标准,我们可以在着色器中定义任意需要数量的纹理采样器,如果一个网格真的包含了(这么多)纹理,我们也能知道它们的名字是什么.根据这个标准,我们也能在一个网格中处理任意数量的纹理,开发者也可以自由选择需要使用的数量,他只需要定义正确的采样器就可以了(虽然定义少的话会有点浪费绑定和uniform调用).

最终的渲染代码是这样的:

void Draw(Shader shader) 
{
unsigned int diffuseNr = 1;
unsigned int specularNr = 1;
for(unsigned int i = 0; i < textures.size(); i++)
{
glActiveTexture(GL_TEXTURE0 + i); // 在绑定之前激活相应的纹理单元
// 获取纹理序号(diffuse_textureN 中的 N)
string number;
string name = textures[i].type;
if(name == "texture_diffuse")
number = std::to_string(diffuseNr++);
else if(name == "texture_specular")
number = std::to_string(specularNr++);

shader.setFloat(("material." + name + number).c_str(), i);
glBindTexture(GL_TEXTURE_2D, textures[i].id);
}
glActiveTexture(GL_TEXTURE0);

// 绘制网格
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
}

我们首先计算了每个纹理类型的N-分量,并将其拼接到纹理类型字符串上,来获取对应的uniform名称.接下来我们查找对应的采样器,将它的位置值设置为当前激活的纹理单元,并绑定纹理.这也是我们在Draw函数中需要着色器的原因.我们也将”material.”添加到了最终的uniform名称中,因为我们希望将纹理储存在一个材质结构体中(这在每个实现中可能都不同).

模型

这一节我们会使用Assimp来加载模型,并将它转换(Translate)至多个在上一节中创建的Mesh对象.
Model的定义如下:

class Model 
{
public:
/* 函数 */
Model(char *path)
{
loadModel(path);
}
void Draw(Shader shader);
private:
/* 模型数据 */
vector<Mesh> meshes;
string directory;
/* 函数 */
void loadModel(string path);
void processNode(aiNode *node, const aiScene *scene);
Mesh processMesh(aiMesh *mesh, const aiScene *scene);
vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type,
string typeName);
};

Model类包含了一个Mesh对象的vector,构造器需要我们给它一个文件路径.在构造器中,它会直接通过loadModel来加载文件.私有函数将会处理Assimp导入过程中的一部分,我们很快就会介绍它们.我们还将储存文件路径的目录,在之后加载纹理的时候还会用到它.

Draw函数没有什么特别之处,基本上就是遍历了所有网格,并调用它们各自的Draw函数.

void Draw(Shader shader)
{
for(unsigned int i = 0; i < meshes.size(); i++)
meshes[i].Draw(shader);
}

导入3D模型到OpenGL

要想导入一个模型,并将它转换到我们自己的数据结构中的话,首先我们需要包含Assimp对应的头文件,这样编译器就不会抱怨我们了.

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

首先需要调用的函数是loadModel,它会从构造器中直接调用.在loadModel中,我们使用Assimp来加载模型至Assimp的一个叫做scene的数据结构中.你可能还记得在模型加载章节的第一节教程中,这是Assimp数据接口的根对象.一旦我们有了这个场景对象,我们就能访问到加载后的模型中所有所需的数据了.

Assimp很棒的一点在于,它抽象掉了加载不同文件格式的所有技术细节,只需要一行代码就能完成所有的工作:

Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);

我们首先声明了Assimp命名空间内的一个Importer,之后调用了它的ReadFile函数.这个函数需要一个文件路径,它的第二个参数是一些后期处理(Post-processing)的选项。除了加载文件之外,Assimp允许我们设定一些选项来强制它对导入的数据做一些额外的计算或操作。通过设定aiProcess_Triangulate,我们告诉Assimp,如果模型不是(全部)由三角形组成,它需要将模型所有的图元形状变换为三角形.aiProcess_FlipUVs将在处理的时候翻转y轴的纹理坐标(你可能还记得我们在纹理教程中说过,在OpenGL中大部分的图像的y轴都是反的,所以这个后期处理选项将会修复这个).其它一些比较有用的选项有:

  • aiProcess_GenNormals:如果模型不包含法向量的话,就为每个顶点创建法线.
  • aiProcess_SplitLargeMeshes:将比较大的网格分割成更小的子网格,如果你的渲染有最大顶点数限制,只能渲染较小的网格,那么它会非常有用.
  • aiProcess_OptimizeMeshes:和上个选项相反,它会将多个小网格拼接为一个大的网格,减少绘制调用从而进行优化.

Assimp提供了很多有用的后期处理指令,你可以在这里找到全部的指令.实际上使用Assimp加载模型是非常容易的(你也可以看到).困难的是之后使用返回的场景对象将加载的数据转换到一个Mesh对象的数组.

完整的loadModel函数将会是这样的:

void loadModel(string path)
{
Assimp::Importer import;
const aiScene *scene = import.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);

if(!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
{
cout << "ERROR::ASSIMP::" << import.GetErrorString() << endl;
return;
}
directory = path.substr(0, path.find_last_of('/'));

processNode(scene->mRootNode, scene);
}

在我们加载了模型之后,我们会检查场景和其根节点不为null,并且检查了它的一个标记(Flag),来查看返回的数据是不是不完整的.如果遇到了任何错误,我们都会通过导入器的GetErrorString函数来报告错误并返回.我们也获取了文件路径的目录路径.

如果什么错误都没有发生,我们希望处理场景中的所有节点,所以我们将第一个节点(根节点)传入了递归processNode函数.因为每个节点(可能)包含有多个子节点,我们希望首先处理参数中的节点,再继续处理该节点所有的子节点,以此类推.这正符合一个递归结构,所以我们将定义一个递归函数.递归函数在做一些处理之后,使用不同的参数递归调用这个函数自身,直到某个条件被满足停止递归.在我们的例子中退出条件(Exit Condition)是所有的节点都被处理完毕。

你可能还记得Assimp的结构中,每个节点包含了一系列的网格索引,每个索引指向场景对象中的那个特定网格.我们接下来就想去获取这些网格索引,获取每个网格,处理每个网格,接着对每个节点的子节点重复这一过程。processNode函数的内容如下:

void processNode(aiNode *node, const aiScene *scene)
{
// 处理节点所有的网格(如果有的话)
for(unsigned int i = 0; i < node->mNumMeshes; i++)
{
aiMesh *mesh = scene->mMeshes[node->mMeshes[i]];
meshes.push_back(processMesh(mesh, scene));
}
// 接下来对它的子节点重复这一过程
for(unsigned int i = 0; i < node->mNumChildren; i++)
{
processNode(node->mChildren[i], scene);
}
}

我们首先检查每个节点的网格索引,并索引场景的mMeshes数组来获取对应的网格.返回的网格将会传递到processMesh函数中,它会返回一个Mesh对象,我们可以将它存储在meshes列表/vector.

所有网格都被处理之后,我们会遍历节点的所有子节点,并对它们调用相同的processMesh函数.当一个节点不再有任何子节点之后,这个函数将会停止执行.

认真的读者可能会发现,我们可以基本上忘掉处理任何的节点,只需要遍历场景对象的所有网格,就不需要为了索引做这一堆复杂的东西了.我们仍这么做的原因是,使用节点的最初想法是将网格之间定义一个父子关系.通过这样递归地遍历这层关系,我们就能将某个网格定义为另一个网格的父网格了.
这个系统的一个使用案例是,当你想位移一个汽车的网格时,你可以保证它的所有子网格(比如引擎网格、方向盘网格、轮胎网格)都会随着一起位移。这样的系统能够用父子关系很容易地创建出来.
然而,现在我们并没有使用这样一种系统,但如果你想对你的网格数据有更多的控制,通常都是建议使用这一种方法的。这种类节点的关系毕竟是由创建了这个模型的艺术家所定义.

从Assimp到网格

将一个aiMesh对象转化为我们自己的网格对象不是那么困难.我们要做的只是访问网格的相关属性并将它们储存到我们自己的对象中.processMesh函数的大体结构如下:

Mesh processMesh(aiMesh *mesh, const aiScene *scene)
{
vector<Vertex> vertices;
vector<unsigned int> indices;
vector<Texture> textures;

for(unsigned int i = 0; i < mesh->mNumVertices; i++)
{
Vertex vertex;
// 处理顶点位置、法线和纹理坐标
...
vertices.push_back(vertex);
}
// 处理索引
...
// 处理材质
if(mesh->mMaterialIndex >= 0)
{
...
}

return Mesh(vertices, indices, textures);
}

处理网格的过程主要有三部分:获取所有的顶点数据,获取它们的网格索引,并获取相关的材质数据.处理后的数据将会储存在三个vector当中,我们会利用它们构建一个Mesh对象,并返回它到函数的调用者那里.

获取顶点数据非常简单,我们定义了一个Vertex结构体,我们将在每个迭代之后将它加到vertices数组中.我们会遍历网格中的所有顶点(使用mesh->mNumVertices来获取).在每个迭代中,我们希望使用所有的相关数据填充这个结构体。顶点的位置是这样处理的:

glm::vec3 vector; 
vector.x = mesh->mVertices[i].x;
vector.y = mesh->mVertices[i].y;
vector.z = mesh->mVertices[i].z;
vertex.Position = vector;

注意我们为了传输Assimp的数据,我们定义了一个vec3的临时变量.使用这样一个临时变量的原因是Assimp对向量、矩阵、字符串等都有自己的一套数据类型,它们并不能完美地转换到GLM的数据类型中.

Assimp将它的顶点位置数组叫做mVertices,这其实并不是那么直观.

处理法线的步骤也是差不多的:

vector.x = mesh->mNormals[i].x;
vector.y = mesh->mNormals[i].y;
vector.z = mesh->mNormals[i].z;
vertex.Normal = vector;

纹理坐标的处理也大体相似,但Assimp允许一个模型在一个顶点上有最多8个不同的纹理坐标,我们不会用到那么多,我们只关心第一组纹理坐标.我们同样也想检查网格是否真的包含了纹理坐标(可能并不会一直如此).

if(mesh->mTextureCoords[0]) // 网格是否有纹理坐标?
{
glm::vec2 vec;
vec.x = mesh->mTextureCoords[0][i].x;
vec.y = mesh->mTextureCoords[0][i].y;
vertex.TexCoords = vec;
}
else
vertex.TexCoords = glm::vec2(0.0f, 0.0f);

vertex结构体现在已经填充好了需要的顶点属性,我们会在迭代的最后将它压入vertices这个vector的尾部.这个过程会对每个网格的顶点都重复一遍.

索引

Assimp的接口定义了每个网格都有一个面(Face)数组,每个面代表了一个图元,在我们的例子中(由于使用了aiProcess_Triangulate选项)它总是三角形.一个面包含了多个索引,它们定义了在每个图元中,我们应该绘制哪个顶点,并以什么顺序绘制,所以如果我们遍历了所有的面,并储存了面的索引到indices这个vector中就可以了.

for(unsigned int i = 0; i < mesh->mNumFaces; i++)
{
aiFace face = mesh->mFaces[i];
for(unsigned int j = 0; j < face.mNumIndices; j++)
indices.push_back(face.mIndices[j]);
}

所有的外部循环都结束了,我们现在有了一系列的顶点和索引数据,它们可以用来通过glDrawElements函数来绘制网格.然而,为了结束这个话题,并且对网格提供一些细节,我们还需要处理网格的材质.

材质

和节点一样,一个网格只包含了一个指向材质对象的索引.如果想要获取网格真正的材质,我们还需要索引场景的mMaterials数组.网格材质索引位于它的mMaterialIndex属性中,我们同样可以用它来检测一个网格是否包含有材质:

if(mesh->mMaterialIndex >= 0)
{
aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
vector<Texture> diffuseMaps = loadMaterialTextures(material,
aiTextureType_DIFFUSE, "texture_diffuse");
textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
vector<Texture> specularMaps = loadMaterialTextures(material,
aiTextureType_SPECULAR, "texture_specular");
textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
}

我们首先从场景的mMaterials数组中获取aiMaterial对象.接下来我们希望加载网格的漫反射和/或镜面光贴图.一个材质对象的内部对每种纹理类型都存储了一个纹理位置数组.不同的纹理类型都以aiTextureType_为前缀.我们使用一个叫做loadMaterialTextures的工具函数来从材质中获取纹理.这个函数将会返回一个Texture结构体的vector,我们将在模型的textures vector的尾部之后存储它.

loadMaterialTextures函数遍历了给定纹理类型的所有纹理位置,获取了纹理的文件位置,并加载并和生成了纹理,将信息储存在了一个Vertex结构体中。它看起来会像这样:

vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
vector<Texture> textures;
for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
{
aiString str;
mat->GetTexture(type, i, &str);
Texture texture;
texture.id = TextureFromFile(str.C_Str(), directory);
texture.type = typeName;
texture.path = str;
textures.push_back(texture);
}
return textures;
}

我们首先通过GetTextureCount函数检查储存在材质中纹理的数量,这个函数需要一个纹理类型。我们会使用GetTexture获取每个纹理的文件位置,它会将结果储存在一个aiString中.我们接下来使用另外一个叫做TextureFromFile的工具函数,它将会(用stb_image.h)加载一个纹理并返回该纹理的ID.

注意,我们假设了模型文件中纹理文件的路径是相对于模型文件的本地(Local)路径,比如说与模型文件处于同一目录下.我们可以将纹理位置字符串拼接到之前(在loadModel中)获取的目录字符串上,来获取完整的纹理路径(这也是为什么GetTexture函数也需要一个目录字符串).
在网络上找到的某些模型会对纹理位置使用绝对(Absolute)路径,这就不能在每台机器上都工作了。在这种情况下,你可能会需要手动修改这个文件,来让它对纹理使用本地路径(如果可能的话).

重大优化

大多数场景都会在多个网格中重用部分纹理.还是想想一个房子,它的墙壁有着花岗岩的纹理.这个纹理也可以被应用到地板、天花板、楼梯、桌子,甚至是附近的一口井上.加载纹理并不是一个开销不大的操作,在我们当前的实现中,即便同样的纹理已经被加载过很多遍了,对每个网格仍会加载并生成一个新的纹理.这很快就会变成模型加载实现的性能瓶颈.

所以我们会对模型的代码进行调整,将所有加载过的纹理全局储存,每当我们想加载一个纹理的时候,首先去检查它有没有被加载过.如果有的话,我们会直接使用那个纹理,并跳过整个加载流程,来为我们省下很多处理能力.为了能够比较纹理,我们还需要储存它们的路径:

struct Texture {
unsigned int id;
string type;
aiString path; // 我们储存纹理的路径用于与其它纹理进行比较
};

接下来我们将所有加载过的纹理储存在另一个vector中,在模型类的顶部声明为一个私有变量:

vector<Texture> textures_loaded;

之后,在loadMaterialTextures函数中,我们希望将纹理的路径与储存在textures_loaded这个vector中的所有纹理进行比较,看看当前纹理的路径是否与其中的一个相同.如果是的话,则跳过纹理加载/生成的部分,直接使用定位到的纹理结构体为网格的纹理.更新后的函数如下:

vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
vector<Texture> textures;
for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
{
aiString str;
mat->GetTexture(type, i, &str);
bool skip = false;
for(unsigned int j = 0; j < textures_loaded.size(); j++)
{
if(std::strcmp(textures_loaded[j].path.data(), str.C_Str()) == 0)
{
textures.push_back(textures_loaded[j]);
skip = true;
break;
}
}
if(!skip)
{ // 如果纹理还没有被加载,则加载它
Texture texture;
texture.id = TextureFromFile(str.C_Str(), directory);
texture.type = typeName;
texture.path = str.C_Str();
textures.push_back(texture);
textures_loaded.push_back(texture); // 添加到已加载的纹理中
}
}
return textures;
}

Shiroha