Pull to refresh

2D магия в деталях. Часть четвёртая. Вода

Reading time 13 min
Views 34K

— Я тут воду для проекта запилил.
— О, круто! А почему она плоская? Даёшь волны!

— Слушай, ты тогда про волны говорил, помнишь? Зацени!
— Да, хорошие волны, а преломление и каустику ещё не делал?

— Привет, я тут игрался с Unity всю ночь, смотри какие отражения и каустику закодил!
— Дарова, и правда, хорошо! А когда у тебя вода кипит, отражения не глючат?

— Хай, реализовал наконец, кипение, вроде ничего?
— О, прямо как нужно! Слушай, прикинь как круто, если кипящую волну заморозить?

— Лови картинку, лёд вроде ничего придумал?
— Норм, слушай, а у тебя лёд замерзает, он в объёме увеличивается? И кстати, ты когда геймлей то делать начнёшь?
Вариации на тему лога с другом.

Да, вы уже поняли, наконец-то расскажу про реализацию воды в проекте. Приступим?


Предыдущие статьи


Часть первая. Свет.
Часть вторая. Структура.
Часть третья. Глобальное освещение.
Часть четвёртая. Вода


Оглавление


  1. Подглядывание за 3D
  2. Хотелки
  3. Первый блин комом
  4. Статика
  5. Генерация воды
  6. Динамика
  7. Температура
  8. Заключение и интрига

Подглядывание за 3D


Для начала подсмотрим, как делают воду "взрослые ребята" из всяких крупных 3D проектов.
Вообще, перетаскивать идеи из 3D — отличный план, в 2D классных алгоритмов ощутимо меньше.


Итак, от самого простого к более сложному:


  • Плоская вода без физики. Бросили полигон на уровень и радуемся. Потому что fps не проседает и выглядит неплохо (художники постарались и собрали красивый шейдер). Взаимодействовать нельзя, говорите? Так у нас гоночная игра, воду видно только на горизонте!


  • Полигональная вода без физики. Планировали карту с плохой погодой, а вода плоская, как зеркало? Добавим полигонов, вспомним про синусы и вот, на горизонте громоздятся штормовые волны. Если в воду не заехать случайно — даже и не заметно, что вода ненастоящая.


  • Плоская вода с физикой. С гонками закончили, делаем Skyrim. Плоская вода с красивыми эффектами, несколько проверок в физическом движке и по воде можно плавать. Никаких тебе бурь и волн, но кому они в rpg нужны, когда по небу летают драконы, а фус-ро-да сбивает с ног прохожих?


  • Полигональная вода с физикой. Завязали с фэнтези и начали делать GTA. И тут уже реалистичные волны, да ещё и с физикой водного транспорта (как в этой великолепной статье). Хорошая вода, неужели можно ещё усложнить?


  • Система частиц. Ещё как можно! Если вы парни из Dark Energy Digital, и когда-то сделали Hydrophobia с "честной" водой, которая умеет течь, брызгаться и затапливать. И если у вас мощная видеокарта (или две) и процессор, способный всё это рассчитать.



Хотелки


Теперь, когда у нас есть референсы из крупных проектов, помечтаем, чего бы хотелось реализовать в нашем проекте.


Помните, шутливое вступление в начале статьи? Это совсем не шутка — каждый раз, когда я кидал другу скрины или видео из очередной демки, он предлагал какие-то совершенно безумные идеи. И это великолепно, ведь большую часть этих идей удалось воплотить, сделав проект ещё интереснее с точки зрения разработки. Поэтому список ниже составлялся итеративно, без мейлстоунов и плана.

А вот и список хотелок:


  1. Возможность в realtime добавлять новые объёмы воды или "испарять" существующие.
  2. Волны на поверхности воды.
  3. Температура, возможность замерзания и кипения воды.
  4. Взаимодействие с другими модулями: ветром, физическими телами, погодой.
  5. Тесное взаимодействие с системой освещения: каустика, рассеивание света, отражения.
  6. Возможность настройки в редакторе.

А про взаимодействия с модулями — тема практически бесконечная. Смотрите сами — в статье про свет я рассказывал, что в проекте есть возможность включить god rays — лучи света, видимые из-за пыли в воздухе. А теперь, когда я доделал кипящую воду, ничто не мешает сделать генерацию частиц пара, над которыми будут видны эти god rays! А ведь с частицами умеет взаимодействовать ветер, а значит, пар над горячей водой будет красиво рассеиваться. И таких взаимодействий столько, что не успеваешь записывать, не то что прототипировать.

Первый блин комом


Мы не пойдем путём команды, сделавшей Hydrophobia, "честно" моделировать жидкость — слишком уж требователен к ресурсам этот способ. Нужно упрощать. Раз уж в проекте пиксельарт — все плоскости либо горизонтальны, либо вертикальны. И пустое пространство на уровне представимо в виде набора прямоугольников. Вот с ними и будем работать.
В препроцессинге на начале уровня:


  1. Разбиваем всё пустое пространство на уровне на прямоугольные непересекающиеся объёмы.
  2. Находим связи объёмов друг с другом и строим граф "течений" воды.

В реальном времени:


  1. Для каждого объёма отдельно рассчитываем волны на поверхности (игнорируем тот факт, что с помощью волн вода может перетекать из объёма в объём).
  2. Рассчитываем перетекание воды с помощью графа.


Граф водных объёмов


Но, сказать по правде, ничего не получилось. Подводных камней столько, что можно построить парочку пятиэтажек. Например: нужно синхронизировать волны, разбираться с давлением в сообщающихся сосудах и т.д. Но нет худа без добра — именно для реализации этого метода когда-то была написана имплементация region tree, которая сейчас используется повсюду в проекте.


Статика


Раз с совсем динамической водой не получилось, давайте упростим себе жизнь — будем настраивать воду в редакторе, а в для иллюзии динамики оставим волны. По сути, перейдем к варианту №3 из списка вариантов.


Вода — очень противоречивая штука, потому что умеет течь: нельзя сказать "вот в этих точках будет вода", разместить там меши для жидкости и получить хорошую картинку. Ну и ладно, пусть левел дизайнеры (мы в будущем) разместят некие ключевые точки, где точно должна быть вода, а движок выльет её из своих запасов, причем ровно до уровня ключевых точек. Конечно, можно поместить один "якорь" под другим, но это уже проблема не движка, а тех самых неизвестных нам левел дизайнеров: наш код аккуратно заполнит водой уровень до самой верхней ключевой точки.


Ещё одна фишка — отдельные якоря для создания "пузырей". Если алгоритм найдёт такой якорь, он постарается не залить его водой, оставив аккуратную каверну с воздухом.


Теперь, когда есть понимание, какие результаты мы получим, пора разработать алгоритм, не так ли?


Генерация воды


В общем случае, вода может залить весь уровень. Так как стены состоят из прямоугольных кусочков и могут быть расположены где угодно, водный объём — это невыпуклый прямоугольный многоугольник с дырами. Гадость какая. Давайте разбивать на более простые фигуры.


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

Разберёмся с пустым пространством, в котором может быть жидкость. Великолепное region tree, про которое я рассказывал в прошлых статьях, очень поможет. Получим с его помощью все прямоугольные объёмы, не занятые стенами:


  1. Начиная с нижнего (изначально — левого) угла дерева ищем первую пустую область сверху.
  2. Начиная с найденной пустой области, ищем первую непустую область сверху.
  3. Добавляем полученный отрезок в список.
  4. Если достигли верхней (в геометрическом смысле) границы дерева, перемещаемся на 1 пиксель вправо.
  5. Если не достигли правой границы дерева, переходим к пт.1.
  6. Собираем из списка отрезков (а они довольно удачно отсортированы) прямоугольники.
  7. Строим связи между смежными прямоугольниками.


Вместо тысячи слов


Скорее всего, можно получить эти прямоугольники куда более красивым способом. Буду рад комментариям на эту тему.

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


Если у прямоугольника A только одна связь справа (с прямоугольником B), а у прямоугольника B - только одна связь слева (с прямоугольником A) - объединяем эти прямоугольники в одну структуру.


После этого этапа останется небольшое количество водных объёмов с ребристым дном, на рисунке ниже это хорошо видно.



Разметка пустого пространства на карте (со связями)


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


  1. Собираем все метки уровня воды (), сортируем их по y-координате по убыванию значения.
  2. Для каждой метки:
    2.1. Находим прямоугольник, которому принадлежит метка.
    2.2. Если прямоугольник не найден, метка — в стене, игнорируем её и переходим к пт. 2.
    2.2. Уменьшаем высоту прямоугольника до уровня метки.
    2.3. Если высота прямоугольника стала равна нулю — удаляем прямоугольник.
    2.4. Находим все смежные прямоугольники, нижний край которых выше метки и удаляем их.
    2.5. Находим все прочие смежные прямоугольники, переходим к пт. 2.3 (рекурсивно обрезаем все прямоугольники по уровню воды).
  3. Собираем все метки воздушных пузырей (), сортируем их по y-координате по убыванию значения.
  4. Для каждой метки:
    4.1. Находим прямоугольник, которому принадлежит метка.
    4.2. Если прямоугольник не найден, метка — в стене, игнорируем её и переходим к пт. 4.
    4.2. Уменьшаем высоту прямоугольника до уровня метки.
    4.3. Если высота прямоугольника стала равна нулю — удаляем прямоугольник.
    4.4. Находим все смежные прямоугольники, нижний край которых выше метки и удаляем их.
    4.5. Находим все прочие смежные прямоугольники, у которых верхний край выше уровня метки, переходим к п 4.3 (рекурсивно обрезаем все прямоугольники по уровню воздушного пузыря).

Как видите, отличие меток воды от меток воздушных пузырей в том, что первые проходят по всем соседям рекурсивно, а вторые останавливаются, когда сосед находится целиком под уровнем пузыря.


А теперь в картинках:



Добавили на карту метки уровня воды



То же самое, но со связями



Обрезали прямоугольники по уровню воды



Удалили лишние связи и прямоугольники



Дебажная визуализация полученных водных объёмов


Небольшая, но важная заметка

Несмотря на то, что мы обрезаем прямоугольники по уровню меток, в каждом из них сохраняем массив изначальных высот (y-координату потолка над водой). В дальнейшем эти высоты понадобятся для ограничения волн — в противном случае волны смогут проходить сквозь стены.


Самое время посмотреть, как это выглядит в редакторе:



Динамика


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


namespace NewEngine.Core.Water {
    public class WaterManager : MonoBehaviour {
        // куча настроек для жидкости
        // ...

        WaterPolygon[] waters;
        CombineInstance[] combineInstances = null;
        Mesh mesh;

        public void Generate() {
            if (tree == null)
                return;

            waters = WaterGenerator.Generate(tree, waveCeil);
            Debug.Log("Regenerate water");
        }

        void FixedUpdate() {
            if (waters == null)
                return;

            var viewportRect = cameraManager.ViewRect;
            WaterPolygon.Update(waters, ref combineInstances, viewportRect, /*а тут куча настроек, в виде структуры или просто списком, кому как больше нравится*/);

            mesh.Clear();
            mesh.CombineMeshes(combineInstances);
        }
    }
}

Пока что из всего этого кода нас (ну или меня, как рассказчика) интересует только WaterPolygon. Это те самые минимальные кусочки воды, для которых можно строить волны. А еще эти элементы связаны друг с другом (информация о графе осталась доступна в этих полигонах). Если упустить неважные детали, выглядит этот класс так:


Большущий кусок кода
namespace NewEngine.Core.Water {
    public class WaterPolygon {
        // одна "линия" - колонка воды шириной в один пиксель
        // хранит свою геометрию (минимальное значение по y, максимальное), свойства пружины (например, текущую скорость)
        class Line {
            public int min; // y-координата нижнего края воды
            public int max; // y-координата верхнего края воды
            public int target;  // y-координата верхнего края воды, когда жидкость не колеблется
            public int height;  // y-координата максимального верхнего края воды (потолок, если есть или MAX_WAVE_HEIGHT, если нет)

            public float speed;
            float lastDelta;

            public void Add(float additionalWater);
            public void Sleep();
            public void Update(float tension, float dampening, float foamThreshold, float foamForce, float foamDampening, float airTemperature, float airTranscalency, float verticalTranscalency, float minBoilTemperature, float minBoilBubble, float maxBoilBubble, float boilFrequency);
        }

        // координаты по y хранятся в Line, а все линии последовательны, так что нужно хранить только начальную позицию по x
        int x;

        // соседние водные полигоны; для синхронизации волн и температуры
        WaterPolygon[] left;
        WaterPolygon[] right;

        // сами водные "колонки"
        Line[] lines;

        // мы не обрабатываяем спящую воду
        bool sleeping;
        int sleepingFrames;

        // кешированные значения для проверки пересечения AABB
        int minWaterY;
        float maxWaterY;

        // обычный меш из UnityEngine
        Mesh mesh;

        // когда я допишу эту статью, запихну все параметры в аккуратную структуру, честно :)
        public static void Update(WaterPolygon[] water, ref CombineInstance[] combineInstances, Geom.IntRect viewportRect, int outsideCameraSleepOffset, float heightSleepThreshold, float speedSleepThreshold, float tension, float dampening, float spread, int steps, float foamThreshold, float foamForce, float foamDampening, float airTemperature, float airTranscalency, float verticalTranscalency, float horisontalTranscalency, float minBoilTemperature, float minBoilBubble, float maxBoilBubble, float boilFrequency, int sleepingUpdateFrames);

        // проверка AABB с неким допуском offset
        public bool IsOutside(Geom.IntRect viewportRect, int offset);

        // первый шаг обновления - фактически, расчет новых скоростей и положений пружин в lines
        void UpdateFirst(Geom.IntRect viewportRect, int outsideCameraSleepOffset, float tension, float dampening, int steps, float foamThreshold, float foamForce, float foamDampening, float airTemperature, float airTranscalency, float verticalTranscalency, float minBoilTemperature, float minBoilBubble, float maxBoilBubble, float boilFrequency, int sleepingUpdateFrames);

        // второй шаг обновления - обмен скоростями между соседними пружинами, в том числе из полигонов соседей (left и right)
        void UpdateSecond(float heightSleepThreshold, float speedSleepThreshold, float spread, int steps, float foamThreshold, float foamForce, float foamDampening, float horisontalTranscalency, float airTemperature, float minBoilTemperature);

        // чистка кешей и ненужных данных перед сном
        void Sleep(bool withClear);

        // поиск ближайшей линии соседа полигона (в зависимости от высоты волны), об этом ниже
        void FindLine(bool isRight, out Line line, out WaterPolygon water);

        // тут всё говорит само за себя
        public void CreateMesh(ref CombineInstance combineInstance);
    }
}

Я не буду рассказывать про физику жидкости, потому что я делал волны по одному хорошему туториалу. Если в двух словах: представляем воду в виде связанных пружин, где у пружины известна текущая высота, скорость и оптимальная высота. Пружины колеблются и передают колебания соседям. Ширина пружины в проекте — один пиксель.

Но есть одно важное дополнение. В туториале всего один "водный полигон" в то время как у нас их целый граф. И нужно правильно синхронизировать волны. На примере понятнее:



Допустим, у нас есть вот такой уровень



Он будет состоять из трёх водных полигонов



В какой то момент в синем полигоне возникает волна



Алгоритм находит подходящего соседа, исходя из высоты левой колонки и синхронизирует волну



При очень больших волнах алгоритм выберет другого соседа


Минутка оптимизаций. Нет смысла рассчитывать волны для:


  1. Полигонов за пределами экрана.
  2. Полигонов, в которых нет волн.

При очередном расчете волн происходит проверка — не оказались ли все волны меньше порогового значения, и если да — bed time! Если же полигон целиком за пределами экрана — пороговое значение чуть выше. Соответственно, разбудить воду может связный полигон или влияние других модулей (физика, ветер и т.д.).


Пока что у воды нет причин для колебаний. Ничто не может потревожить её покой, ни сражения боевых магов, ни банальный ураган. Холодна, как сердце Морры. Время растопить лёд.


Температура


Несмотря на несколько маниакальную проработку никому не нужных мелочей, не очень хочется потратить на реалистичную термодинамику ещё полгода разработки. Так что упростим донельзя. И ещё чуть-чуть.


Начнём с погодного менеджера. Когда-нибудь в нем будут и бури и штиль, но сейчас — только это:


namespace NewEngine.Core.Weather {
    public class WeatherManager : MonoBehaviour {
        [SerializeField, Range(-100, 200)] float airTemperature;

        public float AirTemperature {
            get {
                return airTemperature;
            }
        }
    }
}

Концепция такова:


  1. Температура воды изначально равна температуре воздуха.
  2. При температуре ниже 0 °C вода замерзает.
  3. При температуре в 100 °C вода закипает.
  4. Водные объёмы обмениваются температурой друг с другом.
  5. Водные объёмы обмениваются температурой с атмосферой, при этом учитывается объём (в геометрическом смысле) атмосферы.
  6. Оптимизации и деоптимизации:
    6.1 Кипящая вода никогда не спит.
    6.2. При "пробуждении" воды происходит упрощенный расчёт изменений температуры жидкости за время сна.

Температуру для WaterPoligon.Line будем хранить только для верхнего и нижнего концов водного столба.


class Line {
    public int min;
    public int max;
    ...
    public float minTemperature;
    public float maxTemperature;
    ...
}

Крайне неточный способ — ведь у всех столбов разная высота, но нам подойдёт.


Сначала была идея разбивать столбы на некоторое количество кусочков и рассчитывать передачу тепла между этими кусочками (как в одном столбе, так и между соседними). В этом случае можно было бы делать "слоистую воду" — лёд/вода/лед, что невозможно в текущей реализации.

Передача температуры осуществляется в трёх разных "направлениях". Самое первое и очевидное — между краями водного столба:


if (length > 0) {
    ...

    float avgTemperature = (maxTemperature + minTemperature) * 0.5f;
    float ratioTranscalency = verticalTranscalency / length;

    maxTemperature = maxTemperature + (avgTemperature - maxTemperature) * ratioTranscalency;
    minTemperature = minTemperature + (avgTemperature - minTemperature) * ratioTranscalency;
}

Второе — более интересное. Теплообмен между воздухом и верхним краем столба. При генерации воды мы получали height, фактически, координату потолка. А значит, в любой момент времени мы можем получить высоту воздушной прослойки над водой. Чем больше воздуха над водой, тем быстрее происходит теплообмен. Спорное утверждение. Зато на открытом воздухе вода будет остывать/разогреваться быстрее, чем в невысоких пещерах:


float length = height - min;

if (length > 0) {
    if (height < max) {
        float airVolume = Mathf.Min(max - height, MAX_AIR_VOLUME);
        maxTemperature = maxTemperature + (airTemperature - maxTemperature) * Mathf.Clamp01(airTranscalency * airVolume);
    }
    ...
}

Остается теплообмен между соседними столбиками. Так как информация о температуре хранится только на краях столбов, линейная интерполяция выходит на сцену.



Для любителей картинок


Для любителей кода
static void UpdateTemperatureDelta(float horisontalTranscalency, float[] minTemperatureDelta, float[] maxTemperatureDelta, int i, Line line, Line other) {
    if (line.height <= line.min || other.height <= other.min) {
        minTemperatureDelta[i] = 0;
        maxTemperatureDelta[i] = 0;
        return;
    }

    float height = line.height - line.min;
    float otherHeight = other.height - other.min;
    if (Mathf.Max(line.height, other.height) - Mathf.Min(line.min, other.min) >= height + otherHeight) {
        minTemperatureDelta[i] = 0;
        maxTemperatureDelta[i] = 0;
        return;
    }

    float minY = Mathf.Max(line.min, other.min);
    float maxY = Mathf.Min(line.height, other.height);

    float minT = (minY - line.min) / height;
    float maxT = (maxY - line.min) / height;
    float otherMinT = (minY - other.min) / otherHeight;
    float otherMaxT = (maxY - other.min) / otherHeight;

    float minTemperature = Mathf.Lerp(line.minTemperature, line.maxTemperature, minT);
    float maxTemperature = Mathf.Lerp(line.minTemperature, line.maxTemperature, maxT);
    float otherMinTemperature = Mathf.Lerp(other.minTemperature, other.maxTemperature, otherMinT);
    float otherMaxTemperature = Mathf.Lerp(other.minTemperature, other.maxTemperature, otherMaxT);

    float ratio = horisontalTranscalency * Mathf.Clamp01(height / otherHeight);

    minTemperatureDelta[i] = ratio * (minTemperature - otherMinTemperature);
    maxTemperatureDelta[i] = ratio * (maxTemperature - otherMaxTemperature);
}

Обмен температурой похож по структуре на обмен волнами, но волна передаётся только одному соседнему WaterPolygon'у с каждой стороны, а теплота — каждому из соседей.


И последний штрих — кипение и замерзание воды. Если температура выше или равна 100 °C — добавляем случайную скорость пружины к столбу жидкости, причем чем выше температура — тем больше разброс скоростей (на самом деле, не ровно 100 °C, вода начинает пузыриться чуть раньше).


Ну а если температура ниже или равна 0 °C — сохраняем скорость пружины равной нулю и перестаём реагировать на передачу скоростей от соседних столбиков-пружин.


Или, другими словами:


if (height < max) {
    if (maxTemperature >= minBoilTemperature) {
        float depth = target - min;
        float ratio = Mathf.Clamp01((maxTemperature - minBoilTemperature) / (100 - minBoilTemperature));
        float heightValue = Mathf.Min(depth * 2, Mathf.Max(minBoilBubble, ratio * maxBoilBubble));
        float frequency = Mathf.Lerp(0, boilFrequency, ratio * ratio);

        if (Random.value > 1 - frequency)
            height += Random.Range(-heightValue, heightValue);
    }
}

if (maxTemperature <= 0) {
    speed = 0;
    return;
}


Постепенное закипание воды


Заключение и интрига


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


Было бы обидно получить некрасивую картинку, написав столько кода. Отражения, преломления, каустика — наше всё! А значит, снова толстые шейдеры, взаимодействие с системой освещения, рендер в текстуру и всё такое.


Но об этом — в следующей статье. :)

Tags:
Hubs:
+85
Comments 36
Comments Comments 36

Articles