生成 Mipmap

介绍

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

Mipmap 是图像预先计算的、缩小比例的版本。每个新图像的宽度和高度是前一个图像的一半。Mipmap 用作一种细节级别LOD。远离相机的对象将从较小的 mip 图像中采样它们的纹理。使用较小的图像可以提高渲染速度,并避免诸如莫尔条纹等伪影。以下是 mipmap 的示例外观

mipmaps example

图像创建

在 Vulkan 中,每个 mip 图像都存储在 VkImage 的不同mip 级别中。Mip 级别 0 是原始图像,级别 0 之后的 mip 级别通常称为mip 链

创建 VkImage 时指定 mip 级别的数量。到目前为止,我们始终将此值设置为 1。我们需要从图像的尺寸计算 mip 级别的数量。首先,添加一个类成员来存储这个数字

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

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

int texWidth, texHeight, texChannels;
stbi_uc* pixels = stbi_load(TEXTURE_PATH.c_str(), &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);
...
mipLevels = static_cast<uint32_t>(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);

生成 Mipmap

我们的纹理图像现在有多个 mip 级别,但暂存缓冲区只能用于填充 mip 级别 0。其他级别仍然未定义。为了填充这些级别,我们需要从我们拥有的单个级别生成数据。我们将使用 vkCmdBlitImage 命令。此命令执行复制、缩放和过滤操作。我们将多次调用此命令,以将数据blit到纹理图像的每个级别。

vkCmdBlitImage 被认为是传输操作,因此我们必须告知 Vulkan,我们打算将纹理图像用作传输的源和目标。将 VK_IMAGE_USAGE_TRANSFER_SRC_BIT 添加到 createTextureImage 中纹理图像的使用标志

...
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_GENERAL,但这很可能会很慢。为了获得最佳性能,源图像应处于 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<uint32_t>(texWidth), static_cast<uint32_t>(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

我们现在要编写生成 mipmap 的函数

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;

    endSingleTimeCommands(commandBuffer);
}

我们将进行多次转换,因此我们将重用此 VkImageMemoryBarrier。上述设置的字段对于所有屏障都保持不变。subresourceRange.mipleveloldLayoutnewLayoutsrcAccessMaskdstAccessMask 将为每次转换而更改。

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);

首先,我们将级别 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 数组的两个元素确定将从其中 blit 数据的 3D 区域。dstOffsets 确定将 blit 数据的区域。由于每个 mip 级别是前一个级别的一半大小,因此 dstOffsets[1] 的 X 和 Y 维度除以 2。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);

现在,我们记录 blit 命令。请注意,textureImage 同时用于 srcImagedstImage 参数。这是因为我们是在同一图像的不同层级之间进行 blit 操作。源 mip 层级刚刚转换为 VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,而目标层级由于 createTextureImage 的操作,仍然处于 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL 状态。

请注意,如果您正在使用专用的传输队列(如顶点缓冲区中所建议的那样):vkCmdBlitImage 必须提交到具有图形功能的队列。

最后一个参数允许我们指定要在 blit 中使用的 VkFilter。这里我们有与创建 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。

    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);
}

在结束命令缓冲区之前,我们插入另一个管道屏障。此屏障将最后一个 mip 层级从 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL 转换为 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL。这没有在循环中处理,因为最后一个 mip 层级永远不会被 blit。

最后,在 createTextureImage 中添加对 generateMipmaps 的调用

transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, mipLevels);
    copyBufferToImage(stagingBuffer, textureImage, static_cast<uint32_t>(texWidth), static_cast<uint32_t>(texHeight));
//transitioned to VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL while generating mipmaps
...
generateMipmaps(textureImage, texWidth, texHeight, mipLevels);

现在,我们的纹理图像的 mipmap 已完全填充。

线性滤波支持

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

首先添加一个指定图像格式的额外参数

void createTextureImage() {
    ...

    generateMipmaps(textureImage, VK_FORMAT_R8G8B8A8_SRGB, texWidth, texHeight, mipLevels);
}

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

    ...
}

generateMipmaps 函数中,使用 vkGetPhysicalDeviceFormatProperties 来请求纹理图像格式的属性

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 结构有三个字段,分别名为 linearTilingFeaturesoptimalTilingFeaturesbufferFeatures,它们分别描述了格式如何根据其使用方式来使用。我们使用最佳平铺格式创建纹理图像,因此我们需要检查 optimalTilingFeatures。对线性滤波特性的支持可以使用 VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT 来检查

if (!(formatProperties.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT)) {
    throw std::runtime_error("texture image format does not support linear blitting!");
}

在这种情况下,有两种选择。您可以实现一个函数,该函数搜索常见的纹理图像格式,以找到一个确实支持线性 blit 的格式,或者您可以使用诸如 stb_image_resize 之类的库,在软件中实现 mipmap 的生成。然后,可以将每个 mip 层级以加载原始图像的相同方式加载到图像中。

应该注意的是,在实践中,无论如何都不常见在运行时生成 mipmap 层级。通常,它们是预先生成的,并与基本层级一起存储在纹理文件中,以提高加载速度。在软件中实现大小调整以及从文件中加载多个层级留给读者练习。

采样器

虽然 VkImage 保存 mipmap 数据,但 VkSampler 控制在渲染时如何读取该数据。Vulkan 允许我们指定 minLodmaxLodmipLodBiasmipmapMode(“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));
}

如果 samplerInfo.mipmapModeVK_SAMPLER_MIPMAP_MODE_NEAREST,则 lod 选择从中采样的 mip 层级。如果 mipmap 模式为 VK_SAMPLER_MIPMAP_MODE_LINEAR,则 lod 用于选择要采样的两个 mip 层级。对这些层级进行采样,并将结果进行线性混合。

采样操作也受到 lod 的影响

if (lod <= 0) {
    color = readTexture(uv, magFilter);
} else {
    color = readTexture(uv, minFilter);
}

如果物体靠近相机,则使用 magFilter 作为过滤器。如果物体离相机较远,则使用 minFilter。通常,lod 为非负数,并且仅在靠近相机时才为 0。mipLodBias 允许我们强制 Vulkan 使用比通常情况更低的 lodlevel

要查看本章的结果,我们需要为 textureSampler 选择值。我们已经将 minFiltermagFilter 设置为使用 VK_FILTER_LINEAR。我们只需要为 minLodmaxLodmipLodBiasmipmapMode 选择值。

void createTextureSampler() {
    ...
    samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
    samplerInfo.minLod = 0.0f; // Optional
    samplerInfo.maxLod = VK_LOD_CLAMP_NONE;
    samplerInfo.mipLodBias = 0.0f; // Optional
    ...
}

为了允许使用整个 mip 层级范围,我们将 minLod 设置为 0.0f,并将 maxLod 设置为 VK_LOD_CLAMP_NONE。此常量等于 1000.0f,这意味着将采样纹理中所有可用的 mipmap 层级。我们没有理由更改 lod 值,因此我们将 mipLodBias 设置为 0.0f。

现在运行您的程序,您应该看到以下内容

mipmaps

由于我们的场景非常简单,所以差异并不大。如果仔细观察,会发现细微的差异。

mipmaps comparison

最明显的区别是纸张上的文字。使用 mipmap,文字已被平滑处理。没有 mipmap,文字具有来自莫尔条纹伪影的粗糙边缘和间隙。

您可以尝试不同的采样器设置,以查看它们如何影响 mipmapping。例如,通过更改 minLod,您可以强制采样器不使用最低的 mip 层级

samplerInfo.minLod = static_cast<float>(mipLevels / 2);

这些设置将生成此图像

highmipmaps

这是当物体离相机较远时如何使用更高 mip 层级的情况。

下一章将引导我们完成多重采样,以产生更平滑的图像。