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

в 13:51, , рубрики: C#, pixelart, raycasting, region tree, unity3d, Алгоритмы, глобальное освещение, декали, освещение, разработка игр

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

Глобальное освещение, динамический свет и декали в действии.

Я очень люблю смотреть на белые предметы без текстуры. Недавно в художественном магазине я долго рассматривал гипсовые фигуры, которые художники используют в качестве модельных объектов. Очень приятно видеть все эти плавные переходы света и мягкие тени. Позже, когда я вернулся домой и открыл Unity3D, пришло понимание, что свет в моём проекте по-прежнему скучный и нереалистичный.
С этого момента началась история глобального освещения, которую я сегодня расскажу.

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

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

Оглавление

  1. Как делать процедурно генерируемые эффекты
  2. Что такое глобальное освещение?
  3. Прямое освещение
  4. Непрямое освещение
  5. Освещение стен
  6. Декали
  7. Доработки динамического освещения
  8. Заключение

Как делать процедурно генерируемые эффекты

Самый первый комментарий к начальной статье этого цикла звучал так: "Магия! И прямые руки." Не уверен в полной прямоте моих рук (в конце предыдущей статьи — визуальные баги, которые это подтверждают), но никакой магии тут нет. Поделюсь секретом процедурных эффектов:

  • Минимум треть работы уже сделана, как только вам в голову пришла идея сделать процедурно генерируемый контент. Это может быть что угодно: пятна на крыльях бабочек или атмосфера планеты, деревья и кусты и т.д. Иногда, особенно со светом, сразу понятно, как происходит "генерация" в реальном мире. Чаще всего алгоритм сводится к: "пустить бесконечно много лучей в бесконечное количество направлений и получить реалистичную картинку".

  • И это вторая треть — написать подобный алгоритм (с учетом того, что бесконечность хорошо аппроксимируется тысячей). Он получается простой, как "hello world", но медленный. Руки сразу тянутся что-нибудь оптимизировать, но, поверьте, не стоит. Лучше запустить его в редакторе и пойти пить чай. А после чая понять, что придуманный метод не даст красивой картинки и всё переделать. Если планируется единожды предрассчитать какую-то картинку в редакторе, и потом использовать её в билде — на этом можно остановится.

  • И, наконец, последняя треть — придумать алгоритм, который даст визуально близкий результат, но будет работать быстрее. Обычно тут пригождается знание всяких интересных контейнеров, алгоритмов, деревьев и т.д. За один из таких алгоритмов — большое спасибо Dionis_mgn, который когда-то рассказал, как сделать классные двумерные тени.

Иногда получается сделать интересные штуки.

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

Планета из предыдущего проекта.

Например, небо для планет в одном из проектов предрассчитывалось так: для каждого пикселя неба выпускались по 20-30 лучей до разных частей Солнца, считалось, сколько лучей пересекается с самой планетой, какую часть пути луч прошел в атмосфере (для подобия рассеивания Рэлея). С хорошим качеством расчеты для одной планеты длились около 30-40 секунд и давали на выходе разнообразные атмосферы в зависимости от удаленности Солнца, "состава" и плотности атмосферы. А еще этому алгоритму удавались неплохие закаты.

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

Закат на Земле II.

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

Вся звёздная система.

Что такое глобальное освещение?

Необходимость что-то делать с освещением я заметил, когда добавил в демку смену дня и ночи. Лучи света от солнца и луны красиво освещали стены замков, но вот внутри помещений творилось что-то странное: как только рассветные лучи касались верхушек башен, в самых глубоких казематах становилось светло, простите за каламбур, как днём. Конечно, причина не в источнике света «defaultSun»: при смене дня и ночи менялись цвет и яркость неба. Вот они и влияли на каждый пиксель, в не зависимости того, был ли это пиксель травинки на старой крыше или камня в мрачной пещере.

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

Ещё одно из условий: рассчитываем глобальное освещение только для статических объектов: стен, земли. Так мы сможем запускать его при загрузке и пользоваться результатами весь уровень (без влияния на fps).

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

Кусочек сцены. На самом деле, расчеты идут для всей сцены целиком.

Прямое освещение

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

Для каждого пикселя:

* Проверим, принадлежит ли пиксель стене. Если да - помечаем его и пропускаем;
* Бросаем N лучей в верхнюю полуплоскость с интервалом между лучами в π / N градусов;
* Считаем C количество лучей, которые не пересеклись с элементами карты;
* Принимаем за освещённость пикселя значение C / N.

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

Демонстрация освещения одного пикселя.

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

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

При 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.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;
  }
}

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

Демонстрация непрямого освещения. Собираем из коллизий уже рассчитанное прямое освещение.

Самое главное, что теперь вместо операции raycast'а по region tree нам достаточно взять значение яркости в массиве: так мы получим одно отражение. Конечно, этот метод подходит только для pixelart'a: не нужно учитывать нормали или заботиться о возникающих артефактах.

Посмотрите, какие результаты даёт этот алгоритм:

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

Первое отражение.

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

Третье отражение.

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

Седьмое отражение.

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

Готовый результат для фоновых стен.

Довольно шумная картинка получается. На самом деле, после применения такого освещения к реальным текстурированным объектам шумы почти не заметны. К тому же высокочастотный шум исчезнет при использовании scale > 1.

Освещение стен

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

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

Стены без применения освещения.

В первом случае результат красиво смотрелся только под землёй, во втором — на поверхности. Нужно адаптивно менять яркость стен в зависимости от окружения.

А теперь история одного фейла. После многочасовых размышлений и прогулок в мне голову пришел исключительной красоты алгоритм, включающий в себя добавление новых методов в region tree, поиск ближайшей точки, не принадлежащей стене и прочее, прочее. Я реализовал этот код, потратив на него все выходные, оптимизировал, как только мог. Этот монстр вычислялся около минуты и всё равно выглядел не идеально. В какой-то момент я решил скрыть огрехи алгоритма, немного размыв по Гауссу результат. Это было идеально! Я ещё некоторое время вносил правки и небольшие изменения. Пока не наткнулся на ошибку в условии, из которой следовало, что результаты моего чудесного алгоритма отправлялись прямиком в garbage collector, а на финальные пиксели влияло только размытие. А вот картинка оставалась такой же красивой.

Зато теперь это самый быстрый этап всего глобального освещения. :)

Переведём наши массивы в текстуру, где в одном канале будет яркость пикселя, а другом — принадлежность стене. Размоем пиксели стены на GPU с помощью простого шейдера (простое среднее арифметическое с соседями) в цикле.

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

Размытые стены (scale = 2).

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

Вот такое недоразумение получится, если применить освещение.

В первой статье цикла я рассказывал про основы пиксельарта. Дополню еще одной важной аксиомой: никаких градиентов в духе photoshop'а! Это превращает аккуратную картинку в мыло и пластилин. На фоне градиенты не так бросаются в глаза, как на стенах. Пройдемся по текстуре с еще одним шейдером: для каждого пикселя стены с помощью простого округления (с коэффициентом из параметров шейдера) получим несколько градаций яркости. Конечно, полученные переходы далеки от идеала — рука художника не двигала пиксели, убирая кривые лесенки, но нам подойдет.

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

Световая маска с низкой дискретизацией (scale = 2).

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

Результат применения маски.

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

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

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

Итак, у нас есть глобальное освещение!

Плюсы этого алгоритма:

  • Настраиваемость. Меняя количество лучей, количество переотражений или размер текстуры, можем найти баланс между качеством и скоростью;
  • Многопоточность. В теории (на практике пока не дошли руки), алгоритм должен хорошо распараллеливаться;
  • Реалистичность. В пещерах темно, в комнатах — сумеречно, как мы и хотели;
  • Простота в использовании. Создаём новый уровень, запускаем игру и всё.

И минусы:

  • Скорость работы. Около двух секунд на расчет освещения при загрузке уровня;
  • Зависимость от размера карты. Увеличение карты в два раза замедлит расчет света тоже в два раза (забавный момент: чем сильнее мы заполним уровень стенами, тем быстрее будет рассчитываться свет);
  • Шумы. Возможно, на некоторых картах будут заметны артефакты освещения.

Декали

Хотя основная тема статьи раскрыта, это ещё не повод заканчивать стучать по клавишам. Скорее всего, это последняя статья про освещение. А значит, есть смысл рассказать про некоторые новые фишки, которые были добавлены после рефакторинга игры.

Декали ("decal" — "переводная картинка"), это отличный способ сделать игру более живой, не сильно жертвуя производительностью. Идея проста: на определенную поверхность (стена, пол и т.д) накладывается прямоугольник с текстурой, как настоящая переводная картинка. Это может быть след от пули, какой-нибудь мусор, надпись, что угодно.

Но мы будем использовать декали немного иначе: в качестве источников света произвольной формы. Раз уж мы генерируем текстуру с освещением, мы можем добавлять в неё объекты произвольной формы. И эти объекты сразу же начнут светиться! Так можно легко реализовать эффекты люминесценции, теплового излучения.

Но есть два важных момента:

  1. Кроме самого объекта нужно добавить bloom — как эффект мягкого рассеянного свечения;
  2. Нельзя рисовать объект и bloom одинаково на фоне и стенах: так потеряется ощущение глубины. Вместо этого будем рисовать спрайт либо только на стенах, либо только на фоне (помните маску твердости из глобального освещения?). А силу bloom'а будем менять тоже в зависимости от слоя.

По сути, алгоритм простой:

Разделим все декали (например, с помощью тегов Unity3D) на декали переднего и заднего планов:

  1. Отрисовываем спрайт с нужной яркостью и цветом в текстуру, с учётом п.3 или п.4;
  2. Добавляем эффект "bloom" (очередное размытие), с учётом п.3 или п.4;
  3. Декали переднего плана:
    • Отрисовываются только на пикселях стены;
    • Bloom эффект сильнее на пикселях стены и слабее на пикселях фона.
  4. Декали заднего плана:
    • Отрисовываются только на пикселях фона;
    • Bloom эффект сильнее на пикселях фона и слабее на пикселях стены.

На примере будет понятнее:

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

Находим старый спрайт травы.

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

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

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

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

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

Добавляем свечение на стены.

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

Добавляем свечение на фон.

И получаем интересную радиоактивную плесень.
А еще можно делать раскаленные стены, уникальные светящиеся предметы и многое другое.

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

Стена светится от счастья.

Доработки динамического освещения

Это очень короткий раздел и весь от первого лица. Наконец-то добрались руки сделать рендеринг только видимых источников света. Все источники, которые не попадают в камеру, не отрисовываются и не кушают драгоценный fps.

Более того, оказалось, что источники света составляют отличную иерархию:

1. SkyLight. Фоновое освещение, где важны яркость и цвет;
2. SunLight. Точечный источник света без затухания. Важны яркость, цвет и позиция;
3. PointLight. Точечный источник света c затуханием. Важны яркость, цвет, позиция и радиус;
4. FlashLight. Фонарик с коническим лучом. Важны яркость, цвет, позиция, радиус, угол поворота и ширина луча.

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

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

Вышеописанные источники света.

Заключение

Теперь в нашем проекте есть реалистичный свет, эффекты светимости и обновленные динамические источники света. Сравните с изображением из первой статьи, не так уж мало различий, правда?

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

Изображение из начала этой статьи.

image

Изображение из первой части цикла.

И самое интересное: теперь когда готово освещение и произведен рефакторинг алгоритмов и структуры проекта, пришло время написать про воду!

Спасибо за чтение и комментарии к прошлым частям и до следующей статьи!

Автор: nightrain912

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js