Рендеринг треугольника в Vulkan (почему так сложно)
На прошлой неделе отец спросил, почему я горжусь этим треугольником в ноутбуке. Я рассказал ему, что рендеринг треугольника в Vulkan очень сложен: мне потребовалось 3 дня и более 1000 строк кода, чтобы создать изображение выше. Папа был сбит с толку.
Вот так я написал эту статью. Она объясняет что такое графический интерфейс Vulkan, как он работает, и что нужно для рендеринга треугольника с помощью вашего графического процессора.
Что такое Vulkan
Этот графический API создали для предоставления точной абстракции работы современных графических процессоров (GPU). В отличие от OpenGL, Vulkan очень требует множества подробностей. Любая мелочь, связанная с этим графическим API, должна быть настроена с нуля. Плюс в том, что вы используете только то, что выбираете сами. Так можно лучше понять, что происходит в вашем приложении, и при этом добиться значительно более высокой производительности.
Эта статья предназначена для краткого обзора основ Vulkan. Она была написана для кого-то, кто знал Vulkan, но забыл многие детали (то есть для будущего меня). Большая часть информации здесь взята из Vulkan 1.2 Spec.
Примечание: В данной статье используются C++ привязки для Vulkan.
Кратко о работе Vulkan
Если смотреть с высоты птичьего полета, приложение Vulkan работает так:
Vulkan может получать доступ к устройствам, что дает вам возможность управлять одной или несколькими очередями. Очереди — это способ отправки списка команд в графический процессор, и они могут быть членами одного или нескольких семейств очередей, каждое из которых может выполнять разные действия (например, рисовать вершины трехмерной модели).
Буферы команд — это способ отправки команд в очередь. Команды устройства «записываются» в буфер команд посредством вызовов API Vulkan и затем могут быть переданы один или несколько раз (например, один раз в каждом кадре) в очередь, которая должна быть выполнена.
Это было предисловие. Теперь самое время погрузиться в детали!
Начало
Экземпляры и устройства
В начале был Vulkan API. И API был воплощен через vk :: Instance, и из него возникли все состояния для каждого приложения.
Вы инициализируете Vulkan, создавая экземпляр, который содержит состояние приложения. Т.е. такую информацию, как используемую вами версию Vulkan API, название вашего приложения, а также какие расширения и слои вы хотите включить. Расширения и слои обеспечивают поведение, которое по умолчанию не включено в Vulkan, например, расширенная проверка ошибок и ведение журнала вызовов.
С помощью экземпляра вы можете проверить доступные физические устройства (обычно — графические процессоры). У машины может быть несколько физических устройств, и может быть проверено каждое из их свойств (например, является ли графическая карта выделенной).
Обычная схема выбора физического устройства:
- Перечислить все физические устройства
- Оценить каждое физическое устройство по наличию необходимых свойств
- Дать физическим устройствам без требуемых свойств оценку 0
- Выбрать физическое устройство с самой высокой оценкой
Пример кода для выбора физического устройства
(Совет: читайте комментарии, чтобы не запутаться)
void pickPhysicalDevice() {
// Получить список всех физических устройств, которые может найти Vulkan
auto physicalDevices = instance.enumeratePhysicalDevices();
if (physicalDevices.size() == 0) {
throw std::runtime_error("No GPUs with Vulkan support found!");
}
// Получить список физических устройств, сортированных по rateDeviceSuitability
std::multimap<int, vk::PhysicalDevice> candidates;
for (const auto& physicalDevice : physicalDevices) {
int score = rateDeviceSuitability(physicalDevice);
candidates.insert(std::make_pair(score, physicalDevice));
}
// Проверка, что лучший кандидат соответствует необходимым свойствам (score > 0)
if (candidates.rbegin()->first > 0) {
physicalDevice = candidates.rbegin()->second;
} else {
throw std::runtime_error("failed to find a suitable GPU!");
}
}
int rateDeviceSuitability(vk::PhysicalDevice physicalDevice) {
// Получить все функции / свойства данного физического устройства
vk::PhysicalDeviceFeatures deviceFeatures
= physicalDevice.getFeatures();
vk::PhysicalDeviceProperties deviceProperties
= physicalDevice.getProperties();
int score = 0;
if ( deviceProperties.deviceType
== vk::PhysicalDeviceType::eDiscreteGpu)
{
score += 1000; // Предпочитать выделенные GPU
}
if (!deviceFeatures.geometryShader) {
return 0; // Необходимы геометрические шейдеры
}
return score;
}
Имея физическое устройство, вы можете создать логическое устройство.
Физические устройства представляют графический процессор, а логические — это то, что вы используете для создания ресурсов и доступа к очередям.
Держать логическое устройство отдельно от физического также полезно, потому что это позволяет вам иметь несколько логических устройств (каждое со своим состоянием и ресурсами) для одного физического устройства. Один из примеров того, где это было бы полезно, если у вас есть Vulkan-приложение для рендеринга, которое использует совершенно самостоятельный UI Toolkit (также работающий на Vulkan). Каждому из них нужны свои логические устройства для работы, но они оба используют одно и то же физическое устройство.
Логическое устройство создается там же, где вы создаете очереди.
Как все делается
Очереди, командные буферы и проходы рендеринга
Очередь — это список команд, которые выполняет графический процессор.
Каждая очередь может иметь только определенные типы команд (некоторые могут иметь несколько типов, другие только одну), и это указывается при создании очереди.
Четыре типа операций с очередями:
- Графика -> Рисование вершин модели
- Вычисление -> Трассировка лучей, моделирование ткани
- Перемещение -> Загрузка текстур и буферов
- Разрежение -> Загрузка части “мега-текстуры”
Физическое устройство предоставит вам доступ к нескольким очередям из разных семейств очередей. Когда очередь создается, это будет ее индекс в соответствующем семействе очередей на устройстве. Семейства очередей — это очереди, которые имеют одинаковые свойства друг с другом (например, выполняют как графические, так и вычислительные операции).
Команды передаются в очереди, сначала записывая серию команд в буфер команд, а затем отправляя весь буфер в очередь с помощью vk :: Queue :: submit ().
Примечание. В идеале вы должны повторно использовать командные буферы, но обычно вы можете перезаписывать их каждый кадр без значительного снижения производительности, и это облегчает некоторые вещи (например, отправку однородных буферов в шейдеры).
Кроме того, вы можете отправить несколько командных буферов в одну очередь. Например, это нужно, чтобы один набор команд из буфера завершил выполнение до запуска другого буфера команд в очереди (подробнее об этом позже).
Наконец, команды, записанные в буферах команд, могут выполнить:
- Действия -> отрисовку, отправку, очистку, копирование, операции запроса и т.д.
- Настройку состояния -> связать конвейеры / буферы, перенести константы и т.д.
- Синхронизацию -> установку / ожидание событий, конвейерные барьеры и т.д.
Некоторые команды выполняют только одну из этих задач, а другие несколько. Проход рендеринга необходим для выполнения определенных команд, включая рисование.
Проходы рендеринга
Проход рендеринга состоит из одного или нескольких подпроходов, а также нескольких буферов кадра. Буферы кадра представляют собой последовательность вложений памяти (например, цвет, глубина и т.д.) которые каждый подпроход может считывать и записывать.
Команда в подпроходе может записывать в буфер цвета, что позволит последующим подпроходам / проходам рендеринга читать из него. Это позволяет использовать такие методы, как отложенный рендеринг.
Отложенный рендеринг — это история для другого раза, но суть в том, что вы можете сохранить большую часть информации о геометрии на начальном этапе рендеринга и отложить дорогостоящие операции, такие как освещение, на будущее.
Пример кода для записи в буфер команд и прохода рендеринга
// Создать буфер команд
vk::CommandBuffer cmd;
// Начать запись в буфер команд и проход рендеринга
cmd.begin(vk::CommandBufferBeginInfo());
cmd.beginRenderPass(vk::RenderPassBeginInfo();
// Привязать графический конвейер (рассмотрено позже)
cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, graphicsPipeline);
// Для отрисовки вершин привязать информацию, содержащую буферы вершин
cmd.bindVertexBuffers(0, 1, vertexBuffers, offsets);
// Отрисовать буфер, в текущий момент привязанный к данному буферу команд
cmd.draw(vertices.size(), 1, 0, 0);
// Прекратить запись в проход рендеринга и командный буфер
cmd.beginRenderPass(vk::RenderPassBeginInfo();
cmd.end();
Последняя вещь! Некоторые команды, которые выполняют действия (например, рисуют вершины), делают это на основе текущего состояния (например, текущего связанного массива вершин), установленного командами с начала буфера команд. Это означает, что в приведенном выше примере кода cmd.draw () будет работать с текущим состоянием, установленным cmd.bindVertex () в предыдущей строке. Такая «гарантия синхронизации», что одна команда завершит выполнение до начала следующей, обычно неверна.
Синхронизация
Графические процессоры оптимизированы для высокой пропускной способности операций, и поэтому (за некоторыми исключениями) команды не выполняются в том порядке, в котором они были записаны. Первая команда, выполняющая действие в буфере команд, не обязательно завершит выполнение раньше последней в этом буфере. Первый буфер команд, отправленный в очередь, не обязательно завершит выполнение каких-либо команд раньше запуска последующего. То же самое относится к буферам команд, отправленным в разные очереди и на несколько субпроходов.
Иногда спецификации Vulkan вводят в заблуждение по этому вопросу, но если вкратце, то это верно, если не используется объект синхронизации (или команды установки состояния внутри одного буфера команд).
Существует несколько типов объектов синхронизации:
- Заборы (синхронизация между GPU и CPU) -> Пример: убедиться, что в цепочке переключений одновременно имеется только два визуализированных кадра (то есть двойная буферизация)
- Семафоры (синхронизация GPU с GPU между очередями) -> Пример: дождаться конца рендеринга кадра до того, как представить его
- Барьеры (синхронизация в буфере команд / конвейере) -> Пример: запуск вычислительного шейдера сразу после завершения вершинного шейдера
- Зависимости подпрохода (синхронизация между подпроходами) -> Пример: дождаться завершения нормального и альбедо прикреплений, прежде чем запускать подпроход рендеринга освещения.
Синхронизация тесно связана с графическим конвейером.
Графический конвейер
Он берет меши и текстуры 3D-моделей (вместе с другой информацией) и превращает это в пиксели на вашем 2D-экране. Каждый этап графического конвейера работает на выходе предыдущего этапа.
В графическом конвейере есть два типа этапов: фиксированные функции и шейдеры.
Фиксированные функции выполняют операции, которые можно настроить с помощью параметров, но способ работы функций предопределен. Все, что в графическом конвейере не является шейдером, является фиксированной функцией.
Шейдеры — это созданные пользователем программы, которые выполняются в графическом конвейере. Они могут читать из входных переменных (например, положения вершины / фрагмента / света) и работать на графических процессорах. GPU отлично справляются с параллельными вычислительными задачами, такими как применение одного и того же правила освещения для каждого из 2 миллионов пикселей на экране или вращение 3D-модели с тысячами вершин.
В упрощенном виде графический конвейер состоит из 7 этапов:
- Входная сборка: Собирает необработанные данные вершин из указанных буферов. При желании индексный буфер может использоваться для повтора определенных элементов без дублирования данных вершин.
- Вершинный шейдер: Выполняется на каждой вершине и передает данные каждой вершины дальше по графическому конвейеру. Обычно применяет преобразования к вершинам и преобразует из пространства модели в пространство экрана.
- Тесселяционный шейдер: Необязателен. Работает с массивами вершин («патчами») и подразделяет их на более мелкие примитивы.
- Геометрический шейдер: Необязателен. Работает на каждом примитиве (треугольник, линия, точка) и может отбрасывать примитивы или выводить их больше. Этот этап часто не используется, потому что его производительность невелика на большинстве видеокарт.
- Растеризация: Выполняет дискретизацию примитивов на фрагменты (данные, необходимые для генерации пикселя). Фрагменты, которые выпадают за пределы экрана, и фрагменты, которые находятся за другими примитивами, отбрасываются.
- Фрагментный шейдер: Выполняется для каждого фрагмента и определяет его цвет, значение глубины и в какой кадровый буфер тот записывается. Часто использует интерполированные данные из вершинного шейдера, такие как нормали поверхности, чтобы применить освещение.
- Смешивание цветов: Применяет операции для смешивания разных фрагментов, которые отображаются на один и тот же пиксель в кадровом буфере. Фрагменты могут перезаписывать друг друга или смешиваться на основе прозрачности.
Шейдерные модули
В отличие от OpenGL, код шейдера в Vulkan должен быть в формате байт-кода под названием SPIR-V, а не читабельного синтаксиса типа GLSL.
Преимущество формата байт-кода заключается в том, что компиляторы для преобразования шейдерного кода в собственный код графического процессора значительно менее сложны. Это приводит к тому, что шейдерный код SPIR-V становится надежнее для видеокарт от разных проихводителей.
Тем не менее, шейдеры все еще обычно пишутся на GLSL, а затем компилируются в SPIR-V с помощью инструмента под названием glslc (входит в Vulkan SDK). SPIR-V можно передать в графический конвейер путем чтения байт-кода, а затем обернув тот в объект vk :: ShaderModule. Объект определяет функцию точки входа в шейдере и назначает ее для определенного этапа графики.
Время показа
Цепочки переключений и поверхности окон
Мы проделали всю эту работу для рендеринга изображения, теперь нам нужно представить изображение на поверхности окна из цепочки переключений.
Поверхность окна позволяет вам взаимодействовать с системами отображения для конкретных платформ.
Цепочка переключений представляет собой массив как минимум из двух показываемых изображений. Первое изображение — это буфер кадра, то есть изображение, показываемое на экране, а последующие изображения — вторичный буфер. Если вы не используете вторичный буфер и напрямую отправляете новые изображения в буфер кадра, то во время обновления монитора будет происходить разрыв изображения (когда верхняя часть экрана содержит новое изображение, а нижняя старое).
Использование буфера кадра и одного вторичного буфера называется двойной буфер. Эта техника предотвращает разрыв изображения.
Рендеринг треугольника в Vulkan
В итоге, в приложении Vulkan:
- Мы начнем с создания экземпляра, физического устройства и логического устройства.
- Команды записываются в буферы команд и проходы рендеринга, которые передаются в очереди. Графический процессор проходит через очереди и выполняет команды в них
- Чтобы гарантировать выполнение определенных команд после завершения выполнения других команд, необходимо использовать объекты синхронизации.
- Некоторые команды связаны со стадиями в графическом конвейере, которые могут быть либо шейдерами, либо фиксированными функциями, и превращают трехмерные данные в двухмерное изображение.
- Наконец, поверхность окна показывает изображения из цепочки переключений на экран
Вот почему рендеринг треугольника в Vulkan сложен.
Не стесняйтесь связаться со мной в Twitter. Отзывы и комментарии приветствуются :)
Оригинал статьи: https://liamhinzman.com/blog/vulkan-fundamentals
спасибо!