目录
28 加载模型
Sample mesh
加载顶点和索引
编辑
顶点去重
28 加载模型
我们将使用tinyobjloader库来从OBJ文件中加载顶点和面。它的速度很快,而且很容易集成,因为它是一个像stb_image一样的单文件库。将包含tiny_obj_loader.h
的目录添加到Additional Include Directories
路径中。
Sample mesh
在这一章中,我们还不会启用灯光,所以使用一个将灯光烘烤到纹理中的样本模型会有帮助。找到这种模型的一个简单方法是在Sketchfab上寻找3D扫描。该网站上的许多模型都是以OBJ格式提供的,并有许可权。
在这个教程中,我决定使用nigelgoh (CC BY 4.0)的Viking room模型。我调整了该模型的大小和方向,将其作为当前几何形状的替代品。
- viking_room.obj
- viking_room.png
模型文件放在一个新的models
目录下,放在shaders
和textures
旁边,并把纹理图像放在textures
目录下。 在你的程序中放两个新的配置变量来定义模型和纹理的路径。
const std::string MODEL_PATH = "models/viking_room.obj";
const std::string TEXTURE_PATH = "textures/viking_room.png";
stbi_uc* pixels = stbi_load(TEXTURE_PATH.c_str(), &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);
加载顶点和索引
我们现在要从模型文件中加载顶点和索引,所以你现在应该删除全局的vertices
和indices
数组。用非const容器代替它们作为类成员:
std::vector<Vertex> vertices;
//把索引的类型从uint16_t改为uint32_t,因为顶点会比65535多很多。
std::vector<uint32_t> indices;
VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;
//改变vkCmdBindIndexBuffer参数:
vkCmdBindIndexBuffer(commandBuffers[i], indexBuffer, 0, VK_INDEX_TYPE_UINT32);
//在一个源文件中定义TINYOBJLOADER_IMPLEMENTATION以包括函数体并避免链接器错误:
#define TINYOBJLOADER_IMPLEMENTATION
#include <tiny_obj_loader.h>
现在要写一个loadModel
函数,使用这个库将网格中的顶点数据填充到vertices
和indices
容器中。它应该在创建顶点和索引缓冲区之前被调用: attrib
容器在其attrib.vertices
、attrib.normals
和attrib.texcoords
向量中保存所有的位置、法线和纹理坐标。
void loadModel() {
//OBJ文件由位置、法线、纹理坐标和面组成。面由任意数量的顶点组成,
//其中每个顶点通过索引指向一个位置、法线和/或纹理坐标
tinyobj::attrib_t attrib;
std::vector<tinyobj::shape_t> shapes;
std::vector<tinyobj::material_t> materials;
std::string warn, err;//err字符串包含错误,warn字符串包含加载文件时出现的警告,比如缺少材质定义。
if (!tinyobj::LoadObj(&attrib, &shapes, &materials, &warn, &err, MODEL_PATH.c_str())) {
throw std::runtime_error(warn + err);
}
//我们将把文件中的所有面合并成一个单一的模型,所以只需遍历所有的形状:
for (const auto& shape : shapes) {
//三角化功能已经确保了每个面有三个顶点,所以我们现在可以直接迭代顶点,并将它们直接转入我们的vertices向量
for (const auto& index : shape.mesh.indices) {
Vertex vertex{};
vertices.push_back(vertex);
//为了简单起见,我们将假设每个顶点都是唯一的,因此指数是简单的自动递增。
//index变量的类型是tinyobj::index_t,它包含vertex_index、normal_index和texcoord_index成员。
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]
};
//attrib.vertices数组是一个float值的数组,而不是像glm::vec3那样,所以你需要将索引乘以3。
//同样地,每个条目有两个纹理坐标组件。
//0、1和2的偏移量用来访问X、Y和Z分量,或者在纹理坐标的情况下访问U和V分量。
vertex.color = {1.0f, 1.0f, 1.0f};
indices.push_back(indices.size());
}
}
}
现在运行你的程序并启用优化功能(例如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{};
...
//每次我们从OBJ文件中读取一个顶点时,我们都会检查我们之前是否已经看到过一个位置和纹理坐标完全相同的顶点。
//如果没有,我们将其添加到vertices中,并将其索引存储在uniqueVertices容器中
if (uniqueVertices.count(vertex) == 0) {
uniqueVertices[vertex] = static_cast<uint32_t>(vertices.size());
vertices.push_back(vertex);
}
indices.push_back(uniqueVertices[vertex]);
}
}
现在程序将无法编译,因为使用用户定义的类型,如我们的Vertex
结构作为哈希表的键,需要我们实现两个函数:平等测试和哈希计算。前者可以通过覆盖Vertex
结构中的==
操作符来轻松实现:
bool operator==(const Vertex& other) const {
return pos == other.pos && color == other.color && texCoord == other.texCoord;
}
//Vertex的哈希函数是通过指定std::hash的模板专业化实现的。
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);
}
};
}
//哈希函数是在gtx文件夹中定义的,这意味着它在技术上仍然是GLM的一个实验性扩展。
//因此你需要定义GLM_ENABLE_EXPERIMENTAL来使用它。
#define GLM_ENABLE_EXPERIMENTAL
#include <glm/gtx/hash.hpp>
现在你应该能够成功地编译和运行你的程序。如果你检查一下`顶点’的大小:
29 生成Mipmaps
我们的程序现在可以加载和渲染3D模型。在本章中,我们将增加一个功能,即mipmap生成。Mipmaps在游戏和渲染软件中被广泛使用,而Vulkan让我们可以完全控制它们的创建方式。
Mipmaps是预先计算好的、缩小了的图像版本。每张新图像的宽度和高度都是前一张的一半。Mipmaps被用作Level of Detail或LOD的一种形式。离摄像机较远的物体将从较小的Mip图像中提取其纹理。使用较小的图像可以提高渲染速度,并避免诸如摩尔纹这样的伪影。一个关于mipmaps外观的例子:
创建图像
在Vulkan中,每个mip图像被存储在一个VkImage的不同mip级别中。0级是原始图像,而0级之后的mip级通常被称为mip chain。在创建VkImage时,可以指定mip级的数量。到现在为止,我们一直将这个值设置为1。我们需要根据图像的尺寸来计算mip层的数量。首先,添加一个类成员来存储这个数字:
...
uint32_t mipLevels;
VkImage textureImage;
...
//一旦我们在createTextureImage中加载了纹理,就可以找到mipLevels的值:
mipLevels = static_cast<uint32_t>(std::floor(std::log2(std::max(texWidth, texHeight)))) + 1;
这计算了mip链的层数。max
函数选择最大的维度。log2
函数计算该维度可以被2除以多少倍。floor
函数处理最大维度不是2的幂的情况。1
被添加,以便原始图像有一个mip级别。
为了使用这个值,我们需要改变createImage
、createImageView
和transitionImageLayout
函数,以允许我们指定mip层的数量。在这些函数中添加一个mipLevels
参数:
void createImage(uint32_t width, uint32_t height, uint32_t mipLevels, VkFormat format, VkImageTiling tiling, VkImageUsageFlags usage, VkMemoryPropertyFlags properties, VkImage& image, VkDeviceMemory& imageMemory) {
...
imageInfo.mipLevels = mipLevels;
...
}
VkImageView createImageView(VkImage image, VkFormat format, VkImageAspectFlags aspectFlags, uint32_t mipLevels) {
...
viewInfo.subresourceRange.levelCount = mipLevels;
...
void transitionImageLayout(VkImage image, VkFormat format, VkImageLayout oldLayout, VkImageLayout newLayout, uint32_t mipLevels) {
...
barrier.subresourceRange.levelCount = mipLevels;
...
//更新对这些函数的所有调用,以使用正确的值:
createImage(swapChainExtent.width, swapChainExtent.height, 1, depthFormat, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, depthImage, depthImageMemory);
...
createImage(texWidth, texHeight, mipLevels, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, textureImage, textureImageMemory);
swapChainImageViews[i] = createImageView(swapChainImages[i], swapChainImageFormat, VK_IMAGE_ASPECT_COLOR_BIT, 1);
...
depthImageView = createImageView(depthImage, depthFormat, VK_IMAGE_ASPECT_DEPTH_BIT, 1);
...
textureImageView = createImageView(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_ASPECT_COLOR_BIT, mipLevels);
transitionImageLayout(depthImage, depthFormat, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, 1);
...
transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, mipLevels);
生成Mipmaps
我们的纹理图像现在有多个mip级别,但是暂存缓冲区只能用来填充mip级别0。其他级别仍然没有定义。为了填充这些层次,我们需要从我们拥有的单一层次中生成数据。我们将使用vkCmdBlitImage命令。这个命令执行复制、缩放和过滤操作。我们将多次调用这个命令,以blit数据到我们纹理图像的每一层。
createImage(texWidth, texHeight, mipLevels, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, textureImage, textureImageMemory);