Глобальное освещение, динамический свет и декали в действии.
Я очень люблю смотреть на белые предметы без текстуры. Недавно в художественном магазине я долго рассматривал гипсовые фигуры, которые художники используют в качестве модельных объектов. Очень приятно видеть все эти плавные переходы света и мягкие тени. Позже, когда я вернулся домой и открыл Unity3D, пришло понимание, что свет в моём проекте по-прежнему скучный и нереалистичный.
С этого момента началась история глобального освещения, которую я сегодня расскажу.
Предыдущие статьи
Часть первая. Свет.
Часть вторая. Структура.
Часть третья. Глобальное освещение.
Оглавление
- Как делать процедурно генерируемые эффекты
- Что такое глобальное освещение?
- Прямое освещение
- Непрямое освещение
- Освещение стен
- Декали
- Доработки динамического освещения
- Заключение
Как делать процедурно генерируемые эффекты
Самый первый комментарий к начальной статье этого цикла звучал так: "Магия! И прямые руки." Не уверен в полной прямоте моих рук (в конце предыдущей статьи — визуальные баги, которые это подтверждают), но никакой магии тут нет. Поделюсь секретом процедурных эффектов:
-
Минимум треть работы уже сделана, как только вам в голову пришла идея сделать процедурно генерируемый контент. Это может быть что угодно: пятна на крыльях бабочек или атмосфера планеты, деревья и кусты и т.д. Иногда, особенно со светом, сразу понятно, как происходит "генерация" в реальном мире. Чаще всего алгоритм сводится к: "пустить бесконечно много лучей в бесконечное количество направлений и получить реалистичную картинку".
-
И это вторая треть — написать подобный алгоритм (с учетом того, что бесконечность хорошо аппроксимируется тысячей). Он получается простой, как "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'ы через всю карту оказывается достаточно быстрым делом.
- Поиск твёрдости стен осуществляется тоже через Region tree. А результат (в виде черно-белой текстуры) может использоваться и в других постэффектах.
-
Я не использую цикл по всей текстуре, так как больше половины пикселей принадлежат стенам. Вместо этого итерация производится по массиву индексов "нетвёрдых пикселей".
// Метод строит маску видимости и одновременно список индексов. 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'а, и понимаем, как много времени займёт применение этих умных слов. С другой стороны — ведь любой переотраженный луч приходит откуда-то с карты, а всё прямое освещение мы только что построили! Расширяем массив и храним там целую структуру:
- "Прямая" яркость;
- "Непрямая" яркость;
- Вектор индексов пересечений (Обычный вектор из целых чисел. Его можно оптимизировать и создавать сразу массив размера 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.0f / scale;
float COLLISION_RATIO = 1.0f / 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" — "переводная картинка"), это отличный способ сделать игру более живой, не сильно жертвуя производительностью. Идея проста: на определенную поверхность (стена, пол и т.д) накладывается прямоугольник с текстурой, как настоящая переводная картинка. Это может быть след от пули, какой-нибудь мусор, надпись, что угодно.
Но мы будем использовать декали немного иначе: в качестве источников света произвольной формы. Раз уж мы генерируем текстуру с освещением, мы можем добавлять в неё объекты произвольной формы. И эти объекты сразу же начнут светиться! Так можно легко реализовать эффекты люминесценции, теплового излучения.
Но есть два важных момента:
- Кроме самого объекта нужно добавить bloom — как эффект мягкого рассеянного свечения;
- Нельзя рисовать объект и bloom одинаково на фоне и стенах: так потеряется ощущение глубины. Вместо этого будем рисовать спрайт либо только на стенах, либо только на фоне (помните маску твердости из глобального освещения?). А силу bloom'а будем менять тоже в зависимости от слоя.
По сути, алгоритм простой:
Разделим все декали (например, с помощью тегов Unity3D) на декали переднего и заднего планов:
- Отрисовываем спрайт с нужной яркостью и цветом в текстуру, с учётом п.3 или п.4;
- Добавляем эффект "bloom" (очередное размытие), с учётом п.3 или п.4;
- Декали переднего плана:
- Отрисовываются только на пикселях стены;
- Bloom эффект сильнее на пикселях стены и слабее на пикселях фона.
- Декали заднего плана:
- Отрисовываются только на пикселях фона;
- Bloom эффект сильнее на пикселях фона и слабее на пикселях стены.
На примере будет понятнее:
Находим старый спрайт травы.
Позиционируем "траву" так, чтобы она закрывала кончики стен.
Рендерим спрайт только в текстуру освещения.
Добавляем свечение на стены.
Добавляем свечение на фон.
И получаем интересную радиоактивную плесень.
А еще можно делать раскаленные стены, уникальные светящиеся предметы и многое другое.
Стена светится от счастья.
Доработки динамического освещения
Это очень короткий раздел и весь от первого лица. Наконец-то добрались руки сделать рендеринг только видимых источников света. Все источники, которые не попадают в камеру, не отрисовываются и не кушают драгоценный fps.
Более того, оказалось, что источники света составляют отличную иерархию:
1. SkyLight. Фоновое освещение, где важны яркость и цвет;
2. SunLight. Точечный источник света без затухания. Важны яркость, цвет и позиция;
3. PointLight. Точечный источник света c затуханием. Важны яркость, цвет, позиция и радиус;
4. FlashLight. Фонарик с коническим лучом. Важны яркость, цвет, позиция, радиус, угол поворота и ширина луча.А еще появилась возможность создавать любые другие источники света, наследуясь от базовых.
Вышеописанные источники света.
Заключение
Теперь в нашем проекте есть реалистичный свет, эффекты светимости и обновленные динамические источники света. Сравните с изображением из первой статьи, не так уж мало различий, правда?
Изображение из начала этой статьи.
Изображение из первой части цикла.
И самое интересное: теперь когда готово освещение и произведен рефакторинг алгоритмов и структуры проекта, пришло время написать про воду!
Спасибо за чтение и комментарии к прошлым частям и до следующей статьи!
Автор: nightrain912