Рендеринг треугольника в Vulkan (почему так сложно)

Рендеринг треугольника в 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 ().

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

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

Наконец, команды, записанные в буферах команд, могут выполнить:

  • Действия -> отрисовку, отправку, очистку, копирование, операции запроса и т.д.
  • Настройку состояния -> связать конвейеры / буферы, перенести константы и т.д.
  • Синхронизацию -> установку / ожидание событий, конвейерные барьеры и т.д.

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

Проходы рендеринга

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

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

Цветные прикрепления
Цветные прикрепления после первоначального прохода рендеринга в отложенном рендеринге (LearnOpenGL)

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

Пример кода для записи в буфер команд и прохода рендеринга

// Создать буфер команд
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-модели с тысячами вершин.

Разные шейдеры на одной и той же сфере
Одна 3D-модель с различными шейдерами может выглядеть очень по-разному (Родриго Толедо)

В упрощенном виде графический конвейер состоит из 7 этапов:

  1. Входная сборка: Собирает необработанные данные вершин из указанных буферов. При желании индексный буфер может использоваться для повтора определенных элементов без дублирования данных вершин.
  2. Вершинный шейдер: Выполняется на каждой вершине и передает данные каждой вершины дальше по графическому конвейеру. Обычно применяет преобразования к вершинам и преобразует из пространства модели в пространство экрана.
  3. Тесселяционный шейдер: Необязателен. Работает с массивами вершин («патчами») и подразделяет их на более мелкие примитивы.
  4. Геометрический шейдер: Необязателен. Работает на каждом примитиве (треугольник, линия, точка) и может отбрасывать примитивы или выводить их больше. Этот этап часто не используется, потому что его производительность невелика на большинстве видеокарт.
  5. Растеризация: Выполняет дискретизацию примитивов на фрагменты (данные, необходимые для генерации пикселя). Фрагменты, которые выпадают за пределы экрана, и фрагменты, которые находятся за другими примитивами, отбрасываются.
  6. Фрагментный шейдер: Выполняется для каждого фрагмента и определяет его цвет, значение глубины и в какой кадровый буфер тот записывается. Часто использует интерполированные данные из вершинного шейдера, такие как нормали поверхности, чтобы применить освещение.
  7. Смешивание цветов: Применяет операции для смешивания разных фрагментов, которые отображаются на один и тот же пиксель в кадровом буфере. Фрагменты могут перезаписывать друг друга или смешиваться на основе прозрачности.

Шейдерные модули

В отличие от OpenGL, код шейдера в Vulkan должен быть в формате байт-кода под названием SPIR-V, а не читабельного синтаксиса типа GLSL.

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

Тем не менее, шейдеры все еще обычно пишутся на GLSL, а затем компилируются в SPIR-V с помощью инструмента под названием glslc (входит в Vulkan SDK). SPIR-V можно передать в графический конвейер путем чтения байт-кода, а затем обернув тот в объект vk :: ShaderModule. Объект определяет функцию точки входа в шейдере и назначает ее для определенного этапа графики.

Время показа

Цепочки переключений и поверхности окон

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

Поверхность окна позволяет вам взаимодействовать с системами отображения для конкретных платформ.

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

Разрыв изображения
Разрыв изображения, вызванный прямой отправкой изображений в буфер кадра (Direct2D Succinctly)

Использование буфера кадра и одного вторичного буфера называется двойной буфер. Эта техника предотвращает разрыв изображения.

Рендеринг треугольника в Vulkan

В итоге, в приложении Vulkan:

  • Мы начнем с создания экземпляра, физического устройства и логического устройства.
  • Команды записываются в буферы команд и проходы рендеринга, которые передаются в очереди. Графический процессор проходит через очереди и выполняет команды в них
  • Чтобы гарантировать выполнение определенных команд после завершения выполнения других команд, необходимо использовать объекты синхронизации.
  • Некоторые команды связаны со стадиями в графическом конвейере, которые могут быть либо шейдерами, либо фиксированными функциями, и превращают трехмерные данные в двухмерное изображение.
  • Наконец, поверхность окна показывает изображения из цепочки переключений на экран

Вот почему рендеринг треугольника в Vulkan сложен.

Не стесняйтесь связаться со мной в Twitter. Отзывы и комментарии приветствуются :)

Оригинал статьи: https://liamhinzman.com/blog/vulkan-fundamentals

Понравилась статья? Поделиться с друзьями:
Автор natalya
Переводит для Вас самые интересные статьи про разработку игр. По образованию физик-программист. Техническими переводами начала подрабатывать еще на старших курсах и постепенно это переросло в основное занятие. Интересуется гуманитарными технологиями, пробует себя в журналистике.
  1. Константин :

    спасибо!

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *