Читать книгу «Vulkan и многопоточность для смертных» или: как заставить работать видеокарту и все 16 ядер, а не себя (Алексей Примак) онлайн бесплатно на Bookz (3-ая страница книги)
«Vulkan и многопоточность для смертных» или: как заставить работать видеокарту и все 16 ядер, а не себя
«Vulkan и многопоточность для смертных» или: как заставить работать видеокарту и все 16 ядер, а не себя
Оценить:

4

Полная версия:

«Vulkan и многопоточность для смертных» или: как заставить работать видеокарту и все 16 ядер, а не себя

ПОТОКИ. Запросы к PhysicalDevice безопасны из любых потоков. А вот каждая VkQueue требует внешней синхронизации: два потока не могут одновременно вызвать submit в одну очередь. Отдельные очереди transfer и compute решают это изящно: у каждой свой поток-владелец, и мьютексы не нужны. Именно так устроены главы 23 и 24.

Шпаргалка главы 3

• PhysicalDevice — справочное бюро, Device — рабочий сеанс.

• Жёсткие требования: apiVersion ≥ 1.3, VK_KHR_swapchain, нужные фичи, семейство graphics + present.

• Фичи включаются через StructureChain из Features2/11/12/13.

• Выделенные семейства: compute без graphics, transfer без всего. Опциональны.

• Одна VkQueue = один поток за раз.

Задачи для разминки мозгов

Задача 3.1. «Выбор по заказу». Добавь переменную окружения PEPELAC_GPU. Если она задана, выбирается первая подходящая карта, в имени которой есть эта подстрока. Если такой нет или она не прошла собеседование — предупреждение и обычный выбор по баллам.

Задача 3.2. «Карта с характером». Спецификация допускает GPU, у которого графическое семейство не умеет выводить на экран, а другое умеет. Перепиши findQueueFamilies с отдельным present-семейством, предпочитая совпадение с графическим. Покажи, как поменяется createLogicalDevice, чтобы семейства не повторялись.

Решения — в приложении A.

Глава 4. Surface и swapchain: карусель кадров

Vulkan не умеет рисовать «в окно». Он рисует в картинки, а показом картинок занимается отдельная система показа — presentation engine, часть ОС и драйвера. Swapchain — это договор между тобой и этой системой: набор картинок, которые вы передаёте друг другу по кругу.

4.1. Как устроен swapchain




У каждой картинки в каждый момент есть хозяин: либо ты (после acquire), либо система показа (после present). Рисовать в картинку, которая сейчас на экране, нельзя: получишь разрывы изображения. Поэтому картинок нужно минимум две, а лучше три.

4.2. Режимы показа




FIFO — единственный режим, который обязан поддерживать любой драйвер. Остальные надо проверять.

4.3. Код: создание swapchain








4.4. Разбор

Шаг 1 — формат. B8G8R8A8 — четыре канала по 8 бит. Порядок BGR исторически родной для дисплеев, для шейдера разницы нет. Важнее суффикс Srgb. Шейдеры считают цвет в линейном пространстве (вдвое больше света = вдвое больше число), а монитор ждёт гамма-сжатые значения. При записи в sRGB-картинку железо само делает преобразование, бесплатно. Возьмёшь Unorm — средние тона станут темнее, а ты неделю будешь крутить освещение в шейдерах.

std::ranges::find_if(formats, …) возвращает итератор. Сравнение с formats.end() — проверка «нашли ли». formats.front() безопасен: спецификация гарантирует хотя бы один формат для поддерживаемого surface.

Шаг 2 — режим. Предпочитаем MAILBOX: низкая задержка без разрывов. Нет его — FIFO, он есть всегда. std::span принимает вектор, вернувшийся из Vulkan-Hpp, без копирования.

Шаг 3 — размер. currentExtent обычно равен размеру окна. Специальное значение 0xFFFFFFFF означает «размер решаешь ты», так делает, например, Wayland. Тогда берём размер фреймбуфера в пикселях и зажимаем в разрешённые границы. std::clamp(x, lo, hi) — короткая запись min(max(x, lo), hi). На экранах с масштабированием 200% окно «1280×720» занимает 2560×1440 пикселей, отсюда задача 4.2.

Шаг 4. Три вопроса к паре «видеокарта + surface». Ответы зависят и от драйвера, и от оконной системы, поэтому захардкодить их нельзя.

Шаг 5 — число картинок. Для MAILBOX нужно три: одна на экране, одна ждёт очереди, в одну рисуешь. std::max(3u, …) — 3u именно unsigned, иначе std::max не выведет тип из int и uint32_t и не скомпилируется. maxImageCount == 0 — ещё одно спецзначение, на нём спотыкается каждый второй. И важно: мы просим минимум, драйвер вправе создать больше. Реальное число узнаём из getImages(), и именно его используем для размера renderFinished.

Шаг 6 — прозрачность. compositeAlpha решает, смешивать ли окно с рабочим столом по альфе. Большинство систем поддерживают eOpaque, но не все, поэтому выбираем с проверкой. Битовое & на флагах даёт набор, а в условии ? : он явно и безопасно приводится к bool.

Шаг 7 — создание. Поля, в которых новички ошибаются чаще всего:

• imageUsage = eColorAttachment — мы будем рисовать в эти картинки как в цветовой буфер. Захочешь копировать в них (blit) — добавь eTransferDst, но сначала проверь caps.supportedUsageFlags.

• imageSharingMode = eExclusive — картинка принадлежит одному семейству очередей за раз. Это быстрее, чем eConcurrent. Передачу владения между семействами разберём в главе 23.

• preTransform = currentTransform — «ничего не вращать». На телефонах существует целая наука про поворот кадра без лишнего копирования, но на десктопе это всегда identity.

• clipped = vk::True — содержимое пикселей, перекрытых чужими окнами, нам неважно. Драйвер может их не рисовать.

• getImages() возвращает std::vector — голые хэндлы без RAII. Ими владеет swapchain.

Шаг 8 — image views. Шейдеры и команды рендеринга работают не с картинкой, а с видом на неё. View говорит, как читать байты: какой формат, какие mip-уровни, какие слои массива, какой аспект (цвет, глубина, трафарет). Аналогия: std::string_view на std::string. Из одной картинки можно сделать несколько разных view.

• reserve заранее выделяет память в векторе, чтобы не было переездов при emplace_back.

• emplace_back(device, info) конструирует vk::raii::ImageView прямо в векторе. vk::ImageViewCreateInfo{…} приходится писать с именем типа: через аргументы шаблонной функции фигурные скобки без типа не проходят.

• components не задаём: по умолчанию там eIdentity, каналы не переставляются.

• В Pepelac появляются поля vk::Format swapchainFormat и vk::Extent2D swapchainExtent рядом со swapchain. Они нужны пайплайну (глава 6) и рендерингу (глава 7).

ЗАПОМНИ. Image — память с пикселями, ImageView — инструкция, как их читать. Рисуют в view, барьеры ставят на image. И никогда не полагайся на то, что картинок столько, сколько ты просил.

ГРАБЛИ. MAILBOX рендерит кадры без остановки и выбрасывает лишние. На ноутбуке это 100% загрузки GPU, вентилятор в режиме «взлёт» и батарея на час. Для редактора или UI-приложения бери FIFO.

ПОТОКИ. Swapchain требует внешней синхронизации при acquireNextImage и при пересоздании. present требует её от очереди. Проще всего закрепить acquire и present за одним потоком — обычно главным или выделенным «потоком рендера». На Windows и macOS present ещё и любит поток, который обрабатывает сообщения окна. Не всегда, но лучше не проверять в пятницу.

Шпаргалка главы 4

• Surface = окно для Vulkan. Swapchain = карусель картинок между тобой и системой показа.

• Формат: B8G8R8A8Srgb + SrgbNonlinear, иначе цвета поедут.

• Режим: MAILBOX если есть, иначе FIFO (есть всегда).

• Размер — в пикселях фреймбуфера: glfwGetFramebufferSize.

• Спецзначения: currentExtent = 0xFFFFFFFF, maxImageCount = 0.

• Реальное число картинок — из getImages().

• На каждую картинку — свой ImageView.

Задачи для разминки мозгов

Задача 4.1. «Настройка vsync». Сделай структуру настроек с полем bool vsync. С vsync режим всегда FIFO. Без vsync — первый поддержанный из списка MAILBOX → IMMEDIATE → FIFO. Реализуй выбор одной функцией по списку приоритетов, без лесенки if.

Задача 4.2. «Ноутбук с 4K-экраном». Объясни разницу между glfwGetWindowSize и glfwGetFramebufferSize на экране с масштабом 200%. Что именно сломается, если передать в swapchain размер окна? И нужен ли вообще glfwGetFramebufferSize, если currentExtent обычно заполнен?

Решения — в приложении A.

Глава 5. Шейдеры: программы, которые живут в видеокарте

Шейдер — это маленькая программа, которую GPU запускает тысячи раз параллельно: для каждой вершины, для каждого пикселя. Пишем мы его на Slang, компилируем в SPIR-V при сборке, а драйвер доводит SPIR-V до машинного кода своей видеокарты. Звучит как три компилятора подряд. Так и есть.

5.1. Графический конвейер




Два этапа мы пишем сами, остальные только настраиваем (глава 6).

• Вершинный шейдер запускается один раз на каждую вершину и возвращает её позицию в clip space. После деления на w получаются нормализованные координаты (NDC).

• Растеризатор решает, какие пиксели накрыты треугольником, и интерполирует выходы вершинного шейдера между вершинами.

• Фрагментный шейдер запускается на каждый накрытый пиксель и возвращает его цвет.

Координаты NDC в Vulkan: x от −1 (лево) до +1 (право), y от −1 (верх) до +1 (низ), z от 0 до 1. В OpenGL y смотрит вверх, а z идёт от −1. Половина багов переноса с OpenGL — перевёрнутая картинка и исчезнувшая глубина.

5.2. Первый шейдер на Slang

Slang — язык шейдеров с синтаксисом, близким к HLSL. В одном файле можно держать несколько точек входа, есть модули и даже дженерики. Вершины пока храним прямо в шейдере: буферы появятся в главе 11.





Разбор.

• Шаг 1. static const — константа уровня модуля, как в C++. float2, float3, float4 — вектора из 2, 3 и 4 чисел. Координаты уже в NDC, поэтому треугольник займёт половину окна по каждой оси. Порядок вершин на экране — по часовой стрелке. Запомни это, пригодится для отсечения граней в главе 6.

• Шаг 2. Семантика SV_Position (SV — system value) говорит компилятору: это не просто данные, а позиция для растеризатора. Поле color без семантики Slang сам привяжет к первому свободному location. Растеризатор интерполирует его между вершинами, отсюда градиент.

• Шаг 3. [shader("vertex")] помечает точку входа. SV_VertexID — номер вершины в вызове draw: 0, 1, 2. Именно так мы рисуем без вершинных буферов. В float4(xy, 0.0, 1.0) четвёртая компонента w = 1 означает «перспективы нет». В главе 12 её начнёт менять матрица проекции.

• Шаг 4. Фрагментный шейдер получает ту же структуру, уже проинтерполированную для конкретного пикселя. SV_Target — «записать в цветовой буфер номер 0». Альфа 1.0 — полностью непрозрачный.

5.3. Компиляция в SPIR-V из CMake

SPIR-V — бинарный промежуточный язык из 32-битных слов. Драйвер не парсит текст шейдера, как это было в OpenGL. Плюс двойной: нет багов разных парсеров GLSL у разных производителей, и ошибки в шейдерах ловятся при сборке, а не у пользователя.





Разбор.

• Шаг 5. find_program ищет slangc в SDK и в PATH. REQUIRED остановит конфигурацию с понятной ошибкой, если SDK не найден.

• Шаг 6. add_custom_command описывает, как получить файл OUTPUT и от чего он зависит (DEPENDS). CMake пересоберёт шейдер только когда изменится .slang. target_sources с .spv нужен, чтобы сборка pepelac требовала свежего шейдера. Иначе команда никогда не запустится.

• Флаги slangc: -target spirv — целевой формат; -profile spirv_1_4 — версия SPIR-V (Vulkan 1.3 понимает до 1.6); -emit-spirv-directly — генерировать SPIR-V напрямую, без промежуточного GLSL; -fvk-use-entrypoint-name — сохранить имена vertMain и fragMain, иначе обе точки входа стали бы main и подрались; -matrix-layout-column-major — матрицы в памяти по столбцам, как в GLM (глава 12); -g в debug — отладочная информация, и RenderDoc покажет исходник шейдера.

• "$<$:-g>" — generator expression: подставляет -g только в Debug. Выглядит как проклятие, работает как тернарный оператор.

• PEPELAC_SHADER_DIR пробрасываем в C++ как дефайн с абсолютным путём. Для разработки этого достаточно. В релизе шейдеры кладут рядом с исполняемым файлом или вшивают в него.

5.4. Загрузка SPIR-V и shader module






Разбор.

• Шаг 7. Флаг std::ios::ate открывает файл с курсором в конце, и tellg() сразу даёт размер. Потом seekg(0) и чтение всего целиком. Читаем именно в vector, а не в vector. Vulkan требует, чтобы pCode был выровнен на 4 байта. Буфер char такой гарантии не даёт, а reinterpret_cast поверх него — ещё и нарушение правил алиасинга. В обратную сторону (uint32_t* → char*) стандарт каст разрешает.

• Магическое число 0x07230203 — первое слово любого SPIR-V. Проверка стоит одну строку и экономит час, когда кто-то подсунет пустой файл или HTML-страницу «404», скачанную вместо ассета.

• Шаг 8. codeSize задаётся в байтах, а pCode — указатель на uint32_t. Нелогично, но так исторически сложилось. span::size_bytes() спасает от классической ошибки code.size() вместо code.size() * 4.

• Shader module — не скомпилированная программа, а просто контейнер с SPIR-V. Настоящая компиляция в машинный код GPU происходит при создании пайплайна в главе 6. После этого модуль можно уничтожить, пайплайн работать продолжит. Возврат vk::raii::ShaderModule по значению бесплатен благодаря гарантированному copy elision из C++17.

ЗАПОМНИ. Цепочка шейдера: .slang → (slangc, при сборке) → .spv → (ShaderModule, в рантайме) → (драйвер, при создании пайплайна) → машинный код GPU. Ошибки синтаксиса ловит первый этап, а фризы из-за компиляции рождает последний (глава 25).

ГРАБЛИ. Имя точки входа в пайплайне (pName) должно точно совпадать с именем в SPIR-V. Забыл -fvk-use-entrypoint-name, а в коде пишешь "vertMain" — валидация напишет, что точки входа нет. Без валидации драйвер может просто упасть.

ПОТОКИ. vkCreateShaderModule можно вызывать из любых потоков одновременно, device внешней синхронизации не требует. Чтение файлов с диска — идеальная работа для фонового потока. К этому вернёмся в главах 23 и 25.

Шпаргалка главы 5

• Программируемые этапы: вершинный и фрагментный шейдеры. Растеризатор между ними интерполирует.

• NDC в Vulkan: Y вниз, Z от 0 до 1.

• Slang: [shader("vertex")], SV_Position, SV_VertexID, SV_Target.

• slangc … -target spirv -fvk-use-entrypoint-name — несколько точек входа в одном .spv.

• SPIR-V читаем в vector: выравнивание на 4 байта.

• codeSize — в байтах.

• Shader module нужен только до создания пайплайна.

Задачи для разминки мозгов

Задача 5.1. «Квадрат из воздуха». Измени шейдер так, чтобы вызов draw(6, 1, 0, 0) рисовал квадрат из двух треугольников с четырьмя цветными углами. Без буферов и без массива из шести позиций: четыре угла и массив индексов. Оба треугольника должны идти по часовой стрелке.

Задача 5.2. «Паспорт шейдера». Второе слово SPIR-V — версия формата: байт старшей версии в битах 16–23, младшей — в битах 8–15. Доработай readSpirv, чтобы она проверяла минимальный размер заголовка (пять слов) и версию не выше 1.6. Почему версия выше 1.6 для нас ошибка?

Решения — в приложении A.

Глава 6. Graphics pipeline: запекаем всё заранее

В OpenGL ты менял состояние вызовами glEnable(GL_BLEND) где угодно, а драйвер на каждом draw call собирал из этого конфигурацию железа. В Vulkan всё состояние отрисовки собирается один раз в объект pipeline. Он компилируется заранее и больше не меняется. Хочешь другое смешивание — делай другой пайплайн. Звучит неудобно, но именно так исчезают загадочные фризы из главы 0.

6.1. Из чего состоит пайплайн




Про dynamic rendering. Раньше пайплайн создавался под конкретный VkRenderPass — громоздкий объект с подпроходами и зависимостями. В Vulkan 1.3 достаточно сообщить форматы картинок через PipelineRenderingCreateInfo. Минус два типа объектов и минус сотня строк.

6.2. Код






6.3. Разбор

Шаг 1. Один shader module и две стадии, отличающиеся stage и pName. Модуль — локальная переменная: после создания пайплайна он больше не нужен, и RAII уберёт его на выходе из функции. std::array stages{…} выводит тип и размер сам (CTAD), но только если тип первого элемента написан явно.

Шаг 2. Пустой vertexInput — «вершинных буферов нет», всё берётся из SV_VertexID. Топология eTriangleList: вершины 0-1-2 — первый треугольник, 3-4-5 — второй. Есть ещё strip, fan, линии и точки, но list — самый предсказуемый вариант.

Шаг 3. Viewport — прямоугольник, в который растягивается NDC. Scissor — прямоугольник, за пределами которого пиксели отбрасываются. Оба зависят от размера окна. Запечёшь их в пайплайн — при каждом ресайзе придётся пересоздавать пайплайн. Поэтому они динамические (шаг 7), а здесь указывается только их количество.

Шаг 4. Растеризатор:

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

• polygonMode = eFill — заливать треугольники. eLine (каркас) требует фичу fillModeNonSolid, смотри задачу 6.1.

• cullMode = eBack + frontFace = eClockwise — треугольники, идущие на экране по часовой, считаются лицевыми. Остальные отбрасываются до фрагментного шейдера. У замкнутой модели задние грани всё равно не видны, а экономия — почти половина работы.

• lineWidth = 1.0f — обязательное поле, даже если ты рисуешь треугольники. Оставишь 0 — получишь ошибку валидации.

Шаг 5. Один сэмпл на пиксель, то есть без MSAA. Вернёмся к этому в главе 14.

Шаг 6. Смешивание выключено: новый цвет просто заменяет старый. colorWriteMask — какие каналы вообще записывать. Классическая диверсия для новичка: забыть маску. По умолчанию там ноль, и ты получишь идеально работающий пайплайн, который ничего не рисует. Валидация про это не скажет: формально всё законно.

Шаг 7. Динамическое состояние — компромисс между «всё запечено» и «всё как в OpenGL». Viewport и scissor делают динамическими всегда, это ничего не стоит. Vulkan 1.3 позволяет сделать динамическим почти всё (cull mode, тест глубины, топологию). Это уменьшает число пайплайнов, подробнее в главе 25.

Шаг 8. Pipeline layout описывает «интерфейс» шейдеров к внешнему миру: наборы дескрипторов и push constants. У нас пока пусто, но создать layout всё равно надо. Это поле класса, а не локальная переменная: он понадобится при записи команд.

Шаг 9. Все структуры передаются по указателям, поэтому они обязаны жить до вызова на шаге 10. Локальные переменные функции — идеально. А вот адрес временного объекта, написанного прямо в инициализаторе, взять нельзя: компилятор не даст, и правильно сделает. PipelineRenderingCreateInfo в цепочке сообщает: «рисовать будем в одну картинку формата swapchainFormat». Этот формат обязан совпасть с форматом картинки в beginRendering (глава 7).

Шаг 10. vk::raii::Pipeline(device, nullptr, info) — второй аргумент это pipeline cache. Пока его нет, появится в главе 25. Именно здесь происходит дорогая работа: драйвер компилирует SPIR-V с учётом всего запечённого состояния. На сложном шейдере это десятки и сотни миллисекунд. Тысяча материалов — и игра грузится минуту (спойлер к главе 25).

ЗАПОМНИ. Пайплайн неизменяем. Дорого его создать, дёшево переключить. Всё, что меняется каждый кадр или зависит от окна, делай динамическим состоянием. Данные (матрицы, цвета) идут в буферы и push constants, а не в новые пайплайны.

ГРАБЛИ. Треугольник не виден, валидация молчит? Три подозреваемых: нулевой colorWriteMask, неверный frontFace (треугольник отсёкся как задний), нулевой размер viewport. Временно поставь cullMode = eNone. Появился — значит, дело в порядке вершин.

ПОТОКИ. Неизменяемость — лучший друг многопоточности. Готовый пайплайн можно одновременно использовать в командных буферах из любого числа потоков без блокировок. А vkCreateGraphicsPipelines можно вызывать параллельно из разных потоков. На этом построена фоновая компиляция в главе 25.

Шпаргалка главы 6

• Пайплайн = шейдеры + фиксированные состояния + layout + форматы картинок. Неизменяем.

• Viewport и scissor — всегда динамические.

• cullMode + frontFace должны согласовываться с порядком вершин.

• colorWriteMask = RGBA, иначе ничего не нарисуется.

• Dynamic rendering: PipelineRenderingCreateInfo в pNext, renderPass = nullptr.

• Shader module можно уничтожить сразу после создания пайплайна.

Задачи для разминки мозгов

Задача 6.1. «Рентген». Добавь второй пайплайн, который рисует каркас (ePolygonMode::eLine), и переключение между пайплайнами по клавише F1. Какую фичу устройства нужно включить и в каких двух местах кода из главы 3? Как избежать копипаста всей функции?

Задача 6.2. «Полупрозрачность». Настрой смешивание «поверх» (alpha blending): итог = новый × α + старый × (1 − α). Выпиши все поля PipelineColorBlendAttachmentState. Что поменять в шейдере, чтобы треугольник стал полупрозрачным? И почему для нескольких прозрачных объектов важен порядок отрисовки?

Решения — в приложении A.

Глава 7. Командные буферы: список дел для GPU

Всё, что делает GPU, сначала записывается в командный буфер: очистка, отрисовка, копирование, барьеры. Запись — чистая работа CPU, GPU при этом ничего не делает. Именно поэтому запись можно разнести по потокам (глава 21). В этой главе мы запишем первый кадр целиком.

7.1. Пулы, буферы и их состояния

Командные буферы не создаются, а выделяются из пула. Пул — локальный аллокатор памяти для команд, привязанный к одному семейству очередей. Буфер, выделенный из graphics-пула, нельзя отправить в transfer-очередь.

У командного буфера есть жизненный цикл:





Самое опасное состояние — Pending: буфер отправлен и выполняется GPU. В это время его нельзя ни сбрасывать, ни перезаписывать, ни уничтожать. Ровно для этого в главе 0 стоял waitForFences.

bannerbanner