Пример работы с SFML в dasBox — рендер полупрозрачных объектов в рендер-таргет. Тривиальная задача для графического программиста, но почему-то многие разработчики игр не знают, как это корректно сделать.
Intro
Изначальная задумка поста — собрать небольшой пример, показывающий алгоритм рендеринга полупрозрачных объектов в текстуру, с последующим корректным отображением этой текстуры, как если бы объекты рендерились на экран напрямую, без использования этой текстуры. Но попутно оказалось, что можно запилить фреймворк для таких демок, с возможноостью в live-режиме поиграться с параметрами и посмотреть результат.
dasSFML
Примеры на Opengl+GLFW
:
daScript OpenGL
daScript - live-режим
В отличие от GLFW, SFML
представляет простые объекты-обёртки над функциями OpenGL для работы с текстурами, шейдерами, рендер-таргетами, и прочими примитивами библиотеки рендера, в то же время представляя для кастомизации практически все параметры рендера (во всяком случае необходимые для задуманного примера), для того, чтобы можно было писать небольшие примеры алгоритмов рендера на OpenGL.
Для начала, можно “завести” простейший пример из комплекта привязки SFML
к daScript
.
Для его сборки выкачиваем сабмодули daScript
:git submodule update --init --recursive
Затем включаем сборку этого модуля в CmakeLists.txt и добавляем линковку статической библиотеки с модулем libDasSFML и самих библиотек SFML:option(DAS_SFML_DISABLED "Disable dasSFML (SFML multimedia library)" OFF)
...
target_link_libraries(sfmlApp
libDasModuleSFML
sfml-graphics
sfml-network
sfml-system
sfml-window
#для windows также:
legacy_stdio_definitions
winmm
Opengl32
sfml-main
)
и перегененируем проект./generate_msvc_2019.bat
Затем в хост-приложении на C++:
run_script
вызывает метод main
из примера, который создаёт SFML-окно и запускает цикл обработки сообщений в нём.
В качестве фреймворка для создания окна используется имеющийся в SFML класс Window
. Однако проще воспользоваться другим фреймворком, в который уже внедрены возможности daScript-а по перезагрузке кода.
dasbox
dasbox — примитивный движок для 2d-игр, который Gaijin-ы использовали для проведения конкурса Gaijin Jam (игра-победитель). Движок имеет простую апишку, на которой можно потренироваться использованию языка на уровне “как будто типизированная lua”, а также настроен для подключения отладчика языка, веб-инспектора для отображения состояния ecs-модуля и горячей перезагрузке кода.
Но для меня интереснее другое — dasbox
использует в качестве бекэнда SFML
. И хотя это осознанно скрыто от пользователей для того, чтобы можно было сменить бекэнд, можно немного “поломать инкапсуляцию”, для того, чтобы воспользоваться в нём модулем dasSFML
, и получить доступ к более богатому API, получив от самого dasbox-а фичи и инструменты по работе с кодом.
dasbox_sfml — порт дасбокса с включенным модулем dasSFML.
Базовый пример на нём выглядит так:
dasBox_sfml
подхватывает изменения в файле с кодом, а также отображает в своей консоли ошибки в этом файле.
Идиомы daScript
в примере:
using()
- создание переменной временного типа, который существует только в пределах блока using
interface
- приведение к базовому типу
Генератор привязок на C++ позволяет передать отношение наследования двух C++ типов в daScript так:
daScript
заставляет думать о типах и их времени жизни немного больше, чем C++, но и позволяет выразить больше таких утверждений, которые сможет проверить компилятор, чтобы уберечься от ошибок.
RenderTarget
Следующий пример — рендер в текстуру и отображение её на экране. В SFML для этого можно использовать класс RenderTexture
Пока всё хорошо, и круг отрендеренный в текстуру выглядит также, как если бы был выведен на экран напрямую
Полупрозрачные объекты в RenderTarget
Полупрозрачные круги, отрендеренные в текстуру, выглядят темнее, чем отрисованные на экране напрямую. С реальными текстурами (или отрендеренными в текстуру шрифтами), проблема обычно выглядит как темная рамка на краях объекта (там где края ради сглаживания плавно “уходят” в прозрачность).
Из-за чего это возникает?
Цвет пиксела отрисованного объекта смешивается в пикселем, уже находящимся в буфере цвете по настраиваемым формулам. При прямой отрисовке порядок рендера получается таким:рисуется пиксел красного круга -> смешивается с цветом dst1 фона (белое изображение) = получаем цвет dst2
рисуется пиксел зеленого круга -> смешивается с цветом dst2 (фон + красный круг)
Тогда как в случае рендера в текстуру происходит:рисуется пиксел красного круга -> смешивается с фоновым цветом рендер таргет текстуры dst_rt1 ("пустой" rgba цвет, rgb каналы по умолчанию чёрные) = получаем dst_rt2
рисуется пиксел зелёного круга -> смешивается с цветом dst_rt2 (пустой цвет текстуры + красный круг)
текстура выводится на экран -> смешивается цвет фона dst1 с цветом рендер таргет текстуры (пустой цвет текстуры + красный круг + зелёный круг)
Видно, что в итоговом цвете на экрана присутствует влияние цвета render-target текстуры — прозрачного по альфа каналу, но в итоговой формуле кроме альфа канала влияение оказывают также и RGB каналы цвета (чёрного или любого другого — неважно, но избавиться от влияния этого фонового цвета без изменения формулы смешивания невозможно, “невидимый” цвет начинает быть видимым).
Настройка режимов смешивания
OpenGL
(и SFML
над ней) дают возможность переключать формулы режимов смешивания. Важно отойти от представления “определенная формула магическим образом включает полупрозрачность” к тому, что графический API просто даёт возможность изменить уравнение смешивание, а вывод конкретных формул ложится на плечи программиста.
Стандартное уравнение смешивания для полупрозрачности смешивает цвета в пропорции:glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
GL_SRC_ALPHA - коэффициент смешивания рисуемого цвета - чем прозрачнее рисуемая фигура, тем меньше видно её цвет
GL_ONE_MINUS_SRC_ALPHA - коэффициент смешивания фонового цвета - обратный, чем прозрачнее рисуемая фигура, тем больше видно цвет за ней
r1 - цвет фона
r2 - цвет 1-го круга
r3 - цвет 2-го круга
после отрисовки фона m1 = (a1 * r1)
смешанный цвет после отрисовки первого круга m2 = (a2 * r2 + (1.0 - a2) * a1 * r1)
смешанный цвет после отрисовки второго круга m3 = (a3 * r3 + (1.0 - a3) * (a2 * r2 + (1.0 - a2) * a1 * r1)) =
= (a3 * r3 + (1.0 - a3) * a2 * r2 + (1.0 - a3) * (1.0 - a2) * a1 * r1)
При рендере в текстуру по умолчанию используется другая формула, которая разделяет рассчёты для RGB-каналов цвета и для A-канала.
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); |
Эта формула годится только для рендера полностью непрозрачных объектов. Если какие-то части объекта прозрачные, необходимо использовать другую пару формул (для рендера в текстуру + для рендера полученной текстуры на экране). Для рендера в текстуру при этом можно даже получать “неправильные” цвета, которые можно потом смешать с изображением на экране так, чтобы смешанный итоговый цвет стал корректным.
Хороший разбор проблемы на stackoverflow
Формулы смешивания также разжеваны в статье Как работает альфа-композитинг
Source-Over
Одно из возможных решений — смешать цвет полученной render target текстуры в режиме Source-Over
(термин из статьи выше, из списка операторов смешивания Портера-Даффа):glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA)
итоговый цвет m3 = rt3 + m1 * (1.0 - rta3)
подставим сюда значения значения из предудыщей формулы для рендерт-таргет цвета и текстуры:
m3 = (a3 * r3 + (1.0 - a3) * a2 * r2) + (a1 * r1) * (1.0 - a3 - a2 * (1 - a3))
Теперь можно сравнить полученные значения итогового цвета m3 из формулы прямого рендера и этой формулы:
m3_direct == m3_sourceover
(a3 * r3 + (1.0 - a3) * a2 * r2 + (1.0 - a3) * (1.0 - a2) * a1 * r1) == (a3 * r3 + (1.0 - a3) * a2 * r2) + (a1 * r1) * (1.0 - a3 - a2 * (1 - a3))
(1.0 - a3) * (1.0 - a2) == (1.0 - a3 - a2 * (1 - a3)
1.0 - a3 - a2 + a2*a3 == 1.0 - a3 - a2 + a2*a3
Тада! убедились в равенстве
Код смешивания в режиме Source-Over
, правильный результат:
Premultiply alpha
Если посмотреть на коэффициенты смешивания в “неправильном” блендинге, и в Sourse-Over
, можно прийти к более правильному интуитивному пониманию лишнего влияния цвета в неправильной версии. В неё не “вмешан” лишний чёрный цвет, а добавлено лишнее умножение цвета на альфа канал, которое “гасит” этот цвет больше, чем необходимо при корректном смешивании.
Можно пойти другим путём — убрать умножение цвета на альфу при смешивании совсем, но производить его 1 раз в шейдере — тогда не нужно переключать режим прозрачности, но нужно переключать шейдер, с которым отрисован объект. Преимуществом такого способами может быть то, что исходное изображение часто можно домножить на альфа-канал еще до запуска приложения (в демо-примере изображение генерится динамически, поэтому умножение сделано в шейдере).
Универсальный режим смешивания для premultiply изображений:glBlendFuncSeparate(GL_ONE, GL_ONE_MINUS_SRC_ALPHA, GL_ONE_MINUS_DST_ALPHA, GL_ONE);
Порядок рендера:
1. Вывод в изображения в рендер-таргет текстуру с premultiply-шейдером (или же вывод подготовленного заранее изображения с обычным шейдером)
2. Вывод рендер-таргет текстуры на экран с обычным шейдером (чтобы избежать лишнего умножения)
математика (скопипащена с stackoverflow ответа):
after layer 2: (a2 * r2, a2)
after layer 3: (a3 * r3 + (1.0 - a3) * a2 * r2, (1.0 - a2) * a3 + a2)
srcR = a3 * r3 + (1.0 - a3) * a2 * r2
srcA = (1.0 - a2) * a3 + a2
dstR = a1 * r1
ONE * srcR + ONE_MINUS_SRC_ALPHA * dstR
= srcR + (1.0 - srcA) * dstR
= a3 * r3 + (1.0 - a3) * a2 * r2 + (1.0 - ((1.0 - a2) * a3 + a2)) * a1 * r1
= a3 * r3 + (1.0 - a3) * a2 * r2 + (1.0 - a3 + a2 * a3 - a2) * a1 * r1
= a3 * r3 + (1.0 - a3) * a2 * r2 + (1.0 - a3) * (1.0 - a2) * a1 * r1
Заметки по daScript:
- Вывод объекта с изменённым режимом блендинга и шейдером в
SFML
инкапуслирован в объектеRenderStates
- Вместо создания временных типов в этом примере используются “обычные” для
daScript
указатели:
Такие указатели хранят объект в куче и владеют им, по семантике близко к std::unique_ptr
. При желании можно освободить объект вручную, присвоив указателю nullptr
.
- ключевое слово implicit после имени типа в аргументах функции позволяет функции работать с обычными типами, так и с временными.
Код примеров - https://github.com/spiiin/dasbox_sfml/tree/main/samples/sfml_blending