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

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

  Обновл. 8 Июн 2020  | 

 5022

 ǀ   1 

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

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

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

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

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

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

Когда речь заходит конкретно о вершинном шейдере, то принято каждую его входную переменную называть вершинным атрибутом (англ. «vertex attribute»). Максимальное количество таких вершинных атрибутов, которые мы можем объявить, ограниченно аппаратными средствами компьютера. Спецификация OpenGL гарантирует нам возможность работы с 16-ю 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. На этот раз мы хотим к данным вершин добавить цветовые данные в виде 3-х дополнительных значений типа 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 (Фрагментный Шейдер)). Вы можете называть свои шейдерные файлы так, как вам пожелается.

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

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

Упражнения

Задание №1

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

Ответ №1

Задание №2

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

Ответ №2

Задание №3

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

Ответ №3

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

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

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

  1. Аватар Николай:

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

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

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