Невозможные пространства в Unity. Порталы.

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

Различия паттернов применения

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

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

Что нужно чтобы сделать порталы в Unity?

Зависимости - URP, UniRx, DoTween, UniTask

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

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

С чего начинаются порталы?

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

Вот они, слева направо..
Вот они, слева направо..

Да, окна в другие пространства сделаны из меша, а вы как хотели?

Превращаем доску в окно.

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

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

Это происходит за счет проецирования UV меша в пространство экрана. Понятно? Скорее нет, чем да. Вот как это выглядит на примере:

Базовый Unlit шейдер натягивает текстуру по UV меша
Базовый Unlit шейдер натягивает текстуру по UV меша
Кастомный шейдер проецирует текстуру игнорируя UV меша
Кастомный шейдер проецирует текстуру игнорируя UV меша

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

В репозитории этого проекта будет 2 |стула| шейдера - реализация в шейдерграфе и трушном шейдерлабе. Тут каждый сможет выбрать наиболее приятный ему стул :)

А где объем то?

Самое интересное дамы и господа!

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

Какая-то схема
Какая-то схема

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

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

Кстати для "обычных" порталов понадобится аж две текстуры и несколько итераций рендеринга в одном кадре (чувствуете, чувствуете? Пахнет горячим GPU), но об этом позже.

Лично я использовал такой вариант для оптимизированного портала:

private void CamerasUpdate(ScriptableRenderContext context, Camera camera) { //позиции наших порталов Transform inTransform; Transform outTransform; // меняем в зависимости от нахождения игрока в дугом пространсве if (Traveler.InAnotherWorld) { inTransform = _inPortal.transform; outTransform = _outPortal.transform; } else { inTransform = _outPortal.transform; outTransform = _inPortal.transform; } //сет родителя для камеры _virtualRenderTransform.SetParent(outTransform); //считаем позицию и поворот Vector3 loockerPosition = inTransform.worldToLocalMatrix.MultiplyPoint3x4(Traveler.TravelerCamera.position); Quaternion diffrence = outTransform.rotation * Quaternion.Inverse(inTransform.rotation * Quaternion.Euler(0, 180, 0)); loockerPosition.y = -loockerPosition.y; //применяем _virtualRenderTransform.localPosition = -loockerPosition; _virtualRenderTransform.rotation = diffrence * Traveler.TravelerCamera.rotation; //рендер запрос UniversalRenderPipeline.RenderSingleCamera(context, _virtualRenderCamera); }

Собстна переход через портал меняет состояние "Путешественника", в связи с чем мы меняем положение камеры, которая начинает обрабатывать положение уже относительно второго портала (да, тут четко определен первый портал и второй, и пока активен первый - второй будет показывать всякую срань, ибо у них одна текстура)

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

Как это использовать? Ну, например так:

Это я в зеленой комнате, сейчас дома уже

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

Видим кашу
Видим кашу
Видим то что должны
Видим то что должны

Есть два путя решения этой проблемы:

  • Клиппинг переднего плана камеры до нашего портала
  • Отключение рендеринга для некоторых слоев

Клиппинг сам по себе реализуется довольно просто, но есть несколько НО. Например если мы просто укажем камере с какого расстояния она должна начинать рендерить изображение, то мы столкнемся с артефактами, если камера имеет угол от портала не равный 90 поскольку плоскость клиппинга параллельна камере:

Тут все супер
Тут все супер
А вот тут начинаются приколы
А вот тут начинаются приколы

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

используем клиппинг и все ок
используем клиппинг и все ок
а вот тут уже видно всякое
а вот тут уже видно всякое

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

А что если портал в портале?

УУУУ рекурсия
УУУУ рекурсия
и еще рекурсия
и еще рекурсия

Для достижения такого эффекта нашему GPU придется попотеть. Для начала нам нужно определить количество итераций - в теории можно рисовать портал в портале неограниченно количество раз, но на практе будет достаточно 2-3 итераций.

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

Я использовал вот такую бандуру:

private void AllCamerasUpdate(ScriptableRenderContext context, Camera camera) { if (!_portals[0].IsPlaced || !_portals[1].IsPlaced) return; if (_portals[0].MeshRenderer.isVisible) { _virtualRenderCamera.targetTexture = _texture1; _virtualRenderTransform.SetParent(_portals[1].transform); for (int i = _iter - 1; i >= 0; i--) { OneCameraUpdate(_portals[0], _portals[1], _virtualRenderCamera, _virtualRenderTransform, context, i); } } if (_portals[1].MeshRenderer.isVisible) { _virtualRenderCamera.targetTexture = _texture2; _virtualRenderTransform.SetParent(_portals[0].transform); for (int i = _iter - 1; i >= 0; i--) { OneCameraUpdate(_portals[1], _portals[0], _virtualRenderCamera, _virtualRenderTransform, context, i); } } }

Мы берем одну камеру и двигаем ее для каждого портала отдельно:

private void OneCameraUpdate(ABasePortal portalIn, ABasePortal portalOut, Camera virtualRenderCamera, Transform virtualRenderTransform, ScriptableRenderContext context, int IterId) { Transform inTransform = portalIn.transform; Transform outTransform = portalOut.transform; virtualRenderTransform.position = Traveler.TravelerCamera.position; virtualRenderTransform.rotation = Traveler.TravelerCamera.rotation; for (int i = 0; i <= IterId; ++i) { Vector3 relativePos = inTransform.InverseTransformPoint(virtualRenderTransform.position); Quaternion relativeRot = Quaternion.Inverse(inTransform.rotation) * virtualRenderTransform.rotation; relativePos = Quaternion.Euler(0f, 180f, 0f) * relativePos; relativeRot = Quaternion.Euler(0f, 180f, 0f) * relativeRot; virtualRenderTransform.position = outTransform.TransformPoint(relativePos); virtualRenderTransform.rotation = outTransform.rotation * relativeRot; } virtualRenderCamera.nearClipPlane = (virtualRenderTransform.position - outTransform.position).magnitude; UniversalRenderPipeline.RenderSingleCamera(context, virtualRenderCamera); }

На бумаге это будет выглядеть так:

Последовательность рендеринга
Последовательность рендеринга

Далее - перемещение между порталами.

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

Оригинал и жалкий клон
Оригинал и жалкий клон

Собственно манипуляции такие:

  • при попадании обьекта в триггер портала создать(включить) его клон
  • отключить взаимодействие с коллайдером стены на которой висит портал (только для обычных порталов)
  • пока обьект не покинул триггер зону двигать его клона инверсивно относительно портала
  • если клон больше чем на половину выполз за пределы портала - поменять местами клон и оригинал, а также сделать активным другой портал (коллайдер то только на оригинале есть)
Смотрим на своего клона прямиком из портала
Смотрим на своего клона прямиком из портала

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

public override void UpdateClonePositions(long lon) { if (Travelers.Count == 0) return; for (int i = 0; i < Travelers.Count; i++) { //обновить позицию клона Travelers[i].UpdateClonePosition(); Vector3 objPos = transform.InverseTransformPoint(Travelers[i].Traveler.position); //если клон выполз больше чем на половину if (objPos.x > 0.0f) { //меняем местами Travelers[i].Warp(); } } }

Для обычного портала добавится проверка на то что второй портал существует

public virtual void UpdateClonePosition() { Vector3 relativePos = PortalIn.InverseTransformPoint(Traveler.position); relativePos = halfTurn * relativePos; Clone.position = PortalOut.TransformPoint(relativePos); Quaternion relativeRot = Quaternion.Inverse(PortalIn.rotation) * Traveler.rotation; relativeRot = halfTurn * relativeRot; Clone.rotation = PortalOut.rotation * relativeRot; } public virtual void Warp() { if (WarpDelay) return; SetDelay(); var inTransform = PortalIn.transform; var outTransform = PortalOut.transform; Vector3 relativePos = inTransform.InverseTransformPoint(Traveler.position); relativePos = halfTurn * relativePos; Traveler.position = outTransform.TransformPoint(relativePos); Quaternion relativeRot = Quaternion.Inverse(inTransform.rotation) * Traveler.rotation; relativeRot = halfTurn * relativeRot; Traveler.rotation = outTransform.rotation * relativeRot; Vector3 relativeVel = inTransform.InverseTransformDirection(_rb.velocity); relativeVel = halfTurn * relativeVel; _rb.velocity = outTransform.TransformDirection(relativeVel); var tmp = PortalIn; PortalIn = PortalOut; PortalOut = tmp; }

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

Делэй для предотвращения зацикливания:

public async void SetDelay() { WarpDelay = true; await UniTask.Delay(500); WarpDelay = false; }

Примерно так можно реализовать порталы в юнити.

Не время для итогов

"Обычные" порталы также подразумевают алгоритм их расстановки на подходящей геометрии.

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

Тут (да, это мой девлог в тг, все нормально) вы можете найти ссылку на репозиторий с вышеописанным проектом и самостоятельно поковыряться в нем.

4949
16 комментариев

Круто! Наконец-то на дтф годнота!

8
Ответить

Автор подразумевает что читатель так или иначе знаком с движком Unity и умеет использовать базовый функционалЧитатель иногда и читать то толком не умеет, а тут базовый функционал движка

4
Ответить

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

2
Ответить

Там использовали технологию отсекания пространства (что бы личшнего не считать)
и для работы "портала" в порталах используют промежуточную комнату с ненулевой толщиной
А так - в настройках можно указать количество "глубины" рекурсии просчитываемой движком(до 16-32?)

Ответить

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

1
Ответить

Факт. По сути все вышенаписанное служит только для создания иллюзии порталов и неевклидовых пространств
Думаю если достаточно углубиться в тему, то можно реализовать в юньке нечто похожее на то что показывает optozorax в своих видосах, но это будет уже совершенно другой уровень)
Может когда-нибудь и до этого руки дойдут)

2
Ответить

Недавно ютуб подсунул забавный материал по порталам:
https://youtu.be/o19xXsouJAc?si=04d0X8oc4Hq_VTwG

1
Ответить