加载模型
引言
你的程序现在已经可以渲染带纹理的 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 的 include 目录中
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 格式提供,并具有宽松的许可证。
你可以随意使用自己的模型,但请确保它只包含一种材质,并且尺寸约为 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;
}
通过为 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 类型的哈希函数:
#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 内存。
在下一章中,我们将学习一种改进纹理渲染的技术。