Learn OpenGL. Урок 4.5 — Кадровый буфер

https://learnopengl.com/Advanced-OpenGL/Framebuffers
  • Перевод
  • Tutorial
OGL3

Кадровый буфер


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


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

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

Создание кадрового буфера


Как и любой другой объект в OpenGL объект кадрового буфера (сокращенно FBO от FrameBuffer Object) используя следующий вызов:

unsigned int fbo;
glGenFramebuffers(1, &fbo);

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

glBindFramebuffer(GL_FRAMEBUFFER, fbo);  

После привязки нашего буфера кадра к точке привязки GL_FRAMEBUFFER все последующие операции чтения и записи для буфера кадра будут задействовать именно его. Также возможно привязать кадровый буфер только для чтения или только для записи осуществляя привязку к специальным точкам привязки GL_READ_FRAMEBUFFER или GL_DRAW_FRAMEBUFFER соответственно. Буфер, привязанный к GL_READ_FRAMEBUFFER, будет использован как источник для всех операций чтения типа glReadPixels. А буфер, связанный с GL_DRAW_FRAMEBUFFER, станет приемником всех операций рендера, очистки буфера и прочих операций записи. Однако, по большей части вам не придется пользоваться этими точками привязки, применяя точку привязки GL_FRAMEBUFFER.

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

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

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

if (glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE) {
  // все хорошо, можно плясать джигу!
}

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

glBindFramebuffer(GL_FRAMEBUFFER, 0);   

Именно передача 0 как идентификатора кадрового буфера указывает привязать базовый буфер как активный. После выполнения всех необходимых действий с созданным кадровым буфером не забудьте удалить его объект:

glDeleteFramebuffers(1, &fbo);  

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

Текстурные прикрепления


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

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

Процесс создания текстуры для использования в кадровом буфере примерно совпадает с таковым для обычного текстурного объекта:

unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
  
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);  

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

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

Создав объект текстуры необходимо прикрепить его к буферу кадра:

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);  

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

  • target – тип объекта кадра, к которому подключаем текстуру (только чтение, только запись, чтение/запись).
  • attachment – тип прикрепления, который мы планируем подключить. В данном случае мы подключаем прикрепление цвета. Обратите внимание на 0 на конце идентификатора прикрепления – его наличие подразумевает возможность подключения более чем одного прикрепления к буферу. Подробнее этот момент рассмотрен позже.
  • textarget – тип текстуры, который вы планируете подключить.
  • texture – непосредственно объект текстуры.
  • level – используемый для вывода МИП-уровень.

Кроме прикреплений цвета мы также можем подключить текстуры глубины и трафарета к объекту буфера кадра. Для прикрепления глубины мы задаем тип прикрепления GL_DEPTH_ATTACHMENT. Не забудьте, что параметры format и internalformat объекта текстуры должны принять значение GL_DEPTH_COMPONENT для возможности хранения значений глубины в соответствующем формате. Для прикрепления трафарета тип устанавливается в GL_STENCIL_ATTACHMENT, а параметры формата текстуры – в GL_STENCIL_INDEX.

Также существует возможность подключения и буфера глубины и трафарета одновременно при использовании всего одной текстуры. Для такой конфигурации каждое 32х битное значение текстуры состоит из 24х бит значения глубины и 8 бит информации о трафарете. Для подключения буфера глубины и трафарета как одной текстуры используется тип прикрепления GL_DEPTH_STENCIL_ATTACHMENT, а формат текстуры настраивается для хранения совмещенных значений глубины и трафарета. Пример подключения буфера глубины и трафарета в виде одной текстуры приведен ниже:

glTexImage2D(
  GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, 800, 600, 0, 
  GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, NULL
);

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, texture, 0); 

Прикрепления рендербуфера


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

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

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

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

Создание объекта рендербуфера довольно схоже с созданием объекта буфера кадра:

unsigned int rbo;
glGenRenderbuffers(1, &rbo);

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

glBindRenderbuffer(GL_RENDERBUFFER, rbo);  

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

Создание рендербуфера для глубины и трафарета:

glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);

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

Не забудем и о том, что объект нужно подключить к кадровому буферу:

glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);

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

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


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

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

unsigned int framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); 

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

// создание текстурного объекта
unsigned int texColorBuffer;
glGenTextures(1, &texColorBuffer);
glBindTexture(GL_TEXTURE_2D, texColorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);

// присоедиение текстуры к объекту текущего кадрового буфера
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texColorBuffer, 0); 

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

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

unsigned int rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo); 
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);  
glBindRenderbuffer(GL_RENDERBUFFER, 0);

Как только мы запросили память для объекта – можно его отвязывать.
Затем мы присоединяем объект рендербуфера к совмещенной точке прикрепления глубины и трафарета буфера кадра:

glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);

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

if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
	std::cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << std::endl;
glBindFramebuffer(GL_FRAMEBUFFER, 0);  

Не забудьте в конце отвязать объект буфера кадра, чтобы случайно не начать рендер не туда, куда предполагалось.

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

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

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

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

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

#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoords;

out vec2 TexCoords;

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0); 
    TexCoords = aTexCoords;
}  

Ничего особенного, не так ли? Фрагментный шейдер будет еще проще, поскольку все что он делает – выборка из текстуры:

#version 330 core
out vec4 FragColor;
  
in vec2 TexCoords;

uniform sampler2D screenTexture;

void main()
{ 
    FragColor = texture(screenTexture, TexCoords);
}

На вашей совести остается код, отвечающий за создание и настройку VAO для самого квада. Итерация рендера в итоге имеет следующую структуру:

// первый проход
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // буфер трафарета не используется
glEnable(GL_DEPTH_TEST);
DrawScene();	
  
// второй проход
glBindFramebuffer(GL_FRAMEBUFFER, 0); // возвращаем буфер кадра по умолчанию
glClearColor(1.0f, 1.0f, 1.0f, 1.0f); 
glClear(GL_COLOR_BUFFER_BIT);
  
screenShader.use();  
glBindVertexArray(quadVAO);
glDisable(GL_DEPTH_TEST);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer);
glDrawArrays(GL_TRIANGLES, 0, 6);  

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

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


Слева виден результат идентичный изображению из урока по тесту глубины, но в этот изображение выведено на полноэкранный квад. Если переключить режим рендера в каркасный (glPolygonMode(GL_FRONT_AND_BACK, GL_LINE) – войти в режим, glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) – вернуться в обычный режим, прим. пер.), то можно увидеть, что в буфер кадра по умолчанию отрисована всего пара треугольников.
Исходный код примера находится здесь.

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

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


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

Начнем с, пожалуй, самого простого эффекта.

Инверсия цвета


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

void main()
{
    FragColor = vec4(vec3(1.0 - texture(screenTexture, TexCoords)), 1.0);
}  

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


Все цвета в сцене оказались проинвертированы всего одной строчкой кода в шейдере, недурно, да?

Перевод в градации серого


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

void main()
{
    FragColor = texture(screenTexture, TexCoords);
    float average = (FragColor.r + FragColor.g + FragColor.b) / 3.0;
    FragColor = vec4(average, average, average, 1.0);
} 

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

void main()
{
    FragColor = texture(screenTexture, TexCoords);
    float average = 0.2126 * FragColor.r + 0.7152 * FragColor.g + 0.0722 * FragColor.b;
    FragColor = vec4(average, average, average, 1.0);
}   


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

Применение сверточного ядра


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

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

$\begin{bmatrix}2 & 2 & 2 \\ 2 & -15 & 2 \\ 2 & 2 & 2 \end{bmatrix}$


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

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

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

const float offset = 1.0 / 300.0;  

void main()
{
    vec2 offsets[9] = vec2[](
        vec2(-offset,  offset), // top-left
        vec2( 0.0f,    offset), // top-center
        vec2( offset,  offset), // top-right
        vec2(-offset,  0.0f),   // center-left
        vec2( 0.0f,    0.0f),   // center-center
        vec2( offset,  0.0f),   // center-right
        vec2(-offset, -offset), // bottom-left
        vec2( 0.0f,   -offset), // bottom-center
        vec2( offset, -offset)  // bottom-right    
    );

    float kernel[9] = float[](
        -1, -1, -1,
        -1,  9, -1,
        -1, -1, -1
    );
    
    vec3 sampleTex[9];
    for(int i = 0; i < 9; i++)
    {
        sampleTex[i] = vec3(texture(screenTexture, TexCoords.st + offsets[i]));
    }
    vec3 col = vec3(0.0);
    for(int i = 0; i < 9; i++)
        col += sampleTex[i] * kernel[i];
    
    FragColor = vec4(col, 1.0);
}  

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


Вполне может пригодиться в сценах, где игрок находится в наркотическом трипе.

Размытие


Ядро, реализующее эффект размытия выглядит следующим образом

$\begin{bmatrix} 1 & 2 & 1 \\ 2 & 4 & 2 \\ 1 & 2 & 1 \end{bmatrix} / 16$


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

float kernel[9] = float[](
    1.0 / 16, 2.0 / 16, 1.0 / 16,
    2.0 / 16, 4.0 / 16, 2.0 / 16,
    1.0 / 16, 2.0 / 16, 1.0 / 16  
);

Изменение элементов массива чисел, представляющих собой само ядро привело к полному преображению картинки:


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

Определение границ


Ниже представлено ядро для выявления границ:

$\begin{bmatrix} 1 & 1 & 1 \\ 1 & -8 & 1 \\ 1 & 1 & 1 \end{bmatrix}$


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


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

P.S. Из комментариев к оригиналу: отличная интерактивная демонстрация различных сверток.
P.P.S.: У нас есть телеграм-конфа для координации переводов. Если есть желание вписаться в цикл, то милости просим!
Поделиться публикацией

Похожие публикации

Комментарии 5
    0
    Вторая статья за неделю, спасибо! В ближайшее время будут ещё?
      0
      Как минимум свое посильное участие я пока гарантирую. Т.е. перевод объемом как эта статья в неделю-две я выдам, меньшие — быстрее, конечно. Плюс добровольцы еще помогают с переводами периодически. Как-то так.
      +1
      КРУТО!!!
      А можно в предыдущих статьях делать ссылки на новые???
        0
        Действительно, хорошая и очевидная идея, про которую забыли. Спасибо, попробуем поправить во всех статьях.
        0
        Размытие нужно в два прохода делать — по горизонтали, а затем по вертикали, это значительно снижает число выборок

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

        Самое читаемое