В этой заметке рассмотрим тот кусок кода, который непосредственно берет изображение из нашего свопчейна, заставляет видеопроцессор нарисовать в нем что-то и отправляет готовое изображение на экран монитора.
Command Buffer
Программы, которые мы пишем, используя графические API, состоят из вызовов функций. Вероятно, большинство из них (в OpenGL это команды, такие как glDrawElements, glBindBuffer, glUseProgram и другие) изменяют состояние GPU, заставляют GPU что-то нарисовать… Такие функции отправляют видеопроцессору команды что-то сделать. Во всех API эти команды буферизуются, т. е. накапливаются в некоем буфере памяти перед тем как быть отправленными на выполнение GPU. Как любая буферизация, это делается для того, чтобы сократить количество трансакций по шине. В API предыдущего поколения (OpenGL, DirectX 11) буферизацией команд занимается драйвер видеокарты, т. е. программист явным образом не управляет буферизацией. Впрочем, в этих API есть функции, которые заставляют драйвер немедленно отправить накопленные команды видеопроцессору, в OpenGL это glFlush и glFinish, в DirectX — ID3D11DeviceContext::Flush. В API последнего поколения (Vulkan, DirectX 12) буферизацией команд управляет программист, т. е. он создает в памяти буфер для накопления команд и записывает в него команды.
В Vulkan буфер выделяется из некой кучи (CommandPool), которую необходимо предварительно создать. При создании ее мы должны указать какому семейству очередей команд (command queue family) будут предназначены команды — от этого зависит то, в каком типе памяти будет создан CommandPool:
// Field
vk::CommandPool m_GraphicsCommandPool;
// Constructor
m_GraphicsCommandPool(CreateGraphicsCommandPool())
// Function
vk::CommandPool CreateGraphicsCommandPool()
{
vk::CommandPoolCreateInfo commandPoolCreateInfo
{
// This flag allows command buffer, created from the pool, to be rerecorded (reset) which is not allowed by default.
.flags = vk::CommandPoolCreateFlagBits::eResetCommandBuffer,
// If there is more than one hardware queue in the GPU hardware, as described by
// the physical device queue families, then the driver might need to allocate command
// buffer pools with different memory allocation attributes, specific to each GPU
// hardware queue. These details are handled for you by the driver as long as it knows
// the queue family containing the queue that the command buffer will use.
// https://vulkan.lunarg.com/doc/view/1.2.170.0/linux/tutorial/html/04-init_command_buffer.html
.queueFamilyIndex = m_VkGraphicsContext.GraphicsQueueFamilyIndex() // Queue Family type that buffers from this command pool will use
};
return logicalDevice.createCommandPool(commandPoolCreateInfo);
}
// Destructor
logicalDevice.destroyCommandPool(m_GraphicsCommandPool);
Теперь мы приступаем к созданию буфера команд, точнее нескольких буферов команд. Нам нужно столько буферов команд, сколько у нас изображений в нашем свопчейне. Почему? Потому что мы собираемся выполнять несколько командных буферов параллельно (выполнять параллельно в GPU, но submit’ить последовательно в CPU). Почему мы собираемся выполнять несколько буферов параллельно? Потому что у нас несколько изображений в свопчейне, и мы хотим отрисовывать их настолько быстро, насколько возможно, поэтому — параллельно. Итак, сколько изображений в свопчейне, столько и командных буферов:
// Field
std::vector<vk::CommandBuffer> m_GraphicsCommandBuffers;
// Constructor
m_GraphicsCommandBuffers(CreateGraphicsCommandBuffers(numberOfSwapchainImages))
// Function
// Because one of the drawing commands involves binding the right VkFramebuffer, we'll actually have
// to record a command buffer for every image in the swap chain.
// https://vulkan-tutorial.com/Drawing_a_triangle/Drawing/Command_buffers
std::vector<vk::CommandBuffer> CreateGraphicsCommandBuffers(
uint32_t numberOfSwapchainImages)
{
vk::CommandBufferAllocateInfo cbAllocInfo
{
.commandPool = m_GraphicsCommandPool,
// Primary buffer is the one that you submit directly to queue and can't be called by other command buffers
.level = vk::CommandBufferLevel::ePrimary,
.commandBufferCount = numberOfSwapchainImages
};
return logicalDevice.allocateCommandBuffers(cbAllocInfo);
}
Заметим, что в каждый из созданных нами буферов будут записаны почти одинаковые наборы команд, но все же отличающиеся одной деталью. У каждого изображения в свопчейне есть целочисленный индекс, который его идентифицирует. И, как мы уже знаем, с каждым изображением связан свой отдельный фреймбуфер. Так вот ссылка на этот фреймбуфер будет фигурировать в каждом наборе команд и в каждом наборе она будет своя.
Синхронизация: заборы и семафоры (fences and semaphores)
Забор (fence) осуществляет синхронизацию между потоком выполнения в CPU и потоком в GPU, а именно: CPU опускает (закрывает) забор (функция resetFences); когда GPU заканчивает некую работу, он открывает (signals) забор; CPU ждет, когда откроется забор (функция waitForFences). Непосредственно после создания забор находится в открытом состоянии (signaled state).
Семафор (binary semaphore) осуществляет синхронизацию между потоками выполнения внутри GPU: один поток GPU ожидает зажигания семафора (semaphore wait operation), другой — зажигает семафор (semaphore signal operation), как только выполнена некая операция. Причем, тот, который ожидает зажигания семафора, дождавшись этого зажигания тут же его гасит (sets semaphore to unsignaled state). Непосредственно после создания семафор находится в незажженном состоянии (unsignaled state). Когда мы отправляем (submit) командный(ые) буфер(ы) в очередь команд на выполнение, мы указываем массив семафоров, которые GPU нужно ожидать перед тем как начать выполнение команд; мы также указываем массив семафоров, которые GPU должен зажечь после того, как все команды выполнены.
Как уже говорилось выше, можно параллельно (независимо друг от друга) отрисовывать несколько изображений свопчейна. В принципе можно параллельно отрисовывать столько изображений, сколько у нас есть в свопчейне, но можно ограничиться и меньшим количеством параллельно отрисовываемых изображений, скажем, двумя. Как мы увидим далее, нам понадобится по три примитива синхронизации для каждого из тех кадров, которые мы собираемся отрисовывать параллельно: два семафора и один забор. Коль скоро таких кадров у нас будет два, то нам понадобятся три массива примитивов синхронизации по два элемента в каждом массиве (два массива семафоров и один массив заборов):
// Field
// how many images we can have submitted at once
static constexpr size_t MAX_FRAME_DRAWS = 2;
int m_CurrentFrame;
std::array<vk::Semaphore, MAX_FRAME_DRAWS> m_SemaphoreImageAvailable;
std::array<vk::Semaphore, MAX_FRAME_DRAWS> m_SemaphoreRenderFinished;
std::array<vk::Fence, MAX_FRAME_DRAWS> m_DrawFences;
// Constructor
m_CurrentFrame(0),
m_SemaphoreImageAvailable
{
logicalDevice.createSemaphore(vk::SemaphoreCreateInfo()),
logicalDevice.createSemaphore(vk::SemaphoreCreateInfo())
},
m_SemaphoreRenderFinished
{
logicalDevice.createSemaphore(vk::SemaphoreCreateInfo()),
logicalDevice.createSemaphore(vk::SemaphoreCreateInfo())
},
m_DrawFences
{
logicalDevice.createFence(vk::FenceCreateInfo { .flags = vk::FenceCreateFlagBits::eSignaled }),
logicalDevice.createFence(vk::FenceCreateInfo { .flags = vk::FenceCreateFlagBits::eSignaled })
}
// Destructor
for (size_t i = 0; i < MAX_FRAME_DRAWS; i++)
{
logicalDevice.destroyFence(m_DrawFences[i]);
logicalDevice.destroySemaphore(m_SemaphoreRenderFinished[i]);
logicalDevice.destroySemaphore(m_SemaphoreImageAvailable[i]);
}
В следующем разделе мы увидим, для чего и как используются эти семафоры и заборы.
Acquire-Submit-Present
Процесс отрисовки одного изображения состоит из трех этапов:
- Получить (acquire) очередное изображение
- Подготовить командный буфер (очистить его и записать в него команды)
- Отправить (submit) командный буфер на выполнение в очередь команд
- Отправить изображение на экран (present)
Эти четыре этапа могут выполняться более или менее параллельно, но отдельные их подэтапы должны выполняться в определенной последовательности, а именно:
- Перед тем как очищать командный буфер и записывать в него команды («подэтап 1» — выполняется CPU), нужно подождать пока будут выполнены команды, уже записанные туда ранее («подэтап 2» — выполняется GPU). Поскольку подэтап 1 выполняется в потоке CPU, а подэтап 2 выполняется в очереди GPU, для их синхронизации мы используем забор
m_DrawFences[m_CurrentFrame]
(m_CurrentFrame — это номер текущего кадра, который увеличивается после каждой отрисовки, пока не достигнет значения MAX_FRAME_DRAWS, и тогда он сбрасывается в 0) - Перед тем, как командный буфер, отправленный в очередь команд, начнет выполняться, изображение из свопчейна должно стать доступно для записи (presentation engine должен закончить чтение из этого изображения). Эту зависимость между подэтапами (оба из которых выполняются на стороне GPU) будет регулировать семафор
m_SemaphoreImageAvailable[m_CurrentFrame]
- Перед тем, как изображение отправится на экран (image is issued to the presentation engine), это самое изображение должно быть отрисовано (т. е. определен цвет каждого пикселя; пиксели хранятся в соответствующем фреймбуфере), т. е. выполнены команды из буфера команд. Данная зависимость между подэтапами (оба из которых так же выполняются на стороне GPU) будет регулироваться семафором
m_SemaphoreRenderFinished[m_CurrentFrame]
Ссылки на соответствующие заборы и семафоры передаются в функции vkAcquireNextImageKHR, vkWaitForFences, vkResetFences, vkQueueSubmit, vkQueuePresentKHR
. Итак, вот код, который выполняет отрисовку одного кадра:
std::function<const vk::CommandBuffer&(uint32_t)> prepareCommandBuffer)
{
// 0. Host program waits until fence is signaled ("opened")
// i.e. until all submitted command buffers have completed execution.
logicalDevice.waitForFences(
1, &m_DrawFences[m_CurrentFrame],
VK_TRUE, // whether to wait for all of our fences
std::numeric_limits<uint64_t>::max() // timeout
);
// Reset ("close") fence
logicalDevice.resetFences(
1, &m_DrawFences[m_CurrentFrame]);
// 1. Get next available image to draw to and set a semaphore that
// will be signaled when the image becomes available.
uint32_t imageIndex;
logicalDevice.acquireNextImageKHR(
m_VulkanSwapchain.Swapchain(),
std::numeric_limits<uint64_t>::max(), // timeout
m_SemaphoreImageAvailable[m_CurrentFrame], // semaphore to signal when the image becomes available (semaphore synchronizes GPU<->GPU actions)
// The presentation engine may not have finished reading from the image at the time it is acquired,
// so the application must use semaphore and/or fence to ensure that the image layout and contents
// are not modified until the presentation engine reads have completed.
vk::Fence(), // a fence to signal (fence synchronizes GPU<->CPU actions)
&imageIndex);
// 2. Prepare the command buffer (record commands if necessary)
const vk::CommandBuffer& commandBuffer = prepareCommandBuffer(imageIndex);
// 3. Submit command buffer to queue for execution, making sure it waits for the image
// to be signaled as available before drawing and signals it has finished rendering.
std::array<vk::PipelineStageFlags, 1> waitStages
{
vk::PipelineStageFlags(vk::PipelineStageFlagBits::eColorAttachmentOutput)
};
m_VkGraphicsContext.GraphicsQueue().submit(
vk::SubmitInfo
{
.waitSemaphoreCount = 1,
.pWaitSemaphores = &m_SemaphoreImageAvailable[m_CurrentFrame], // semaphores to wait before the command buffers begin execution
.pWaitDstStageMask = waitStages.data(), // array of pipeline stages at which each corresponding semaphore wait will occur
.commandBufferCount = 1,
.pCommandBuffers = &commandBuffer, // command buffers to submit
.signalSemaphoreCount = 1,
.pSignalSemaphores = &m_SemaphoreRenderFinished[m_CurrentFrame] // semaphores to signal when command buffer finishes
},
m_DrawFences[m_CurrentFrame] // a fence to be signaled once all submitted command buffers have completed execution
);
// 4. Present image to screen when it has signaled finished rendering.
vk::PresentInfoKHR presentInfo
{
.waitSemaphoreCount = 1,
.pWaitSemaphores = &m_SemaphoreRenderFinished[m_CurrentFrame], // semaphores to wait for before issuing the present request
.swapchainCount = 1,
.pSwapchains = &m_VulkanSwapchain.Swapchain(), // swapchains to present images to
.pImageIndices = &imageIndex
};
m_VkGraphicsContext.PresentationQueue().presentKHR(&presentInfo);
m_CurrentFrame = (m_CurrentFrame + 1) % MAX_FRAME_DRAWS;
}
Swapchain Recreation
Существуют условия, при которых мы вынуждены пересоздавать Swapchain. Связано это с изменением размеров окна, ведь, как мы видели, Swapchain содержит набор изображений и связанных с ними фреймбуферов, каждый из которых имеет определенную длину и ширину. Поэтому при изменении размеров окна, и Swapchain должен быть создан заново. Пересоздание свопчейна влечет за собой также пересоздание ряда других объектов, которые от этого свопчейна зависят, а именно:
- Командные буферы (их ведь у нас столько же, сколько изображений в свопчейне, а оно может измениться после пересоздания)
- RenderPass (он зависит от формата изображений в свопчейне)
- Фреймбуферы (поскольку они ссылаются на изображения в свопчейне)
- Дескрипторы переменных (uniform variables), поскольку их количество равно количеству изображений в свопчейне
- Сами переменные uniform variables, поскольку их количество равно количеству изображений в свопчейне
Помимо вышеперечисленного, есть объекты, которые также необходимо пересоздавать при изменении размеров окна. Это буфер глубины (так как его размер зависит от размеров окна), а также Pipeline (в том случае, если размеры viewport’а и scissor’а, которые зависят от размеров окна, не были указаны как dynamic states).
Теперь давайте разберемся с тем, как узнать, а нужно ли пересоздавать свопчейн. Во-первых, когда меняются размеры окна, в Windows окно получает сообщение WM_SIZE. Во вторых, факт несоответствия поверхности (surface) и нашего свопчейна будет отражен в возвращаемом значении функций vkAcquireNextImageKHR и vkQueuePresentKHR, которые мы вызываем, когда производим отрисовку: они вернут значения VK_ERROR_OUT_OF_DATE_KHR или VK_SUBOPTIMAL_KHR.
{
// 1. Get next available image to draw to
auto result = logicalDevice.acquireNextImageKHR(...);
// A surface has changed in such a way that it is no longer compatible with the swapchain, and further
// presentation requests using the swapchain will fail. Applications must query the new surface properties
// and recreate their swapchain if they wish to continue presenting to the surface.
// https://www.khronos.org/registry/vulkan/specs/1.2-extensions/html/vkspec.html#vkAcquireNextImageKHR
if (result == vk::Result::eErrorOutOfDateKHR // The surface is no longer compatible with the swapchain and the swapchain cannot be used to present to the surface
|| result == vk::Result::eSuboptimalKHR) // A swapchain no longer matches the surface properties exactly, but can still be used to present to the surface successfully
{
RecreateSwapchain();
return;
}
else if (result != vk::Result::eSuccess)
throw std::runtime_error("Failed to acquire swap chain image!");
...
// 4. Present image to screen
result = m_VkGraphicsContext.PresentationQueue().presentKHR(&presentInfo);
if (result == vk::Result::eErrorOutOfDateKHR // The surface is no longer compatible with the swapchain and the swapchain cannot be used to present to the surface
|| result == vk::Result::eSuboptimalKHR) // A swapchain no longer matches the surface properties exactly, but can still be used to present to the surface successfully
{
RecreateSwapchain();
return;
}
else if (result != vk::Result::eSuccess)
throw std::runtime_error("Failed to present swap chain image!");
...
}
void RecreateSwapchain()
{
// Wait on the host for the completion of outstanding queue
// operations for all queues on a given logical device because
// we shouldn't touch resources that may still be in use.
logicalDevice.waitIdle();
// Cleanup swapchain and all the stuff that depends on it.
// The uniform descriptor pool should be destroyed when the swap chain is recreated because it depends on the number of images.
logicalDevice.destroyDescriptorPool(m_UniformDescriptorPool);
#ifndef USE_DYNAMIC_STATES
// Viewport and scissor rectangle size is specified during graphics pipeline creation, so the pipeline also needs to be rebuilt.
// It is possible to avoid this by using dynamic state for the viewports and scissor rectangles.
DestroyGraphicsPipeline();
#endif
logicalDevice.freeCommandBuffers(
m_GraphicsCommandPool, m_GraphicsCommandBuffers);
// Obviously, we'll have to recreate the swap chain itself.
DestroySwapchain();
m_VulkanSwapchain = CreateSwapchain();
m_GraphicsCommandBuffers = CreateGraphicsCommandBuffers(
m_VulkanSwapchain.NumberOfImages());
#ifndef USE_DYNAMIC_STATES
// Viewport and scissor rectangle size is specified during graphics pipeline creation, so the pipeline also needs to be rebuilt.
// It is possible to avoid this by using dynamic state for the viewports and scissor rectangles.
m_GraphicsPipeline = CreateGraphicsPipeline();
#endif
// The uniform descriptor pool should be recreated when the swap chain is recreated because it depends on the number of images.
m_UniformDescriptorPool = CreateUniformDescriptorPool();
// Because we've recreated uniform descriptor pool, we need to recreate descriptor sets.
m_UniformDescriptorSets = CreateUniformDescriptorSets();
}
Традиционно дополняем объектную модель программы новыми сведениями:
На этом всё. В следующей заметке речь пойдет о том, как и что нужно прикручивать к пайплайну, чтобы отрисовать конкретный трехмерный объект, т. е. мы рассмотрим запись команд в командный буфер (как) а также дескрипторы, буферы памяти и текстуры (что).