Если коротко, то суть статьи можно можно проиллюстрировать так:



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

В момент, когда игра выглядела как на картинке ниже, стало понятно, что надо добавить что-то такое, что визуально сделает более разнообразной-живой картинку, и при этом обойтись навыками программиста:



Технические условия на момент начала работ были такими:
— 2012 год
— XNA Framework 4.0 Refresh. Rich Profile, не дающий возможности использовать свои шейдеры.
— Мобильный телефон на базе Windows Phone 7: Nokia Lumia 800 (2011 год выпуска)
— Всё должно выдавать на телефоне 60fps и оставлять хороший запас для всей остальной логики игры (AI, физика, музыка)

Это я к тому, что мощности ограничены, поэтому приходилось экономить, где возможно.
Поехали!

День 0. Прототип освещения в игре

Для начала, просто чтобы проверить саму идею, было решено нарисовать освещение вручную. Это минимум работы:
  1. Берем карту и рисуем свет и тени вручную в пейнте
  2. Используем полученную текстуру как так назыаемый lightmap
  3. Подбираем правильный режим смешения.

Если кому-то интересно, я использовал простой без пересветки Blend Mode со следующими параметрами
ColorSourceBlend = Blend.Zero,
AlphaSourceBlend = Blend.Zero,
ColorDestinationBlend = Blend.SourceColor,
AlphaDestinationBlend = Blend.SourceColor,
ColorBlendFunction = BlendFunction.Add,
AlphaBlendFunction = BlendFunction.Add,

На выходе получилось нечто такое:



По этому скрину не так очевидно, но, всё же, смотреться стало приятнее. Значит, решено, делаем освещение.

День 1: Простые статические тени
Так как игра по сути 2D и камера практически всегда смотрит под одним углом, освещение делаем самое простое и статическое:



При загрузке уровня генерируется текстура освещения, которая отрисовывается поверх уровня, так как игра “почти” 2d, необходимости в развертке под геометрию нет. Так как 3д геометрия вся статична, её освещение “запекается” в цвет вертексов.

Генерация буфера текстуры освещения (light map), достаточно проста:
Для каждого истоничка света:

  1. Очищаем временный буфер
  2. Отрисовываем во временный буфер текстуру освещения (обычный градиентный круг, используя color blending источника света), затем накладываем абсолютно черные тени, для препятствий попадающих в область освещения
  3. Полученный временный буфер смешиваем с общим буфером освещения (используя обычный additive blend)


Полученный результат выглядит интересно, хоть и резковато.



День2: Добавляем полутень

Обычно источник света не точечный, а значит и тень от него не совсем четкая, и более того имеет свойство быть все более расплывчатой с увеличением расстояния от источника.
Здесь идея была подсмотрена у великолепной игры F.E.A.R. Для каждого источника света карта освещения рисуется несколько раз с небольшим смещением, а и если быть точнее поворотом относительно источника света.



День3: Плавные тени

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

Пример


Для того, чтобы получить чуть более плавные тени:
  1. Оригинальную текстуру отрисовываем несколько раз с меньшим масштабом (1/2, 1/4, и тд) в разные буферы
  2. Смешиваем все эти буферы с соответствующим масштабом (2 для текстуры ? размера, 4 для текстуры ? и тд) используя additive blend mode и альфу 1/N, где N — кол-во буферов


Была идея смешивать более «интеллектуально» чтобы еще больше подчеркнуть четкость тени в начале и размытость полутени. Но результат даже простого смешения + полутени из прошлого пункта показался нам достаточным, и мы остановились на таком варианте.



День4: Occlusion shadow

Для создания иллюзии самозатенения стен пришлось использовать еще одну текстуру (благо малого разрешения), сгенерировать которую помог distance map: карта в которой в каждой клетке записано расстояние до ближайшей стены.

Например, вот физическая карта уровня, где красным цветом показаны стены:



Карта уровня + Distance grid (синий — стена близко, белый — стена далеко):



Карта + Occlusion shadow:



В этой текстуре цвет пикселя выбирался по простому правилу: если расстояние до ближайшей стены больше определенного порогового значения — прозрачный цвет, иначе чёрный. Так как текстура маленькая (1 пиксель на 1 игровую клетку ~1.5m), то плавные переходы между цветами обеспечивает аппаратная интерполяция при увеличении текстуры (она растягивается примерно в 50 раз). А из-за того, что все стены в игре квадратные и расположены строго по сетке, малый размер текстуры не создает никаких визуальных артефактов.



Или в игре:



Разница, как можно заметить, не разительная, но глубины картинке, кажется, добавляет.

День 5. Динамические тени

Статические тени хорошо, а динамические — лучше. Вот только тратить много ресурсов ни своих, ни машинных желания не было. Идея была использовать по 1-2 спрайта на одну динамическую тень и менять им только угол и масштаб в зависимости от относительного расположения объекта и источника света. А за счет того, что все игровые объекты прямоугольные, расчет всего этого не столь уж и сложен. Нет необходимости трассировать лучи по габаритам. Тени примерные, поэтому достаточно просто нарисовать прямоугольную тень с шириной, равной проекции габаритного прямоугольника [он выделен красным на скриншоте ниже] на ось перпендикулярную лучу от источника света к центру объекта.



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



Для спрайта тени использовалась текстуру 4x4 пикселя с градиентом (Красная точка — это центр поворота).



В результате получаем что-то такое:



За счет градиента на текстуре получаем полутени, а так как текстуры отрисовывается две с обычным альфа смешением, получаем по центру более насыщенную тень.



И, как пример, сравнение статичной тени и динамической:



Небольшие хитрости:
1. Так как тень у нас упрощенная и не учитывает стены, необходимо позаботиться чтобы она не “просвечивала” через стены. Для этого, опять же, пригодился Distance grid. Для каждого объекта максимальная длина тени ограничивалась значением из Distance grid + минимальным размер стены. Конечно, это приводит к не совсем верному поведению этих теней возле стен, однако этот эффект значительно менее заметный, чем артефакт вида.



2. На небольшом расстоянии от источника света, угловой размер становится слишком большим, чтобы две отрисованные текстуры могли “сымитировать тень” без разрыва. Здесь варианта два: или увеличивать количество отрисовок, или при превышении определенного угла уводить тень в прозрачность до полного исчезновения. Мы выбрали второй вариант как более экономный в плане ресурсов.

3. На большом расстоянии от источника света две текстуры тени практически сливаются при отрисовке, поэтому, при превышении определенного углового размера объекта, достаточно одной отрисовки с x2 альфой.

4. Как можно было заметить, данная реализация теней адекватно работают только при одном источнике света, поэтому если источников больше, то мы просто…. не показываем тень.

5. Следствие из 4. Так как источник света для таких теней выбирается всегда один, при его смене(или исчезновении) будет происходить неприятный эффект резкого изменения тени. Чтобы от него избавиться достаточно добавить плавный переход: то есть старая тень в течение какого-то времени уходит в прозрачность, а новая(если необходима) наоборот проявляется из полной прозрачности. Игра динамичная, поэтому такие переходы чаще всего особо не привлекают внимания своей неестественностью.

День 6. Эффект грязных линз

Последним штрихом стало добавление полноэкранного эффекта вида «грязные линзы».

Референс
image

Это оказалось не так то просто при отсутствии полноценного доступа к шейдерам и необходимости сохранить производительность.

Способ #1 — простой и быстрый.
Взяли текстуру грязного стекла и использовали blend mode, который проявляет себя на ярких участках.
пример бленд мода
ColorSourceBlend = Blend.DestinationColor,
AlphaSourceBlend = Blend.DestinationColor,
ColorDestinationBlend = Blend.One,
AlphaDestinationBlend = Blend.One,
ColorBlendFunction = BlendFunction.Add,
AlphaBlendFunction = BlendFunction.Add,

И хотя данный способ был быстр и в некоторых ситуациях позволял получить желаемую картинку:



Во многих ситуациях результат был печальный:



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

Способ #2 — медленный, но красивый.
Отрисовываем в буфер все световые пятна от всех источников освещения (меньших размеров и без учета теней) в проекции камеры, затем отрисовываем текстуру грязного стекла с blend mode из способа #1. После этого полученный буфер уже используем.



Однако, рендер дополнительного буфера каждый кадр — это совсем не быстро. Плюс отдельного буфера в том, что его можно обновлять не каждый кадр, а только при движении камеры, но даже после таких оптимизаций производительность на мобильных устройствах была далека от желаемой.

Способ #3 — быстрый и красивый
Полноценного доступа к шейдерам у нас не было, но зато был доступ к одному предустановленному dual texture shader. Он смешивает две текстуры с учетом текстурных координат через умножение (если точнее, через Modulate2X blend mode blogs.msdn.com/b/shawnhar/archive/2010/08/04/dualtextureeffect.aspx). Первой текстурой была подготовленная текстура содержащая все интересующие нас световые пятна (за счет того, что игра по сути 2d ее достаточно подготовить один раз для уровня), вторая — грязное стекло. И единственное, что необходимо обновлять каждый кадр, это текстурные координаты первой текстуры. Они высчитываются проекцией экрана в координаты текстуры 1 (это просто мировые координаты с масштабом).



Итоговый результат, по сути, не отличается от способа 2, зато не требует лишних отрисовок в буфер.

Итого:

Таким образом, для финального кадра нам понадобилось:
  • A) Один раз на старте карты
    • Раccчитать карту статического освещения
    • Раccчитать карту затенения для occlusion shadow
    • Подготовить буфер ярких точек для эффекта грязных линз
    • Подготовить кэш ближайшего источника света для всех точек для динамических теней


  • Б) Для каждого кадра
    • Отрисовать карту статического освещения
    • Раccчитать угол, и ширину динамичеких теней и отрисовать 1-2 спрайта на объект
    • Подготовить буфер ярких точек для эффекта грязных линз
    • Спроецировать 4 точки в мировые координаты, обновить текстурные координаты, и отрисовать одну текстурас шейдером dual texture для эффекта грязных линз




Комментарии (27)


  1. Hertz
    22.09.2015 22:49
    +7

    Очень симпатично, спасибо за статью!


  1. oYASo
    23.09.2015 01:46
    +1

    Красиво!


  1. Meklon
    23.09.2015 09:19
    +5

    Спасибо, интересно. Хотя мне кажется, что грязь на камере ухудшает результат. Брызги более уместны динамические. Типа брызг крови, воды после всплесков, масла после взрывов.


    1. Pavelius
      23.09.2015 11:24

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


    1. Pavelius
      23.09.2015 12:13

      Про брызги масла


  1. Torvald3d
    23.09.2015 09:47

    Очень круто! Динамическое освещение и грязные линзы сильно повышают зрелищность. Я бы еще поэксперементировал с картой нормалей на спрайтах для придания рельефности


    1. slonopotamus
      23.09.2015 09:56
      +10

      Грязные линзы повышают только одно — желание протереть экран.


    1. Pavelius
      23.09.2015 11:25

      Это было одно из ограничений, не возможность использовать свои шейдеры. Поэтому и нет карт нормалей, полноценных пост эффектов, и других общепринятых фич, зато есть свои велосипеды:-)


  1. cruzo
    23.09.2015 09:47
    -1

    Для просто программиста — очень симпатичный результат. Даже захотелось поиграть на Андройде


  1. heart
    23.09.2015 10:30
    +2

    Очень круто. Но где-то в глубине оставалось ощущение, что что-то не так. Попытался проанализировать: если сделать ребра(черные линии) не такими четкими, или вообще убрать(играть только цветами граней), то может получиться еще более круто. Ну хотя бы можно сделать их не черными, а в оттенок граней.


    1. heart
      23.09.2015 10:33

      Под цветом граней — можно понимать так же степень их освещенности в зависимости от угла расположения к источнику света.


    1. Pavelius
      23.09.2015 11:46
      +1

      Мы пробовали разные вариаты цвета ребер(и вообще без них), но при черном цвете геометрия лучше всего просматривалась.

      Пример


      1. heart
        23.09.2015 12:04
        +1

        На «ver2» все хорошо на самом деле. Только наличие граней на некоторых динамических объектах идет в разрез с их отсутствием на стенах. Т.е. они не гармонируют между собой. Либо все в гранях, либо ни чего. К чему вы в итоге и склонились — оставить грани везде.


  1. teamfighter
    23.09.2015 12:06
    +1

    А как игра-то называется?) Я б поиграл)


    1. Pavelius
      23.09.2015 18:20
      +1

      Tile Rider


    1. nitso
      23.09.2015 20:49
      +1

      никто не читает тэги ;)


  1. AndersonDunai
    23.09.2015 15:59
    +1

    Очень красиво, спасибо! Узнал себя некоторое время назад (правда, я так далеко не заходил — всего лишь делал raytracing к краям видимых объектов и затем повторял процесс со смещением в несколько градусов по пару раз в каждом направлении. Получалось такое: rt.dun.ai (работает только в Firefox). Правда, без сглаживания — просто полупрозрачные полутени :)



    На этом всё и заглохло :) идея была сделать игру с акцентом на геймплей вокруг т. наз. «fog of war».


    1. Pavelius
      23.09.2015 23:00

      Скриншот выглядит интересно, но к сожалению Firefox нет.


    1. Eefrit
      06.10.2015 13:29

      502 Bad Gateway. С сервером что-то или он просто перегружен?


  1. nitso
    23.09.2015 20:52

    Жаль, что нельзя приобрести сразу и десктопную, и мобильную версию (все-в-одном) в windows-маркете.


    1. Pavelius
      23.09.2015 22:59

      Можно, если покупаешь в WinStore то игра\приложение становится доступно и на телефоне и на десктопе.
      Только пока Win8 версия в маркете уже убрана, а Win10 еще не доделана :-)


  1. roller
    24.09.2015 11:29

    TileRider в андроид маркете не находится (


    1. Pavelius
      24.09.2015 12:01

      Потому что его нет для андроида, нас мало, движок не кроссплатформенный из коробки(даже с учетом того что MonoGame кроссплатформенный как бы). Да и не формат это для андроида


      1. iSeiryu
        29.09.2015 18:53

        не формат это для андроида

        Это как?


        1. Pavelius
          30.09.2015 00:55

          Платная игра, без рекламы. Даже на иос 99% игроков у нас пираты (судя по статистике). Что будет на андроид даже страшно представить.


          1. iSeiryu
            30.09.2015 01:09

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


            1. Pavelius
              30.09.2015 01:17

              Она уже вышла для иоса, винфона, винстора и стима.
              И кроме русского там есть переводы на английский, немецкий, испанский, итальянский, и даже белорусский в версии для WP8 :-)