Рендерим облака на мобильных девайсах

3 года назад художник спросил меня:
 — Слушай, а можно в нашу мобильную игру добавить красивые облачка?
 — Нет, это абсолютно невозможно, у нас постоянно вращается камера, так что билборды будут смотреться очень фальшиво даже если на них добавить карты нормалей, а другие способы…
*художник погружается в летаргический сон*

Для меня нет большего удовольствия, чем выяснять, что я был неправ.

Про фотореалистичный рендеринг облаков написано много статей, но если хочется рисовать облака на смартфоне, приходится придумывать кучу всяких хаков, упрощений и допущений.
Под катом подробное описание рендеринга облаков на мобильных и много html5 гифок.

Собираем данные


Нам понадобятся:

  1. Глубина мира: jfe798ofz5dxlu5vunpv9wwfmmw.jpeg
  2. Глубина облаков: kvwzcgb3spp2tnssdpqlbmohdmw.jpeg
  3. Нормали облаков: axg_d9e5-blqemvmmpyihd1-xao.jpeg


Немного о формате
Левая половина изображения — Aльфа канал. Чем темнее — тем прозрачнее.
Правая половина изображения — RGB каналы.

Единственный гарантированно поддерживаемый формат текстур на мобильных — ARGB32, так что его я и буду использовать.

Глубина зашифровывается в RG каналы текстуры, при этом $z = r + g / 255$.
Нормаль представляется как 3d вектор в пространстве камеры, причем $rgb = (normal + 1) / 2$, т.к. RGB не поддерживает отрицательные значения.


Размываем


Размываем нормали в 2 прохода:

  1. По горизонтали:
    2k-cpvew12t8zmjfgakgjuru2e0.jpeg
  2. По вертикали:
    pql-vpciflqu24zdp7dsxfxs_cu.jpeg


Примечание: нормали размываем активнее чем прозрачность, это даст облакам стать мягкими, но не даст им потерять очертания
42obweyzxort4khvpegvd96csxu.png


Аналогично размываем карту глубины облаков:

a3tv19p9yssupyxtgcgh0kzpwci.jpeg

Проецируем шум


Неплохо было бы добавить шума в наши данные.
Есть 3 варианта:

  1. 3D текстура — требует много памяти, медленно работает на мобильных.
  2. Генератор шума в шейдере — для шума перлина нужно много раз вызывать ГСЧ => медленно работает; Нет художественного контроля: нельзя включить другой тип шума без переписывания кода.
  3. Трипланарная проекция 2d текстуры — генерируем текстуру с шумом, проецируем её по осям X, Y и Z. Эффективно; можно подставить любую текстуру; занимает мало памяти.


Временно отключим размытие чтобы лучше понять как работает проекция шума.

Трипланарная проекция
Если $p$ — координаты точки в 3d пространстве, а $n$ — нормаль к поверхности, то проекция рассчитывается так:
$color = noise(p.yz) * n.x^2 +\\ \qquad \quad\, noise(p.zx) * n.y^2 +\\ \qquad \quad\, noise(p.xy) * n.z^2$

Так как длина вектора $n$ равна 1, сумма квадратов его координат дадут 1, сохранив яркость шума.

Проецируем шум по оси X:

Проецируем шум по осям X и Y:



Проецируем шум по осям X, Y, Z:

Теперь используем этот шум, чтобы изменить карту глубины облака по формуле:
$depth \mathrel{+}= noise.r*sin(t * \pi \qquad \;\;\,) + \\ \qquad \qquad \; noise.g*sin(t * \pi + \dfrac{\pi}3\ ) + \\ \qquad \qquad \; noise.b*sin(t * \pi+\dfrac{2\pi}3)$


И спроецируем шум заново:


Вернем размытие:

Освещение


Освещение складывается из 2 составляющих:

  1. Псевдо диффузное освещение
    $t = saturate((cos(a) + lightEnd) / (1 + lightEnd))$
    $color = lerp(lightColor, shadowColor, t) = lightColor * t + shadowColor * (1 - t)$
    Иллюстрации
    2ilqvxwjd77opn-zjonxznlxlqq.jpeg
    Зависимость освещения от угла $a$ при различных значениях $lightEnd$:

    У этой формулы есть физический смысл: именно так выглядело бы наивное распространение света у сферического облака с линейным затуханием яркости и без учета рассеивания.

    Результат:

    qcmeqi7hkycacv4olhxnv-oxu08.jpeg

  2. Просвечивание
    Если в этом пикселе нет ни одного объекта из твердого мира, добавляем просвечивание:
    $t = saturate(radius - distance) ^ 2 * (1 - color.a) ^ 2 $
    $color = lerp(color, lightColor, t)$
    Где
    $radius$ — радиус просвечивания, а 
    $distance$ — расстояние от солнца до текущего пикселя

    bmotiwbejcjkl7f4xpsr6fwefoi.jpeg

Применяем шум


Пока мы применили шум только к карте глубины.
Давайте в процессе применения освещения тоже используем шум.
Прибавим вектор шума к нормалям:

Сдвинем позицию из которой мы читаем на $noise.xy$:

Наложение облаков на остальной мир


Накладываем на остальной мир с помощью альфа-блендинга, добавляя прозрачность там, где объекты мира близки к поверхности облаков, или и вовсе заслоняют их.
$color.a \mathrel{*}= 1 - saturate((cloudDepth + fallback - worldDepth) / fallback)$
Где $fallback$ — глубина, на котором объект пропадает из видимости внутри облака.

Конечный результат:

© Habrahabr.ru