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等級的效果。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章