Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Добрый день! Я продолжаю выкладывать на Хабр переводы Vulkan Tutorial на русский язык (оригинал руководства можно посмотреть здесь).
Сегодня я перехожу к следующему разделу — Uniform buffers, и начну я с перевода статьи, которая называется Descriptor layout and buffer.
Содержание
1. Вступление
2. Краткий обзор
3. Настройка окружения
4. Рисуем треугольник
5. Вершинные буферы
6. Uniform-буферы
7. Текстурирование
8. Буфер глубины
9. Загрузка моделей
10. Создание мип-карт
11. Multisampling
FAQ
Политика конфиденциальности
2. Краткий обзор
3. Настройка окружения
4. Рисуем треугольник
- Подготовка к работе
- Базовый код
- Экземпляр (instance)
- Слои валидации
- Физические устройства и семейства очередей
- Логическое устройство и очереди
- Отображение на экране
- Window surface
- Swap chain
- Image views
- Графический конвейер (pipeline)
- Вступление
- Шейдерные модули
- Непрограммируемые стадии конвейера
- Проходы рендера (Render passes)
- Заключение
- Отрисовка
- Фреймбуферы
- Буферы команд
- Рендеринг и отображение на экране
- Пересоздание swap chain
5. Вершинные буферы
- Описание входных данных вершин
- Создание вершинного буфера
- Промежуточный буфер
- Индексный буфер
6. Uniform-буферы
- Layout дескрипторов и буфер
- Пул дескрипторов и сеты дескрипторов
7. Текстурирование
- Изображения
- Image view и image sampler
- Комбинированный image sampler
8. Буфер глубины
9. Загрузка моделей
10. Создание мип-карт
11. Multisampling
FAQ
Политика конфиденциальности
Layout дескрипторов и буфер
- Вступление
- Вершинный шейдер
- Layout дескрипторов
- Uniform-буфер
- Обновление uniform-данных
Вступление
Мы можем передавать произвольные атрибуты вершинному шейдеру, но как быть с глобальными переменными? С этой главы мы переходим к трехмерной графике, и для этого нам потребуются матричные преобразования. Мы будем использовать 3 матрицы: model — матрица преобразования из локальных координат в мировые, view — для преобразования из мировых координат во view пространство, и proj — проекционная матрица. Мы могли бы записать уже преобразованные координаты в вершинный буфер, но для этого пришлось бы выделять лишнюю память и постоянно обновлять данные.
Чтобы решить эту проблему в Vulkan, нужно использовать дескрипторы ресурсов. Дескрипторы предоставляют шейдерам доступ к таким ресурсам, как буферы и images. Мы создадим буфер, содержащий матрицы преобразования, и предоставим вершинному шейдеру доступ к ним через дескриптор. Использование дескрипторов состоит из трех этапов:
- Указание layout-а дескрипторов во время создания конвейера
- Выделение набора дескрипторов из пула дескрипторов
- Привязка (bind) набора дескрипторов во время рендеринга
Есть два вида объектов для работы с дескрипторами ресурсов: layout-ы дескрипторов и сеты дескрипторов (descriptor set). Layout определяет типы ресурсов, к которым будет осуществляться доступ из конвейера, точно так же, как render pass определяет типы attachment-ов, с которыми он работает. Сет дескрипторов определяет, какие именно буферы или images нужно привязать к дескрипторам, точно так же, как фреймбуфер определяет, какие именно images использовать для работы с render pass-ом. Именно сеты привязываются к командам рисования, как вершинные буферы или фреймбуферы.
Есть разные типы дескрипторов, но в этой главе мы будем работать с объектами uniform-буферов (uniform buffer objects или UBO). В дальнейшем мы рассмотрим и другие типы дескрипторов, но принцип работы у них один.
Допустим, у нас есть данные, которые мы хотим расположить в вершинном шейдере в виде следующей структуры:
struct UniformBufferObject {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
Затем мы можем скопировать эти данные в
VkBuffer
и получить к ним доступ из вершинного шейдера с помощью дескриптора uniform-буфера:layout(binding = 0) uniform UniformBufferObject {
mat4 model;
mat4 view;
mat4 proj;
} ubo;
void main() {
gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 0.0, 1.0);
fragColor = inColor;
}
Мы постоянно будем обновлять матрицы model, view и proj, чтобы прямоугольник, который мы получили в предыдущей главе, вращался в 3D.
Вершинный шейдер
Изменим вершинный шейдер, чтобы подключить uniform-буфер. Вероятно, вы уже знакомы с MVP преобразованиями. Если нет, см. ссылку.
#version 450
layout(binding = 0) uniform UniformBufferObject {
mat4 model;
mat4 view;
mat4 proj;
} ubo;
layout(location = 0) in vec2 inPosition;
layout(location = 1) in vec3 inColor;
layout(location = 0) out vec3 fragColor;
void main() {
gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 0.0, 1.0);
fragColor = inColor;
}
Обратите внимание, что порядок объявлений
uniform
, in
и out
не имеет значения. Директива binding
аналогична директиве location
для атрибутов. Мы укажем на нее в layout-е дескрипторов. Строка с вычислением gl_Position
изменена, чтобы использовать матрицы для преобразования координат. В отличие от двумерных треугольников, последний компонент координат в пространстве отсечения может быть не равен 1
, что приведет к делению при преобразовании в конечные нормализованные экранные координаты устройства. Это используется в перспективной проекции в качестве перспективного деления и необходимо для того, чтобы более близкие объекты выглядели больше, чем объекты вдалеке.Layout дескрипторов
Наш следующий шаг — описать используемый в шейдере uniform buffer на стороне C ++.
struct UniformBufferObject {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
Мы дублируем описание буфера из шейдера с помощью соответствующих типов GLM. Данные в матрицах бинарно совместимы с тем, как ожидает шейдер, поэтому в дальнейшем мы можем просто скопировать
UniformBufferObject
в VkBuffer
с помощью memcpy
.Нам нужно предоставить подробную информацию о каждом дескрипторе, используемом в шейдерах. Для этого создадим новую функцию, которая называется
createDescriptorSetLayout
. Ее нужно вызвать перед самым созданием конвейера, т. к. она нам там понадобится.void initVulkan() {
...
createDescriptorSetLayout();
createGraphicsPipeline();
...
}
...
void createDescriptorSetLayout() {
}
Каждая привязка должна быть описана с помощью структуры
VkDescriptorSetLayoutBinding
.void createDescriptorSetLayout() {
VkDescriptorSetLayoutBinding uboLayoutBinding{};
uboLayoutBinding.binding = 0;
uboLayoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
uboLayoutBinding.descriptorCount = 1;
}
Первые два поля определяют
binding
, используемый в шейдере, и тип дескриптора, который представляет собой uniform-буфер. Переменная шейдера может представлять собой массив uniform-буферов, а descriptorCount
указывает количество значений в массиве. Это можно использовать, например, для указания трансформаций для каждой кости скелета в скелетной анимации. Наше MVP преобразование находится в единственном uniform-буфере, поэтому в
descriptorCount
мы укажем 1
.uboLayoutBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
Также необходимо указать, на каких этапах шейдера будет упоминаться дескриптор. В поле
stageFlags
может быть указана комбинация значений VkShaderStageFlagBits
или значение VK_SHADER_STAGE_ALL_GRAPHICS. В нашем случае мы ссылаемся на дескриптор только из вершинного шейдера.uboLayoutBinding.pImmutableSamplers = nullptr; // Optional
Поле
pImmutableSamplers
актуально только для дескрипторов, связанных с чтением из текстур. Их мы рассмотрим позже. Здесь можно оставить значение по умолчанию.Все привязки дескрипторов объединены в один объект
VkDescriptorSetLayout
. Определим новый член класса перед pipelineLayout
:VkDescriptorSetLayout descriptorSetLayout;
VkPipelineLayout pipelineLayout;
Затем мы можем создать его с помощью
vkCreateDescriptorSetLayout
. Эта функция принимает довольно простую структуру VkDescriptorSetLayoutCreateInfo
, описывающую массив привязок:VkDescriptorSetLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutInfo.bindingCount = 1;
layoutInfo.pBindings = &uboLayoutBinding;
if (vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &descriptorSetLayout) != VK_SUCCESS) {
throw std::runtime_error("failed to create descriptor set layout!");
}
Мы должны указать на только что созданный layout при создании конвейера, чтобы сообщить Vulkan, какие дескрипторы будут использоваться шейдерами. Layout-ы дескрипторов указываются в объекте layout-а конвейера. Изменим
VkPipelineLayoutCreateInfo
:VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 1;
pipelineLayoutInfo.pSetLayouts = &descriptorSetLayout;
Вас может удивить, почему здесь возможно указать несколько layout-ов
дескрипторов, ведь один layout уже содержит все привязки. Мы еще вернемся к этому вопросу в следующей главе, когда будем рассматривать пулы дескрипторов и сеты дескрипторов.
Layout дескриптора должен быть доступен до тех пор, пока мы можем создавать новые графические конвейеры, то есть до завершения программы:
void cleanup() {
cleanupSwapChain();
vkDestroyDescriptorSetLayout(device, descriptorSetLayout, nullptr);
...
}
Uniform-буфер
Пришло время создать буфер для хранения наших матриц. Мы будем копировать новые данные в uniform-буфер в каждом кадре, поэтому нет необходимости создавать промежуточный буфер — он лишь приведет к снижению производительности.
Сразу несколько кадров могут обрабатываться одновременно (например, один кадр рендерится, другой подготавливается к рендеру). Мы не должны допускать ситуации, когда данные из буфера считываются видеокартой и обновляются процессором в один и тот же момент, поэтому нам необходимо использовать несколько буферов. Мы можем использовать по одному uniform-буферу на кадр или на image из swap chain. Но поскольку мы ссылаемся на uniform-буферы из буфера команд, то лучше использовать свой uniform-буфер для каждого image.
Для этого добавим новые члены класса для
uniformBuffers
и uniformBuffersMemory
:VkBuffer indexBuffer;
VkDeviceMemory indexBufferMemory;
std::vector<VkBuffer> uniformBuffers;
std::vector<VkDeviceMemory> uniformBuffersMemory;
Аналогичным образом создадим новую функцию
createUniformBuffers
, которая вызывается после createIndexBuffer
и выделяет буферы:void initVulkan() {
...
createVertexBuffer();
createIndexBuffer();
createUniformBuffers();
...
}
...
void createUniformBuffers() {
VkDeviceSize bufferSize = sizeof(UniformBufferObject);
uniformBuffers.resize(swapChainImages.size());
uniformBuffersMemory.resize(swapChainImages.size());
for (size_t i = 0; i < swapChainImages.size(); i++) {
createBuffer(bufferSize, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, uniformBuffers[i], uniformBuffersMemory[i]);
}
}
Мы напишем отдельную функцию, которая будет обновлять буфер на каждом кадре, поэтому
vkMapMemory
здесь не используется. Uniform-данные будут использоваться для всех вызовов отрисовки, поэтому буфер, содержащий их, должен быть уничтожен только после того, как мы закончим рендеринг. Поскольку он также зависит от количества images в swap chain, которые могут измениться после пересоздания swap chain, мы удалим его в cleanupSwapChain
:void cleanupSwapChain() {
...
for (size_t i = 0; i < swapChainImages.size(); i++) {
vkDestroyBuffer(device, uniformBuffers[i], nullptr);
vkFreeMemory(device, uniformBuffersMemory[i], nullptr);
}
}
И пересоздадим в
recreateSwapChain
:void recreateSwapChain() {
...
createFramebuffers();
createUniformBuffers();
createCommandBuffers();
}
Обновление uniform-данных
Создадим новую функцию
updateUniformBuffer
и вызовем ее из drawFrame
сразу после того, как узнаем, какой image будет получен из swap chain:void drawFrame() {
...
uint32_t imageIndex;
VkResult result = vkAcquireNextImageKHR(device, swapChain, UINT64_MAX, imageAvailableSemaphores[currentFrame], VK_NULL_HANDLE, &imageIndex);
...
updateUniformBuffer(imageIndex);
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
...
}
...
void updateUniformBuffer(uint32_t currentImage) {
}
Эта функция будет постоянно генерировать новое преобразование, чтобы заставить геометрию вращаться. Для реализации этой функции нам нужно подключить два новых заголовочных файла:
#define GLM_FORCE_RADIANS
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <chrono>
Заголовочный файл
glm/gtc/matrix_transform.hpp
предоставляет функции, которые можно использовать для генерирования model-преобразований, таких как glm :: rotate
, view-преобразований, таких как glm :: lookAt
, и projection-преобразований, таких как glm::perspective
. Определение GLM_FORCE_RADIANS
необходимо, чтобы такие функции, как glm :: rotate
, использовали радианы в качестве аргументов во избежание любой возможной путаницы.Заголовочный файл стандартной библиотеки
chrono
предоставляет функции для точного хронометража. Мы будем использовать их, чтобы обеспечивать вращение геометрии на 90 градусов в секунду независимо от частоты кадров.void updateUniformBuffer(uint32_t currentImage) {
static auto startTime = std::chrono::high_resolution_clock::now();
auto currentTime = std::chrono::high_resolution_clock::now();
float time = std::chrono::duration<float, std::chrono::seconds::period>(currentTime - startTime).count();
}
В начале функции
updateUniformBuffer
мы вычисляем время, прошедшее от начала рендеринга, причем используем для этого числа с плавающей запятой.Теперь вычислим model, view и proj матрицы. Трансформация в мировую систему координат (model) – это просто вращение вокруг оси Z с использованием переменной
time
:UniformBufferObject ubo{};
ubo.model = glm::rotate(glm::mat4(1.0f), time * glm::radians(90.0f), glm::vec3(0.0f, 0.0f, 1.0f));
В качестве параметров функция
glm :: rotate
принимает существующее преобразование, угол вращения и ось вращения. Конструктор glm :: mat4 (1.0f)
возвращает единичную матрицу. Использование угла вращения time * glm :: radians (90.0f)
позволяет осуществлять вращение на 90 градусов в секунду.ubo.view = glm::lookAt(glm::vec3(2.0f, 2.0f, 2.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f));
Для view-преобразования я решил взглянуть на геометрию сверху под углом в 45 градусов. В качестве параметров функция
glm :: lookAt
принимает точку, в которой находится камера (eye position), точку, на которую смотрит камера (center position) и направление «вверх» для камеры (up axis).ubo.proj = glm::perspective(glm::radians(45.0f), swapChainExtent.width / (float) swapChainExtent.height, 0.1f, 10.0f);
Я решил использовать перспективную проекцию с вертикальным полем зрения в 45 градусов. Остальные параметры — это отношение сторон, ближняя плоскость отсечения и дальняя плоскость отсечения. Важно использовать текущее разрешение swap chain, чтобы рассчитать отношения сторон с учетом новой ширины и высоты после изменения размера окна.
ubo.proj[1][1] *= -1;
Изначально GLM был разработан для OpenGL, где ось Y в пространстве отсечения имеет противоположное направление. Самый простой способ компенсировать это — инвертировать знак соответствующего поля в матрице проекции. Если этого не сделать, изображение будет рендериться вверх ногами.
Теперь все матрицы вычислены, поэтому мы можем скопировать данные в текущий uniform-буфер.
void* data;
vkMapMemory(device, uniformBuffersMemory[currentImage], 0, sizeof(ubo), 0, &data);
memcpy(data, &ubo, sizeof(ubo));
vkUnmapMemory(device, uniformBuffersMemory[currentImage]);
Такой способ использования UBO — не самый эффективный для передачи постоянно меняющихся значений шейдеру. Для передачи небольших буферов данных эффективнее будет использовать push-константы. Мы рассмотрим их в следующих главах.
В следующей главе мы рассмотрим сеты дескрипторов, которые привяжут (bind) VkBuffers к дескрипторам uniform-буферов, чтобы шейдер мог получить доступ к данным преобразований.
Код С++
/ Вершинный шейдер
/ Фрагментный шейдер