Моделирование воды в компьютерной графике в реальном времени до сих пор остается весьма сложной задачей. Особенно актуально это при разработке компьютерных игр, в которых требуется создать визуально привлекательную картинку для игрока в рамках жесткого ограничения вычислительных ресурсов. И если на десктопах программист еще может рассчитывать на наличие мощной видеокарты и процессора, то в мобильных играх необходимо опираться на значительно более слабое железо. В этой статье мы хотели поговорить о моделировании волн в открытом море и представить алгоритм, который позволил достичь достаточно интересные результаты при приемлемых 25-30Fps на среднем китайфоне. В общем виде для моделирования поверхности волн в открытом море обычно используют экспериментально подобранный спектр Филлипса, т.е. разложение всего спектра волн на Фурье составляющие, которые анимируются во времени. Однако, данное решение весьма ресурсоемкое и, хотя быстрое Фурье разложение может выполняться на видеокарте, его практически невозможно использовать на слабых смартфонах как за счет быстродействия, так и из-за ограничения функционала видеокарты (поддержка рендеринга во float текстуру, ограничение в точности вычислений). Пример такого метода можно найти здесь и здесь. Более простым методом является генерация распределения волн заранее (или непосредственно в шейдере), а затем сложение волн разной фазы и амплитуды.
Несмотря на простоту метода он может обеспечить весьма впечатляющие результаты, однако требует точной настройки и имеет ряд ограничений. Рассмотрим этот подход подробнее и попробуем разобраться с возникающими нюансами как в части качества картинки так и быстродействия. Для генерации волны нам нужно знать ее высоту в определенной точке. Ее можно получить множеством способов. Например, просто использовать комбинацию синусов, косинусов от координат этой точки, но очевидно, что полученное распределение высот выглядит слишком искусственно и не подходит для решения нашей задачи. В этом случае наблюдается периодичность даже если менять направление волн относительно друг друга. Забегая немного вперед, на изображении внизу представлена поверхность воды, полученное путем сложения волн разной амплитудой и высоты.  Тот же алгоритм, но издали наблюдается периодичность. Более оптимально использовать шум Перлина, пример генерации которого на шейдерах представлен ниже (код для Cg, для GLSL требует косметических изменений): rand — генерирует псевдослучайное значение, а noise интерполирует четыре случайный числа в углах квадрата на любую точку внутри него. Периодичности не наблюдается Результат уже лучше, распределение волн заметно изменилось, но их форма все также отличается от естественных волн. При небольшой амплитуде это еще приемлемо, но для больших волн характерно наличие резких пиков.  При использовании спектра Филлипса это решается смещением сетки поверхности воды к пикам, что придает необходимую форму. Однако, более простым методом и существенно более эффективным в нашем случае является использование простой формулы, что приводит к удовлетворительному результату в виде остроконечных волн   Недостаток метода в том, что он приводит к появлению кругов и прочих замкнутых фигур, заметных глазу, но при соответствующем подборе параметров этот недостаток становится несущественным. Очевидно, что наличие только одной фазы (или иначе октавы) для воды недостаточно для получения реалистичной воды и требуется наложить несколько волн с разной амплитудой и фазой, что позволяет получить как большие волны так и мелкую рябь на поверхности.  Хорошие результаты получаются при выборе sp, sa равным 2, но мы вольны выбирать любые значения, обеспечивающие приемлемые результаты. Подбор этих параметров позволяет получить разнообразные типы волн и управлять их изменением вплоть до полного штиля. Для анимации волн достаточно каждую фазу смещать в своем направлении. При этом необходимо учесть, что скорость движения больших волн больше и волны двигаются преимущественно в одном направлении.  Как показали эксперименты вполне достаточно иметь порядка 7 фаз для получения “вкусной” картинки и, в принципе, в очередном велосипеде не было необходимости. Однако, первые же тесты на смартфонах повергли в шок, т.к. фпс неумолимо стремился к 0, что не могло не огорчать. Посмотрим сколько операций в шейдере потребуются для отображения одной точки: В целом можно не согласиться с приведенными выше рассуждениями, т.к. высоту поверхности в целом достаточно вычислить для каждой вершины меша, что заведомо быстрее, чем считать для каждой точки экрана. Но в таком случае ни о каком реализме можно и не мечтать. Максмум, что мы получим очень грубое приближение к желаемому результату. Таким образом, все приведенные выше операции приходится вычислять именно во фрагментном шейдере. Варианты оптимизации:  Последний вариант имеет право на жизнь, но fps также слишком мал — порядка 3-4 кадров в секунду. Кроме того, в этом случае при расчете нормали мы упираемся в точность хранения данных в текстуре, что приводит к появлению "ступенек" на воде. Конечно можно использовать текстуру с вещественными числами, но тогда мы дополнительно ограничим количество поддерживаемых устройств. В тоже время, для получения высоты точки необходимо считать значение высоты из текстуры, но нам ничего не мешает запечь в эту текстуру еще и карту нормалей. Таким образом, одной выборкой из текстуры мы можем получить высоту точки и одновременно нормаль к ней. Запекание данных в текстуру можно выполнить заранее или же непосредственно на видеокарте с помощью рендеринга в текстуру (например перед запуском приложения или смене параметров). При вычислении текстуры необходимо обеспечить достаточную точность хранения нормалей. Если сохранять нормаль в привычном виде карты нормалей, то эта точность оказывается недостаточной, что проявляется в артефактах изображения.  Действительно, для получения нормали нам достаточно сохранять лишь проекцию нормали в горизонтальной плоскости (nx, ny). В общем виде, каждая из этих компонент меняется в диапазоне [-1,1]. Но в случае воды используемый диапазон оказывается существенно меньше, т.к. нормали в основном ориентированы вверх (что особенно заметно при генерации волн малой амплитуды). Таким образом, если нормализовать этот диапазон по максимальному значению, то мы сможем существенно увеличить точность хранения нормалей и, соответственно, качество картинки. При этом для генерации результирующей волны надо аккуратно преобразовать нормаль каждой фазы с учетом амплитуды волны, ее фазы, а также подобранного выше коэффициента масштабирования. Несмотря на выполненные оптимизации у нас до сих пор происходит выборка из 7 текстур и хотелось бы уменьшить это количество. Как упоминалось ранее, уменьшение этого числа в общем виде не желательно. Однако, форму волн мы храним в текстуре, в которой можем заранее сгенерить вместо одной фазы сразу несколько. Это решает проблему генерации множества волн небольшим количество проходов, однако при анимации становится заметно, что часть волн движется с одной скоростью. Для уменьшения этого эффекта можно сохранять в текстуру волны с большей разницей фаз, например 1 — 3 — 5, а при рендеринге мы получим 1-1'-3-3'-5-5'. Также мы использовали подход при котором первые две фазы из четырех использовали одну текстуру, а последние две уже другую с иным распределением и количеством фаз. Именно этим способом получено изображение, приведенное в начале поста Кроме описанных выше методов мы тестировали несколько других вариантов оптимизации. Наиболее интересным из них нам показалась интерполяция волн по времени. Смысл этого метода в том, что мы можем отрендерить в текстуру результирующую карту высот и нормалей в некоторые моменты времени, а для прочих сделать интерполяцию высот и нормалей.
Таким образом раз в N кадров требуется сделать полный расчет поверхности, а N-1 кадр можно считать путем простой выборки из двух текстур.  В таком случае получается, что одна волна уменьшается и рядом появляется следующая. При уменьшении N анимация становится более плавной, хотя эффективность метода и снижается.
Таким образом его можно вполне эффективно использовать в определенных условиях, например, при малой скорости волн или на относительном удалении от поверхности, когда недостаток метода становится менее заметным. На данный момент мы получили вполне жизнеспособную и симпатичную водичку, однако не обговаривали какую сетку будем использовать для поверхности воды. Очевидно, что для отображения воды до линии горизонта нам фактически придется использовать бесконечно большой меш точек (по крайней мере растянуть его до плоскости отсечения камеры), в то время как, количество точек в нем весьма ограничено). Простое линейное масштабирование не работает, т.к вблизи камеры меш становится слишком разреженным, а в вблизи линии горизонта, наоборот, излишне густым. Самым простым способом решения этого проблемы является масштабирование меша в вершинном шейдере в зависимости от расстояния до камеры. Недостаток же вполне очевиден — сложно подобрать необходимые параметры, а полученное распределение точек все также будет неравномерно. Другой вариант — это использование меша с разной детализации в зависимости от расстояния. Но это может приводит к рывкам при смене уровня детализации, а также требует введения дополнительной логики контроля этих уровней. Наиболее удобным является использование метода projected Grid, который в общем можно описать следующим образом: В качестве аналогии можно представить слайд с точками, прикрепленный к прожектору (камере). Там где тень от точки попадает на плоскость и находится искомая точка. В то же время с точки расположения камеры наблюдатель увидит всю ту же равномерную сетку Этот метод имеет несколько преимуществ: В основе получения корректного изображения лежит правильный учет всех составляющих светового потока — отраженный свет, свет рассеянный в толще воды, блики от солнца и т.д.
В общем виде это является нетривиальной задачей, но в нашем случае мы использовали более простой подход, т.к. не было необходимости отображать поверхность дна, каустики и прочие.  Подробное описание расчета освещения не будем, т.к. есть много подробных статей посвященных этой тематики (например здесь, здесь). Хотелось бы только отметить необходимость выбора "правильной" формулы для вычисления коэффициента Френеля.  В первой версии шейдера мы никак не могли добиться реалистично выглядящей воды. Полученный результат был больше похож на нарисованную или пластиковую воду. Оказалось, что мы использовали самую примитивную версию для вычисления коэффициента Френеля:      Приведенный способ симуляции воды мы реализовали в Unity3D. Для расчета освещения и создания формы волн мы использовали явные вертексные и фрагментные шейдеры (можно было реализовать и на поверхностных шейдерах, но это не играет принципиальной роли). При тестах на андроид смартфонах мы получили от 25 к/c (Adreno 405 + MediaTek MT6735P) до 45 (Adreno 505 + Snapdragon 430). На части смартфонах, как и ожидалось, приложение на заработало из-за отсутствия поддержки чтения из текстуры в вершинном шейдере. При этом интересно отметить, что расчет освещения в результате оказался сопоставим со сложностью c генерацией волн. При необходимости можно поднять fps за счет использования других моделей освещения или отключения части элементов как карта окружения, блики и т.д Анимации с 7 фазами волн Ютуб Анимации с 4 фазами Ютуб Спасибо за внимание!
Авторские материалы для разработчиков мы также публикуем в наших соцсетях – ВКонтакте и Telegram.