狸村 Mystic Web · 狸村
目录

引言

我们的程序现在可以加载并渲染 3D 模型了。 在本章中,我们将再添加一个功能:mipmap 生成。 Mipmap 广泛应用于游戏和渲染软件中,而 Vulkan 赋予了我们对其创建方式的完全控制。

Mipmap 是预先计算好的、缩小版本的图像。 每个新图像的宽度和高度都是前一个的一半。 Mipmap 用作一种 细节级别(Level of Detail)LOD。距离相机较远的物体会从较小的 mip 图像中采样纹理。 使用较小的图像可以提高渲染速度,并避免诸如 摩尔纹 之类的伪影。 以下是一个 mipmap 的示例:

mipmaps example

图像创建

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

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

...
uint32_t               mipLevels          = 0;
vk::raii::Image        textureImage       = nullptr;
...

一旦我们在 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 参数:

std::pair<vk::raii::Image, vk::raii::DeviceMemory>
    createImage(uint32_t                width,
                uint32_t                height,
                uint32_t                mipLevels,
                vk::Format              format,
                vk::ImageTiling         tiling,
                vk::ImageUsageFlags     usage,
                vk::MemoryPropertyFlags properties)
{
    vk::ImageCreateInfo imageInfo{.imageType     = vk::ImageType::e2D,
                                  .format        = format,
                                  .extent        = {width, height, 1},
                                  .mipLevels     = mipLevels,
    ...
}
vk::raii::ImageView createImageView(vk::Image const &image, vk::Format format, vk::ImageAspectFlags aspectFlags, uint32_t mipLevels) const
{
		vk::ImageViewCreateInfo viewInfo{
		    .image            = image,
		    .viewType         = vk::ImageViewType::e2D,
		    .format           = format,
		    .subresourceRange = {.aspectMask = aspectFlags, .levelCount = mipLevels, .layerCount = 1}};
    ...
}
void transitionImageLayout(
    vk::raii::CommandBuffer &commandBuffer, vk::raii::Image const &image, vk::ImageLayout oldLayout, vk::ImageLayout newLayout, uint32_t mipLevels)
{
		vk::ImageMemoryBarrier barrier{.oldLayout           = oldLayout,
		                               .newLayout           = newLayout,
		                               .srcQueueFamilyIndex = vk::QueueFamilyIgnored,
		                               .dstQueueFamilyIndex = vk::QueueFamilyIgnored,
		                               .image               = image,
		                               .subresourceRange    = {.aspectMask = vk::ImageAspectFlagBits::eColor, .levelCount = mipLevels, .layerCount = 1}};
    ...

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

std::tie(depthImage, depthImageMemory) = createImage(swapChainExtent.width,
                                                     swapChainExtent.height,
                                                     1,
                                                     depthFormat,
                                                     vk::ImageTiling::eOptimal,
                                                     vk::ImageUsageFlagBits::eDepthStencilAttachment,
                                                     vk::MemoryPropertyFlagBits::eDeviceLocal);
...
std::tie(textureImage, textureImageMemory) =
    createImage(texWidth,
                texHeight,
                mipLevels,
                vk::Format::eR8G8B8A8Srgb,
                vk::ImageTiling::eOptimal,
                vk::ImageUsageFlagBits::eTransferSrc | vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eSampled,
                vk::MemoryPropertyFlagBits::eDeviceLocal);
swapChainImageViews.emplace_back(createImageView(image, swapChainSurfaceFormat.format, vk::ImageAspectFlagBits::eColor, 1));
...
depthImageView = createImageView(depthImage, depthFormat, vk::ImageAspectFlagBits::eDepth, 1);
...
textureImageView = createImageView(*textureImage, vk::Format::eR8G8B8A8Srgb, vk::ImageAspectFlagBits::eColor, mipLevels);
transitionImageLayout(commandBuffer, textureImage, vk::ImageLayout::eUndefined, vk::ImageLayout::eTransferDstOptimal, mipLevels);

生成 Mipmap

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

vk::raii::CommandBuffer::blitImage 被视为传输操作,因此我们必须告知 Vulkan,我们打算将纹理图像同时用作传输的源和目标。 在 createTextureImage 中,将 vk::ImageUsageFlagBits::eTransferSrc 添加到纹理图像的用途标志中:

...
std::tie(textureImage, textureImageMemory) =
    createImage(texWidth,
                texHeight,
                mipLevels,
                vk::Format::eR8G8B8A8Srgb,
                vk::ImageTiling::eOptimal,
                vk::ImageUsageFlagBits::eTransferSrc | vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eSampled,
                vk::MemoryPropertyFlagBits::eDeviceLocal);
...

与其他图像操作一样,vk::raii::CommandBuffer::blitImage 依赖于其操作对象的布局。 我们可以将整个图像转换为 vk::ImageLayout::eGeneral,但这很可能会很慢。 为了获得最佳性能,源图像应处于 vk::ImageLayout::eTransferSrcOptimal 布局,目标图像应处于 vk::ImageLayout::eTransferDstOptimal 布局。 Vulkan 允许我们独立转换图像的每个 mip 级别。 每次 blit 操作只涉及两个 mip 级别,因此我们可以在 blit 命令之间将每个级别转换为最佳布局。

transitionImageLayout 只对整个图像执行布局转换,因此我们需要编写一些额外的管线屏障命令。 在 createTextureImage 中移除现有的到 vk::ImageLayout::eShaderReadOnlyOptimal 的转换:

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

这将使纹理图像的每个级别都保持在 vk::ImageLayout::eTransferDstOptimal。 在每个 blit 命令读取完某个级别后,该级别将被转换为 vk::ImageLayout::eShaderReadOnlyOptimal

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

void generateMipmaps(vk::raii::CommandBuffer &commandBuffer,
                     vk::raii::Image         &image,
                     int32_t                  texWidth,
                     int32_t                  texHeight,
                     uint32_t                 mipLevels)
{
		vk::ImageMemoryBarrier barrier = {.srcAccessMask       = vk::AccessFlagBits::eTransferWrite,
		                                  .dstAccessMask       = vk::AccessFlagBits::eTransferRead,
		                                  .oldLayout           = vk::ImageLayout::eTransferDstOptimal,
		                                  .newLayout           = vk::ImageLayout::eTransferSrcOptimal,
		                                  .srcQueueFamilyIndex = vk::QueueFamilyIgnored,
		                                  .dstQueueFamilyIndex = vk::QueueFamilyIgnored,
		                                  .image               = image,
}

我们将进行多次转换,因此我们将重用这个 vk::ImageMemoryBarrier。 上面设置的字段对于所有屏障都将保持不变。 subresourceRange.mipleveloldLayoutnewLayoutsrcAccessMaskdstAccessMask 将在每次转换时更改。

int32_t mipWidth  = texWidth;
int32_t mipHeight = texHeight;

for (uint32_t i = 1; i < mipLevels; i++)
{
}

这个循环将记录每个 vk::raii::CommandBuffer::blitImage 命令。 注意循环变量从 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::ImageLayout::eTransferSrcOptimal。 此转换将等待级别 i - 1 被填充,无论是来自之前的 blit 命令,还是来自 vk::raii::CommandBuffer::copyBufferToImage。 当前的 blit 命令将等待此转换。

vk::ImageBlit blit = {.srcSubresource = {.aspectMask = vk::ImageAspectFlagBits::eColor, .mipLevel = i - 1, .layerCount = 1},
                      .srcOffsets     = std::array<vk::Offset3D, 2>({{}, {mipWidth, mipHeight, 1}}),
                      .dstSubresource = {.aspectMask = vk::ImageAspectFlagBits::eColor, .mipLevel = i, .layerCount = 1},
                      .dstOffsets     = std::array<vk::Offset3D, 2>({{}, {1 < mipWidth ? mipWidth / 2 : 1, 1 < mipHeight ? mipHeight / 2 : 1, 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 命令。 注意,image 同时用作 srcImagedstImage 参数。 这是因为我们在同一图像的不同级别之间进行 blit。 源 mip 级别刚刚转换为 vk::ImageLayout::eTransferSrcOptimal,目标级别仍然处于 createTextureImage 中设置的 vk::ImageLayout::eTransferDstOptimal

注意,如果你使用的是专用的传输队列(如顶点缓冲章节中所建议的):vk::raii::CommandBuffer::blitImage 必须提交到具有图形能力的队列。

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

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::ImageLayout::eShaderReadOnlyOptimal。 此转换等待当前的 blit 命令完成。 所有采样操作将等待此转换完成。

    ...
    if (1 < mipWidth)
    {
      mipWidth /= 2;
    }
    if (1 < mipHeight)
    {
      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);
}

最后,我们再插入一个管线屏障。 此屏障将最后一个 mip 级别从 vk::ImageLayout::eTransferDstOptimal 转换为 vk::ImageLayout::eShaderReadOnlyOptimal。 循环没有处理这个,因为最后一个 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));
//在生成 mipmap 时转换为 vk::ImageLayout::eShaderReadOnlyOptimal
generateMipmaps(commandBuffer, textureImage, texWidth, texHeight, mipLevels);

我们的纹理图像的 mipmap 现在已经被填充。

线性过滤支持

使用像 vk::raii::CommandBuffer::blitImage 这样的内置函数来生成所有 mip 级别非常方便,但不幸的是,它并不能保证在所有平台上都得到支持。 这要求我们使用的纹理图像格式支持线性过滤,这可以通过 vk::raii::PhysicalDevice::getFormatProperties 函数进行检查。 我们将在 generateMipmaps 函数中添加对此的检查。

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

void createTextureImage()
{
    ...
    generateMipmaps(commandBuffer, textureImage, vk::Format::eR8G8B8A8Srgb, texWidth, texHeight, mipLevels);
}

void generateMipmaps(vk::raii::CommandBuffer &commandBuffer,
                     vk::raii::Image         &image,
                     vk::Format               imageFormat,
                     int32_t                  texWidth,
                     int32_t                  texHeight,
                     uint32_t                 mipLevels)
{
    ...
}

generateMipmaps 函数中,使用 vk::raii::PhysicalDevice::getFormatProperties 来请求纹理图像格式的属性:

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

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

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

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

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

采样器

虽然 vk::raii::Image 保存了 mipmap 数据,但 vk::raii::Sampler 控制着在渲染时如何读取这些数据。 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::SamplerMipmapMode::eNearest,则 lod 选择要采样的 mip 级别。 如果 mipmap 模式是 vk::SamplerMipmapMode::eLinear,则使用 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::eLinear。 我们只需要为 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,
		    .minLod           = 0.0f,
		    .maxLod           = vk::LodClampNone};
    ...
}

在上面的代码中,我们将采样器设置为对缩小和放大都使用线性过滤,并在 mip 级别之间使用线性插值。我们还设置了 mip 级别偏差为 0.0f。

minLodmaxLod 用于通过限制最小和最大 LOD 值来有效设置要使用的 mip 级别范围。通过将 minLod 设置为 0.0f 并将 maxLod 设置为 vk::LodClampNone,我们确保将使用完整的 mip 级别范围。

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

mipmaps

由于我们的场景非常简单,差异并不明显。 如果你仔细观察,会有细微的差别。

mipmaps comparison

最明显的区别是纸张上的文字。 使用 mipmap 后,文字变得平滑。 没有 mipmap,文字有尖锐的边缘和由摩尔纹伪影造成的间隙。

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

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

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

highmipmaps

这就是当物体离相机较远时,较高 mip 级别将被使用的情况。

下一章将带我们了解多重采样,以生成更平滑的图像。

Vulkan 是 Khronos Group Inc. 的注册商标

教程内容版权归原作者,遵循 CC BY-SA 4.0;本站独立代码及设计除外。