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


Глобальне освітлення, динамічний світло і декалі в дії.
Я дуже люблю дивитися на білі предмети без текстури. Нещодавно в художньому магазині я довго розглядав гіпсові фігури, які художники використовують в якості модельних об'єктів. Дуже приємно бачити всі ці плавні переходи світла і м'які тіні. Пізніше, коли я повернувся додому і відкрив Unity3D, прийшло розуміння, що світло в моєму проекті раніше нудний і нереалістичний.
З цього моменту почалася історія глобального освітлення, яку я сьогодні розповім.
Попередні статті
Частина перша. Світло.
Частина друга. Структура.
Частина третя. Глобальне освітлення.
Зміст
  1. Як робити процедурно генеруються ефекти
  2. Що таке глобальне освітлення?
  3. Пряме освітлення
  4. Непряме освітлення
  5. Освітлення стін
  6. Декалі
  7. Доопрацювання динамічного освітлення
  8. Висновок
Як робити процедурно генеруються ефекти
перший коментар до початкової статті цього циклу звучав так: "Магія! І прямі руки." Не впевнений в повній щирості моїх рук (в кінці попередньої статті — візуальні баги, які це підтверджують), але тут немає ніякої магії. Поділюся секретом процедурних ефектів:
  • Мінімум третину роботи вже зроблена, як тільки вам в голову прийшла ідея зробити процедурно генерований контент. Це може бути що завгодно: плями на крилах метеликів або атмосфера планети, дерева і кущі і т. д. Іноді, особливо зі світлом, відразу зрозуміло, як відбувається "генерація" в реальному світі. Найчастіше алгоритм зводиться до: "пустити нескінченно багато променів в нескінченну кількість напрямів і отримати реалістичну картинку".
  • І це друга третина — написати такий алгоритм (з урахуванням того, що нескінченність добре апроксимується тисячею). Він виходить простий, як "hello world", але повільний. Руки відразу тягнуться що-небудь оптимізувати, але, повірте, не варто. Краще запустити його в редакторі і піти пити чай. А після чаю зрозуміти, що придуманий метод не дасть красивої картинки і все переробити. Якщо планується один раз предрассчитать якусь картинку в редакторі, і потім використовувати її в билде — на цьому можна зупинитися.
  • І, нарешті, остання третина — придумати алгоритм, який дасть візуально близький результат, але буде працювати швидше. Зазвичай тут знадобиться знання всяких цікавих контейнерів, алгоритмів, дерев і т. д. За один з таких алгоритмів — велике спасибі Dionis_mgn, який колись розповів, як зробити класні двовимірні тіні.
Іноді виходить зробити цікаві штуки.
Планета з попереднього проекту.
Наприклад, небо для планет в одному з проектів предрассчитывалось так: для кожного пікселя неба випускалися по 20-30 променів до різних частин Сонця, вважалося, скільки променів перетинається з самою планетою, яку частину шляху промінь пройшов в атмосфері (для подібності розсіювання Релея). З гарною якістю розрахунки для однієї планети тривали близько 30-40 секунд і давали на виході різноманітні атмосфери в залежності від віддаленості від Сонця, "складу" і щільності атмосфери. А ще цього алгоритму вдавалися непогані заходи.

Захід сонця на Землі II.

Вся зоряна система.
Що таке глобальне освітлення?
Необхідність щось робити з освітленням я помітив, коли додав в демку зміну дня і ночі. Промені світла від сонця і місяця красиво висвітлювали стіни замків, але ось всередині приміщень творилося щось дивне: як тільки світанкові промені торкалися верхівок башт, у найглибших казематах ставало ясно, вибачте за каламбур, як вдень. Звичайно, причина не в джерелі світла «defaultSun»: при зміні дня і ночі змінювалися колір і яскравість неба. Ось вони і впливали на кожен піксель не залежно, чи це піксель травинки на старій даху чи каменю в похмурій печері.
— Давайте визначимося, яку картинку ми взагалі хочемо отримати. "На світлі світло, в темряві — темно" — звучить непогано для відправної точки. Як в реальному світі: в шафі темно, в коридорі світліше, в кімнаті ще світліше, а на даху зовсім яскраво. Переформулюємо: елементи фону, персонажі та інші об'єкти повинні отримувати стільки світла, скільки фотонів змогла дістатися до них від небесної сфери (в нашому 2D випадку — небесної колу). Зрозуміло, що краще направляти наші "фотони" не з неба, як в реальному світі, а навпаки, з освітлюваної точки в небо: в іншому випадку нам знадобиться занадто багато кидків, та й то, більшість підуть "у молоко".
Ще одна з умов: розраховуємо глобальне освітлення тільки для статичних об'єктів: стін, землі. Так ми зможемо запускати його при завантаженні і користуватися результатами весь рівень (без впливу на fps).

Шматочок сцени. Насправді, розрахунки йдуть для всієї сцени цілком.
Пряме освітлення
Сказано — зроблено. Створюємо текстуру розміром зі все ігрове поле. Пробегаемся по кожному пікселю і дивимося, як багато прямих променів можна протягнути від цієї точки до "неба". Промені будемо кидати з рівними кутами у верхню полуплоскость, а "небом" вважаємо найближчу точку за межами карти (цілком вистачить відстані діагоналі описує карту прямокутника).
Разом, алгоритм прямого освітлення:
Для кожного пікселя:
* Перевіримо, чи належить піксель стіні. Якщо так - помічаємо його і пропускаємо;
* Кидаємо N променів у верхню полуплоскость з інтервалом між променями в π / N градусів;
* Вважаємо C кількість променів, які не перетнулися з елементами карти;
* Приймаємо за освітленість пікселя значення C / N.


Демонстрація освітлення одного пікселя.
Щоб прискорити процес, будемо працювати не з текстурою, а з одновимірним масивом яскравостей. Та й не обов'язково обробляти кожен піксель: введемо коефіцієнт scale, при scale=4 будемо працювати з кожним четвертим пікселем. Розмір текстури і швидкість роботи зросте в scale^2 разів. Крім того, нам не потрібно обробляти "тверді" пікселі стін, але вони нам знадобляться надалі. Заведемо для них окремий масив з булевими операторами значеннями "твердості".

При 25и променях отримуємо таку текстуру.
Пам'ятайте, у минулій частині був розділ про Region tree? З його допомогою кидати raycast'и через всю карту виявляється досить швидким справою.
Хінти
  1. Пошук твердості стін здійснюється також через Region tree. А результат (у вигляді чорно-білої текстури) може використовуватися і в інших постэффектах.
  2. Я не використовую цикл по всій текстурою, так як більше половини пікселів належать стін. Замість цього ітерація проводиться по масиву індексів "нетвердих пікселів".
    // Метод будує маску видимості і одночасно список індексів.
    static Texture2D FindEmptyCells(VolumeTree tree, IntVector2 startPosition, int fullHeight, int fullWidth, int height, int width, int scale, out List<IntVector2> result, out List<int> indexes) {
    var texture = new Texture2D(fullWidth, fullHeight, Core.Render.Utils.GetSupportsFormat(TextureFormat.Alpha8), false, true);
    texture.filterMode = FilterMode.Point;
    texture.wrapMode = TextureWrapMode.Clamp;
    
    result = new List<IntVector2>();
    indexes = new List < int>();
    Color[] mask = new Color[fullWidth * fullHeight]; 
    
    var point = startPosition;
    
    int index = 0;
    int fullIndex = 0;
    
    for (int y = 0; y < fullHeight; ++y) {
    point.x = startPosition.x;
    for (int x = 0; x < fullWidth; ++x) {
    if (tree.Test(point)) {
    mask[fullIndex].a = 0;
    ++point.x;
    ++fullIndex;
    
    if (y % scale == 0 && x % scale == 0)
    ++index;
    
    continue;
    }
    
    mask[fullIndex].a = 1;
    
    if (y % scale == 0 && x % scale == 0) {
    result.Add(point);
    indexes.Add(index);
    ++index;
    }
    ++point.x;
    ++fullIndex;
    }
    
    ++point.y;
    }
    
    texture.SetPixels(mask);
    texture.Apply();
    return texture;
    }


Непряме освітлення
Прямих променів явно недостатньо: занадто темно буде в кімнатах замку, так і різкі межі добре видно. Згадуємо розумні слова, начебто raytracing'а, і розуміємо, як багато часу займе застосування цих розумних слів. З іншого боку — адже будь переотраженный промінь приходить звідкись з карти, а всі пряме освітлення ми тільки що побудували! Розширюємо масив і зберігаємо там цілу структуру:
  1. "Пряма" яскравість;
  2. "Непряма" яскравість;
  3. Вектор індексів перетинів (Звичайний вектор з цілих чисел. Його можна оптимізувати і створювати відразу масив розміру N, і зберігати реальну кількість в однієї змінної).
Переробимо алгоритм прямого освітлення, додаючи дані про колізії:
Для кожного пікселя:
* Перевіримо, чи належить піксель стіні. Якщо так - помічаємо його і пропускаємо;
* Кидаємо N променів у верхню полуплоскость з інтервалом між променями в π / N градусів;
* Для кожного променя:
* Якщо промінь перетнувся з елементом карти:
* Отримуємо точку перетину;
* Переводимо координати цієї точки в індекс у масиві (з урахуванням масштабу);
* Додаємо індекс вектор перетинів
* Вважаємо C кількість променів, які не перетнулися з елементами карти;
* Приймаємо за освітленість пікселя значення C / N.

Нарешті вихідні!
struct CellInfo {
public float directIllumination;
public float indirectIllumination;
public Vector2[] normals;
public Vector2[] collisions;
public int collisionsCount;

public CellInfo (int directions) {
directIllumination = 0;
indirectIllumination = 0;
normals = new Vector2[directions];
collisions = new Vector2[directions];
collisionsCount = 0;
}
}

static CellInfo[] GenerateDirectIllumination(VolumeTree tree, List<IntVector2> points, List<int> indexes, IntVector2 startPosition, int height, int width, int scale, int directionsCount) {
const float DISTANCE_RATIO = 2;
float NORMAL_RATIO = 2.0 f / scale;
float COLLISION_RATIO = 1.0 f / scale;
var result = new CellInfo[width * height];

Vector2[] directions = new Vector2[directionsCount];

var distance = Mathf.Sqrt(height * height + width * width) * scale * DISTANCE_RATIO;
for (int i = 0; i < directionsCount; ++i) {
float angle = i * Mathf.PI / directionsCount * 2;
directions[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * distance;
}

for (int i = 0, count = points.Count; i < count; ++i) {
var point = points[i];
int cellIndex = indexes[i];
result[cellIndex] = new CellInfo(directionsCount);

int collisionIndex = 0;
for (int j = 0; j < directionsCount; j++) {
// TODO винести на початок функції якщо профайлер скаже
float collisionX = 0;
float collisionY = 0;
int normalX = 0;
int normalY = 0;

if (tree.Raycast(point.x, point.y, point.x + directions[j].x, point.y + directions[j].y, ref collisionX, ref collisionY, ref normalX, ref normalY)) {
result[cellIndex].normals[collisionIndex].Set(normalX * NORMAL_RATIO, normalY * NORMAL_RATIO);
result[cellIndex].collisions[collisionIndex].Set(collisionX * COLLISION_RATIO, collisionY * COLLISION_RATIO);
++collisionIndex;
}
}

result[cellIndex].directIllumination = 1 - (float)collisionIndex / directionsCount;
result[cellIndex].collisionsCount = collisionIndex;
}

return result;
}

* Нормалі потрібні з простої причини: точка перетину, що повертається raycast'му — в стіні. Нам потрібно відступити вбік, щоб отримати координати найближчого до стіни пікселя.
* Метод raycast'а для region tree я знайти не зміг, тому ділюся своїми напрацюваннями:
1. Беремо вузол (спочатку — кореневий) і знаходимо перетин з ним за допомогою алгоритму Ліанга-Панськи;
2. З чотирьох вузлів нащадків знаходимо той, якому належить найближча точка перетину;
2.1. Якщо вузол — твердий аркуш, повертаємо координати точки перетину і нормалі;
2.2. Якщо вузол не є листом, спускаємося нижче, починаючи з кроку 1;
3. Знаходимо дальню точку перетину прямої з вузлом нащадком (той же алгоритм Ліанга-Панськи). Знаходимо ще одного нащадка, якому належить ця точка (тобто, якщо ми спочатку потрапили у верхній лівий вузол, а пряма — вертикальна, то тепер це буде нижній лівий кут). Продовжуємо з кроку 2.1.

Якщо простіше, ми перевіряємо перетину відрізка з квадратами, починаючи від самого великого і до самого дрібного, причому сортуємо їх по близькості до початку променя, до тих пір, поки не натрапимо на твердий вузол.

Тепер у нас достатньо інформації, щоб розрахувати будь-яку кількість відбиттів: якщо
промінь пішов у небо, отримуємо пряме освітлення, в іншому випадку — непряма з точки перетину.
Так виходить алгоритм непрямого освітлення:
* Для кожного пікселя A:
* Приймемо кількість збережених колізій за M;
* Для кожної колізії, збереженої в пікселі A:
* Отримаємо яскравість прямого освітлення пікселя B з координатами колізії;
* Додамо отриману яскравість в "непряме освітлення" пікселя A.
*Для кожного пікселя A:
* Додамо значення "непрямого освітлення" в "прямого освітлення" з коефіцієнтом 1 / M;
* Очистимо значення "непрямого освітлення".

А тепер у вигляді коду.
static void GenerateIndirectIllumination(List<IntVector2> points, List<int> indexes, CellInfo[] info, IntVector2 startPosition, int height, int width, int scale, int directionsCount) {
Vector2 floatStartPosition = startPosition.ToPixelsVector() / scale;

for (int i = 0, count = points.Count; i < count; ++i) {
var point = points[i];
int cellIndex = indexes[i];

var pixelInfo = info[cellIndex];

if (pixelInfo.collisionsCount == 0)
continue;

float indirectIllumination = directionsCount - pixelInfo.collisionsCount;
for (int j = 0, collisionsCount = pixelInfo.collisionsCount; j < collisionsCount; j++) {
var collisionPoint = pixelInfo.collisions[j] + pixelInfo.normals[j] - floatStartPosition;
int x = Mathf.RoundToInt(collisionPoint.x);
int y = Mathf.RoundToInt(collisionPoint.y);

if (x < 0 || y < 0 || x >= width || y >= height)
continue;

int index = x + y * width;
indirectIllumination += info[index].directIllumination;
}

info[cellIndex].indirectIllumination = indirectIllumination / (float)directionsCount;
}
}


Демонстрація непрямого освітлення. Збираємо з колізій вже розраховане пряме освітлення.
найголовніше, що тепер замість операції raycast'а за region tree нам достатньо взяти значення яскравості в масиві: так ми отримаємо одне відображення. Звичайно, цей метод підходить тільки для pixelart'a: не потрібно враховувати нормалі або піклуватися про виникають артефакти.
Подивіться, які результати дає цей алгоритм:

Перше відображення.

Третє відображення.

Сьоме відображення.

Готовий результат для фонових стін.
Досить гучна картинка виходить. Насправді, після застосування такого освітлення до реальних текстурованим об'єктів шуми майже не помітні. До того ж високочастотний шум зникне при використанні scale > 1.
Освітлення стін
Ось тільки стіни в поточній текстурі чорні. "Звичайно", заперечить зануда, далекий від геймдеву, пиксельарта і почуття прекрасного — "Адже це не стіни, а зріз тривимірних стін у двовимірному просторі. А всередині стін, як відомо, темно.". Подякуємо зануду і продовжимо експерименти. Спробуємо взагалі не затемнювати стіни:

Стіни без застосування освітлення.
У першому випадку результат красиво виглядав тільки під землею, у другому — на поверхні. Потрібно адаптивно міняти яскравість стін в залежності від оточення.
А тепер історія одного фейла. Після багатогодинних роздумів і прогулянок в мені голову прийшов виняткової краси алгоритм, що включає в себе додавання нових методів в region tree, пошук найближчої точки, яка не належить стіни і інше, інше. Я реалізував цей код, витративши на нього всі вихідні, оптимізував, як тільки міг. Цей монстр обчислювався близько хвилини і все одно виглядав не ідеально. В якийсь момент я вирішив приховати огріхи алгоритму, трохи розмив за Гаусом результат. Це було ідеально! Я ще деякий час вносив правки і невеликі зміни. Поки не натрапив на помилку в умові, з якої випливало, що результати мого чудесного алгоритму відправлялися прямо в garbage collector, а на фінальні пікселі впливало тільки розмиття. А ось картинка залишалася такою ж красивою.
Зате тепер це найшвидший етап всього глобального освітлення. :)
Переведемо наші масиви в текстуру, де в одному каналі буде яскравість пікселя, а іншому — приналежність стіні. Розмиємо пікселі стіни на GPU з допомогою простого шейдерів (просте середнє арифметичне із сусідами) у циклі.

Розмиті стіни (scale = 2).

Ось таке непорозуміння вийде, якщо застосувати освітлення.
У першій статті циклу я розповідав про основи пиксельарта. Доповню ще однією важливою аксіомою: жодних градієнтів в дусі photoshop'у! Це перетворює акуратну картинку в мило і пластилін. На тлі градієнти не так кидаються в очі, як на стінах. Пройдемося по текстурі з ще одним шейдером: для кожного пікселя стіни з допомогою простого округлення (з коефіцієнтом з параметрів шейдера) отримаємо кілька градацій яскравості. Звичайно, отримані переходи далекі від ідеалу — рука художника не рухала пікселі, прибираючи криві драбинки, але нам підійде.

Світлова маска з низькою дискретизацією (scale = 2).

Результат застосування маски.

Результат застосування маски при використанні реальних текстур.
Зверніть увагу, як добре ховаються шуми і недоліки освітлення, коли ми застосовуємо його до реальних текстур. Якщо б глобальне освітлення було динамічним, людський мозок, відмінно розпізнає рух, відразу ж знайшов би косяки.
Отже, у нас є глобальне освітлення!
Плюси цього алгоритму:
  • Настроюваність. Змінюючи кількість променів, кількість перевідбиттів або розмір текстури, можемо знайти баланс між якістю і швидкістю;
  • Багатопоточність. В теорії (на практиці поки не дійшли руки), алгоритм повинен добре розпаралелюватися;
  • Реалістичність. В печерах темно, в кімнатах — сутінково, як ми і хотіли;
  • Простота у використанні. Створюємо новий рівень, запускаємо гру і все.
І мінуси:
  • Швидкість роботи. Близько двох секунд на розрахунок освітлення при завантаженні рівня;
  • Залежність від розміру карти. Збільшення карти в два рази сповільнить розрахунок світла теж у два рази (кумедний момент: чим сильніше ми заповнимо рівень стінами, тим швидше буде розраховуватися світло);
  • Шуми. Можливо, на деяких картах будуть помітні артефакти освітлення.
Декалі
Хоча основна тема статті розкрита, це ще не привід закінчувати стукати по клавішах. Швидше за все, це остання стаття про освітлення. А значить, є сенс розповісти про деякі нові фішки, які були додані після рефакторінгу гри.
Декалі ("decal" — "перекладна картинка"), це відмінний спосіб зробити гру більш живий, не сильно жертвуючи продуктивністю. Ідея проста: на певну поверхню (стіна, підлога і т. д) накладається прямокутник з текстурою, як справжня перекладна картинка. Це може бути слід від кулі, який-небудь сміття, напис, що завгодно.
Але ми будемо використовувати декалі трохи інакше: в якості джерел світла довільної форми. Раз вже ми генеруємо текстуру з освітленням, ми можемо додавати в неї об'єкти довільної форми. І ці об'єкти відразу ж почнуть світитися! Так можна легко реалізувати ефекти люмінесценції, теплового випромінювання.
Але є два важливих моменти:
  1. Крім самого об'єкта потрібно додати bloom — як ефект м'якого розсіяного світла;
  2. не Можна малювати об'єкт і bloom однаково на тлі і стінах: так втратиться відчуття глибини. Замість цього будемо малювати спрайт або тільки на стінах, або тільки на тлі (пам'ятаєте маску твердості з глобального освітлення?). А силу bloom'а будемо міняти теж в залежності від шару.
По суті, алгоритм простий:
Розділимо всі декалі (наприклад, з допомогою тегів Unity3D) на декалі переднього і заднього планів:
  1. Промальовуємо спрайт з потрібною яскравістю і кольором текстури, з урахуванням п. 3 або 4 п.;
  2. Додаємо ефект "bloom" (чергове розмиття), з урахуванням п. 3 або 4 п.;
  3. Декалі переднього плану:
    • Відмальовує тільки на пікселях стіни;
    • Bloom ефект сильніше на пікселях стіни і слабкіше пікселях фону.

  4. Декалі заднього плану:
    • Відмальовує тільки на пікселях фону;
    • Bloom ефект сильніше на пікселях фону і слабкіше пікселях стіни.

На прикладі буде зрозуміліше:

Знаходимо старий спрайт трави.

Позиціонуємо "траву" так, щоб вона закривала кінчики стін.

Рендерим спрайт тільки на текстуру освітлення.

Додаємо світіння на стіни.

Додаємо світіння на фон.
І отримуємо цікаву радіоактивну цвіль.
А ще можна робити розпечені стіни, унікальні світяться предмети і багато іншого.

Стіна світиться від щастя.
Доопрацювання динамічного освітлення
Це дуже короткий розділ і весь від першої особи. Нарешті дійшли руки зробити рендеринг тільки видимих джерел світла. Всі джерела, які не потрапляють в камеру, не відмальовує і не їдять дорогоцінний fps.
Більше того, виявилося, що джерела світла складають відмінну ієрархію:
1. SkyLight. Фонове освітлення, де важливі яскравість і колір;
2. SunLight. Точкове джерело світла без загасання. Важливі яскравість, колір і позиція;
3. PointLight. Точкове джерело світла c загасанням. Важливі яскравість, колір, позиція і радіус;
4. FlashLight. Ліхтарик з конічним променем. Важливі яскравість, колір, позиція, радіус, кут повороту і ширина променя.

А ще з'явилася можливість створювати будь-які інші джерела світла, наследуясь від базових.

Вищеописані джерела світла.
Висновок
Тепер у нашому проекті є реалістичний світ, ефекти світності і оновлені динамічні джерела світла. Порівняйте з зображенням з першої статті, не так вже мало відмінностей, правда?

Зображення з початку цієї статті.
image
Зображення з першої частини циклу.
І найцікавіше: тепер коли готове освітлення та проведений рефакторинг алгоритмів та структури проекту, прийшов час написати про воду!
Спасибі за читання і коментарі до минулих частинах і до наступної статті!

Джерело: Хабрахабр

0 коментарів

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