操作系統:Windows8.1
顯卡:Nivida GTX965M
開發工具:Visual Studio 2017
Introduction
應用程序現在已經可以渲染紋理3D模型,但是 vertices 頂點和 indices 索引數組中的幾何體不是很有趣。在本章節我們擴展程序,從實際的模型文件沖加載頂點和索引數據,並使圖形卡實際做一些工作。
許多圖形API系列教程中讓讀者在這樣的章節中編寫自己的OBJ加載程序。這樣做的問題是任何有趣的3D應用程序很快需要某種功能,但是該文件格式不支持,比如骨骼動畫 skeletal animation。我們將在本章加載 OBJ 模型文件的網格數據,但是我們更多關注在網格數據與程序本身進行整合,而不是從文件中加載它們的細節。
Library
我們將使用 tinyobjloader 庫來從OBJ文件中加載vertices和faces數據。它很快速,容易集成,因為它是一個單獨的文件庫,如stb_image。轉到上面鏈接的庫地址,並將 tiny_obj_loader.h 頭文件下載到庫目錄中的文件夾中。
Visual Studio
因為之前已經在VS中設置了引用的庫目錄在解決方案的根目錄下,所以我們直接在庫目錄新建 tinyobjloader目錄 存放 tiny_obj_loader.h 頭文件即可使用。下圖示例:
Sample mesh
在本章中我們不會涉及光照,所以它有助於使用具有烘培到紋理中的光照的樣本模型。找到這樣的模型的簡單方法是直接在 Sketchfab 上查找。該網站上的許多模型都具有OBJ格式,並且都有 lisence 授權許可。
在本教程中我們決定使用 Chalet Hippolyte Chassande Baroz 模型,它是由Escadrone制作並授權的。我調整了模型的大小和方向,將其用作當前幾何體的替代品:
它有50W個三角形,所以它是我們的應用程序的一個很好的基准。在這里隨意使用自己的模型文件,但是要確保它們是由一種材質構成的,尺寸約為1.5 x 1.5 x 1.5 單位。如果大於此值,則必須修改視圖矩陣。將模型文件放在 shaders 和 textures 同級的新模型目錄中 models,並將紋理貼圖放在 textures 目錄中。
添加兩個新的配置變量到程序中,用於定義模型和貼圖的路徑:
const int WIDTH = 800; const int HEIGHT = 600; const std::string MODEL_PATH = "models/chalet.obj"; const std::string TEXTURE_PATH = "textures/chalet.jpg";
並且更新 createTextureImage 使用該變量:
stbi_uc* pixels = stbi_load(TEXTURE_PATH.c_str(), &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);
Loading vertices and indices
現在我們將要從模型文件中加載頂點和索引數據,所以應該移除全局的 vertices 和 indices 數組。將它們作為類成員替換為非常量容器:
std::vector<Vertex> vertices; std::vector<uint32_t> indices; VkBuffer vertexBuffer; VkDeviceMemory vertexBufferMemory;
在這里應該修改索引數據類型 uint16_t 到 uint32_t 。因為將會有超過65535個或者更多的頂點。還需要更改 vkCmdBindIndexBuffer 參數:
vkCmdBindIndexBuffer(commandBuffers[i], indexBuffer, 0, VK_INDEX_TYPE_UINT32);
tinyobjloader庫與STB庫一樣。包括 tiny_obj_loader.h 文件,並確保在一個源文件中定義 TINYOBJLOADER_IMPLEMENTATION 以包含函數體,並避免鏈接錯誤:
#define TINYOBJLOADER_IMPLEMENTATION #include <tiny_obj_loader.h>
我們現在編寫一個 loadModel 函數,該函數使用這個庫來填充頂點 vertices 和索引 indices 容器,其中包含網格中的頂點數據。在創建頂點和索引緩沖區之前應該調用它:
void initVulkan() { ... loadModel(); createVertexBuffer(); createIndexBuffer(); ... } ... void loadModel() { }
模型加載后被封裝到庫的數據結構中,通過調用 tinyobj::LoadObj 函數完成。
void loadModel() { tinyobj::attrib_t attrib; std::vector<tinyobj::shape_t> shapes; std::vector<tinyobj::material_t> materials; std::string err; if (!tinyobj::LoadObj(&attrib, &shapes, &materials, &err, MODEL_PATH.c_str())) { throw std::runtime_error(err); } }
OBJ文件由positions, normals, texture uvs 和 faces組成,其中每個頂點指向一個位置,通過索引指向 法線或者紋理坐標。這使得不僅可以重復使用整個頂點,還可以具有單獨的屬性。
attrib 容器持有所有的 positions, normals 和 texture uvs 在它的 attrib.vertices, attrib.normals 和 attrib.texcoords 容器中。 shapes 容器包含所有單獨的對象和面。每個面由一組頂點組成,每個頂點包含 positions,normals 和 texture uvs 對應的 indices。OBJ模型也可以定義每個面的材質和紋理,但是我們忽略它們。
err 字符串包含了加載文件過程中產生的錯誤和警告信息,比如缺少材質的定義。如果 LoadObj 函數返回 false,則加載才算真的失敗。如上所述,OBJ 問及愛你中的面可以包含任意數量的頂點,而我們的應用程序只能渲染三角形。幸運的是, LoadObj 有一個可選參數來自動對這些面進行三角測量,這是默認啟用的。
我們將組合所有的面到一個單獨的模型中,所以遍歷所有的形狀:
for (const auto& shape : shapes) { }
三角測量功能已經確保每個面都有三個頂點,所以我們現在可以直接迭代頂點將它們直接存儲到我們的 vertices 向量中:
for (const auto& shape : shapes) { for (const auto& index : shape.mesh.indices) { Vertex vertex = {}; vertices.push_back(vertex); indices.push_back(indices.size()); } }
為了簡單起見,我們假設每個頂點現在是唯一的,因此簡單的自動遞增索引。 index 變量是 tinyobj::index_t 類型的,包含了 vertex_index, normal_index 和 texcoord_index 成員。我們需要使用這些索引從 attrib 數組中 查找實際的頂點屬性:
vertex.pos = { attrib.vertices[3 * index.vertex_index + 0], attrib.vertices[3 * index.vertex_index + 1], attrib.vertices[3 * index.vertex_index + 2] }; vertex.texCoord = { attrib.texcoords[2 * index.texcoord_index + 0], attrib.texcoords[2 * index.texcoord_index + 1] }; vertex.color = {1.0f, 1.0f, 1.0f};
遺憾的是, attrib.vertices 數組是一個 float 數組,而不是glm::vec3,所以需要將索引乘以 3 。相似的,每個條目有兩個紋理坐標分量。 0,1,2的偏移用於訪問X,Y和Z分量,或者在紋理坐標的情況下訪問U和V分量。
運行程序,啟動優化(例如Visual studio中的 Relase,以及GCC的 -O3 編譯器標志),這是必要的,否則加載模型會很慢,你會看到如下內容:
很好,看起來幾何圖形是正確的,但是紋理貼圖發生了什么?這個問題是由於Vulkan的紋理坐標的起點是左上角,而OBJ格式則是左下角。通過反轉紋理坐標的垂直分量來解決這個問題:
vertex.texCoord = { attrib.texcoords[2 * index.texcoord_index + 0], 1.0f - attrib.texcoords[2 * index.texcoord_index + 1] };
再次運行程序看到如下正確結果:
所有這些艱苦的工作終於開始通過這樣的演示得到回報!
Vertex deduplication
遺憾的是我們並沒有真正利用索引緩沖區的優勢。 vertices 向量包含大量重復的頂點數據,因為許多頂點包含在多個三角形中。我們應該只保留唯一的頂點數據,並使用索引緩沖區來重新使用它們。實現這一點的直接方法是使用 map 或者 unordered_map 來跟蹤唯一的頂點和相應的索引信息:
#include <unordered_map> ... std::unordered_map<Vertex, uint32_t> uniqueVertices = {}; for (const auto& shape : shapes) { for (const auto& index : shape.mesh.indices) { Vertex vertex = {}; ... if (uniqueVertices.count(vertex) == 0) { uniqueVertices[vertex] = static_cast<uint32_t>(vertices.size()); vertices.push_back(vertex); } indices.push_back(uniqueVertices[vertex]); } }
每次從OBJ文件中讀取頂點時,我們檢查一下是否已經看到一個具有相同位置和紋理坐標的頂點。如果沒有,我們將其添加到 vertices 並將其索引存儲在 uniqueVertices 容器中。之后,我們將新的頂點的索引添加到索引容器中。如果我們已經看到完全相同的頂點,那么我們在 uniqueVertices 中查找其索引,並將該索引存儲在 indices 中。
程序將會編譯錯誤,因為使用類似我們的 Vertex 結構體,它是自定義類型作為哈希表中的鍵,因為需要實現兩個功能:燈飾測試和散列值計算。前者通過覆蓋 Vertex 結構中的 == 運算符很容易實現:
bool operator==(const Vertex& other) const { return pos == other.pos && color == other.color && texCoord == other.texCoord; }
通過為 std::hash<T> 指定模版專門來實現 Vertex 的哈希函數。散列函數是一個復雜的主題,但 cppreference.com 建議采用以下方法組合結構體的字段來創建質量比較高的散列函數:
namespace std { template<> struct hash<Vertex> { size_t operator()(Vertex const& vertex) const { return ((hash<glm::vec3>()(vertex.pos) ^ (hash<glm::vec3>()(vertex.color) << 1)) >> 1) ^ (hash<glm::vec2>()(vertex.texCoord) << 1); } }; }
該代碼應該放置在 Vertex 結構體之外。需要使用以下頭文件來包含GLM類型的哈希函數:
#include <glm/gtx/hash.hpp>
現在應該能夠成功編譯和運行程序。如果檢查 vertices 頂點數量,會發現它已經從 1,500,000 縮小到 265,645!這意味着每個頂點以平均被 大約6個三角形重新使用。這絕對會為我們節省很多GPU內存。
Conclusion
到目前為止,已經做了很多工作,但是現在你終於有了一個很好的基礎。現在擁有的Vulkan的基本原理的只是應該足以探索更多的更能,諸如:
- Push constants
- Instanced rendering
- Dynamic uniforms
- Separate images and sampler descriptors
- Pipeline cache
- Multi-threaded command buffer generation
- Multi subpasses
- Compute shaders
現在的程序有很多方式進行擴展,比如添加 Blinn-Phong lighting,post-processing效果和陰影映射。你應該能夠了解這些效果如何從其他的API來完成,盡管因為Vulkan的明確性,但是許多概念是相同的。
項目代碼 GitHub 地址。