Vulkan教程 – 24 生成Mip贴图

现在我们的程序可以加载和渲染3D模型了,本章我们再添加一个新的特性,Mip贴图。Mip贴图是被游戏和渲染软件所广泛使用的,Vulkan也对Mip生成给了我们足够的控制。

Mip贴图是图像预先计算好的缩小版本。每个新的图像宽高都是之前图像宽高的一半。Mip贴图可以作为LOD的一种格式。远离计算机的对象会从较小的mip图像中采样。使用较小图像能提高渲染速度并避免一些假象,如龟纹。

mip级别个数是创建VkImage的时候指定的。直到现在我们都是把它设为1的。我们要从图像大小计算mip级别个数。首先,添加一个类成员来存储该数值:

uint32_t mipLevels;
VkImage textureImage;

mipLevels值可以在我们用createTextureImage加载贴图后确定:

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

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_UNORM, VK_IMAGE_TILING_OPTIMAL,
    VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
    VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, textureImage, textureImageMemory);
...
textureImageView = createImageView(textureImage, VK_FORMAT_R8G8B8A8_UNORM,
    VK_IMAGE_ASPECT_COLOR_BIT, mipLevels);
...
swapChainImageViews[i] = createImageView(swapChainImages[i],
    swapChainImageFormat, VK_IMAGE_ASPECT_COLOR_BIT, 1);
...
depthImageView = createImageView(depthImage, depthFormat, VK_IMAGE_ASPECT_DEPTH_BIT, 1);
...
transitionImageLayout(depthImage, depthFormat, VK_IMAGE_LAYOUT_UNDEFINED,
    VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, 1);
...
transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_UNORM,
    VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, mipLevels);
...
transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_UNORM,
    VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, mipLevels);

现在我们的贴图图像有了多重mip等级,但是临时缓冲只能用于填充等级0。其他等级还是未定义的。为了填充这些等级,我们要从我们有的这个单个等级来生成数据,我们用vkCmdBlitImage命令来做。该命令执行复制、缩放和过滤操作。我们会多次调用它来进行块转移,转移到我们贴图图像的各个等级上。

VkCmdBlit被认为是一个转移操作,所以我们必须告诉Vulkan我们想要使用贴图图像作为转移源和目的地。在createTextureImage中添加VK_IMAGE_USAGE_TRANSFER_SRC_BIT到贴图图像用法标记:

createImage(texWidth, texHeight, mipLevels, VK_FORMAT_R8G8B8A8_UNORM,
    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等级。每个块传送一次处理两个mip等级,所以我们可以在这些块传送命令间转移各个等级到最优布局中。

transitionImageLayout只进行整个图像的布局转移,所以我们要写一些管线屏障命令。将createTextureImage中现存的转移移动到VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL:

//transitioned to VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL while generating mipmaps
//transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_UNORM,
//  VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, mipLevels);

这会将贴图图像每个等级都留在VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL中。在块转移命令从它读取数据完成后,各个等级都会转移到VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL。

我们写一个方法来生成Mip贴图:

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

首先我们转移i – 1级到VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL。该转移会等待i – 1级填充好,从之前的块转移命令或者vkCmdCopyBufferToImage填充。当前块转移命令会等待本次转移。

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;

接着,我们指定用于块转移的操作。源mip等级是i – 1,目的mip等级是i。srcOffsets数组的两个元素决定了数据要进行块转移的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);

现在我们记录块转移命令。注意textureImage用于srcImage和dstImage参数,因为我们要在同一个图像的两个不同级别之间进行块转移。源mip等级转移到VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,目的等级还在VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL。

最后一个参数指定块转移的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。该转移会等待当前块转移命令完成。所有采样操作会等该转移完成。

if (mipWidth > 1) mipWidth /= 2;
if (mipHeight > 1) mipHeight /= 2;

循环结尾处,我们把当前mip大小用2切分。之前会检查每个大小来确保大小不会变成0。这会处理图像不是正方形的情况,因为有一个大小会比其他那个先到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。这个不是循环来做的,因为最后一个等级是不会进行块转移的。

最终,createTextureImage中添加一个对generateMipmaps的调用:

transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_UNORM,
    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);

使用内置方法如vkCmdBlitImage来生成mip等级很方便,但是它不一定是所有平台都支持的。它需要贴图图像格式支持线性过滤,这点可以用vkGetPhysicalDeviceFormatProperties查看。为此,我们在generateMipmaps中添加一个检查步骤。

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

void createTextureImage() {
    ...
    generateMipmaps(textureImage, VK_FORMAT_R8G8B8A8_UNORM, texWidth, texHeight, mipLevels);
}
...
void generateMipmaps(VkImage image, VkFormat imageFormat, int32_t texWidth,
    int32_t texHeight, uint32_t mipLevels) {
    ...
}

generateMipmaps方法中,使用vkGetPhysicalDeviceFormatProperties来获取贴图图像格式属性:

VkFormatProperties formatProperties;
vkGetPhysicalDeviceFormatProperties(physicalDevice, imageFormat, &formatProperties);

VkFormatProperties结构体有三个字段,linearTilingFeatures,optimalTilingFeatures和bufferFeatures,每个描述了格式如何使用。我们创建一个有着最优平铺格式的贴图图像,所以我们要检查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!");
}

这里有两个可选项。你可以实现一个方法,查找支持线性块转移的常用贴图图像格式,或者用一些库在软件中实现Mip贴图生成,如stb_image_resize。每个mip等级就能和你用源图像加载一样加载到图像中。

需要注意的是,运行时再生成Mip贴图不是常用的做法。一般它们都是提前生成的,存储在贴图文件中,就在基本等级的旁边放着,从而加快加载速度。在软件中实现调整大小和从文件中加载多个等级就留作练习了。

虽然VkImage保存了Mip贴图数据,VkSampler才是控制渲染时这些数据如何读取的。Vulkan允许我们指定minLod,maxLod,mipLodBias和mipmapMode。当采样贴图的时候,采样器根据下面的伪代码选择一个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.mipmapMode是VK_SAMPLER_MIPMAP_MODE_NEAREST,lod会选择要采样的mip等级。如果Mip贴图是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使用比正常情况下更低的lod和级别。

为了看到本章的效果,我们要选择textureSampler值。我们已经设置了VK_FILTER_LINEAR的minFilter和magFilter,现在就只需要选择minLod,maxLod,mipLodBias和mipmapMode的值了。

samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
samplerInfo.minLod = 0;
samplerInfo.maxLod = static_cast<float>(mipLevels);
samplerInfo.mipLodBias = 0;  // Optional

为了允许使用全范围的mip等级,我们设置minLod为0,maxLod为mip等级个数。我们不用改lod的值,所以就设置mipLodBias为0。

现在运行程序可以看到:

这个区别不容易看出了,因为场景很简单。

仔细看的话,最显著的区别是牌子上的字。有了Mip贴图,这些字就平滑掉了,否则这些字会有来自龟纹的尖锐的边和缝隙。

现在可以改动采样器设置看看它们如何影响Mip贴图的。比如设置minLod如下:

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

得到这个图:

这就是对象远离相机时用高mip等级的效果。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片