
Полная версия:
«Vulkan и многопоточность для смертных» или: как заставить работать видеокарту и все 16 ядер, а не себя
7.2. Layout картинки за пять минут
GPU хранит пиксели картинки по-разному в зависимости от того, что с ней делают: сжатие для рендеринга, другая раскладка для чтения из шейдера, третья для показа на экране. Эта раскладка называется layout. Перед каждым способом использования картинку нужно перевести в подходящий layout. Перевод делает барьер — команда, которая заодно упорядочивает работу GPU.
Для нашего кадра нужны два перехода:

Всю теорию барьеров разберём в главе 15. Сейчас достаточно знать: барьер говорит «всё, что до меня, на стадии X должно закончиться, прежде чем всё, что после, начнётся на стадии Y».
7.3. Код




7.4. Разбор
Шаг 1. У барьера шесть параметров, и четыре из них попарно одного типа. Функция f(a, b, c, d, e, f) с такими аргументами — гарантированная путаница. Структура с designated initializers делает каждый вызов самодокументируемым. А перепутать .srcStage и .dstStage не даст компилятор: порядок полей обязан совпадать с объявлением.
ImageMemoryBarrier2 и pipelineBarrier2 — из synchronization2. В старом API стадии задавались аргументами функции сразу для всех барьеров. В новом они у каждого барьера свои, и это намного читабельнее. subresourceRange говорит, какую часть картинки переводим: у нас один mip и один слой. У текстур с mip-уровнями (глава 13) будет иначе.
Шаг 2. Флаг eResetCommandBuffer разрешает сбрасывать буферы по одному (cmd.reset() в drawFrame). Без него сбрасывается только весь пул целиком. В главе 22 мы перейдём именно к этому варианту, он быстрее. allocateCommandBuffers возвращает std::vector
Шаг 3. eOneTimeSubmit обещает драйверу: буфер будет отправлен один раз и перезаписан. Драйвер может сделать из этого мелкие оптимизации. Мы и так перезаписываем буфер каждый кадр.
Шаг 4. Первый барьер. oldLayout = eUndefined говорит: старое содержимое можно выбросить, мы всё равно зальём картинку цветом. Это дешевле перевода из ePresentSrcKHR с сохранением данных. Тонкость со стадиями: srcStage = eColorAttachmentOutput совпадает со стадией, на которой submit ждёт семафор imageAvailable (глава 0, шаг 5). Ожидание семафора и барьер сцепляются в цепочку: переход layout случится после того, как система показа отдаст картинку. Поставь здесь eTopOfPipe — и переход может начаться раньше, чем картинка освободится.
Шаг 5. Dynamic rendering целиком умещается в две структуры:
• RenderingAttachmentInfo — одна картинка, в которую рисуем. loadOp решает, что делать со старым содержимым в начале: eClear (залить цветом), eLoad (сохранить) или eDontCare (неважно). storeOp решает, сохранять ли результат. На мобильных GPU с тайловой архитектурой правильные load/store экономят самое дорогое — пропускную способность памяти.
• vk::ClearColorValue(r, g, b, a) — объединение (union) для float/int/uint цветов. У объединений в Vulkan-Hpp конструкторы остаются, поэтому здесь круглые скобки. Цвет очистки задаётся в линейном пространстве: sRGB-формат сам сделает преобразование.
• RenderingInfo — весь «проход рендеринга»: область, число слоёв, список цветовых картинок, глубина (глава 14).
Шаг 6. Команды отрисовки. Порядок важен: bindPipeline до setViewport, если пайплайн заявил viewport динамическим. На практике динамическое состояние переживает смену пайплайна, но так надёжнее. setViewport(0, viewport) — первый аргумент задаёт номер первого viewport, второй — ArrayProxy, куда можно передать один объект или массив. draw(3, 1, 0, 0) запускает вершинный шейдер три раза с SV_VertexID = 0, 1, 2.
Шаг 7. Второй барьер: все записи цвета должны завершиться (srcAccess = eColorAttachmentWrite), а картинка — перейти в ePresentSrcKHR. В приёмнике никого нет: дальше порядок обеспечивает семафор renderFinished. Забудешь этот барьер — валидация скажет, что картинка показывается в неверном layout. На части железа получишь мусор, на части — всё будет «работать». До первого пользователя с другой видеокартой.
ЗАПОМНИ. Запись команд ничего не выполняет. cmd.draw — это строчка в списке дел, а не отрисовка. Выполнение начнётся после submit, и до сигнала fence буфер нельзя трогать.
ГРАБЛИ. Формат картинки в beginRendering обязан совпадать с форматом в PipelineRenderingCreateInfo пайплайна. Поменялся формат swapchain при пересоздании (бывает при перетаскивании окна на HDR-монитор) — пайплайн тоже надо пересоздать.
ПОТОКИ. Командный пул и все буферы, выделенные из него, требуют внешней синхронизации. Два потока не могут одновременно записывать два разных буфера из одного пула: пул один, и аллокатор внутри него тоже один. Решение в главе 21 тривиально: по пулу на поток. А вот сама запись GPU не трогает, и в этом её сила.
Шпаргалка главы 7
• Пул → буферы. Пул привязан к семейству очередей.
• Состояния: Initial → Recording → Executable → Pending. В Pending буфер не трогать.
• Кадр: барьер (Undefined → ColorAttachment) → beginRendering → pipeline, viewport, scissor, draw → endRendering → барьер (ColorAttachment → PresentSrc).
• loadOp: Clear / Load / DontCare. storeOp: Store / DontCare.
• srcStage первого барьера = стадия ожидания семафора acquire.
• Один пул = один поток за раз.
Задачи для разминки мозгов
Задача 7.1. «Дышащий космос». Сделай так, чтобы цвет очистки плавно пульсировал с периодом 3 секунды. Используй std::chrono::steady_clock. Почему для этого не нужно ни пересоздавать пайплайн, ни менять шейдер? И почему steady_clock, а не system_clock?
Задача 7.2. «Сплитскрин». Нарисуй треугольник дважды: в левой и в правой половинах окна, внутри одного beginRendering. Затем ответь: что увидишь, если viewport — левая половина, а scissor — левая четверть? А наоборот?
Решения — в приложении A.
Глава 8. Синхронизация I: первый живой кадр
Все детали на месте: instance, устройство, swapchain, пайплайн, команды. Не хватает объектов синхронизации и сборки всего вместе. В конце этой главы на экране появится треугольник. Около тысячи строк ради трёх вершин, да. Зато каждую из этих строк ты понимаешь.
8.1. Два инструмента синхронизации

Последняя строка таблицы важнее всего. Binary semaphore работает парами: один signal, один wait. Два signal подряд без wait между ними — ошибка. Wait без предшествующего signal — ошибка и вечное зависание. В части IV появятся timeline-семафоры без этих ограничений. Но для acquire и present нужны именно бинарные.
8.2. Код: объекты синхронизации

Разбор. clear() в начале пригодится в главе 9, когда функция будет вызываться повторно. emplace_back(device, info) создаёт RAII-объект прямо в векторе. У пустого SemaphoreCreateInfo{} заполнен только sType, а больше и не нужно: бинарный семафор — тип по умолчанию. Флаг eSignaled нужен, потому что первое, что делает drawFrame, — ждёт fence. Кадра до первого не было, и без флага ждать пришлось бы вечно.
8.3. Собираем всё вместе: pepelac.hpp
Вот заголовок рендерера целиком, со всем, что накопилось за главы 2–8. Дальше в книге будут показываться только новые поля.



8.4. Конструктор и главный цикл



Разбор.
• Шаг 3. Порядок вызовов в конструкторе совпадает с порядком полей в классе. Это не обязано совпадать, но когда совпадает, сумасшедшего порядка уничтожения не будет. Что будет, если, скажем, createGraphicsPipeline бросит исключение? Тело конструктора прервётся, деструктор ~Pepelac не вызовется, а все уже созданные поля уничтожатся в обратном порядке. Никаких утечек, никаких if (initialized). Это и есть радость RAII.
• explicit Pepelac(GLFWwindow*) запрещает неявное превращение указателя на окно в рендерер. Иначе вызов функции, принимающей const Pepelac&, с аргументом-окном создал бы второй Vulkan-рендерер. Смешно, пока не случится.
• Шаг 4. steady_clock — монотонные часы, они не прыгают при синхронизации времени ОС. Для замеров только они. duration
• Шаг 5. Цикл простой: события → кадр. Скорость цикла регулирует сам drawFrame: с FIFO он блокируется в acquireNextImage или на fence, когда CPU убегает вперёд. Никаких sleep не нужно.
• Деструктор renderer ждёт waitIdle и потом разбирает Vulkan снизу вверх. Затем умирает окно, затем GLFW. Порядок объявления в main делает всю работу.
8.5. Запуск
Собираем, запускаем и видим на тёмно-синем фоне треугольник с красной, зелёной и синей вершинами и плавным градиентом. Лог валидации пуст. В заголовке окна частота монитора (с FIFO) или тысячи кадров (с MAILBOX).
Если ничего не видно, пройди по списку:
1. Лог валидации пуст? Если нет — исправляй сверху вниз, первая ошибка часто порождает все остальные.
2. Виден фон, но нет треугольника? colorWriteMask, frontFace, имена точек входа.
3. Нет даже фона? Барьеры из главы 7 и семафоры в submit2.
4. Всё чёрное и программа висит? Fence создан без eSignaled или не передан в submit2.
5. Всё ещё непонятно? Запусти через RenderDoc и посмотри, что реально делает кадр (глава 28).
ЦИТАТА. «Первый треугольник на Vulkan — как первый прыжок с парашютом: тысяча строк инструктажа ради трёх секунд восторга. Зато второй треугольник стоит одну строку». — автор книги
ЗАПОМНИ. Никогда не вызывай device.waitIdle() или queue.waitIdle() в каждом кадре «для надёжности». Это убивает параллельную работу CPU и GPU, ради которой придуманы кадры в полёте. waitIdle — инструмент для выхода из программы и пересоздания swapchain, а не для рабочего цикла.
ГРАБЛИ. Попробуй сейчас изменить размер окна. В лучшем случае картинка растянется, в худшем программа упадёт с vk::OutOfDateKHRError. Swapchain был создан под старый размер, и чинить это мы будем в следующей главе.
ПОТОКИ. Это первая настоящая параллельная программа в книге, хотя поток у нас один. CPU и GPU — два независимых исполнителя, fence — точка их встречи, аналог join для потока. Правило «не трогай то, что читает другой исполнитель» для командного буфера — то же самое правило про data race, которое в главе 16 встретится для потоков CPU.
Шпаргалка главы 8
• Fence: GPU → CPU, сброс вручную, создаём с eSignaled.
• Binary semaphore: GPU → GPU, строго пара signal/wait.
• imageAvailable и inFlight — по числу кадров в полёте; renderFinished — по числу картинок.
• Инициализация сверху вниз, исключение в середине безопасно благодаря RAII.
• waitIdle в каждом кадре — антипаттерн.
• Ничего не видно → валидация → пайплайн → барьеры → семафоры → RenderDoc.
Задачи для разминки мозгов
Задача 8.1. «Кто кого ждёт». Перед waitForFences проверь состояние fence без ожидания (getStatus()) и посчитай долю кадров, в которых CPU пришлось ждать GPU. Выводи её рядом с FPS. Что она говорит о том, во что упирается программа? Как на неё влияет FIFO?
Задача 8.2. «Для надёжности». Джун добавил graphicsQueue.waitIdle() в конец drawFrame, чтобы «точно ничего не сломалось». Нарисуй временную диаграмму CPU/GPU для CPU = 4 мс и GPU = 10 мс и посчитай FPS до и после «улучшения». Какие объекты синхронизации стали лишними и почему это всё равно плохая идея?
Решения — в приложении A.
Глава 9. Пересоздание swapchain: окно, которое можно трогать
Треугольник есть, но пользователь тянет за угол окна — и всё падает. Swapchain создавался под конкретный размер, и когда окно меняется, система показа считает его устаревшим. В этой главе научимся пересоздавать его аккуратно: без падений, без утечек и без вечно зависших fence.
9.1. Как Vulkan сообщает о проблеме

Последние две строки нужны потому, что не все драйверы и оконные системы надёжно сообщают out-of-date. И создать swapchain размером 0×0 нельзя в принципе.
9.2. Код: пересоздание
Сначала одно маленькое изменение в createSwapchain из главы 4:

Теперь само пересоздание:


И обновлённые шаги 2 и 6 из drawFrame:

В заголовок добавляются методы installResizeCallback(), recreateSwapchain() и поле bool framebufferResized = false;. installResizeCallback() вызывается в конце конструктора.
9.3. Разбор
Изменение в createSwapchain. Поле oldSwapchain подсказывает драйверу, что новый swapchain заменяет старый. Он может переиспользовать ресурсы и допоказать уже поставленные в очередь кадры. Самое тонкое место — строка swapchain = vk::raii::SwapchainKHR(device, info);. В C++ сначала вычисляется правая часть (новый объект), и только потом перемещающее присваивание уничтожает старый хэндл. В момент создания нового старый ещё жив, ровно как требует спецификация. RAII сделал правильный порядок бесплатно.
Шаг 1 — callback. GLFW — C-библиотека и не знает про наш класс. У неё есть версия «рюкзака»: glfwSetWindowUserPointer хранит произвольный указатель внутри окна. Callback — лямбда без захвата, только такая превращается в указатель на функцию. this она достаёт из «рюкзака» через static_cast. Хранить this во внешней библиотеке безопасно, потому что Pepelac нельзя ни копировать, ни перемещать: адрес объекта не меняется за всю его жизнь. Внутри callback только ставим флажок. Callback вызывается изнутри glfwPollEvents, в середине чего угодно, и пересоздавать там swapchain — верный способ себе навредить.
Шаг 2 — свёрнутое окно. У свёрнутого окна фреймбуфер 0×0, а swapchain такого размера создать нельзя. glfwWaitEvents() усыпляет поток до первого события ОС, так что свёрнутая игра не ест батарею. В однопоточной программе это нормально. В части IV поток рендера будет просто пропускать кадры, потому что события окна живут в главном потоке.
Шаг 3 — waitIdle. Редкий случай, когда он уместен: ресайз случается не каждый кадр, а простота дороже пары миллисекунд. После него ни один командный буфер уже не ссылается на старые view.
Шаг 4 — сносим view. View смотрят на картинки, которыми владеет старый swapchain. Их нужно уничтожить до того, как старый swapchain умрёт и заберёт картинки с собой. vector::clear() вызовет деструктор каждого raii::ImageView. При этом иерархия из главы 0 соблюдена и внутри одной функции.
Шаги 5–6. Переиспользуем функции из главы 4 без изменений, кроме oldSwapchain. Пайплайн не зависит от размера окна: viewport и scissor мы сделали динамическими именно ради этого момента. А вот от формата картинки зависит, поэтому проверяем. Формат меняется редко, но перетаскивание окна на монитор с другим цветовым профилем — вполне реальный сценарий.
Шаг 7 — семафоры. Новый swapchain вправе дать другое число картинок, а renderFinished индексируется imageIndex. Мы только добавляем недостающие семафоры и не уничтожаем старые. waitIdle ждёт очереди GPU, но не систему показа. Семафор последнего present формально может быть ещё в работе, и уничтожать его опасно. Точное решение даёт расширение VK_KHR_swapchain_maintenance1 с fence для present. В Roadmap 2026 есть целый пункт про улучшения swapchain, так что на новом железе оно будет всё чаще.
Обновлённый шаг 2 drawFrame. acquireNextImage бросает vk::OutOfDateKHRError — наследника vk::SystemError. Ловим именно его, а не все исключения подряд: потерю устройства молча проглотить нельзя. И снова про порядок из главы 0: fence сбрасывается после успешного acquire. Благодаря этому ранний return безопасен: fence остался сигнальным, и следующий вызов drawFrame пройдёт шаг 1 без ожидания. Именно здесь порядок шагов из главы 0 оправдывает себя.
Обновлённый шаг 6. Suboptimal — не ошибка, кадр уже показан. Пересоздаём после present, чтобы не терять кадр и не оставлять полученную через acquire картинку непоказанной. Флаг framebufferResized обрабатывается в том же месте: сначала показать, потом перестраиваться.
ЗАПОМНИ. Что зависит от swapchain, то и пересоздаём: image views всегда, семафоры при смене числа картинок, пайплайн при смене формата. В главе 14 в этот список добавится буфер глубины: он должен совпадать по размеру с окном.
ГРАБЛИ. Положиться только на eErrorOutOfDateKHR нельзя: на части драйверов он не приходит при ресайзе, и картинка просто растягивается. Положиться только на callback тоже нельзя: out-of-date может прийти и без ресайза, например при переходе в полноэкранный режим. Нужны оба механизма.
ПОТОКИ. Пересоздание swapchain — точка глобальной синхронизации. Во-первых, device.waitIdle() требует, чтобы ни один другой поток не отправлял работу в любую очередь в этот момент. Во-вторых, ни один поток не должен записывать команды со старыми view. В части IV это решается так: пересоздание делается на границе кадров, когда все рабочие потоки завершили запись.
Шпаргалка главы 9
• Сигналы: OutOfDateKHRError (исключение), eSuboptimalKHR, callback размера, размер 0×0.
• Порядок: дождаться разворачивания → waitIdle → снести views → новый swapchain с oldSwapchain → views → (пайплайн, семафоры при необходимости).
• swapchain = vk::raii::SwapchainKHR(...) — новый рождается до смерти старого.
• Suboptimal — пересоздать после present.
• Callback окна только ставит флажок.
• Fence сбрасываем после acquire, иначе ранний return завешивает программу.
Задачи для разминки мозгов
Задача 9.1. «На весь экран». Добавь переключение полноэкранного режима по F11 через glfwSetWindowMonitor. Что именно запустит пересоздание swapchain? Почему нельзя вызвать recreateSwapchain() прямо из callback клавиатуры? Как запомнить и восстановить положение и размер окна?
Задача 9.2. «Бережливый пересоздатель». Составь таблицу: какие объекты «Пепелаца» (с учётом будущего буфера глубины из главы 14) зависят от размера swapchain, какие — от формата, какие — от числа картинок. Перепиши recreateSwapchain так, чтобы она сравнивала старые и новые параметры и пересоздавала ровно то, что нужно.
Решения — в приложении A.
Часть II. Ресурсы и данные
Шесть глав о том, как данные попадают в видеопамять и как шейдеры их находят. В конце — главная глава книги, про барьеры.
Глава 10. Память GPU: куча, но не та, о которой вы думаете
В C++ ты пишешь new, и память просто появляется. В Vulkan ты сначала выбираешь, какая память нужна: быстрая для GPU, доступная с CPU или какой-то гибрид. Потом выделяешь её большими кусками, потому что число выделений ограничено. Потом сам нарезаешь их под буферы с учётом выравнивания. Писать такой аллокатор самому — отличный способ потратить месяц. Поэтому разберёмся в механике и возьмём VMA.
10.1. Кучи и типы памяти
У видеокарты есть несколько куч (heaps) — физических пулов памяти, и несколько типов памяти — способов использовать эти кучи. Типичная дискретная карта:

У встроенной графики память общая, и почти всё сразу DEVICE_LOCAL | HOST_VISIBLE. Вывод: захардкодить номер типа нельзя, его ищут по флагам на каждой машине.
Пара слов про флаги. HOST_COHERENT означает, что запись CPU становится видна GPU без ручного vkFlushMappedMemoryRanges. HOST_CACHED делает чтение с CPU быстрым. Без него чтение идёт мимо кэша и медленнее в десятки раз.
10.2. Как выделить буфер вручную (и почему не надо)

Четыре шага, и всё работает. Проблема в шаге 3: лимит maxMemoryAllocationCount на многих драйверах равен 4096. Выделишь память под каждую из десяти тысяч моделей — и упрёшься в лимит. Да и каждое выделение — дорогой системный вызов. Правильно выделять блоки по 64–256 МБ и нарезать их самому, учитывая alignment и совместимость типов. Это ровно то, что делает VMA.
10.3. VMA в RAII-обёртке
VMA — сишная библиотека в одном заголовке от AMD, стандарт де-факто. Подключение: find_package(VulkanMemoryAllocator CONFIG REQUIRED) и GPUOpen::VulkanMemoryAllocator в target_link_libraries. Реализация должна оказаться ровно в одном .cpp:





Разбор.
• Шаг 1. VmaAllocatorCreateInfo — C-структура, но designated initializers работают и для неё. VMA живёт в мире C, поэтому хэндлы Vulkan-Hpp приводим к C-типам через static_cast. Функции Vulkan VMA берёт из загрузчика, с которым мы слинковались через Vulkan::Vulkan в главе 1. Класс перемещаемый, но не копируемый — ровно по шаблону Unique из главы 1.
• Шаг 2. vmaCreateBuffer делает все четыре шага из 10.2 сразу и берёт память из своих больших блоков. VMA_MEMORY_USAGE_AUTO — рекомендуемый способ: вместо того чтобы указывать флаги памяти, ты описываешь намерение. Флагов в аллокации нет — значит, CPU туда не пишет, и VMA выберет видеопамять. Флаг VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT — «CPU будет писать последовательно», VMA выберет память для загрузки. VMA_ALLOCATION_CREATE_MAPPED_BIT — держать память постоянно отображённой, указатель будет в pMappedData.

