Урок №5. Шейдеры в OpenGL

  Дмитрий Бушуев  | 

  Обновл. 9 Сен 2021  | 

 37279

 ǀ   7 

Как уже упоминалось на предыдущем уроке, шейдеры — это небольшие программы, «живущие» в графическом процессоре. Данные программы выполняются на каждом отдельно взятом участке графического конвейера. В более широком смысле этого слова, шейдеры — это программы, которые конвертируют входные данные в выходные. При этом шейдеры являются изолированными программами, т.к. им не разрешается общаться друг с другом; единственная связь, которую они имеют — это их входы и выходы.

На предыдущем уроке мы лишь кратко коснулись темы шейдеров и того, как правильно их использовать. Теперь же мы изучим их более подробно и, в частности, рассмотрим язык программирования шейдеров GLSL.

Язык программирования GLSL

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

Код шейдеров всегда начинается с объявления их версии, за которым следуют списки входных/выходных переменных, uniform-переменных и функции main(). Отправной точкой каждого шейдера является его функция main(), в которой происходит обработка всех входных переменных и вывод результата работы через выходные переменные. Не переживайте, если вы сейчас не понимаете, что такое «uniform-переменные», мы к ним перейдем несколько позже.

Шейдер обычно имеет следующую структуру:

Когда речь заходит конкретно о вершинном шейдере, то принято каждую его входную переменную называть вершинным атрибутом (англ. «vertex attribute»). Максимальное количество таких вершинных атрибутов, которые мы можем объявить, ограничено аппаратными средствами компьютера. Спецификация OpenGL гарантирует нам возможность работы с шестнадцатью 4-компонентыми вершинными атрибутами, но при этом стоит учитывать тот факт, что некоторое компьютерное железо может допускать возможность работы и с гораздо большим количеством вершинных атрибутов. Чтобы узнать их точное количество, необходимо вызвать функцию glGetIntegerv(), указав в качестве параметра GL_MAX_VERTEX_ATTRIBS:

В большинстве случаев минимальным возвращаемым значением является число 16, и этого более чем достаточно для выполнения большинства задач.

Типы данных в GLSL


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

   int;

   float;

   double;

   unsigned int;

   bool.

Также, GLSL содержит два типа контейнеров — матрицы и векторы, которые мы часто будем использовать по ходу этих уроков. Матрицы мы обсудим несколько позже, а сейчас предлагаю перейти к рассмотрению векторов.

Векторы

Вектор в GLSL — это контейнер, содержащий до 4 элементов базовых типов данных, о которых мы только что говорили. Данный контейнер может иметь следующий вид (n — это количество компонент):

   vecn — вектор по умолчанию из n элементов типа float;

   bvecn — вектор из n элементов типа bool;

   ivecn — вектор из n элементов типа int;

   uvecn — вектор из n элементов типа unsigned int;

   dvecn — вектор из n элементов типа double.

Большую часть времени мы будем использовать базовый тип vecn, так как возможностей типа float хватает для большинства наших задач.

Доступ к компонентам вектора можно получить при помощи выражения vec.x, где x — это первая компонента вектора. Вы также можете использовать vec.y, vec.z, vec.w для того, чтобы получить доступ ко второй, третьей и четвертой компоненте, соответственно. Также GLSL позволяет использовать rgba для указания цвета и stpq для указания координат текстур, получая при этом доступ к тем же компонентам.

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

Вы можете использовать любую комбинацию до 4 букв для создания нового вектора того же типа данных до тех пор, пока в исходном векторе присутствуют данные компоненты; например, мы не можем получить доступ к компоненте .z в векторе типа vec2, так как он содержит всего 2 компоненты — .x и .y.

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

Таким образом, векторы являются примером гибкого типа данных, который мы можем использовать для всех видов операций ввода и вывода данных. На протяжении данных уроков вы увидите множество примеров того, как изобретательно управлять векторами.

Входы и Выходы


Сами по себе шейдеры — это миленькие маленькие программки, которые при этом являются частью чего-то большего. Именно поэтому, чтобы мы могли перемещать наш рабочий материал от одного шейдера к другому, у каждого отдельно взятого шейдера должны быть вход и выход. Специально для этого в GLSL определено два ключевых слова — in и out. Используя данные ключевые слова, каждый шейдер может задавать для себя входы и выходы, и везде, где выходная переменная будет совпадать с входной переменной следующего шейдера, данные смогут передаваться. Однако для вершинного и фрагментного шейдеров существуют некоторые отличия.

Вершинный шейдер должен получать некоторую информацию о структуре входных данных, иначе он будет довольно неэффективен. Отличительной особенностью входных данных вершинного шейдера является то, что он их получает непосредственно из данных вершины. Чтобы определить, как организованы данные вершин, мы указываем входные переменные с метаданными местоположения, чтобы мы могли настроить атрибуты вершин, которые находятся в центральном процессоре. Для этого вершинный шейдер, как было упомянуто на предыдущем уроке, запрашивает дополнительную layout-спецификацию (location = 0) для своих входных данных, чтобы их можно было связать с данными вершин.

Примечание: Стоит отметить, что спецификатор layout (location = 0) можно опустить и запросить расположение атрибутов непосредственно в вашем OpenGL-коде с помощью функции glGetAttribLocation(), но более предпочтительным способом является задание атрибутов именно в вершинном шейдере. В этом случае код остается более понятным, избавляя вас (и OpenGL) от лишней работы.

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

Поэтому, если мы хотим отправить данные из одного шейдера в другой, мы должны объявить выходные данные в отправляющем шейдере и такие же входные данные в принимающем шейдере. При совпадении типов и имен переменных передающей и принимающей сторон OpenGL связывает эти переменные между собой, и тогда данные можно будет пересылать между шейдерами (это делается при связывании программного объекта). Чтобы показать вам, как это работает на практике, мы изменим шейдеры предыдущего урока, чтобы позволить вершинному шейдеру задавать цвет для фрагментного шейдера.

Вот вершинный шейдер:

Вот фрагментный шейдер:

После внесения данных изменений исходный код вашей программы (файл main.cpp) должен быть примерно следующий:

Вы можете видеть, что мы объявили переменную vertexColor типа vec4 в качестве выходной переменной вершинного шейдера. И переменную с таким же именем vertexColor мы объявили в качестве входящей переменной для фрагментного шейдера. Поскольку обе переменные имеют одинаковый тип и имя, то теперь переменная vertexColor во фрагментном шейдере будет связана с переменной vertexColor в вершинном шейдере. А так как в вершинном шейдере мы устанавливаем для этой переменной темно-красный цвет, то и результирующие фрагменты также должны быть темно-красными.

Результат работы шейдеров:

Так держать! Нам только что удалось отправить значение из вершинного шейдера во фрагментный шейдер. Давайте немного оживим треугольник и посмотрим, сможем ли мы отправить цвет из нашего приложения во фрагментный шейдер!

uniform-переменные

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

Чтобы объявить uniform-переменную в GLSL, мы просто добавляем в шейдер ключевое слово uniform с типом и именем переменной. И начиная с этого момента, мы можем использовать в шейдере объявленную uniform-переменную. Давайте посмотрим, получится ли у нас установить цвет треугольника через uniform-переменную:

Разберем этот код детально. Во фрагментном шейдере мы объявили uniform-переменную ourColor типа vec4, и теперь выходной цвет фрагментного шейдера зависит от значения данной переменной. Поскольку uniform-переменные являются глобальными переменными, то мы можем определить их на любом этапе шейдера, а это означает, что нет необходимости снова проходить через вершинный шейдер для получения промежуточного результата, который затем будет передан во фрагментный шейдер. Так как мы не используем эту uniform-переменную в вершинном шейдере, то и нет необходимости её там определять.

Примечание: Если вы объявите uniform-переменную, которая в вашем GLSL-коде нигде не используется, то компилятор автоматически удалит данную переменную из скомпилированной версии, что может привести к появлению неприятных ошибок; имейте это в виду!

uniform-переменная в настоящее время является пустой; мы еще не добавили никаких данных в нее, так что давайте попробуем это сделать. Сначала нам нужно найти индекс/местоположение uniform-переменной в нашем шейдере. Как только это у нас будет, то мы сможем обновить её значение. Вместо того, чтобы передавать конкретно выбранный цвет во фрагментный шейдер, давайте попробуем постепенно менять цвет:

В начале, с помощью функции glfwGetTime(), мы получаем в секундах время, прошедшее с момента старта программы. Затем мы меняем цвет в диапазоне от 0.0 до 1.0 с помощью тригонометрической функции sin() и сохраняем результат в greenValue.

Затем мы запрашиваем местоположение uniform-переменной ourColor, используя функцию glGetUniformLocation(), передавая ей в качестве аргументов шейдерную программу и имя uniform-переменной (чьё местоположение мы и ищем). Если функция glGetUniformLocation() возвращает значение -1, то это означает, что она не может найти данное местоположение. После того, как местоположение будет найдено, мы сможем установить значение для uniform-переменной с помощью функции glUniform4f(). Обратите внимание, что для поиска местоположения uniform-переменной не требуется предварительно использовать шейдерную программу, но обновление значения uniform-переменной требует сначала использовать шейдерную программу (вызовом функции glUseProgram()), поскольку glUniform4f() устанавливает значение для uniform-переменной только для активной в данный момент шейдерной программы.

Примечание: Так как OpenGL по своей сути является библиотекой, написанной на языке Си, то у нее нет собственной поддержки перегрузки функций, поэтому везде, где функция может быть вызвана с различными типами переменных, OpenGL определяет новые функции для каждого требуемого типа; функция glUniform() является прекрасным примером этого. Данная функция во время её применения требует указания определенного постфикса для определения типа uniform-переменной, значение которой вы хотите задать/изменить. Ниже приведен список из некоторых возможных постфиксов:

   f — функция ожидает значение типа float;

   i — функция ожидает значение типа int;

   ui — функция ожидает значение типа unsigned int;

   3f — функция ожидает три значения типа float;

   fv — функция ожидает значение типа вектор/массив, элементы которого имеют тип float.

Всякий раз, когда вы хотите настроить какой-либо параметр OpenGL, просто выберите перегруженную функцию, соответствующую вашему типу. В нашем случае мы хотим установить 4 значения типа float, поэтому мы передаем наши данные через функцию glUniform4f() (обратите внимание, что также можно было бы использовать и тип fv).

Теперь, когда мы уже знаем, как устанавливать значения uniform-переменных, можно попробовать использовать их для визуализации. Как вы помните, нашей задачей является постепенное изменение цвета треугольника, для этого данную uniform-переменную нужно обновлять каждый кадр, иначе треугольник будет иметь только один цвет. Таким образом, мы должны вычислять переменную greenValue и обновлять значение uniform-переменной на каждой итерации рендеринга:

Этот код является относительно простой адаптацией предыдущего кода. Только на этот раз, прежде чем нарисовать треугольник, мы в каждом кадре обновляем значение uniform-переменной. Если вы всё сделали правильно, то увидите, что цвет вашего треугольника постепенно меняется с зеленого на черный и наоборот:

Как вы можете видеть, uniform-переменные — это полезный инструмент для установки атрибутов, которые могут изменяться каждый кадр, или для обмена данными между вашим приложением и шейдерами. Но что делать, если мы хотим установить отдельный цвет для каждой вершины? В таком случае нам придется объявить столько uniform-переменных, сколько у нас имеется вершин. Гораздо эффективнее было бы добавить некоторую информацию в дополнение к уже содержащимся в атрибутах вершин данным. Чем мы сейчас и займемся.

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

Полный исходный код программы

Дополнительные атрибуты


На предыдущем уроке мы видели, как можно заполнить VBO, настроить указатели вершинных атрибутов и сохранить всё это в VAO. На этот раз мы хотим к данным вершин добавить цветовые данные в виде трех дополнительных значений типа float. Мы присваиваем красный, зеленый и синий цвета каждому из углов нашего треугольника:

Поскольку данных, отправляемых в вершинный шейдер, у нас стало больше, то теперь необходимо настроить вершинный шейдер, чтобы он также получал наше значение цвета в качестве входного вершинного атрибута. Обратите внимание, что мы с помощью layout-спецификатора установили расположение атрибута aColor равным 1:

Поскольку мы теперь не используем uniform-переменную для указания цвета фрагмента, а используем выходную переменную ourColor, то нам придется внести изменения и во фрагментный шейдер:

Так как мы добавили еще один атрибут вершины и обновили память VBO, то мы должны перенастроить указатели вершинных атрибутов. Обновленные данные в памяти VBO теперь выглядят примерно следующим образом:

Зная текущую компоновку элементов, мы можем обновить формат вершин с помощью функции glVertexAttribPointer():

Первые несколько аргументов функции glVertexAttribPointer() относительно просты. На этот раз вершинный атрибут мы устанавливаем в позицию под номером 1. Значения цвета имеют размер 3 * float и они не нуждаются в нормализации.

Поскольку теперь у нас есть два атрибута вершин, то мы должны пересчитать значение шага. Чтобы получить следующее значение атрибута (например, следующий компонент x вектора координат) в массиве данных, мы должны переместиться на расстояние 6 элементов типа float (три — для значений координат и три — для значений цвета). Это дает нам величину шага в 6 * sizeof(float) (равное 24 байтам). Кроме того, на этот раз мы должны указать смещение. Для каждой вершины первыми идут данные её координат, поэтому мы объявляем смещение равным 0. Затем идут данные, задающие цвет вершины, располагающиеся следом за данными координат. Исходя из этого, мы получаем величину смещения равную 3 * sizeof (float) байтам (т.е. 12 байт).

Запуск приложения должен привести к следующему результату:

Полный исходный код данного примера

Изображение может быть не совсем таким, как ожидается, ведь мы задали только 3 цвета, а не огромную цветовую палитру, которую прямо сейчас видим на картинке. Всё это является результатом так называемой фрагментарной интерполяции во фрагментном шейдере. При рендеринге треугольника стадия растеризации обычно приводит к появлению гораздо большего количества фрагментов, чем изначально заданных вершин. Затем растеризатор определяет положение каждого из этих фрагментов в зависимости от того, где они находятся на фигуре треугольника. Основываясь на этих позициях, он интерполирует все входные переменные фрагментного шейдера. В качестве примера предположим, что у нас есть вертикальная линия, где верхняя точка (отметим её как 100%) имеет зеленый цвет, а нижняя (отметим её как 0%) — синий. Если фрагментный шейдер выполняется на фрагменте, который находится где-то в районе позиции 70% линии, то его итоговый входной атрибут цвета будет представлять собой линейную комбинацию зеленого и синего цветов (если быть более точным, то 30% синего и 70% зеленого).

Именно это и произошло в треугольнике. У нас есть 3 вершины и, следовательно, 3 цвета, и, судя по пикселям треугольника, он, вероятно, содержит около 50 000 фрагментов, где фрагментный шейдер интерполировал цвета между этими пикселями. Если вы внимательно посмотрите на цвета, то увидите, что всё это похоже на правду: смена цвета от красного к синему проходит через промежуточный фиолетовый цвет. Интерполяция фрагментов применяется ко всем входным атрибутам фрагментного шейдера.

Наш собственный шейдерный класс

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

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

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

Шейдерный класс содержит идентификатор шейдерной программы. Его конструктор требует путь к файлам исходного кода вершинного и фрагментного шейдеров, которые мы можем хранить на диске в виде простых текстовых файлов. Чтобы немного расширить возможности нашего классы, мы также добавляем несколько полезных функций: функция use() активирует шейдерную программу, а все функции set…() запрашивают местоположение uniform-переменной и устанавливают её значение.

Чтение из файла

Мы используем файловые потоки языка С++ для чтения содержимого из файла в несколько объектов типа string:

Далее нам нужно скомпилировать и связать шейдеры. Обратите внимание, что мы также проверяем вариант, где нам не удалось выполнить компиляцию/связывание, и, если такое случилось, выводим сообщение об ошибке компиляции. Это чрезвычайно полезно при отладке (в конечном итоге, вам понадобятся эти логи):

Функция use() очень простая:

Аналогично выглядят и остальные uniform-сеттеры:

Вот и наш итоговый шейдерный класс:

Использование шейдерного класса довольно простое; создав шейдерный объект, мы просто начинаем его использовать:

Обратите внимание, исходный код вершинного и фрагментного шейдеров сохранен в двух файлах: shader.vs и shader.fs. Я специально выбрал для файлов интуитивно понятные расширения (.vsVertex Shader (Вершинный Шейдер), .fsFragment Shader (Фрагментный Шейдер)). Вы можете называть свои шейдерные файлы так, как вам пожелается.

  GitHub / Урок №5. Шейдеры в OpenGL — Исходный код

Упражнения

Задание №1

Измените вершинный шейдер так, чтобы треугольник был перевернут «вверх ногами».

Ответ №1

Задание №2

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

Ответ №2

Задание №3

Передайте положение вершины во фрагментный шейдер с помощью ключевого слова out и установите цвет фрагмента равным этому положению вершины (посмотрите, как даже значения положения вершины в треугольнике подвергаются интерполяции). Как только у вас получится это сделать, попробуйте ответить на следующий вопрос: «Почему нижняя левая сторона вашего треугольника черная?».

Ответ №3

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (62 оценок, среднее: 5,00 из 5)
Загрузка...

Комментариев: 7

  1. Сергей Федоров:

    Я попытался отрисовать два треугольника (цветной и моноцветный).
    Добавил перегрузку вектора для цвета.

    Столкнулся с таким вопросом: Если я в первой части программы не изменяю uniform-переменную, то её значение неопределено. Можно ли как-то определить в GLSL что uniform-переменная ещё не определена и в зависимости от этого выбирать цвет по умолчанию (вернее полученный из VBO) либо цвет заданный через переменную.
    Пока что пришлось в обоих случаях задавать доп.параметр, а в шейдере делать проверку:

    Соответственно код отрисовки вышел такой (p.s. timeValue влияет на координаты каждого треугольника — в общем они ещё и летают):

  2. fidius:

    У меня проблема с комментариями вот например фрагментный
    шейдер

    компилирую шейдер и

    ERROR::SHADER_COMPILATION_ERROR of type: FRAGMENT
    Fragment shader failed to compile with the following errors:
    ERROR: 0:7: error(#131) Syntax error: pre-mature EOF parse error
    ERROR: error(#273) 1 compilation errors. No code generated

    — ————————————————— —
    ERROR::PROGRAM_LINKING_ERROR of type: PROGRAM
    Fragment shader(s) were not successfully compiled before glLinkProgram() was called. Link failed.

    — ————————————————— —

    я так понимаю у нас же код в одну строку так? Так получается что
    после комментария идёт '\n' и походу он комментируется
    и получается что весь код после комментария комментируется так же,
    а у меня в коде получается что '}' комментируется и функция не оканчивается, вот и ошибка. Что делать?

  3. Grave18:

    «Почему нижняя левая сторона вашего треугольника черная?» — так как ее кордината (-0.5, -0,5, 0.0), что скорее всего приводится к (0,0,0), что соответствует черному цвету.

  4. Вениамин:

    Как включить вертикальную синхронизацию? Ограничение fps на всех устройствах разное, где 60, где 120, а где и вовсе нет и скачет рывками под 3000

    1. Евгений:

    2. Vitaly:

      Перед основным циклом надо прописать это:

  5. Николай:

    А почему бы не использовать raw-строки, чтобы не писать кучу кавычек и \n?
    мне кажется такой код гораздо удобнее:

Добавить комментарий

Ваш E-mail не будет опубликован. Обязательные поля помечены *