В этой краткой заметке будет рассказано о том, как устроена модель атмосферного рассеяния света в нашей последней 4к интре Appear by Jetlag, party-версия которой заняла почётное 12-е место в 4k intro compo на демопати Revision 2018 в апреле этого года. Cкачать бинарник бесплатно без смс можно здесь. Если, однако, у вас не Виндовс, или нет мощной современной видеокарты, то есть утешительный утупчик: Музыку к этой работе написал keen, используя 4klang. За мной же остался весь код и визуальный ряд. Здесь будет рассказано только о модели рассеяния света. Остальные вещи, как то: инструментарий, модель города, модель освещения и материалов, не затрагиваются. Смелых могу отправить читать исходники, или смотреть записи того, как я часами туплю — большая часть разработки попала на видео. Работа над этой работой началась с осознания, что основная на-полный-рабочий-день работа не оставляет времени на работу над полноценной 4к работой — на дворе уже почти середина марта, до Ревижена остаётся пара-тройка недель.
Остаётся лишь придумать что-нибудь достаточно простое для быстрого, на пару вечеров, компо-филлера. Делать очередной тупо реймарч — не уважать зрителя, поэтому я вспомнил, что несколько лет назад доводилось делать шейдер с рассеянием, и он был довольно простым, компактным и в то же время допустимо красивым, хотя и довольно медленным. В ходе недолгого обсуждения я настоял на своём, и мы решили остановиться на следующем: сделать ландшафт, залитый рассеянным светом, с закатом, облаками и сумеречными лучами (TIL как переводится выражение "god rays"). Для того, чтобы не задирать количество шагов по атмосфере до совсем неинтерактивных величин, придётся сильно дребезжать лучами (дворовый такой Монте-Карло метод), что породит видимый шум. Но не беда, если камеру двигать и сцену менять медленно и пустить эмбиент-трек, то можно безболезненно смешивать соседние кадры и темпорально сглаживать этот шум. Keen написал музыку довольно быстро — она была практически готова за две недели до Ревижена. Меня, однако, серьёзно подкосил грипп — со скорой и инфекционкой — поэтому я практически не начинал работать над шейдером вплоть до того момента, пока в более-менее как-то живом состоянии не попал на самолёт до Франкфурта. Прототип этой модели рассеяния был написан уже в воздухе. Party-версию интры мы уже на скорую руку собирали из песка и слюней на самой пати в течение нескольких остававшихся часов до дедлайна (и, наверное, парочки — после ;D), пока я параллельно отходил от гриппа, недосыпа, многочасовых перелётов, а так же постоянно отвлекался на участие в Shader showdown livecoding compo. Версия, показанная на большом экране, содержала очень много артефактов и лишь рудиментарную геометрию города на основе диаграммы Вороного со случайными высотами. В общем, 12-е место — это довольно щедро. Финальная версия, продемонстрированная выше, была сделана уже позднее и в более расслабленном режиме по 1-2 вечера в неделю в течение месяца. В общей сложности на работу ушло около 40-50 часов труда. (Примечание: я не занимаюсь программированием графики профессионально. Это — моё маленькое уютное хобби на хорошо, если сотню-другую очень расфокусированных под пивовино часов в год. Поэтому не нулевая вероятность того, что некоторые вещи ниже описаны и/или названы неправильно. Дяденьки, бейте!) Модель рассеяния позаимствована из статьи "High Performance Outdoor Light Scattering Using Epipolar Sampling" товарища Egor Yusov, опубликованной в книге GPU Pro 5, с полностью выкинутым эпиполярным семплингом. Фотоны солнышка бомбардируют атмосферу земли и взаимодействуют с частицами воздуха. Фотон может быть рассеян частицей, что влечёт за собой изменение направления фотона, или он может быть поглощён, что означает, что фотон потерян, а его энергия была преобразована в какую-то иную форму.
Оба процесса вероятностные и зависят в частности от плотности частиц и энергии фотона (которая соответствует его цвету). На пальцах, "красные" фотоны имеют более низкую вероятность взаимодействия с воздухом, поэтому они преодолевают толщу атмосферы относительно нетронутыми.
"Голубые", однако, имеют более высокую вероятность рассеяния, отчего они могут менять направление неоднократно и преодолевать значительные расстояния в атмосфере, прежде чем достигнут (или нет) наблюдателя.  Интересующие нас параметры взаимодействия света с воздухом следующие: Предполагается, что воздух состоит из двух типов частиц, рассеяние на которых происходит независимо: молекулы (Рэлеевская модель) и аэрозоли (относительно крупные сферические частицы, Mie scattering в англоязычной литературе). Модели отличаются только разными значениями для параметров выше. Для обеих моделей считается, что плотность соответствующих частиц экспоненциально убывает с высотой: , где  — плотность на уровне моря. Коэффициенты  пропорциональны , и их значения ниже даны для уровня моря. Приближение рассеяния строится на пускании луча из каждого пикселя камеры и расчете, сколько света из атмосферы должно попасть из этого направления. Каждому лучу соответствуют все три RGB компоненты света, будто вдоль этого луча летят три фотона с соответствующими энергиями. Свет, достигающий камеры, формируется следующими процессами в толще воздуха: Из соображений производительности считаем, что свет может от рассеяния попасть в направление на камеру только один раз, и всем остальным светом (который был рассеян более одного раза) можно пренебречь. Этого делать не рекомендуют для сумерек, но что поделать. Этот подход изображен на следующем прекрасном изображении (я старался!):  Таким образом, количество света, которое должно быть зарегистрировано пикселем камеры в точке  может быть рассчитано как сумма , где  — врассеянный свет от солнца, а  — количество света от точки  объекта геометрии сцены, достигающего . , где  — это свет, испущенный из точки  в направлении камеры.  называется оптической толщиной среды между точками  и , и вычисляется следующим образом:    Принимая во внимание, что члены  состоят из константы уровня моря и переменной плотности, это выражение можно преобразовать до:    Обратите внимание, что я специально не раскрываю , потому что мы будем их менять в дальнейшем при добавлении облаков. Также обращаю внимание на то, что  — вектора RGB (по крайней мере  имеют разные значения для RGB-компонент, а  — вектор просто для консистентности). Члены с  под интегралом — скаляры. Солнечный свет  рассчитывается интегрированием по всем точкам  вдоль отрезка  и накоплением всего входящего солнечного света, рассеивающегося в сторону камеры и затухающего в толще . Количество солнечного света, достигающего точки , вычисляется по аналогичной формуле , где  — яркость солнца, а  — точка, в которой луч из точки  в направлении солнца  покидает атмосферу. Доля этого света, которая будет рассеяна в направлении камеры, составляет . Итого получим:    Можно заметить, что: Это позволяет преобразовать выражение в:    где        и  отличаются только функциями плотности, их экспоненты при этом одинаковы. Эти интегралы никому так и не удалось вычислить аналитически, поэтому придётся считать численно, с помощью рэймарчинга (как, говорится в исходных публикациях, делать нельзя!). Из соображений размера и лени считать будем как можно более тупо:  Маршировка по лучу будет производиться в противоположном потоку света направлении: от точки камеры  до пересечения луча с геометрией . Отрезок  делится на  шагов. Прежде чем запустить марш, инициализируем переменные: Далее для точки  каждого шага между  и : Финальный цвет после рэймарчинга вычисляется суммой слагаемых: Немного причёсанные и откомментированные исходники рассеяния, взятые (почти) напрямую из самой интры: Зазыкать на шейдертое Неплохо, но такую картинку можно было бы также получить гораздо проще каким-нибудь хитрым нагромождением градиентов. Обманным путём же получить облака и god rays значительно сложнее. Давайте добавим. Идея заключается в приближении облаков аэрозолями и модификации только функции плотностей densitiesRM(). Это может быть не настолько физически корректно, как хотелось бы (понятия не имею, как на самом деле приближается рассеяние света в облаках в компьютерной графике). Вопреки ожиданиям, получаем не красивые облака, сладкую победу и поклонниц, а артефакты. Попытка в лоб поднять количество шагов артефакты убирает не полностью, однако значительно портит производительность. Решения костыли, которыми подтыкается интра: Увеличивается количество шагов для зон, требующих большего количества деталей (например, слой с облаками). Именно для этого сделано такое нелепое разделение функций на scatterImpl() и scatterDepthInt(): В результате традиционного рэймарчинга функций расстояния и затенения уже получено расстояние L до точки B и цвет пикселя Lo. Эти значения просто подставляются в функцию scatter(). Если луч не упёрся в геометрию и покинул сцену, тогда цвет Lo нулевой, а L рассчитывается с помощью escape() — считается, что луч покинул атмосферу. Вроде всё. … На самом деле конечно не всё. Довольно большая боль теперь притереть все части друг к другу так, чтобы оно в целом выглядело правдоподобно. Просто куча возни с подкручиванием параметров, геометрии сцены, шумовых функций, траектории и ракурса камеры. Боюсь, у меня здесь нет хороших советов, кроме как рекомендации много часов итерировать и биться головой о стену. После обработки shader minifier'ом конечный шейдерный код рассеяния имеет размер около 1500 байт. Crinkler ужимает его до ~700 байт, что составляет примерно 30% всего шейдерного кода. Я не умею в компьютерную графику. Eclectic Engineer