1. 程式人生 > >OpenGL教程翻譯 第二十二課 使用Assimp載入模型

OpenGL教程翻譯 第二十二課 使用Assimp載入模型

第二十二課 使用Assimp載入模型

背景

到現在為止我們都在使用手動生成的模型。正如你所想的,指明每個頂點的位置和其他屬性有點時候並不是十分方便。對於一個箱子、錐體和簡單平面還好,但是像人們的臉怎麼辦?現實的商業應用和遊戲中,程式中使用模型一般都是由美術人員通過如 Blender, Maya 或 3ds Max 等建模軟體來解決這個問題。這些軟體提供了高階的工具幫助他們創造很複雜的模型。模型完成後可以以不同格式的檔案儲存。檔案中包含了這個模型的所有幾何解釋。這些檔案可以被載入到一個遊戲引擎(提供支援特定格式的引擎)裡面,檔案中的內容可用來填充到渲染所需要的頂點快取和索引快取中。使用這些專業的模型對於場景效果的提升是十分關鍵的。

自己開發解析器將耗費你很長的時間。如果你想從不同格式的模型檔案中載入模型,你需要學習每一種格式然後為其開發一個特定的直譯器。有些模型的格式簡單,但是一些也很複雜,你最終可能花費很多時間在這些並不是 3D 設計的核心內容上。因此,這章介紹的內容就是使用外加的庫負責從檔案中解釋和載入模型。

Open Asset Import Library,也稱 Assimp,是一個可以處理許多 3D 格式的開源庫,包括最受歡迎的二進位制反碼格式。它是跨平臺的,可用於 Linux 和 Windows,非常容易使用和嵌入到以 C/C++ 程式中。

這課沒有太多的理論。讓我們直接去看如何使用 Assimp 庫中提供的函式來匯入 3D 模型。在開始之前,請先確認你已經從上面的連結安裝了 Assimp。

程式碼

(mesh.h:50)
class Mesh
{
public:
Mesh();
~Mesh();
bool LoadMesh(const std::string& Filename);
void Render();
private:
bool InitFromScene(const aiScene* pScene, const std::string& Filename);
void InitMesh(unsigned int Index, const aiMesh* paiMesh);
bool InitMaterials(const aiScene* pScene, const
std::string& Filename); void Clear(); #define INVALID_MATERIAL 0xFFFFFFFF struct MeshEntry { MeshEntry(); ~MeshEntry(); bool Init(const std::vector& Vertices, const std::vector& Indices); GLuint VB; GLuint IB; unsigned int NumIndices; unsigned int MaterialIndex; }; std::vector<MeshEntry> m_Entries; std::vector<Texture*> m_Textures; };

這個 Mesh 類是 Assimp 和 OpenGL 程式之間的介面。這個類使用一個檔名引數作為 LoadMesh() 函式的引數,藉助於 Assimp 載入模型,然後我們對載入進的模型資料進行解析,並將這些模型資料填充到頂點快取、索引快取以及紋理物件。為了渲染 mesh,我們定義了 Render() 函式。Mesh 類的內部結構與 Assimp 載入模型的方法相匹配。Assimp 用一個 aiScene 物件來代表被載入的 mesh, aiScene 物件中封裝了包含模型各部分的 mesh 結構體。 aiScene 物件中必須至少含有一個 mesh 結構,複雜的模型可以包含多個 mesh 結構。 Mesh 類的成員 m_Entries 是 MeshEntry 結構體向量,其中的每個結構體和 aiScene 物件中的一個 mesh 結構相對應。這個結構體包含頂點快取、索引快取和材質的索引。目前一個材質只是一個紋理,又因為 mesh 實體可以共享材質,所以我們給每個材質( m_Textures )單獨設一個向量。MeshEntry::MaterialIndex 指向 m_Textures 裡面的一個紋理。

(mesh.cpp:77)
bool Mesh::LoadMesh(const std::string& Filename)
{
    // Release the previously loaded mesh (if it exists)
    Clear();
    bool Ret = false;
    Assimp::Importer Importer;
    const aiScene* pScene = Importer.ReadFile(Filename.c_str(), aiProcess_Triangulate | aiProcess_GenSmoothNormals | aiProcess_FlipUVs);
    if (pScene) {
        Ret = InitFromScene(pScene, Filename);
    }
    else {
        printf("Error parsing '%s': '%s'\n", Filename.c_str(), Importer.GetErrorString());
    }
    return Ret;
}

載入mesh從這個函式開始。我們在棧上建立了一個 Assimp::Importer 類的例項,並且呼叫它的讀檔案(ReadFile)方法。這個函式需要兩個引數:模型檔案的完整路徑和一些處理的選項。 Assimp 能對載入的模型執行很多實用的優化。例如,為沒有法線的模型生成法線,優化模型的結構來提高效能等,我們可以根據需要來選擇合適的操作。在這裡我們使用了其提供的三個操作:第一個是 aiProcess_Triangulate ,它將不是由三角組成的模型轉換為基於三角形的網格模型。例如:一個四邊形 mesh 可以通過從其中的每個四邊形生成兩個三角形而被變換為三角形 mesh; 第二個操作是 aiProcess_GenSmoothNormals ,為那些原來不含頂點法線的模型生成頂點法線。記住這些加工方式是非重疊的位掩碼,因此你可以使用或運算來對這些操作進行組合;第三個操作是 aiProcess_FlipUVsv ,沿著 y 軸翻轉紋理座標。你需要根匯入的模型資料來選擇合適的操作。如果 mesh 成功載入,我們獲得一個指向 aiScene 物件的指標。這個物件包含整個模型的內容,模型的不同結構都保持在一個 aiMesh 結構中。接下來我們呼叫 InitFromScene() 函式來初始化 Mesh 物件。

(mesh.cpp:97)
bool Mesh::InitFromScene(const aiScene* pScene, const std::string& Filename)
{
    m_Entries.resize(pScene->mNumMeshes);
    m_Textures.resize(pScene->mNumMaterials);
    // Initialize the meshes in the scene one by one
    for (unsigned int i = 0 ; i < m_Entries.size() ; i++) {
        const aiMesh* paiMesh = pScene->mMeshes[i];
        InitMesh(i, paiMesh);
    }
    return InitMaterials(pScene, Filename);
}

首先我們根據需要用到的 m_Entries 和 m_Textures 數量來為其分配儲存空間,其數目可經由 aiScene 物件中的成員 mNumMeshes 和 mNumMaterials 得到。接下來我們遍歷 aiScene 物件中的 mMeshes 陣列,並挨個兒初始化 m_Entries 例項。最後初始化材質。

(mesh.cpp:111)
void Mesh::InitMesh(unsigned int Index, const aiMesh* paiMesh)
{
    m_Entries[Index].MaterialIndex = paiMesh->mMaterialIndex;
    std::vector Vertices;
    std::vector Indices;
    ...

首先我們記錄下當前 mesh 的材質索引,在渲染過程中將通過它來找到 mesh 對應的正確材質。接下來,我們建立兩個 STL 容器來儲存頂點和索引緩衝器的內容。STL 容器有一個很好的特性:能夠在連續的緩衝區中儲存資料,這使得將資料載入到 OpenGL 快取中變得很容易(使用 glBufferData() 函式)。

(mesh.cpp:118)
    const aiVector3D Zero3D(0.0f, 0.0f, 0.0f);
    for (unsigned int i = 0 ; i < paiMesh->mNumVertices ; i++) {
        const aiVector3D* pPos = &(paiMesh->mVertices[i]);
        const aiVector3D* pNormal = &(paiMesh->mNormals[i]) : &Zero3D;
        const aiVector3D* pTexCoord = paiMesh->HasTextureCoords(0) ? &(paiMesh->mTextureCoords[0][i]) : &Zero3D;
        Vertex v(Vector3f(pPos->x, pPos->y, pPos->z),
                Vector2f(pTexCoord->x, pTexCoord->y),
                Vector3f(pNormal->x, pNormal->y, pNormal->z));
        Vertices.push_back(v);
    }
    ...

這裡我們通過對模型資料的解析將頂點屬性資料依次存放到我們的 Vertices 容器中。我們使用到 aiMesh 類中下面的一些方法:
1. mNumVertices - 頂點數量
2. mVertices - 包含位置屬性的陣列
3. mNormals - 包含頂點法線屬性的陣列
4. mTextureCoords - 包含紋理座標陣列,這是一個二維陣列,因為每個頂點可以擁有多個紋理座標。

因此,總的來說我們有三個相互獨立的陣列,它們囊括了所有我們需要的頂點資訊,我們可以通過這些資訊來構建我們最終的頂點結構體。注意一些模型沒有紋理座標,所以在訪問mTextureCoords陣列之前(可能會引發錯誤),我們應該通過呼叫 HasTextureCoords() 來檢查紋理是否存在。除此之外,一個 mesh 的每個頂點可以包含多個紋理座標。在這章,我們只是簡單地使用其第一個紋理座標。因此 mTextureCoords 陣列(二維的)始終只有第一行的值被訪問。如果紋理座標不存在,我們將這個頂點的紋理座標初始化為 0 向量。

(mesh.cpp:132)
for (unsigned int i = 0 ; i < paiMesh->mNumFaces ; i++) {
        const aiFace& Face = paiMesh->mFaces[i];
        assert(Face.mNumIndices == 3);
        Indices.push_back(Face.mIndices[0]);
        Indices.push_back(Face.mIndices[1]);
        Indices.push_back(Face.mIndices[2]);
    }
...

接下來我們建立索引快取。aiMesh 類的成員 mNumFaces 告訴我們有多少個多邊形,而 mFaces 陣列包含了頂點的索引。首先我們要確保每個多邊形的頂點數都為3(載入模型的時候我們要求進行三角化,但是最好再檢查一下)。然後我們從模型資料中解析出每個面的索引並將其存放到 Indices 容器中。

(mesh.cpp:140)
    m_Entries[Index].Init(Vertices, Indices);
}

最後,我們用頂點和索引向量初始化 MeshEntry 結構體。函式 MeshEntry::Init() 中沒有新內容,所以這裡不再對其進行詳細介紹。它僅僅是使用 glGenBuffer(), glBindBuffer() 和 glBufferData() 來建立和填充頂點快取和索引快取。

(mesh.cpp:143)
bool Mesh::InitMaterials(const aiScene* pScene, const std::string& Filename)
{
    for (unsigned int i = 0 ; i < pScene->mNumMaterials ; i++) {
        const aiMaterial* pMaterial = pScene->mMaterials[i];
       ...

這個函式載入模型所用的所有紋理。在 aiScene 物件中 mNumMaterials 屬性存放材質數量,而 mMaterials 是一個指標陣列,其中的每一個元素都指向一個 aiMaterials 結構體。aiMaterials 結構體十分複雜,但是它通過幾個 API 對其進行了封裝。

(mesh.cpp:165)
        m_Textures[i] = NULL;
        if (pMaterial->GetTextureCount(aiTextureType_DIFFUSE) > 0) {
            aiString Path;
            if (pMaterial->GetTexture(aiTextureType_DIFFUSE, 0, &Path, NULL, NULL, NULL, NULL, NULL) == AI_SUCCESS) {
                std::string FullPath = Dir + "/" + Path.data;
                m_Textures[i] = new Texture(GL_TEXTURE_2D, FullPath.c_str());
                if (!m_Textures[i]->Load()) {
                    printf("Error loading texture '%s'\n", FullPath.c_str());
                    delete m_Textures[i];
                    m_Textures[i] = NULL;
                    Ret = false;
                }
            }
        }
        ...

一個材質可以包含多個的紋理,但並不是所有的紋理都必須包含顏色。例如,一個紋理可以是高度圖、法向圖、位移圖等。因為當前我們針對光照計算的著色器程式只使用一個紋理,所以我們也只關心漫反射紋理。因此,我們使用 aiMaterial::GetTextureCount() 函式檢查有多少漫反射紋理存在。這個函式以紋理型別為引數而返回此特定型別紋理的數目。如果至少存在一個漫反射紋理,我們使用 aiMaterial::GetTexture() 函式來獲取它。這個函式的第一個引數是型別,接下來是紋理索引,然後我們需要一個指向紋理檔案路徑的字串指標。最後有 5 個指標引數允許我們去獲取紋理的各種配置,比如混合因子、全圖模式和紋理操作等。這些是可選的,現在我們忽略它們而只傳遞 NULL。這裡我們假定模型和紋理在同一子目錄中。如果模型的結構比較複雜,你可能需要在別處尋找紋理,那樣的話我們可以像往常一樣建立紋理物件並載入它。

(mesh.cpp:187)
       if (!m_Textures[i]) {
          m_Textures[i] = new Texture(GL_TEXTURE_2D, "./white.png");
          Ret = m_Textures[i]->Load();
       }
    }
    return Ret;
}

上面這一小段程式碼用於處理你在模型載入時遇到的一些問題。有時候一個模型可能並沒有紋理,這樣的話你可能會看不到任何東西,因為如果紋理不存在的話取樣的結果預設為黑色。在這裡當我們遇到這種問題時我們為其載入一個白色的紋理(你將在附件中找到這個紋理)。這將使得所有畫素的基色變為白色,這樣可能看起來不是很好,但是至少你可以看到一些東西。

(mesh.cpp:197)
void Mesh::Render()
{
    glEnableVertexAttribArray(0);
    glEnableVertexAttribArray(1);
    glEnableVertexAttribArray(2);
    for (unsigned int i = 0 ; i < m_Entries.size() ; i++) {
        glBindBuffer(GL_ARRAY_BUFFER, m_Entries[i].VB);
        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 0);
        glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)12);
        glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)20);
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_Entries[i].IB);
        const unsigned int MaterialIndex = m_Entries[i].MaterialIndex;
        if (MaterialIndex < m_Textures.size() && m_Textures[MaterialIndex]) {
            m_Textures[MaterialIndex]->Bind(GL_TEXTURE0);
        }
        glDrawElements(GL_TRIANGLES, m_Entries[i].NumIndices, GL_UNSIGNED_INT, 0);
    }
    glDisableVertexAttribArray(0);
    glDisableVertexAttribArray(1);
    glDisableVertexAttribArray(2);
}

這個函式封裝了 mesh 的渲染,並將其從主函式中分離出來(以前是主函式的一部分)。它遍歷 m_Entries 陣列,將陣列中每個元素。節點的材質索引被用來從 m_Texture 陣列中取出紋理物件,並將這個紋理繫結。最後,執行繪製命令。現在我們有多個已經從檔案中載入進來的 mesh 物件,呼叫 Mesh::Render() 函式一個接一個渲染它們。

glEnable(GL_DEPTH_TEST);

最後我們需要學習的是以前章節略去的。如果你繼續使用上面的程式碼匯入模型並渲染,你的場景將可能出現異常。原因是距離相機較遠的三角形被繪製在距離較近的三角形的上面。為了解決這個問題,我麼需要開啟深度測試,這樣光柵化程式就可以比較螢幕上相同位置存在的畫素的深度優先權。最後被繪製到螢幕上的就是“贏得”深度測試(距離相機較近)的畫素。深度測試預設不開啟,上面的程式碼用於開啟深度測試(這段程式碼在 GLUTBackendRun() 函式中,用於 OpenGl 狀態的初始化)。但是這只是開啟深度測試的第一步。(繼續看下面)

(glut_backend.cpp:73)
glutInitDisplayMode(GLUT_DOUBLE|GLUT_RGBA|GLUT_DEPTH);

這一步是對深度快取的初始化,為了比較兩個畫素的深度,“舊”的畫素必須被儲存起來。出於這個目的,我們建立一個特殊的緩衝過去——深度快取(或者 Z 緩衝器)。深度快取的大小與螢幕尺寸對應,這樣顏色緩衝器裡面的每個畫素在深度緩衝器都有相應的位置。這個位置總是儲存離相機最近的畫素的深度值,用於深度測試時的比較。

(tutorial22.cpp:95)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

最後我們需要做的是在開始渲染新的一幀的時候清除深度快取。如果我們不那樣做,深度快取中將會保留上一幀中各畫素的深度值,並且新一幀畫素的深度將被與上一幀畫素的深度比較。正如所想象的,這將導致最後繪製出來的圖象千奇百怪(自己試試!)。 glClear() 函式接收它需要處理的緩衝器的位掩碼。之前我們只清除了顏色快取,現在也該清除深度快取了。

操作結果

這裡寫圖片描述