Для отрисовки графики в Vulkan применяется графический конвейер (graphics pipeline), который представляет последовательность операций по рендерингу графики. Графический конвейер предполагает ряд этапов:
Ассемблер входных данных (input assembler): собирает из полученных буферов необработанные данные вершин и может также использовать индексный буфер для повторения определенных элементов без необходимости дублирования самих данных вершин.
Вершинный шейдер (vertex shader): запускается для каждой вершины и обычно применяет преобразования для перевода позиций вершин из пространства модели в пространство экрана. Он также передает данные по вершинам по конвейеру.
Тесселяционные шейдеры (tessellation shaders) позволяют подразделять поверхности на мелкие части.
Геометрический шейдер (geometry shader) запускается для каждого примитива (треугольник, линия, точка) и может отбросить его или разделить на большее количество примитивов. Он похоже на шейдер тесселяции, но гораздо более гибкий. Однако он нечасто используется в современных приложениях, поскольку его производительность не так хороша на большинстве видеокарт, за исключением интегрированных графических процессоров Intel.
Растеризация (rasterization) разделяет примитивы на фрагменты. Это пиксельные элементы, которые они заполняют в буфере кадров. Любые фрагменты, которые выходят за пределы экрана, отбрасываются, а атрибуты, выводимые вершинным шейдером, интерполируются по фрагментам. Обычно фрагменты, которые находятся за другими примитивными фрагментами, также отбрасываются здесь из-за проверки глубины.
Фрагментный шейдер (fragment shader) вызывается для каждого фрагмента и определяет, в какой буфер кадра(ов) передаются эти фрагменты и с какими значениями цвета и глубины..
Смешивание цветов (color blending) применяет операции для смешивания различных фрагментов, которые отображаются на один и тот же пиксель в буфере кадра. Фрагменты могут просто перезаписывать друг друга, складываться или смешиваться на основе прозрачности.
>На рисунке этапы c зеленом цветом представляют с фиксированные функции. Эти этапы позволяют настраивать их операции с помощью параметров, но способ их работы предопределен.
Этапы с оранжевым цветом являются программируемыми, то есть мы можем загрузить свой собственный код на видеокарту, чтобы применить именно те операции, которые нам нужны. Это позволяет использовать фрагментные шейдеры, например, для реализации чего угодно, от текстурирования и освещения до трассировщиков лучей. Эти программы работают на многих ядрах GPU одновременно для параллельной обработки множества объектов, таких как вершины и фрагменты. Некоторые из программируемых этапов являются необязательными в зависимости от того, что надо делать. Например, этапы тесселяции и геометрии можно отключить, если надо нарисовать простую геометрию.
Графический конвейер в Vulkan практически полностью неизменяем, поэтому если надо изменить шейдеры, привязать различные буферы кадров или изменить функцию смешивания, то придется заново создать конвейер с нуля. В итоге это может привести к тому, что придется создать несколько конвейеров, которые представляют все различные комбинации состояний для использования в операциях рендеринга. Но поскольку все операции конвейера заранее известны, драйвер может их оптимизировать.
В целом для построения графического конвейера нам необходимы следующие компоненты:
Этапы шейдера: модули шейдера, которые определяют функциональность программируемых стадий графического конвейера
Состояние фиксированных функций: все структуры, которые определяют стадии фиксированных функций конвейера, такие как ассемблер входных данных, растеризатор, область просмотра и смешивание цветов
Макет конвейера, значения, на которые ссылается шейдер и которые могут быть обновлены во время отрисовки
Проход рендеринга и прикрепления, на которые ссылаются стадии конвейера
Шейдеры являются основополагающими в современном графическом программировании и играют центральную роль в рендеринге на основе Vulkan. Шейдеры — это небольшие программы, выполняемые на GPU, отвечающие за различные аспекты рендеринга, включая обработку вершин, затенение фрагментов и многое другое. В Vulkan шейдеры являются важнейшими компонентами для создания графики и визуальных эффектов. Есть много различных типов шейдеров, из которых наиболее часто используются следующие:
Вершинный шейдер (Vertex Shader): брабатывает каждую вершину 3D-модели, применяя преобразования, расчеты освещения и многое другое. Он передает преобразованные вершины на следующий этап конвейера.
Фрагментный шейдер (также называют пиксельным шейдером)(Fragment Shader или Pixel Shader): вычисляет окончательный цвет каждого фрагмента (пикселя), который составляет примитив (например, треугольник). Он отвечает за применение текстур, освещения и других эффектов.
Геометрический шейдер (Geometry Shader): может создавать, изменять или отбрасывать геометрию. Это необязательный этап в конвейере, используемый для таких задач, как тесселяция, генерация теневого объема или систем частиц.
Вычислительный шейдер (Compute Shader): используется для вычислений на GPU общего назначения, позволяет выполнять задачи параллельной обработки, такие как физическое моделирование, обработка изображений и т.д.
Шейдеры в Vulkan обычно пишутся на специализированных языках шейдеров, таких как GLSL (OpenGL Shading Language) или SPIR-V (Standard Portable Intermediate Representation — двоичный формат Vulkan). GLSL представляет высокоуровневый язык с синтаксисом в стиле C, который легко писать и отлаживать. Он включает в себя множество возможностей для графического программирования, таких как встроенные векторные и матричные примитивы, встроенные функции для вычисления произведения матрицы и вектора, отражения вокруг вектора и т.д. SPIR-V же представляет двоичный формат байт-кода, который обеспечивает лучшую производительность и кроссплатформенную совместимость и предназначен для использования как с Vulkan, так и с OpenCL.
Хотя для написания шейдеров наиболее простым вариантом является использование GLSL, но проблема заключается в том, что Vulkan понимает только байт-код SPIR-V. Но, к счастью, необязательно
вручную писать этот байт-код. Компания Khronos выпустила свой собственный независимый от конкретных поставщиков GPU компилятор - программу glslangValidator, который компилирует GLSL в SPIR-V. Этот компилятор предназначен для проверки того,
что код шейдера полностью соответствует стандартам и создает один двоичный файл SPIR-V, который можно поставлять вместе со своей программой. Но нередко для компиляции шейдеров используется
специальная обертка - компилятор glslc от Google. Преимущество glslc в том, что он использует тот же формат параметров, что и известные компиляторы, такие как GCC и Clang,
и включает в себя некоторые дополнительные функции, такие как include. Оба они уже включены в Vulkan SDK, поэтому вам не нужно ничего загружать дополнительно (располагаются в папке x86_64/bin).
Но в целом можно применять как glslc, так и glslangValidator.
И таким образом, для написания шейдеров мы можем использовать более простой язык GLSL
Каждый шейдер Vulkan начинается с определения точки входа. В GLSL точкой входа обычно является функция main для каждого этапа шейдера, например, main для вершинного шейдера, main для фрагментного шейдера и т. д. Эти функции служат отправной точкой для выполнения при вызове шейдера:
void main() {
// код шейдера
}
Шейдеры взаимодействуют друг с другом и конвейером рендеринга через входные и выходные глобальные переменные. Для получения данных извне шейдер использует входные переменные (input variables), которые определяются с помощью ключевого слова in:
layout(location = 0) in vec3 position;
В данном случае определена входная переменная position, которая имеет тип vec3 (трехмерный вектор). А слово in указывает, что эти данные будут передаваться извне.
Синтаксис layout(location = ...) определяет расположение входных переменных, гарантируя их соответствие данным, предоставленным в буфере вершин.
Для возвращения данных шейдер использует выходные переменные (output variables), которые определяются с ключевым словом out:
out vec3 fragColor;
Здесь определяется выходная переменная fragColor, которая представляет тип vec3 и через которую шейдер будет возвращать результат своей работы.
Пример простейшего шейдера:
layout(location = 0) in vec3 inPosition; // получаем позицию
layout(location = 1) in vec3 inColor; // получаем цвет
out vec3 fragColor;
void main() {
gl_Position = vec4(inPosition, 1.0);
fragColor = inColor;
}
Здесь шейдер получает извне данные через два входных параметра - inPosition и inColor (условно позицию вершины и цвет). И в функции main устанавливает позицию вершины в глобальной встроенной переменной gl_Position. И устанавливает выходную
переменную fragColor.
Одним из базовых элементов GLSL является вектор, который представлен типом vec с числом, указывающим количество элементов.
Например, трехмерное положение будет храниться в vec3. Можно получить доступ к отдельным компонентам через поля .x, .y, .z.
Но также можно создать новый вектор из нескольких компонентов одновременно. Например, выражение:
vec3(1.0, 2.0, 3.0).xy
приведет к созданию двухмерного вектора - vec2. Конструкторы векторов также могут принимать комбинации векторных объектов и скалярных значений. Например, vec3 можно построить с помощью выражения:
vec3(vec2(1.0, 2.0), 3.0)
Вкратце рассмотрим основные типы шейдеров.
Вершинный шейдер (vertex shader) обрабатывает каждую входящую вершину. В качестве входных данных он принимает ее атрибуты - положение, цвет, нормаль и координаты текстуры. Выходными данными является конечная позиция в координатах отсечения (clip coordinate) и атрибуты, которые необходимо передать в фрагментный шейдер, такие как координаты цвета и текстуры. Затем эти значения будут интерполированы по фрагментам растеризатором для получения плавного градиента.
Координата отсечения (clip coordinate) представляет четырехмерный вектор (vec4) из вершинного шейдера, который впоследствии преобразуется в нормализованную координату устройства путем деления всего вектора на его последний компонент. Эти нормализованные координаты устройства являются однородными координатами, которые отображают буфер кадра в систему координат [-1, 1] на [-1, 1] следующего вида:
Пример вершинного шейдера:
#version 450
vec2 positions[3] = vec2[](
vec2(0.0, -0.5),
vec2(0.5, 0.5),
vec2(-0.5, 0.5)
);
void main() {
gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
}
Прежде всего здесь определен массив векторов vec2 - двухмерных векторов, которые представляют точку на плоскости. В массиве три вектора - по сути три точки, которые представляют условный треугольник.
Функция main вызывается для каждой вершины и устанавливает позицию каждой вершины в виде встроенной выходной глобальной переменной gl_Position.
Встроенная переменная gl_VertexIndex содержит индекс текущей вершины. Обычно это индекс в буфере вершин, но в данном случае это индекс в жестко закодированном массиве positions .
Положение каждой вершины в виде двухмерного вектора объединяется с фиктивными компонентами z и w для получения положения в координатах отсечения и передается в переменную gl_Position.
Фигура (например, треугольник), которая образутся с помощью позиций из вершинного шейдера, заполняет область на экране фрагментами. Фрагментный шейдер вызывается для этих фрагментов, чтобы определить цвет и глубину для буфера кадра. Простой фрагментный шейдер, который выводит красный цвет для всего треугольника, выглядит следующим образом:
#version 450
layout(location = 0) out vec4 fragColor;
void main() {
fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
Цвета в GLSL представляют собой 4-мерные векторы (vec4) с каналами R, G, B и альфа в диапазоне [0, 1]. В GLSL нет встроенной глобальной переменной для вывода цвета для текущего фрагмента.
Поэтому необходимо создать собственную выходную переменную для каждого буфера кадров, где модификатор layout(location = 0) указывает индекс буфера кадров, с которым связана выходная переменная.
И в данном случае определяется выходная переменная fragColor, через которую будет возвращаться цвет текущего фрагмента и которая связана с буфером кадров по индексу 0.
Функция main вызывается для каждого фрагмента. Значение vec4(1.0, 0.0, 0.0, 1.0) представляет красный цвет, который записывается в переменную fragColor.
После того, как мы рассмотрели самы базовые аспекты вершинного и фрагментого шейдеров, рассмотрим как их компилировать. Для этого создадим в папке проекта новый каталог "shaders". Далее создадим в нем файл shader.vert со следующим кодом:
#version 450
layout(location = 0) out vec3 fragColor;
vec2 positions[3] = vec2[](
vec2(0.0, -0.5),
vec2(0.5, 0.5),
vec2(-0.5, 0.5)
);
vec3 colors[3] = vec3[](
vec3(1.0, 0.0, 0.0),
vec3(0.0, 1.0, 0.0),
vec3(0.0, 0.0, 1.0)
);
void main() {
gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
fragColor = colors[gl_VertexIndex];
}
Здесь определен вершинный шейдер, который будет описывать треугольник. Вершины треугольника представлены двухмерными векторами из массива positions. В массиве colors хранятся цвета для каждой вершины. Поскольку обычное определение цвета представляет три компоненты: R (компонента красного цвета), G (зеленого цвета) и B (синий цвет), то для описания цвета каждой из трех вершин применяется трехмерный вектор.
В функции main устанавливаем позицию для каждой вершины и передаем ее через gl_Position, а через выходную переменную fragColor возвращаем цвет вершины из массива colors.
И также в папке "shaders" определим новый файл shader.frag, где будет располагаться фрагментный шейдер:
#version 450
layout(location = 0) in vec3 fragColor;
layout(location = 0) out vec4 outColor;
void main() {
outColor = vec4(fragColor, 1.0);
}
Здесь через fragColor получаем из вершинного шейдера настройки цвета вершины и используем эту переменную для установки финального цвета фрагмента, возвращаемого через outColor.
Теперь скомпилируем шейдеры. Компиляция шейдеров — это процесс перевода GLSL (или других языков шейдеров) в байт-код SPIR-V. Этот байт-код можно загрузить в Vulkan и использовать в модулях шейдеров. Пример компиляции с помощью утилиты glslangValidator:
glslangValidator -V shader.vert -o shader.vert.spv
Но в данном случае используем обертку над этой программой - утилиту от Google glslc, которая также доступна в составе Vulkan SDK и которая упрощает компиляцию. На Linux компиляция выглядит следующим образом:
glslc shader.vert -o vert.spv glslc shader.frag -o frag.spv
Компилятору передается путь к файлу шейдера, а с помощью опции -o указывается путь к генерируемому файлу. Таким образом, вершинный шейдер компилируется в файл vert.spv, а фрагментный шейдер - в файл frag.spv.
Для Windows команды на компиляцию выглядят следующим образом:
C:/VulkanSDK/x.x.x.x/Bin/glslc.exe shader.vert -o vert.spv C:/VulkanSDK/x.x.x.x/Bin/glslc.exe shader.frag -o frag.spv
В данном случае "C:/VulkanSDK/x.x.x.x/Bin" - это путь к утилите glslc.exe в папке Vulkan SDK.
Но по идее при установке Vulkan SDK пути к этой папке должны автоматически добавляться в переменные среды, поэтому можно не указывать полный путь:
glslc shader.vert -o vert.spv glslc shader.frag -o frag.spv
Для загрузки шейдерой мы можем определить вспомогательную функцию:
#include <fstream>
static std::vector<char> readFile(const std::string& filename) {
std::ifstream file(filename, std::ios::ate | std::ios::binary);
// открываем файл
if (!file.is_open()) {
throw std::runtime_error("Не удалось загрузить файл!");
}
size_t fileSize = (size_t) file.tellg(); // получаем размер
std::vector<char> buffer(fileSize); // определяем буфер для считывания
file.seekg(0); // перемещаемся в начало файла
file.read(buffer.data(), fileSize); // считываем файл в буфер
file.close(); // закрываем поток файла
return buffer;
}
Функция readFile считывает все байты из указанного файла и возвращает их в виде вектора байтов. Для этого определяем объект std::ifstream, в конструктор которого передаем
имя файла и флаги std::ios::ate (начать чтение с конца файла) и std::ios::binary (считать файл как двоичный файл). И далее открываем файл, получаем его размер, создаем буфер данного размера и считываем данные в этот буфер.
Затем при необходимости мы можем использовать эту функцию для загрузки скомпилированных шейдеров в программе:
void createGraphicsPipeline() {
auto vertShaderCode = readFile("shaders/vert.spv");
auto fragShaderCode = readFile("shaders/frag.spv");
}