Реализация тумана войны (1/3)

Однажды для одного проекта, над которым я работал, понадобилось реализовать туман войны. Казалось бы, это такая популярная фича, что для нее в Asset Store непременно найдется с десяток решений. На деле их нашлось всего несколько, а доверие вызвало лишь одно из них. Основная его фича – расчет полей видимостей с учетом перекрытий, вроде как в Heroes of the Storm или других MOBA’х. Нам же нужен был всего лишь старый добрый туман войны, как в старых RTS’ках, когда карта раскрывается кружочками вокруг юнитов. Это была одна из причин, почему я в итоге отказался от использования этого ассета и решил делать свою реализацию. О том, как я это делал, собственно, и хочу рассказать.

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

Туманы войны в дикой природе

Реализации тумана войны можно классифицировать по куче всяких критериев, но меня в первую очередь интересовало следующее: каким образом плоский туман войны (а нам нужен именно такой) проецируется на объемный мир? От ответа на этот вопрос зависит то, какой подход мы в принципе можем применить.

Во-первых, мы можем проецировать карту видимости в направлении взгляда камеры. Это простой и быстрый способ: достаточно просто нарисовать full-screen quad поверх картинки с прозрачными областями для разведанной территории и закрашенными для неразведанной. Пример такого тумана войны можно увидеть в Казаках 3:

Туман войны в Казаках 3: обрубленные здания

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

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

Другой вариант: проецируем карту видимости сверху-вниз. Тогда у нас из тумана могут торчать верхушки деревьев и здания. Но для расчета потребуется знать положение обрабатываемого пикселя в мировом пространстве. А это значит, что либо мы используем буфер глубины в пост-эффекте, либо мы делаем обработку в обычном проходе и подмешиваем соответствующий код во все шейдеры. Но ведь результат того стоит! Посмотрите, например, на Starcraft II:

Туман войны в Starcraft II

Обратите внимание, как граница видимости проходит по верхушкам зданий. На самом деле в SC2 туман войны гораздо сложнее, чем нам было нужно, он скорее похож на то, что предлагал упомянутый ранее ассет. Это, кстати, довольно интересная тема для размышлений: как лучше реализовать такой сложный туман войны? Может быть, хранить в карте видимости еще и высоту, на которую эта видимость распространяется? Или диапазон высот? Впрочем, сейчас не об этом.

В общем, ожидаемо, что мы довольно быстро остановились на втором варианте.

План

Итак, делаем туман войны с простой плоской картой видимости и проекцией сверху-вниз. План вырисовывался довольно простой:

  1. Ортогональной камерой сверху рендерим круги вокруг всех юнитов в текстуру видимости.
  2. Подмешиваем во все шейдеры код, сэмплящий эту текстуру и модифицирующий результирующий цвет.

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

Белые круги

По первому пункту все просто. Напишем код, который выставит нужный render target, пройдется по всем юнитам и нарисует вокруг них круг. Шучу :-) Такая мысль может прийти в программистский мозг, но ее надо гнать. Зачем работать на таком уровне абстракции, когда у нас есть более мощные инструменты? Куда лучше просто настроить отдельный слой в Unity, сделать префаб с ортогональной камерой и добавить круги-меши на все юниты.

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

Да, я не стал заморачиваться с переменной детализацией. 64 сегмента при разрешении текстуры до 400 х 400 (самый большой размер карты) дает отличный результат на любых юнитах, встречающихся в игре. Переходить на квад-и-шейдер тоже не стал, т.к. никакого импакта на производительность эта геометрия не дала (в игре относительно мало юнитов, не более 50-60 на уровне).

Итак, вот что мы пока что получили:

Геометрия, используемая для отрисовки раскрытых областей

Туман пикселей

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

С подмешиванием все просто. Сделаем cginc, добавим туда немного функций… и макросов! Как же приятно иногда вернутся в этот дикий мир текстовых макросов, ЗАГЛАВНЫХ букв и настоящей магии! :-)

Да, так просто. И долгое время реализация тумана войны была именно такой. Да она и сейчас такая же, разве что 0.75f и 0.25f заменились константами.

Конечно, не обошлось и без сложностей. Во-первых, в проекте используются и Lambert и Standard модели освещения. Во-вторых, в проекте используется и Deferred и Forward рендеринг, зависимо от платформы. В итоге я довольно долго провозился, пытаясь добиться единообразного затенения на всех сочетаниях модели освещения и способа рендеринга. В идеале я бы хотел просто взять самый-самый финальный цвет пикселя и немного затенить его. В проекте используются Surface Shaders, которые, вроде бы предоставляют такую возможность – finalcolor и finalgbuffer. Но между ними есть ощутимая разница: finalcolor применяется после освещения, а finalgbuffer – до. Если подумать, это может быть и логично, т.к. в Deferred освещение вообще выполняется отдельным проходом… но это не снимает вопрос: как модифицировать финальный цвет?

Собственно, камнем преткновения была Standard модель освещения. Даже нулевой Albedo в ней дает очень светлые объекты:

Standard-освещение с нулевым Albedo

Все объекты как объекты, но чертовы камни! Но решение, как часто бывает, оказалось очень простым: использовать Occlusion вместо Albedo. Вот пример одного из Standard-шейдеров, использованных в проекте:

В этом шейдере:

  1. Убираем лишний код на уровнях без тумана войны (есть и такие)
  2. Подключаем показанный ранее файл
  3. Прячем нужные для тумана войны входные параметры под макросом (фишка подсмотрена у Unity)
  4. Выставляем SurfaceOutputStandard.Occlusion зависимо от тумана войны
В случае с Lambert-освещением вместо Occlusion просто модулируем Albedo. Для Emissive-материалов также модулируем Emission. С Terrain-шейдерами пришлось повозиться чуть подольше. Дело в том, что стандартные Unity’вские заголовки не дают способа подменить структуру, передаваемую из вершинного шейдера в пиксельный. Поэтому пришлось скопировать заголовок Unity и внести в него нужные изменения (опечатка в комментарии – не моя):

И вот он долгожданный результат:

Первая версия: сильно пикселизированный туман войны

Обратите внимание, как видимая область распространяется вверх на всю высоту здания. Как раз то, чего хотелось! Да вот только эти пиксели…

3 thoughts on “Реализация тумана войны (1/3)

  1. Спасибо за наводку на “Occlusion”, почему-то ранее не принимал это в серьез. “пришлось скопировать заголовок Unity и внести в него нужные изменения” вот это я понимаю уровень кастомизации!

  2. Здравствуйте, вы не могли бы скинуть исходники вашего шейдера?

    1. Здравствуйте! На самом деле шейдеры совсем примитивные, не уверен, что они будут полезны. Но, в любом случае, скинул на почту :-)

Leave a Reply to Стасян Cancel reply