Смешивание — это широко известный в OpenGL метод реализации прозрачности объектов. Когда мы говорим о прозрачности, то имеем в виду объекты (или их части), представленные не сплошным цветом, а комбинацией цветов (различной интенсивности) самого объекта и любого другого объекта позади него.
Альфа-значение
Окно из цветного стекла — это прозрачный объект; стекло имеет свой собственный цвет, но конечный цвет также содержит и цвета всех объектов, расположенных за окном. Именно отсюда и происходит название термина смешивание, поскольку мы смешиваем несколько цветов пикселей (от разных объектов) в единый цвет. Таким образом, благодаря прозрачности у нас появляется возможность видеть сквозь объекты.
Прозрачные объекты могут быть как полностью прозрачными (пропускающими все цвета), так и частично прозрачными (пропускающими цвета, но также имеющими и некоторый индивидуальный цвет). Степень прозрачности объекта определяется альфа-значением его цвета. Альфа-значение цвета — это 4-й компонент цветового вектора, с которым вы, вероятно, уже встречались. Вплоть до этого урока мы всегда сохраняли значение данной 4-й компоненты на уровне 1.0
, придавая объекту нулевую прозрачность. Альфа-значение 0.0
приведет к тому, что объект будет иметь полную прозрачность. Альфа-значение 0.5
говорит нам, что цвет объекта состоит из 50% соотношения его собственного цвета и цветов других объектов, стоящих позади него.
Все текстуры, которые мы использовали до сих пор, состояли из 3 цветовых компонентов: красный, зеленый и синий. Но некоторые текстуры при этом могут иметь (или имели) встроенный альфа-канал, который содержит альфа-значение каждого текселя. Данное альфа-значение в точности указывает на то, какие части текстуры имеют прозрачность, и какой она величины. Например, следующая текстура окна задает альфа-значение, равное 0.25
, для своей внутренней стеклянной части, и альфа-значение 0.0
— для своих углов. Раньше стеклянная часть окна имела бы сплошной красный цвет. Но поскольку величина прозрачности для данного фрагмента составляет 75%, то она в значительной степени просвечивает сквозь себя фон страницы, что делает её гораздо менее красной:
Вскоре мы добавим данную текстуру окна к сцене из Урока №19. Тестирование глубины в OpenGL, но сначала обсудим более простой метод реализации прозрачности пикселей, которые могут быть либо полностью прозрачными, либо полностью непрозрачны.
Отбрасывание фрагментов
Есть эффекты, которые не затрагивает частичная прозрачность, т.к. алгоритм их работы основан на следующем принципе: в зависимости от значения цвета фрагмента текстуры, они либо полностью отображают фрагмент, либо не отображают его вообще. Представьте себе траву; чтобы без особых усилий создать что-то вроде куста травы, вы должны поместить текстуру травы на плоский прямоугольник и добавить данный прямоугольник в свою сцену. Однако форма травы отличается от формы прямоугольника, поэтому необходимо отобразить лишь некоторые части текстуры травы, а другие — проигнорировать.
Следующее изображение — пример именно такой текстуры, которая либо полностью непрозрачна (альфа-значение составляет 1.0
), либо полностью прозрачна (альфа-значение составляет 0.0
), третьего — не дано. Вы можете видеть, что там, где нет травы, изображение показывает не свой собственный цвет, а цвет фона страницы.
Поэтому, добавляя к сцене растительность, мы не хотим получить квадратное изображение травы, а скорее хотим иметь непосредственно только саму траву и возможность видеть сквозь остальную часть её текстуры. Для этого необходимо отбросить (а не хранить в цветовом буфере) те фрагменты, которые отображают прозрачные части текстуры.
Прежде чем мы перейдем к решению данного вопроса, нам нужно научиться загружать прозрачную текстуру. Чтобы загрузить текстуры с альфа-значениями, необходимо внести в код несколько небольших корректировок. Библиотека stb_image.h сама автоматически загрузит альфа-канал изображения (если таковой имеется), но в процессе генерации текстур нужно сообщить OpenGL, что теперь текстура использует альфа-канал:
1 |
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data); |
Кроме того, убедитесь, что во фрагментном шейдере вы извлекаете все 4 цветовых компонента текстуры, а не только компоненты RGB:
1 2 3 4 5 |
void main() { // FragColor = vec4(vec3(texture(texture1, TexCoords)), 1.0); FragColor = texture(texture1, TexCoords); } |
Теперь, когда мы знаем, как загружать прозрачные текстуры, пришло время проверить это на практике, добавив несколько пучков травы к базовой сцене урока №19.
Для этого создадим небольшой векторный массив, в который, для представления местоположения травы, добавим несколько векторов glm::vec3
:
1 2 3 4 5 6 |
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, заполнить VBO и установить соответствующие указатели атрибутов вершин. После того, как мы отрисуем пол и два кубика, необходимо будет отобразить саму траву:
1 2 3 4 5 6 7 8 9 |
glBindVertexArray(vegetationVAO); glBindTexture(GL_TEXTURE_2D, grassTexture); for(unsigned int i = 0; i < vegetation.size(); i++) { model = glm::mat4(1.0f); model = glm::translate(model, vegetation[i]); shader.setMat4("model", model); glDrawArrays(GL_TRIANGLES, 0, 6); } |
Результат запуска приложения должен выглядеть примерно следующим образом:
GitHub / Урок №21. Смешивание в OpenGL — Исходный код №1
Обратите внимание на белые части текстур, их появление связано с тем, что по умолчанию OpenGL не знает, что делать с альфа-значениями текстуры и в каких случаях соответствующие фрагменты объекта нужно отбросить. Мы должны это сделать сами вручную. К счастью, благодаря использованию шейдеров, задача решается очень легко и просто. GLSL предоставляет нам команду discard
, которая (после вызова) гарантирует, что далее фрагмент не будет обработан и, таким образом, не попадет в цветовой буфер. Благодаря этой команде мы можем проверить, имеет ли фрагмент альфа-значение ниже определенного порога, и если имеет — значит отбрасываем фрагмент, как будто он никогда и не обрабатывался:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#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
, и если имеет, то отбрасываем фрагмент. Данный фрагментный шейдер гарантирует нам, что он отображает только те фрагменты, которые не являются (почти) полностью прозрачными. Теперь всё будет выглядеть так, как и должно:
GitHub / Урок №21. Смешивание в OpenGL — Исходный код №2
Примечание: Обратите внимание, что при сэмплировании текстур на их границах, OpenGL интерполирует значения границ со следующим повторяющимся значением текстуры (поскольку мы устанавливаем GL_REPEAT
по умолчанию в качестве её параметров наложения). В обычной ситуации такое поведение нас бы устроило, но поскольку мы используем прозрачность, верхняя часть текстурного изображения определяется значением прозрачности, полученным в результате интерполяции со значением сплошного цвета нижней границы. В итоге, вокруг нашего текстурированного квадрата получается слегка полупрозрачная цветная граница. Чтобы предотвратить появление данного артефакта, всякий раз, когда мы используем альфа-текстуры, которые не должны повторяться, устанавливаем GL_CLAMP_TO_EDGE
в качестве режима наложения текстур:
1 2 |
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, мы можем активировать режим смешивания с помощью опции GL_BLEND
:
1 |
glEnable(GL_BLEND); |
Теперь, когда мы задействовали данный режим, нам нужно понять, каким образом OpenGL должен произвести вышеупомянутое смешивание.
В OpenGL операция смешивания описывается следующей формулой:
Рассмотрим эту формулу детально:
Csource — это вектор цвета источника (т.е. выходные цветовые данные фрагментного шейдера).
Cdestination — это вектор цвета приемника (т.е. цветовой вектор, который в данный момент хранится в цветовом буфере).
Fsource — это значение коэффициента источника, которое устанавливает влияние альфа-значения на цвет источника.
Fdestination — это значение коэффициента приемника, которое устанавливает влияние альфа-значения на цвет приемника.
После запуска фрагментного шейдера и прохождения всех тестов к выходному цвету фрагмента (и ко всему, что в данный момент находится в цветовом буфере) применяется указанное уравнение смешивания. Цвет источника и цвет приемника задаются в OpenGL автоматически, но коэффициенты источника и приемника могут быть определены по нашему усмотрению. Давайте начнем с простого примера:
У нас есть два квадрата, и мы хотим нарисовать полупрозрачный зеленый квадрат поверх красного. Красный квадрат будет приемником цвета (и, следовательно, в цветовом буфере должен быть первым).
Тогда возникает вопрос: «Какие значения коэффициентов мы должны установить?». Ну по крайней мере планируется умножить зеленый квадрат на его альфа-значение, поэтому необходимо установить Fsource, равным альфа-значению вектора цвета источника, т.е. равным 0.6
. Тогда квадрат приемника будет вносить свой вклад, равный остатку от альфа-значения. Если зеленый квадрат в результат конечного цвета вносит свои 60%, то красный квадрат должен вносить 40%, т.е. 1.0 – 0.6
. Поэтому мы задаем Fdestination как единица минус альфа-значение вектора цвета источника. Таким образом, уравнение принимает следующий вид:
В результате получается, что совмещенные фрагменты квадратов содержат цвет, который составляет 60% зеленого и 40% красного:
Затем полученный цвет сохраняется в цветовом буфере, заменяя предыдущий цвет.
Это конечно здорово, но как нам заставить OpenGL использовать подобные коэффициенты? Для этого есть специальная функция под названием glBlendFunc().
Функция glBlendFunc(GLenum sfactor, GLenum dfactor)
принимает два параметра, которые задают опции для коэффициентов источника и приемника. В OpenGL для нас определен большой набор самых различных опций, самые распространенные из которых мы перечислим ниже. Обратите внимание, что вектор постоянного цвета Cconstant может задаваться отдельно с помощью функции glBlendColor().
Опция | Значение |
GL_ZERO | Коэффициент равен 0 |
GL_ONE | Коэффициент равен 1 |
GL_SRC_COLOR | Коэффициент равен вектору источника цвета Csource |
GL_ONE_MINUS_SRC_COLOR | Коэффициент равен 1 минус вектор источника цвета: 1 − Csource |
GL_DST_COLOR | Коэффициент равен вектору цвета приемника Cdestination |
GL_ONE_MINUS_DST_COLOR | Коэффициент равен 1 минус вектор цвета приемника: 1 − Cdestination |
GL_SRC_ALPHA | Коэффициент равен альфа-компоненте вектора цвета источника Csource |
GL_ONE_MINUS_SRC_ALPHA | Коэффициент равен 1 − альфа вектора цвета источника Csource |
GL_DST_ALPHA | Коэффициент равен альфа-компоненте вектора цвета приемника Cdestination |
GL_ONE_MINUS_DST_ALPHA | Коэффициент равен 1 − альфа вектора цвета приемника Cdestination |
GL_CONSTANT_COLOR | Коэффициент равен вектору постоянного цвета Cconstant |
GL_ONE_MINUS_CONSTANT_COLOR | Коэффициент равен 1 − вектор постоянного цвета Cconstant |
GL_CONSTANT_ALPHA | Коэффициент равен альфа-компоненте вектора постоянного цвета Cconstant |
GL_ONE_MINUS_CONSTANT_ALPHA | Коэффициент равен 1 − альфа вектора постоянного цвета Cconstant |
Чтобы в нашем небольшом примере с двумя квадратами получить результат смешивания, необходимо в качестве коэффициента источника взять альфа-значение вектора исходного цвета и значение 1−альфа того же цветового вектора для коэффициента приемника. Таким образом, вызов функции glBlendFunc() принимает следующий вид:
1 |
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); |
Также с помощью функции glBlendFuncSeparate() можно индивидуально задавать параметры для RGB- и альфа-компонент:
1 |
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO); |
Данная функция устанавливает RGB-компоненты так, как мы определяли их ранее, но при этом только результирующей альфа-компоненте позволено оказывать влияние на альфа-значение источника.
OpenGL дает нам еще больше гибкости в использовании вышеуказанного уравнения, позволяя изменять оператор действия между источником и приемником. В данный момент компоненты источника и приемника суммируются друг с другом, но мы также можем вычесть их, если захотим. Функция glBlendEquation(GLenum mode)
позволяет нам изменять операцию, имея на выбор 5 различных вариантов действия:
GL_FUNC_ADD
— складывает цвета друг с другом (по умолчанию): Cresult = Источник + Приемник.
GL_FUNC_SUBTRACT
— вычитает цвет Приемника из цвета Источника: Cresult = Источник − Приемник.
GL_FUNC_REVERSE_SUBTRACT
— вычитает цвет Источника из цвета Приемника: Cresult = Приемник − Источник.
GL_MIN
— использует минимальные значения покомпонентного сравнения обоих векторов: Cresult = min(Приемник, Источник).
GL_MAX
— использует максимальные значения покомпонентного сравнения обоих векторов: Cresult = max(Приемник, Источник).
Обычно мы просто опускаем вызов glBlendEquation(), потому что для большинства операций опция GL_FUNC_ADD
является предпочтительным способом вычисления смешивания, но если вы действительно стараетесь изо всех сил быть не как все, то любое из этих уравнений может удовлетворить ваши потребности.
Рендеринг полупрозрачных текстур
Теперь, когда мы знаем, как OpenGL работает в режиме смешивания цветов, пришло время проверить наши знания на практике, добавив в нашу программу несколько полупрозрачных окон. Мы будем использовать ту же сцену, что и в начале этого урока, но вместо визуализации текстуры травы мы задействуем текстуру прозрачного окна:
Начнем с того, что во время инициализации мы включим режим смешивания и установим соответствующую функцию смешивания:
1 2 |
glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); |
Поскольку был активирован режим смешивания, то нет необходимости проводить «отбраковку» фрагментов, поэтому мы приводим фрагментный шейдер в первоначальный вид:
1 2 3 4 5 6 7 8 9 10 11 |
#version 330 core out vec4 FragColor; in vec2 TexCoords; uniform sampler2D texture1; void main() { FragColor = texture(texture1, TexCoords); } |
Сейчас (и всякий раз, когда OpenGL визуализирует фрагмент) он совмещает цвет текущего фрагмента с цветом фрагмента, находящегося в данный момент в цветовом буфере, на основе альфа-значения переменной FragColor
. Поскольку стеклянная часть текстуры окна полупрозрачна, то у нас должна появиться возможность наблюдать остальную часть сцены, смотря через это окно.
GitHub / Урок №21. Смешивание в OpenGL — Исходный код №3
Однако, если вы повнимательнее присмотритесь, то сможете заметить, что здесь что-то не так. Прозрачные части переднего окна перекрывают окна на заднем плане. Почему это происходит?
Причина данного эффекта заключается в том, что тестирование глубины в сочетании с режимом смешивания работает несколько хитрее. Проверка глубины при записи в буфер глубины не заботится о том, имеет ли фрагмент прозрачность или нет, поэтому прозрачные части записываются в буфер глубины так же, как и любое другое значение. В результате, для окон на заднем плане тест глубины проходит подобно любому другому непрозрачному объекту, игнорируя саму прозрачность. Несмотря на то, что прозрачная часть должна показывать окна, расположенные за ней, тест глубины отбрасывает их.
Таким образом, мы не можем просто визуализировать окна так, как нам хочется, и ожидать, что буфер глубины сам решит все наши проблемы; это именно тот момент, когда работа с режимом смешивания становится немного неприятной. Чтобы быть уверенным в том, что все окна показывают объекты, находящиеся позади них, мы должны сначала нарисовать окна, являющиеся фоном. Это означает, что необходимо вручную сортировать окна от самых дальних до ближайших и в соответствии с этим их визуализировать.
Примечание: Обратите внимание, что с полностью прозрачными объектами, такими как кусты травы, у нас есть возможность отбросить прозрачные фрагменты вместо того, чтобы смешивать их, тем самым избавляя себя от лишней головной боли (ведь никаких проблем с глубиной при этом не возникает).
Соблюдаем порядок
Чтобы режим смешивания корректно работал для нескольких объектов, сначала мы должны нарисовать самый удаленный объект, а самый близкий — отобразить последним. Обычные несмешанные объекты все еще могут быть нарисованы стандартным способом с помощью буфера глубины, поэтому их сортировать не нужно. Необходимо убедиться, что они будут нарисованы первыми, прежде чем производить отрисовку (отсортированных) прозрачных объектов. При отображении сцены, которая содержит прозрачные и непрозрачные объекты, общий алгоритм действий можно описать следующими шагами:
Шаг №1: Сначала нарисуйте все непрозрачные объекты.
Шаг №2: Отсортируйте все прозрачные объекты.
Шаг №3: Нарисуйте все прозрачные объекты в отсортированном порядке.
Одним из способов сортировки прозрачных объектов является сортировка на основе расстояния между объектом и наблюдателем. Это может быть достигнуто путем измерения расстояния между вектором положения камеры и вектором положения объекта. Затем мы сохраняем это расстояние вместе с соответствующим вектором положения в структуре данных типа map из библиотеки STL. Объект типа map автоматически сортирует свои значения на основе ключей, поэтому, как только мы добавили все позиции с их расстояниями в качестве ключа, они автоматически сортируются по их значению расстояния:
1 2 3 4 5 6 |
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]; } |
Результатом этого является отсортированный объект контейнера, который хранит каждую позицию окна на основе значения ключа расстояния от самого маленького до самого большого расстояния.
Затем, на этот раз при рендеринге, мы берем каждое из значений объекта типа map в обратном порядке (от самого дальнего к самому близкому) и отрисовываем соответствующие окна в заданном порядке:
1 2 3 4 5 6 7 |
for(std::map<float,glm::vec3>::reverse_iterator it = sorted.rbegin(); it != sorted.rend(); ++it) { model = glm::mat4(1.0f); model = glm::translate(model, it->second); shader.setMat4("model", model); glDrawArrays(GL_TRIANGLES, 0, 6); } |
Мы используем реверсивный итератор объекта типа map, чтобы перебрать каждый из элементов в обратном порядке, а затем перевести каждый прямоугольник окна в соответствующее положение окна. Этот относительно простой подход к сортировке прозрачных объектов исправляет предыдущую проблему, теперь сцена выглядит следующим образом:
GitHub / Урок №21. Смешивание в OpenGL — Исходный код №4
Хотя этот подход сортировки объектов по их расстоянию и хорошо работает для данного конкретного случая, но он не учитывает повороты, масштабирование или любое другое преобразование, в результате чего объекты более причудливой формы будут нуждаться в определении другой метрики, нежели чем простой вектор положения.
Сортировка объектов в сцене — это сложная задача, которая в значительной степени зависит от типа сцены, не говоря уже о задействовании дополнительной вычислительной мощности, которая может для этого потребоваться. Полностью визуализировать сцену с твердыми и прозрачными объектами не так-то и просто. Есть более продвинутые методы, вроде алгоритма порядко-независимой прозрачности, но их рассмотрение выходит за рамки этого урока. На данный момент вам придется работать с обычным смешиванием ваших объектов, но если вы будете осторожны и знаете накладываемые ограничения, то сможете получить довольно приличные реализации режима смешивания.
Действительно, для отрисовки одновременно и травы и окон пришлось вернуть в шейдер отбрасывание полностью прозрачнызх участков.
(на окна это никак не повлияло, поскольку альфа канал в них всё равно больше 0.1)
А вот создать из тектур окна прозрачный куб корректно не получилось. Возможно для прозрачного куба можно создать EBO и морочить голову с сортировкой отображения граней на каждом проходе рендеринга.