Разработка игр на Vulkan API и параллельное изучение C++

Курс учит создавать небольшой игровой движок и простые игры, используя чистый Vulkan API, одновременно прокачивая практический C++. Вы последовательно пройдёте путь от базовой настройки рендера до полноценного игрового цикла, ресурсов, материалов, света, ввода и оптимизации.

1. Настройка окружения и основы современного C++ для игр

Настройка окружения и основы современного C++ для игр

Зачем эта статья и что будет дальше

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

Эта первая статья решает две задачи:

  • Настроить рабочее окружение, чтобы вы могли собирать и запускать Vulkan-приложения.
  • Заложить фундамент современного C++ (C++17/20), который прямо пригодится при работе с ресурсами GPU, памятью и временем жизни объектов.
  • Далее в курсе мы постепенно перейдём к созданию окна, инициализации Vulkan (instance, device, swapchain), загрузке шейдеров, рендер-проходам, синхронизации и структуре мини-движка.

    Что именно мы собираем

    Минимальная цель после этой статьи: проект, который

  • собирается через CMake,
  • создаёт окно (через GLFW),
  • создаёт Vulkan instance,
  • включает слои валидации в debug-сборке,
  • умеет удобно проверять VkResult.
  • > Vulkan — низкоуровневый графический API. Он даёт контроль и производительность, но требует явного управления ресурсами и синхронизацией. Поэтому в этом курсе мы с самого начала будем опираться на практики современного C++.

    Выбор платформы и компилятора

    Vulkan доступен на Windows и Linux. В курсе примеры будут кроссплатформенными на уровне CMake.

    Рекомендуемые варианты

  • Windows
  • - Visual Studio 2022 (MSVC) или clang-cl - установка и отладка максимально простые
  • Linux
  • - GCC или Clang - удобная работа с пакетными менеджерами

    Полезные ссылки:

  • Visual Studio
  • GCC
  • Clang
  • Стандарт C++

    Рекомендуемый минимум: C++20. Если у вас ограничения, допустим C++17, но часть удобных типов (например, std::span) придётся заменять.

    Инструменты, которые нужны почти всегда

    Git

    Git нужен, чтобы

  • брать шаблон проекта,
  • подключать зависимости,
  • фиксировать изменения и откатываться.
  • Ссылка:

  • Git
  • CMake

    CMake — генератор сборочных файлов. Он позволит собирать один и тот же проект в Visual Studio, Ninja, Make и так далее.

    Ссылка:

  • CMake
  • Менеджер зависимостей (опционально, но полезно)

    Для библиотек вроде GLFW и math-библиотек удобно использовать менеджер зависимостей.

    Самый простой старт на Windows и Linux:

  • vcpkg
  • Если вы пока не хотите менеджер зависимостей, можно подключить GLFW через CMake FetchContent (покажу ниже).

    Установка Vulkan SDK

    Вам нужен Vulkan SDK от LunarG. Он обычно включает:

  • заголовки Vulkan (vulkan.h),
  • Vulkan Loader (часть, которая загружает драйвер),
  • слои валидации (validation layers),
  • утилиты и примеры,
  • компиляторы/инструменты для шейдеров (например, glslangValidator).
  • Установка:

  • скачайте и установите SDK с сайта
  • убедитесь, что переменная окружения VULKAN_SDK выставлена (обычно ставится установщиком)
  • Ссылка:

  • Vulkan SDK (LunarG)
  • Справочные материалы Vulkan:

  • Vulkan Registry (Khronos)
  • Окно и ввод: GLFW

    Vulkan не создаёт окно сам. Для кроссплатформенного окна и ввода мы используем GLFW.

    Ссылка:

  • GLFW
  • Важно: мы будем использовать GLFW только для окна и поверхностей (surface). Весь рендер — на Vulkan.

    Отладка графики: RenderDoc

    RenderDoc позволяет захватывать кадр и смотреть:

  • какие команды ушли на GPU,
  • какие ресурсы использовались,
  • какие шейдеры выполнялись,
  • где именно всё «сломалось».
  • Ссылка:

  • RenderDoc
  • Как выглядит рабочий цикл разработки

    !Схема того, как исходники превращаются в приложение и где подключаются инструменты отладки

    Структура проекта

    Рекомендуемая минимальная структура:

  • CMakeLists.txt
  • src/
  • - main.cpp - vk_check.hpp
  • assets/
  • - shaders/
  • external/ (если не используете менеджер зависимостей)
  • Почему так удобно:

  • код и ресурсы разделены
  • шейдеры версионируются вместе с проектом
  • зависимости можно изолировать
  • Минимальный CMake-проект с Vulkan и GLFW

    Ниже пример CMake, который:

  • включает C++20
  • находит Vulkan через find_package
  • подтягивает GLFW через FetchContent
  • CMakeLists.txt:

    Пояснения:

  • find_package(Vulkan REQUIRED) просит CMake найти Vulkan, установленный через SDK/систему.
  • FetchContent скачает GLFW при конфигурации проекта.
  • APP_ENABLE_VALIDATION — наш флаг, чтобы включать слои валидации только в debug.
  • Минимальный стартовый код

    Проверка VkResult

    Vulkan-функции часто возвращают VkResult. Это код результата: успех или конкретная ошибка.

    Сделаем маленький помощник:

    src/vk_check.hpp:

    Почему это полезно:

  • вы не забываете проверять ошибки
  • место падения становится очевидным
  • Создание окна и Vulkan instance

    src/main.cpp:

    Что здесь важно понимать:

  • GLFW_INCLUDE_VULKAN заставляет GLFW подключить Vulkan-заголовки корректно.
  • glfwGetRequiredInstanceExtensions возвращает список расширений Vulkan, необходимых для работы с окнами на вашей платформе.
  • VkInstance — самый первый объект Vulkan: контекст API.
  • Слои валидации включаем только в debug, чтобы в release не терять производительность.
  • Слои валидации: зачем они нужны

    Слои валидации (validation layers) — это прослойка, которая проверяет правильность вызовов Vulkan и сообщает о проблемах:

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

  • VK_LAYER_KHRONOS_validation
  • Исходники и документация проекта:

  • Khronos Vulkan Validation Layers
  • Важно: включить слой — это только половина дела. Позже мы добавим debug messenger, чтобы сообщения приходили в консоль в удобном виде.

    Основы современного C++ для Vulkan и игр

    Ниже — только те основы, которые сразу помогают писать надёжный код вокруг Vulkan.

    Время жизни объектов и RAII

    В Vulkan вы постоянно создаёте и уничтожаете объекты: instance, device, image, buffer, semaphore и так далее.

    RAII — подход, где ресурс захватывается в конструкторе объекта C++, а освобождается в деструкторе. Тогда даже при раннем выходе из функции или исключении ресурсы корректно освободятся.

    Мини-идея:

  • объект живёт → ресурс существует
  • объект разрушен → ресурс освобождён
  • Пока мы вручную вызвали vkDestroyInstance. В следующих статьях начнём оборачивать Vulkan-объекты в маленькие RAII-обёртки.

    Владение памятью: std::unique_ptr и std::shared_ptr

  • std::unique_ptr<T>единоличное владение. Самый частый выбор по умолчанию.
  • std::shared_ptr<T>разделяемое владение с подсчётом ссылок. Полезно реже, потому что дороже и может скрывать жизненный цикл.
  • Для игр и Vulkan-кода обычно выгодно:

  • держать владение явным
  • минимизировать shared_ptr в горячих местах
  • Перемещение вместо копирования (move semantics)

    Большие контейнеры (std::vector, std::string) копировать дорого. C++ умеет перемещать — передавать владение внутренним буфером без копирования.

    Это критично для:

  • массивов вершин и индексов
  • пакетов ресурсов
  • временных буферов при загрузке
  • Практическое правило:

  • возвращать большие объекты из функций в современном C++ обычно нормально: компилятор применит оптимизации и/или перемещение.
  • Контейнеры и представления данных

  • std::vector<T> — основной динамический массив.
  • std::span<T>не владеющее представление массива: указывает на память, но не управляет ей.
  • std::string_viewне владеющее представление строки.
  • Почему это важно для игр:

  • вы часто хотите передать данные в функцию без копирования и без изменения владельца
  • span и string_view помогают делать API удобным и быстрым
  • enum class вместо обычных enum

    enum class даёт более строгую типизацию. Это уменьшает количество ошибок, когда разные перечисления случайно смешиваются.

    Для движка это полезно, например, для:

  • типов ресурсов
  • состояний рендера
  • режимов ввода
  • constexpr для констант и таблиц

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

  • констант размеров
  • таблиц предрасчёта (не всегда нужно, но приятно)
  • Обработка ошибок в Vulkan-стиле

    Vulkan редко использует исключения: большинство функций возвращает VkResult.

    В C++-коде проекта возможны два подхода:

  • возвращать ошибки наверх и обрабатывать их
  • в не-движковых местах (например, прототип/учебный проект) бросать исключение при фатальной ошибке
  • Мы в начале курса используем vkCheck(..., "...") с исключениями для простоты. Когда архитектура станет серьёзнее, обсудим, где исключения уместны, а где лучше явные коды ошибок.

    Проверка окружения: как понять, что всё установлено

    Признаки, что вы готовы к следующим статьям:

  • проект конфигурируется CMake без ошибок
  • приложение запускается и не падает на vkCreateInstance
  • в debug-сборке слой VK_LAYER_KHRONOS_validation находится (если нет — переустановите SDK или проверьте переменные окружения)
  • Если vkCreateInstance возвращает ошибку:

  • проверьте установку GPU-драйверов
  • убедитесь, что Vulkan SDK установлен корректно
  • на Linux проверьте системные пакеты Vulkan (в зависимости от дистрибутива)
  • Итоги

    Теперь у вас есть:

  • базовое окружение для Vulkan-разработки (SDK, CMake, компилятор)
  • минимальный каркас проекта с окном и VkInstance
  • понимание, почему современный C++ (RAII, умные указатели, перемещение) критически важен для Vulkan
  • В следующей статье мы расширим каркас: добавим debug messenger, аккуратную инициализацию, выбор физического устройства (GPU) и подготовимся к созданию swapchain.

    2. Архитектура приложения: игровой цикл, события, ECS и модули

    Архитектура приложения: игровой цикл, события, ECS и модули

    Зачем эта статья

    В прошлой статье вы настроили окружение, собрали минимальный проект с окном (GLFW) и создали VkInstance. Дальше в курсе мы будем добавлять реальные подсистемы Vulkan (debug messenger, выбор устройства, swapchain, командные буферы, синхронизация). Чтобы код не превратился в один огромный main.cpp, нам нужна архитектура приложения.

    В этой статье мы разберём:

  • что такое игровой цикл и почему он почти всегда делится на обновление и рендер
  • как устроить события (ввод, окна, внутренние события движка) без «спагетти»-зависимостей
  • что такое ECS (Entity Component System) и когда он полезен
  • как разложить проект на модули (по CMake-target’ам/папкам/namespace’ам), чтобы Vulkan-код и игровой код развивались отдельно
  • > Цель статьи: получить понятный каркас приложения, в который в следующих уроках мы “вставим” Vulkan-рендерер.

    Картина целиком: какие части есть у типичного приложения

    Минимально полезное Vulkan-игровое приложение обычно можно разделить на слои:

  • Platform: окно, ввод, время, обработка событий ОС (GLFW)
  • Renderer: Vulkan-инициализация и отрисовка (instance/device/swapchain/кадры)
  • Game: правила игры, мир, логика, состояния (меню/игра/пауза)
  • Assets/Resources: загрузка моделей, текстур, шейдеров, кэширование
  • Core/Utils: базовые типы, логирование, ошибки, маленькие RAII-обёртки
  • !Схема модулей и направлений зависимостей

    Главное правило зависимостей

    Чтобы проект не «запутался», удобно держать направление зависимостей таким:

  • Game может вызывать Renderer, но Renderer не должен знать про конкретную игру
  • Renderer может использовать Platform (для поверхности, размеров окна), но Platform не должен знать про Vulkan
  • Иначе вы быстро попадёте в круговые зависимости: “рендереру нужна игра, игре нужен рендерер”.

    Игровой цикл

    Игровой цикл — это повторяющаяся последовательность шагов, пока приложение работает:

  • обработать события (окно/ввод)
  • обновить состояние игры
  • отрисовать кадр
  • Базовый вариант

    Начнём с понятной формы:

    Здесь:

  • platform.pollEvents() получает события ОС (в GLFW это glfwPollEvents())
  • dt — время (в секундах) между кадрами
  • game.update(dt) — логика
  • renderer.render(...) — отрисовка
  • Почему часто отделяют “update” от “render”

    Если привязать логику к частоте кадров, то:

  • на слабом ПК игра будет “замедляться”
  • на сильном ПК физика/логика может стать нестабильной (если она чувствительна к dt)
  • Поэтому часто делают фиксированный шаг обновления (например, 60 обновлений в секунду), а рендер выполняют “как получится”.

    Фиксированный шаг (fixed timestep)

    Идея: логика работает шагами по фиксированному интервалу step (например, 1/60 секунды), а реальное прошедшее время накапливается в “аккумулятор”.

    Что важно:

  • std::chrono::steady_clock — монотонные часы (не “скачут” при смене системного времени)
  • fixedUpdate(step) — логика/физика с одинаковым шагом
  • renderFrame() — рисование кадра (в Vulkan позже появится “кадр в полёте”, семафоры и fence’ы)
  • > На практике ещё добавляют ограничение на максимальный frameTime, чтобы после долгой паузы (например, окно “зависло”) приложение не пыталось выполнить тысячу апдейтов подряд.

    События: ввод, окно и события движка

    Событие — это сообщение о факте, который произошёл:

  • пользователь нажал клавишу
  • мышь сдвинулась
  • окно изменило размер
  • игра захотела “сменить сцену”
  • ассет загрузился
  • Зачем нужен слой событий

    Если напрямую дергать логику игры из GLFW-колбэков и одновременно дергать рендерер из логики, быстро получится код, где:

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

    Два основных подхода

  • Немедленная обработка: событие сразу вызывает обработчик
  • Очередь событий: события складываются в очередь, а затем обрабатываются в контролируемом месте цикла
  • Для учебного движка на Vulkan чаще удобнее очередь, потому что:

  • легче контролировать порядок
  • проще избегать “вызовов в неожиданное время”
  • удобнее связывать с кадрами (например, “resize” обрабатываем перед пересозданием swapchain)
  • Минимальный тип событий

    Идея consumeAll():

  • мы забираем события пачкой
  • очередь внутри становится пустой
  • обработка происходит в одном месте игрового цикла
  • Где жить EventQueue

    Обычно:

  • Platform имеет доступ к EventQueue и складывает туда события окна/ввода
  • Game (или GameState) читает очередь в начале update/fixedUpdate
  • Схема шага цикла может быть такой:

  • platform.pollEvents()
  • auto events = eventQueue.consumeAll()
  • game.handleEvents(events)
  • game.fixedUpdate(step)
  • renderer.renderFrame()
  • GLFW и события

    GLFW умеет работать и через polling (glfwPollEvents()), и через колбэки. Для “очереди событий” удобно:

  • использовать glfwPollEvents() каждый кадр
  • настроить колбэки GLFW так, чтобы они только “складывали” события в очередь
  • Документация GLFW:

  • GLFW Documentation
  • ECS: Entity Component System

    ECS — это подход к организации игрового мира.

    Три термина:

  • Entity (сущность): просто идентификатор (например, число). “Игрок”, “пуля”, “лампа” — это entity.
  • Component (компонент): данные, прикреплённые к entity. Например, Transform, Velocity, Camera.
  • System (система): код, который обрабатывает entity с определённым набором компонентов. Например, система движения читает Transform + Velocity.
  • Почему ECS часто удобен

  • данные отделены от поведения: компоненты — это структура данных, системы — алгоритмы
  • проще комбинировать поведение: сущность получает поведение через набор компонентов
  • проще масштабировать проект: добавление новой системы редко требует переписывать старые
  • Полезный ориентир по паттернам архитектуры игр:

  • Game Programming Patterns
  • Когда ECS может быть лишним

    Если ваша цель — небольшая игра/прототип, ECS может оказаться сложнее, чем надо. Но для курса по Vulkan он полезен как “параллельная” структура: рендерер обычно хочет видеть много однотипных объектов, а ECS естественно хранит однотипные данные.

    Минимальный “учебный ECS” без внешних библиотек

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

    Что здесь “по-ECS”:

  • Entity — только ID
  • Transform/Velocity — “пакеты данных” без логики
  • MovementSystem — отдельный код, который обрабатывает сущности по наличию компонентов
  • ECS и рендерер

    Позже, когда появится рендерер, вы обычно делаете отдельный компонент, например:

  • MeshRenderer (какая сетка, какой материал)
  • А система рендера будет собирать “видимые” сущности и готовить данные для GPU.

    Важно: рендерер Vulkan не должен зависеть от ECS напрямую. Лучше, чтобы игра передавала рендереру уже подготовленный список “что рисовать” (например, массив draw-команд/объектов), а ECS оставался частью игрового слоя.

    Модули: как разложить код по частям

    Под модулем в рамках курса будем понимать отдельную библиотеку/таргет CMake (или хотя бы чёткую папку с публичными заголовками), которая имеет ясные зависимости.

    Рекомендуемая структура каталогов

  • src/core/
  • src/platform/
  • src/renderer/
  • src/game/
  • src/assets/
  • src/main.cpp
  • Пример разбиения на CMake-target’ы

    Идея зависимостей:

  • platform зависит от glfw и core
  • renderer зависит от Vulkan, platform и core
  • game зависит от core, но не обязан знать про Vulkan
  • Почему это важно именно для Vulkan

    Vulkan требует много кода вокруг:

  • создание/уничтожение объектов
  • обработка ошибок
  • пересоздание swapchain при resize
  • синхронизация кадров
  • Если всё это смешать с игровой логикой, вы получите ситуацию, где менять игру больно, а чинить рендерер ещё больнее. Модульность защищает от этого.

    Связываем всё в “скелет” приложения

    Ниже пример того, как может выглядеть верхний уровень приложения (без реального Vulkan-рендера, но с правильными границами):

    Практика C++: несколько правил, которые окупятся

  • RAII везде, где есть ресурс: окно, Vulkan instance/device, swapchain, буферы, изображения
  • Запрещайте копирование для владельцев ресурсов: делайте Class(const Class&) = delete; и разрешайте перемещение, если это уместно
  • Минимизируйте глобальные переменные: лучше передавать зависимости через конструкторы
  • Отделяйте интерфейс от реализации: заголовки модулей должны быть небольшими и стабильными
  • Что дальше по курсу

    В следующих статьях мы начнём развивать модуль renderer:

  • добавим debug messenger, чтобы видеть сообщения валидации
  • выберем физическое устройство и создадим логическое устройство
  • создадим surface и swapchain
  • введём понятие “кадр в полёте” и базовую синхронизацию
  • Архитектура из этой статьи поможет встроить всё это без переписывания игры и платформенного слоя.

    3. Vulkan основы: instance, device, swapchain и синхронизация

    Vulkan основы: instance, device, swapchain и синхронизация

    Зачем эта статья

    В прошлых статьях вы:

  • настроили окружение, CMake-проект, окно GLFW и создали VkInstance
  • обсудили архитектуру приложения: игровой цикл, очередь событий и разбиение на модули
  • Теперь мы соберём минимально жизнеспособный Vulkan-рендерер, который уже умеет:

  • создавать логическое устройство и очереди
  • создавать surface и swapchain
  • делать правильный кадровый цикл acquire -> submit -> present
  • использовать базовую синхронизацию (semaphore и fence)
  • > Цель статьи: после неё у вас появится модуль renderer, который выводит «пустой кадр» в окно, но делает это правильно с точки зрения Vulkan-объектов и синхронизации.

    !Диаграмма последовательности работы кадра: acquire, submit, present и где используются семафоры и fence

    Какие объекты Vulkan мы вводим и зачем

    Ниже краткая карта объектов, которые вы будете видеть постоянно:

    | Объект | Что это | Зачем нужен в игре | |---|---|---| | VkInstance | подключение к Vulkan loader | стартовая точка Vulkan, расширения, слои | | VkDebugUtilsMessengerEXT | доставка сообщений валидации | быстро находить ошибки | | VkPhysicalDevice | выбранный GPU | выбор возможностей и форматов | | VkDevice | логическое устройство | создание ресурсов и работа с очередями | | VkQueue | очередь команд GPU | отправка команд на выполнение | | VkSurfaceKHR | связь Vulkan и окна | вывод на экран | | VkSwapchainKHR | набор изображений под вывод | то, куда мы рендерим перед показом | | VkSemaphore | синхронизация GPU-GPU | порядок между acquire, render, present | | VkFence | синхронизация CPU-GPU | не перезаписывать ресурсы кадра |

    Справочник по API:

  • Vulkan Specification
  • Vulkan Tutorial
  • Vulkan Guide
  • Модуль renderer и границы ответственности

    Из архитектуры прошлой статьи сохраняем направление зависимостей:

  • platform создаёт окно и собирает события
  • renderer зависит от platform, потому что ему нужна поверхность и размер окна
  • game не зависит от Vulkan
  • Практически это означает: Renderer получает от платформы:

  • GLFWwindow* для создания VkSurfaceKHR
  • размеры кадра
  • событие resize, чтобы пересоздать swapchain
  • Instance и debug messenger

    Почему одного VkInstance мало

    Создать VkInstance достаточно, чтобы “войти в Vulkan”, но почти бесполезно для разработки без сообщений валидации.

    Debug messenger позволяет видеть предупреждения и ошибки в понятном виде.

    Минимальная реализация debug messenger

    Добавим в renderer (или core) функции для создания и уничтожения VkDebugUtilsMessengerEXT, потому что это расширение и грузится через vkGetInstanceProcAddr.

    Важно:

  • debug messenger создаётся после instance
  • уничтожается до vkDestroyInstance
  • Чтобы расширение VK_EXT_debug_utils было доступно, его нужно добавить в список расширений instance (в дополнение к тем, что просит GLFW).

    Документация расширения:

  • VK_EXT_debug_utils
  • Physical device и logical device

    Physical device

    VkPhysicalDevice это конкретный GPU (или программная реализация). Его нужно выбрать.

    Выбор обычно учитывает:

  • поддержку нужных расширений (для окна почти всегда нужно VK_KHR_swapchain)
  • наличие очередей нужных типов
  • поддержку нужных форматов поверхности
  • Очереди и семейства очередей

    В Vulkan команды исполняются через очереди (VkQueue). Очереди сгруппированы в семейства (queue families) и описываются VkQueueFamilyProperties.

    Для минимального вывода в окно чаще всего нужны:

  • graphics queue: умеет выполнять графические команды
  • present queue: умеет делать vkQueuePresentKHR
  • Иногда это одно и то же семейство, иногда разные.

    Минимальный выбор GPU

    В этом коде важно:

  • vkGetPhysicalDeviceSurfaceSupportKHR проверяет именно для вашей поверхности окна, умеет ли очередь презентовать
  • Logical device

    VkDevice создаётся из выбранного GPU и описывает:

  • какие очереди вы хотите получить
  • какие расширения устройства включаете
  • какие фичи устройства включаете
  • Минимально для swapchain нужно расширение устройства:

  • VK_KHR_swapchain
  • C++-заметка:

  • классы, которые владеют VkDevice, VkSwapchainKHR, семафорами и fence, обычно делают некопируемыми: Class(const Class&) = delete; и перемещаемыми при необходимости
  • Surface и swapchain

    Surface

    Surface это объект Vulkan, который представляет “куда показывать” в терминах ОС.

    GLFW создаёт surface за вас:

    Ссылка:

  • GLFW: Vulkan support
  • Что такое swapchain

    Swapchain это набор изображений, которые связаны с surface.

    Принцип:

  • вы запрашиваете у swapchain “свободное изображение” через vkAcquireNextImageKHR
  • рендерите в него
  • отдаёте его на показ через vkQueuePresentKHR
  • Из чего состоит создание swapchain

    Вам нужно выбрать:

  • VkSurfaceFormatKHR: формат пикселей и цветовое пространство
  • VkPresentModeKHR: режим показа (например, vsync или без)
  • VkExtent2D: размер изображений (обычно размер окна)
  • количество изображений swapchain
  • Для выбора этих параметров у surface запрашивают “возможности”:

  • vkGetPhysicalDeviceSurfaceCapabilitiesKHR
  • vkGetPhysicalDeviceSurfaceFormatsKHR
  • vkGetPhysicalDeviceSurfacePresentModesKHR
  • Практичные значения “по умолчанию”

    Часто выбирают:

  • формат VK_FORMAT_B8G8R8A8_UNORM если доступен
  • present mode VK_PRESENT_MODE_MAILBOX_KHR если доступен, иначе VK_PRESENT_MODE_FIFO_KHR
  • Важно:

  • VK_PRESENT_MODE_FIFO_KHR обычно доступен всегда и соответствует vsync
  • Минимальный код создания swapchain

    Ниже упрощённый вариант. Он делает главное, но не покрывает все тонкости.

    Что здесь важно понимать:

  • swapchain хранит изображения, но вы обычно работаете через image view
  • imageUsage определяет, как вы будете использовать эти изображения (здесь как color attachment)
  • Синхронизация: семафоры, fence и кадры в полёте

    Почему синхронизация нужна сразу

    Даже если вы пока “ничего не рисуете”, вывод в окно уже требует упорядочивания:

  • нельзя рендерить в изображение, пока оно не выдано acquire
  • нельзя презентовать, пока GPU не закончил рендер
  • нельзя на CPU переиспользовать ресурсы кадра, пока GPU ещё выполняет команды прошлого кадра
  • Семафор

    VkSemaphore синхронизирует работу на GPU.

    Типичный минимальный набор на кадр:

  • imageAvailable: сигналится, когда vkAcquireNextImageKHR выдал изображение
  • renderFinished: сигналится, когда GPU закончил выполнение команд рендера
  • Fence

    VkFence синхронизирует CPU и GPU.

    Типичный сценарий:

  • перед записью команд кадра CPU ждёт fence прошлого использования этого набора ресурсов
  • после отправки команд в очередь мы “переводим fence в несигнальное состояние” и сигналим его по завершению GPU
  • Кадры в полёте

    Чтобы не ждать GPU каждый кадр, делают несколько наборов синхронизации и командных буферов.

    Минимально удобно начать с:

  • MAX_FRAMES_IN_FLIGHT = 2
  • Тогда CPU может готовить следующий кадр, пока GPU исполняет предыдущий.

    Минимальный кадровый цикл acquire -> submit -> present

    Ниже каркас, который показывает порядок вызовов и синхронизацию. Командные буферы пока условные, потому что render pass, pipeline и реальные draw-команды будут следующими темами.

    Ключевые моменты порядка:

  • vkWaitForFences защищает CPU от перезаписи “ресурсов кадра”, пока GPU ещё их использует
  • vkAcquireNextImageKHR сигналит imageAvailable
  • vkQueueSubmit ждёт imageAvailable и сигналит renderFinished
  • vkQueuePresentKHR ждёт renderFinished
  • Документация по синхронизации:

  • Synchronization in Vulkan
  • Resize окна и пересоздание swapchain

    Swapchain зависит от размеров surface. При изменении размера окна обычно происходит одно из двух:

  • vkAcquireNextImageKHR возвращает VK_ERROR_OUT_OF_DATE_KHR
  • vkQueuePresentKHR возвращает VK_SUBOPTIMAL_KHR или VK_ERROR_OUT_OF_DATE_KHR
  • Правильная реакция:

  • дождаться, пока устройство не использует swapchain ресурсы
  • уничтожить image views и swapchain
  • создать swapchain заново под новый размер
  • Минимально для учебного проекта можно делать:

  • vkDeviceWaitIdle(device) перед пересозданием
  • Позже мы обсудим более аккуратные схемы, но для старта это работает.

    Как это связывается с игровым циклом

    Ваш верхнеуровневый цикл из прошлой статьи остаётся тем же, но renderer.renderFrame() теперь делает реальный Vulkan-кadр:

    При этом renderer сам управляет:

  • кадрами в полёте
  • семафорами и fence
  • пересозданием swapchain при resize
  • game не обязан знать, что внутри Vulkan.

    Частые ошибки новичков и как их ловить

  • Не включены слои валидации в debug: вы не увидите подсказок, почему всё падает.
  • Забыли VK_KHR_swapchain при создании VkDevice: swapchain не создастся.
  • Перепутали очереди graphics и present: vkQueuePresentKHR может работать только на очереди с поддержкой present.
  • Нет ожидания fence: CPU начинает перезаписывать командные буферы и ресурсы, пока GPU ещё их использует.
  • Инструменты:

  • RenderDoc
  • Итоги

    Теперь у вас есть базовый фундамент Vulkan-рендерера:

  • instance с debug messenger
  • выбор GPU и создание VkDevice с очередями
  • surface и swapchain (включая image views)
  • кадровый цикл acquire -> submit -> present
  • базовая синхронизация: semaphores и fences, плюс идея кадров в полёте
  • В следующей части курса мы начнём “делать настоящий рендер”: командный пул, командные буферы, render pass, framebuffers и первый вывод (например, очистка цветом), а затем перейдём к пайплайну и треугольнику.

    4. Рендер-пайплайн: шейдеры, descriptor sets, pipeline layout

    Рендер-пайплайн: шейдеры, descriptor sets, pipeline layout

    Зачем эта статья

    В прошлой статье вы собрали базовый Vulkan-каркас: устройство, swapchain и кадровый цикл acquire -> submit -> present с семафорами и fence. Но пока мы не рисуем — у нас нет того, что Vulkan называет графическим пайплайном.

    В этой статье мы сделаем следующий ключевой шаг: разберём, как Vulkan связывает вместе

  • шейдеры (код, который выполняется на GPU),
  • ресурсы (буферы, текстуры),
  • и способ, которым шейдеры получают доступ к ресурсам.
  • Это именно то место, где Vulkan отличается от более высокоуровневых API: доступ к данным задаётся явно через descriptor sets и pipeline layout.

    > Цель статьи: чтобы вы могли подготовить шейдеры и описать все ресурсы, которые они используют, через VkDescriptorSetLayout и VkPipelineLayout. Это фундамент для следующей темы: создание render pass и графического pipeline, а затем отрисовка треугольника.

    Термины, которые нам нужны

  • Шейдер: программа для GPU. Для классического рендера важны как минимум vertex shader (вершинный) и fragment shader (фрагментный).
  • SPIR-V: бинарный формат шейдеров, который Vulkan принимает на вход.
  • Descriptor: запись, описывающая ресурс (например, какой буфер или какая текстура доступна шейдеру).
  • Descriptor set: набор таких записей, выделяемый из пула и привязываемый при отрисовке.
  • Descriptor set layout: описание структуры descriptor set: какие ресурсы, в каких слотах и для каких стадий шейдера.
  • Pipeline layout: описание того, какие descriptor set layouts и push constants видит пайплайн.
  • Push constants: маленький блок данных, который можно очень быстро передать в шейдер без буферов.
  • Где эти сущности находятся в общей картине кадра

    !Схема того, где в кадре используются шейдеры, descriptor sets и pipeline layout

    Ключевая идея:

  • шейдеры компилируются в SPIR-V и загружаются в VkShaderModule
  • ресурсы описываются через VkDescriptorSetLayout
  • VkPipelineLayout фиксирует, какие наборы ресурсов и push constants доступны
  • во время записи команд мы привязываем конкретные VkDescriptorSet и вызываем vkCmdDraw (это будет в следующей статье, когда появится VkPipeline)
  • Шейдеры в Vulkan

    Почему Vulkan требует SPIR-V

    Vulkan не принимает исходники GLSL или HLSL напрямую. Он принимает SPIR-V: это промежуточное представление, которое удобно валидировать, кэшировать и загружать.

    Практический вывод:

  • вы храните исходники шейдеров (например, GLSL) в assets/shaders/
  • при сборке или перед запуском компилируете их в .spv
  • приложение загружает .spv и создаёт VkShaderModule
  • Полезные ссылки:

  • Vulkan Specification
  • glslang (GLSL to SPIR-V)
  • SPIRV-Tools
  • Минимальные шейдеры (GLSL)

    assets/shaders/triangle.vert:

    assets/shaders/triangle.frag:

    Пояснения:

  • location задаёт соответствие между входами/выходами стадий и данными вершинного буфера
  • пока нет uniform buffer и текстур, поэтому descriptor sets нам ещё не нужны для этого конкретного треугольника, но мы всё равно закладываем правильную архитектуру
  • Компиляция GLSL в SPIR-V

    Самый простой стартовый вариант для учебного проекта: вызывать glslangValidator, который идёт вместе с Vulkan SDK.

    Пример CMake-логики (упрощённо, чтобы было понятно направление):

    Что важно как концепция:

  • .spv должны пересобираться при изменении исходников
  • путь к .spv должен быть известен приложению (например, вы копируете их рядом с бинарником, или читаете из CMAKE_BINARY_DIR)
  • VkShaderModule: загрузка SPIR-V в приложение

    VkShaderModule создаётся из массива байт SPIR-V.

    Минимальный код загрузки:

    C++-заметка про дизайн:

  • VkShaderModule почти всегда удобно оборачивать в RAII-класс, который вызывает vkDestroyShaderModule в деструкторе
  • такие классы обычно делают некопируемыми и при желании перемещаемыми
  • Descriptor sets: как шейдеры получают доступ к ресурсам

    Проблема, которую решают descriptor sets

    Шейдеру нужны данные:

  • константы камеры (матрицы)
  • параметры материала
  • текстуры
  • буферы со светом
  • В Vulkan нельзя просто сказать вот указатель на текстуру. Вместо этого Vulkan требует заранее описать интерфейс ресурсов и потом привязывать конкретные экземпляры.

    Эта система состоит из трёх уровней:

  • VkDescriptorSetLayout: схема, какие ресурсы будут
  • VkDescriptorPool: из чего мы будем выделять descriptor sets
  • VkDescriptorSet: конкретный набор привязок ресурсов
  • Как это выглядит в шейдере

    Пример: uniform buffer с данными камеры и текстура.

    Пояснения:

  • set = 0 означает номер descriptor set, который будет привязываться на slot 0
  • binding = 0 и binding = 1 означают слоты внутри набора
  • Vulkan обязует вас создать VkDescriptorSetLayout, который точно соответствует этим binding
  • Типы дескрипторов, которые чаще всего встречаются в играх

    | Тип Vulkan | Что это | Типичный кейс | |---|---|---| | VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER | небольшой буфер констант | камера, параметры материала | | VK_DESCRIPTOR_TYPE_STORAGE_BUFFER | произвольный буфер для чтения/записи | частицы, culling, compute | | VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER | текстура + sampler | базовая текстура материала | | VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE | только изображение | когда sampler отдельный | | VK_DESCRIPTOR_TYPE_SAMPLER | только sampler | общий sampler на много текстур |

    VkDescriptorSetLayout: описание структуры набора

    Пример: набор set=0 содержит

  • binding=0: uniform buffer для вершинного шейдера
  • binding=1: combined image sampler для фрагментного шейдера
  • Что важно:

  • stageFlags ограничивает, в каких стадиях шейдера доступен ресурс
  • descriptorCount обычно равен 1, но может быть больше (например, массив текстур)
  • VkDescriptorPool и выделение VkDescriptorSet

    Почему нужен пул

    VkDescriptorSet нельзя создавать напрямую как многие другие объекты Vulkan. Он выделяется из VkDescriptorPool. Это сделано для того, чтобы управление памятью descriptor sets было явным и эффективным.

    Минимальная идея:

  • вы заранее говорите пулу, сколько каких дескрипторов вам потребуется
  • выделяете наборы (VkDescriptorSet)
  • обновляете их конкретными буферами и текстурами
  • Пример создания пула

    Пул на 1 набор, в котором есть

  • 1 uniform buffer
  • 1 combined image sampler
  • Выделение descriptor set

    Обновление descriptor set: связываем ресурсы с binding

    Когда set выделен, он пустой. Нужно сказать Vulkan, какой именно буфер и какая именно текстура стоят на binding=0 и binding=1.

    Пример обновления:

    Ключевые правила:

  • dstBinding должен совпадать с binding в шейдере и в VkDescriptorSetLayout
  • imageLayout должен соответствовать реальному layout изображения к моменту выборки в шейдере (переходы layout мы разберём позже, вместе с барьерами)
  • Pipeline layout: контракт между пайплайном и ресурсами

    Что такое VkPipelineLayout

    VkPipelineLayout объединяет два вида вещей:

  • список VkDescriptorSetLayout, которые будут доступны пайплайну
  • диапазоны push constants (если используются)
  • Это контракт: графический pipeline будет считаться совместимым только с теми descriptor sets, которые соответствуют layout.

    !Как VkPipelineLayout связывает descriptor set layouts и push constants

    Создание VkPipelineLayout

    На практике часто делают несколько наборов:

  • set=0: данные кадра (камера, время)
  • set=1: данные материала (текстуры)
  • set=2: данные объекта (например, storage buffer с transforms)
  • Но для учебного проекта удобно начать с одного набора, а затем расширять.

    Push constants: быстрые маленькие данные

    Что это и зачем

    Push constants позволяют передавать небольшие данные в шейдер очень быстро, без создания буферов и без descriptor sets.

    Типичный кейс:

  • цвет объекта
  • индекс материала
  • маленькая матрица для одного объекта (если вы рисуете мало)
  • Ограничение: размер push constants ограничен возможностями устройства. Минимально по спецификации гарантируется 128 байт, но часто доступно больше.

    Пример объявления в GLSL

    Добавление push constants в pipeline layout

    Пояснения:

  • stageFlags задаёт, какие стадии видят push constants
  • size должен точно совпадать с тем, что вы отправляете из кода
  • Как это встроить в архитектуру приложения

    На уровне модулей из прошлой статьи удобная ответственность выглядит так:

  • assets или renderer отвечает за компиляцию и загрузку .spv
  • renderer хранит и управляет Vulkan-объектами: VkShaderModule, VkDescriptorSetLayout, VkDescriptorPool, VkPipelineLayout
  • game не знает про Vulkan-типы; максимум он формирует данные (например, параметры камеры), а renderer решает, куда их положить (в UBO или push constants)
  • Практический совет по C++:

  • заведите небольшие RAII-обёртки: ShaderModule, DescriptorSetLayout, DescriptorPool, PipelineLayout
  • запрещайте копирование владельцев (= delete) и включайте перемещение, если нужно
  • Частые ошибки и как их ловить

  • Несовпадение set/binding между шейдером и VkDescriptorSetLayout.
  • Забыли добавить нужный VkDescriptorSetLayout в VkPipelineLayout.
  • Указали неправильный VkDescriptorType (например, UNIFORM_BUFFER вместо STORAGE_BUFFER).
  • Неправильный imageLayout в VkDescriptorImageInfo.
  • Что помогает:

  • включённые validation layers и debug messenger
  • захват кадра в RenderDoc
  • документация по подходам к ресурсам в Vulkan Guide
  • Итоги

    Теперь у вас есть базовый язык, на котором Vulkan описывает рендер:

  • шейдеры компилируются в SPIR-V и загружаются как VkShaderModule
  • ресурсы описываются через VkDescriptorSetLayout и передаются через VkDescriptorSet
  • VkPipelineLayout фиксирует, какие descriptor sets и push constants доступны пайплайну
  • В следующей статье мы соберём это в настоящий рендер: VkRenderPass, VkFramebuffer, командные буферы и графический pipeline, после чего вы увидите первый вывод (очистку и/или треугольник).

    5. Ресурсы и ассеты: буферы, текстуры, загрузка моделей и материалов

    Ресурсы и ассеты: буферы, текстуры, загрузка моделей и материалов

    Зачем эта статья

    В прошлых статьях вы собрали базовый Vulkan-каркас (instance, device, swapchain, синхронизация) и разобрались с тем, как шейдеры получают доступ к данным через descriptor sets и pipeline layout.

    Теперь нам нужно ответить на практический вопрос: откуда берутся данные для шейдеров и как правильно доставить их на GPU.

    В этой статье вы изучите:

  • какие бывают GPU-ресурсы в Vulkan: VkBuffer и VkImage
  • чем отличается память device local и host visible
  • как устроена загрузка ресурсов через staging
  • базовый жизненный цикл текстур и моделей
  • как построить минимальный модуль ассетов в C++: кэш, идентификаторы, владение и время жизни
  • > Цель статьи: получить понятный и расширяемый путь диск → CPU → GPU, чтобы в следующих темах вы могли рисовать не только треугольник, но и меши с текстурами и материалами.

    !Путь данных от файлов до Vulkan-ресурсов

    Термины и базовые понятия

    Ресурс

    Ресурс в контексте рендера это данные, которые использует GPU:

  • геометрия: вершины и индексы
  • текстуры
  • константы кадра: камера, параметры света
  • В Vulkan почти всё упирается в два семейства объектов:

  • VkBuffer для линейных массивов байт
  • VkImage для изображений (2D, 3D, массивы, кубмапы)
  • Память и размещение

    Vulkan разделяет:

  • объект (VkBuffer или VkImage)
  • и выделенную память (VkDeviceMemory), в которую этот объект “привязан”
  • Это ключевая причина, почему Vulkan кажется низкоуровневым: вы явно управляете тем, где лежат данные.

    Практическая опора:

  • память host visible можно отображать в адресное пространство CPU и писать туда
  • память device local обычно быстрее для GPU, но CPU писать туда напрямую не может
  • Staging

    Staging это промежуточная загрузка:

  • CPU пишет данные в временный VkBuffer в host-visible памяти
  • затем GPU копирует эти данные в финальный ресурс в device-local памяти
  • Этот подход почти всегда нужен для производительности.

    Буферы: геометрия, индексы, uniform и storage

    Какие буферы бывают в игре

    Обычно вы встретите такие роли буферов:

  • Vertex buffer хранит массив вершин
  • Index buffer хранит индексы вершин
  • Uniform buffer хранит небольшие константы (камера, параметры кадра)
  • Storage buffer хранит большие структуры и часто используется в compute, culling, инстансинге
  • В Vulkan роль задаётся флагами VkBufferUsageFlags при создании буфера.

    Минимальная структура для буфера в C++

    В учебном проекте удобно держать вместе “хэндл буфера” и “память буфера”.

    Правило дизайна C++ для Vulkan:

  • владелец ресурса должен быть некопируемым
  • перемещение допустимо, если вам удобно перекладывать владение
  • Создание VkBuffer и выделение памяти

    Создание буфера обычно выглядит так:

  • vkCreateBuffer
  • vkGetBufferMemoryRequirements
  • выбор типа памяти по memoryTypeBits и нужным свойствам
  • vkAllocateMemory
  • vkBindBufferMemory
  • Самая “скользкая” часть здесь это выбор типа памяти.

    #### Поиск типа памяти

    Что означают параметры:

  • typeFilter приходит из memoryTypeBits и ограничивает типы памяти, допустимые для конкретного ресурса
  • properties это ваши требования, например VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT
  • Staging upload для vertex/index

    Базовый путь для геометрии:

  • создать staging buffer с VK_BUFFER_USAGE_TRANSFER_SRC_BIT в host-visible памяти
  • vkMapMemory и скопировать байты из CPU
  • создать финальный buffer с VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT в device-local памяти
  • записать vkCmdCopyBuffer в командный буфер
  • отправить командный буфер и дождаться завершения (на старте можно через vkQueueWaitIdle)
  • !Как staging переносит данные в быстрый буфер

    Про Vulkan Memory Allocator

    Ручное выделение VkDeviceMemory полезно понять, но в реальных проектах часто используют аллокатор.

    Самый распространённый вариант:

  • Vulkan Memory Allocator (VMA)
  • VMA упрощает:

  • выделение и привязку памяти
  • создание staging и device-local ресурсов
  • дефрагментацию и статистику
  • В рамках курса вы можете начать с ручного кода, а затем перейти на VMA, когда появится больше типов ресурсов.

    Текстуры: VkImage, layout-переходы, копирование и VkSampler

    Почему текстура это VkImage, а не VkBuffer

    Изображения требуют специальных возможностей:

  • выборка в шейдерах через sampler
  • mip-уровни
  • сжатые форматы
  • оптимальные размещения для кэширования
  • Поэтому Vulkan разделяет линейные данные (VkBuffer) и изображения (VkImage).

    Минимальный жизненный цикл текстуры

    Практически всегда выглядит так:

  • загрузить файл изображения на CPU и получить массив пикселей
  • создать staging buffer и скопировать пиксели в него
  • создать VkImage в device-local памяти
  • сделать переход layout: UNDEFINEDTRANSFER_DST_OPTIMAL
  • выполнить vkCmdCopyBufferToImage
  • сделать переход layout: TRANSFER_DST_OPTIMALSHADER_READ_ONLY_OPTIMAL
  • создать VkImageView
  • создать VkSampler
  • записать в VkDescriptorSet как VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER
  • Загрузка пикселей на CPU

    Для учебного проекта удобно использовать stb_image:

  • stb (stb_image.h)
  • Принцип:

  • декодируете PNG/JPG в RGBA8
  • получаете width, height, bytesPerPixel
  • копируете байты в staging
  • Важно: соглашение о цветовом пространстве.

  • если это цветовая текстура (albedo), чаще хотят sRGB формат, например VK_FORMAT_R8G8B8A8_SRGB
  • если это данные (normal/roughness/metallic), чаще нужен UNORM формат, например VK_FORMAT_R8G8B8A8_UNORM
  • Layout-переходы без “магии”

    VkImageLayout это состояние, которое говорит драйверу, как вы используете изображение.

    Для текстуры обычно важны:

  • VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL чтобы копировать в неё
  • VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL чтобы читать в шейдере
  • Layout-переход делается через pipeline barrier. На старте достаточно понять идею:

  • до копирования изображение должно быть “готово к приёму данных”
  • до использования в шейдере изображение должно быть “готово к чтению”
  • Подробная синхронизация и барьеры требуют отдельного углубления, хороший ориентир:

  • Vulkan Guide: Synchronization
  • VkImageView и VkSampler

  • VkImageView описывает, как интерпретировать VkImage (формат, mip-уровни, слои)
  • VkSampler описывает, как семплировать (фильтрация, адресация, анизотропия)
  • Типичные стартовые настройки sampler:

  • фильтрация LINEAR
  • адресация REPEAT
  • анизотропия включается позже, когда вы добавите проверку поддержки
  • Спецификация sampler:

  • VkSampler
  • Модели и материалы: что грузить и как хранить

    Почему удобно начинать с glTF

    glTF это популярный формат для “модели + материалы + текстуры”. Для учебного движка он удобен тем, что вы быстро получите реальную сцену.

    Официальный сайт формата:

  • Khronos glTF
  • Для C++ парсинга есть лёгкая библиотека:

  • tinygltf
  • Какую структуру данных стоит получить на выходе загрузки

    Полезно разделить две стадии:

  • CPU-стадия: распарсили модель, декодировали текстуры, подготовили массивы
  • GPU-стадия: создали VkBuffer и VkImage, обновили descriptor sets
  • Пример CPU-представления меша:

    Важно: это именно CPU-формат, не Vulkan-формат. Он должен быть удобен для валидации и конвертации.

    GPU-представление для рендера

    Для рендера вам нужен набор GPU-ресурсов:

  • vertex buffer
  • index buffer
  • текстуры материалов
  • дескрипторы, которые укажут шейдеру, какие текстуры и параметры использовать
  • Пример минимального GPU-материала:

    Замечание по архитектуре из прошлых статей:

  • модуль game может держать “сущности и компоненты”
  • модуль assets может грузить CpuModel
  • модуль renderer принимает CpuModel и строит GpuModel
  • Так вы не протаскиваете Vulkan-типы в игровой слой.

    Система ассетов: кэш, идентификаторы и время жизни

    Почему нужен кэш

    Если 10 мешей используют одну и ту же текстуру, вы не хотите грузить и загружать её на GPU 10 раз.

    Вам нужен кэш, который:

  • по ключу (например, путь к файлу) возвращает уже загруженный ресурс
  • управляет временем жизни
  • Ключи и “хэндлы” вместо прямых указателей

    В движках часто используют:

  • ключ загрузки: std::string или std::filesystem::path
  • внутренний идентификатор: число или “хэндл”
  • Пример “хэндла”:

    Плюсы:

  • проще сериализовать
  • проще хранить в ECS
  • меньше риска случайно держать “сырой указатель” на уничтоженный объект
  • Владение ресурсами и RAII

    Правило из первой статьи про RAII становится критичным:

  • каждый Vulkan-объект должен быть корректно уничтожен
  • порядок уничтожения важен
  • Практическое правило для текстуры:

  • уничтожить VkSampler
  • уничтожить VkImageView
  • уничтожить VkImage
  • освободить VkDeviceMemory
  • И аналогично для буфера:

  • уничтожить VkBuffer
  • освободить VkDeviceMemory
  • Асинхронная загрузка и “двухфазность”

    В играх ассеты часто грузятся в фоне, но Vulkan-объекты обычно создают в потоке, где у вас корректно организован доступ к VkDevice и очередям.

    Удобная схема для курса:

  • фоновые потоки делают только CPU-работу: чтение файлов, декодирование, парсинг
  • главный поток делает GPU-работу: создание VkBuffer, VkImage, запись команд копирования
  • Это помогает избежать сложной синхронизации на раннем этапе.

    Практика: минимальный “upload context”

    Когда вы загружаете ресурсы, вам нужно уметь:

  • временно записать командный буфер
  • выполнить его
  • дождаться завершения
  • Удобно выделить это в маленькую сущность, которая живёт в рендерере.

    Концепция:

  • один VkCommandPool для transfer/upload
  • один VkCommandBuffer
  • один VkFence для ожидания
  • Это не самый быстрый подход, но он простой и надёжный для начала.

    Официальные описания команд копирования:

  • vkCmdCopyBuffer
  • vkCmdCopyBufferToImage
  • Как это связывается с descriptor sets и pipeline layout

    Из статьи про descriptor sets важное следствие:

  • текстура сама по себе не “попадает в шейдер”
  • в шейдер попадает descriptor, который указывает на VkImageView и VkSampler
  • То есть пайплайн выглядит так:

  • ассеты создают VkImage, VkImageView, VkSampler
  • рендерер обновляет VkDescriptorSet, записывая VkDescriptorImageInfo
  • при рендере вы привязываете нужный VkDescriptorSet для материала
  • Именно поэтому архитектурно удобно хранить VkDescriptorSet как часть материала.

    Частые ошибки при работе с ресурсами

    Несовпадение формата текстуры и ожиданий

    Если шейдер ожидает sRGB, а вы создали UNORM (или наоборот), освещение и цвета будут выглядеть неправильно.

    Забыли нужные usage-флаги

    Если вы хотите копировать в ресурс, но не указали VK_BUFFER_USAGE_TRANSFER_DST_BIT или VK_IMAGE_USAGE_TRANSFER_DST_BIT, валидация будет ругаться.

    Неправильный layout изображения при чтении в шейдере

    Если вы обновили дескриптор как SHADER_READ_ONLY_OPTIMAL, но не сделали переход layout, поведение может быть неопределённым.

    Уничтожение ресурсов “раньше времени”

    Если GPU ещё использует буфер или изображение, а вы их уничтожили на CPU, будет ошибка. Для упрощения на старте полезно:

  • перед массовым уничтожением ресурсов делать vkDeviceWaitIdle(device)
  • Позже вы замените это на более точную синхронизацию.

    Итоги

    Теперь у вас есть цельная картина ресурсов в Vulkan-игре:

  • VkBuffer для линейных данных и VkImage для текстур
  • staging как основной путь загрузки данных на GPU
  • минимальная структура ассетов: CPU-представление и GPU-представление
  • кэширование и управление временем жизни через RAII и хэндлы
  • Следующий логичный шаг после этой статьи: собрать рендер, который использует эти ресурсы в настоящих draw-вызовах, то есть добавить render pass, framebuffer, графический pipeline, а затем перейти к отрисовке мешей с материалами.

    6. Камера, освещение и постобработка: PBR-основы и эффекты

    Камера, освещение и постобработка: PBR-основы и эффекты

    Зачем эта статья

    В предыдущих статьях вы собрали каркас Vulkan-приложения (instance, device, swapchain, синхронизация), разобрались с descriptor sets и научились думать в терминах ресурсов (VkBuffer, VkImage, staging-загрузка, текстуры и материалы).

    Теперь мы поднимаемся на уровень визуального результата игры. Почти любой современный рендер начинается с трёх базовых вещей:

  • камера: как мы смотрим на сцену и как переносим мир в экранные координаты
  • освещение: откуда берётся свет и почему материалы выглядят реалистично
  • постобработка: как из «сырого» HDR-кадра получить красивую картинку на мониторе
  • Цель статьи: получить целостную схему того, какие данные нужно передавать в шейдеры, как устроен минимальный PBR-подход (на практике чаще всего glTF metallic-roughness), и как организовать простой постпроцесс-пайплайн в Vulkan.

    !Общая карта: где камера и PBR-освещение, а где постобработка и вывод в swapchain

    Камера: что это такое в рендерере

    Камера в 3D-графике — это набор параметров, которые определяют:

  • где находится наблюдатель
  • куда он смотрит
  • как 3D-точки проецируются на 2D-экран
  • На практике камера почти всегда передаётся в шейдеры как две матрицы:

  • view-матрица: переводит координаты из мира в пространство камеры
  • projection-матрица: проецирует в clip space (пространство отсечения), после чего GPU делит на w и получает нормализованные координаты экрана
  • Пространства координат простыми словами

    Типичный путь вершины:

  • model space (локальные координаты меша)
  • world space (координаты в мире)
  • view space (координаты относительно камеры)
  • clip space (после проекции)
  • NDC (после деления на w)
  • !Визуальная интуиция: какие пространства координат проходят вершины

    Какие параметры камеры нужны игре

    Обычно достаточно:

  • позиция камеры в мире
  • ориентация (например, yaw/pitch или quaternion)
  • fov (угол обзора)
  • near и far (плоскости отсечения)
  • размер окна (чтобы посчитать aspect ratio)
  • Как передать камеру в Vulkan-шейдеры

    В статье про descriptor sets вы уже сделали главный вывод: шейдер получает данные не «по указателю», а через заранее описанные привязки.

    Для камеры самый частый вариант — uniform buffer.

    Данные кадра и данные объекта

    В реальном рендерере удобно разделить:

  • данные кадра (frame data): камера, время, параметры экспозиции, позиция наблюдателя
  • данные объекта (per-draw): матрица модели, индекс материала, мелкие параметры
  • Практичная схема в Vulkan:

  • set = 0: данные кадра (UBO)
  • push constants: данные одного draw-вызова (например, model)
  • set = 1: материал (текстуры и параметры)
  • C++ структура UBO и выравнивание

    В Vulkan для uniform-данных критично выравнивание. Для учебного проекта проще всего придерживаться правила: хранить матрицы и vec4 так, чтобы они были выровнены на 16 байт.

    Если вы используете GLM, включите режим под Vulkan:

    GLM_FORCE_DEPTH_ZERO_TO_ONE важен, потому что в Vulkan глубина в NDC обычно в диапазоне 0..1.

    Пример данных кадра:

    Рекомендация по архитектуре из прошлых статей:

  • храните по одному FrameUBO на каждый кадр в полёте (например, 2)
  • обновляйте UBO перед записью команд кадра
  • привязывайте соответствующий VkDescriptorSet для текущего кадра
  • Ссылки:

  • GLM (OpenGL Mathematics)
  • Vulkan Guide: Descriptor Sets
  • Камера как C++ объект

    Камеру удобно держать в модуле game, но данные в GPU формат отправлять через интерфейс рендерера.

    Минимальная логика для камеры от первого лица:

  • yaw/pitch на мыши
  • WASD для перемещения
  • собрать view из позиции и направления
  • Важно архитектурно:

  • game решает, какая камера и как она движется
  • renderer решает, куда положить её данные (UBO/push constants)
  • Освещение: базовые типы источников света

    Освещение отвечает за то, как материал выглядит при разных условиях. Даже с идеальными текстурами без света сцена будет «плоской».

    В учебном движке удобно начать с трёх типов:

  • directional light (направленный): имитация солнца, свет параллельными лучами
  • point light (точечный): лампочка, свет во все стороны, обычно затухает с расстоянием
  • ambient / IBL (окружение): общий вклад окружения, в PBR часто делают через environment map
  • На первых шагах часто делают один directional light и один ambient-коэффициент, чтобы проверить материалы.

    PBR: что означает «физически корректно» на практике

    PBR (physically based rendering) — это подход, где модель освещения старается вести себя похоже на физику света:

  • энергия не появляется «из ниоткуда»
  • материалы описываются параметрами, которые имеют понятный смысл
  • одни и те же материалы выглядят устойчиво при смене освещения
  • В играх самый распространённый стандарт материалов — glTF metallic-roughness.

    Ссылки:

  • glTF 2.0 Specification
  • LearnOpenGL: PBR Theory
  • Filament PBR documentation
  • Параметры metallic-roughness

    Минимальный набор параметров материала:

  • baseColor (альбедо): базовый цвет материала
  • metallic: насколько материал «металл»
  • roughness: насколько поверхность шероховатая (размывает отражения)
  • normal map (опционально): мелкая детализация нормалей
  • emissive (опционально): самосвечение
  • Интуитивно:

  • неметаллы (дерево, пластик) имеют сильный диффузный цвет и слабые отражения
  • металлы имеют почти нулевую диффузную составляющую, а цвет уходит в отражения
  • roughness управляет «гладкостью» бликов
  • !Визуальная связь metallic и roughness с видом бликов

    Какие текстуры обычно нужны

    Типичный набор текстур (как в glTF):

  • baseColor в sRGB формате
  • metallicRoughness как data-текстура (обычно в UNORM, каналы packed)
  • normal как data-текстура (UNORM)
  • emissive часто в sRGB
  • Это напрямую связывается со статьёй про ресурсы: цветовые текстуры обычно создаются как _SRGB, а data-текстуры как _UNORM.

    Линейное пространство и sRGB: почему без этого PBR ломается

    Монитор и большинство изображений хранят цвета в sRGB, где значения уже «сжаты» под восприятие человека. Освещение и все вычисления света должны происходить в линейном пространстве.

    Практические правила:

  • читать baseColor как sRGB (GPU выполнит преобразование в линейное, если формат изображения sRGB)
  • normal/metallic/roughness читать как UNORM (без sRGB-преобразования)
  • все lighting-вычисления делать в линейном
  • перед выводом на монитор сделать тонмаппинг и гамма-коррекцию, либо использовать swapchain sRGB
  • Swapchain формат и вывод

    Если ваша поверхность поддерживает VK_FORMAT_B8G8R8A8_SRGB (или аналогичный sRGB формат), часто удобно выбрать его для swapchain.

    Тогда ожидаемая схема такая:

  • вы рендерите в HDR offscreen-изображение (обычно R16G16B16A16_SFLOAT)
  • в постпроцессе выдаёте линейный LDR-цвет
  • запись в swapchain sRGB приводит к правильному преобразованию в sRGB на выходе
  • Справка по форматам:

  • Vulkan Spec: Image Formats
  • Минимальный набор данных для PBR-шейдера

    Чтобы фрагментный шейдер мог посчитать свет, ему обычно нужны:

  • позиция фрагмента в мире
  • нормаль (в мире)
  • направление на камеру
  • параметры материала (baseColor, metallic, roughness)
  • параметры света (направление, цвет, интенсивность)
  • Где хранить параметры света

    Есть два простых варианта:

  • в FrameUBO (если у вас 1-2 источника)
  • в отдельном storage buffer (если источников много)
  • Для учебного проекта удобно начать с FrameUBO и одного directional light:

    Постобработка: зачем она нужна и как её сделать в Vulkan

    Постобработка — это эффекты, которые делаются уже после основного 3D-рендера, обычно на полном экране.

    Зачем она нужна даже в простом движке:

  • PBR обычно даёт HDR-значения яркости, которые нельзя напрямую показать на мониторе
  • тонмаппинг делает картинку визуально приятной
  • гамма-коррекция или sRGB вывод — обязательны для правильных цветов
  • эффекты вроде bloom, vignette, color grading сильно улучшают восприятие
  • Базовая схема двух проходов

  • Main pass: рендер сцены в offscreen HDR-цвет + depth
  • Postprocess pass: full-screen треугольник (или quad), чтение HDR-цвета как текстуры, запись в swapchain
  • !Понимание, почему постпроцесс почти всегда требует offscreen изображения

    Vulkan-ресурсы для постпроцесса

    В терминах Vulkan вам обычно понадобятся:

  • offscreen VkImage для HDR-цвета
  • VkImageView на него
  • sampler для чтения в постпроцесс-шейдере
  • отдельный VkRenderPass или отдельный subpass (на старте проще отдельный render pass)
  • descriptor set для постпроцесса: combined image sampler
  • Это напрямую продолжает темы:

  • из статьи про ресурсы: создание VkImage, layout-переходы, VkImageView, VkSampler
  • из статьи про дескрипторы: VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER
  • Тонмаппинг

    Тонмаппинг — это функция, которая переводит HDR-яркости в диапазон, подходящий для вывода на экран.

    Часто используют простые операторы:

  • Reinhard
  • ACES filmic (популярная аппроксимация)
  • Ссылка на практичную кривую ACES:

  • ACES Filmic Tone Mapping Curve
  • Важная идея для движка:

  • экспозиция (exposure) должна быть параметром кадра
  • тонмаппинг почти всегда живёт в постпроцессе
  • Bloom

    Bloom — эффект «свечения» вокруг ярких областей.

    Классическая схема (упрощённо):

  • выделить яркие пиксели (threshold)
  • размыть (обычно separable blur: горизонтально + вертикально)
  • прибавить к исходному изображению
  • Для первого учебного шага можно:

  • сделать только тонмаппинг и гамму
  • добавить bloom позже, когда вы уверенно управляете несколькими изображениями и проходами
  • Как это уложить в архитектуру модулей курса

    Свяжем с архитектурой из статьи про модули:

  • game
  • - хранит состояние камеры - обновляет её по вводу
  • renderer
  • - имеет формат FrameUBO и знает, как обновить UBO текущего кадра - управляет оффскрин изображениями постпроцесса - выполняет рендер-проходы и layout-переходы
  • assets
  • - загружает текстуры baseColor/normal/metallicRoughness - сообщает рендереру, какие текстуры принадлежат материалу

    Практичный C++ дизайн для рендерера:

  • отдельный тип PerFrameResources, где лежат:
  • - VkBuffer + память под UBO - VkDescriptorSet для set=0

  • отдельный тип PostprocessResources, где лежат:
  • - HDR VkImage + память - VkImageView - VkDescriptorSet для чтения HDR в постпроцессе

    Частые ошибки и как их диагностировать

  • Освещение считается в sRGB вместо линейного: материалы выглядят «грязно» или слишком тёмно/ярко.
  • baseColor создан как UNORM, а не SRGB: цвета и яркость будут неверными.
  • не сделан layout-переход перед чтением HDR-изображения в постпроцессе: валидация будет ругаться, а поведение может быть неопределённым.
  • камера обновляется, но вы забыли обновить UBO текущего кадра в полёте: картинка «прыгает» или отстаёт на кадр.
  • Инструменты:

  • RenderDoc
  • Khronos Vulkan Validation Layers
  • Итоги

    Теперь у вас есть связанная картина поверх предыдущих тем:

  • камера — это данные кадра (view/proj/viewProj + позиция), которые удобно хранить в uniform buffer
  • PBR в стиле glTF metallic-roughness задаёт понятные параметры материала и устойчивое освещение
  • правильные цветовые пространства (linear и sRGB) — обязательное условие для корректного PBR
  • постобработка в Vulkan обычно строится как рендер в offscreen HDR-изображение и полный экран в swapchain с тонмаппингом
  • Следующий логичный шаг после этой статьи — собрать полноценный рендер-граф из нескольких проходов: основной forward PBR, затем тонмаппинг, а затем дополнительные эффекты (bloom, vignette, LUT-коррекция), не ломая архитектуру и управление ресурсами.

    7. Оптимизация и выпуск: профилирование, multi-threading, сборка и деплой

    Оптимизация и выпуск: профилирование, multi-threading, сборка и деплой

    Зачем эта статья

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

    Теперь появляется типичная проблема: всё работает, но

  • FPS нестабилен
  • на слабых GPU кадр резко дороже
  • на CPU растёт время записи команд
  • сборка и запуск на другой машине превращаются в квест
  • Эта статья про то, как перейти от прототипа к выпускаемому приложению:

  • как профилировать CPU и GPU так, чтобы находить реальные узкие места
  • как распараллелить подготовку кадра и запись команд в Vulkan
  • как настроить Release-сборку, шейдеры, ассеты и упаковку
  • !Таймлайн кадра и места для измерений

    Главный принцип оптимизации

    Оптимизация в графике почти всегда начинается с вопроса:

  • вы ограничены CPU или GPU?
  • Если ограничены CPU, ускорение шейдеров почти ничего не даст. Если ограничены GPU, распараллеливание логики может не изменить FPS.

    Практическая схема диагностики:

  • Замерьте время кадра на CPU и GPU
  • Поймите, кто из них “длиннее”
  • Уточните источник внутри CPU или GPU
  • Меняйте только то, что подтверждено измерениями
  • Инструменты профилирования

    Быстрый обзор инструментов

    | Задача | Инструмент | Что даёт | |---|---|---| | Захват кадра Vulkan и анализ пайплайна | RenderDoc | просмотр команд, ресурсов, шейдеров, состояний | | GPU-профилирование на NVIDIA | NVIDIA Nsight Graphics | тайминги проходов, bottleneck analysis, wave/warp инфо | | GPU-профилирование на AMD | Radeon GPU Profiler | таймлайны GPU, кэш/память, волны, occupancy | | Трассировка CPU (зоны, потоки) | Tracy Profiler | удобный таймлайн, многопоточность, аллокации | | Профилирование на Linux | perf | sampling, hotspots, call stacks |

    Почему RenderDoc важен даже для оптимизации

    RenderDoc часто воспринимают как отладчик “когда сломалось”. Но для оптимизации он полезен тем, что позволяет:

  • увидеть реальные render pass и draw calls
  • понять, какие ресурсы и форматы используются
  • обнаружить лишние барьеры и переходы layout
  • заметить “случайно дорогие” состояния (например, огромные attachment’ы)
  • CPU-профилирование: где утекает время на стороне приложения

    Что измерять в игровом цикле

    Минимальный набор зон (scopes), которые нужно измерять на CPU:

  • PollEvents
  • Update или FixedUpdate
  • BuildRenderList (сбор видимых объектов, сортировка, батчинг)
  • UpdateGPUData (обновление UBO/SSBO, подготовка staging)
  • RecordCommandBuffers
  • SubmitAndPresent (ожидания fence, acquire, submit, present)
  • Если вы используете фиксированный шаг логики, отдельно измеряйте:

  • сколько раз за кадр выполнялся fixedUpdate
  • Частый враг CPU: аллокации и “мелкие контейнеры”

    В C++-игровом коде типичные источники тормозов:

  • частые new/delete в кадре
  • создание временных std::vector и std::string в горячих местах
  • std::function и виртуальные вызовы в tight loop
  • Практические меры:

  • переиспользуйте буферы: vector.clear() вместо создания нового
  • заранее делайте reserve()
  • используйте std::span для передачи “видов” на массив без копирования
  • отделяйте подготовку списка рендера (данные) от записи команд (API-вызовы)
  • Минимальная интеграция Tracy (идея)

    Tracy добавляется как библиотека и позволяет размечать зоны.

    Цель не в конкретной библиотеке, а в том, чтобы у вас появился таймлайн и вы перестали гадать.

    GPU-профилирование: измеряем реальную стоимость проходов

    Два уровня GPU-анализа

  • Внешний профайлер (Nsight/RGP) показывает полную картину GPU, включая стадии и узкие места
  • Встроенные измерения через timestamp queries позволяют видеть стоимость ваших проходов прямо в приложении
  • Практика: используйте оба подхода.

    Timestamp queries в Vulkan: что это такое

    Vulkan позволяет ставить “метки времени” в командном буфере и затем читать результаты на CPU. Это базовый способ получить:

  • время shadow pass
  • время main pass
  • время постпроцесса
  • Ключевые объекты:

  • VkQueryPool типа VK_QUERY_TYPE_TIMESTAMP
  • команды vkCmdWriteTimestamp
  • чтение через vkGetQueryPoolResults
  • Упрощённая идея API (без полного кода и всех проверок):

    Важно понимать два момента:

  • timestampPeriod берётся из VkPhysicalDeviceProperties и задаёт, сколько наносекунд соответствует единице timestamp
  • метки времени измеряют GPU-время, но чтение результатов обычно делается с задержкой (например, на кадр-два позже), чтобы не стопорить CPU
  • Что искать на GPU в первую очередь

    Практичные подозреваемые:

  • слишком большой overdraw (много пикселей перерисовываются)
  • слишком дорогие фрагментные шейдеры (PBR, много текстур, ветвления)
  • высокое разрешение HDR-таргетов и постпроцесса
  • тяжёлые форматы (например, неоправданно дорогой MSAA)
  • лишние переходы layout и барьеры
  • Хорошая база по направлению оптимизаций Vulkan:

  • Vulkan Guide
  • Vulkan-оптимизации, которые дают эффект почти всегда

    Уменьшайте работу “на кадр”

    В Vulkan дорогой не только GPU, но и подготовка кадра:

  • меньше vkUpdateDescriptorSets в кадре
  • меньше пересозданий пайплайнов
  • меньше “мелких” команд, больше батчинга
  • Практика:

  • делайте дескрипторы долговечными: обновляйте материалы при загрузке, а не каждый кадр
  • храните per-frame UBO в одном большом буфере (динамические оффсеты или массив)
  • используйте pipeline cache
  • Pipeline cache

    VkPipelineCache позволяет ускорять создание пайплайнов между запусками.

    Минимальная стратегия:

  • при старте попытаться загрузить blob из файла
  • создать VkPipelineCache с этими данными
  • создавать пайплайны с этим кэшем
  • при выходе сохранить обновлённые данные через vkGetPipelineCacheData
  • Это особенно важно, если вы создаёте несколько вариантов пайплайнов (PBR, скиннинг, тени, постпроцесс).

    Не профилируйте с validation layers

    Слои валидации очень полезны в разработке, но они:

  • добавляют CPU-оверход
  • иногда меняют тайминги
  • Правило:

  • проверяем корректность с validation layers
  • измеряем производительность в максимально близкой к Release конфигурации
  • Multi-threading: как распараллелить подготовку кадра

    Что реально имеет смысл распараллеливать

    В типичном Vulkan-рендерере есть три категории задач:

  • CPU-логика игры (ECS системы, физика, AI)
  • подготовка данных рендера (culling, сортировка, построение списков)
  • запись командных буферов (Vulkan команды)
  • На практике чаще всего выигрывает распараллеливание:

  • culling и построения draw list
  • записи командных буферов по чанкам сцены или по проходам
  • Важное правило Vulkan про потоки

    Vulkan в целом thread-safe на уровне объекта, но:

  • один и тот же VkCommandBuffer нельзя записывать из нескольких потоков одновременно
  • один VkCommandPool нельзя одновременно использовать для аллокации/сброса командных буферов из нескольких потоков без внешней синхронизации
  • Практическое следствие:

  • делайте command pool на поток
  • каждый поток пишет в свои command buffers
  • Архитектура: primary и secondary command buffers

    Типичная схема:

  • основной поток пишет primary command buffer (начало кадра, render pass, общие состояния)
  • рабочие потоки пишут secondary command buffers (наборы draw calls)
  • основной поток “вклеивает” их командой vkCmdExecuteCommands
  • Это удобный способ параллелить запись draw calls.

    !Параллельная запись команд через secondary command buffers

    Минимальный план внедрения многопоточности в рендерере

  • Введите структуру RenderPacket без Vulkan-типов: массив объектов для рисования, отсортированный по материалу/мешу
  • Разбейте RenderPacket на чанки
  • Создайте пул потоков (или job system)
  • На каждый поток выделите свой VkCommandPool
  • Пусть потоки пишут secondary command buffers по чанкам
  • Primary command buffer исполняет все secondary
  • C++-практика: простая система задач

    В учебном движке не обязательно писать сложный job system. Достаточно минимальной очереди задач и пула потоков.

    Но важно соблюдать два правила:

  • задачи не должны владеть Vulkan-ресурсами “непонятно как” (явное владение, никаких случайных shared_ptr в горячем месте)
  • данные для записи команд должны быть стабильны (не изменяться другими потоками)
  • Обычно это означает:

  • на кадр формируется immutable RenderPacket
  • рендер-потоки только читают его
  • Стабильный кадр: frame pacing и ожидания

    Что такое frame pacing

    Даже при среднем хорошем FPS игра может “дёргаться”, если время кадра нестабильно.

    Частые причины:

  • ожидания vkWaitForFences начинают регулярно блокировать CPU
  • пересоздание swapchain при resize или при смене режима окна
  • фоновые загрузки ассетов конкурируют за CPU/диск
  • Практические меры:

  • используйте 2-3 кадра в полёте, но не увеличивайте бесконечно
  • отделите фоновые CPU-загрузки (декодирование) от GPU-upload
  • не делайте vkDeviceWaitIdle в середине игрового процесса (кроме простых учебных упрощений)
  • Release-сборка: что включать и что выключать

    Конфигурации сборки

    Минимально полезный набор:

  • Debug: валидация, максимальная проверка, символы
  • RelWithDebInfo: почти Release, но со символами для профилирования
  • Release: финальная
  • Если вы используете CMake, удобны CMake Presets:

  • CMake Presets
  • Практичные флаги и опции

  • Включайте оптимизации в релизе (-O2/-O3 или эквивалент MSVC)
  • Используйте LTO там, где это не мешает итерации (Link Time Optimization)
  • В релизе отключайте:
  • validation layers
  • debug messenger
  • любые тяжёлые проверки в горячем цикле
  • В RelWithDebInfo оставляйте:
  • символы
  • минимальные лог-сообщения
  • Sanitizers для C++

    До релиза полезно прогнать приложение с санитайзерами, чтобы поймать:

  • use-after-free
  • out-of-bounds
  • data races
  • Документация Clang Sanitizers:

  • Clang Sanitizers
  • Важно: санитайзеры не совместимы с реальной оптимизацией производительности, это этап поиска ошибок, а не финального профилирования.

    Шейдеры, ассеты и деплой

    Шейдеры

    Практика для деплоя:

  • шейдеры должны быть скомпилированы в .spv на этапе сборки
  • в рантайме приложение загружает только .spv
  • Это продолжает схему из статьи про шейдеры и SPIR-V.

    Ассеты и пути

    Стабильная схема для путей:

  • считать, что рядом с бинарником лежит папка assets/
  • при старте вычислять basePath относительно argv[0] или через std::filesystem::current_path() и явные правила
  • Важное правило: не используйте абсолютные пути из вашей машины разработчика.

    Что нужно для запуска Vulkan-приложения на другой машине

  • На машине должны быть установлены драйверы GPU с поддержкой Vulkan
  • На Windows обычно не нужно поставлять Vulkan SDK пользователю, но нужно:
  • правильно подтянуть зависимости вашего приложения (DLL от сторонних библиотек, если они не статически линкованы)
  • На Linux нужно убедиться, что у пользователя есть Vulkan loader и ICD драйвера (обычно через пакеты дистрибутива)
  • Хорошая отправная точка по экосистеме Vulkan:

  • Khronos Vulkan Registry
  • Минимальный деплой-чеклист

  • Release/RelWithDebInfo сборка без validation layers
  • .spv шейдеры рядом с приложением или в ожидаемом каталоге
  • assets/ упакованы и проверены (модели, текстуры)
  • проверка на “чистой машине” или в отдельном окружении
  • сбор GPU-логов и crash-логов (хотя бы текстовый файл)
  • Итоги

    После этой статьи у вас должна сложиться практическая картина, как доводить Vulkan-игру до состояния, где её можно измерять и выпускать:

  • вы умеете отличать CPU-ограничение от GPU-ограничения и измерять оба
  • вы понимаете, как использовать внешние профайлеры и timestamp queries
  • вы знаете, как распараллеливать подготовку кадра и запись команд через per-thread command pools и secondary command buffers
  • вы понимаете, что именно меняется в Release-сборке и какие шаги нужны для деплоя (шейдеры, ассеты, зависимости)