Pull to refresh

В этой статье слишком много воды

Reading time 9 min
Views 40K
«Мы начинаем разработку новой игры, и нам нужна классная вода. Такую сможешь?»


, — cпросили меня. «Да не вопрос! Конечно, смогу», — ответил я, но голос предательски задрожал. «А, еще и на Unity?», — и мне стало понятно, что впереди очень много работы.

Итак, водичка. Unity до того момента я в глаза не видел, ровно как и C#, так что решил, что буду делать прототип на знакомых мне инструментах: С++ и DX9. Что я знал и умел на практике на тот момент так это скроллящиеся текстурки нормалей для формирования поверхности, и примитивный дисплейсмент маппинг на их основе. Тут же надо было менять абсолютно все. Реалистичная анимированная форма водной поверхности. Усложненый (сильно) шейдинг. Генерация пены. LOD система, привязанная к камере. Начал я выискивать информацию в интернете как же все это сделать то.

Первым пунктом, естественно, было вникание в «Simulating Ocean Water» Джерри Тессендорфа.

Академические пейперы с кучей заумных формул никогда мне особо не давались, так что после пары прочтений я мало что понял. Общие принципы были понятны: каждый кадр генерируется карта высот с помощью Fast Fourier Transform, которая, как функция от времени, плавно меняет свою форму формируя реалистичную водную поверхность. Но как и что считать я не знал. Я потихоньку вникал в премудрости просчета FFT на шейдерах в D3D9, и мне в этом очень помог исходник со статьей где-то в дебрях интернета, который я битый час пытался отыскать, но безуспешно (к сожалению). Первый результат был получен (страшный как ядерная война):


Стартовые успехи порадовали, и начался перенос воды на Unity с его доработкой.

К воде в игре про морские битвы выдвигались несколько требований:

  • Реалистичный внешний вид. Красивые как близкие так и дальние ракурсы, динамическая пена, скаттеринг и т. д.
  • Поддержка различных погодных условий: штиль, шторм и промежуточные состояния. Смена времени суток.
  • Физика плавучести кораблей по симулированной поверхности, плавучие объекты.
  • Так как игра мультиплеерная, вода должна быть у всех участников боя одинаковая.
  • Рисование по поверхности: нарисованные зоны полета ядер залпа, пена от попаданий ядер в воду.

Геометрия


Решено было построить quadtree-like структуру, с центром вокруг камеры, которая дискретно перестраивается при движении наблюдателя. Почему дискретно? Если двигать меш плавно вместе с камерой или использовать screen space reprojection как в статье Real-time water rendering — introducing the projected grid concept, то на дальних планах из-за недостаточного разрешения геометрической сетки при выборке карты высот волны будут «скакать» полигоны вверх и вниз. Это очень сильно бросается в глаза. Картинка «рябит». Чтобы это побороть, надо либо сильно увеличивать разрешение полигональной сетки water mesh'a, либо «уплощать» геометрию на дальних дистанциях, либо так строить и двигать полигоны, чтобы эти сдвиги не было видно. Вода у нас прогрессивная (хехе) и выбрал я третий путь. Как и в любой подобной технике (особенно знакомой всем, кто создавал terrain в играх), необходимо избавиться от T-junctions на границах переходов уровней детализации. Для решения этой задачи на старте предрасчитывается 3 вида квадов с заданными параметрами тесселяции:



Первый тип для тех квадов, которые не являются переходными на более низкую детализацию. Ни одна из сторон не имеет уменьшенное в 2 раза количество вершин. Второй тип для граничных, но не угловых квадов. Третий тип — угловые граничные квады. Финальный меш для воды строится поворотом и масштабированием этих трех видов мешей.

Вот так выглядит рендер с подсветкой разным цветом LOD уровней воды.


На первых кадрах видно соединение двух различных уровней детализации.

Видео как кадр заполняется водяными квадами:


Напомню, это все было давно (и неправда). Сейчас более оптимально и гибко можно сделать сразу на GPU (GPU Pro 5. Quadtrees on the GPU). И рисовать будет в один draw call, и тесселяцией можно поднять детализацию.

Позднее проект переехал на D3D11, но до апгрейда этой части рендера океана руки так и не дошли.

Генерация формы волны


Вот для этого нам понадобится Fast Fourier Transform. Для выбранного (нужного) разрешения текстуры волны (пока назовем ее так, далее я объясню, какие данные там хранятся) подготавливаем начальные данные, используя параметры, заданные художниками (сила, направление ветра, зависимость волны от направления ветра и другие). Все это необходимо скормить в формулы т.н. Phillips spectrum'а. Полученные начальные данные модифицируем каждый кадр с учетом времени и выполняем FFT над ними. На выходе получаем тайлящуюся по всем направлениям текстуру которая содержит смещение вершин плоского меша. Почему не просто heightmap? Если хранить только оффсет по высоте, то результатом будет нереалистичная «бурлящая» масса, лишь отдаленно напоминающая море:


Если считать смещения для всех трех координат, то будут генерироваться красивые «острые» реалистичные волны:


Одной анимированной текстурки мало. Виден тайлинг, недостаточно деталей на ближних планах. Берем описанный алгоритм и делаем не одну, а 3 fft-generated текстуры. Первая — крупные волны. Она задает основную форму волны и используется для физики. Вторая — средние волны. Ну и напоследок самые мелкие. 3 FFT генератора (4-й вариант это финальный микс):


Параметры слоев задаются независимо друг от друга, и полученные текстуры смешиваются в шейдере воды в финальную форму волны. Параллельно со смещениями генерируются и карты нормалей каждого слоя.

«Одинаковость» воды у всех участников боя обеспечивается синхронизацией параметров океана на старте боя. Эту информацию передает сервер каждому клиенту.

Физическая модель плавучести


Так как необходимо было сделать не только красивую картинку, но и реалистичное поведение кораблей. А также учитывая то, что в игре должно присутствовать штормовое море (крупные волны), то еще одной задачей, которую требовалось решить, являлось обеспечение плавучести объектов на поверхности сгенерированного моря. Сперва я попытался сделать GPU readback текстуры волны. Но, так как быстро выяснилось, что всю физику морского боя необходимо делать на сервере, то и море, а точнее первый его слой который задает форму волны, необходимо считать также и на сервере (а на нем, скорее всего, нет быстрого и/или совместимого GPU), то было принято решение написать полную функциональную копию GPU FFT генератора, на CPU в виде native C++ плагина к Unity. Сам FFT алгоритм я не реализовывал и использовал готовый в библиотеке Intel Performance Primitives (IPP). А вот всю обвязку и постпроцессинг результатов был выполнен мной, с последующей оптимизацией на SSE и распараллеливанием по потокам. Сюда входила и подготовка массива данных для FFT каждый кадр, и финальное преобразование посчитанных значений в wave offset map.

Была еще одна интересная особенность алгоритма, которая исходила из требований к физике воды. Нужна была функция быстрого получения высоты волны в данной точке мира. Логично, ведь это и есть основа построения плавучести любого объекта. Но, так как на выходе FFT процессора у нас получалается offsetmap, а не heightmap, то обычная выборка из текстуры не давала нам высоту волны там где было необходимо. Для простоты рассмотрим 2D вариант:



Для формирования волны, тексели (текстурные элементы, показанные вертикальными линиями) содержат вектор (стрелки) который задает смещение вертекса плоского меша (синие точки) в направлении его финальной позиции (острие стрелки). Предположим мы возьмем эти данные и попробуем извлечь из нее высоту воды в интересующей нас точке. Например, нам надо узнать высоту в точке hB. Если мы возьмем вектор в текселе tB, то мы получим смещение в точку около hC, что может сильно отличаться от того что нам нужно. Вариантов решения этой проблемы два: при каждом запросе высоты проверять множество соседних текселей, пока не найдем тот, который имеет смещение в интересующую нас позицию. В нашем примере мы найдем тексель tA как содержащий наиболее близкое смещение. Но такой подход не назовешь быстрым. Сканирование радиуса текселей непонятно какого размера (а от того, штормовое море или спокойное, смещения могут сильно варьироваться) может занять продолжительное время.

Второй вариант — после просчета offset map конвертировать ее в height map, используя scattering подход. Это означает, что для каждого offset vector'а мы запишем высоту волны, которую он задает, в ту точку, куда он смещается. Это будет отдельный массив данных, который и будет использоваться для получения высоты в интересующей точке. Используя нашу иллюстрацию, ячейка tB будет содержать высоту hB полученную из вектора tA→hB. Есть еще одна особенность. Ячейка tA не будет содержать валидного значения, так как нет вектора, смещающегося в него. Для заполнения таких «дырок» выполняется проход заполнения их соседними значениями.

Вот так это выглядит, если сделать визуализацию смещений с помощью векторов (красные — большое смещение, зеленый — малое):


Далее все просто. Для корабля задается плоскость условной ватерлинии. На ней определяется прямоугольная сетка точек-проб, которая задает места приложения выталкивающих из воды сил для корабля. Затем для каждой точки проверяем, под водой она или нет, используя water heightmap, описанную выше. Если точка под водой, то прикладываем вертикальную силу вверх к physics hull корпуса в этой точке, масштабированной расстоянием от точки до водной поверхности. Если над водой, то ничего не делаем, гравитация сделает все для нас. На самом деле там формулы немного сложнее (вся для тонкого тюнига поведения корабля), но основной принцип такой. На видео визуализации плавучести ниже, синие кубы — это места расположения проб, а линии от них вниз — это величина выталкивающей из воды силы.


В реализации сервера есть еще один интересный оптимизационный момент. Нет никакой надобности симулировать разную воду для разных боевых инстансов, если они проходят в одинаковых погодных условиях (одинаковые параметры FFT симулятора). Так что логичным решением было сделать пул симуляторов, к которым боевые инстансы выполняют запросы на получение симулированной воды с заданными параметрами. Если параметры одинаковые от нескольких инстансов, то им вернется одна и та же вода. Реализовано это с помощью Memorу Mapped File API. Когда FFT симулятор создается, он дает доступ к своим данным, экспортируя дескрипторы нужных блоков. Серверный инстанс вместо того, чтобы запускать реальный симулятор, запускает «пустышку» которая просто отдает данные, открытые по этим дескрипторам. Было несколько веселых багов, связанных с этим функционалом. Из-за ошибок подсчета ссылок симулятор уничтожался, но memory mapped file жив пока открыт хоть один дескриптор на него. Данные переставали обновляться (симулятора-то нет) и вода «останавливалась».

На клиентской стороне нам необходима информация о форме волны для просчета попаданий ядер в волну и проигрывания систем частиц и пены. Просчет повреждений происходит на сервере и там также необходимо корректно определять, попало ли ядро в воду (волна может закрывать корабль, особенно в штормах). Тут уже необходимо делать heightmap tracing по аналогии как это делается в parallax mapping либо SSAO эффектах.

Шейдинг


В принципе как и везде. Отражения, преломления, subsurface scattering хитро замешиваем, учитывая глубину дна, учитываем fresnel effect, считаем спекуляр. Скаттеринг считаем для гребней в зависимости от позиции солнышка. Пена генерируется следующим образом: создаем «пятно пены» на гребнях волн (используем высоту как метрику), затем накладываем новосозданные пятна на пятна с предыдущих кадров одновременно уменьшая их интенсивность. Таким образом получаем размазывание пятен пены в виде хвоста от идущего гребня волны.


Используем полученную текстуру «пятен» как маску к которой примешиваем текстуры пузырьков, разводов и т. д. Получаем довольно реалистичный динамический рисунок пены на поверхности волн. Данная маска создается для каждого FFT слоя (напомню, у нас их 3), и в финальном миксе они все смешиваются.

На видео выше визуализация маски пены. Первый и второй слои. Я модифицирую параметры генератора и результат виден на текстуре.

И видео немножко коряво настроенного штормового моря. Тут хорошо видна форма волны, возможности генератора и пена:


Рисование по водной поверхности


Картинка-пример использования:



Используется для:

  • Маркеры, визуализации зоны разлета ядер.
  • Рисование пены в месте попадания ядер в воду.
  • Пенный след за кораблем
  • Выдавливание воды под кораблем, чтобы убрать эффект заливания волнами палубы и затопленного трюма.

Очевидный базовый вариант — проективное текстурирование. Оно и было реализовано. Но тут появились дополнительные требования. Ближние виды — мыло из-за недостаточного разрешения (можно увеличивать, но не бесконечно), и хочется чтобы далеко было видно эти проективные рисунки на воде. Где решается такая же задача? Правильно, в тенях (shadow map). Как она там решается? Правильно, Cascaded (Parallel Split) Shadow Maps. Возьмем и мы эту технологию на вооружение и применим к нашей задаче. Разбиваем фрустум камеры на N (3-4 обычно) сабфрустумов. Для каждого строим описывающий прямоугольник в горизонтальной плоскости. Для каждого такого прямоугольника строим orthographic projection матрицу и рисуем все интересующие объекты для каждой из N таких ortho камер. Каждая такая камера рисует в отдельную текстуру, а затем, в шейдере океана, мы их комбинируем в одну цельную проективную картинку.


Вот я положил на море большущую плоскость с текстурой флагов:



Вот то что содержится в сплитах:



Кроме обычных картинок надо абсолютно таким же образом нарисовать дополнительную маску пены (для следов кораблей и мест попаданий ядер), а также маску выдавливания воды под кораблями. Это много камер и много проходов. Поначалу оно так тормозно и работало, но затем, после перехода на D3D11, с помощью «размножения» геометрии в геометрическом шейдере и рисования каждой копии в отдельный render target через SV_RenderTergetArrayIndex, удалось сильно ускорить этот эффект.

Улучшения и модернизации


D3D11 очень сильно развязывает руки во многих моментах. После перехода на него и Unity 5 я сделал FFT генератор на сompute шейдерах. Визуально ничего не поменялось, но стало чуточку быстрее. Перевод просчет текстуры отражений с отдельного полноценного рендера камеры на технологию Screen Space Planar Reflections дал неплохой буст производительности. Про оптимизацию water surface objects я писал выше, а до перевода mesh'а на GPU Quadtree руки так и не дошли.

Многое, возможно, можно было сделать оптимальнее и проще. Например, не городить огороды с CPU симулятором, а просто запустить GPU вариант на сервере с WARP (программным) d3d девайсом. Массивы данных там не очень большие.

Ну, в общем как-то так. В то время, как разработка начиналось, все это было современно и круто. Сейчас уже местами подустарело. Появилось больше доступных материалов, даже есть похожий аналог на github: Crest. В большинстве игр, где есть моря, используется похожий подход.
Tags:
Hubs:
+173
Comments 36
Comments Comments 36

Articles