生成 Mipmaps

      +

      简介

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

      Mipmaps 是图像的预计算、缩小版本。 每个新图像的宽度和高度是前一个图像的一半。 Mipmaps 用作_细节级别(LOD)_的一种形式。远离摄像机的对象将从较小的 Mip 图像中采样纹理。 使用较小的图像可以提高渲染速度,并避免诸如 莫尔条纹 的伪影。 Mipmaps 的示例:

      mipmaps example

      图像创建

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

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

      ...
      uint32_t mipLevels;
      std::unique_ptr<vk::raii::Image> 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, vk::Format format, vk::ImageTiling tiling, vk::ImageUsageFlags usage, vk::MemoryPropertyFlags properties, vk::raii::Image& image, vk::raii::DeviceMemory& imageMemory) const {
          ...
          imageInfo.mipLevels = mipLevels;
          ...
      }
      [[nodiscard]] std::unique_ptr<vk::raii::ImageView> createImageView(const vk::raii::Image& image, vk::Format format, vk::ImageAspectFlags aspectFlags, uint32_t mipLevels) const {
          ...
          viewInfo.subresourceRange.levelCount = mipLevels;
          ...
      void transitionImageLayout(const vk::raii::Image& image, const vk::ImageLayout oldLayout, const vk::ImageLayout newLayout, uint32_t mipLevels) const {
          ...
          barrier.subresourceRange.levelCount = mipLevels;
          ...

      更新所有调用这些函数的地方以使用正确的值:

      createImage(swapChainExtent.width, swapChainExtent.height, 1, depthFormat, vk::ImageTiling::eOptimal, vk::ImageUsageFlagBits::eDepthStencilAttachment, vk::MemoryPropertyFlagBits::eDeviceLocal, depthImage, depthImageMemory);
      ...
      createImage(texWidth, texHeight, mipLevels, vk::Format::eR8G8B8A8Srgb, vk::ImageTiling::eOptimal, vk::ImageUsageFlagBits::eTransferSrc | vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eSampled, vk::MemoryPropertyFlagBits::eDeviceLocal, textureImage, textureImageMemory);
      swapChainImageViews[i] = createImageView(swapChainImages[i], swapChainImageFormat, vk::ImageAspectFlagBits::eColor, 1);
      ...
      depthImageView = createImageView(depthImage, depthFormat,vk::ImageAspectFlagBits::eDepth, 1);
      ...
      textureImageView = createImageView(textureImage, vk::Format::eR8G8B8A8Srgb, vk::ImageAspectFlagBits::eColor, mipLevels);
      transitionImageLayout(depthImage, depthFormat, vk::ImageLayout::eUndefined,vk::ImageLayout::eDepthStencilAttachmentOptimal, 1);
      ...
      transitionImageLayout(textureImage, vk::ImageLayout::eUndefined, vk::ImageLayout::eTransferDstOptimal, mipLevels);

      生成 Mipmaps

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

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

      ...
      createImage(texWidth, texHeight, mipLevels, vk::Format::eR8G8B8A8Srgb, vk::ImageTiling::eOptimal, vk::ImageUsageFlagBits::eTransferSrc | vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eSampled, vk::MemoryPropertyFlagBits::eDeviceLocal, 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::ImageLayout::eUndefined, vk::ImageLayout::eTransferDstOptimal, mipLevels);
          copyBufferToImage(stagingBuffer, textureImage, static_cast<uint32_t>(texWidth), static_cast<uint32_t>(texHeight));
      //在生成 Mipmaps 时转换为 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
      ...

      这将使纹理图像的每个级别保持在 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL。 每个级别将在从中读取的 blit 命令完成后转换为 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL

      我们现在将编写生成 Mipmaps 的函数:

      void generateMipmaps(vk::raii::Image& image, vk::Format imageFormat, int32_t texWidth, int32_t texHeight, uint32_t mipLevels) {
          std::unique_ptr<vk::raii::CommandBuffer> commandBuffer = beginSingleTimeCommands();
      
          vk::ImageMemoryBarrier barrier (vk::AccessFlagBits::eTransferWrite, vk::AccessFlagBits::eTransferRead
                                     , vk::ImageLayout::eTransferDstOptimal, vk::ImageLayout::eTransferSrcOptimal
                                     , vk::QueueFamilyIgnored, vk::QueueFamilyIgnored, image);
          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::ImageLayout::eTransferDstOptimal;
      barrier.newLayout = vk::ImageLayout::eTransferSrcOptimal;
      barrier.srcAccessMask = vk::AccessFlagBits::eTransferWrite;
      barrier.dstAccessMask = vk::AccessFlagBits::eTransferRead;
      
      commandBuffer->pipelineBarrier(vk::PipelineStageFlagBits::eTransfer, vk::PipelineStageFlagBits::eTransfer, {}, {}, {}, barrier);

      首先,我们将级别 i - 1 转换为 VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL。 此转换将等待级别 i - 1 被填充,无论是通过先前的 blit 命令还是 vkCmdCopyBufferToImage。 当前的 blit 命令将等待此转换。

      vk::ArrayWrapper1D<vk::Offset3D, 2> offsets, dstOffsets;
      offsets[0] = vk::Offset3D(0, 0, 0);
      offsets[1] = vk::Offset3D(mipWidth, mipHeight, 1);
      dstOffsets[0] = vk::Offset3D(0, 0, 0);
      dstOffsets[1] = vk::Offset3D(mipWidth > 1 ? mipWidth / 2 : 1, mipHeight > 1 ? mipHeight / 2 : 1, 1);
      vk::ImageBlit blit = { .srcSubresource = {}, .srcOffsets = offsets,
                          .dstSubresource =  {}, .dstOffsets = dstOffsets };
      blit.srcSubresource = vk::ImageSubresourceLayers( vk::ImageAspectFlagBits::eColor, i - 1, 0, 1);
      blit.dstSubresource = vk::ImageSubresourceLayers( vk::ImageAspectFlagBits::eColor, i, 0, 1);

      接下来,我们指定将在 blit 操作中使用的区域。 源 Mip 级别是 i - 1,目标 Mip 级别是 isrcOffsets 的两个元素确定将从中 blit 数据的 3D 区域。 dstOffsets 确定将 blit 数据的区域。 dstOffsets[1] 的 X 和 Y 维度除以 2,因为每个 Mip 级别是前一个级别的一半大小。 srcOffsets[1]dstOffsets[1] 的 Z 维度必须为 1,因为 2D 图像的深度为 1。

      commandBuffer->blitImage(image, vk::ImageLayout::eTransferSrcOptimal, image, vk::ImageLayout::eTransferDstOptimal, { blit }, vk::Filter::eLinear);

      现在,我们记录 blit 命令。 注意 textureImage 同时用作 srcImagedstImage 参数。 这是因为我们在同一图像的不同级别之间进行 blit。 源 Mip 级别刚刚转换为 VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,目标级别仍处于 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL(来自 createTextureImage)。

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

      最后一个参数允许我们指定在 blit 中使用的 VkFilter。 我们在这里有与创建 VkSampler 时相同的过滤选项。 我们使用 VK_FILTER_LINEAR 来启用插值。

      barrier.oldLayout = vk::ImageLayout::eTransferSrcOptimal;
      barrier.newLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
      barrier.srcAccessMask = vk::AccessFlagBits::eTransferRead;
      barrier.dstAccessMask = vk::AccessFlagBits::eShaderRead;
      
      commandBuffer->pipelineBarrier(vk::PipelineStageFlagBits::eTransfer, vk::PipelineStageFlagBits::eFragmentShader, {}, {}, {}, 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::ImageLayout::eTransferDstOptimal;
          barrier.newLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
          barrier.srcAccessMask = vk::AccessFlagBits::eTransferWrite;
          barrier.dstAccessMask = vk::AccessFlagBits::eShaderRead;
      
          commandBuffer->pipelineBarrier(vk::PipelineStageFlagBits::eTransfer, vk::PipelineStageFlagBits::eFragmentShader, {}, {}, {}, barrier);
      
          endSingleTimeCommands(*commandBuffer);
      }

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

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

      transitionImageLayout(*textureImage, vk::ImageLayout::eUndefined, vk::ImageLayout::eTransferDstOptimal, mipLevels);
      copyBufferToImage(stagingBuffer, *textureImage, static_cast<uint32_t>(texWidth), static_cast<uint32_t>(texHeight));
      //在生成 Mipmaps 时转换为 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
      ...
      generateMipmaps(textureImage, texWidth, texHeight, mipLevels);

      我们的纹理图像的 Mipmaps 现在已填充。

      线性过滤支持

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

      首先,添加一个指定图像格式的参数:

      void createTextureImage() {
          ...
      
          generateMipmaps(*textureImage, vk::Format::eR8G8B8A8Srgb, texWidth, texHeight, mipLevels);
      }
      
      void generateMipmaps(vk::raii::Image& image, vk::Format imageFormat, int32_t texWidth, int32_t texHeight, uint32_t mipLevels) {
      
          ...
      }

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

      void generateMipmaps(vk::raii::Image& image, vk::Format imageFormat, int32_t texWidth, int32_t texHeight, uint32_t mipLevels) {
      
          // 检查图像格式是否支持线性 blit
          vk::FormatProperties formatProperties = physicalDevice->getFormatProperties(imageFormat);
      
          ...

      VkFormatProperties 结构体有三个字段,名为 linearTilingFeaturesoptimalTilingFeaturesbufferFeatures,每个字段描述了格式如何根据其使用方式使用。 我们使用最佳平铺格式创建纹理图像,因此我们需要检查 optimalTilingFeatures。 可以通过 VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT 检查线性过滤功能的支持:

      if (!(formatProperties.optimalTilingFeatures & vk::FormatFeatureFlagBits::eSampledImageFilterLinear)) {
          throw std::runtime_error("纹理图像格式不支持线性 blit!");
      }

      在这种情况下,有两种替代方案。 你可以实现一个函数,搜索常见的纹理图像格式以找到_确实_支持线性 blit 的格式,或者你可以使用像 stb_image_resize 这样的库在软件中实现 Mipmap 生成。 然后,每个 Mip 级别可以以与加载原始图像相同的方式加载到图像中。

      应该注意的是,在运行时生成 Mipmap 级别在实践中并不常见。 通常它们会预生成并与基础级别一起存储在纹理文件中以提高加载速度。 在软件中实现调整大小并从文件中加载多个级别留给读者作为练习。

      采样器

      虽然 VkImage 保存 Mipmap 数据,但 VkSampler 控制在渲染时如何读取这些数据。 Vulkan 允许我们指定 minLodmaxLodmipLodBiasmipmapMode("Lod" 表示 "细节级别")。 当采样纹理时,采样器根据以下伪代码选择 Mip 级别:

      lod = getLodLevelFromScreenSize(); //当对象靠近时较小,可能为负
      lod = clamp(lod + mipLodBias, minLod, maxLod);
      
      level = clamp(floor(lod), 0, texture.mipLevels - 1);  //限制为纹理中的 Mip 级别数量
      
      if (mipmapMode == vk::SamplerMipmapMode::eNearest) {
          color = sample(level);
      } else {
          color = blend(sample(level), sample(level + 1));
      }

      如果 samplerInfo.mipmapModeVK_SAMPLER_MIPMAP_MODE_NEARESTlod 选择要采样的 Mip 级别。 如果 Mipmap 模式是 VK_SAMPLER_MIPMAP_MODE_LINEARlod 用于选择两个要采样的 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() {
          vk::PhysicalDeviceProperties properties = physicalDevice.getProperties();
          vk::SamplerCreateInfo samplerInfo {
              .magFilter = vk::Filter::eLinear,
              .minFilter = vk::Filter::eLinear,
              .mipmapMode = vk::SamplerMipmapMode::eLinear,
              .addressModeU = vk::SamplerAddressMode::eRepeat,
              .addressModeV = vk::SamplerAddressMode::eRepeat,
              .addressModeW = vk::SamplerAddressMode::eRepeat,
              .mipLodBias = 0.0f,
              .anisotropyEnable = vk::True,
              .maxAnisotropy = properties.limits.maxSamplerAnisotropy,
              .compareEnable = vk::False,
              .compareOp = vk::CompareOp::eAlways
          };
          ...
      }

      在上面的代码中,我们设置了采样器,用于缩小和放大的线性过滤,以及 Mip 级别之间的线性插值。我们还设置了 Mip 级别偏差为 0.0f。

      默认情况下,将使用所有可用的 Mip 级别。默认的 minLod 是 0.0f,默认的 maxLodVK_LOD_CLAMP_NONE(等于 1000.0f),这意味着纹理中的所有可用 Mipmap 级别都将被采样。

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

      mipmaps

      这不是一个戏剧性的差异,因为我们的场景非常简单。如果你仔细观察,会有细微的差别。

      mipmaps comparison

      最明显的区别是纸张上的文字。使用 Mipmaps 后,文字变得平滑。没有 Mipmaps 时,文字有尖锐的边缘和莫尔条纹的间隙。

      你可以尝试调整采样器设置,看看它们如何影响 Mipmapping。例如,通过更改 minLod,你可以强制采样器不使用最低的 Mip 级别:

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

      这些设置将产生以下图像:

      highmipmaps

      这是当对象远离摄像机时将使用更高的 Mip 级别的方式。

      下一章 将引导我们通过多重采样来生成更平滑的图像。