在我们开始绘制之前,我们需要实现其他几件事。首先,我们有一个删除队列,它允许我们安全地处理不断增长的对象数量的清理工作,然后我们将更改渲染循环,以绘制到非交换链图像中,然后将其复制到交换链。
删除队列
当我们开始添加越来越多的 vulkan 结构时,我们需要一种方法来处理它们的销毁。我们可以继续在 cleanup()
函数中添加更多内容,但这无法扩展,并且很难保持正确同步。我们将向引擎添加一个新的结构,称为 DeletionQueue。这是许多引擎常用的方法,我们将要删除的对象添加到某个队列中,然后运行该队列以按正确的顺序删除所有对象。在我们的实现中,我们将保持简单,并在 deque 中存储 std::function 回调。我们将使用该 deque 作为先进后出队列,以便在刷新删除队列时,它首先销毁最后添加到其中的对象。
这是整个实现。
struct DeletionQueue
{
std::deque<std::function<void()>> deletors;
void push_function(std::function<void()>&& function) {
deletors.push_back(function);
}
void flush() {
// reverse iterate the deletion queue to execute all the functions
for (auto it = deletors.rbegin(); it != deletors.rend(); it++) {
(*it)(); //call functors
}
deletors.clear();
}
};
std::function 存储一个 lambda,我们可以使用它来存储带有某些数据的回调,这非常适合这种情况。
像这样进行回调在规模上是低效的,因为我们为要删除的每个对象存储了完整的 std::function,这并不是最优的。对于我们将在本教程中使用的对象数量,这没问题。但是,如果您需要删除数千个对象并希望更快地删除它们,则更好的实现是存储各种类型的 vulkan 句柄数组,例如 VkImage、VkBuffer 等。然后从循环中删除它们。
我们将在多个位置拥有删除队列,用于对象的多个生命周期。其中一个位于引擎类本身上,将在引擎销毁时刷新。全局对象进入该队列。我们还将为飞行中的每个帧存储一个删除队列,这将使我们能够在对象被使用后的下一帧删除对象。
将其添加到主类内部的 VulkanEngine 类中,以及 FrameData 结构体内部
struct FrameData {
//other data
DeletionQueue _deletionQueue;
};
class VulkanEngine{
//other data
DeletionQueue _mainDeletionQueue;
}
然后我们在 2 个位置调用它,分别是在每帧等待 Fence 之后,以及在 WaitIdle 调用之后的 cleanup() 函数中。通过在栅栏之后立即刷新它,我们确保 GPU 已完成执行该帧,因此我们可以安全地删除仅为该特定帧创建的对象。我们还希望确保在销毁帧数据的其余部分时释放这些每帧资源。
void VulkanEngine::draw()
{
//wait until the gpu has finished rendering the last frame. Timeout of 1 second
VK_CHECK(vkWaitForFences(_device, 1, &get_current_frame()._renderFence, true, 1000000000));
get_current_frame()._deletionQueue.flush();
//other code
}
void VulkanEngine::cleanup()
{
if (_isInitialized) {
//make sure the gpu has stopped doing its things
vkDeviceWaitIdle(_device);
//free per-frame structures and deletion queue
for (int i = 0; i < FRAME_OVERLAP; i++) {
vkDestroyCommandPool(_device, _frames[i]._commandPool, nullptr);
//destroy sync objects
vkDestroyFence(_device, _frames[i]._renderFence, nullptr);
vkDestroySemaphore(_device, _frames[i]._renderSemaphore, nullptr);
vkDestroySemaphore(_device, _frames[i]._swapchainSemaphore, nullptr);
_frames[i]._deletionQueue.flush();
}
//flush the global deletion queue
_mainDeletionQueue.flush();
//rest of cleanup function
}
}
设置删除队列后,现在每当我们创建新的 vulkan 对象时,我们都可以将它们添加到队列中。
内存分配
为了改进渲染循环,我们将需要分配一个图像,这将引导我们了解如何在 vulkan 中分配对象。我们将跳过整个章节,因为我们将使用 Vulkan Memory Allocator 库。处理不同的内存堆和对象限制(例如图像对齐)非常容易出错,并且很难做好,特别是如果您想获得体面的性能。通过使用 VMA,我们跳过了所有这些,并且获得了一种经过实战检验且保证运行良好的方法。在 PCSX3 模拟器项目等案例中,他们用 VMA 替换了他们的分配尝试,并额外赢得了 20% 的帧率。
vk_types.h 已经包含了 VMA 库所需的包含,但我们还需要做其他事情。
在 vk_engine.cpp 中,我们也包含它,但定义了 VMA_IMPLEMENTATION
。
#define VMA_IMPLEMENTATION
#include "vk_mem_alloc.h"
VMA 将普通头文件和函数的实现都保存在同一个头文件中。我们需要在项目的其中一个 .cpp 文件中精确定义 VMA_IMPLEMENTATION
,这将存储和编译 VMA 函数的定义。
将分配器添加到 VulkanEngine 类
class VulkanEngine{
VmaAllocator _allocator;
}
现在我们将从 init_vulkan()
调用中初始化它,在该函数的末尾。
// initialize the memory allocator
VmaAllocatorCreateInfo allocatorInfo = {};
allocatorInfo.physicalDevice = _chosenGPU;
allocatorInfo.device = _device;
allocatorInfo.instance = _instance;
allocatorInfo.flags = VMA_ALLOCATOR_CREATE_BUFFER_DEVICE_ADDRESS_BIT;
vmaCreateAllocator(&allocatorInfo, &_allocator);
_mainDeletionQueue.push_function([&]() {
vmaDestroyAllocator(_allocator);
});
没有什么太多可解释的,我们正在初始化 _allocator 成员,然后将其销毁函数添加到销毁队列中,以便在引擎退出时将其清除。我们将物理设备、实例和设备挂钩到创建函数。我们给出了标志 VMA_ALLOCATOR_CREATE_BUFFER_DEVICE_ADDRESS_BIT
,这将使我们稍后在需要时可以使用 GPU 指针。Vulkan Memory Allocator 库遵循与 vulkan api 类似的调用约定,因此它使用类似的信息结构。
新的绘制循环。
直接绘制到交换链对于许多项目来说很好,在某些情况下(例如手机)甚至可能是最佳的。但它有一些限制。其中最重要的是交换链中使用的图像格式不能保证。不同的操作系统、驱动程序和窗口模式可以具有不同的最佳交换链格式。HDR 支持等功能也需要其自身非常特定的格式。另一个是,我们只能从窗口呈现系统获得交换链图像索引。有一些低延迟技术,我们可以渲染到另一个图像中,然后以非常低的延迟将该图像直接推送到交换链。
一个非常重要的限制是,它们的分辨率固定为您的窗口大小。如果您想要更高或更低的分辨率,然后进行一些缩放逻辑,则需要绘制到不同的图像中。
最后,交换链格式在很大程度上是低精度的。一些具有高动态范围渲染的平台具有更高精度的格式,但您通常会默认为每种颜色 8 位。因此,如果您想要高精度光照计算、可以防止条带化的系统,或者能够超出标准化颜色范围 1.0,则需要单独的图像进行绘制。
由于所有这些原因,我们将在本教程中将整个渲染过程绘制到与交换链不同的图像中。在我们完成绘制后,我们将把该图像复制到交换链图像并将其呈现到屏幕上。
我们将使用的图像将采用 RGBA 16 位浮点格式。这有点过分,但将为我们提供大量额外的精度,这在进行光照计算和更好的渲染时会派上用场。
Vulkan 图像
在设置交换链时,我们已经肤浅地处理了图像,但这由 VkBootstrap 处理。这次我们将自己创建图像。
让我们首先添加我们将需要的新成员到 VulkanEngine 类中。
在 vk_types.h 上,添加此结构体,其中包含图像所需的数据。我们将保存一个 VkImage
及其默认 VkImageView
,然后是图像内存的分配,最后是图像大小及其格式,这在处理图像时很有用。我们还添加了一个 _drawExtent
,我们可以用它来决定要渲染的大小。
struct AllocatedImage {
VkImage image;
VkImageView imageView;
VmaAllocation allocation;
VkExtent3D imageExtent;
VkFormat imageFormat;
};
class VulkanEngine{
//draw resources
AllocatedImage _drawImage;
VkExtent2D _drawExtent;
}
让我们检查 vk_initializers 函数以获取图像和图像视图创建信息。
VkImageCreateInfo vkinit::image_create_info(VkFormat format, VkImageUsageFlags usageFlags, VkExtent3D extent)
{
VkImageCreateInfo info = {};
info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
info.pNext = nullptr;
info.imageType = VK_IMAGE_TYPE_2D;
info.format = format;
info.extent = extent;
info.mipLevels = 1;
info.arrayLayers = 1;
//for MSAA. we will not be using it by default, so default it to 1 sample per pixel.
info.samples = VK_SAMPLE_COUNT_1_BIT;
//optimal tiling, which means the image is stored on the best gpu format
info.tiling = VK_IMAGE_TILING_OPTIMAL;
info.usage = usageFlags;
return info;
}
VkImageViewCreateInfo vkinit::imageview_create_info(VkFormat format, VkImage image, VkImageAspectFlags aspectFlags)
{
// build a image-view for the depth image to use for rendering
VkImageViewCreateInfo info = {};
info.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
info.pNext = nullptr;
info.viewType = VK_IMAGE_VIEW_TYPE_2D;
info.image = image;
info.format = format;
info.subresourceRange.baseMipLevel = 0;
info.subresourceRange.levelCount = 1;
info.subresourceRange.baseArrayLayer = 0;
info.subresourceRange.layerCount = 1;
info.subresourceRange.aspectMask = aspectFlags;
return info;
}
我们将硬编码图像平铺为 OPTIMAL,这意味着我们允许 gpu 随意改组数据。如果我们想从 cpu 读取图像数据,我们需要使用平铺 LINEAR,这将 gpu 数据变成一个简单的 2d 数组。这种平铺高度限制了 gpu 可以执行的操作,因此 LINEAR 的唯一真正用例是 CPU 读回。
在图像视图创建中,我们需要设置子资源。这类似于我们在管线屏障中使用的子资源。
现在,在 init_swapchain 的末尾,让我们创建它。
//draw image size will match the window
VkExtent3D drawImageExtent = {
_windowExtent.width,
_windowExtent.height,
1
};
//hardcoding the draw format to 32 bit float
_drawImage.imageFormat = VK_FORMAT_R16G16B16A16_SFLOAT;
_drawImage.imageExtent = drawImageExtent;
VkImageUsageFlags drawImageUsages{};
drawImageUsages |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
drawImageUsages |= VK_IMAGE_USAGE_TRANSFER_DST_BIT;
drawImageUsages |= VK_IMAGE_USAGE_STORAGE_BIT;
drawImageUsages |= VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;
VkImageCreateInfo rimg_info = vkinit::image_create_info(_drawImage.imageFormat, drawImageUsages, drawImageExtent);
//for the draw image, we want to allocate it from gpu local memory
VmaAllocationCreateInfo rimg_allocinfo = {};
rimg_allocinfo.usage = VMA_MEMORY_USAGE_GPU_ONLY;
rimg_allocinfo.requiredFlags = VkMemoryPropertyFlags(VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
//allocate and create the image
vmaCreateImage(_allocator, &rimg_info, &rimg_allocinfo, &_drawImage.image, &_drawImage.allocation, nullptr);
//build a image-view for the draw image to use for rendering
VkImageViewCreateInfo rview_info = vkinit::imageview_create_info(_drawImage.imageFormat, _drawImage.image, VK_IMAGE_ASPECT_COLOR_BIT);
VK_CHECK(vkCreateImageView(_device, &rview_info, nullptr, &_drawImage.imageView));
//add to deletion queues
_mainDeletionQueue.push_function([=]() {
vkDestroyImageView(_device, _drawImage.imageView, nullptr);
vmaDestroyImage(_allocator, _drawImage.image, _drawImage.allocation);
});
我们首先创建一个 VkExtent3d 结构体,其大小为我们想要的图像大小,这将与我们的窗口大小匹配。我们将其复制到 AllocatedImage 中
然后,我们需要填充我们的使用标志。在 vulkan 中,所有图像和缓冲区都必须填充 UsageFlags 以及它们将用于的用途。这允许驱动程序根据该缓冲区或图像稍后将执行的操作在后台执行优化。在我们的例子中,我们想要 TransferSRC 和 TransferDST,以便我们可以从图像复制和复制到图像,Storage 是因为它是“计算着色器可以写入它”的布局,以及 Color Attachment,以便我们可以使用图形管线将几何体绘制到其中。
格式将是 VK_FORMAT_R16G16B16A16_SFLOAT
。这是所有 4 个通道的 16 位浮点数,每个像素将使用 64 位。这相当多的数据,是 8 位彩色图像使用的 2 倍,但它将很有用。
在创建图像本身时,我们需要将图像信息和分配信息发送到 VMA。VMA 将为我们执行 vulkan 创建调用,并直接为我们提供 vulkan 图像。这里有趣的是 Usage 和所需的内存标志。使用 VMA_MEMORY_USAGE_GPU_ONLY 用法,我们让 VMA 知道这是一个永远不会从 CPU 访问的 gpu 纹理,这让它可以将其放入 gpu VRAM 中。为了进一步确保这一点,我们还将 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
设置为内存标志。这是一个只有 gpu 侧 VRAM 才有的标志,并保证最快的访问。
在 vulkan 中,我们可以从中分配图像和缓冲区的内存区域有很多个。具有独立 GPU 的 PC 实现通常将具有 cpu 内存区域、GPU Vram 区域和一个“上传堆”,这是一个特殊的 gpu vram 区域,允许 cpu 写入。如果您启用了可调整大小的 bar,则上传堆可以是整个 gpu vram。否则它会小得多,通常只有 256 兆字节。我们告诉 VMA 将其放在 GPU_ONLY 上,这将优先将其放在 gpu vram 上,但在该上传堆区域之外。
分配图像后,我们创建一个图像视图以与其配对。在 vulkan 中,您需要一个图像视图才能访问图像。这通常是图像本身上的一个薄包装器,可让您执行诸如限制对仅 1 个 mipmap 的访问之类的操作。在本教程中,我们将始终将 vkimage 与其“默认”图像视图配对。
新的绘制循环
现在我们有了新的绘制图像,让我们将其添加到渲染循环中。
我们将需要一种复制图像的方法,因此将其添加到 vk_images.cpp 中
void vkutil::copy_image_to_image(VkCommandBuffer cmd, VkImage source, VkImage destination, VkExtent2D srcSize, VkExtent2D dstSize)
{
VkImageBlit2 blitRegion{ .sType = VK_STRUCTURE_TYPE_IMAGE_BLIT_2, .pNext = nullptr };
blitRegion.srcOffsets[1].x = srcSize.width;
blitRegion.srcOffsets[1].y = srcSize.height;
blitRegion.srcOffsets[1].z = 1;
blitRegion.dstOffsets[1].x = dstSize.width;
blitRegion.dstOffsets[1].y = dstSize.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 = 0;
blitRegion.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
blitRegion.dstSubresource.baseArrayLayer = 0;
blitRegion.dstSubresource.layerCount = 1;
blitRegion.dstSubresource.mipLevel = 0;
VkBlitImageInfo2 blitInfo{ .sType = VK_STRUCTURE_TYPE_BLIT_IMAGE_INFO_2, .pNext = nullptr };
blitInfo.dstImage = destination;
blitInfo.dstImageLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
blitInfo.srcImage = source;
blitInfo.srcImageLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
blitInfo.filter = VK_FILTER_LINEAR;
blitInfo.regionCount = 1;
blitInfo.pRegions = &blitRegion;
vkCmdBlitImage2(cmd, &blitInfo);
}
还要将相应的声明添加到 vk_images.h
namespace vkutil {
// Existing:
void transition_image(VkCommandBuffer cmd, VkImage image, VkImageLayout currentLayout, VkImageLayout newLayout);
void copy_image_to_image(VkCommandBuffer cmd, VkImage source, VkImage destination, VkExtent2D srcSize, VkExtent2D dstSize);
}
Vulkan 有 2 种将一个图像复制到另一个图像的主要方法。您可以使用 VkCmdCopyImage 或 VkCmdBlitImage。CopyImage 更快,但限制更多,例如,两个图像的分辨率必须匹配。同时,blit image 允许您将不同格式和不同大小的图像相互复制。您有一个源矩形和一个目标矩形,系统会将其复制到其位置。这两个函数在设置引擎时很有用,但稍后最好忽略它们,并编写您自己的版本,该版本可以在全屏片段着色器上执行额外的逻辑。
有了它,我们现在可以更新渲染循环。由于 draw() 变得太大,我们将把同步、命令缓冲区管理和转换保留在 draw() 函数中,但我们将把绘制命令本身添加到 draw_background() 函数中。
void VulkanEngine::draw_background(VkCommandBuffer cmd)
{
//make a clear-color from frame number. This will flash with a 120 frame period.
VkClearColorValue clearValue;
float flash = std::abs(std::sin(_frameNumber / 120.f));
clearValue = { { 0.0f, 0.0f, flash, 1.0f } };
VkImageSubresourceRange clearRange = vkinit::image_subresource_range(VK_IMAGE_ASPECT_COLOR_BIT);
//clear image
vkCmdClearColorImage(cmd, _drawImage.image, VK_IMAGE_LAYOUT_GENERAL, &clearValue, 1, &clearRange);
}
也将该函数添加到头文件中。
我们将更改记录命令缓冲区的代码。您现在可以删除旧代码。新代码如下。
_drawExtent.width = _drawImage.imageExtent.width;
_drawExtent.height = _drawImage.imageExtent.height;
VK_CHECK(vkBeginCommandBuffer(cmd, &cmdBeginInfo));
// transition our main draw image into general layout so we can write into it
// we will overwrite it all so we dont care about what was the older layout
vkutil::transition_image(cmd, _drawImage.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
draw_background(cmd);
//transition the draw image and the swapchain image into their correct transfer layouts
vkutil::transition_image(cmd, _drawImage.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL);
vkutil::transition_image(cmd, _swapchainImages[swapchainImageIndex], VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
// execute a copy from the draw image into the swapchain
vkutil::copy_image_to_image(cmd, _drawImage.image, _swapchainImages[swapchainImageIndex], _drawExtent, _swapchainExtent);
// set swapchain image layout to Present so we can show it on the screen
vkutil::transition_image(cmd, _swapchainImages[swapchainImageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR);
//finalize the command buffer (we can no longer add commands, but it can now be executed)
VK_CHECK(vkEndCommandBuffer(cmd));
我们在渲染循环中的主要区别是我们不再在交换链图像上进行清除。相反,我们在 _drawImage.image
上执行此操作。清除图像后,我们将交换链和绘制图像都转换为传输布局,然后执行复制命令。完成复制命令后,我们将交换链图像转换为呈现布局以进行显示。由于我们始终在同一图像上绘制,因此我们的 draw_image 不需要访问交换链索引,它只是清除绘制图像。我们还在编写 _drawExtent,我们将将其用于我们的绘制区域。
现在这将为我们提供一种在交换链本身之外渲染图像的方法。我们现在获得了明显更高的像素精度,并且我们解锁了一些其他技术。
完成此操作后,我们现在可以进入实际的计算着色器执行步骤。
下一步: Vulkan 着色器