Небольшой дисклеймер: статья нацелена на тех, кто уже как минимум на базовом уровне знаком с Unreal Engine, его основными понятиями и процессом работы в нем. Поэтому простейшие действия не поясняются на уровне «куда нажимать».

Если вы делаете игру с изометрической камерой, то наверняка сталкивались с тем, что заходя за некоторые объекты, игрок теряет из вида своего персонажа. Управление персонажем, которого не видно, довольно неудобно и может игрока несколько фрустрировать. С другой стороны, если убрать объекты за которыми персонаж может «потеряться», уровень рискует стать однообразной кишкой без резких поворотов и высоких объектов. А иногда это и вовсе невозможно, например, если речь идет об условных пещерах или катакомбах. В данной статье‑туториале рассмотрим один из наиболее универсальных способов решения данной проблемы — Occlusion Masking. Это способ, при котором на уровне материала часть объекта, загораживающая персонажа, становится прозрачной («вырезается»).

Разбор существующего решения

Начнем, пожалуй, с разбора уже существующей реализации, которую, в том числе, я использовал как основу при создании своего варианта Occlusion’а в рамках работы над игрой Descensus. Конкретно речь идет про данный видео-гайд на русском языке, который практически копирует другой англоязычный гайд, который, в свою очередь, использует такую же идею как и еще одна статья, которая, видимо, уже удалена. Так или иначе, на этот подход мы будем опираться, поэтому давайте рассмотрим его.

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

Итак, нам понадобится материал (я свой назвал M_OcclusionMat) и Material Function (MF_DynamicOcclusion_SphereMask). Начнем с материала. Сразу поменяем во вкладке Details значение параметра Blend Mode на Masked.

Это во вкладке Details, если что
Это во вкладке Details, если что

Касательно Blend Mode’ов, есть такой замечательный гайд, объясняющий, чем они отличаются. Здесь же ограничусь тем, что скажу о том, что режим Masked используется для бинарной прозрачности (прозрачность пикселя может принимать только значения 0 и 1). Прозрачность конкретного пикселя определяется специальной текстурой – собственно, маской – которая подается на выход Opacity Mask материала.

Граф нашего материала выглядит следующим образом:

Для целей гайда, Base Color материала содержит просто белый цвет, но на практике, естественно, тут может быть любая необходимая логика. На выход Opacity Mask подается выход нашей функции. Зачем нужен каждый из входных параметров разберем немного позже. 

Строго говоря, создавать функцию не обязательно. Можно всю логику оттуда реализовать сразу в материале. Но создание функции:
а) более наглядно для формата статьи
б) реализовано в гайде, который мы сейчас разбираем
в) с практической точки зрения, позволяет добавлять данный функционал в разные материалы без прибегания к массовой копипасте

Теперь перейдем к самому интересному — реализации функции MF_DynamicOcclusion_SphereMask. Её код выглядит так: 

Это самый большой блюпринтовый граф в статье. Рекомендую его открыть в отдельной вкладке и увеличить. Всё лучше, чем воспроизводить его по получасовому видео. Хотя такой вариант у вас тоже остается
Это самый большой блюпринтовый граф в статье. Рекомендую его открыть в отдельной вкладке и увеличить. Всё лучше, чем воспроизводить его по получасовому видео. Хотя такой вариант у вас тоже остается

Суть такова: нода Sphere Mask возвращает значение от 0 до 1, определяющее, находится ли точка A в сфере радиуса Radius и центром в точке B. Последний вход — Hardness (жесткость) — это такая штука, которую значительно проще показать, чем описать словами, что на данном этапе затруднительно, поскольку в рассматриваемом примере, она фактически не используется. Так что ограничусь нестрогим словесным описанием, а посмотрим в действии на нее попозже. Итак, жесткость нужна для добавления плавного перехода между прозрачной и непрозрачной областями сферы. Указывается она в долях от радиуса. То есть, например, при жесткости 1 вся сфера будет полностью прозрачной, при жесткости 0 вся сфера будет заполнена круговым градиентом, а при жесткости 0.5 половина сферы будет прозрачной, а половина заполнена градиентом. 

Внимательный читатель мог заметить, что выше я писал о том, что в режиме Masked материал может иметь прозрачность либо 1, либо 0, а тут мы вводим какой-то плавный переход. Собственно, это одна из проблем рассматриваемого гайда, из-за которой продемонстрировать как выглядит жесткость затруднительно и которую мы пофиксим ниже.

Для наших целей на вход ноды Sphere Mask подаются положения текущего пикселя и камеры, а также параметризованные радиус сферы и жесткость. Поскольку результат мы будем использовать как Opacity, необходимо его инвертировать (нода 1-x). Далее мы сравниваем расстояние от точки текстуры до камеры с некоторым параметром (Distance), определяющим минимальное расстояние от камеры, на котором мы начинаем применять просвечивание. Если расстояние подходит — функция возвращает вычисленное нодой 1-x значение, а если нет — то единицу, что соответствует непрозрачности. Последнее условие — это дополнительный механизм фильтрации областей, которые мы хотим просвечивать. Но, на мой взгляд, реализован он довольно топорно и принцип его работы не вполне очевиден (хоть и крайне прост). 

Нижняя часть графа содержит дополнительные украшающие элементы: наложение на сферическую маску дополнительной текстуры (текстура определяется входным параметром Occlusion Texture. Для примера я использовал текстуру с названием LowResBlurredNoise из ассетов движка) и движение этой текстуры со временем (скорость движения определяется параметром Panner Speed). Так выглядит полученный результат:

С наложенной двигающейся текстурой
С наложенной двигающейся текстурой
С неподвижной (PannerSpeed=0) наложенной текстурой
С неподвижной (PannerSpeed=0) наложенной текстурой
Без дополнительных украшений (только Sphere Mask)
Без дополнительных украшений (только Sphere Mask)

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

  1. Отчетливо заметно искажение маски на стыках поверхностей демо-куба

  2. Как было упомянуто выше, нет никакого сглаживания перехода от прозрачного к непрозрачному (изменение параметра Hardness, по сути, просто меняет размер выреза)

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

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

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

Прежде, чем перейти к описанию доработок, отмечу, что есть и другие подходы к просвечиванию объектов, которые обычно строятся на скрытии/увеличении прозрачности объектов целиком. То есть, конечно, можно составить комнату из небольших кубов или других элементов и скрывать те из них, которые находятся между камерой и персонажем. И, возможно, даже можно сделать так, чтобы это более-менее стабильно работало. И это даже несколько проще с технической точки зрения чем то, что мы сейчас рассматриваем. Но, с другой стороны, это добавляет очень много работы тем, кто будет собирать помещения из этих небольших объектов, вместо того, чтобы ставить одну стену. Либо будет выглядеть весьма специфично в случае, если мы прячем стену целиком. Так что такие подходы имеют право на жизнь, но подробно рассматривать в этой статье я их не буду.

Реализация более совершенной маски на HLSL

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

Поскольку такой функционал уже выходит за рамки того, что нам предоставляет Unreal по умолчанию, поступим так же, как и всегда, когда при помощи блюпринтов невозможно получить желаемый результат — будем писать код. В случае с материалами, есть специальная нода, которая называется Custom и предназначена для того, чтобы записывать в нее произвольный HLSL код. HLSL (High‑Level Shader Language) — это специальный язык для написания шейдеров, разработанный Microsoft. Подробнее можете почитать, например, тут. А нас сейчас интересует только то, что синтаксически он достаточно сильно похож на C и у любого программиста, специализирующегося на работе с UE и имеющего доступ в интернет, не должно возникнуть с ним слишком большого количества проблем.

Итак, приступим. Создаем новую Material Function, пусть называется MF_DynamicOcclusion_HLSL. В ней создаем ноду Custom, в которой нужно заполнить несколько полей:

Код нужно писать в обычном текстовом поле и поэтому он практически нечитаем. Рекомендую при работе с HLSL писать код в стороннем редакторе, а затем просто вставлять его в Custom ноду
Код нужно писать в обычном текстовом поле и поэтому он практически нечитаем. Рекомендую при работе с HLSL писать код в стороннем редакторе, а затем просто вставлять его в Custom ноду
  • Code — собственно, исполняемый нодой код на HLSL, его разберем ниже

  • Output Type — мы хотим, чтобы как и Sphere Mask, наша нода возвращала скаляр. Выбираем здесь Float 1

  • Description — это то, как будет называться наша нода на графе. Я написал ScreenSpaceOcclusion, но это не принципиально

  • Inputs — здесь нужно указать все входы нашей ноды. Нам потребуются

    • PixelPos — положение текущего просчитываемого пикселя на экране (кстати, координаты на экране указываются в диапазоне от 0 до 1. Причем точка (0;0) находится в левом верхнем углу, а (1;1) в правом нижнем)

    • CharPos — положение персонажа на экране

    • Radius — радиус круга, который будем вырезать

    • ViewSize — размер игрового окна. Он нам тоже понадобится 

Теперь посмотрим подробнее на код:

//Инициализируем переменные
float ScreenFormat = ViewSize.x / ViewSize.y;

//Вычисляем расстояние между положениями на экране персонажа и текущего пикселя
float Distance = sqrt(pow(PixelPos.x - CharPos.x, 2) + 
  pow((PixelPos.y - CharPos.y) / ScreenFormat, 2));

//Возвращаем требуемую opacity: 0 если пиксель внутри круга, 1 если снаружи
if (Distance < Radius)    
  return 0;
else    
  return 1;

Идея довольно простая: мы вычисляем расстояние между двумя точками на плоскости и сравниваем с радиусом, который заблаговременно перевели в интервал [0; 1]. Отдельно остановимся на переменной ScreenFormat. Как правило, размеры игрового окна по разным осям различаются (например, 1920x1080). Если не учитывать этого в вычислениях, то вместо круга мы получим эллипс, вытянутый вдоль длинной стороны экрана. 

Например, для FullHD разрешения, нормированный радиус 0.1 в переводе на пиксели по горизонтальной оси будет составлять 192, а по короткой — только 108.

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

Теперь, когда мы разобрались с нашей самодельной маской, нужно подключить ее входы и выходы примерно таким образом. ScreenPosition и ViewSize — это доступные по умолчанию данные, Radius — наш единственный (пока) входной параметр, а вместо позиции игрока пока возьмем центр экрана — точку (0.5; 0.5).

Вообще, в некоторых случаях, когда персонаж всегда находится в центре экрана (или другой фиксированной точке), можно ограничиться использованием этой константы внутри кода ноды и не заморачиваться вычислением положения персонажа на экране. Но зачастую персонаж все же двигается относительно центра. Например, в Top‑down template, который я использую для демонстрации, по умолчанию включен лаг камеры, из‑за чего в движении камера «догоняет» персонажа. Поэтому в данном случае я использую константу как временную меру для проверки работоспособности маски.

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

Вот что получаем на выходе:

Очень заметен лаг камеры – то, как маска догоняет персонажа после остановки
Очень заметен лаг камеры – то, как маска догоняет персонажа после остановки

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

Явно нежелательное поведение
Явно нежелательное поведение

Но давайте решать обозначенные проблемы по порядку.

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

Привязка маски к персонажу

Для передачи информации в материал мы используем Material Parameter Collection (в оригинальном гайде она используется просто для того чтобы задать значения параметров, но это, по-моему, не совсем корректный вариант применения). Идея крайне проста: персонаж будет на тике записывать свое положение на экране в специально для этого отведенное поле в коллекции параметров, а материал будет читать значение оттуда.

Создаем новый Material Parameter Collection (я назвал ее MPC_OcclusionData) и добавляем параметр-вектор, в который будем записывать положение персонажа.

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

В реальном проекте подобный специфический функционал рекомендуется выносить в отдельный компонент, а не нагромождать всё в акторе
В реальном проекте подобный специфический функционал рекомендуется выносить в отдельный компонент, а не нагромождать всё в акторе

Осталось заменить в нашей Material Function константу на чтение параметра из коллекции.

Поскольку параметр-вектор имеет тип float4, а нам нужно только float2 - обрезаем ненужные поля нодой Component Mask
Поскольку параметр‑вектор имеет тип float4, а нам нужно только float2 — обрезаем ненужные поля нодой Component Mask

Теперь вырезанный круг жестко привязан к положению персонажа

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

Добавление градиента

Итак, настало время обсудить как нам получить плавный переход прозрачности при том, что материал может быть либо прозрачным, либо нет. Строго говоря, для этого нужно выставить Blend Mode материала на Translucent и возвращать из нашей функции произвольную прозрачность. Тогда мы получим честный полупрозрачный переход. Однако у прозрачных материалов есть ряд проблем, связанных с особенностями рендера. Во‑первых, рендер прозрачного материала банально дороже с вычислительной точки зрения. То есть утыкав всю карту псевдо‑прозрачными объектами получим существенное уменьшение FPS. Во‑вторых, прозрачные объекты взаимодействуют со светом не так, как непрозрачные. Из‑за этого они могут выглядеть значительно хуже: неестественно блеклыми, неправильно передавать цвета текстуры. Ну и третье — баг с порядком рендера. При наложении прозрачных частей одного и того же объекта поверх друг друга на выходной картинке рендерер не знает какую часть нужно рисовать раньше и может отрисовать более далекую часть поверх более близкой, что приводит к появлению странных геометрических визуальных артефактов.

Для фикса последней проблемы в UE вроде даже есть специальная галочка в настройках проекта (называется Order Independent Translucency). Но она экспериментальная, работает не очень стабильно и потенциально еще больше увеличивает вычислительную сложность рендера.

Для обхода этой проблемы существует такая штука как Dither Opacity. Это имитация полупрозрачности за счет чередования прозрачных и непрозрачных пикселей на Masked материале. Такое изображение получается зернистым и не очень красивым, но зато остальная часть объекта рендерится как обычно и не тащит за собой проблем с производительностью и отрисовкой. На примерах ниже наглядно покажу разницу. 

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

Но начнем с реализации. Нам потребуется добавить еще один вход в нашу Custom ноду — ту самую жесткость Hardness — и внести некоторые изменения в код.

//Инициализируем переменные
float ScreenFormat = ViewSize.x / ViewSize.y;
float Distance = sqrt(pow(PixelPos.x - CharPos.x, 2) + 
        pow((PixelPos.y - CharPos.y) / ScreenFormat, 2));

//Возвращаем требуемую opacity: 0 если пиксель внутри круга, где жесткость 
//еще не действует
if (Distance < Radius * (1 - Hardness))    
  return 0;
else    
  // а дальше линейно увеличиваем непрозрачность в зависимости от дальности
  //от центра круга (формула – это преобразованное уравнение прямой по двум
  //точкам)    
  return (Distance - Radius * (1 - Hardness)) / (Radius - Radius * (1 - Hardness));

Также нужно в материале включить Dither Opacity Mask, а также подключить параметр Occlusion Hardness в новый вход нашей функции на графе материала

Это снова во вкладке Details, если что
Это снова во вкладке Details, если что

Таким нехитрым образом при использовании Hardness = 0.2 получаем вот такой аккуратный градиент

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

Обещанное выше сравнение: вот как выглядит маска с Hardness = 0.5 на Translucent и Masked материалах:

Кстати, еще замечу, что не обязательно из нашей Custom ноды возвращать 0 для точек внутри круга. Возвращая оттуда другое значение, например 0.2, можно получить полупрозрачную область, не скрывая часть объекта полностью.

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

Выбор просвечиваемых объектов

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

Заводим в Material Parameter Collection новый скалярный параметр ShouldApplyOcclusion:

В блюпринте персонажа на тике записываем значение этого параметра:

В свернутой ноде SetCharPos находится установка параметра CharPos, которую мы делали ранее
В свернутой ноде SetCharPos находится установка параметра CharPos, которую мы делали ранее

Небольшой нюанс: я использую трейс по каналу Camera, потому что блок канала Visibility для стен я отключил. Это нужно для того, чтобы иметь возможность управлять персонажем за стеной без значительных переработок дефолтного Top Down проекта.

В нашу кастомную ноду Screen Space Occlusion добавляем еще один инпут и подключаем в него новый параметр из коллекции:

В коде ноды добавляем проверку в начало:

if (ShouldApplyOcclusion == 0)    
  return 1;

Теперь стены будут просвечивать только когда персонаж заходит за одну из них.

Ну и напоследок, чтобы фича выглядела еще лучше, сделаем появление/исчезание просвета не мгновенным.

Плавное появление/исчезание маски

Как обычно, начнем с обсуждения принципа работы и, как обычно, постараюсь оставить его достаточно простым. Для плавного появления просвета нам необходим переменный радиус, который будет плавно расти от нуля до заданного значения при заходе за стену и уменьшаться обратно при выходе. Поскольку внутри графа материала понятия тика как такового нет, нужно получать его откуда-то извне. Снова используем для этого Material Parameter Collection, снова заводим в MPC_OcclusionData скалярный параметр и записываем его на тике в блюпринте персонажа.

Еще раз отмечу, что правильнее было бы вынести всё это в отдельный компонент
Еще раз отмечу, что правильнее было бы вынести всё это в отдельный компонент

Этот код, в зависимости от того, включен ли сейчас просвет (то же значение, которое мы записывали в коллекцию параметров) постепенно увеличивает или уменьшает текущее значения радиуса (Current Occlusion Radius). Переменная Occlusion Radius — это целевой (максимальный) радиус, а Fade Duration — время перехода радиуса от нуля до максимума в секундах (я поставил 0.3). Нода Clamp контролирует чтобы полученное значение оставалось не меньше нуля и не больше максимального радиуса. 

Запись Should Apply Occlusion тоже немного изменилась. Теперь помимо непосредственно результата проверки мы также учитываем значение текущего радиуса. Это нужно, чтобы когда мы выходим из-за стены продолжать просвечивать ее в течение времени, пока радиус не достигнет нуля.

Теперь осталось только заменить в MF_DynamicOcclusion_HLSL входной радиус на параметр из MPC_OcclusionData

Получилась такая красота, на которой, пожалуй и остановимся.

Пара слов про мультиплеер

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

Правда, есть техническое неудобство, связанное с тем, что в Material Parameter Collection нельзя добавлять массивы. Из‑за этого приходится заводить векторный (положение) и скалярный (текущий радиус) параметры для каждого потенциального игрока. Например, если максимальное количество игроков — три, то нам нужны будут три скаляра и три вектора, которые придется заносить в отдельные инпуты в Custom ноде (внутри которой их уже можно объединять в массивы и нормально обрабатывать). Как будет выглядеть граф, если мы захотим иметь, например, 32 или 64 игрока — лучше не задумываться.

Опять же, если Вы знаете какой‑то способ объединения внешних параметров для материалов в массивы, то, пожалуйста, напишите об этом в комментариях.

В качестве заключения

Статья получилась несколько более длинной, чем я планировал изначально, но надеюсь, что мне удалось раскрыть тему и дать разработчикам, которые будут делать подобный функционал, некоторую базу, на которую можно будет опереться. Если я упустил что-то важное, то дополняйте меня в комментариях. В любом случае спасибо всем, кто осилил этот текст до конца. Надеюсь, он был для вас полезен.

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


  1. datacompboy
    18.07.2024 15:30
    +1

    — Скажите, это, стало быть, любую стенку можно так убрать? Вашему изобретению цены нет, гражданин интеллигент!

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

    Финальный результат смотрится как... Как везде? Просто очень хорошо, но изюму больше нет


    1. 3sSTheWriter Автор
      18.07.2024 15:30
      +1

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