Приветствую!

Я знаю, и вы в глубине души знаете, чего не хватает вашим карточным играм или играм «три в ряд». Системы скрытности!

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

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

Способ 1: коллайдеры


Простой и не особо ресурсоёмкий способ.

К каждому источнику освещения добавляем по сферическому коллайдеру. Делаем его триггером. Выставляем размеры примерно равными радиусу света.

Остальное — ясно, как тень. Пишем простенький скрипт, где в OnTriggerEnter() размещаем активацию расчета освещенности (чтобы источники света не работали «вхолостую», когда игрока рядом нет).

Сам расчет освещенности будет расположен в Update(). По сути, это обычный Physics.Raycast(). Если попадает в игрока — игрок в зоне света. Если не попадает — значит, игрок за препятствиями и, значит, в тени.

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

Пример

В точке 1 освещенность близка к максимальной. В точке 2 освещенность минимальна — между светом и точкой препятствие. В точке 3 освещенность средняя.

И это не всё! Можно добавить триггер-коллайдеров в различные «зоны теней», где игрок должен прятаться. В лучших традициях Manhunt. Аналогичным образом можно помечать коллайдером светлые зоны, имитируя, например, свет прожектора.

Преимущества:


  • Легко настроить Point light'ы.
  • Довольно экономен в плане ресурсов, если не спамить источники света.

Недостатки:


  • Тяжело настраиваются Spot light и Directional light. Если для первого достаточно отпозиционировать коллайдер в области света (чтобы повышать видимость игрока при входе), то второй представляется настоящим ужасом. Нужно либо размещать коллайдеры у каждой тени (чтобы снижать видимость игрока при входе), либо постоянно проверять с помощью Physics.Raycast() между игроком и «солнцем» — находится тот под лучами или в тени.
  • Большое количество коллайдеров захламляет сцену, усложняя физику.
  • Необходимо аккуратно работать с пересекающимися источниками света.
  • Динамичный свет (перемещающийся или меняющий интенсивность) нужно отдельно дописывать через скрипты.

Способ 2: RenderTexture


Что мы тут делаем? По сути — получаем «скриншот» с камеры, причем необязательно с камеры основной. А затем анализируем цвет скриншота, чтобы узнать, насколько яркий свет падает на предмет.

Для начала нам нужен объект, с которого мы будем «читать» свет. Создаем обычную сферу или плоскость, делаем маленькой (scale 0.1), размещаем вплотную к полу, делаем белой, убираем коллайдер:

Скрытый текст


Добавляем камеру (обязательно убираем audio listener и проверяем, что не стоит таг MainCamera). Привязываем её к нашему объекту. Ставим её чуть выше, направляем вниз. Выставляем в настройках не основной дисплей. Делать ли её ортографической — это на ваш вкус.

Под конец позиционируем её так, чтобы она смотрела на наш объект и только на него.

Скрытый текст


Под конец настраиваем Culling mask основной и вторичных камер, чтобы основная не отображала наши «световые» объекты, а вторичные видели только их, не захламляясь ничем прочим.

И тут начинается самое интересное. Привязываем к камере скрипт:

public Camera cam; // наша камера
RenderTexture tex;
Texture2D _tex;

void Start () {
	// Создаем изображение для "скриншота". 
	// Да, он всего в один пиксель размером - больше не надо.
	// Depth лучше на 0 не ставить - появляются различные баги.
	tex = new RenderTexture (1, 1, 8); 
	// RenderTexture "читать" нельзя, 
	// поэтому создаем текстуру, в которую его переводим.
	_tex = new Texture2D (1, 1, TextureFormat.RGB24, false);
}

void Update () {
	// назначаем текстуру "скриншота" камере
	cam.targetTexture = tex; 
	cam.Render ();
	// делаем полученный скриншот активным
	RenderTexture.active = tex;
	// записываем в Texture2D
	_tex.ReadPixels (new Rect (0, 0, 1, 1), 0, 0); 
	_tex.Apply ();
	Color col = _tex.GetPixel (0, 0);
	float vis = (col.r + col.g + col.b) / 3;
}

На выходе получаем float vis, который, по сути, является числовой репрезентацией уровня освещения, падающего на наш объект. Если источник близко — предмет белый — vis равен 1. Если темно — предмет черный — vis равен ~0.

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

float interval = 0;
void Update ()
{
	interval += Time.deltaTime;
	if (interval < 1)
		return;
	interval = 0;
	// наш код
}

Далее мы всю нашу систему привязываем к игроку, чтобы она передвигалась вместе с ним. И наша переменная vis автоматически выдает освещенность вокруг игрока!

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

Преимущества очевидны, поговорим о недостатках.

Недостатки


  • Каждый «детектор света» (если их больше одного) требует отдельной камеры.
  • Texture2D.ReadPixels() — ну крайне медленная. Даже если проделывать её раз в секунду, а не каждый фрейм, даже если разбить функции записи и чтения текстур на разные фреймы, все равно бывают пролагивания в 40-110ms.
  • Эта система не учитывает некоторые редкие случаи. Например, на персонажа светят фонариком. Персонаж хорошо освещен, но свет падает на него и за ним, а не вниз, соответственно, наш детектор света показывает низкий уровень освещенности. Решить проблему можно, например, размещая детектор не у пола, а на уровне груди персонажа. Тогда нужно ставить две камеры с противоположных сторон, чтобы читать свет с любой стороны. Что замедлит систему ещё вдвое.

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


  1. GromGT1
    14.07.2018 08:53

    Мне кажется должен быть способ взять уровень освещенности напрямую с объекта-сферы. без использования камеры.


    1. Griboks
      14.07.2018 10:15

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


      1. Evir
        14.07.2018 10:30

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


        1. Griboks
          14.07.2018 11:13

          Или использовать квадратный корень из суммы квадратов разностей координат. Тогда каждый источник проверяется за 2 строчки скрипта. Это ещё быстрее. Хотя, мне кажется, будет правильным напрямую получать карту освещённости, как написано ниже.


          1. Evir
            16.07.2018 12:18

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

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

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

            Если «ловить освещение» со сцены (не важно, подхватывать из отрисованного кадра, или из отдельного Render to texture), то можно отхватить неожиданных багов. Например, берём пиксел со спины персонажа (камера находится за спиной персонажа), он тёмный — считаем, что персонаж в темноте. А на самом деле у персонажа там тёмный рюкзак. Или персонаж в светлом, но между ним и камерой чёрный столб. Или персонаж стоит лицом к источнику света и спина у него тёмная; но при этом всё равно неправильно считать, что персонаж спрятался в тени. Спереди его может быть отлично видно.

            Опять же, в статье мало сказано о варианте с Render to texture. Если мы рисуем маленькую сферу в центре персонажа, чтобы оценить его освещенённость, то необходимо, чтобы сфера была на отдельном слое, который не будет отрисовываться на главной камере; а персонаж (или даже все персонажи) не должны отрисовываться при оценке освещённости. Иначе probe («маленькая сфера») будет всегда затенён персонажем. Так же будет хорошей идеей убрать из отрисовки на оценку освещённости незначимые объекты (не влияющие на результат):

            • декали и частицы, которые лишь украшают поверхности, но не влияют на форму и свет;
            • skybox и прочие далеко расположенные декорации;
            • мелкие объекты, которые не могут влиять на результат (нельзя спрятаться за копьём или шваброй).


            1. Griboks
              16.07.2018 12:32

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


  1. dnnkeeper
    14.07.2018 11:01
    +1

    Кошмарные костыли. Надо при помощи SRP или command buffer подхватить карту теней и найти в ней нужный фрагмент для оценки освещения, я ожидал прочитать об этом. В случае deferred режима рендеринга Light occlusion и lighting буферы можно оценить на этапе lighting pass.


    1. Griboks
      14.07.2018 11:10

      Не подскажете, как?


    1. Xatmo Автор
      14.07.2018 16:42

      Интересно. Я думал об этом способе, но не смог найти вменяемых реализаций. Надо будет перепроверить информацию и, если вы правы, дополнить статью.
      Спасибо!


  1. SmallSnowball
    14.07.2018 16:39

    Способ конечно забавный, но неужели рендер и последующее чтение из текстуры получается быстрее и лучше чем простой рейкаст до источника? Получается 1 рейкаст до каждого источника, источников конечно может быть в сцене много, но не запредельное количество. 40 мс на 1 детектор выглядит как оверкилл, даже если делать это раз в секунду. Это же все равно скачок времени на рендер одного кадра получается. Рейкасты хотя бы можно раскидать по разным фреймам, чтобы сгладить просадку. Я юнити ковырял мало, но там разве над коллайдерами внутри нет какой-то ускоряющей структуры для рейкастов? Это же вроде бы не должно быть дорогой операцией.


    1. Xatmo Автор
      14.07.2018 16:47

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


      1. SmallSnowball
        14.07.2018 16:53

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


  1. honeyrooibos
    14.07.2018 16:48

    Второй подход можно оптимизировать написав для сферы Шейдер(за основу можно взять пример diffuse lighting из документации юнити (https://docs.unity3d.com/Manual/SL-VertexFragmentShaderExamples.html)). В frag методе посчитать средний цвет всех пикселей и присвоить результат переменной, например, float _illumination. Значение этой переменной можно достать в monobehaviour скрипте имея ссылку на материал с этим шейдером и зная имя переменной, используя метод Material.GetFloat("_illumination"). Таким образом вычисление освещенности ложится на видеокарту, а с этим она справляется хорошо, отпадает необходимость использовать медленный Render Texture, и получение значения освещенности можно делать хоть каждый кадр, без падения производительности.


    1. Xatmo Автор
      14.07.2018 16:48

      Тоже интересный способ, спасибо, изучим!


    1. Leopotam
      15.07.2018 23:24

      Давно ли шейдер может писать обратно в переменные, которые ставятся по сути как стейты на цпу? Material.GetFloat вернет закешированное значение, установленное ранее через Material.SetFloat.