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

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


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

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

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

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

float[] xpositions;
float[] ypositions;
float[] velocities;
float[] accelerations;
LineRenderer Body;

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

GameObject[] meshobjects;
Mesh[] meshes;

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

GameObject[] colliders;

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

const float springconstant = 0.02f;
const float damping = 0.04f;
const float spread = 0.05f;
const float z = -1f;

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

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

float baseheight; //Нормальный уровень воды
float left; //Координата левого края
float bottom; //Координата дна

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

public GameObject splash; //Частицы брызг
public Material mat; //Материал для границы
public GameObject watermesh; //Меш для самой воды

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

public void SpawnWater(float Left, float Width, float Top, float Bottom)

Создаём узлы

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

int edgecount = Mathf.RoundToInt(Width) * 5;
int nodecount = edgecount + 1; //Ещё один узел в конце

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

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

Body = gameObject.AddComponent<LineRenderer>();
Body.material = mat;
Body.material.renderQueue = 1000; //Назначаем позицию в очереди рендеринга, чтоб граница рисовалась поверх воды
Body.SetVertexCount(nodecount);
Body.SetWidth(0.1f, 0.1f); //Ширина линии (в начале и в конце)

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

xpositions = new float[nodecount];
ypositions = new float[nodecount];
velocities = new float[nodecount];
accelerations = new float[nodecount];
 
meshobjects = new GameObject[edgecount];
meshes = new Mesh[edgecount];
colliders = new GameObject[edgecount];
 
baseheight = Top;
bottom = Bottom;
left = Left;

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

for (int i = 0; i < nodecount; i++)
{
    ypositions[i] = Top;
    xpositions[i] = Left + Width * i / edgecount;
    accelerations[i] = 0;
    velocities[i] = 0;
    Body.SetPosition(i, new Vector3(xpositions[i], ypositions[i], z));
}

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

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

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

for (int i = 0; i < edgecount; i++)
{
    meshes[i] = new Mesh();

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

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

Vector3[] Vertices = new Vector3[4];
Vertices[0] = new Vector3(xpositions[i], ypositions[i], z);
Vertices[1] = new Vector3(xpositions[i + 1], ypositions[i + 1], z);
Vertices[2] = new Vector3(xpositions[i], bottom, z);
Vertices[3] = new Vector3(xpositions[i+1], bottom, z);

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

Vector2[] UVs = new Vector2[4];
UVs[0] = new Vector2(0, 1); //Верхний левый угол
UVs[1] = new Vector2(1, 1); //Верхний правый угол
UVs[2] = new Vector2(0, 0); //Нижний левый угол
UVs[3] = new Vector2(1, 0); //Нижний правый угол

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

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

int[] tris = new int[6] { 0, 1, 3, 3, 2, 0 };

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

meshes[i].vertices = Vertices;
meshes[i].uv = UVs;
meshes[i].triangles = tris;

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

meshobjects[i] = Instantiate(watermesh,Vector3.zero,Quaternion.identity) as GameObject;
meshobjects[i].GetComponent<MeshFilter>().mesh = meshes[i];
meshobjects[i].transform.parent = transform;

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

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

colliders[i] = new GameObject();
colliders[i].name = "Trigger";
colliders[i].AddComponent<BoxCollider2D>();
colliders[i].transform.parent = transform;
colliders[i].transform.position = new Vector3(Left + Width * (i + 0.5f) / edgecount, Top - 0.5f, 0);
colliders[i].transform.localScale = new Vector3(Width / edgecount, 1, 1);
colliders[i].GetComponent<BoxCollider2D>().isTrigger = true;
colliders[i].AddComponent<WaterDetector>();

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

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

void UpdateMeshes()
    {
        for (int i = 0; i < meshes.Length; i++)
        {
 
            Vector3[] Vertices = new Vector3[4];
            Vertices[0] = new Vector3(xpositions[i], ypositions[i], z);
            Vertices[1] = new Vector3(xpositions[i+1], ypositions[i+1], z);
            Vertices[2] = new Vector3(xpositions[i], bottom, z);
            Vertices[3] = new Vector3(xpositions[i+1], bottom, z);
 
            meshes[i].vertices = Vertices;
        }
    }

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

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

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

void FixedUpdate()
{
for (int i = 0; i < xpositions.Length ; i++)
        {
            float force = springconstant * (ypositions[i] - baseheight) + velocities[i]*damping
            accelerations[i] = -force; 
            ypositions[i] += velocities[i];
            velocities[i] += accelerations[i];
            Body.SetPosition(i, new Vector3(xpositions[i], ypositions[i], z));
        }

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

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

float[] leftDeltas = new float[xpositions.Length];
float[] rightDeltas = new float[xpositions.Length];

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

for (int j = 0; j < 8; j++)
{
    for (int i = 0; i < xpositions.Length; i++)
    {
        if (i > 0)
        {
            leftDeltas[i] = spread * (ypositions[i] - ypositions[i-1]);
            velocities[i - 1] += leftDeltas[i];
        }
        if (i < xpositions.Length - 1)
        {
            rightDeltas[i] = spread * (ypositions[i] - ypositions[i + 1]);
            velocities[i + 1] += rightDeltas[i];
        }
    }
}
for (int i = 0; i < xpositions.Length; i++)
{
    if (i > 0)
    {
        ypositions[i-1] += leftDeltas[i];
    }
    if (i < xpositions.Length - 1)
    {
        ypositions[i + 1] += rightDeltas[i];
    }
}

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

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

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

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

public void Splash(float xpos, float velocity)

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

if (xpos >= xpositions[0] && xpos <= xpositions[xpositions.Length-1])

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

xpos -= xpositions[0];

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

int index = Mathf.RoundToInt((xpositions.Length-1)*(xpos / (xpositions[xpositions.Length-1] - xpositions[0])));

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

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

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

velocities[index] = velocity;

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

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

float lifetime = 0.93f + Mathf.Abs(velocity)*0.07f;
splash.GetComponent<ParticleSystem>().startSpeed = 8+2*Mathf.Pow(Mathf.Abs(velocity),0.5f);
splash.GetComponent<ParticleSystem>().startSpeed = 9 + 2 * Mathf.Pow(Mathf.Abs(velocity), 0.5f);
splash.GetComponent<ParticleSystem>().startLifetime = lifetime;

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

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

Vector3 position = new Vector3(xpositions[index],ypositions[index]-0.35f,5);
Quaternion rotation = Quaternion.LookRotation(new Vector3(xpositions[Mathf.FloorToInt(xpositions.Length / 2)], baseheight + 8, 5) - position);

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

GameObject splish = Instantiate(splash,position,rotation) as GameObject;
        Destroy(splish, lifetime+0.3f);
    }
}

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

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

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

void OnTriggerEnter2D(Collider2D Hit)
{
    if (Hit.rigidbody2D != null)
    {
        transform.parent.GetComponent().Splash(transform.position.x, Hit.rigidbody2D.velocity.y*Hit.rigidbody2D.mass / 40f);
    }
}

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

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