Руководство по моделированию 2D водной поверхности

Рассказывает Alex Rose


В этой статье мы рассмотрим создание динамической 2D воды с простейшей физикой. Мы будем использовать рендер линий, мешей, триггеры и частицы. Конечный результат с волнами и брызгами вы сможете добавить в какую-нибудь свою игру. Я выложил то, что получилось у меня на Unity (Unity 3D), но, используя принципы из данной статьи, вы сможете сделать то же самое на любом движке.

Итак, приступим

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

Нам нужно будет хранить позиции, скорости и ускорения для каждого узла, так что мы будем использовать массивы:

С помощью LineRenderer мы создадим границу нашей воды, но нам всё ещё нужна сама вода. Для этого мы будем использовать меши. Для их хранения нам также нужен будет массив GameObject:

Ещё нам нужно будет отслеживать столкновения с поверхностью воды:

Теперь несколько констант:

Здесь springconstant — «коэффициент жёсткости» наших волн (они будут двигаться подобно пружинному маятнику); damping — коэффициент гашения (иначе волна, единожды начав качаться, не остановится никогда); spread — коэффициент, отвечающий за скорость распространения волн. Вы можете играть с этими значениями, стараясь достичь свойств, наиболее напоминающих настоящую воду. Значение z (координату воды по оси Z) вы можете менять в зависимости от того, что должно стоять ближе к переднему плану в вашем приложении.

Далее, нам нужно хранить местоположение нашей воды:

…И её внешний вид:

Объект, в котором все эти данные будут храниться — что-то вроде менеджера, он же будет создавать воду. Создадим для этого функцию — SpawnWater(). В неё мы будем передавать координату левого края воды, ширину водного пространства, а также его верхнюю и нижнюю границы:

Создаём узлы

Посчитаем количество узлов, которое нам потребуется:

Мы будем использовать пять узлов на единицу ширины, но вы можете менять это значение в поисках своего баланса между производительностью и гладкостью воды.

Теперь самое время настроить наш LineRenderer:

После инициализируем переменные, которые мы создавали выше:

…И заполним их реальными значениями:

В конце этого участка кода, как вы видите, мы помещаем каждый узел из LineRenderer на его место.

Создаём саму воду

Здесь начинается самое интересное. Сейчас у нас есть граница воды, но самой воды нет. Поэтому сейчас мы создадим для неё меши:

Теперь в меш нужно передать несколько значений. Для начала — координаты его углов.

На диаграмме отмечены нужные нам углы первого меша. В общем виде это будет выглядеть так:

Как вы могли заметить, в нулевой ячейке хранится верхняя левая точка, в первой — верхняя правая, во второй — нижняя левая, а в третьей — нижняя правая. Теперь нам нужно выбрать, какую часть текстуры мы хотим использовать для этого меша. Мы хотим использовать всю, а значит нам нужно просто написать:

Меши должны состоять из треугольников. К счастью, любой четырёхугольник можно разделить на два треугольника.

Как вы можете видеть, треугольник A состоит из вершин 0, 1, 3, а треугольник B — из вершин 3, 2, 0. Эти шесть чисел нам нужно записать в отдельный массив, в этом же порядке:

Теперь три созданных нами массива нужно передать в меш:

Теперь у нас есть меши, но нет GameObject-ов, чтоб их рендерить. Исправим это:

Все эти меши теперь являются потомками менеджера.

Настройка детекторов столкновений

Здесь мы создаём несколько BoxCollider-ов, назначаем им имена и делаем их потомками менеджера. Также мы назначаем им позиции — посередине между узлами, определяем их размер и добавляем им WaterDetector.

Ну и следующая функция будет обновлять позиции наших узлов:

Добавим немного физики

Чтобы находить ускорения, скорости и новые позиции узлов, нам нам понадобятся закон Гука и метод Эйлера.

Итак, закон Гука гласит, что F=-k*x, где F — сила, с которой пружина стремится вернуться в начальное состояние (не забывайте, что наши волны будут состоять из множества маленьких пружин); k — коэффициент жёсткости (помните, мы записывали его в константах?); x — растяжение. По этой формуле мы будем высчитывать ускорение для узлов, значение растяжения будет высчитываться как разница между текущей позицией воды и её базовым уровнем. Ещё к силе мы будем прибавлять скорость, умноженную на коэффициент гашения — чтобы волны не были бесконечными.

Как видите, метод Эйлера очень прост — мы просто каждый раз прибавляем к позиции узла его скорость, а к скорости прибавляем ускорение. Разумеется, для точных вычислений больше бы подошло интегрирование Верье, но из-за использования гашения это с трудом представляется возможным. К тому же метод Эйлера на порядок проще.

Теперь добавим немного распространения волн. Создадим два массива, в которых мы будем хранить разницу между высотами соседних узлов, умноженную на коэффициент распространения:

…И заполним их, попутно меняя скорости и высоты:

Как вы заметили, выше мы совершаем действия 8 раз. Это сделано для большей плавности.

Добавим столкновения

Теперь у нас есть вода, которая может не просто отображаться, но и переливаться. Теперь нам нужна возможность её всколыхнуть!

Для этого создадим метод Splash(), который будет проверять место и скорость удара по воде:

Для начала проверим корректность переданной координаты по иксу:

Теперь сделаем так, чтоб хранилась не абсолютная координата, а расстояние от первого узла:

Теперь нужно вычислить, на который узел приходится удар:

Здесь мы делаем буквально следующее:

  • Мы берём координату относительно первого узла — xpos.
  • Делим её на разницу между позицей последнего и первого узла.
  • Таким образом мы получаем дробное число, которое говорит нам, куда именно пришёлся удар.
  • Умножаем полученное число на количество углов и округляем до целого.

Теперь приравниваем скорость найденного узла к скорости удара:

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

Теперь добавим брызги:

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

Дальше я добавил одну линию, но вы можете её пропустить:

Брызги не уничтожатся при столкновении с другими объектами, поэтому вам стоит либо назначить им Z координату фона (у меня — 5), либо сделать так, чтобы частицы всегда попадали обратно на воду. Я выбрал нечто среднее:

Вроде бы всё, так?

Обнаружение столкновений

Нет, не так! Нам нужно отслеживать столкновения объектов с водой, иначе всё это было написано зря. Помните класс WaterDetector, который мы упоминали выше? Сейчас мы займёмся именно им. Всё, что нам нужно от него — лишь один метод:

Теперь вам осталось лишь вызвать SpawnWater() где-либо в вашем коде и наслаждаться великолепным волнами и брызгами!

Перевод статьи «Creating Dynamic 2D Water Effects in Unity»