Смешивание цветов
Смешивание в OpenGL (да и других графических API, прим. пер.) является той техникой, которую обычно связывают с реализацией прозрачности объектов. Полупрозрачность объекта подразумевает, что он не залит одним сплошным цветом, а сочетает в себе в различных пропорциях оттенок своего материала с цветами объектов, находящихся позади. Как пример, можно взять цветное стекло в окне: у стекла есть свой оттенок, но в итоге мы наблюдаем смешение оттенка стекла и всего того, что видно за стеклом. Собственно, из этого поведения и возникает термин смешивание, поскольку мы наблюдаем итоговый цвет, являющийся смешением цветов отдельных объектов. Благодаря этому, мы можем видеть сквозь полупрозрачные объекты.
В передыдущих сериях
Часть 1. Начало
Часть 2. Базовое освещение
Часть 3. Загрузка 3D-моделей
Часть 4. Продвинутые возможности OpenGL
- OpenGL
- Создание окна
- Hello Window
- Hello Triangle
- Shaders
- Текстуры
- Трансформации
- Системы координат
- Камера
Часть 2. Базовое освещение
Часть 3. Загрузка 3D-моделей
Часть 4. Продвинутые возможности OpenGL
- Тест глубины
- Тест трафарета
- Смешивание цветов
Полупрозрачные объекты могут быть полностью прозрачными (все цвета проходят насквозь) или же частично прозрачными (пропускает свет, но добавляет и собственный оттенок). В компьютерной графике принято обозначать степень непрозрачности так называемоей альфа-компонентой вектора цвета. Альфа-компонента является четвертым элементом вектора цвета и вы, должно быть, уже не раз заметили её в предыдущих уроках. Однако, до этого момента мы всегда сохраняли это значение равным 1.0, что равнозначно полной непрозрачности. Установив же альфа-компоненту в 0.0 мы бы добились полной прозрачности. Значение 0.5 подразумевало бы, что итоговый цвет объекта на 50% задается своим материалом, а на 50% задается объектами, находящимися позади.
Все текстуры, что мы использовали до сих пор содержали 3 компонента цвета: красный, синий и зеленый. Некоторые форматы текстур позволяют сохранять также и четвертую альфа-компоненту для каждого текселя. Это значение указывает, какие части текстуры полупрозрачны и насколько именно. Например, данная текстура оконного стекла имеет альфа-компоненту установленной в 0.25 для стеклянных участков и 0.0 для рамы. В других условиях стеклянные части были бы полностью красными, но из-за 75% прозрачности цвет большей частью определен фоном текущей веб-страницы.
В скором мы добавим данную текстуру в новую сцену, но, для начала, обсудим более простую технику достижения прозрачности в тех случаях, где нужна либо полная прозрачность, либо полная непрозрачность.
Отбрасывание фрагментов
В некоторых случаях частичная прозрачность не требуется: необходимо либо отобразить что-то, либо ничего, основываясь на значении цвета текстуры. Представьте себе пучок травы: простейшая реализация пучка потребовала бы текстуры травы на 2D кваде, расположенном в вашей сцене. Однако, задаче имитации пучка травы форма квада не очень-то помогает – нам бы не помешало скрыть части наложенной текстуры, оставляя некоторые другие.
Представленная ниже текстура в точности представляет описанный случай: её участки либо полностью непрозрачны (альфа-компонента = 1.0), либо же полностью прозрачны (альфа-компонента = 0.0) – никаких средних значений. Можно заметить, что там, где нет изображения травинок видно фон сайта, а не цвет текстуры:
Таким образом, при размещении растительности в нашей сцене мы бы хотели видеть только части текстуры, соответствующие частям растения, а остальные части текстуры, заполняющие полигон – отбрасывать. То есть отбрасывать фрагменты, содержащие прозрачные части текстуры, не сохраняя их в буфере цвета. Но прежде чем мы непосредственно замараем руки работой с фрагментами, необходимо научиться загружать текстуры с альфа-каналом.
Для этого нам не придется что-то сильно изменять в знакомом коде. Функция-загрузчик из stb_image.h автоматически подгружает альфа-канал изображения, если таковой доступен. Но при этом необходимо явно указать OpenGL при создании текстуры, что она использует альфа-канал:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
Также убедитесь, что во фрагментом шейдере вы делаете выборку в вектор с 4мя компонентами, чтобы не остаться только с RGB значениями:
void main()
{
// FragColor = vec4(vec3(texture(texture1, TexCoords)), 1.0);
FragColor = texture(texture1, TexCoords);
}
Теперь, когда мы разобрались с загрузкой текстур с прозрачностью – самое время раскидать несколько пучков травы по сцене, использованной в уроке по тесту глубины.
Создадим небольшой вектор, хранящий положение пучков травы в виде glm::vec3:
vector<glm::vec3> vegetation;
vegetation.push_back(glm::vec3(-1.5f, 0.0f, -0.48f));
vegetation.push_back(glm::vec3( 1.5f, 0.0f, 0.51f));
vegetation.push_back(glm::vec3( 0.0f, 0.0f, 0.7f));
vegetation.push_back(glm::vec3(-0.3f, 0.0f, -2.3f));
vegetation.push_back(glm::vec3( 0.5f, 0.0f, -0.6f));
Каждый объект травы рендерится как единственный квад с текстурой травы, назначенной ему. Не самый захватывающий метод имитации травки в 3D, но гораздо более эффективный, нежели использование полигональных моделей. С помощью небольших уловок, типа добавления в том же положении еще пары повернутых квадов с той-же текстурой, можно добиться неплохих результатов.
Поскольку мы назначаем текстуру травы кваду нам понадобится новый VAO (vertex array object), заполнить VBO (vertex buffer object) и установить соответствующие указатели на атрибуты вершин. Далее, после рендера поверхности пола и кубов мы выводим нашу траву:
glBindVertexArray(vegetationVAO);
glBindTexture(GL_TEXTURE_2D, grassTexture);
for(unsigned int i = 0; i < vegetation.size(); i++)
{
model = glm::mat4();
model = glm::translate(model, vegetation[i]);
shader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
Выполнение программы выдаст такой вот результат:
Так произошло, поскольку сама по себе OpenGL не знает ни что делать со значениями альфа-канала, ни когда применять отбрасывание фрагментов. Все это мы должны указать вручную. К счастью, с помощью шейдеров все делается довольно просто. В GLSL существует встроенная директива discard, вызов которой приводит к прекращению дальнейшей обработки текущего фрагмента без его попадания в буфер цвета. Отсюда вырисовывается решение: проверяем значение альфа-компоненты текстурного элемента и, если он меньше некого порога, отбрасываем его:
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D texture1;
void main()
{
vec4 texColor = texture(texture1, TexCoords);
if(texColor.a < 0.1)
discard;
FragColor = texColor;
}
В данном коде мы отбрасываем фрагмент, если альфа-компонента выборки из текстуры оказалась меньше величины 0.1. Такой шейдер обеспечит нам вывод только тех фрагментов, что оказались достаточно непрозрачными:
Отмечу, что при выборке на границах текстуры OpenGL выполняет интерполяцию значения на границе со значением из следующего за ним значения, полученным повторением текстуры (поскольку мы установили параметр повторения текстуры в GL_REPEAT). Для обычного применения текстур это нормально, но для нашей текстуры с прозрачностью это не годится: полностью прозрачное значение текселей на верхней границе смешивается с полностью непрозрачными текселями нижней границы. В результате вокруг квада с нашей текстурой может появится полупрозрачная цветная рамка. Для избежания этого артефакта нужно параметр повтора установить в GL_CLAMP_TO_EDGE при использовании текстур с прозрачностью.
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
Код примера находится здесь.
Смешивание
Несмотря на то, что отбрасывание фрагментов – удобный и простой способ, он не дает возможности применять частичное смешивание полупрозрачных цветов. Для рендера изображений с объектами, имеющими разную степень непрозрачности мы должны включить режим смешивания. Делается это, как и для большинства режимов OpenGL:
glEnable(GL_BLEND);
Теперь, включив смешивание, стоит разобраться как же конкретно это работает.
Смешивание OpenGL выполняется по следующей формуле
где – вектор цвета источника. Это значение цвета, полученное из текстуры.
– вектор цвета приемника. Это значение цвета, хранимое на данный момент в буфере цвета.
– множитель источника. Задает степень влияния альфа-компоненты на цвет источника.
– множитель приемника. Задает степень влияния альфа-компоненты на цвет приемника.
После этапа выполнения фрагментного шейдера и прочих тестов (тесты трафарета и глубины, прим. пер.) данная формула смешивания вольна делать что угодно с цветами обработанных фрагментов и хранящимися в буфере цвета в текущий момент (значения цвета фрагментов из предыдущего кадра). Роли источника и приемника назначаются OpenGL автоматически, но множители для них мы можем задать сами. Для начала, рассмотрим следующий пример:
Имеются два квадрата и полупрозрачный зеленый мы бы хотели нарисовать поверх непрозрачного красного. В таком случае цветом-приемником будет цвет красного квадрата, а, значит, должен быть занесен в буфер цвета первым.
Возникает вопрос: как выбрать значения множителей в формуле смешивания? Ну, по крайней мере, нам следует умножить зеленый цвет второго квадрата на его величину альфа-компоненты, следовательно, примем равным альфа компоненте вектора цвета источника, т.е. 0.6. Исходя из этого, разумно будет предположить, что приемник обеспечит вклад в результат пропорциональный степени прозрачности, оставшейся доступной. Если зеленый квадрат обеспечивает 60% от итога, то красному квадрату должно достаться 40% (1. – 0.6). Так что множитель устанавливаем равным разности единицы и альфа-компоненты вектора цвета источника. В результате выражение смешивания приобретает следующий вид:
Результатом смешивания будет цвет на 60% состоящий из исходного зеленого и 40% исходного красного – это невнятный бурый цвет:
Результат будет занесен в буфер цвета, замещая старые значения.
Ну а как же нам дать понять OpenGL какие значения коэффициентов смешивания мы хотим использовать? На наше счастье есть специальная функция:
glBlendFunc(GLenum sfactor, GLenum dfactor)
Она принимает два параметра, определяющие значения коэффициентов источника и приемника. В OpenGL API определен исчерпывающий список значений этих параметров, позволяющий настроить режим смешивания как душе угодно. Здесь приведу самые «ходовые» значения параметров. Отмечу, что постоянный вектор цвета задается отдельно функцией glBlendColor.
Чтобы получить результат, описанный в примере с двумя квадратами, нам следует выбрать такие параметры, чтобы коэффициент источника равнялся alpha (значение альфа-компоненты) цвета источника, а коэффициент приемника равнялся 1 – alpha. Что равнозначно вызову:
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
Также возможна раздельная настройка коэффициентов для RGB и альфа компонент посредством функции glBlendFuncSeparate:
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO);
Такой вызов настраивает смешивание RGB компонент как в предыдущем примере, дополнительно указывая, что альфа-компонента результата будет равна альфа-компоненте источника.
OpenGL разрешает и еще более гибкую настройку формулы смешивания, разрешая выбор операции, производимой между компонентами формулы. По умолчанию компоненты источника и приемника складываются, но можно выбрать и вычитание, если таков замысел. Определяет поведение функция
glBlendEquation(GLenum mode)
И доступны три варианта значения параметра:
- GL_FUNC_ADD: значение по умолчанию, складывает компоненты: .
- GL_FUNC_SUBTRACT: вычитает компоненту приемника из компоненты источника: .
- GL_FUNC_REVERSE_SUBTRACT: вычитает компоненту источника из компоненты приемника: .
Обычно вызов glBlendEquation не требуется, поскольку режим по умолчанию GL_FUNC_ADD и так подходит для большинства случаев применения. Но для нестандартных подходов и попыток создать непривычное визуальное решение вполне могут пригодится и прочие режимы вычисления формулы смешивания.
Рендер полупрозрачных текстур
Итак, мы познакомились с тем, как библиотека выполняет смешивание. Самое время применить эти знания на практике, создав пару-тройку прозрачных окон. Используем ту же сцену, что и в начале урока, но вместо пучков травы разместим объекты с уже упоминавшейся в начале урока текстурой окна.
Для начала включим режим смешивания и выберем его параметры:
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
Поскольку мы включили смешивание, то в отбрасывании прозрачных фрагментов более нет нужды. Код фрагментного шейдера вернем к прежнему состоянию:
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D texture1;
void main()
{
FragColor = texture(texture1, TexCoords);
}
Теперь при обработке каждого фрагмента OpenGL смешивает цвет обрабатываемого фрагмента и хранимого в буфере цвета согласно значению альфа-компоненты первого. Поскольку стеклянная часть окна полупрозрачна, то мы должны видеть остальную сцену позади окна:
Однако, присмотревшись внимательно, можно заметить, что рендер некорректен. По какой-то причине полупрозрачные части ближайшего к нам окна перекрывают другие окна в фоне!
Причиной то, что тест глубины не учитывает прозрачен фрагмент или нет при обработке. В итоге все фрагменты квада с текстурой окна проходят тест глубины одним образом, принадлежат ли они к стеклянной части или нет. Несмотря на то, что позади стеклянных частей должны сохраняться старые фрагменты тест глубины их отбросит.
Итог: нельзя выводить полупрозрачные объекты абы как и надеяться, что тест глубины и смешивание сами решат как все сделать корректно. Чтобы обеспечить корректный рендер окон, перекрытых другими окнами, нам необходимо сначала осуществлять вывод окон, лежащих вдали. Таким образом нам необходимо самим отсортировать окна по положению от самых дальних к ближайшим и вывести в соответствии с этим порядком.
Отмечу, что для случаев с полной прозрачностью (случай с травой) операция отбрасывания фрагментов не вызывает описанной проблемы, поскольку смешивания не происходит.
Рендер с сохранением порядка следования
Для корректной работы смешивания при рендере множества объектов необходимо начинать вывод с самого дальнего и заканчивать ближайшим. Непрозрачные объекты, не требующие смешивания могут выводиться в привычной манере с использованием буфера глубины, здесь сортировка не требуется. Но непрозрачную часть сцены необходимо отрисовать до вывода элементов, использующих смешивание. В итоге, порядок действий при рендере сцены, содержащей как непрозрачные, так и прозрачные объекты, выглядит следующим образом:
- Вывести все непрозрачные объекты.
- Отсортировать прозрачные объекты по удалению.
- Нарисовать прозрачные объекты в отсортированном порядке.
Одним из способов сортировки является упорядочивание на основе дистанции от объекта до наблюдателя. Определяется эта величина как расстояние между векторами положений камеры и самого объекта. Далее мы сохраним это расстояние вместе с вектором положения объекта в контейнере map стандартной библиотеки C++. Ассоциативный контейнер map автоматически обеспечит упорядочивание хранимых элементов на основе значений ключа, так что нам будет достаточно всего лишь занести все пары дистанция-положение объектов:
std::map<float, glm::vec3> sorted;
for (unsigned int i = 0; i < windows.size(); i++)
{
float distance = glm::length(camera.Position - windows[i]);
sorted[distance] = windows[i];
}
В результате у нас будет контейнер с положениями объектов окна, отсортированными по величине distance от наименьшей до наибольшей.
В момент отрисовки нам понадобиться пройтись по контейнеру в обратном порядке (от наибольшего удаления до наименьшего) и нарисовать окна в соответствующих положениях:
for(std::map<float,glm::vec3>::reverse_iterator it = sorted.rbegin(); it != sorted.rend(); ++it)
{
model = glm::mat4();
model = glm::translate(model, it->second);
shader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
Здесь мы используем обратный итератор для контейнера, чтобы обеспечить проход по нему в обратном порядке. Каждый объект окна при этом смещается в соответствующее положение и отрисовывается. Относительно несложная модификация кода привела к полному разрешению ранее обозначенной проблемы:
Как видно, сцена теперь выводится корректно. Исходный код примера находится здесь.
Стоит отметить, что простая сортировка по дальности хоть и хорошо сработала в данном случае, но не учитывает таких особенностей как повороты, масштабирование и прочие трансформации объектов. Также объекты сложной формы потребовали бы более изощренной метрики для сортировки, нежели только удаленность от камеры.
Кроме того, сортировка не дается бесплатно: сложность этой задачи определяется видом и составом сцены, а сам процесс требует дополнительных вычислительных затрат. Существуют и более продвинутые методы вывода сцен, содержащих как прозрачные, так и непрозрачные объекты: например, алгоритм порядко-независимой прозрачности (Order Independent Transparency, OIT). Но освещение этой темы выходит за рамки урока. А вам придется обходиться обычной реализацией смешивания. Но для печали нет причины, зная ограничения технологии и будучи осторожным, можно добиться вполне впечатляющих результатов!
P.S.: И снова в комментариях полезная ссылка. Можно вживую посмотреть, как выбор режимов смешивания влияет на итог.
P.P.S.: У нас с eanmos есть телеграм-конфа для координации переводов. Если есть желание вписаться в цикл, то милости просим!