В этой статье я хочу поговорить о методах смешивания растеризуемой геометрии. Классические модели смешивания полупрозрачных объектов — Alpha, Additive, Multiplicative — объединяет один и тот же принцип отрисовки: последовательно рисуем один примитив за другим, смешивая получаемые на выходе фрагментного шейдера пиксели с тем, что находится в текущем буфере. Каждый новый примитив обновляет область буфера, в которую рисуется; в случае с альфа-смешиванием объекты, которые находятся выше, заслоняют ранее отрисованные. Но что если хочется что-то сделать с группой объектов, рисуемых поверх сцены, — например, обрезать их по маске или подсветить? Тут сразу в голову приходят два решения: или внести изменения в их материал (т.е. изменить шейдер, расширить набор текстур), к примеру, добавив проекцию еще одной текстуры, которая будет отвечать за маску прозрачности. Однако если у нас много разношерстных объектов, менять каждый уникальный материал неудобно и чревато ошибками. Второй вариант — нарисовать все интересующие нас объекты в отдельный полноэкранный таргет и рисовать уже его на финальную сцену. Тут мы можем сделать с его содержимым все, что захотим, но это требует выделения лишней памяти и, что самое неприятное, — переключения рендер таргетов. Это не самая «дешевая» операция на мобильных устройствах, которую будет необходимо выполнить дважды. А если захочется вот так работать с несколькими слоями?
Есть и другой, более простой и элегантный, способ решить эти проблемы. Мы можем рисовать сцену в обратном порядке!
Как происходит формирование изображения по шагам: сначала мы рисуем фон, дальше отрисовываем все объекты по слоям друг за другом. То, что отрисовывается последним, перезаписывает предыдущие пиксели:
Суть технологии обратной отрисовки или, как её можно ещё назвать, отложенного смешивания заключается в следующем. Мы рисуем сцену задом наперёд, используя другую формулу смешивания. Притом итоговое изображение останется точно таким же, как и при классическом подходе.
Выше был описан метод смешивания через канал прозрачности изображения, которое рисуем. Теперь мы это повернём наоборот: будем использовать прозрачность уже нарисованных пикселей (а точнее, смешивание рисуемой прозрачности с уже отрисованной). То есть вместо AlphaSrc мы будем использовать AlphaSaturate, а вместо OneMinusAlphaSrc — One. Получается, если в буфере уже лежит что-то с прозрачностью = 1, то вклад будет нулевой, и цвет такого пикселя не изменится. Если там была нулевая прозрачность — сложим оба цвета вместе (для этого нам нужно будет очищать буфер кадра нулями или чёрным цветом с нулевой прозрачностью). При таком сложении результирующий цвет будет равен рисуемому. Итоговая формула выглядит так:
(прим. AlphaSaturate = min(AlphaSrc, 1 — AlphaDst) )
Значения прозрачности требуется складывать: она должна накапливаться слой за слоем, то есть у нас будет One и One в переменных смешивания для альфа-канала. Почему мы не модифицируем ColorDst и очищаем буфер нулями? Это нужно для Additive-смешивания, AdditiveBlending при этом будет отличаться только тем, что в AlphaSrc переменной у него будет находиться Zero. Он не должен модифицировать прозрачность, только цвет.
Для наглядности, схема обратной отрисовки выглядит так:
Сперва мы очистим буфер кадра. Затем выставим приведнёную выше функцию смешивания и начнём отрисовку с самых верхних объектов (в классическом подходе они бы рисовались последними), спускаясь к самым нижним. Последним будет нарисовано фоновое изображение.
Я опишу несколько задач, решаемых этим методом, на примере нашего проекта:
Есть и обратная сторона, а точнее ограничения: не все блендинги можно повторить для такой техники. Alpha-смешивание и additive — точно можно, а вот собственные специальные блендинги придётся или адаптировать или не использовать. Но есть выход: можно разделить этапы отрисовки сцены. Часть выполнить обратным методом, часть — обычным, у нас так и сделано для спецэффектов поверх поля и постпроцесса.
Важный момент с Additive-ом и смешанной техникой отрисовки: если он будет рисоваться ДО прохода с обратной отрисовкой и если в текстуре отсутствует информация о прозрачности (текстура вида «белое пятно на черном фоне»), то такой объект перезапишет прозрачность. В «обратном» проходе будет потеряна информация об этом участке, а визуально это будет выглядеть как «темный квадрат» или черная окантовка вокруг светлого аддитивного пятна:
Это можно побороть, модифицировав аддитивный блендинг в части смешения альфа-канала:
Но не для всех видов смешивания это подойдёт, и надёжнее будет модифицировать саму текстуру. Что имеется в виду:
Если есть текстуры вида:
То из них нужно сделать такие:
То есть яркость цветовых каналов нужно конвертировать в прозрачность и вытянуть цвета обратно пропорционально прозрачности. Получившаяся и старая текстуры должны одинаково выглядеть на черном фоне. Вручную такое реализовать вряд ли получится, имеет смысл сделать автоматический конвертер. В таком случае псевдокод преобразования каналов будет выглядеть следующим образом:
Метод даёт возможность достаточно просто и «дёшево» работать со слоями рисуемой сцены, используя альфа-канал как маску. Его сравнительно просто имплементировать в уже работающий проект: он не требует глубокой модификации кода графической подсистемы, достаточно изменить порядок отрисовки и формулы смешивания. Местами он может существенно сэкономить производительность. Есть и ограничения, но в большинстве случаев с ними можно примириться.
Есть и другой, более простой и элегантный, способ решить эти проблемы. Мы можем рисовать сцену в обратном порядке!
Небольшое отступление, чтобы напомнить, как работает классический способ отрисовки
При отрисовке на экран чего-либо через Alpha Blending мы смешиваем пиксель, который хотим нарисовать, и пиксель того, что там уже было нарисовано до нас. При этом у нас есть 4 канала RGBA, где RGB — цвет и A(Alpha) — прозрачность (другие форматы нас в данный момент не интересуют). Функция смешивания работает отдельно для трёх цветовых каналов и отдельно для канала прозрачности. Для цвета она выглядит так:
Здесь ColorSrc — RGB цвет, который мы хотим нарисовать (наша изначальная текстура, результат работы пиксельного шейдера), ColorDst — цвет пикселя в буфере, куда мы рисуем, Color_Result — то, что получилось при смешивании, и то, что будет записано в буфер. Что же такое Variable1 и Variable2, на которые мы умножаем рисуемый цвет и цвет в буфере? Это настраиваемые переменные, динамическая часть формулы (знак плюс тоже можно изменить, но сейчас нам это не нужно). Их значением может быть то, что нам уже известно на данный момент: цвета и прозрачность двух пикселей, заранее заданные константы...
Классическое смешивание полупрозрачных объектов выглядит так:
Где AlphaSrc — альфа-канал рисуемого пикселя (прозрачность), OneMinusAlphaSrc, как нетрудно догадаться, значит 1.0 — AlphaSrc. Формула аналогична такой записи: С1 * а + С2 * (1 — а). В итоге у нас получается линейная интерполяция между двумя цветами через alpha (прозрачность). То есть если альфа = 1, мы перезапишем значение в буфере, а если альфа = 0, старое значение останется неизменным. Подробнее ознакомиться с таблицей возможных значений в OpenGL можно тут.
Эта таблица справедлива и для OpenGL ES 2.0 включительно — за последние пару десятков лет тут принципиально ничего не изменилось.
Здесь ColorSrc — RGB цвет, который мы хотим нарисовать (наша изначальная текстура, результат работы пиксельного шейдера), ColorDst — цвет пикселя в буфере, куда мы рисуем, Color_Result — то, что получилось при смешивании, и то, что будет записано в буфер. Что же такое Variable1 и Variable2, на которые мы умножаем рисуемый цвет и цвет в буфере? Это настраиваемые переменные, динамическая часть формулы (знак плюс тоже можно изменить, но сейчас нам это не нужно). Их значением может быть то, что нам уже известно на данный момент: цвета и прозрачность двух пикселей, заранее заданные константы...
Классическое смешивание полупрозрачных объектов выглядит так:
Где AlphaSrc — альфа-канал рисуемого пикселя (прозрачность), OneMinusAlphaSrc, как нетрудно догадаться, значит 1.0 — AlphaSrc. Формула аналогична такой записи: С1 * а + С2 * (1 — а). В итоге у нас получается линейная интерполяция между двумя цветами через alpha (прозрачность). То есть если альфа = 1, мы перезапишем значение в буфере, а если альфа = 0, старое значение останется неизменным. Подробнее ознакомиться с таблицей возможных значений в OpenGL можно тут.
Эта таблица справедлива и для OpenGL ES 2.0 включительно — за последние пару десятков лет тут принципиально ничего не изменилось.
Как происходит формирование изображения по шагам: сначала мы рисуем фон, дальше отрисовываем все объекты по слоям друг за другом. То, что отрисовывается последним, перезаписывает предыдущие пиксели:
В чём же фокус?
Суть технологии обратной отрисовки или, как её можно ещё назвать, отложенного смешивания заключается в следующем. Мы рисуем сцену задом наперёд, используя другую формулу смешивания. Притом итоговое изображение останется точно таким же, как и при классическом подходе.
Как это работает?
Выше был описан метод смешивания через канал прозрачности изображения, которое рисуем. Теперь мы это повернём наоборот: будем использовать прозрачность уже нарисованных пикселей (а точнее, смешивание рисуемой прозрачности с уже отрисованной). То есть вместо AlphaSrc мы будем использовать AlphaSaturate, а вместо OneMinusAlphaSrc — One. Получается, если в буфере уже лежит что-то с прозрачностью = 1, то вклад будет нулевой, и цвет такого пикселя не изменится. Если там была нулевая прозрачность — сложим оба цвета вместе (для этого нам нужно будет очищать буфер кадра нулями или чёрным цветом с нулевой прозрачностью). При таком сложении результирующий цвет будет равен рисуемому. Итоговая формула выглядит так:
(прим. AlphaSaturate = min(AlphaSrc, 1 — AlphaDst) )
Значения прозрачности требуется складывать: она должна накапливаться слой за слоем, то есть у нас будет One и One в переменных смешивания для альфа-канала. Почему мы не модифицируем ColorDst и очищаем буфер нулями? Это нужно для Additive-смешивания, AdditiveBlending при этом будет отличаться только тем, что в AlphaSrc переменной у него будет находиться Zero. Он не должен модифицировать прозрачность, только цвет.
Для наглядности, схема обратной отрисовки выглядит так:
Сперва мы очистим буфер кадра. Затем выставим приведнёную выше функцию смешивания и начнём отрисовку с самых верхних объектов (в классическом подходе они бы рисовались последними), спускаясь к самым нижним. Последним будет нарисовано фоновое изображение.
Как это можно использовать?
Я опишу несколько задач, решаемых этим методом, на примере нашего проекта:
- Отсечение объектов по маске с прозрачностью. Плавное отсечение игровой комнаты:
После отрисовки игрового поля достаточно очистить прозрачность в тех местах изображения, которые мы хотим спрятать. Это делается с помощью формулы смешивания, при которой отрисовываемый объект-маска перезаписывает цвет и прозрачность обратно пропорционально своей собственной прозрачности, а степень очистки можно плавно регулировать. В данном случае для отсечения используется такая геометрия:
Она меняет свою форму по мере движения камеры между комнатами. Формула смешивания для очистки следующая:
ColorSrc = GL_ZERO, ColorDst = GL_ONE_MINUS_SRC_ALPHA, AlphaSrc = GL_ZERO, AlphaDst = GL_ONE_MINUS_SRC_ALPHA
Можно использовать любую геометрию с любыми текстурами, начиная очистку с того слоя, с которого потребуется:
- Плавное исчезновение поля делается аналогично. Цена вопроса — один DrawCall.
- Тени только на определённых объектах:
Нам требовалось, чтобы тень рисовалась только на поле с фишками и на некоторых элементах UI, но не на фоне. После отрисовки объектов, «принимающих» тени, рисуем спрайты теней, которые «затемняют цвета», не внося вклада в прозрачность. Таким образом, сразу два зайца убиты: фон без теней, а тень не меняет уровень прозрачности объекта. Блендинг тени такой:
ColorSrc = GL_SRC_ALPHA, ColorDst = GL_ONE_MINUS_SRC_ALPHA, AlphaSrc = GL_ZERO, AlphaDst = GL_ONE
- Засветку, свет и эффект бликов можно имитировать таким же образом:
Ещё пример - Подсветка для «бустеров»:
Туториальная подсветка
При этом никаких дополнительных рендер таргетов или специальной геометрии с вырезами не требуется. Можно сделать подсветку из любых спрайтов, в том числе с пересечениями друг между другом. Цена вопроса — 2 DrawCalls.
И в качестве эксперимента имитация Ambient occlusion
Не всё так просто
Есть и обратная сторона, а точнее ограничения: не все блендинги можно повторить для такой техники. Alpha-смешивание и additive — точно можно, а вот собственные специальные блендинги придётся или адаптировать или не использовать. Но есть выход: можно разделить этапы отрисовки сцены. Часть выполнить обратным методом, часть — обычным, у нас так и сделано для спецэффектов поверх поля и постпроцесса.
Важный момент с Additive-ом и смешанной техникой отрисовки: если он будет рисоваться ДО прохода с обратной отрисовкой и если в текстуре отсутствует информация о прозрачности (текстура вида «белое пятно на черном фоне»), то такой объект перезапишет прозрачность. В «обратном» проходе будет потеряна информация об этом участке, а визуально это будет выглядеть как «темный квадрат» или черная окантовка вокруг светлого аддитивного пятна:
Это можно побороть, модифицировав аддитивный блендинг в части смешения альфа-канала:
AlphaSrc = GL_ONE_MINUS_DST_ALPHA
AlphaDst = GL_ONE
Но не для всех видов смешивания это подойдёт, и надёжнее будет модифицировать саму текстуру. Что имеется в виду:
Если есть текстуры вида:
То из них нужно сделать такие:
То есть яркость цветовых каналов нужно конвертировать в прозрачность и вытянуть цвета обратно пропорционально прозрачности. Получившаяся и старая текстуры должны одинаково выглядеть на черном фоне. Вручную такое реализовать вряд ли получится, имеет смысл сделать автоматический конвертер. В таком случае псевдокод преобразования каналов будет выглядеть следующим образом:
RGB_old = Texel_in.rgb
A_old = Texel_in.a
A_middle = 1.0 / ((RGB_old) / 3.0) * A_old // linear color space
RGB_new = RGB_old * A_middle;
A_shift = minimum( 1.0 / RGB_new.r, 1.0)
A_shift = minimum( 1.0 / RGB_new.g, A_shift)
A_shift = minimum( 1.0 / RGB_new.b, A_shift)
RGB_result = RGB_new * A_shift;
A_result = (RGB_result) / 3.0)
Texel_out = Vector4(RGB_result, A_result)
Здесь я по шагам разберу отрисовку сцены нашего проекта
- Рисуем верхние объекты поля. Тип смешивания может быть любым, важно лишь чтобы объекты оставили свой след в альфа-канале.
- Отрисовываем то, что под фишками, и подложку, тут и начинается «обратное» смешивание:
- Рисуем нужную нам в этом проходе часть UI:
- Дальше следует проход, в котором мы рисуем тени, свет, блики для поля:
- Рисуем верхние части фона:
- Проход основной картинки фона:
- Дальше идёт постпроцесс, но в этой статье мы его затрагивать не будем.
Заключение
Метод даёт возможность достаточно просто и «дёшево» работать со слоями рисуемой сцены, используя альфа-канал как маску. Его сравнительно просто имплементировать в уже работающий проект: он не требует глубокой модификации кода графической подсистемы, достаточно изменить порядок отрисовки и формулы смешивания. Местами он может существенно сэкономить производительность. Есть и ограничения, но в большинстве случаев с ними можно примириться.