Link

目前引擎可以工作,但有些部分的效率明显低于应有的水平。让我们研究一下如何改进它。

定时 UI

在我们开始优化性能之前,我们需要某种方法来跟踪事物的运行速度。为此,我们将使用 std::chrono 和 imgui 来设置一个非常基本的基准测试计时。如果需要,您可以尝试使用 Tracy,但这可以提供一个简单的基于 UI 的定时显示。我们不会分析 GPU 侧,因为这样做需要管线查询和其他,而且它是一个更复杂的系统。对于我们的需求,在 NSight 或其他等效的 GPU 分析程序中运行程序会更好。

让我们在 vk_engine.h 中添加一个结构体来保存定时信息。

struct EngineStats {
    float frametime;
    int triangle_count;
    int drawcall_count;
    float scene_update_time;
    float mesh_draw_time;
};

将其放入 VulkanEngine 类中,作为 EngineStats stats;

frametime 将是我们的全局定时,并且很可能只是锁定到您的显示器刷新率,因为我们正在进行垂直同步。其他参数将有助于测量。

让我们首先计算 frametime。

run() 的引擎主循环中,我们将在循环的开始和结束处添加一些代码

// main loop
while (!bQuit) {
    //begin clock
    auto start = std::chrono::system_clock::now();

    //everything else

    //get clock again, compare with start clock
    auto end = std::chrono::system_clock::now();
     
     //convert to microseconds (integer), and then come back to miliseconds
    auto elapsed = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    stats.frametime = elapsed.count() / 1000.f;
}

使用 auto start = std::chrono::system_clock::now(); 为我们提供了“现在”的高精度时钟。通过稍后再次调用它,我们可以找到给定代码段花费了多少时间。为了将其转换为毫秒为单位的帧时间,我们需要首先将其转换为微秒(千分之一毫秒),然后将其乘以 1000.f。这样我们就得到 3 位小数。

在 draw_geometry 中,我们将为此定时添加一些代码,并计算三角形和绘制调用的数量。

void VulkanEngine::draw_geometry(VkCommandBuffer cmd)
{
    //reset counters
    stats.drawcall_count = 0;
    stats.triangle_count = 0;
    //begin clock
    auto start = std::chrono::system_clock::now();

    /* code */

    auto draw = [&](const RenderObject& r) {

        /* drawing code */

        vkCmdDrawIndexed(cmd, draw.indexCount, 1, draw.firstIndex, 0, 0);

        //add counters for triangles and draws
        stats.drawcall_count++;
        stats.triangle_count += draw.indexCount / 3;   
    }

    /* code */


    auto end = std::chrono::system_clock::now();

    //convert to microseconds (integer), and then come back to miliseconds
    auto elapsed = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    stats.mesh_draw_time = elapsed.count() / 1000.f;
}

我们获取开始时间,并将计数器重置为 0。然后从 draw lambda 中,我们在绘制逻辑之后添加绘制调用计数和三角形计数。在该函数的最后,我们获取最终时间并将其存储在 stats 结构体中。

update_scene() 上使用相同的代码以及开始/结束时钟,将其存储在 scene_update_time

现在我们需要使用 imgui 显示它们。

run() 函数中,在调用 ImGui::NewFrame()ImGui::Render(); 之间,添加此代码以绘制一个新的 imgui 窗口。

        ImGui::Begin("Stats");

        ImGui::Text("frametime %f ms", stats.frametime);
        ImGui::Text("draw time %f ms", stats.mesh_draw_time);
        ImGui::Text("update time %f ms", stats.scene_update_time);
        ImGui::Text("triangles %i", stats.triangle_count);
        ImGui::Text("draws %i", stats.drawcall_count);
        ImGui::End();

如果您运行引擎,您将看到定时。现在我们启用了验证层,并且可能还启用了调试模式。在您的编译器设置中启用发布模式,并通过将 constexpr bool bUseValidationLayers = true; 设置为 false 来禁用验证层。在一台配备 ryzen 5950x CPU 的 PC 上,从启用验证层到禁用验证层,draw_time 从约 6.5 毫秒降至 0.3 毫秒。如果场景渲染正确,则绘制调用计数应显示 1700。

绘制排序

现在,我们调用 vulkan 调用的次数比我们应该调用的次数多得多,因为我们每次绘制都不断重新绑定管线和其他内容。我们需要跟踪我们正在绑定的状态,并且仅在必须在绘制时更改时才再次调用它。

我们将修改上一篇文章中看到的 draw() lambda,并为其提供状态跟踪。它只会在参数更改时调用 vulkan 函数。

//defined outside of the draw function, this is the state we will try to skip
 MaterialPipeline* lastPipeline = nullptr;
 MaterialInstance* lastMaterial = nullptr;
 VkBuffer lastIndexBuffer = VK_NULL_HANDLE;

 auto draw = [&](const RenderObject& r) {
     if (r.material != lastMaterial) {
         lastMaterial = r.material;
         //rebind pipeline and descriptors if the material changed
         if (r.material->pipeline != lastPipeline) {

             lastPipeline = r.material->pipeline;
             vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, r.material->pipeline->pipeline);
             vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,r.material->pipeline->layout, 0, 1,
                 &globalDescriptor, 0, nullptr);

            VkViewport viewport = {};
            viewport.x = 0;
            viewport.y = 0;
            viewport.width = (float)_windowExtent.width;
            viewport.height = (float)_windowExtent.height;
            viewport.minDepth = 0.f;
            viewport.maxDepth = 1.f;

            vkCmdSetViewport(cmd, 0, 1, &viewport);

            VkRect2D scissor = {};
            scissor.offset.x = 0;
            scissor.offset.y = 0;
            scissor.extent.width = _windowExtent.width;
            scissor.extent.height = _windowExtent.height;

            vkCmdSetScissor(cmd, 0, 1, &scissor);
         }

         vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, r.material->pipeline->layout, 1, 1,
             &r.material->materialSet, 0, nullptr);
     }
    //rebind index buffer if needed
     if (r.indexBuffer != lastIndexBuffer) {
         lastIndexBuffer = r.indexBuffer;
         vkCmdBindIndexBuffer(cmd, r.indexBuffer, 0, VK_INDEX_TYPE_UINT32);
     }
     // calculate final mesh matrix
     GPUDrawPushConstants push_constants;
     push_constants.worldMatrix = r.transform;
     push_constants.vertexBuffer = r.vertexBufferAddress;

     vkCmdPushConstants(cmd, r.material->pipeline->layout, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(GPUDrawPushConstants), &push_constants);

     vkCmdDrawIndexed(cmd, r.indexCount, 1, r.firstIndex, 0, 0);
    //stats
    stats.drawcall_count++;
    stats.triangle_count += r.indexCount / 3;
 };

我们存储上一个管线、上一个材质和上一个索引缓冲区。我们首先检查管线是否已更改,如果已更改,我们将再次绑定管线,并重新绑定全局描述符集。我们还必须调用 SetViewport 和 SetScissor 命令。

然后,如果材质实例已更改,我们将绑定材质参数和纹理的描述符集。最后,如果索引缓冲区已更改,则再次绑定它。

我们现在应该获得性能提升,特别是当我们只有 2 个管线时,因此现在很多对 VkCmdBindPipeline 的调用都会消失。但让我们进一步改进它。在配备 ryzen 5950x 的 PC 上,这使 draw_geometry 时间减少了一半。

我们将按这些参数对渲染对象进行排序,以最大限度地减少调用次数。我们只会对不透明对象执行此操作,因为透明对象需要不同类型的排序(深度排序),我们没有这样做,因为我们没有关于对象中心的信息。

为了实现排序,我们不会对绘制数组本身进行排序,因为对象很大。相反,我们将对索引数组进行排序,以指向此绘制数组。这是大型引擎中的常用技术。

在 draw_geometry() 函数的开头,添加此内容

std::vector<uint32_t> opaque_draws;
opaque_draws.reserve(mainDrawContext.OpaqueSurfaces.size());

for (uint32_t i = 0; i < mainDrawContext.OpaqueSurfaces.size(); i++) {
    opaque_draws.push_back(i);
}

// sort the opaque surfaces by material and mesh
std::sort(opaque_draws.begin(), opaque_draws.end(), [&](const auto& iA, const auto& iB) {
    const RenderObject& A = mainDrawContext.OpaqueSurfaces[iA];
    const RenderObject& B = mainDrawContext.OpaqueSurfaces[iB];
    if (A.material == B.material) {
        return A.indexBuffer < B.indexBuffer;
    }
    else {
        return A.material < B.material;
    }
});

std::algorithms 有一个非常方便的排序函数,我们可以使用它来对 opaque_draws 向量进行排序。我们给它一个 lambda,它定义了一个 < 运算符,它可以有效地为我们排序。

我们将首先索引绘制数组,并检查材质是否相同,如果相同,则按 indexBuffer 排序。但如果不是,那么我们直接比较材质指针。另一种方法是我们会计算一个排序键,然后我们的 opaque_draws 将类似于 20 位绘制索引和 44 位排序键/哈希。这种方法会比这种方法更快,因为它可以通过更快的方法进行排序。

现在,对于绘制,我们从排序后的数组中绘制。将不透明绘制循环替换为以下循环。

for (auto& r : opaque_draws) {
    draw(mainDrawContext.OpaqueSurfaces[r]);
}

这样,渲染器将最大限度地减少描述符集绑定的数量,因为它将逐材质进行。我们仍然需要处理索引缓冲区绑定,但切换速度更快。

通过这样做,引擎现在应该具有更高的性能。仅渲染 1 个场景,排序成本使其是否更快变得无关紧要。我们正在进行低效的单线程排序,因此其成本很容易覆盖我们在减少 vulkan 调用中获得的性能。确保分析您的场景并决定是否要启用它。

但是我们在 CPU 和 GPU 上处理的对象比我们需要的要多得多,因为我们仍在渲染相机后面的对象。我们可以通过视锥裁剪来改进这一点。

视锥裁剪

现在我们渲染地图中的每个对象,但我们不必绘制视图之外的东西。当我们有绘制列表时,我们将对其进行过滤以检查哪些对象在视图中,并跳过那些不在视图中的对象。只要过滤的成本低于渲染对象的成本,我们就能获得胜利。

有多种视锥裁剪的方法,但根据我们拥有的数据和架构,我们将使用定向包围盒。我们将计算每个 GeoSurface 的边界,然后检查边界是否在视图中。

使用边界更新 vk_loader.h 中的结构体。

struct Bounds {
    glm::vec3 origin;
    float sphereRadius;
    glm::vec3 extents;
};

struct GeoSurface {
    uint32_t startIndex;
    uint32_t count;
    Bounds bounds;
	std::shared_ptr<GLTFMaterial> material;
};

还将 Bounds 结构体添加到 RenderObject

struct RenderObject {
    uint32_t indexCount;
    uint32_t firstIndex;
    VkBuffer indexBuffer;
    
    MaterialInstance* material;
    Bounds bounds;
    glm::mat4 transform;
    VkDeviceAddress vertexBufferAddress;
};

我们的边界是原点、范围(盒子大小)和球体半径。如果我们要使用其他视锥裁剪算法,则可以使用球体半径,并且还有其他用途。

为了计算它,我们必须将其添加到加载器代码中。

此代码位于 loadGLTF 函数内部,在加载网格数据的循环末尾。

//code that writes vertex buffers

//loop the vertices of this surface, find min/max bounds
glm::vec3 minpos = vertices[initial_vtx].position;
glm::vec3 maxpos = vertices[initial_vtx].position;
for (int i = initial_vtx; i < vertices.size(); i++) {
    minpos = glm::min(minpos, vertices[i].position);
    maxpos = glm::max(maxpos, vertices[i].position);
}
// calculate origin and extents from the min/max, use extent lenght for radius
newSurface.bounds.origin = (maxpos + minpos) / 2.f;
newSurface.bounds.extents = (maxpos - minpos) / 2.f;
newSurface.bounds.sphereRadius = glm::length(newSurface.bounds.extents);

newmesh->surfaces.push_back(newSurface);

从 MeshNode::Draw() 函数中,确保将边界复制到 RenderObject。现在它应该看起来像这样。

void MeshNode::Draw(const glm::mat4& topMatrix, DrawContext& ctx) {
    glm::mat4 nodeMatrix = topMatrix * worldTransform;

    for (auto& s : mesh->surfaces) {
        RenderObject def;
        def.indexCount = s.count;
        def.firstIndex = s.startIndex;
        def.indexBuffer = mesh->meshBuffers.indexBuffer.buffer;
        def.material = &s.material->data;
        def.bounds = s.bounds;
        def.transform = nodeMatrix;
        def.vertexBufferAddress = mesh->meshBuffers.vertexBufferAddress;

        if (s.material->data.passType == MaterialPass::Transparent) {
            ctx.TransparentSurfaces.push_back(def);
        } else {
            ctx.OpaqueSurfaces.push_back(def);
        }
    }

    // recurse down
    Node::Draw(topMatrix, ctx);
}

现在我们在 GeoSurface 上有了边界,只需要检查 RenderObject 上的可见性。将此函数添加到 vk_engine.cpp,它是一个全局函数。

bool is_visible(const RenderObject& obj, const glm::mat4& viewproj) {
    std::array<glm::vec3, 8> corners {
        glm::vec3 { 1, 1, 1 },
        glm::vec3 { 1, 1, -1 },
        glm::vec3 { 1, -1, 1 },
        glm::vec3 { 1, -1, -1 },
        glm::vec3 { -1, 1, 1 },
        glm::vec3 { -1, 1, -1 },
        glm::vec3 { -1, -1, 1 },
        glm::vec3 { -1, -1, -1 },
    };

    glm::mat4 matrix = viewproj * obj.transform;

    glm::vec3 min = { 1.5, 1.5, 1.5 };
    glm::vec3 max = { -1.5, -1.5, -1.5 };

    for (int c = 0; c < 8; c++) {
        // project each corner into clip space
        glm::vec4 v = matrix * glm::vec4(obj.bounds.origin + (corners[c] * obj.bounds.extents), 1.f);

        // perspective correction
        v.x = v.x / v.w;
        v.y = v.y / v.w;
        v.z = v.z / v.w;

        min = glm::min(glm::vec3 { v.x, v.y, v.z }, min);
        max = glm::max(glm::vec3 { v.x, v.y, v.z }, max);
    }

    // check the clip space box is within the view
    if (min.z > 1.f || max.z < 0.f || min.x > 1.f || max.x < -1.f || min.y > 1.f || max.y < -1.f) {
        return false;
    } else {
        return true;
    }
}

这只是我们可以用于视锥裁剪的多种可能函数之一。这种方法的工作原理是将网格空间包围盒的 8 个角中的每一个使用对象矩阵和视图投影矩阵转换为屏幕空间。对于这些角,我们找到屏幕空间盒子边界,并检查该盒子是否在裁剪空间视图内。与其他公式相比,这种计算边界的方法速度较慢,并且可能存在误报,即它认为对象可见但实际上不可见。所有函数都有不同的权衡,选择此函数是为了代码简洁性以及与我们在顶点着色器上执行的函数并行。

为了使用它,我们更改了我们添加的循环以填充 opaque_draws 数组。

for (int i = 0; i < mainDrawContext.OpaqueSurfaces.size(); i++) {
	if (is_visible(mainDrawContext.OpaqueSurfaces[i], sceneData.viewproj)) {
		opaque_draws.push_back(i);
	}
}

现在,我们不再添加 i,而是检查可见性。

渲染器现在将跳过视图之外的对象。它应该看起来与以前相同,但运行速度更快,绘制次数更少。如果您遇到视觉故障,请仔细检查 GeoSurface 的包围盒的构建,并查看 is_visible 函数中是否存在拼写错误。

用于对透明对象执行相同裁剪和排序的代码已被跳过,但它与不透明对象相同,因此您可以尝试自己完成它。

对于透明对象,您还需要更改排序代码,使其检查从边界到相机的距离,以便对象绘制更正确。但是按深度排序与按管线排序不兼容,因此您需要决定哪种方法更适合您的用例。

您应该看到统计窗口中的绘制调用计数和三角形计数在您旋转相机时发生变化。性能应该会显着提高,尤其是在您看向远离对象的情况下。

创建 Mipmaps

当我们添加纹理加载时,我们没有制作 mipmap。与 OpenGL 不同,没有直接的一键调用来生成它们。我们需要自己做。

create_image 已经具有 mipmap 支持,但我们需要更改上传数据的版本,以便它生成 mipmap。为此,我们将更改函数

AllocatedImage VulkanEngine::create_image(void* data, VkExtent3D size, VkFormat format, VkImageUsageFlags usage, bool mipmapped)
{
    size_t data_size = size.depth * size.width * size.height * 4;
    AllocatedBuffer uploadbuffer = create_buffer(data_size, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU);

    memcpy(uploadbuffer.info.pMappedData, data, data_size);

    AllocatedImage new_image = create_image(size, format, usage | VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT, mipmapped);

    immediate_submit([&](VkCommandBuffer cmd) {
        vkutil::transition_image(cmd, new_image.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);

        VkBufferImageCopy copyRegion = {};
        copyRegion.bufferOffset = 0;
        copyRegion.bufferRowLength = 0;
        copyRegion.bufferImageHeight = 0;

        copyRegion.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
        copyRegion.imageSubresource.mipLevel = 0;
        copyRegion.imageSubresource.baseArrayLayer = 0;
        copyRegion.imageSubresource.layerCount = 1;
        copyRegion.imageExtent = size;

        // copy the buffer into the image
        vkCmdCopyBufferToImage(cmd, uploadbuffer.buffer, new_image.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1,
            &copyRegion);

        if (mipmapped) {
            vkutil::generate_mipmaps(cmd, new_image.image,VkExtent2D{new_image.imageExtent.width,new_image.imageExtent.height});
        } else {
            vkutil::transition_image(cmd, new_image.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
        }
    });
    destroy_buffer(uploadbuffer);
    return new_image;
}

如果我们想在此图像上使用 mipmapping,immediate_submit 现在可以调用 vkutil::generate_mipmaps() 函数。让我们将其添加到 vk_images.h

namespace vkutil {
void generate_mipmaps(VkCommandBuffer cmd, VkImage image, VkExtent2D imageSize);
}

有多种生成 mipmap 的选项。我们也不必在加载时生成它们,并且可以使用 KTX 或 DDS 等格式,这些格式可以预先生成 mipmap。一种流行的选择是在计算着色器中生成它们,该着色器一次生成多个级别,这可以提高性能。我们将要制作 mipmap 的方式是使用一系列 VkCmdImageBlit 调用。

对于每个级别,我们需要将图像从前一个级别复制到下一个级别,每次将分辨率降低一半。在每次复制时,我们将 mipmap 级别转换为 VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL。完成所有复制后,我们添加另一个屏障,这次针对所有 mipmap 级别一次性转换,将图像转换为 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL

伪代码如下所示。

//image already comes with layout VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL on all mipmap levels from image creation

int miplevels = calculate_mip_levels(imageSize);
for (int mip = 0; mip < mipLevels; mip++) {

    barrier( image.mips[mip] , VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL)

    //not the last level
    if (mip < mipLevels - 1)
    {
        copy_image(image.mips[mip], image.mips[mip+1];)
    }
}

barrier( image , VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)

现在让我们看看实际代码

void vkutil::generate_mipmaps(VkCommandBuffer cmd, VkImage image, VkExtent2D imageSize)
{
    int mipLevels = int(std::floor(std::log2(std::max(imageSize.width, imageSize.height)))) + 1;
    for (int mip = 0; mip < mipLevels; mip++) {

        VkExtent2D halfSize = imageSize;
        halfSize.width /= 2;
        halfSize.height /= 2;

        VkImageMemoryBarrier2 imageBarrier { .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2, .pNext = nullptr };

        imageBarrier.srcStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;
        imageBarrier.srcAccessMask = VK_ACCESS_2_MEMORY_WRITE_BIT;
        imageBarrier.dstStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;
        imageBarrier.dstAccessMask = VK_ACCESS_2_MEMORY_WRITE_BIT | VK_ACCESS_2_MEMORY_READ_BIT;

        imageBarrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
        imageBarrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;

        VkImageAspectFlags aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
        imageBarrier.subresourceRange = vkinit::image_subresource_range(aspectMask);
        imageBarrier.subresourceRange.levelCount = 1;
        imageBarrier.subresourceRange.baseMipLevel = mip;
        imageBarrier.image = image;

        VkDependencyInfo depInfo { .sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO, .pNext = nullptr };
        depInfo.imageMemoryBarrierCount = 1;
        depInfo.pImageMemoryBarriers = &imageBarrier;

        vkCmdPipelineBarrier2(cmd, &depInfo);

        if (mip < mipLevels - 1) {
            VkImageBlit2 blitRegion { .sType = VK_STRUCTURE_TYPE_IMAGE_BLIT_2, .pNext = nullptr };

            blitRegion.srcOffsets[1].x = imageSize.width;
            blitRegion.srcOffsets[1].y = imageSize.height;
            blitRegion.srcOffsets[1].z = 1;

            blitRegion.dstOffsets[1].x = halfSize.width;
            blitRegion.dstOffsets[1].y = halfSize.height;
            blitRegion.dstOffsets[1].z = 1;

            blitRegion.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
            blitRegion.srcSubresource.baseArrayLayer = 0;
            blitRegion.srcSubresource.layerCount = 1;
            blitRegion.srcSubresource.mipLevel = mip;

            blitRegion.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
            blitRegion.dstSubresource.baseArrayLayer = 0;
            blitRegion.dstSubresource.layerCount = 1;
            blitRegion.dstSubresource.mipLevel = mip + 1;

            VkBlitImageInfo2 blitInfo {.sType = VK_STRUCTURE_TYPE_BLIT_IMAGE_INFO_2, .pNext = nullptr};
            blitInfo.dstImage = image;
            blitInfo.dstImageLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
            blitInfo.srcImage = image;
            blitInfo.srcImageLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
            blitInfo.filter = VK_FILTER_LINEAR;
            blitInfo.regionCount = 1;
            blitInfo.pRegions = &blitRegion;

            vkCmdBlitImage2(cmd, &blitInfo);

            imageSize = halfSize;
        }
    }

    // transition all mip levels into the final read_only layout
    transition_image(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
}

屏障与我们在 transition_image 上的屏障非常相似,而 blit 与我们在 copy_image_to_image 中的 blit 非常相似,但具有 mip 级别。在某种程度上,此函数结合了两者。

在每个循环中,我们将图像大小除以 2,转换我们从中复制的 mip 级别,并执行从一个 mip 级别到下一个 mip 级别的 VkCmdBlit。

确保更新 vk_loader.cpp 中 load_image 上的图像加载器代码,以确保它在 create_image 调用中将最后一个参数设置为 true,以便生成 mipmap。

这样,现在我们自动生成所需的 mipmap。我们已经使用正确的选项创建了采样器,因此它应该可以直接工作。