История света и тени в одной маленькой, но гордой игре

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

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

Технические условия на момент начала работ были такими:
— 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,


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

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

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

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

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

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

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

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

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

9945f6245d76461db286fc0492391d6c.png

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

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

Пример
58903e8bd94e4bbc82a6992f9cb13f98.png


Для того, чтобы получить чуть более плавные тени:

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


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

280587f0d1e349949ab13b8773dd0bcf.png

День4: Occlusion shadow

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

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

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

Карта + Occlusion shadow:
20bbd68556d240639c490b7a327b6ddb.png

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

c7e9b2c995ae4d3cab021d33f41d0e9d.png

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

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

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

e476006f98cc41a896a5d02f3e0743a4.png

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

68027f9bae79415e895f5e0bde31be70.png

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

7cbf70677cb4477fa90f2b382b68d751.png

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

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

e6199d20d4f147aa9825e124d647dd47.png

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

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

db6e5b40c40f49c99a5f95f598e527ad.png

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,


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

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

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

0ee143918d9a4b3691049fe9cf030067.png

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

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

b703e08311dc4e33a73bb2d569de1dbe.png

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

Итого:

Таким образом, для финального кадра нам понадобилось:

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


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


885f795bebd64a5dba955b5ba0c4f2f7.png

© Habrahabr.ru