加载模型
介绍
你的程序现在已经准备好渲染带纹理的 3D 网格,但是 vertices
和 indices
数组中当前的几何体还不是很有趣。在本章中,我们将扩展程序以从实际模型文件中加载顶点和索引,以使显卡真正开始工作。
许多图形 API 教程都会让读者在本章中编写自己的 OBJ 加载器。这样做的问题是,任何稍微有趣的 3D 应用程序很快就会需要此文件格式不支持的功能,例如骨骼动画。我们将在本章中从 OBJ 模型加载网格数据,但我们将更多地关注将网格数据与程序本身集成,而不是从文件加载它的细节。
库
我们将使用 tinyobjloader 库从 OBJ 文件加载顶点和面。它速度快且易于集成,因为它是一个像 stb_image 这样的单文件库。转到上面链接的存储库,并将 tiny_obj_loader.h
文件下载到库目录中的文件夹中。
Visual Studio
将包含 tiny_obj_loader.h
的目录添加到 Additional Include Directories
路径中。
Makefile
将包含 tiny_obj_loader.h
的目录添加到 GCC 的包含目录中
VULKAN_SDK_PATH = /home/user/VulkanSDK/x.x.x.x/x86_64
STB_INCLUDE_PATH = /home/user/libraries/stb
TINYOBJ_INCLUDE_PATH = /home/user/libraries/tinyobjloader
...
CFLAGS = -std=c++17 -I$(VULKAN_SDK_PATH)/include -I$(STB_INCLUDE_PATH) -I$(TINYOBJ_INCLUDE_PATH)
示例网格
在本章中,我们尚不启用光照,因此使用将光照烘焙到纹理中的示例模型很有帮助。查找此类模型的简便方法是在 Sketchfab 上查找 3D 扫描。该网站上的许多模型都以 OBJ 格式提供,并具有宽松的许可证。
对于本教程,我决定使用 Viking room 模型,作者是 nigelgoh (CC BY 4.0)。我调整了模型的大小和方向,以将其用作当前几何体的直接替代品
欢迎使用您自己的模型,但请确保它仅包含一种材质,并且尺寸约为 1.5 x 1.5 x 1.5 单位。如果它大于此尺寸,则您必须更改视图矩阵。将模型文件放在 shaders
和 textures
旁边的新的 models
目录中,并将纹理图像放在 textures
目录中。
在您的程序中放置两个新的配置变量,以定义模型和纹理路径
const uint32_t WIDTH = 800;
const uint32_t HEIGHT = 600;
const std::string MODEL_PATH = "models/viking_room.obj";
const std::string TEXTURE_PATH = "textures/viking_room.png";
并更新 createTextureImage
以使用此路径变量
stbi_uc* pixels = stbi_load(TEXTURE_PATH.c_str(), &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);
加载顶点和索引
我们现在要从模型文件加载顶点和索引,因此您现在应该删除全局 vertices
和 indices
数组。将它们替换为作为类成员的非 const 容器
std::vector<Vertex> vertices;
std::vector<uint32_t> indices;
VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;
您应该将索引的类型从 uint16_t
更改为 uint32_t
,因为顶点数量将远多于 65535。请记住还要更改 vkCmdBindIndexBuffer
参数
vkCmdBindIndexBuffer(commandBuffer, 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 warn, err;
if (!tinyobj::LoadObj(&attrib, &shapes, &materials, &warn, &err, MODEL_PATH.c_str())) {
throw std::runtime_error(warn + err);
}
}
OBJ 文件由位置、法线、纹理坐标和面组成。面由任意数量的顶点组成,其中每个顶点通过索引引用位置、法线和/或纹理坐标。这使得不仅可以重用整个顶点,还可以重用单个属性。
attrib
容器在其 attrib.vertices
、attrib.normals
和 attrib.texcoords
向量中保存所有位置、法线和纹理坐标。shapes
容器包含所有单独的对象及其面。每个面都由一个顶点数组组成,并且每个顶点都包含位置、法线和纹理坐标属性的索引。OBJ 模型还可以为每个面定义材质和纹理,但我们将忽略这些。
err
字符串包含错误,warn
字符串包含加载文件时发生的警告,例如缺少材质定义。仅当 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 中的 Release
模式和 GCC 的 -O3
编译器标志)。这是必要的,因为否则加载模型会非常慢。您应该看到类似以下内容
太棒了,几何体看起来是正确的,但是纹理发生了什么?OBJ 格式假定一个坐标系,其中垂直坐标 0
表示图像的底部,但是我们已将图像以上下方向上传到 Vulkan 中,其中 0
表示图像的顶部。通过翻转纹理坐标的垂直分量来解决此问题
vertex.texCoord = {
attrib.texcoords[2 * index.texcoord_index + 0],
1.0f - attrib.texcoords[2 * index.texcoord_index + 1]
};
再次运行程序时,您现在应该看到正确的结果
所有这些辛勤工作终于开始得到回报,看到了这样的演示!
当模型旋转时,您可能会注意到后部(墙壁的背面)看起来有点奇怪。这是正常的,仅仅是因为该模型实际上并非设计为从该侧查看。
顶点去重
不幸的是,我们还没有真正利用索引缓冲区。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
容器中。之后,我们将新顶点的索引添加到 indices
。如果我们之前已经看到完全相同的顶点,那么我们在 uniqueVertices
中查找其索引,并将该索引存储在 indices
中。
程序现在将无法编译,因为在哈希表中使用像我们的 Vertex
结构这样的用户定义类型作为键,需要我们实现两个函数:相等性测试和哈希计算。前者很容易通过重写 Vertex
结构中的 ==
运算符来实现
bool operator==(const Vertex& other) const {
return pos == other.pos && color == other.color && texCoord == other.texCoord;
}
Vertex
的哈希函数通过为 std::hash<T>
指定模板特化来实现。哈希函数是一个复杂的主题,但 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 类型的哈希函数需要使用以下头文件包含
#define GLM_ENABLE_EXPERIMENTAL
#include <glm/gtx/hash.hpp>
哈希函数在 gtx
文件夹中定义,这意味着从技术上讲,它仍然是 GLM 的实验性扩展。因此,您需要定义 GLM_ENABLE_EXPERIMENTAL
才能使用它。这意味着 API 可能会在未来 GLM 的新版本中发生更改,但实际上 API 非常稳定。
您现在应该能够成功编译并运行您的程序。如果您检查 vertices
的大小,您将看到它从 1,500,000 缩小到 265,645!这意味着每个顶点在平均约 6 个三角形中被重用。这绝对为我们节省了大量 GPU 内存。