Vulkan Tutorial 9 模型加载&Mipmaps

目录

28 加载模型

Sample mesh

加载顶点和索引

​编辑

顶点去重

29 生成Mipmaps

创建图像

生成Mipmaps

支持线性滤波

采样器


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目录下,放在shaderstextures旁边,并把纹理图像放在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);

加载顶点和索引

我们现在要从模型文件中加载顶点和索引,所以你现在应该删除全局的verticesindices数组。用非const容器代替它们作为类成员:

std::vector vertices;
//把索引的类型从uint16_t改为uint32_t,因为顶点会比65535多很多。
std::vector indices;
VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;

//改变vkCmdBindIndexBuffer参数:

vkCmdBindIndexBuffer(commandBuffers[i], indexBuffer, 0, VK_INDEX_TYPE_UINT32);

//在一个源文件中定义TINYOBJLOADER_IMPLEMENTATION以包括函数体并避免链接器错误:

#define TINYOBJLOADER_IMPLEMENTATION
#include 

现在要写一个loadModel函数,使用这个库将网格中的顶点数据填充到verticesindices容器中。它应该在创建顶点和索引缓冲区之前被调用: attrib容器在其attrib.verticesattrib.normalsattrib.texcoords向量中保存所有的位置、法线和纹理坐标。

void loadModel() {
//OBJ文件由位置、法线、纹理坐标和面组成。面由任意数量的顶点组成,
//其中每个顶点通过索引指向一个位置、法线和/或纹理坐标
    tinyobj::attrib_t attrib;
    std::vector shapes;
    std::vector 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编译器标志)。这是必要的,因为否则加载模型会非常慢。你应该看到类似以下的情况:\

Vulkan Tutorial 9 模型加载&Mipmaps_第1张图片

几何图形看起来很正确,但是纹理是怎么回事?OBJ格式假定了一个坐标系统,其中垂直坐标为0意味着图像的底部,然而我们已经将我们的图像以从上到下的方向上传到了Vulkan,其中0意味着图像的顶部。通过翻转纹理坐标的垂直部分来解决这个问题:

vertex.texCoord = {
    attrib.texcoords[2 * index.texcoord_index + 0],
    1.0f - attrib.texcoords[2 * index.texcoord_index + 1]
};

Vulkan Tutorial 9 模型加载&Mipmaps_第2张图片

顶点去重

不幸的是,我们还没有真正利用好索引缓冲区的优势。vertices向量包含很多重复的顶点数据,因为很多顶点被包含在多个三角形中。我们应该只保留唯一的顶点,并在它们出现的时候使用索引缓冲区来重用它们。实现这一目标的直接方法是使用mapunordered_map来跟踪唯一顶点和各自的索引:

#include 

...

std::unordered_map 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(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 {
        size_t operator()(Vertex const& vertex) const {
            return ((hash()(vertex.pos) ^
                   (hash()(vertex.color) << 1)) >> 1) ^
                   (hash()(vertex.texCoord) << 1);
        }
    };
}

//哈希函数是在gtx文件夹中定义的,这意味着它在技术上仍然是GLM的一个实验性扩展。
//因此你需要定义GLM_ENABLE_EXPERIMENTAL来使用它。
#define GLM_ENABLE_EXPERIMENTAL
#include 

 现在你应该能够成功地编译和运行你的程序。如果你检查一下`顶点’的大小:

Vulkan Tutorial 9 模型加载&Mipmaps_第3张图片

29 生成Mipmaps

我们的程序现在可以加载和渲染3D模型。在本章中,我们将增加一个功能,即mipmap生成。Mipmaps在游戏和渲染软件中被广泛使用,而Vulkan让我们可以完全控制它们的创建方式。

Mipmaps是预先计算好的、缩小了的图像版本。每张新图像的宽度和高度都是前一张的一半。Mipmaps被用作Level of DetailLOD的一种形式。离摄像机较远的物体将从较小的Mip图像中提取其纹理。使用较小的图像可以提高渲染速度,并避免诸如摩尔纹这样的伪影。一个关于mipmaps外观的例子:

Vulkan Tutorial 9 模型加载&Mipmaps_第4张图片

创建图像

在Vulkan中,每个mip图像被存储在一个VkImage的不同mip级别中。0级是原始图像,而0级之后的mip级通常被称为mip chain。在创建VkImage时,可以指定mip级的数量。到现在为止,我们一直将这个值设置为1。我们需要根据图像的尺寸来计算mip层的数量。首先,添加一个类成员来存储这个数字:

...
uint32_t mipLevels;
VkImage textureImage;
...

//一旦我们在createTextureImage中加载了纹理,就可以找到mipLevels的值:

mipLevels = static_cast(std::floor(std::log2(std::max(texWidth, texHeight)))) + 1;

这计算了mip链的层数。max函数选择最大的维度。log2函数计算该维度可以被2除以多少倍。floor函数处理最大维度不是2的幂的情况。1被添加,以便原始图像有一个mip级别。

为了使用这个值,我们需要改变createImagecreateImageViewtransitionImageLayout函数,以允许我们指定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);

//vkCmdBlitImage取决于它所操作的图像的布局
//为了获得最佳性能,源图像应该在VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL中
//目标图像应该在VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL中

 Vulkan允许我们对图像的每个mip级别进行独立过渡。每个blit一次只处理两个mip层,所以我们可以在blit命令之间将每个层过渡到最佳布局。

//transitionImageLayout只对整个图像进行布局转换,所以我们需要多写几个管道屏障命令。
//在createTextureImage中删除现有的过渡到VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, mipLevels);
    copyBufferToImage(stagingBuffer, textureImage, static_cast(texWidth), static_cast(texHeight));
//transitioned to VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL while generating mipmaps

//这将使纹理图像的每个层次处于VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL。
//在blit命令读完之后,每一层都将过渡到VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL。

现在要写的是生成mipmaps的函数:

void generateMipmaps(VkImage image, int32_t texWidth, int32_t texHeight, uint32_t mipLevels) {
    VkCommandBuffer commandBuffer = beginSingleTimeCommands();

    VkImageMemoryBarrier barrier{};
    barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
    barrier.image = image;
    barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
    barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
    barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
    barrier.subresourceRange.baseArrayLayer = 0;
    barrier.subresourceRange.layerCount = 1;
    barrier.subresourceRange.levelCount = 1;
    
//我们将进行多次转换,所以我们将重复使用这个VkImageMemoryBarrier。
//上面设置的字段对所有屏障都将保持不变。
//subresourceRange.miplevel、oldLayout、newLayout、srcAccessMask和dstAccessMask将在每次转换中改变。

int32_t mipWidth = texWidth;
int32_t mipHeight = texHeight;

for (uint32_t i = 1; i < mipLevels; i++) {
   // 这个循环将记录每个VkCmdBlitImage命令。注意,循环变量从1开始,而不是0。

barrier.subresourceRange.baseMipLevel = i - 1;
barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;

vkCmdPipelineBarrier(commandBuffer,
    VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0,
    0, nullptr,
    0, nullptr,
    1, &barrier);
}

    endSingleTimeCommands(commandBuffer);
}

首先,我们将级别i - 1过渡到VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL。这个转换将等待级别i - 1被填充,可以是前一个blit命令,也可以是vkCmdCopyBufferToImage。当前的blit命令将在这个过渡中等待。

VkImageBlit blit{};
blit.srcOffsets[0] = { 0, 0, 0 };
blit.srcOffsets[1] = { mipWidth, mipHeight, 1 };
blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.srcSubresource.mipLevel = i - 1;
blit.srcSubresource.baseArrayLayer = 0;
blit.srcSubresource.layerCount = 1;
blit.dstOffsets[0] = { 0, 0, 0 };
blit.dstOffsets[1] = { mipWidth > 1 ? mipWidth / 2 : 1, mipHeight > 1 ? mipHeight / 2 : 1, 1 };
blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.dstSubresource.mipLevel = i;
blit.dstSubresource.baseArrayLayer = 0;
blit.dstSubresource.layerCount = 1;

接下来,我们指定将在blit操作中使用的区域。源mip级别是i - 1,目标mip级别是isrcOffsets数组的两个元素决定了数据将被混合的3D区域。dstOffsets决定了数据将被混入的区域。dstOffsets[1]的X和Y尺寸被除以2,因为每个mip层是上一层的一半。srcOffsets[1]dstOffsets[1]的Z尺寸必须是1,因为2D图像的深度是1。

vkCmdBlitImage(commandBuffer,
    image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
    image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
    1, &blit,
    VK_FILTER_LINEAR);
//最后一个参数允许我们指定一个VkFilter,以便在blit中使用。
//我们在这里有和制作VkSampler时一样的过滤选项。我们使用VK_FILTER_LINEAR来启用插值。

barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
barrier.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;

vkCmdPipelineBarrier(commandBuffer,
    VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0,
    0, nullptr,
    0, nullptr,
    1, &barrier);

//这个屏障将mip级别i - 1过渡到VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL。
//这个转换会等待当前blit命令的完成。所有的采样操作都将等待这个过渡完成。
 if (mipWidth > 1) mipWidth /= 2;
    if (mipHeight > 1) mipHeight /= 2;
//在循环的最后,我们将当前的mip尺寸除以2。我们在除法之前检查每个维度,以确保该维度不会变成0。
//这可以处理图像不是正方形的情况,因为其中一个mip维度会在另一个维度之前达到1。当这种情况发生时,该维度在所有剩余的层次中应该保持为1。

 在我们结束命令缓冲区之前,我们再插入一个管道障碍。这个屏障将最后一个mip层从VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL过渡到VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL

    barrier.subresourceRange.baseMipLevel = mipLevels - 1;
    barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
    barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
    barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
    barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;

    vkCmdPipelineBarrier(commandBuffer,
        VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0,
        0, nullptr,
        0, nullptr,
        1, &barrier);

    endSingleTimeCommands(commandBuffer);
}

//最后,在createTextureImage中添加对generateMipmaps的调用:
generateMipmaps(textureImage, texWidth, texHeight, mipLevels);

支持线性滤波

使用像vkCmdBlitImage这样的内置函数来生成所有的mip级别是非常方便的,但不幸的是,它不能保证在所有平台上都被支持。它要求我们使用的纹理图像格式支持线性过滤,这可以用vkGetPhysicalDeviceFormatProperties函数检查。我们将为此在generateMipmaps函数中添加一个检查。

void generateMipmaps(VkImage image, VkFormat imageFormat, int32_t texWidth, int32_t texHeight, uint32_t mipLevels) {

    // Check if image format supports linear blitting
    VkFormatProperties formatProperties;
    vkGetPhysicalDeviceFormatProperties(physicalDevice, imageFormat, &formatProperties);
//VkFormatProperties结构有三个字段,分别是linearTilingFeatures、optimalTilingFeatures和bufferFeatures,
//它们分别描述了该格式的使用方式,取决于它的使用方式。
//我们用最佳平铺格式创建纹理图像,所以我们需要检查optimalTilingFeatures。
if (!(formatProperties.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT)) 
{
    throw std::runtime_error("texture image format does not support linear blitting!");
}
    ...

应该注意的是,在实践中,在运行时生成mipmap级别是不常见的。通常,它们是预先生成的,并与基本级别一起存储在纹理文件中,以提高加载速度。

采样器

虽然VkImage持有mipmap数据,但VkSampler控制渲染时如何读取这些数据。Vulkan允许我们指定 “minLod”、“maxLod”、“mipLodBias”和 “mipmapMode”(“Lod”是指 “细节级别”)。当对纹理进行采样时,采样器会根据以下伪码选择mip级别:

lod = getLodLevelFromScreenSize(); //smaller when the object is close, may be negative
lod = clamp(lod + mipLodBias, minLod, maxLod);

level = clamp(floor(lod), 0, texture.mipLevels - 1);  //clamped to the number of mip levels in the texture

if (mipmapMode == VK_SAMPLER_MIPMAP_MODE_NEAREST) {
    color = sample(level);
} else {
    color = blend(sample(level), sample(level + 1));
}

//采样操作也受到`lod’的影响:
//如果物体靠近相机,则使用magFilter作为过滤器。如果物体离摄像机较远,则使用minFilter。
//通常情况下,lod是非负值,并且只有在靠近相机时才为0。
//mipLodBias让我们强制Vulkan使用比它通常使用的更低的lod和level。
if (lod <= 0) {
    color = readTexture(uv, magFilter);
} else {
    color = readTexture(uv, minFilter);
}

如果samplerInfo.mipmapModeVK_SAMPLER_MIPMAP_MODE_NEARESTlod会选择要采样的mip层。如果mipmap模式是VK_SAMPLER_MIPMAP_MODE_LINEARlod用于选择两个要采样的mip级别。这些级别被采样,结果被线性混合。

void createTextureSampler() {
    ...
    samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
    samplerInfo.minLod = 0.0f; // Optional
//为了允许使用全部的mip级别,我们将minLod设为0.0f,maxLod设为mip级别的数量。
//我们没有理由改变lod值,所以我们将mipLodBias设置为0.0f。
    samplerInfo.maxLod = static_cast(mipLevels);
    samplerInfo.mipLodBias = 0.0f; // Optional
    ...
}

现在运行你的程序,你应该看到以下情况:

Vulkan Tutorial 9 模型加载&Mipmaps_第5张图片 有mipmap Vulkan Tutorial 9 模型加载&Mipmaps_第6张图片 无mipmap

最明显的区别是纸张上的文字。有了mipmaps,字迹就被磨平了。如果没有mipmaps,文字的边缘很粗糙,而且有摩尔纹伪影的空隙。

你可以玩玩采样器的设置,看看它们如何影响mipmapping。例如,通过改变`minLod’,你可以强迫采样器不使用最低的mip级别:这就是当物体离摄像机较远时,较高的mip水平将被使用。

samplerInfo.minLod = static_cast(mipLevels / 2);

这些设置将产生这样的图像:

Vulkan Tutorial 9 模型加载&Mipmaps_第7张图片

 

你可能感兴趣的:(vulkan,c++,图形渲染)