Уроки по OpenGL с сайта OGLDev


Урок 51 - Очистка экрана в Vulkan

Добро пожаловать снова. Я надеюсь, что у вас получилось пройти предыдущий урок и вы готовы продолжить. В этом уроке мы добавим очень простую операцию, с которой обычно начинают рендер кадра - очистку экрана. В OpenGL для этого достаточно вызвать функцию glClear(), но, как вы могли уже предположить, в Vulkan это совсем другая история. В этом уроке мы познакомимся с тремя новыми понятиями Vulkan: цепочки переключений (swap chain), изображения и буферы команд.

Давайте рассмотрим очень простой цикл рендера в OpenGL, который только очищает экран:

void RenderLoop()
{
    glClear(GL_COLOR_BUFFER_BIT);
    glutSwapBuffers();   // Или как в GLFW: glfwSwapBuffers(pWindow);
}

Здесь мы видим комманду GL для очистки буфера цвета, следом за которой идет вызов GLUT или GLFW, который переключает первый буфер (который отображается на экран) на второй (с которым работает команда glClear). Эти две невинные на первый взляд функции прячут за собой тонну действий драйвера OpenGL. А Vulkan предоставляет нам стандартный интерфейс для низкоуровневых операций, которые использует и драйвер OpenGL. Нам же требуется реализовать функционал этих функций самостоятельно.

Сейчас давайте подумаем, что же на самом деле делает драйвер при проходе по циклу рендера. В большинстве графических драйверов существует такое понятие как буфер команд. Он представляет собой буфер памяти, в который драйвер записывает инструкции GPU. Драйвер переводит команды GL в инструкции GPU. В GPU, обычно, существует очередь из всех буферов команд, которые сейчас обрабатываются. GPU выбирает буферы по одному и выполняет их содержимое. Буфер команд содержит инструкции, указатели на ресурсы, изменения состояний и всё остальное необходимое для корректного запуска команд OpenGL. Каждый буфер команд может содержать несколько команд OpenGL (обычно так и происходит из соображений эффективности). За упаковку команд OpenGL в буферы команд отвечает драйвер. GPU сообщает драйверу когда буфер команд уже заполнен, так что драйвер может приостановить приложение чтобы оно не обгоняло GPU слишком сильно (например, GPU рендерит кадр N, а приложение уже на кадре N+10).

Такой подход вполне себе работает, так почему же мы должны его менять? Дело в том, что перекладывание ответственности за обработку буферов команд на драйвер не даёт нам возможности произвести некоторые оптимизации, которые можем сделать только мы. Например, вспомним класс Mesh, который мы разрабатывали в предыдущих уроках когда мы изучали библиотеку Assimp. Рендер меша заключался в том, что мы отправляли одну и ту же группу команд отрисовки, хотя изменялись лишь матрицы преобразований. Для каждой команды отрисовки драйвер должен произвести существенное количество работы, и так каждый кадр. А что если бы мы могли создать заранее буфер команд для этого класса и просто отправлять его каждый кадр (обновляя при этом матрицы)? В этом и заключается главная идея Vulkan. С точки зрения драйвера OpenGL кадр - это просто набор команд GL, и драйвер не имеет малейшего понятия что делает приложение. Он даже не подозревает что эти команды повторятся на следующем кадре. Только разработчик приложения знает что происходит и может создавать такие буферы команд, которые подойдут приложению лучшим образом.

Другая область в которой OpenGL никогда не блистал, это многопоточность. Отправка команд отрисовки в других потоках хоть и возможна, но сложна. Проблема в том, что OpenGL создавался без учёта многопоточности. Поэтому, в большинстве случаев графическое приложение имеет только один поток рендера и использует многопоточность для всего остального. Vulkan реализует многопоточность позволяя конкурентно создавать буферы команд и добавляет очереди и семафоры для обработки конкурентности на уровне GPU.

Вернёмся к нашему циклу рендера. На данный момент мы собираемся создать буфер команд и добавить в него инструкции для очистки. А что насчёт смены буферов? Мы использовали GLUT/GLFW, поэтому никогда не задумывались об этом. Но GLUT/GLFW не являются частью OpenGL, это всего лишь библиотеки построенные поверх оконного API, такого как GLX (Linux), WGL (Windows), EGL (Android) и CGL (Mac). Они упрощают процесс написания ОС-независимых программ OpenGL. Если же использовать API OpenGL напрямую, то вам потребуется создать контекст и оконную поверхность, что, в общем-то, соответствует экземпляру и поверхности из предыдущего урока. API предоставляет такие функции, как glXSwapBuffers() и eglSwapBuffers() для смены буферов, которые находятся в поверхности. Они не дают большого контроля над буферами.

Vulkan идёт дальше и вводит понятия цепочек переключений, изображений и движка представления. Спецификация Vulkan описывает цепочки переключений как абстракцию над массивом представляемых изображений, связанных с поверхностью. Изображения представляют собой то, что будет отображаться на экране, и только одно может быть выведено на экран одновременно. Пока одно изображение показывается, приложение вольно подготовить и добавить в очередь остальные изображения. Общее число изображений также подконтрольно приложению.

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

Теперь, когда мы разобрались с новыми концептами, давайте разберёмся, что мы должны добавить в предыдущий урок чтобы заставить его очищать экран. Вот что нам потребуется сделать один раз в процессе инициализации:

  1. Получить очередь для буфера команд из логического устройства. Вспомним, что информация, которую поставляет устройство, включает массив структур VkDeviceQueueCreateInfo с количеством очередей каждого семейства. Для простоты, мы используем только одну очередь из графического семейства. Такая очередь уже была создана в предыдущем уроке. Мы просто получаем её адрес.

  2. Создать цепочку переключений и получить ссылки на её изображения.

  3. Создать буфер команд и добавить в него инструкцию для очистки.

А вот что нам потребуется делать в цикле рендера:

  1. Получить следующее изображение из цепочки.
  2. Отправить буфер команд.
  3. Отправить запрос на вывод изображения.

Что же, давайте перейдём к коду.

Прямиком к коду!

Вся логика этого урока уместилась в этот класс:

class OgldevVulkanApp
{
public:

    OgldevVulkanApp(const char* pAppName);

    ~OgldevVulkanApp();

    void Init();

    void Run();

private:

    void CreateSwapChain();
    void CreateCommandBuffer();
    void RecordCommandBuffers();
    void RenderScene();

    std::string m_appName;
    VulkanWindowControl* m_pWindowControl;
    OgldevVulkanCore m_core;
    std::vector<VkImage> m_images;
    VkSwapchainKHR m_swapChainKHR;
    VkQueue m_queue;
    std::vector<VkCommandBuffer> m_cmdBufs;
    VkCommandPool m_cmdBufPool;
};

У нас здесь есть пара публичных функций Init() и Run(), которые будут вызваны из main(), а также несколько приватных функций, которые совпадают с шагами из предыдущей секции. Кроме того, у класса есть несколько приватных свойств. VulkanWindowControl и OgldevVulkanCore из main() из прошлого урока были перемещены сюда. Кроме того, у нас есть вектор изображений, объект цепочки переключений, очередь команд, вектор буферов команд и пул буферов команд. Давайте перейдём к функции Init():

void OgldevVulkanApp::Init()
{
#ifdef WIN32
    m_pWindowControl = new Win32Control(m_appName.c_str());
#else
    m_pWindowControl = new XCBControl();
#endif
    m_pWindowControl->Init(WINDOW_WIDTH, WINDOW_HEIGHT);

    m_core.Init(m_pWindowControl);

    vkGetDeviceQueue(m_core.GetDevice(), m_core.GetQueueFamily(), 0, &m_queue);

    CreateSwapChain();
    CreateCommandBuffer();
    RecordCommandBuffers();
}

Эта функция начинается аналогично предыдущему уроку с создания и инициализации объекта Vulkan и окна. После этого мы вызываем приватные методы для создания цепочки переключений, буфера команд и записи инструкции очистки в буфер команд. Обратите внимание на вызов vkGetDeviceQueue(). Эта функция Vulkan получает ссылку на объект VkQueue с устройства. Первые три параметра - это устройство, индекс набора очередей и индекс очереди в этом наборе (в нашем случае 0 т.к. у нас только одна очередь). Драйвер возвращает результат в последнем параметре. В этом уроке были добавлены две функции в объект Vulkan.

Давайте рассмотрим процесс создания цепочки переключений по шагам:

void OgldevVulkanApp::CreateSwapChain()
{
    const VkSurfaceCapabilitiesKHR& SurfaceCaps = m_core.GetSurfaceCaps();

    assert(SurfaceCaps.currentExtent.width != -1);

Первое что нам нужно сделать, это получить свойства поверхности из объекта ядра Vulkan. Вспомним, что в предыдущем уроке мы заполняли базу данных физического устройства в объекта ядра Vulkan информацией обо всех физических устройствах в системе. Некоторая часть этой информации была не общей, а относилась к конкретной паре физического устройства и созданной позднее поверхности. Примером может послужить вектор VkSurfaceCapabilitiesKHR, который содержит структуры VkSurfaceCapabilitiesKHR для каждого физического устройства. Функция GetSurfaceCaps() для этого вектора использует индексы от физических устройств (которые были получены в предыдущем уроке). Структура VkSurfaceCapabilitiesKHR содержит очень много информации о поверхности. Свойство currentExtent описывает текущий размер поверхности. Его тип VkExtent2D включает в себя ширину и высоту. В теории, экстент должен содержать ту размерность, которую мы установили при создании поверхности, и я заметил, что это соответствует действительности и для Linux, и для Windows. В некоторых примерах (включая официальный из Khronos SDK) я видел проверку, что ширина текущего экстента равна -1, и если так, то происходила замена значения на желаемое. Мне показалось, что эта логика излишняя, поэтому я добавил ассерты выше.

    uint NumImages = 2;

    assert(NumImages >= SurfaceCaps.minImageCount);
    assert(NumImages <= SurfaceCaps.maxImageCount);

Затем мы задаем число изображений, которые мы будем создавать в цепочке, равным 2. Это будет имитировать двойную буферизацию в OpenGL. Я добавил ассерты чтобы убедиться, что платформа поддерживает это число. Я предполагаю, что эти ассерты у вас не сработают. Но если такое случилось, то можете попробовать и с одним изображением.

    VkSwapchainCreateInfoKHR SwapChainCreateInfo = {};

    SwapChainCreateInfo.sType            = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
    SwapChainCreateInfo.surface          = m_core.GetSurface();
    SwapChainCreateInfo.minImageCount    = NumImages;

Функция, которая создает цепочку, принимает большую часть своих параметров из структуры VkSwapchainCreateInfoKHR. Первые три параметра очевидные - это тип структуры, ссылка на поверхность и число изображений. После создания цепочка навсегда привязана к одной поверхности.

    SwapChainCreateInfo.imageFormat      = m_core.GetSurfaceFormat().format;
    SwapChainCreateInfo.imageColorSpace  = m_core.GetSurfaceFormat().colorSpace;

Дальше идёт формат изображения и цветовое пространство. Формат изображения уже был рассмотрен в предыдущем уроке. Он описывает формат данных в памяти изображения. Он содержит такие параметры, как каналы (красный, зелёный и / или синий) и формат (целое число, с плавающей запятой и прочие). Цветовое пространство описывает способ, которым значения сопоставляются цветам. Например, может быть линейным или sRGB. Мы возьмём оба значения из базы данных физического устройства.

    SwapChainCreateInfo.imageExtent      = SurfaceCaps.currentExtent;

Мы можем создать цепочку с размером, отличным от размера поверхности. Но пока что просто возьмём экстент из структуры свойств поверхности.

    SwapChainCreateInfo.imageUsage       = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;

Нам необходимо сообщить драйверу то, как мы собираемся использовать цепочку переключений. Для этого мы указываем маску из 8 бит. Например, цепочка может быть использована как источник или как место назначения команды перемещения (копирования буфера), как трафарет глубины и прочее. Нам нужен обычный буфер цвета, поэтому мы используем флаг выше.

    SwapChainCreateInfo.preTransform     = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR;

Поле предварительной обработки предназначается для портативных устройств, чьё положение в пространстве может изменяться (мобильные телефоны и планшеты). Оно описывает то, как должна изменяться ориентация перед отображением (90 градусов, 180 градусов и т.д.). Это поле имеет смысл в основном для Android, поэтому мы говорим драйверу не делать никаких преобразований.

    SwapChainCreateInfo.imageArrayLayers = 1;

imageArrayLayers предназначается для стереоскопических приложений, где рендеринг происходит более чем из одной точки, а затем результат комбинируется перед отображением. Примером может послужить виртуальная реальность, где для каждого глаза рендер сцены происходит по отдельности. Это не наш случай, поэтому просто зададим значение 1.

    SwapChainCreateInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;

Изображения в цепочке могут быть поделены между несколькими семействами очередей. Мы будем даём доступ только для очереди, которую выбрали ранее.

    SwapChainCreateInfo.presentMode      = VK_PRESENT_MODE_FIFO_KHR;

В предыдущем уроке мы вскользь затронули движки представления, которые, собственно, принимают изображение из цепочки и выводят его на экран. Подобное есть и в OpenGL, но с куда меньшим числом возможностей. В OpenGL можно выбирать между одинарной и двойной буферизацией. Двойная буферизация позволяет избежать разрывов в изображении с помощью переключения буферов при вертикальной синхронизации, и у нас есть возможность изменять число синхронизаций в секунду. Вот и всё. В то же самое время, Vulkan предоставляет не менее 4-х различных режимов операций, что даёт большую гибкость и производительность. Мы же будем консервативны и используем режим FIFO, который наиболее близок к двойной буферизации OpenGL.

    SwapChainCreateInfo.clipped          = true;

Поле clipped обозначает, может ли драйвер части изображения, которые выходят за пределы видимой области поверхности. Бывают странные ситуации, когда это может пригодиться, но это не наш случай.

    SwapChainCreateInfo.compositeAlpha   = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;

compositeAlpha управляет тем, как изображение сочетается с другими поверхностями. Параметр имеет смысл только для некоторых ОС, поэтому мы его не используем.

    VkResult res = vkCreateSwapchainKHR(m_core.GetDevice(), &SwapChainCreateInfo, NULL, &m_swapChainKHR);
    CHECK_VULKAN_ERROR("vkCreateSwapchainKHR error %d\n", res);

Наконец, мы можем создать цепочку переключений и получить на неё ссылку.

    uint NumSwapChainImages = 0;
    res = vkGetSwapchainImagesKHR(m_core.GetDevice(), m_swapChainKHR, &NumSwapChainImages, NULL);
    CHECK_VULKAN_ERROR("vkGetSwapchainImagesKHR error %d\n", res);

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

    m_images.resize(NumSwapChainImages);
    m_cmdBufs.resize(NumSwapChainImages);

    res = vkGetSwapchainImagesKHR(m_core.GetDevice(), m_swapChainKHR, &NumSwapChainImages, &(m_images[0]));
    CHECK_VULKAN_ERROR("vkGetSwapchainImagesKHR error %d\n", res);
}

Нам требуется получить ссылки на все изображения в цепочке, чтобы мы могли соответственно изменить вектор ссылок на изображения. Кроме того, мы изменяем размер вектора буферов команд, так как для каждого изображения в цепочке мы будем использовать отдельный буфер.

Следующие функции создают буферы команд:

void OgldevVulkanApp::CreateCommandBuffer()
{
    VkCommandPoolCreateInfo cmdPoolCreateInfo = {};
    cmdPoolCreateInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
    cmdPoolCreateInfo.queueFamilyIndex = m_core.GetQueueFamily();

    VkResult res = vkCreateCommandPool(m_core.GetDevice(), &cmdPoolCreateInfo, NULL, &m_cmdBufPool);
    CHECK_VULKAN_ERROR("vkCreateCommandPool error %d\n", res);

Буферы команд не создаются напрямую. Вместо этого они должны быть получены из пула. Как и следовало ожидать, причина тому - производительность. Благодаря тому, что буферы команд являются частью пула, лучше организована работа с памятью и появилась возможность повторного использования. Важно заметить, что пулы не потокобезопасны. Это значит, что все действия над пулом или его буферами команд должны быть явно синхронизированными с приложением. Поэтому, если вы хотите создавать буферы команд параллельно в несколько потоков, то вы обязаны использовать синхронизацию, либо для каждого потока создать по своему пулу.

Функция vkCreateCommandPool() создает пул. Она принимает на вход структуру VkCommandPoolCreateInfo, самым интересным свойством которой является индекс семейства очередей. Все команды, получаемые из пула, должны быть отправлены в очередь из этого семейства.

    VkCommandBufferAllocateInfo cmdBufAllocInfo = {};
    cmdBufAllocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    cmdBufAllocInfo.commandPool = m_cmdBufPool;
    cmdBufAllocInfo.commandBufferCount = m_images.size();
    cmdBufAllocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;

    res = vkAllocateCommandBuffers(m_core.GetDevice(), &cmdBufAllocInfo, &m_cmdBufs[0]);
    CHECK_VULKAN_ERROR("vkAllocateCommandBuffers error %d\n", res);
}

Теперь мы готовы к созданию буферов команд. В структуре VkCommandBufferAllocateInfo мы задаем только что созданный пул и число буферов команд (нам потребуется отдельный буфер для каждого изображения в цепочке). Также нам требуется указать, будет ли этот буфер первичным или вторичным. Первичные буферы используются для передачи команд в GPU, но они не могут ссылаться друг на друга. Это означает, что даже если у нас есть пара похожих буферов, мы должны записать полный набор команд в каждый из них. Общую часть выделить отдельно нельзя. Вот тут и могут пригодиться вторичные буферы. Их нельзя отправлять в очереди, но зато они могут быть использованы первичным буфером. Так и решается проблема с выносом общей части. Пока что нам будет достаточно первичных буферов.

Давайте запишем инструкцию очистки в наш новый буфер команд.

void OgldevVulkanApp::RecordCommandBuffers()
{
    VkCommandBufferBeginInfo beginInfo = {};
    beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
    beginInfo.flags = VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT;

Код, который отвечает за запись в буфер команды, должен быть обрамлен вызовами функций vkBeginCommandBuffer() и vkEndCommandBuffer(). В структуре VkCommandBufferBeginInfo есть свойство под названием flags, которое сообщает драйверу, что буферы команд будут записываться в очередь снова и снова. Существуют и другие модели, но пока что они нам не интересны.

    VkClearColorValue clearColor = { 164.0f/256.0f, 30.0f/256.0f, 34.0f/256.0f, 0.0f };
    VkClearValue clearValue = {};
    clearValue.color = clearColor;

Чтобы указать цвет, которым будет очищен экран, мы используем две структуры выше. Первая объединяет четыре значения типов float/int/uint. Вторая структура объединяет структуры VkClearColorValue и VkClearDepthStencilValue. Такой подход используется в тех частях API, которые могут принимать на вход любую из двух структур. В нашем случае используется цвет. Поскольку меня сегодня прёт, то я взял цвет из логотипа Vulkan ;).

Заметим, что каждый канал цвета принимает значения от 0 (темнее) до 1 (светлее). И этот бесконечный спектр вещественных значений разбит на 256 дискретных отрезков, вот почему я делю на 256.

    VkImageSubresourceRange imageRange = {};
    imageRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
    imageRange.levelCount = 1;
    imageRange.layerCount = 1;

Нам требуется указать диапазон изображений, которые мы хотим очистить. В будущих уроках мы изучим более сложные схемы, где будут несколько уровней mip-текстур, слоев и прочего. Пока что мы просто хотим обойтись малой кровью, поэтому мы указываем один слой и один уровень mip-текстур. Поле aspectMask сообщает драйверу что очищать - цвет, глубину или шаблон (или их комбинацию). Сейчас нас интересует только цветовая часть изображения.

    for (uint i = 0 ; i < m_cmdBufs.size() ; i++) {
        VkResult res = vkBeginCommandBuffer(m_cmdBufs[i], &beginInfo);
        CHECK_VULKAN_ERROR("vkBeginCommandBuffer error %d\n", res);

        vkCmdClearColorImage(m_cmdBufs[i], m_images[i], VK_IMAGE_LAYOUT_GENERAL, &clearColor, 1, &imageRange);

        res = vkEndCommandBuffer(m_cmdBufs[i]);
        CHECK_VULKAN_ERROR("vkEndCommandBuffer error %d\n", res);
    }
}

Теперь мы готовы к записи в буфер команд. Как я уже говорил, команды, которые производят запись, должны быть окружены вызовами функций обозначающих начало и конец буфера команд. Для этого мы указываем в какой буфер будет происходить запись и структуру beginInfo, которую мы подготовили ранее. Так как у нас массив буферов команд (один буфер на цепочку изображений), запись происходит в цикле. vkCmdClearColorImage() записывает инструкцию очистки экрана в буфер команд. В качестве параметров она принимает буфер команд, в который будет запись, целевое изображение, слой изображения в памяти, цвет очистки, число структур VkImageSubresourceRange которые должны использоваться и указатель на их массив (в нашем случае структура только одна).

Мы подготовили всё что нам нужно и теперь мы можем писать код нашей главной функции рендера. Обычно в OpenGL это означает указание списка команд OpenGL для отрисовки сцены с последующим вызовом смены буферов (будь это GLUT, GLFW или любое другое оконное API). Для драйвера это означает нудное повторение записи в буфер команд и его отправки, причем отличия между кадрами относительно малы (изменения в шейдерных матрицах, и т.д.). Но в мире Vulkan все наши буферы команд уже записаны! Нам нужно только добавить их в очередь к GPU. Но так как в Vulkan действия должны быть более подробно описаны, то нам ещё требуется решить как получить изображение для рендера и как указать движку чтобы он это изображение вывел.

void OgldevVulkanApp::RenderScene()
{
    uint ImageIndex = 0;

    VkResult res = vkAcquireNextImageKHR(m_core.GetDevice(), m_swapChainKHR, UINT64_MAX, NULL, NULL, &ImageIndex);
    CHECK_VULKAN_ERROR("vkAcquireNextImageKHR error %d\n", res);

Первое что нам требуется сделать - это получить доступное для рендера изображение из движка представления. Мы можем получить больше чем одно изображение (например, если мы хотим рендерить два или больше кадра наперёд) в более сложной ситуации, но для нас и одного изображения более чем достаточно. Вызов API выше принимает на вход устройство и цепочку переключений как первые два параметра, соответственно. Третий параметр это количество времени, которое мы готовы подождать, прежде чем функция вернёт управление. Часто движек представления не может вернуть изображение мгновенно потому, что он должен подождать пока изображение не будет готово, либо какого-то другого события ОС или GPU (например, сигнал вертикальной синхронизации от дисплея). Если мы укажем 0, то это будет не блокирующий вызов, т.е. если изображение готово, то мы получим его сразу, а иначе функция вернёт ошибку. Любое число больше 0 и меньше чем максимальное значение беззнакового типа 64bit запустит таймер с этим числом наносекунд. Значение UINT64_MAX функция вернёт управление только когда изображение будет готово (или какую-то внутреннюю ошибку). Это наиболее безопасное для нас решение. Следующие два параметра - это указатели на семафор и барьер памяти (fence), соответственно. При дизайне Vulkan многопоточность была хорошо учтена. Это значит, что мы можем добавить взаимозависимость между очередями GPU, между CPU и GPU, и так далее. Это позволяет отправлять задачи над изображением даже если оно ещё не полностью готово для рендера (это, конечно, не совсем соответствует тому, что должен делать vkAcquireNextImageKHR, но тем не менее). Эти семафоры и барьеры синхронизируют примитивы, которые нужно подождать прежде чем начнётся сам рендер в изображение. Семафор занимается синхронизацией на GPU, а барьер между CPU и GPU. Как вы заметили, я установил оба значения в NULL, что может быть не безопасно, а в теории вообще не должно работать, но работает. Вероятно это происходит из-за того, что наше приложение очень простое. Но это позволяет мне убрать вопросы синхронизацией в долгий ящик. Пожалуйста, сообщите мне о любых проблемах связанных с этим. Последним параметром функции является индекс доступного изображения.

    VkSubmitInfo submitInfo = {};
    submitInfo.sType                = VK_STRUCTURE_TYPE_SUBMIT_INFO;
    submitInfo.commandBufferCount   = 1;
    submitInfo.pCommandBuffers      = &m_cmdBufs[ImageIndex];

    res = vkQueueSubmit(m_queue, 1, &submitInfo, NULL);
    CHECK_VULKAN_ERROR("vkQueueSubmit error %d\n", res);

Теперь, когда у нас есть изображение, давайте отправим задачу в очередь. Функция vkQueueSubmit() принимает ссылку на очередь, число структур VkSubmitInfo и указатель на их массив. Последним параметром идет барьер, который мы пока что просто проигнорируем. VkSubmitInfo имеет 8 свойств не считая тех, что идут в стандартном sType, но нам понадобятся только 2 (можете представить, сколько сложностей остаётся за кадром). Мы указываем, что у нас только один буфер команд, и мы передаём его адрес (на тот буфер, который связан с изображением). Документация к Vulkan указывает, что отправка задач может быть затратной по ресурсам, и призывает нас запаковывать как можно больше буферов команд за раз. В нашем простом приложении у нас нет такой возможности, но следует помнить об этом, когда приложение будет нарастать функционалом.

    VkPresentInfoKHR presentInfo = {};
    presentInfo.sType              = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
    presentInfo.swapchainCount     = 1;
    presentInfo.pSwapchains        = &m_swapChainKHR;
    presentInfo.pImageIndices      = &ImageIndex;

    res = vkQueuePresentKHR(m_queue, &presentInfo);
    CHECK_VULKAN_ERROR("vkQueuePresentKHR error %d\n" , res);
}

После того как предыдущий вызов API вернул управление, то мы знаем, что буфер команд уже направился в очередь GPU, но мы не имеем никакого понятия о том, когда он будет запущен. Забавно то, что нам и нет до этого дела. Буферы команд гарантированно будут исполнены в том порядке, в котором их отправили. А так как мы отправили в одной очереди команду отображения после команды очистки, то мы можем быть уверены, что изображение будет очищено, прежде чем отобразится. Так что вызов vkQueuePresent() просто отмечает, что кадр закончен и говорит движку отображения, чтобы он его вывел на экран. Функция принимает на вход два параметра - очередь, которая имеет возможности отображения (мы позаботились об этом при инициализации устройства и очереди), и указатель на структуру VkPresentInfoKHR. Помимо прочего, эта структура содержит два массива одного размера - массив цепочек и массив изображений. Это значит, можно положить в очередь одну и ту же команду отображения в разные цепочки переключений, где каждая цепочка подключена к отдельному окну. Каждая цепочка в массиве связана с изображением по индексу. Свойство swapchainCount указывает на число цепочек и изображений.

void OgldevVulkanApp::Run()
{
    while (true) {
        RenderScene();
    }
}

Главная функция рендера очень проста. Мы вызываем в бесконечном цикле ту функцию, которую только что рассмотрели.

int main(int argc, char** argv)
{
    OgldevVulkanApp app("Tutorial 51");

    app.Init();

    app.Run();

    return 0;
}

Функция main очень простая. Мы объявляем объект OgldevVulkanApp, инициализируем и запускаем его.

На этом всё. Я надеюсь, что окно у вас чистое. В следующий раз мы нарисуем треугольник.

powered byDisqus