Generating Mipmaps (создание мип-текстур, мип-мэппинг, мипмаппинг, MIP-текстурирование)

Прежде чем читать про то как использовать мипмаппинг в вулкане, можете ознакомиться с общими положениями по мипмаппингу https://www.ixbt.com/video/mip-mapping.html.

Предисловие

Наша программа теперь может загружать и рендерить 3D модели. В этой главе мы добавим еще одну возможность, мипмаппинг. Мип-текстуры — этошироко используемый инструмент в программах занимающихся рендерингом, в том числе играх, и Vulkan дает нам полный контроль над его созданием.

Мип-текстуры — это предварительно просчитанные, масштабированные версии изображения. Каждое новое изображение имеет половину от ширины и высоты предыдущего. Мип-текстуры используются в качестве разновидности Level of Detail (Уровень детализации) или LOD. Отдаленные от камеры объекты будут выбирать текстуры из небольших мип изображений. Использование небольших изображений увеличивает скорость отрисовки и позволяет избежать артефактов на подобии Муаровых узоров.

Пример того, как выглядят мип-текстуры:

Создание изображения

В Vulkan’е каждое из мип-изображений хранится на разных mip levels (мип уровнях) изображения VkImage. Мип уровень 0 — это оригинальное изображение, и мип урони после уровня 0 обычно называются mip chain (мип цепочкой).

Количество мип уровней определяется во время создания VkImage. До сих пор мы всегда выставляли это значение равным одному. Нам нужно рассчитать количество мип уровней исходя из размера изображения. Первым делом добавит член класса для хранения данного числа:

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

Значение для mipLevels указываем во время загрузки текстуры в createTextureImage:

int texWidth, texHeight, texChannels;
stbi_uc* pixels = stbi_load(TEXTURE_PATH.c_str(), &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);
...
mipLevels = static_cast(std::floor(std::log2(std::max(texWidth, texHeight)))) + 1;

Таким образом получили количество уровней в мип цепочке. Функция max выбирает максимальное разрешение. Функция log2  вычисляет, сколько раз размер можно делить на 2. Функция занимается случаями, когда больший размер не является степенью 2. Добавляем 1, т.к. оригиналоное изображение тоже является ми уровнем.

Что бы использовать это значение, нам нужно изменить функции createImage и createImageView, дабы указывать мип уровень.

Добавляем 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;
    ...

Обновляем все вызовы  этих функций для правильного значения:

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_SRC_BIT | 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_UNORM, VK_IMAGE_ASPECT_COLOR_BIT, mipLevels);

Создание мип-текстур

Наша текстура теперь имеет несколько мип уровней, но staging buffer можно использовать только для заполнения уровня 0. Остальные уровни остаются неопределены. Для их заполнения нам понрадобиться сгенерировать данные из единственного имеющегося уровня. Мы будем использовать команду vkCmdBlitImage. Эта команда выполняет копирование, скалирование и фильтрацию. Мы будем вызывать её несколько раз для blit (block transfer) данных к каждому уровня нашего изображения.

Как и другие операции с изображениями, vkCmdBlitImage  определяет layout изображения, на котором происходят операции. Для улучшения производительности, изображение-источник должно быть в VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL  и изображение-получатель VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, пока не закончится операция. Vulkan позволяет нам перемещать каждый мип уровень независимо. transitionImageLayout выполняет только передачу layout изображения целиком, потому нужно описать несколько дополнительных барьеров конвейера. Первым делом удалим существующее VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL в createTextureImage:

...
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);
    
transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_UNORM, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, mipLevels);
    copyBufferToImage(stagingBuffer, textureImage, static_cast(texWidth), static_cast(texHeight));
//transitioned to VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL while generating mipmaps
...

Это сделает каждый уровень текстуры изображения в  VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL.

Теперь напишем функцию, которая создает мип-текстуры:

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.

int32_t mipWidth = texWidth;
int32_t mipHeight = texHeight;

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

}

Этот цикл будет записывать каждую из VkCmdBlitImage команд. Обратите внимание, цикл начинается с 1, не с 0.

Далее сделаем уровень i - 1   VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL.

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

Следующим шагом будет определение области, которая будет использоваться в blit операции. Источник мип уровня —  i - 1, а записывать будет в мип уровень i. Два элемента массива srcOffsets определяют 3D область из которых будет производиться blit. dstOffsets определяет область, в которую будет производиться blit. Измерения X и Y из dstOffsets[1] делятся на два, так как каждый мип уровень вдвое меньше предыдущего. Z измерение как  srcOffsets[1], так и dstOffsets[1] должно быть равно 1, т.к. 2D изображение имеет глубину равную 1.

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 / 2, mipHeight / 2, 1 };
blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blit.dstSubresource.mipLevel = i;
blit.dstSubresource.baseArrayLayer = 0;
blit.dstSubresource.layerCount = 1;

Теперь запишем blit команду. Обратите внимание, что  textureImage используется для обоих параметров,  srcImage  и  dstImage. Это потому что мы производим blit между различными уровнями одного изображения.

Последний параметр позволяет нам указать VkFilter. Этот параметр у нас будет такой же, что мы указывали и в  VkSampler. Мы используем  VK_FILTER_LINEAR для включения интерполяции.

vkCmdBlitImage(commandBuffer,
    image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
    image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
    1, &blit,
    VK_FILTER_LINEAR);

Следующий барьер обеспечивает изменение  i - 1 в  VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL.

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

В конце цикла мы делим текущие размеры на 2. Мы проверяем каждый размер, перед тем как делить, что бы случайно не получилось деление на 0. Это так же позволит так же работать с не квадратными изображениями, ни одна из размерностей изображения никогда не станет меньше 1.

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

Перед тем как мы закончим буфер комманд, вставим еще один барьер конвейера. Этот барьер изменить последний мип уровень из VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL в VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, т.к. этого не происходит в цикле.

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

И наконец добавим вызов generateMipmaps в createTextureImage:

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

Генерация наших мип изображений текстур теперь полностью завершена.

Sampler

В то время как VkImage хранит, VkSampler определяет как эти данные будут считываться во время рендеринга. Vulkan позволяет нам определить  minLodmaxLodmipLodBias, и mipmapMode («Lod» означает «Level of Detail» или «Уровень детализации»). Когда текстура определена, sampler указывает мип уровень в соответствии со следующем псевдокодом:

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 выбирает мип уровень напрямую из sample.Если мод  VK_SAMPLER_MIPMAP_MODE_LINEAR, то lod используется для указания двух мип уровней для последующей обработки. Эти уровни определяются и смешиваются линейным образом.

Операция sample так же основывается на значении lod:

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

Если объект находится близко к камере, в качестве фильтра используется  magFilter. Если объект отдален, тогда используется minFilter. Обычно lod не отрицательный, и для наиболее близкого к камере состояния используется 0. mipLodBias позволяет нам указывать Vulkan’у минимальные значения  lod and level, которые будут обычно использоваться.

Что бы увидеть результат этой главы, нам понадобится выбрать значения для нашего textureSampler. Мы уже установили  minFilter и magFilter для использования  VK_FILTER_LINEAR. Теперь просто нужно выбрать значения для  minLodmaxLodmipLodBias, and mipmapMode.

void createTextureSampler() {
    ...
    samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
    samplerInfo.minLod = 0; // Optional
    samplerInfo.maxLod = static_cast(mipLevels);
    samplerInfo.mipLodBias = 0; // Optional
    ...
}

Для того, что бы было возможно использовать все мип уровни, мы устанавливаем  minLod равным 0, а для  maxLod указываем количество мип уровней. У нас нет причин менять значение lod, потому устанавливаем  mipLodBias равным 0.

Теперь запускаем программу и видим следующее:

Разница не высока, ведь сцена у нас простейшая. Что бы увидеть разницу нужно вглядываться (на телефоне не увидите скорее всего).

Больше всего разница заметно в надписях. С мип-текстурами они смазались. А без мип текстур края получаются более рубленными и появляется Муаровы артефакты.

Вы можете поиграться с настройками и увидеть как они отражаются на мип-текстурах. Например, если изменить  minLod, вы можете намеренно использовать более низкие мип уровни:

samplerInfo.minLod = static_cast(mipLevels / 2);

Это будет выглядеть так:

Main Admin

4 Comments

  1. Только не «увеличивает время отрисовки», а «увеличивает скорость отрисовки», а то смысл обратный получился. 🙂

  2. Кто-нибудь пытался приспособить mipmaping из этого примера к 3d-текстурам? У меня очень странные ошибки сыпятся.

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *