Россия и Беларусь начали и продолжают войну против целого народа Украины!

Урок №23. Фреймбуферы в OpenGL

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

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

 8372

До сих пор мы использовали несколько типов буферов экрана: цветовой буфер для записи значений цвета, буфер глубины для записи и проверки информации о глубине и, наконец, буфер трафарета, который позволял нам отбрасывать определенные фрагменты на основе некоторого условия. Сочетанием этих буферов является объект под названием фреймбуфер (англ. «framebuffer» = «буфер кадра, кадровый буфер, видеобуфер»), хранящийся в памяти графического процессора. OpenGL предоставляет нам широкие возможности в определении собственных фреймбуферов, и, таким образом, мы можем определять наш собственный буфер цвета (и, возможно, буферы глубины и трафарета).

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

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

Создание фреймбуфера

Как и любой другой объект в OpenGL, мы можем создать объект фреймбуфера (сокр. «FBO» от англ. «Frame Buffer Object») с помощью функции glGenFramebuffers():

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

Если при привязке фреймбуфера используется цель GL_FRAMEBUFFER, то все последующие операции чтения и записи во фреймбуфере будут влиять на текущий связанный фреймбуфер. Кроме того, можно привязать фреймбуфер к целевому объекту чтения или записи, связав его с GL_READ_FRAMEBUFFER или GL_DRAW_FRAMEBUFFER, соответственно. Фреймбуфер, привязанный к GL_READ_FRAMEBUFFER, будет использоваться для всех операций чтения типа glReadPixels(), а фреймбуфер, привязанный к GL_DRAW_FRAMEBUFFER, будет использоваться в таких случаях, как: место назначения рендеринга, очистка и другие операции записи. Однако в большинстве случаев вы будете пользоваться только привязкой к GL_FRAMEBUFFER.

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

   мы должны прикрепить хотя бы один буфер (буфер цвета, глубины или трафарета);

   должен быть прикреплен хотя бы один цветовой буфер;

   формирование всех прикрепляемых объектов также должно быть завершено (зарезервирована память);

   в каждом буфере должно быть одинаковое количество сэмплов (об этом чуть позже).

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

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

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

Теперь, прежде чем выполнить проверку завершенности формирования фреймбуфера, нужно прикрепить к нему один или сразу несколько объектов. Прикрепляемый объект — это место в памяти, используемое в качестве буфера для фреймбуфера, которое можно представить в виде некоторого изображения. При создании прикрепляемого объекта у нас есть два варианта действий: использовать текстуры или использовать объекты рендербуфера (или «буфера рендеринга»).

Текстуры в качестве прикрепляемых объектов


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

Создание текстуры для фреймбуфера — это примерно то же самое, что и создание обычной текстуры:

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

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

Теперь, когда мы создали текстуру, последнее, что нам нужно сделать — это прикрепить её к фреймбуферу:

Функция glFrameBufferTexture2D() имеет следующие параметры:

   target — это тип фреймбуфера, на который мы нацеливаемся (отрисовка, чтение или всё вместе);

   attachment — это тип прикрепляемого объекта. В данный момент мы используем цветовой прикрепляемый объект. Обратите внимание, 0 в конце предполагает, что мы можем прикрепить более 1 объекта (мы вернемся к этому на следующем уроке);

   textarget — это тип текстуры, которую мы хотим прикрепить;

   texture — это фактическая текстура для прикрепления;

   level — это мипмап-уровень. Мы оставим значение данного параметра равным 0.

Наряду с цветовыми объектами к объекту фреймбуфера можно прикрепить текстуру глубины или трафарета. Чтобы прикрепить объект глубины, необходимо указать его тип как GL_DEPTH_ATTACHMENT. Чтобы прикрепить буфер трафарета, необходимо использовать GL_STENCIL_ATTACHMENT в качестве второго аргумента и указать формат текстуры как GL_STENCIL_INDEX.

Кроме того, можно прикрепить буфер глубины и буфер трафарета в виде одной текстуры. В результате этого каждое 32-битное значение текстуры будет содержать 24 бита информации о глубине и 8 бит информации о трафарете. Чтобы присоединить буферы глубины и трафарета в качестве одной текстуры, мы воспользуемся типом GL_DEPTH_STENCIL_ATTACHMENT и затем настроим формат текстуры так, чтобы он содержал комбинированные значения глубины и трафарета. Пример присоединения к фреймбуферу буферов глубины и трафарета в виде одной текстуры приведен ниже:

Рендербуфер в качестве прикрепляемого объекта

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

Объекты рендербуфера хранят все данные рендеринга непосредственно в своем буфере без каких-либо преобразований в текстурные форматы, что делает их более быстрыми в качестве записываемого носителя данных. Вы не можете читать из них напрямую, но вы можете читать из них через вызов функции glReadPixels(). В результате возвращается определенная область пикселей из текущего связанного фреймбуфера, но не напрямую из прикрепленного объекта.

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

Создание объекта рендербуфера напоминает создание фреймбуфера:

И точно так же нам необходимо привязать объект рендербуфера, в результате чего все последующие операции с объектом рендербуфера будут влиять на текущий rbo:

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

Создание объекта рендербуфера глубины и трафарета выполняется вызовом функции glRenderbufferStorage():

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

Последнее, что осталось сделать — это фактически прикрепить объект рендербуфера:

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

Рендеринг в текстуру


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

Первое, что нужно сделать — это непосредственно создать и привязать объект фреймбуфера:

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

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

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

Как только мы выделили для объекта рендербуфера необходимый объем памяти, можно отвязать его.

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

Затем необходимо проверить, завершено ли формирование фреймбуфера. Если нет, то выводим сообщение об ошибке:

Обязательно отключаем привязку фреймбуфера, чтобы быть уверенными, что мы рендерим в нужный фреймбуфер.

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

Итак, чтобы нарисовать сцену в единую текстуру, нам придется выполнить следующие шаги:

   Шаг №1: Визуализируйте сцену с новым фреймбуфером, привязанным в качестве активного фреймбуфера.

   Шаг №2: Выполните привязку к заданному по умолчанию фреймбуферу.

   Шаг №3: Нарисуйте прямоугольник, занимающий весь экран, с текстурой, используемой в качестве цветового буфера нового фреймбуфера.

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

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

Здесь ничего особенного. Фрагментный шейдер еще проще, так как единственное, что нам нужно сделать — это сэмплировать текстуры:

Затем мы должны создать и настроить VAO для экранного прямоугольника. Отдельно взятая итерация рендеринга принимает следующий вид:

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

Если всё получилось удачно, то мы должны получить следующий визуальный результат:

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

  GitHub / Урок №23. Фреймбуферы в OpenGL — Исходный код №1

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

Постобработка

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

Начнем с одного из самых простейших эффектов постобработки.

Инверсия

У нас есть доступ к каждому из цветов вывода рендеринга, поэтому не так уж трудно произвести инверсию цвета во фрагментном шейдере. Мы можем взять цвет текстуры экрана и инвертировать его, вычитая значение 1.0:

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

Теперь вся сцена имеет цвета, инвертированные с помощью одной строки кода во фрагментном шейдере. Круто ведь, не так ли?

Оттенки серого

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

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

Результат:

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

Эффекты ядра

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

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

Данное ядро принимает 8 значений окружающих пикселей и умножает их на 2, а текущий пиксель умножает на -15. В этом примере ядро умножает окружающие пиксели на несколько весовых значений, определенных в ядре, и уравновешивает результат, умножая текущий пиксель на значение с бОльшим отрицательным весом.

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

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

Во фрагментном шейдере мы сначала создаем массив из 9 смещений типа vec2 для каждой текстурной координаты. Смещение — это константное значение, которое вы можете настроить по своему желанию. Затем мы определяем ядро, которое в данном случае является ядром резкости, которое изменяет резкость каждого пикселя, сэмплируя окружающие. Наконец, при сэмплировании мы добавляем каждое смещение к текущей текстурной координате и умножаем полученные значения на весовые значения ядра, и складываем их вместе.

Результат применения ядра резкости выглядит следующим образом:

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

Размытие

Ядро, создающее эффект размытия (англ. «blur»), определяется следующим образом:

Поскольку сумма всех значений равна 16, то результат объединения всех цветов приведет к чрезвычайно яркому цвету, поэтому мы должны разделить каждое значение ядра на 16. Таким образом, результирующий массив ядра принимает следующий вид:

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

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

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

Выделение границ

Ниже представлено ядро выделения границ, напоминающее нам ядро резкости:

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

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

Упражнения


Задание №1

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

Результат должен быть примерно следующий:

Ответ №1

Задание №2

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

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

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

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

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