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


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

— Слухай, ти тоді про хвилі говорив, пам'ятаєш? Зацени!
— Так, гарні хвилі, а заломлення і каустику ще не робив?

— Привіт, я тут грався з 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, Line out 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.5 f;
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;
}


Поступове закипання води
Висновок і інтрига
Після всього вищеописаного в проекті з'явилася вода, яку можна "вилити" на рівень у редакторі. Волнующаяся, кипляча, що твердіє на морозі цілюща волога!
Було б прикро отримати негарну картинку, написавши стільки коду. Відбиття, заломлення, каустику — наше все! А значить, знову товсті шейдери, взаємодія з системою освітлення, рендер в текстуру і все таке.
Але про це — в наступній статті. :)
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.