本文同時(shí)發(fā)布在我的個(gè)人博客上:https://dragon_boy.gitee.io
模型
??在實(shí)際工作中,例如做游戲和作動(dòng)畫(huà),我們并不會(huì)在程序中手動(dòng)定義頂點(diǎn)、法線和紋理坐標(biāo),我們往往通過(guò)專門用來(lái)三維造型的軟件來(lái)構(gòu)建需要模型,例如maya,blender,3dmax。這些三維造型軟件允許我們建立復(fù)雜的模型,并通過(guò)UV貼圖賦予紋理,軟件會(huì)自動(dòng)生成頂點(diǎn)坐標(biāo)、法線和紋理坐標(biāo),之后我們可以將所有的信息到處到一個(gè)文件中。市面上有許多主流的3d模型文件格式,比如obj,只存儲(chǔ)模型的信息,不保留顏色信息,當(dāng)然還有fbx,abc,collada等,每種格式的存儲(chǔ)模型的信息和方式都是不同的。接下來(lái)我們會(huì)學(xué)習(xí)解析這種模型文件來(lái)導(dǎo)入模型。
Assimp
??一個(gè)非常有名的模型導(dǎo)入庫(kù)為Assimp(open asset import library)。Assimp可以導(dǎo)入許多格式的模型文件并將數(shù)據(jù)存儲(chǔ)在Assimp生成的數(shù)據(jù)結(jié)構(gòu)中,然后我們就可以從該數(shù)據(jù)結(jié)構(gòu)中獲取我們需要的信息。當(dāng)通過(guò)Assimp導(dǎo)入模型后,整個(gè)模型被導(dǎo)入名為scene的對(duì)象中。Assimp有一系列節(jié)點(diǎn),用于存儲(chǔ)scene對(duì)象中的不同數(shù)據(jù)的索引,每個(gè)節(jié)點(diǎn)又有任意數(shù)量的子節(jié)點(diǎn)。Assimp最簡(jiǎn)單的模型結(jié)構(gòu)如下:

- 所有的數(shù)據(jù)被存儲(chǔ)在Scene對(duì)象中,例如材質(zhì)和網(wǎng)格,同時(shí)也包含對(duì)所有根節(jié)點(diǎn)的引用。
- 場(chǎng)景的根節(jié)點(diǎn)包含許多子節(jié)點(diǎn),并包含由點(diǎn)構(gòu)成網(wǎng)格的順序索引,索引存儲(chǔ)在mMeshes數(shù)組中。Scene對(duì)象的mMeshes數(shù)組包含真正的Mesh對(duì)象,而節(jié)點(diǎn)中的mMeshes數(shù)組只包含索引。
- 一個(gè)Mesh對(duì)象包含所有渲染需要用到的數(shù)據(jù),如頂點(diǎn)位置,法線向量,紋理坐標(biāo),面,材質(zhì)。
- 一個(gè)網(wǎng)格包含有幾個(gè)面,而一個(gè)面代表一個(gè)可渲染的基本幾何體(三角形,四邊形,點(diǎn))。一個(gè)面包含構(gòu)成基本幾何體的點(diǎn)的索引。由于點(diǎn)和它的索引是分離的,我們可以使用EBO來(lái)繪制基本幾何體。
- 最后一個(gè)網(wǎng)格也與一個(gè)Material對(duì)象鏈接,包含針對(duì)Scene對(duì)象的材質(zhì)的索引。Material對(duì)象提供一些方法來(lái)重建一個(gè)對(duì)象的材質(zhì)屬性。
??整理一下思路,我們需要做的是:首先,將模型文件的數(shù)據(jù)導(dǎo)入到Scene對(duì)象中,再通過(guò)遞歸的方式檢索每個(gè)節(jié)點(diǎn)相關(guān)的Mesh對(duì)象,通過(guò)處理Mesh對(duì)象我們獲取它的頂點(diǎn)數(shù)據(jù),索引和材質(zhì)屬性。最后我們得到一個(gè)模型對(duì)象,其中包含我們需要的所有網(wǎng)格數(shù)據(jù)。
??注意:在建模時(shí),我們往往不會(huì)直接建立一個(gè)整體模型,而是分組件建立,例如一個(gè)人的模型,我們會(huì)將頭和身子分開(kāi),然后創(chuàng)建頭發(fā),服裝,小道具,最后組合起來(lái)。而這其中的每一個(gè)組件即一個(gè)網(wǎng)格,一個(gè)模型會(huì)包含多個(gè)網(wǎng)格。
編譯Assimp
??和我們使用的其它的第三方庫(kù)一樣,我們會(huì)編譯官方提供的源碼來(lái)保證能夠符合本機(jī)的環(huán)境。官網(wǎng):http://assimp.org/index.php/downloads。如有編譯錯(cuò)誤請(qǐng)參考官網(wǎng):https://learnopengl.com/Model-Loading/Assimp。
網(wǎng)格類
??通過(guò)Assimp,我們可以導(dǎo)入模型文件并將數(shù)據(jù)保存在Assimp特有的數(shù)據(jù)結(jié)構(gòu)中,但我們需要將其轉(zhuǎn)化為OpenGL可以使用的格式。上面我們說(shuō)過(guò),一個(gè)網(wǎng)格代表一個(gè)可繪制的實(shí)體,接下來(lái)我們創(chuàng)建自己的Mesh類。
??一個(gè)網(wǎng)格至少包含一系列的頂點(diǎn),每個(gè)頂點(diǎn)包含一個(gè)坐標(biāo)向量,一個(gè)法線向量,一個(gè)紋理坐標(biāo)向量,一個(gè)網(wǎng)格同時(shí)也包含繪制的索引,包含紋理信息的材質(zhì)數(shù)據(jù)。
??首先定義一個(gè)頂點(diǎn)的結(jié)構(gòu)體,包含位置,法線,紋理坐標(biāo)的屬性:
struct Vertex {
glm::vec3 Position;
glm::vec3 Normal;
glm::vec2 TexCoords;
};
??同樣,,我們想管理紋理數(shù)據(jù),我們定義一個(gè)紋理的結(jié)構(gòu)體:
struct Texture {
unsigned int id;
string type;
};
??接下來(lái)構(gòu)建我們的網(wǎng)格類:
class Mesh{
public:
// 網(wǎng)格數(shù)據(jù)
vector<Vertex> vertices;
vector<unsigned int> indices;
vector<Texture> textures;
//構(gòu)造方法
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures);
// 繪制網(wǎng)格
void Draw(Shader shader);
private:
// 緩沖對(duì)象
unsigned int VAO, VBO, EBO;
//初始化緩沖對(duì)象
void setupMesh();
};
??實(shí)現(xiàn)構(gòu)造方法:
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures)
{
this->vertices = vertices;
this->indices = indices;
this->textures = textures;
setupMesh();
}
初始化
??還是那么一套對(duì)于VAO,VBO,EBO的配置,我們來(lái)實(shí)現(xiàn)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);
// 頂點(diǎn)位置
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
// 頂點(diǎn)法線
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
// 頂點(diǎn)紋理
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));
glBindVertexArray(0);
}
??注意上面的代碼,由于結(jié)構(gòu)體的特性,屬性在結(jié)構(gòu)體中的排列是有順序的,同時(shí)可以很方便的轉(zhuǎn)化為數(shù)組來(lái)方便傳入數(shù)組緩沖。比如我們定義一個(gè)頂點(diǎn),并賦予屬性,那么順序如下:
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];
??因?yàn)檫@種特性,我們可以直接傳入結(jié)構(gòu)體指針作為緩沖數(shù)據(jù):
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);
??結(jié)構(gòu)體的另一個(gè)特性是我們可以使用offset函數(shù)來(lái)獲取偏移量。offset(s,m)的將結(jié)構(gòu)體作為第一個(gè)參數(shù), 第二個(gè)參數(shù)傳入屬性名。這個(gè)方法返回從結(jié)構(gòu)體的初始位置到屬性的位置計(jì)算的字節(jié)偏移量,我們?cè)趃lVertexAttribPointer使用:
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
渲染
??接下來(lái)我們來(lái)實(shí)現(xiàn)Draw方法。由于我們不清除一個(gè)網(wǎng)格擁有多少種貼圖,以及每種貼圖有多少?gòu)?,所以我們可以為紋理編號(hào)。如每個(gè)漫反射紋理被定義為:texture_diffuseN,高光紋理為:texture_specularN(N的最大值由OpenGL限制)。這樣我們可以很方便的在著色器中這樣定義紋理:
uniform sampler2D texture_diffuse1;
uniform sampler2D texture_diffuse2;
uniform sampler2D texture_diffuse3;
uniform sampler2D texture_specular1;
uniform sampler2D texture_specular2;
??接著就可以在Draw方法中為這些紋理賦予編號(hào)并激活和綁定紋理,然后繪制三角形:
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); // 在綁定前激活紋理
// 檢索紋理編號(hào)
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);
}
// 繪制網(wǎng)格
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
}
??在上述的方法中我們加入了material結(jié)構(gòu)體來(lái)管理紋理。
??Mesh類的代碼:Mesh.h。
模型類
??接下來(lái)我們構(gòu)建模型導(dǎo)入類。就像之前說(shuō)的,一個(gè)模型包含若干網(wǎng)格,在模型類中我們會(huì)使用多個(gè)網(wǎng)格對(duì)象。
??下面是Model類的基礎(chǔ)定義:
class Model
{
public:
// 構(gòu)造方法
Model(char *path)
{
loadModel(path);
}
// 繪制模型
void Draw(Shader shader);
private:
// 模型數(shù)據(jù)(多個(gè)網(wǎng)格)
vector<Mesh> meshes;
string directory;
// 導(dǎo)入模型
void loadModel(string path);
// 處理節(jié)點(diǎn)
void processNode(aiNode *node, const aiScene *scene);
// 處理網(wǎng)格對(duì)象
Mesh processMesh(aiMesh *mesh, const aiScene *scene);
// 導(dǎo)入材質(zhì)紋理
vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type,
string typeName);
};
??一個(gè)模型類包含一系列網(wǎng)格對(duì)象,構(gòu)造方法要求文件的路徑,接著通過(guò)構(gòu)造方法中的loadModel方法導(dǎo)入模型。所有的private方法用來(lái)處理模型數(shù)據(jù)。
??Draw方法很簡(jiǎn)單,繪制所有的網(wǎng)格:
void Draw(Shader shader)
{
for(unsigned int i = 0; i < meshes.size(); i++)
meshes[i].Draw(shader);
}
導(dǎo)入3D模型到OpenGL
??接下來(lái)我們要使用Assimp庫(kù):
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>
??我們來(lái)實(shí)現(xiàn)loadModel方法。就像之前說(shuō)的,我們先將模型文件中的數(shù)據(jù)存儲(chǔ)在Scene對(duì)象中:
Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
??我們創(chuàng)建一個(gè)Importer對(duì)象來(lái)使用讀取文件的ReadFile方法。該方法的一個(gè)參數(shù)要求一個(gè)文件路徑,第二個(gè)參數(shù)要求一些后置處理選項(xiàng)。aiProcess_Trianglulate代表我們強(qiáng)制將所有的基本幾何體轉(zhuǎn)化為三角形。aiProcess_FlipUVs代表我們要反轉(zhuǎn)y軸的紋理坐標(biāo),這是由于圖片的原點(diǎn)在左上角,而OpenGL存儲(chǔ)紋理的坐標(biāo)原點(diǎn)在左下角。當(dāng)然,還有以下幾種后置處理命令:
- aiProcess_GenNoemals:如果模型不包含法線信息將為每個(gè)頂點(diǎn)創(chuàng)建法線。
- aiProcess_SplitLargeMeshes:將大的網(wǎng)格分割為若干個(gè)小的網(wǎng)格。如果網(wǎng)格的頂點(diǎn)數(shù)超出了渲染要求的最大頂點(diǎn)數(shù),可以使用這個(gè)命令。
- aiProcess_OptimizeMeshes:這個(gè)命令嘗試將一些網(wǎng)格合并為大的網(wǎng)格,來(lái)減少繪制次數(shù)。
??這里還有許多其它的assimp提供的后置處理命令:post-processing。
??完整的loadModel函數(shù)如下:
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);
}
??在導(dǎo)入模型后我們檢查場(chǎng)景和根節(jié)點(diǎn)是否為空,同時(shí)檢查返回的數(shù)據(jù)是否不完整。我們接著檢索文件所在的路徑。如果沒(méi)有問(wèn)題的話我們就開(kāi)始處理場(chǎng)景的節(jié)點(diǎn),我們將根節(jié)點(diǎn)作為processNode的第一個(gè)參數(shù),接著遞歸整個(gè)場(chǎng)景。
??注意,每個(gè)節(jié)點(diǎn)包含一系列網(wǎng)格索引,每個(gè)索引指向場(chǎng)景中的一個(gè)網(wǎng)格對(duì)象。因此我們檢索這些網(wǎng)格索引,檢索每個(gè)網(wǎng)格,處理每個(gè)網(wǎng)格,接著處理每個(gè)節(jié)點(diǎn)的子節(jié)點(diǎn)。在處理完所有節(jié)點(diǎn)后結(jié)束遞歸。下面是processNode的實(shí)現(xiàn):
void processNode(aiNode *node, const aiScene *scene)
{
// 處理所有節(jié)點(diǎn)的網(wǎng)格
for(unsigned int i = 0; i < node->mNumMeshes; i++)
{
aiMesh *mesh = scene->mMeshes[node->mMeshes[i]];
meshes.push_back(processMesh(mesh, scene));
}
// then do the same for each of its children
for(unsigned int i = 0; i < node->mNumChildren; i++)
{
processNode(node->mChildren[i], scene);
}
}
??我們首先檢索每個(gè)節(jié)點(diǎn)的網(wǎng)格索引,然后檢索相關(guān)的網(wǎng)格對(duì)象,并傳入porcessMesh進(jìn)行處理,并將結(jié)果存儲(chǔ)到我們定義的成員變量meshes中。接著對(duì)所有的子節(jié)點(diǎn)做同樣的操作,所有的節(jié)點(diǎn)被處理后結(jié)束遞歸。
將aiMesh對(duì)象轉(zhuǎn)化為我們定義的Mesh對(duì)象
??下面我們實(shí)現(xiàn)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;
// 處理頂點(diǎn)位置,法線,紋理坐標(biāo)
...
vertices.push_back(vertex);
}
// 處理索引
...
// 處理材質(zhì)
if(mesh->mMaterialIndex >= 0)
{
...
}
return Mesh(vertices, indices, textures);
}
??處理過(guò)程包含3部分,檢索所有的頂點(diǎn)數(shù)據(jù),檢索索引,檢索材質(zhì)。檢索數(shù)據(jù)存儲(chǔ)在我們定義的三個(gè)變量中,我們組裝為Mesh對(duì)象并作為返回值。
??處理頂點(diǎn)數(shù)據(jù)很簡(jiǎn)單,位置通過(guò)mesh->nVertices檢索,法線通過(guò)mesh->mNormals檢索,紋理通過(guò)mesh->mTextureCoords檢索:
??頂點(diǎn)位置:
glm::vec3 vector;
vector.x = mesh->mVertices[i].x;
vector.y = mesh->mVertices[i].y;
vector.z = mesh->mVertices[i].z;
vertex.Position = vector;
??頂點(diǎn)法線:
vector.x = mesh->mNormals[i].x;
vector.y = mesh->mNormals[i].y;
vector.z = mesh->mNormals[i].z;
vertex.Normal = vector;
??紋理坐標(biāo),注意,Assimp允許模型至多由8種不同的紋理坐標(biāo),但我們只關(guān)心紋理集合的首元素是否含有紋理坐標(biāo):
if(mesh->mTextureCoords[0]) // 判斷紋理集的首元素是否由紋理坐標(biāo)
{
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);
索引
??接著我們處理索引。Assimp種,每個(gè)網(wǎng)包含一系列面,每個(gè)面代表一個(gè)基本幾何體,在這里我們?cè)O(shè)置的是三角形。一個(gè)面包含繪制面的點(diǎn)的索引。所以我們檢索所有的面然后存儲(chǔ)每個(gè)面的索引:
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]);
}
材質(zhì)
??和節(jié)點(diǎn)一樣,一個(gè)網(wǎng)格只包含對(duì)材質(zhì)對(duì)象的索引。為了檢索網(wǎng)格的材質(zhì),我們需要場(chǎng)景mMaterials數(shù)組的索引,這被存儲(chǔ)在mMaterialIndex屬性中。我們首先確定網(wǎng)格是否包含材質(zhì)信息:
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());
}
??我們通過(guò)索引從場(chǎng)景的mMaterials數(shù)組中獲取aiMaterial對(duì)象,其中包含網(wǎng)格的材質(zhì)信息,即每種紋理的位置。接著我們從aiMaterial對(duì)象中加載紋理。我們通過(guò)loadMaterialTextures方法來(lái)實(shí)現(xiàn)。
??loadMaterialTextures基于紋理類型檢索所有的紋理位置,并檢索所有的紋理文件位置,然后加載和生成紋理并保存在Vertex結(jié)構(gòu)體中:
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;
}
??我們通過(guò)GetTextureCount來(lái)獲取某一種紋理的數(shù)量。接著通過(guò)GetTexture方法檢索每個(gè)紋理文件的位置并存儲(chǔ)在一個(gè)aiString類型的變量中。接著我們使用TextureFromFile(stb_image.h的方法)來(lái)加載紋理文件并返回紋理ID。
??注意,這里我們假設(shè)模型的紋理文件保存在和模型的相同目錄中,我們可以改變directory來(lái)獲取任意路徑下的紋理文件。
優(yōu)化
??現(xiàn)在仍存在一個(gè)問(wèn)題,即一張紋理可能會(huì)有多個(gè)網(wǎng)格對(duì)象使用。所以我們這樣優(yōu)化一下代碼:我們?nèi)执鎯?chǔ)所有導(dǎo)入的紋理,接著每次導(dǎo)入紋理的時(shí)候,我們先檢測(cè)紋理是否已經(jīng)導(dǎo)入;如果是,我們跳過(guò)所有的導(dǎo)入步驟并使用對(duì)應(yīng)的紋理。
??我們先為Texture結(jié)構(gòu)體添加文件路徑屬性:
struct Texture {
unsigned int id;
string type;
string path;
};
??接著,定義一個(gè)變量存儲(chǔ)所有導(dǎo)入了的紋理:
vector<Texture> textures_loaded;
??在loadMaterialTextures方法中,我們比較紋理文件的路徑來(lái)判斷紋理是否復(fù)用。如果是,則跳過(guò)紋理導(dǎo)入和生成步驟,并使用對(duì)應(yīng)已生成的紋理。修改過(guò)的方法如下:
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)
{
// 如果紋理文件沒(méi)有導(dǎo)入,則導(dǎo)入并生成紋理
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); // 添加到已導(dǎo)入紋理隊(duì)列
}
}
return textures;
}
??最終模型類的代碼參考在這里:Model.h。
??最后,我們就可以使用模型類導(dǎo)入復(fù)雜的模型來(lái)豐富我們的場(chǎng)景了。請(qǐng)多多關(guān)注原文:https://learnopengl.com/Model-Loading/Model。