Привет. Мы полностью переписали мобильную версию Хабра. Теперь все работает быстрее и выглядит современнее.

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

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

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



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

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


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

  1. Глубина мира:
  2. Глубина облаков:
  3. Нормали облаков:

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

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

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

Размываем


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

  1. По горизонтали:
  2. По вертикали:

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


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



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


Неплохо было бы добавить шума в наши данные.
Есть 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)$
    Иллюстрации

    Зависимость освещения от угла $a$ при различных значениях $lightEnd$:

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

    Результат:


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




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


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



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



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


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



Производительность


Если запустить все эти преобразования в разрешении Full HD, ваш топовый смартфон заплачет, свернется в клубочек и начнет показывать слайдшоу с частотой 15-20 fps: телефоны вытягивают 3d графику только с помощью технологии early-z, которая уменьшает количество операций до выполнения трёх-четырёх простых шейдеров на пиксель. А мы выполняем много вычислений в каждом пикселе.

Что делать? Пора избавляться от операций. Формы у облаков расплывчатые, а движущийся шум прикроет наши грязные делишки: будем резать разрешение!

  1. Карта глубины мира рисуется в полном или половинном разрешении (всё равно у смартфонов очень мелкие пиксели).
  2. Все остальные операции производятся в разрешении $\dfrac{1}{4}$, уменьшая площадь в 16 раз => увеличивая производительность в 16 раз.
  3. Операцию наложения мы производим уже в полноразмерный экранный буфер, при этом используя исходную карту глубины мира, что позволяет показывать четкие контуры объектов, поддерживая иллюзию того, что облака рендерились в полном разрешении.

Итоговая производительность:
7 ms на nexus 5x при средних настройках графики (бюджетный телефон 2015 года).
0.5 ms на ноутбуке с GTX 940M, что означает что такие облака по производительности отлично подойдут для VR, где важна высокая частота кадров.

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



Не знаю, можно ли выкладывать ссылку на Asset Store, но кто ищет — тот всегда найдёт:)

UPD: Добавил раздел про производительность
Проголосовать:
+69
Сохранить: