Моделирование воды в компьютерной графике в реальном времени до сих пор остается весьма сложной задачей. Особенно актуально это при разработке компьютерных игр, в которых требуется создать визуально привлекательную картинку для игрока в рамках жесткого ограничения вычислительных ресурсов. И если на десктопах программист еще может рассчитывать на наличие мощной видеокарты и процессора, то в мобильных играх необходимо опираться на значительно более слабое железо.
В этой статье мы хотели поговорить о моделировании волн в открытом море и представить алгоритм, который позволил достичь достаточно интересные результаты при приемлемых 25-30Fps на среднем китайфоне.
В общем виде для моделирование поверхности волн в открытом море обычно используют экспериментально подобранный спектр Филлипса , т.е. разложение всего спектра волн на Фурье составляющие, которые анимируются во времени. Однако, данное решение весьма ресурсоемкое и, хотя быстрое Фурье разложение может выполняться на видеокарте, его практически невозможно использовать на слабых смартфонах как за счет быстродействия, так и из-за ограничения функционала видеокарты (поддержка рендеринга во float текстуру, ограничение в точности вычислений). Пример такого метода можно найти здесь и здесь.
Более простым методом является генерация распределения волн заранее (или непосредственно в шейдере), а затем сложение волн разной фазы и амплитуды.
Несмотря на простоту метода он может обеспечить весьма впечатляющие результаты, однако требует точной настройки и имеет ряд ограничений. Рассмотрим этот подход подробнее и попробуем разобраться с возникающими нюансами как в части качества картинки так и быстродействия.
Генерация волн
Для генерации волны нам нужно знать ее высоту в определенной точке. Ее можно получить множеством способов. Например, просто использовать комбинацию синусов, косинусов от координат этой точки, но очевидно, что полученное распределение высот выглядит слишком искусственно и не подходит для решения нашей задачи. В этом случае наблюдается периодичность даже если менять направление волн относительно друг друга.
Забегая немного вперед, на изображении внизу представлена поверхность воды, полученное путем сложения волн разной амплитудой и высоты.
Тот же алгоритм, но издали наблюдается периодичность.
Более оптимально использовать шум Перлина, пример генерации которого на шейдерах представлен ниже (код для Cg, для GLSL требует косметических изменений):
float rand(float2 n) {
return frac(sin(dot(n, float2(12.9898, 4.1414))) * 43758.5453);
}
float noise(float2 n) {
const float2 d = float2(0.0, 1.0);
float2 b = floor(n), f = smoothstep(0, 1, frac(n));
return lerp(lerp(rand(b), rand(b + d.yx), f.x), lerp(rand(b + d.xy), rand(b + d.yy), f.x), f.y);
}
rand — генерирует псевдослучайное значение, а noise интерполирует четыре случайный числа в углах квадрата на любую точку внутри него.
Периодичности не наблюдается
Результат уже лучше, распределение волн заметно изменилось, но их форма все также отличается от естественных волн. При небольшой амплитуде это еще приемлемо, но для больших волн характерно наличие резких пиков.
При использовании спектра Филлипса это решается смещением сетки поверхности воды к пикам, что придает необходимую форму. Однако, более простым методом и существенно более эффективным в нашем случае является использование простой формулы, что приводит к удовлетворительному результату в виде остроконечных волн
Недостаток метода в том, что он приводит к появлению кругов и прочих замкнутых фигур, заметных глазу, но при соответствующем подборе параметров этот недостаток становится несущественным.
Фазовое распределение и анимация
Очевидно, что наличие только одной фазы (или иначе октавы) для воды недостаточно для получения реалистичной воды и требуется наложить несколько волн с разной амплитудой и фазой, что позволяет получить как большие волны так и мелкую рябь на поверхности.
float amp = maxHeight / 2;
for (int i = 0; i < count; ++i){
h += amp * phase(pos + v[i]*t);
pos *= sp;
amp /= sa;
}
Хорошие результаты получаются при выборе sp, sa равным 2, но мы вольны выбирать любые значения, обеспечивающие приемлемые результаты. Подбор этих параметров позволяет получить разнообразные типы волн и управлять их изменением вплоть до полного штиля.
Для анимации волн достаточно каждую фазу смещать в своем направлении. При этом необходимо учесть, что скорость движения больших волн больше и волны двигаются преимущественно в одном направлении.
Оптимизация
Как показали эксперименты вполне достаточно иметь порядка 7 фаз для получения “вкусной” картинки и, в принципе, в очередном велосипеде не было необходимости. Однако, первые же тесты на смартфонах повергли в шок, т.к. фпс неумолимо стремился к 0, что не могло не огорчать. Посмотрим сколько операций в шейдере потребуются для отображения одной точки:
- Шум Перлина: Для каждого вертекса требуется 4 случайных значения, т.е 4 sin, 4 fract и прочие более простые операции.
- Расчет для 7 волн — 28 sin.
- Расчет нормалей к поверхности: Кроме высоты самой точки нам необходимо знать высоты 2 соседних точек. Соответственно, количество расчетов увеличивается в 3 раза — до 84! операций вычисления sin.
В целом можно не согласиться с приведенными выше рассуждениями, т.к. высоту поверхности в целом достаточно вычислить для каждой вершины меша, что заведомо быстрее, чем считать для каждой точки экрана. Но в таком случае ни о каком реализме можно и не мечтать. Максмум, что мы получим очень грубое приближение к желаемому результату. Таким образом, все приведенные выше операции приходится вычислять именно во фрагментном шейдере. Варианты оптимизации:
- Попробовать сократить число фаз, но как упоминалось ранее, желательно иметь порядка 7 фаз для приемлемого отображения. А меньшее число значительно ухудшает "качество" волн.
- Попробовать заменить затратную операцию вычисления шума на выборку из готовой текстуры. Вместо 84 sin мы получаем уже 21 выборку (т.к. для шума Перлина требуется 4 sin).
Карта высот-нормалей
Последний вариант имеет право на жизнь, но fps также слишком мал — порядка 3-4 кадров в секунду. Кроме того, в этом случае при расчете нормали мы упираемся в точность хранения данных в текстуре, что приводит к появлению "ступенек" на воде. Конечно можно использовать текстуру с вещественными числами, но тогда мы дополнительно ограничим количество поддерживаемых устройств.
В тоже время, для получения высоты точки необходимо считать значение высоты из текстуры, но нам ничего не мешает запечь в эту текстуру еще и карту нормалей. Таким образом, одной выборкой из текстуры мы можем получить высоту точки и одновременно нормаль к ней. Запекание данных в текстуру можно выполнить заранее или же непосредственно на видеокарте с помощью рендеринга в текстуру (например перед запуском приложения или смене параметров).
При вычислении текстуры необходимо обеспечить достаточную точность хранения нормалей. Если сохранять нормаль в привычном виде карты нормалей, то эта точность оказывается недостаточной, что проявляется в артефактах изображения.
Действительно, для получения нормали нам достаточно сохранять лишь проекцию нормали в горизонтальной плоскости (nx, ny). В общем виде, каждая из этих компонент меняется в диапазоне [-1,1]. Но в случае воды используемый диапазон оказывается существенно меньше, т.к. нормали в основном ориентированы вверх (что особенно заметно при генерации волн малой амплитуды). Таким образом, если нормализовать этот диапазон по максимальному значению, то мы сможем существенно увеличить точность хранения нормалей и, соответственно, качество картинки.
При этом для генерации результирующей волны надо аккуратно преобразовать нормаль каждой фазы с учетом амплитуды волны, ее фазы, а также подобранного выше коэффициента масштабирования.
Дополнительная оптимизация
Несмотря на выполненные оптимизации у нас до сих пор происходит выборка из 7 текстур и хотелось бы уменьшить это количество. Как упоминалось ранее, уменьшение этого числа в общем виде не желательно.
Однако, форму волн мы храним в текстуре, в которой можем заранее сгенерить вместо одной фазы сразу несколько.
Это решает проблему генерации множества волн небольшим количество проходов, однако при анимации становится заметно, что часть волн движется с одной скоростью. Для уменьшения этого эффекта можно сохранять в текстуру волны с большей разницей фаз, например 1 — 3 — 5, а при рендеринге мы получим 1-1'-3-3'-5-5'. Также мы использовали подход при котором первые две фазы из четырех использовали одну текстуру, а последние две уже другую с иным распределением и количеством фаз. Именно этим способом получено изображение, приведенное в начале поста
Недостатки метода
- Основная техническая проблема в том, что для получения волн необходимо наличие возможности выборки из текстуры в вершинном шейдере. Технически это поддерживается с Opengl ES 2.0, но далеко не все смартфоны обеспечивают эту функциональность.
- Необходимость тщательной подборки параметров, чтобы скрыть артефакты метода и получить привлекательное изображение.
- Полученное море периодично по своей природе и это ограничивает сферу применения. При полете высоко над поверхностью периодичность бросается в глаза.
Еще немного про оптимизацию
Кроме описанных выше методов мы тестировали несколько других вариантов оптимизации. Наиболее интересным из них нам показалась интерполяция волн по времени.
Смысл этого метода в том, что мы можем отрендерить в текстуру результирующую карту высот и нормалей в некоторые моменты времени, а для прочих сделать интерполяцию высот и нормалей.
Таким образом раз в N кадров требуется сделать полный расчет поверхности, а N-1 кадр можно считать путем простой выборки из двух текстур.
В таком случае получается, что одна волна уменьшается и рядом появляется следующая. При уменьшении N анимация становится более плавной, хотя эффективность метода и снижается.
Таким образом его можно вполне эффективно использовать в определенных условиях, например, при малой скорости волн или на относительном удалении от поверхности, когда недостаток метода становится менее заметным.
Линия горизонта
На данный момент мы получили вполне жизнеспособную и симпатичную водичку, однако не обговаривали какую сетку будем использовать для поверхности воды. Очевидно, что для отображения воды до линии горизонта нам фактически придется использовать бесконечно большой меш точек (по крайней мере растянуть его до плоскости отсечения камеры), в то время как, количество точек в нем весьма ограничено). Простое линейное масштабирование не работает, т.к вблизи камеры меш становится слишком разреженным, а в вблизи линии горизонта, наоборот, излишне густым.
Самым простым способом решения этого проблемы является масштабирование меша в вершинном шейдере в зависимости от расстояния до камеры. Недостаток же вполне очевиден — сложно подобрать необходимые параметры, а полученное распределение точек все также будет неравномерно.
Другой вариант — это использование меша с разной детализации в зависимости от расстояния. Но это может приводит к рывкам при смене уровня детализации, а также требует введения дополнительной логики контроля этих уровней.
Проецируемая сетка
Наиболее удобным является использование метода projected Grid, который в общем можно описать следующим образом:
- Создается равномерная плоская сетка, в координатах [-1..1], всегда расположенная в пространстве камеры
- В вершинном шейдере эта сетка проецируется на горизонтальную плоскость.
- Для полученной точки выполняются все расчеты для отображения воды
В качестве аналогии можно представить слайд с точками, прикрепленный к прожектору (камере). Там где тень от точки попадает на плоскость и находится искомая точка. В то же время с точки расположения камеры наблюдатель увидит всю ту же равномерную сетку
Этот метод имеет несколько преимуществ:
- Полученная сетка равномерная и легко позволяет менять свой размер
- Не затратный по производительности метод. Для корректного расчета достаточно одной операции умножения и сложения векторов в вершинном шейдере
Расчет освещения
В основе получения корректного изображения лежит правильный учет всех составляющих светового потока — отраженный свет, свет рассеянный в толще воды, блики от солнца и т.д.
В общем виде это является нетривиальной задачей, но в нашем случае мы использовали более простой подход, т.к. не было необходимости отображать поверхность дна, каустики и прочие.
Подробное описание расчета освещения не будем, т.к. есть много подробных статей посвященных этой тематики (например здесь, здесь). Хотелось бы только отметить необходимость выбора "правильной" формулы для вычисления коэффициента Френеля.
В первой версии шейдера мы никак не могли добиться реалистично выглядящей воды. Полученный результат был больше похож на нарисованную или пластиковую воду. Оказалось, что мы использовали самую примитивную версию для вычисления коэффициента Френеля:
. В то же время замена формулы на
значительно улучшило реализм воды. Прочие апроксимации для коэффициента Френеля можно посмотреть здесь.
Результаты
Приведенный способ симуляции воды мы реализовали в Unity3D. Для расчета освещения и создания формы волн мы использовали явные вертексные и фрагментные шейдеры (можно было реализовать и на поверхностных шейдерах, но это не играет принципиальной роли). При тестах на андроид смартфонах мы получили от 25 к/c (Adreno 405 + MediaTek MT6735P) до 45 (Adreno 505 + Snapdragon 430). На части смартфонах, как и ожидалось, приложение на заработало из-за отсутствия поддержки чтения из текстуры в вершинном шейдере. При этом интересно отметить, что расчет освещения в результате оказался сопоставим со сложностью c генерацией волн. При необходимости можно поднять fps за счет использования других моделей освещения или отключения части элементов как карта окружения, блики и т.д