Шейдеры

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

Шейдеры

Каждая современная видеокарта содержит графические процессор. Но обычные программы, которые пишутся на языках вроде C++, выполняются на центральном процессоре.

Для того чтобы гибко использовать ресурсы видеокарты, OpenGL позволяет выполнять на графическом процессоре мини-программы, написанные на специальном языке программирования. Такие программы называются шейдеры. Шейдеры встраиваются в графический конвейер, и должны соблюдать определенные соглашения о входных и выходных данных.

Существует несколько языков программирования шейдеров. Для OpenGL «родным» является GLSL (OpenGL Shading Language).

Существует 5 мест в графическом конвейере, куда могут быть встроены шейдеры. Соответственно шейдеры делятся на типы:

  • вершинный (vertex)
  • фрагментный (fragment)
  • геометрический (geometry)
  • 2 тесселяционных шейдера (tesselation), отвечающие за 2 разных этапа тесселяции

Дополнительно существуют вычислительные (compute) шейдеры, который выполняются независимо от графического конвейера.

Разные шаги графического конвейера накладывают разные ограничения на работу шейдеров. Поэтому у каждого типа шейдеров есть своя специфика.

Геометрический и тессяляционные шейдеры не являются обязательными. Современный OpenGL требует наличия только вершинного и фрагментного шейдера. Хотя существует сценарий, при котором фрагментный шейдер может отсутствовать.

Перед использованием шейдеры должны быть слинкованы в шейдерную программу.

Сначала рассмотрим, как шейдеры используются из основной программы на C++, а затем ниже разберем, как пишутся сами шейдеры на языке GLSL.

Компиляция, линковка и использование шейдеров

Сначала нужно создать новый (пустой) шейдерный объект и получить его идентификатор:

GLuint vs = glCreateShader(GL_VERTEX_SHADER);

Для создания вершинного шейдера указывается константа GL_VERTEX_SHADER, для фрагментного - GL_FRAGMENT_SHADER.

Далее в шейдерный объект копируется текст шейдера на языке GLSL. Текст шейдера может быть разбит на несколько строк. В данном примере одна строка:

const char* vertexShaderText = "строка с текстом шейдера на языке GLSL";

glShaderSource(vs, 1, &vertexShaderText, NULL);

Компилируем шейдер:

glCompileShader(vs);

Далее рекомендуется выполнить проверку на возможные ошибки компиляции:

//В переменную status будет записан код статуса компиляции
int status = -1;
glGetShaderiv(vs, GL_COMPILE_STATUS, &status);
if (status != GL_TRUE)
{
    //Запрашиваем длину строки с описанием ошибки
    GLint errorLength;
    glGetShaderiv(vs, GL_INFO_LOG_LENGTH, &errorLength);

    //Выделяем память под строку с описанием ошибки
    std::vector<char> errorMessage;
    errorMessage.resize(errorLength);

    //Получаем строку с описанием ошибки
    glGetShaderInfoLog(vs, errorLength, 0, errorMessage.data());

    //Сообщение пользователю
    std::cerr << "Failed to compile the shader:\n" << errorMessage.data() << std::endl;
}

Аналогично делается для фрагментного шейдера.

После этого нужно собрать всё в шейдерную программу и слинковать её:

//Создаем новую (пустую) шейдерную программу
GLuint program = glCreateProgram();

//Прикрепляем шейдерные объекты
glAttachShader(program, vs); //вершинный
glAttachShader(program, fs); //фрагментный

//Линкуем программу
glLinkProgram(program);

//Проверяем ошибки линковки
status = -1;
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status != GL_TRUE)
{
    //Запрашиваем длину строки с описанием ошибки
    GLint errorLength;
    glGetProgramiv(program, GL_INFO_LOG_LENGTH, &errorLength);

    //Выделяем память под строку с описанием ошибки
    std::vector<char> errorMessage;
    errorMessage.resize(errorLength);

    //Получаем строку с описанием ошибки
    glGetProgramInfoLog(program, errorLength, 0, errorMessage.data());

    //Сообщение пользователю
    std::cerr << "Failed to link the program:\n" << errorMessage.data() << std::endl;
}

Перед использованием шейдерной программы нужно её активировать командой glUseProgram:

glUseProgram(program);

glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, 3);

Для рендеринга разных 3D-моделей можно использовать разные шейдерные программы. Рекомендуется минимизировать переключения между шейдерными программами во время рендеринга одного кадра.

Язык GLSL

Язык GLSL очень похож на язык C/C++: объявления переменных, условия, циклы, функции, препроцессор.

Отличия: нет указателей и ссылок, нет строк, нет классов и ООП (но есть структуры и массивы).

Базовые типы данных: bool, int, uint, float, double.

Важное замечание: использовать тип double не рекомендуется, т.к. операции с ним значительно медленнее, чем с типом float.

Поскольку GLSL предназначен для 3D-вычислений, то в язык встроены производные типы данных: векторные и матричные:

  • vec2 - вектор из 2х-компонент типа float
  • vec3 - вектор из 3х-компонент типа float
  • vec4 - вектор из 4х-компонент типа float

Аналогично dvec2, dvec3, dvec4, ivec2, ivec3, ivec4, uvec2, uvec3, uvec4, bvec2, bvec3, bvec4 - вектора с компонентами типов double, int, uint, bool .

  • mat2 - матрица 2х2 с компонентами типа float
  • mat3 - матрица 3х3 с компонентами типа float
  • mat4 - матрица 4х4 с компонентами типа float

Также есть матрицы mat2x3, mat2x4, mat3x2, mat3x4, mat4x2, mat4x3 с компонентами типа float. Первое число - количество столбцов, второе - количество строк.

Аналогично типы dmat2, dmat3, dmat4, dmat2x3, dmat2x4, dmat3x2, dmat3x4, dmat4x2, dmat4x3 - матрицы с компонентами типа double.

Для инициализации векторов и матриц существует множество конструкторов на все случаи жизни. Можно передавать данные в конструктор, почти любые комбинации чисел и векторов. Вот несколько примеров:

vec4 v1 = vec4(3.0); //Все компоненты вектора будут равны 3.0

vec4 v2 = vec4(1.0, 5.0, 3.0, 8.0);

vec4 v3 = vec4(vec3(1.0, 5.0, 3.0), 8.0);  //Компоненты вектора будут равны (1.0, 5.0, 3.0, 8.0)

vec4 v4 = vec4(vec2(1.0, 5.0), vec2(3.0, 8.0)); //Компоненты вектора будут равны (1.0, 5.0, 3.0, 8.0)

mat2 m1 = mat2(5.0); //Диагональные элементы будут равны 5.0, остальные - нули

mat2 m2 = mat2(vec2(3.0, 5.0), vec2(1.0, 8.0)); //Первый и второй столбцы матрицы

К элементам вектора можно обращаться как к элементам массива:

vec4 v;

v[0] = 2.0;
v[2] = 3.0;

Также к элементам вектора можно обращаться, как к полям структуры. Причем существует 3 равнозначные группы полей:

x, y, z, w используются, если вектор содержит пространственные координаты или нормаль
r, g, b, a используются, если вектор содержит цвет
s, t, p, q используется, если вектор содержит текстурные координаты

Т.е. выражения v[0], v.x, v.r, и v.s эквивалентны. Это нужно для облегчения чтения текста шейдера. Когда читатель видит v.r, он понимает, что вектор v содержит цвет.

GLSL позволяет одновременно обращаться сразу к нескольким полям как на чтение, так и на запись:

vec4 v;

v.xy = 3.0; //Присваивает 3.0 первым двум элементам вектора

vec2 v2 = v.zx; //Инициализирует новый вектор, используя 2ю и 0ю компоненты вектора

v.yw = v2; //Использует компоненты вектора v2 для присвоения значений 1й и 3й компонентам вектора v

v.xy = v.yx; //Меняет местами первые 2 компоненты вектора

Большинство операций с векторами - поэлементные. Векторы складываются и умножаются поэлементно:

vec4 u, v;

vec4 w = u * v; //Поэлементное умножение

Функции в GLSL устроены аналогично C/C++. Из функции можно вернуть значение заданного типа, либо void. Но иногда необходимо передать значение обратно через аргументы. Поскольку в GLSL нет указателей и ссылок, то применяются модификаторы out и inout.

  • out означает, что переменная будет проинициализирована в функции и потом скопирована наружу.
  • inout означает, что переменная будет скопирована в функцию, там возможно будет изменена, и потом скопирована наружу.

Пример:

void functionName(type1 var1, inout type2 var2, out type3 var3)
{
}

В языке GLSL определен ряд встроенных функций. Полный список можно посмотреть в спецификации.

В целом структура любого шейдера выглядит следующим образом:

#version 330 //Используемая версия языка GLSL (обязательно)

//Блок объявления юниформ, входных, выходных переменных и дополнительных функций

void main() //Точка входа в шейдер
{

}

Юниформ-переменные

В шейдере можно объявлять константы, которые будут иметь фиксированное значение:

const vec3 color = vec3(0.5, 0.75, 1.0);

Но можно также объявлять константы, которые будут проинициализированы основной программой до запуска шейдеров. Такие константы называют юниформ-переменными. Они объявляются вне тел функций с помощью модификатора uniform:

uniform float time;
uniform mat4 matrix;

Каждая юниформ-переменная имеет свой номер (location). Если номер не указан, то он назначается автоматически при линковке шейдерной программы. Вручную номер можно указать так:

layout(location = 0) uniform float time;
layout(location = 1) uniform mat4 matrix;

В основной программе значение юниформ-переменной задается по её номеру. Если номер не известен, то его можно запросить командой glGetUniformLocation:

//Получаем номер юниформ-переменной в шейдерной программе (program) по её имени (time)
GLint uniformLoc = glGetUniformLocation(program, "time");

//Устанавливаем нужное значение типа float
glUniform1f(uniformLoc, value);

Примеры задания значений векторных и матричных юниформ-переменных будут в следующих частях.

Вершинный шейдер

Вершинный шейдер работает на первом шаге графического конвейера. Шейдер обрабатывает одну вершину за раз: принимает на вход атрибуты вершины, и выдает новые значения, которые передаются на следующие шаги конвейера. Благодаря этому возможно параллельно обрабатывать несколько вершин на ядрах графического процессора.

Задача вершинного шейдера - подготовить данные для следующих шагов графического конвейера. Эта подготовка включает обработку вершинных атрибутов.

Входные переменные объявляются с модификатором in. Они должны соответствовать вершинным атрибутам, которые настраивались функцией glVertexAttribPointer: количество атрибутов, количество компонентов у каждого атрибута, типы данных. Подробнее об этом в предыдущей части.

layout(location = 0) in vec3 vertexPosition; //координаты вершины в локальной системе координат
layout(location = 1) in vec4 vertexColor; //цвет вершины

Каждый атрибут в шейдере имеет свой номер (location). Его можно:

  • задать вручную в коде шейдера (как в примере выше)
  • задать в основной программе с помощью функции glBindAttribLocation
  • он может быть назначен автоматически при линковке шейдерной программы

Номер атрибута указывается первым аргументом команды glVertexAttribPointer. Если номер атрибута не известен, то его можно запросить командой glGetAttribLocation.

GLint location = glGetAttribLocation(program, "vertexPosition");
glVertexAttribPointer(location, 3, GL_FLOAT, GL_FALSE, 0, 0);

Выходные переменные объявляются с модификатором out.

out vec4 color; //выходной цвет вершины

Также существует одна встроенная выходная переменная gl_Position, неявно определенная так:

out vec4 gl_Position;

Вершинный шейдер должен записать в эту переменную преобразованные координаты вершины. Подробнее об этом в следующей части.

Простейший вершинный шейдер просто копирует выходные значения на выход:

#version 330

uniform mat4 matrix;

layout(location = 0) in vec3 vertexPosition; //координаты вершины в локальной системе координат
layout(location = 1) in vec4 vertexColor; //цвет вершины

out vec4 color; //выходной цвет вершины

void main()
{
    color = vertexColor; //копируем вход на выход

    gl_Position = matrix * vec4(vertexPosition, 1.0); //преобразуем координаты в новую систему координат
}

Фрагментный шейдер

Фрагментный шейдер выполняется после растеризации - разбиения треугольника на фрагменты. Шейдер обрабатывает один фрагмент за раз. Благодаря этому возможно параллельно обрабатывать несколько фрагментов на ядрах графического процессора.

Задача шейдера - вычислить цвет фрагмента. При этом шейдер принимает на вход значения, которые были получены путем интерполирования выходных значений вершинного шейдера.

Имена и типы должны совпадать у выходных переменных вершинного шейдера и входных переменных фрагментного шейдера.

in vec4 color; //интерполированный цвет вершин

Можно выбирать из 3х вариантов интерполяции путем указания специального модификатора:

  • flat - интерполяция не производится, выходные значения одной из вершин присваиваются всем фрагментам треугольника;
  • noperspective - интерполяция производится в экранной системе координат;
  • smooth - интерполяция производится с учетом эффекта перспективы (это значение по умолчанию).

Модификатор должен быть указан перед объявлением переменной в обоих шейдерах (вершинном и фрагментном):

flat in vec4 color;

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

in vec4 gl_FragCoord;
in bool gl_FrontFacing;

Переменная gl_FragCoord содержит координаты центра текущего фрагмента в экранной системе координат. Переменная gl_FrontFacing принимает значение true, если фрагмент был получен при растеризации лицевой грани треугольника.

Шейдер должен записать выходной цвет фрагмента в выходную переменную. Название может быть произвольным:

out vec4 fragColor; //выходной цвет фрагмента

Есть также неявная встроенная переменная gl_FragDepth:

out float gl_FragDepth;

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

Также шейдер может забраковать фрагмент по некоторому условию путем указания ключевого слова discard:

if (условие)
{
    discard;
}

Забракованный фрагмент отбрасывается и далее не обрабатывается.

Простейший фрагментный шейдер выглядит следующим образом:

#version 330

in vec4 color; //интерполированный цвет вершин

out vec4 fragColor; //выходной цвет фрагмента

void main()
{
    fragColor = color; //копируем вход на выход
}

Uniform Buffer Object

Часто приходится работать с большим числом юниформ-переменных, сгруппированных в логические блоки. Разные группы могут обновляться с разной частотой. Некоторые каждый кадр, некоторые реже. Разные шейдерные программы могут иметь одинаковые юниформ-переменные.

Чтобы ускорить копирование юниформ-перменных в оперативную память, а также, чтобы можно было использовать одни и те же значения в разных шейдерных программах, применяют Uniform Buffer Object (UBO).

В шейдере UBO задается в виде блока юниформ-переменных, например, так:

layout (std140) uniform Matrices
{
  mat4 viewMatrix;
  mat4 projectionMatrix;
};

Этот блок содержит 2 переменные и имеет название Matrices. Параметр std140 задает схему выравнивания переменных внутри блока. Есть разные схемы. Некоторые зависят от реализации. Схема std140 не зависит от конкретной реализации, она описана в спецификации OpenGL.

В основной программе необходимо создать буфер и заполнить его данными. Тип буфера GL_UNIFORM_BUFFER:

GLuint ubo;
glGenBuffers(1, &ubo);
glBindBuffer(GL_UNIFORM_BUFFER, ubo);
glBufferData(GL_UNIFORM_BUFFER, sizeof(camera), 0, GL_DYNAMIC_DRAW);
glBindBuffer(GL_UNIFORM_BUFFER, 0);

Здесь используется переменная camera, которая определяется так:

struct CameraInfo
{
    glm::mat4 viewMatrix;
    glm::mat4 projMatrix;
};

CameraInfo camera;

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

glBindBuffer(GL_UNIFORM_BUFFER, ubo);
glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(camera), &camera);

Функция glBufferSubData не выделяет новую память на видеокарте, а обновляет содержимое существующего участка памяти.

Теперь осталось только связать буфер, заданный переменной ubo, и блок с именем Matrices в шейдере. Для этого применяется 2хступенчатая схема. Вводится набор возможных точек привязки 0, 1, 2, … Буфер ubo привязывается к некоторой выбранной точке привязки. Далее номер точки привязки привязывается к юниформ-блоку. Для этого нужно сначала получить индекс (номер) юниформ-блока в шейдере:

//Привязываем ubo к 0й точке привязки
glBindBufferBase(GL_UNIFORM_BUFFER, 0, ubo);

//Получаем индекс юниформ-блока в шейдере по имени Matrices
unsigned int blockIndex = glGetUniformBlockIndex(program, "Matrices");

//Связываем точку привязки 0 и юниформ-блок
glUniformBlockBinding(program, blockIndex, 0);

Вот и всё!

Но что делать, если блок содержит большое число переменных, или схема выравнивания не std140. В этом случае, чтобы заполнить буфер значениями, нужно получить сдвиги отдельных юниформ-переменных:

//Имена переменных внутри блока
const char* names[2] =
{
    "viewMatrix",
    "projectionMatrix"
};

//Сначала запрашиваем индексы для 2х юниформ-переменных
GLuint index[2];
glGetUniformIndices(program, 2, names, index);

//Зная индексы, запрашиваем сдвиги для 2х юниформ-переменных
GLint offset[2];
glGetActiveUniformsiv(program, 2, index, GL_UNIFORM_OFFSET, offset);

Если нужно узнать требуемый размер буфера:

//Получаем индекс юниформ-блока в шейдере по имени Matrices
unsigned int blockIndex = glGetUniformBlockIndex(program, "Matrices");

//Запрашиваем размер блока, зная индекс блока
GLint blockSize;
glGetActiveUniformBlockiv(program, blockIndex, GL_UNIFORM_BLOCK_DATA_SIZE, &blockSize);

Теперь можно заполнить буфер значениям:

std::vector<GLubyte> buffer;
buffer.resize(blockSize);

//Заполняем буфер данными, зная сдвиги
memcpy(buffer.data() + offset[0], &camera.viewMatrix, sizeof(camera.viewMatrix));
memcpy(buffer.data() + offset[1], &camera.projectionMatrix, sizeof(camera.projectionMatrix));

//Обновляем содержимое буфера на видеокарте
glBindBuffer(GL_UNIFORM_BUFFER, ubo);
glBufferSubData(GL_UNIFORM_BUFFER, 0, blockSize, buffer.data());