steps3D - Tutorials - Vulkan. Начало

Vulkan. Начало

Imagination is the only weapon in the war against reality.

The Cheshire Cat.

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

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

Так шейдеры по-прежнему компилируются драйвером и разработчики драйверов могут слегка по-разному понимать GLSL (что имело раньше место по отношению к драйверам от NVidia и AMD). Также когда вы загружаете данные в VBO, то OpenGL должен выполнить загрузку этих данных исходя из наихудшего сценария - что эти данные будут сразу же нужны. А это копирование данных между CPU и GPU, требующее синхронизации. И есть еще много других мест, где OpenGL заботится от программистах, выполняя кучу всяческих (и зачастую совершенно ненужных при нормальной работе программы) проверок.

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

И с этой точки зрения Vulkan является совершенно новым графическим API, построенным для максимально эффективной работы с современными GPU. Причем он ориентирован на весь спектр GPU - от мобильных до мощных GPU на настольных компьютерах (т.е. не такого деления на мобильную и немобильную версию как для OpenGL и OpenGL ES).

Для обеспечения высокой эффективности работы и снижения затрат в драйвере данный API сделан довольно низкоуровневым. В нем именно вы сами явно отвечаете за большое количество действий, таких как синхронизация, своевременную загрузку данных в память GPU и т.п. Такого "подкладывания соломки" как было в OpenGL больше нет - если вы сделали ошибку, то вы сами виноваты и программа с большой вероятностью просто упадет или будет работать некорректно.

За счет этого удается избежать многочисленных проверок там, где они не должны быть. И это очень хорошо для нормальной работы программы у пользователя. Но при написании и отладке программы возможность выполнять различные проверки оказывается очень удобной. Поэтому в Vulkan есть такой функционал как слои валидации (validation layers). Фактически каждый такой слой это просто набор различных хуков (hook) в команды Vulkan. Они перехватывают вызовы функций Vulkan и осуществляют различные проверки (валидацию) входных значений. При этом во время нормальной работы приложения они просто отключаются.

Рис 1. Приложение - загрузчик Vulkan - слои валидации - устройство (GPU)

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

При этом какие именно слои (и будут ли вообще) использоваться задается в момент инициализации. Это позволяет использовать большое количество различных слоев валидации при разработке и отладке и не использовать их (и значит не платить за них) на этапе работы программы у пользователя.

Еще одним из значительных отличий Vulkan от API вроде OpenGL является то, что в нем нельзя в произвольном месте менять параметры рендеринга, т.е. отсутствуют всевозможные команды вроде glEnable/gDepthMask/glBlendFunc и т.п.

С одной стороны это было очень удобно для программиста. Но с другой стороны важно понимать что при этом текущее состояние рендеринга определяется отдельными командами, разбросанными по всему коду. Очень важным является и то, что подобное поведение крайне не GPU-friendly - с точки зрения конвейера рендеринга менять его состояние в произвольный момент времени очень дорого. Поэтому уже давно стало распространенной практикой сортировать рендеринг по состоянию, чтобы минимизировать число переключений состояния GPU. Кроме того, возможность в произвольный момент времени изменить параметры рендеринга может мешать драйверу производить оптимизацию.

Вместо этого Vulkan вводит понятие объекта-конвейера (pipeline). Такой объект создается заранее и он содержит в себе все настройки рендеринга. Вы больше не можете изменять эти настройки - они задаются только при создании такого объекта-конвейера. Это позволяет драйверу выполнить на этапе создания такого объекта все необходимые проверки и произвести оптимизацию. Эти объекты создаются заранее и потом переиспользуются. На этапе рендеринга вы просто переключаетесь между ними.

Еще одним важным отличием Vulkan, заметно влияющим на быстродействие, являются командные буфера (command buffer). В OpenGL/Direct3D есть серьезное ограничение на количество вызовов команд типа glDraw* (DIP в терминах Direct3D) за кадр. Это связано с большим количеством внутренних проверок, с которыми сопряжены подобные команды. Вместо этого в Vulkan позволяет заранее собрать такие вызове вместе и записать их в командный буфер. Каждый командный буфер содержит команды, посылаемые на GPU. Все проверки выполняются только на этапе создания подобных буферов и записи в них команд. На этапе рендеринга мы просто помещаем эти буфера в очередь GPU. Командные буфера также являются переиспользуемыми объектами.

Есть и другие отличия Vulkan, но мы будем рассматривать их по ходу изложения материала. Сам API Vulkan строится (как и OpenGL) как набор функций (их имена начинаются с префикса vk), констант (их имена начинаются с VK_) и структур и перечислений (их имена начинаются на Vk). Как и в OpenGL в Vulkan есть расширения и мы с самого начала будем их использовать.

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

В Vulkan принято что каждая такая структура начинается в двух обязательных полей - sType и pNext. Поле sType содержит константу (например, VK_STRUCTURE_TYPE_APPLICATION_INFO), задающую тип передаваемой структуры. А поле pNext обычно равно nullptr и позволяет к этой структуре подключить дополнительную структуру (точнее список структур), содержащих дополнительные параметры.

Рис 2. Список из структур с параметрами для команды

Хотя Vulkan это скорее C API, мы будем постоянно иметь дело с объектами Vulkan, такими как устройства, очереди, буфера и т.п. Для задания таких объектов используются непрозрачные значения handle. Фактически handle это просто 64-битовое число, которые будет передаваться функциям Vulkan. Есть зарезервированное значения VK_NULL_HANDLE обозначающее что объекта нет (например при его создании произошла ошибка).

Обычно объекты создаются при помощи вызовов функций vkCreate* и vkAllocate*. После того, как объект перестает быть нужным его необходимо уничтожить (правило Тараса Бульбы :)). Для уничтожения объектов служат функции vkDestroy* и vkFree* соответственно. Во многие функции Vulkan можно передать указатель pAllocator на свой аллокатор памяти. Мы не будем использовать свои аллокаторы, поэтому у нас этот параметр всегда будет равен nullptr.

В отличии от OpenGL у которого очень плохо с поддержкой работы одновременно из нескольких нитей в Vulkan можно эффективно работать сразу из нескольких нитей. Однако в ряде случаев имеют определенные ограничения на работу из нескольких нитей 0 и как правило это значит что с с объектом заданного типа можно одновременно работать только из одной нити. Но при этом вы можете работать с несколькими такими объектами - просто каждая нить работает только со своим объектом, например каждая нить может заполнять свой командный буфер.

А чего так сложно ?

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

Эффективное программирование GPU на таком ровне это на самом деле очень сложная задача. Если вам хочется чего-то простого - вам лучше остаться на OpenGL, его вполне достаточно для решения многих задач. Но за счет использования Vulkan вы можете гораздо более эффективно использовать GPU. И, что возможно так же важно, вы получите действительно глубокое понимание того, как на самом деле работают современные GPU и как их эффективно использовать.

Платой за это будет заметно более сложный код. Если вы посмотрите на программу, выводящую обычный треугольник, то сразу удивитесь ее размеру - если не использовать какие-то вспомогательные библиотеки, то он будет во многие разы (если не десятки раз) больше чем у аналогичной программы на OpenGL.

В этих статьях мы начнем именно с подробного изложения и первые несколько статей будут довольно прямолинейным использованием Vulkan API. Целью будет написание чисто счетного приложения - создать массив данных, разместить его в памяти GPU, запустить вычислительный шейдер для его обработки и скопировать результаты обратно.

Этот выбор объясняется тем, что вычислительный конвейер гораздо проще графического и написанное приложение будет гораздо проще и меньше, чем вывод треугольника. После этого мы уже не будем работать с API "в лоб". Вместо этого будут использоваться возможности С++ для создания различных "оберток", позволяющих заметно упростить наш код. В результате мы придем к гораздо более простому и читаемому коду, который легко можно будет переиспользовать.

А сейчас давайте рассмотрим из каких шагов состоит рендеринг даже одного треугольника в Vulkan. Итак, во-первых, в Vulkan есть явный контекст, называемый экземпляров (instance), который служит для связи с Vulkan. И для начала работы нам нужно его создать, а в конце - уничтожить.

Следующим шагом будет выбор физического устройства, т.е. реального GPU, с которым мы будем работать. В вашей системе может быть установлено несколько различных GPU и при помощи Vulkan можно работать одновременно со всеми ними.

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

Как и в OpenGL, Vulkan является кроссплатформенным API и он абстрагируется от особенностей конкретной платформы. Для взаимодействия с оконной системой конкретной платформы используются расширения (в Vulkan как и в OpenGL есть расширения). Для непосредственного создания окна для рендеринга мы будем использовать библиотеку Vulkan, но все равно нам нужно будет сделать много дополнительных действий.

Однако одной библиотеки GLFW нам будет недостаточно. Нам понадобится создать так называемый swap chain. Фактически это просто набор изображений, в которые будет осуществляться рендеринг. Задачей swap chain будет предоставлять изображения для рендеринга очередного кадра и гарантировать при этом, что каждый раз показывается будет уже полностью готовое изображение.

Для того, чтобы Vulkan могу осуществлять рендеринг в изображение нам нужно создать две дополнительные сущности - вид изображения (image view) и фреймбуфер. Вид изображения определяет в какую именно его часть мы будем осуществлять рендеринг. А фреймбуфер это просто набор видов, предоставляющих все необходимые для рендеринга буфера - цвета, глубины, трафарета и т.п.

Следующим шагом будет создание объекта renderpass, описывающего проход рендеринга - куда осуществляется рендеринг, надо ли очищать буфера, в которые идет запись, что делать с ними по окончании рендеринга.

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

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

Как видите получается довольно много шагов, которые необходимо выполнить даже для такой простой задачи, как рендеринг треугольника. С другой стороны все это как правило является легко переиспользуемым кодом и если вам нужно вывести не один треугольник а много разных объектов, то сложность работы с Vulkan при этом возрастает незначительно.

Инициализация Vulkan

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

Сам instance создается при помощи вызова функции vkCreateInstance. На вход функция получает указатель на структуру VkInstanceCreateInfo, указатель на аллокатор памяти (мы будем использовать стандартный аллокатор, поэтому этот параметр всегда будет равен nullptr и адрес переменной типа VkInstance, в которую будет помещен созданный instance. Это связано с тем, что практически все функции Vulkan возвращают результат операции (код ошибки, значение типа VkResult), успешному завершению соответствует VK_SUCCESS.

В структуре VkInstanceCreateInfo мы передаем такую информацию, как список имен слоев валидации (поля enabledLayerCount и ppEnabledLayerNames) и список требуемых расширений (поля enabledExtensionCount и ppEnabledExtensionNames). В отличии от OpenGL мы сразу задаем какие расширения нам понадобятся и далее будут доступны только они. Если хотя бы одно расширение из запрашиваемого списка окажется недоступным, то вызов vkCreateInstance приведет к ошибке. Подобная схема работы с расширениями может помочь драйверу осуществлять оптимизацию работы с GPU.

Также в этой структуре есть указатель (pApplicationInfo) на еще одну структуру - VkApplicationInfo. Она содержит в себе общую информацию о вашем приложении, которая может помочь драйверу в оптимизации.

VkApplicationInfo appInfo = {};

    // prepare information about our app
appInfo.sType              = VK_STRUCTURE_TYPE_APPLICATION_INFO;
appInfo.pApplicationName   = "Hello Triangle";
appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0);
appInfo.pEngineName        = "No Engine";   
appInfo.engineVersion      = VK_MAKE_VERSION(1, 0, 0);
appInfo.apiVersion         = VK_API_VERSION_1_0;

VkInstance          instance;
VkInstanceCreateInfo   createInfo = {};

    // prepar information about application, layers and extensions
createInfo.sType                   = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
createInfo.pApplicationInfo        = &appInfo;  
createInfo.enabledExtensionCount   = 0;
createInfo.ppEnabledExtensionNames = nullptr;   
createInfo.enabledLayerCount       = 0;     

    // create the instance
if ( vkCreateInstance ( &createInfo, nullptr, &instance ) != VK_SUCCESS) 
    fatal () << "failed to create instance!" << Log::endl;

Давайте теперь рассмотрим как можно задавать слои валидации. Для этого мы заведем переменную типа std::vector<const char *>, куда мы будем помещать имена слоев. Далее мы используем эту переменную для создания instance.

const std::vector<const char*> validationLayers = 
{
    "VK_LAYER_KHRONOS_validation",
};

createInfo.enabledLayerCount       = static_cast<uint32_t>(validationLayers.size());
createInfo.ppEnabledLayerNames     = validationLayers.data();

Сейчас мы можем посмотреть как еще до создания instance мы можем получить список всех поддерживаемых расширений. Для этого служит функция vkEnumerateInstanceExtensionPreperties. Как и большинство функций vkEnumerate она берет на вход указатель на буфер, куда надо будет поместить значения, и указатель на переменную, содержащую на входе размер этого массива, а на выходе - число записанных туда значений.

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

uint32_t extensionCount = 0;

    // get number of supported extensions
vkEnumerateInstanceExtensionProperties ( nullptr, &extensionCount, nullptr );

    // allocate memory for extension information
std::vector<VkExtensionProperties> extensions ( extensionCount );

    // get extensions info
vkEnumerateInstanceExtensionProperties ( nullptr, &extensionCount, extensions.data () );

    // print it
std::cout << "available extensions:" << std::endl;

for ( const auto& extension : extensions ) 
    std::cout << "\t" << extension.extensionName << std::endl;

Теперь рассмотрим как по аналогии с расширениями мы можем задать требуемые нам расширения.

const std::vector<const char*> requiredExtensions = { VK_EXT_DEBUG_UTILS_EXTENSION_NAME };

createInfo.ppEnabledExtensionNames = requiredExtensions.data ();
createInfo.enabledLayerCount       = static_cast<uint32_t>(validationLayers.size());

Закончив работу с Vulkan необходимо в самом конце приложения уничтожить созданный instance.

vkDestroyInstance ( instance, nullptr );

Как уже говорилось ранее, каждый слой валидации это набор определенных проверок на валидность передаваемых аргументов. В состав Vulkan SDK от LunarG входит большое число готовых слоев валидации, но мы далее будем использовать только слой VK_LAYER_KHRONOS_validation.

По аналогии с расширениями, можно еще до создания instance получить полный список поддерживаемых слоев валидации. Для этого используется функция vkEnumerateInstanceLayerProperties.

uint32_t layerCount;

    // get number of supported validation layers
vkEnumerateInstanceLayerProperties ( &layerCount, nullptr );

    // allocate memory for information about layers
std::vector<VkLayerProperties> availableLayers ( layerCount );

    // get layers data
vkEnumerateInstanceLayerProperties ( &layerCount, availableLayers.data () );

    // print it
std::cout << "available layers:" << std::endl;

for ( auto& layerProperties : availableLayers )
    std::cout << '\t' << layerProperties.layerName << std::endl;

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

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

Как и в OpenGL, расширение в Vulkan могут вводить свои функции. Только в Vulkan эти функции бывают двух типов. Есть функции экземпляра (instance) и есть функции, связанные с конкретным устройством (devic). Это связано с тем, что из одного instance мы можем работать сразу с несколькими различными физическими устройствами (GPU).

Для получения адреса функции экземпляра в Vulkan служит функция vkGetInstanceProcAddrЮ а для получения адреса функции устройства служит функция vkGetDeviceProcAddr. Каждая из них берет в качестве параметров экземпляр/устройство и имя функции, адрес которой нужно вернуть. В случае если такая функция не найдена, то возвращается nullptr. В следующем фрагмента кода мы будем использовать vkGetDeviceProcAddr для получения адресов функций vkCreateDebugUtilsMessengerEXT vkDestroyDebugUtilsMessengerEXT.

VkResult createDebugUtilsMessengerEXT ( VkInstance instance, const VkDebugUtilsMessengerCreateInfoEXT* pCreateInfo, const VkAllocationCallbacks* pAllocator, VkDebugUtilsMessengerEXT* pDebugMessenger )
{
    auto func = (PFN_vkCreateDebugUtilsMessengerEXT) vkGetInstanceProcAddr(instance, "vkCreateDebugUtilsMessengerEXT");

    if ( func != nullptr )
        return func ( instance, pCreateInfo, pAllocator, pDebugMessenger );

    return VK_ERROR_EXTENSION_NOT_PRESENT;
}

void destroyDebugUtilsMessengerEXT ( VkInstance instance, VkDebugUtilsMessengerEXT debugMessenger, const VkAllocationCallbacks* pAllocator )
{
    auto func = (PFN_vkDestroyDebugUtilsMessengerEXT) vkGetInstanceProcAddr(instance, "vkDestroyDebugUtilsMessengerEXT");

    if ( func != nullptr )
        func ( instance, debugMessenger, pAllocator );
}

void    populateDebugMessengerCreateInfo ( VkDebugUtilsMessengerCreateInfoEXT& createInfo )
{
    createInfo = {};

    createInfo.sType           = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
    createInfo.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
    createInfo.messageType     = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT     | VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT  | VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT;
    createInfo.pfnUserCallback = debugCallback;
}


    // . . .
VkDebugUtilsMessengerCreateInfoEXT  debugCreateInfo = {};
VkDebugUtilsMessengerEXT            debugMessenger  = VK_NULL_HANDLE;

populateDebugMessengerCreateInfo ( debugCreateInfo );


createInfo.enabledExtensionCount   = (uint32_t) requiredExtensions.size ();
createInfo.ppEnabledExtensionNames = requiredExtensions.data ();
createInfo.enabledLayerCount       = static_cast<uint32_t>(validationLayers.size());
createInfo.ppEnabledLayerNames     = validationLayers.data();
createInfo.pNext                   = (VkDebugUtilsMessengerCreateInfoEXT*) &debugCreateInfo;        

if ( vkCreateInstance ( &createInfo, nullptr, &instance ) != VK_SUCCESS) 
    fatal () << "failed to create instance!" << Log::endl;

    // . . .
populateDebugMessengerCreateInfo ( debugCreateInfo );

if ( createDebugUtilsMessengerEXT ( instance, &debugCreateInfo, nullptr, &debugMessenger ) != VK_SUCCESS )
    fatal () << "failed to set up debug messenger!" << Log::endl;

Через debugCallback обозначена функция-обработчик. Она будет вызываться для каждого сообщения и она должна иметь следующий вид:

static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback ( VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity, VkDebugUtilsMessageTypeFlagsEXT messageType, const VkDebugUtilsMessengerCallbackDataEXT* pCallbackData, void* pUserData )
{
    if ( messageSeverity >= VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT )       // display only warning or higher
        log () << "validation layer: " << pCallbackData->pMessage << Log::endl;

    return VK_FALSE;
}

Первый параметр - messageSeverity - задает серьезность возникшей ситуации и может принимать одно из следующий значений:

Параметр messageType задает тип сообщения и принимает одно из следующий значений:

Параметр pCallbackData указывает на структуру типа VkDebugUtilsMessageCallbackDataExt, содержащую следующие поля:

Параметр pUserData содержит указатель, который был передан при создании обработчика. Если функция-обработчик возвращает VK_TRUE, то это значит, что вызов Vulkan, приведший к этому сообщению, должен быть завершен с ошибкой VK_ERROR_VALIDATION_FAULTED_EXT. Во всех остальных случаях возвращается VK_FALSE.

По этой ссылке можно скачать весь исходный код к этой статье.