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


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


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


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


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


    Оглавление


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

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


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


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


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


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

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

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


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



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



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


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


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

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


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



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


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


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


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

    UPD:


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


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


    Чтобы ускорить процесс, будем работать не с текстурой, а с одномерным массивом яркостей. Да и не обязательно обрабатывать каждый пиксель: введем коэффициент 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.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" — "переводная картинка"), это отличный способ сделать игру более живой, не сильно жертвуя производительностью. Идея проста: на определенную поверхность (стена, пол и т.д) накладывается прямоугольник с текстурой, как настоящая переводная картинка. Это может быть след от пули, какой-нибудь мусор, надпись, что угодно.


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


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


    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

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


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


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

    Only registered users can participate in poll. Log in, please.

    Больше кода, больше картинок или больше хинтов?

    • 39.8%Больше кода. Так станет понятнее, о чем ты вообще пишешь.100
    • 36.2%Больше картинок. Алгоритмы понятны, а вот как выглядит результат — нет.91
    • 23.9%Больше хинтов. Кода и картинок полно на хабре, а подводные камни Unity3d почти не описаны.60
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 47

      +8
      БОЛЬШЕ БОЛЬШЕ БОГУ БОЛЬШЕ!!! Всё интересно, и алгоритмы, и картинки, и видео, и подводные камни!
        0
        Чую скоро кирпичи будут с нормалмаппингом и объемным попиксельным освещением.

        Спасибо вам за интересную статью. Нового для себя почерпнул мало, но картинки посмотрел с удовольствием.
          0
          Нет, скорее всего, normalMapping'а не будет, сколько его не пробовал для пиксельарта, получается два варианта:
          1. Пластилиновое, с сильными градиентами нечто, убивающее весь дух пиксельарта;
          2. Шумное и нереалистичное нечто.
            +2
            Честно говоря с пиксельартом не пробовал, не работаю в данном стиле. Нахожу 99% его реализаций сегодня жутким трешем с пикселями разного размера, мерцаниями при движениях и так далее. У вас пока пиксели очень радуют глаз.
              +7
              Как-то экспериментировал с пиксель артом и PBR. Скажу, что результата добился желаемого, но задача действительно сложная и нормал мапы шлифовал долго.
              image
                +1
                Смотрится очень круто. Поделитесь, что за проект?
                Я так понимаю, плащ изначально был отрендерен в 3d?
                  –1
                  Клинок коротковатый. Это кинжальчик? Длина одноручного меча должна быть такой что при удерживании в руке остриём вниз, до земли должна оставаться пара сантиметров.
                    +1
                    PS хотя конечно зависит от сеттинга: для античности это вполне обычный клинок, а в Средние Века во многих странах, короткие мечи были для простолюдинов, а длинные были исключительно только для дворян (ну и купцам, в виде исключения, при длительном путешествии разрешалось взять с собой седельный меч — носимый пристёгнутым к седлу и запрещённый для пешего использования).
                    0
                    Выглядит чуднО. Возможно из-за черной обводки. Но круто, конечно, мне нравится.
                      +1
                      Ого, а как такое отражение сделано? Это в юнити?
                    +1
                    nightrain912 посмотрите, когда то давно делал normalMapping для пиксель арта:
                    https://habrahabr.ru/post/183534/
                    И большое спасибо за статью, хочется еще и еще)
                      0

                      Да, читал вашу статью, когда начинал этот проект :)
                      Возможно, если сделать свет более дискретным (round(lightValue * 5) / 5), мне может подойти normalMapping. Но мне кажется, в динамике даже нескольких уровней будет достаточно глазу, чтобы заметить градиентное мыло.
                      Думал над таким вариантом: делать очень дискретную normal map, всего с несколькими уровнями. А результат normalMapping'а искусственно зашумлять.

                        0
                        А почему вы решили, что обязательно мыло-то будет?
                          0

                          Пробовал пару лет назад что-то подобное :)

                  0
                  Чёрный фон вместо земли всё портит, как по мне. Некоторые локации выглядят просто ляпами на картинке из-за этого.

                  Возможно в движении всё по другому, конечно, но в статике так.
                    0
                    Дело еще и в размере персонажа: по факту, мы будем видеть несколько десятков пикселей земли максимум. Поэтому будут незаметны большие тёмные зоны.
                      0
                      А где-то можно на это всё в динамике глянуть?
                        0

                        Пока нет, к сожалению. Сейчас доделываю воду, в следующей статье будет много видео. Постараюсь показать этот момент.

                  0
                  А где видео к этой статье? :)
                    +2
                    Боюсь, что видео для статического глобального освещения ничем не будет отличаться от картинки :)
                    +1
                    Интересно попробовать просто последовательность размытий неба. Навскидку в фотошопе получилось вот так:



                    Эффект конечно совсем не тот :) Но зато можно в одном шейдере реализовать))
                      +1
                      Это SSAO для «бедных» xD, оно не мешает тому что сделал автор, а вполне может его дополнить.
                        +1
                        HBAO тут наверное более уместен. И да, он явно бы дополнил светотень.
                      0

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


                      Так круто, когда кто-нибудь не просто комментит статьи, но и еще создаёт какой-то контент для коммента, чтобы подчеркнуть своё мнение! :)

                      0
                      Придется конкретно заморочиться с оптимизацией при пересчете света при любом изменении геометрии уровня. Для открывания дверей например.
                        0
                        Можно заранее просчитать маски при изменении геометрии и накладывать их по условиям. Главное, не делать уровни со вложенным комбинаторным взрывом.
                          0

                          Я думал о немного другом пути (и не могли бы вы пояснить идею с масками?):
                          По сути, можно рендерить освещение только для текущей области видимости в текстуру/кеш. Это могло бы позволить делать уровни любого размера (сейчас размер очень ограничен), даже добавлять динамику. Но появятся ограничения:


                          • Нельзя будет сильно отдалять камеру (хотя можно перерассчитывать свет с меньшим масштабом, но артефакты будут сразу видны)
                          • возможно, не получится добиться высокой производительности: сейчас вся фишка алгоритма в том, что мы не делаем raycast'ы для непрямого освещения (т.к уже есть все данные), а если считать только для экрана — придётся их делать, причем достаточно много.
                            +1
                            Вот, смотрите, у вас статическое ненаправленное освещение и направленное дальше 3-4 шага отражений, в принципе, не особо будут изменяться с изменением направления освещения. Первое можно в принципе посчитать один раз и забыть (или два, при минимальной и максимальной силе освещения), второе считать в динамике первые 3 шага, а остальное считать из 5-10 заранее посчитанных масок для разных углов, плавно накладывая их друг на друга умножением. Их разница и общий вклад уже не будут столь важны, чтобы кто-то не особо въедливый заметил, да и глубину для экономии ресурсов можно значительно уменьшить.
                            Но я скорее писал о геометрии, если её изменение можно предсказывать, а далеко не все игры предполагают обратное, можно заранее посчитать освещение до и после и сохранить в памяти дельту. Это касается каких-нибудь дверей, ставней, источников света и тп.
                          +1

                          Поэтому у меня нет дверей :)
                          А если серьезно — да, несколько секунд расчетов — это неприемлемо для динамического освещения. Просто для интереса: я выкрутил на минимум настройки света и подвигал блоки, которые должны быть static (у меня такие вещи вызывают перерасчет метаинформации о уровне, для редактора). Смотрится дико круто. Но, даже и минимальными настройками (масштабом 4 или 8, и меньшим количеством лучей) притормаживает. :(
                          Всё-таки, алгоритм был изначально направлен на статику.

                            +1
                            Можно рассмотреть вариант:
                            1. Определить конкретную геометрию как динамическую (только присутствие\отсутствие).
                            2. Разделить результат расчета света на несколько слоев, для каждого луча или группы.
                            3. При пересечении лучом динамической геометрии Считать сразу 2 варианта. С учетом отражения и с учетом прохода сквозь.
                            4. В рантайме в зависимости от вкл\выкл динамического объекта использовать один или другой результат расчета слоев.
                            5. Перед самим рендером из набора необходимых для кадра слоев собрать текущую карту освещенности.
                            Правда при большом количестве коллизий будет задействовано очень много видеопамяти, нужно дополнительно упаковывать или обрезать результаты расчетов света для лучей\групп.
                              0

                              Кстати, неплохая идея. Интересно только, как обеспечить всего 2 текстуры: по идее, для каждого динамического объекта придется считать отдельную текстуру, разве нет?

                                +1
                                К сожалению этого будет недостаточно, будут артефакты и засветы. Все из-за того что в некоторых случаях пиксели будут подсвечиваться и прямым и отраженным светом и накладываться на освещение только с прямым светом. Если ввести ограничение на количество отражений, то можно интенсивность освещения для каждой итерации записывать в свой цветовой диапазон. Тогда можно проходить для пикселя примерно так:
                                — смотрим освещенность из главного диапазона, освещаем ей.
                                — сморим освещенность из менее приоритетного диапазона (первое отражение) и досветляем если значение больше чем в первой итерации, и не засветляем если больше.
                                и так до окончания диапазонов. Экономим n текстур,

                                Но вообще все это сложно, и не эффективно, для динамического освещения нужно исследовать в другую сторону. Данные алгоритмы пригодны только для красивой статики, лепить поддержку динамики не продуктивно. ИМХО.
                          0
                          Не приходило в голову идеи при первичной трассировке проходить не попиксельно все мягкие точки с фиксированным углом, а похожим способом, как вы делали в первой статье: построением лучей (скорее, прямых) между двумя углами грунта, или, точнее, между границами и углами фона\стен. Тогда можно будет отсечь освещённые\неосвещённые первичными лучами пиксели без погрешности, которая появляется при фиксированных углах трассировки и выглядит, порой, мерзотно (в смысле несоответствия геометрии).
                          Если описывать алгоритм словами: предварительно выделяем все координаты твёрдых углов и перекрываемые квадранты, затем для каждой точки границы пускаем лучи (для оптимизации, вычислять квадранты направления и пускать только в те, которые не перекрывают луч твёрдым квадрантом-границей), и, если луч упирается в границу с небом, работать с яркостью.
                            0

                            Я примерно трижды прочел ваш комментарий, и, к своему стыду, так и не понял идею алгоритма. :(
                            Вы не могли бы как-нибудь визуализировать (да хоть на листке бумаги) его? Заранее спасибо большущее.

                              +1
                              Да, действительно бредово описал. Попробую проще.
                              Вы в первой статье описывали динамическое освещение через построение мешей по вершинам — углам. А сейчас отошли от этой идеи в пользу наивной трассировки лучей с фиксированным шагом угла. Я, весьма сумбурно спросил, а почему, собственно, нужно было отходить в пользу монструозной трассировки, если можно построить эти самые меши для каждого источника света, включая бесконечно удалённый, посчитать смесь наложений в областях как первичную яркость, а грани твёрдых поверхностей представить в качестве вторичных излучателей. А когда с непосредственно трассировкой лучей будет покончено, сложить все яркости и применить рассеивание света. На слух это не больший ужос, чем 7 раз проходить через всю огромную текстуру уровня, бросая по 25 лучей из каждой точки. (Кстати, имеет смысл каждый проход поворачивать бросаемые вектора на delta*i/(steps+1), избавитесь от явных погрешностей, вроде несовпадение размеров лучей из узких бойниц)
                            0
                            В голосовании не хватает пункта: «И так все потрясающе!»)
                              0

                              Нет, если бы я хотел себя нахваливать, мог бы и без статей обойтись. А сюда пришел поделится опытом и получить фидбек (потому что иногда возникает ощущение, что делаю какую то хрень :) ).
                              Но спасибо за комплимент.

                                0
                                Скромность — это полезно, но я так подозреваю, что минимум половина воздержавшихся проголосовала бы за этот вариант)
                                  0

                                  На самом деле, от своих знакомых я получал в том числе и такой фидбек:
                                  "Красивые картинки, но ни черта не понятно";
                                  "Картинок и текста много, а вот кода не хватает, было бы круто выложить код шейдеров и кое-каких классов";
                                  "Очень не хватает uml диаграммы или чего-то подобного, чтобы понять в целом структуру проекта";
                                  "Очень сухой стиль изложения и много ненужных подробностей, как в курсовой работе".


                                  Так что есть над чем работать и именно для этого нужны прям конкретные (и, может быть, злые) замечания :)

                              +1
                              Для прямого освещение используете только верхнюю полусферу (что вполне логично), но для не прямого возможно уже есть смысл на 360 градусов смотреть?
                              Или это получается как-то автоматом, например при пересечении со стеной две точки «связываются» вместе для не прямого освещения друг друга.
                                +1

                                Знаете, а я накосячил в статье. Изначально, действительно, работал только с верхней полусферой, но в какой-то момент оказалось, что свет получается не очень естественный. Причем, т.к непрямое освещение не использует raycast'инг, а берет направления из прямого освещения, пришлось работать со всей сферой целиком.


                                Вот так получается при сфере:



                                А вот так при полусфере:



                                Спасибо за комментарий, подправлю текст статьи :)

                                0
                                А это точно не ambient occlusion?
                                  0

                                  Ну, можно и так сказать. Но в AO ведь рассчитывается только прямая освещенность точки? В то время как тут учитываются переотражения.

                                    0
                                    AO это симуляция GI.
                                    Конкретных алгоритмов я не знаю, но там расчитывается затенённость именно по рассеянному свету.

                                    Вот тут есть описание хитростей и фокусов AO 80ого уровня:
                                    https://renderman.pixar.com/view/production-ready-global-illumination

                                Only users with full accounts can post comments. Log in, please.