Телепорты в Unity — вид от третьего лица
Недавно я добавил в свой авиасимулятор порталы. Реализация этой механики стала для меня реальным вызовом, и я хотел бы поделиться с вами своим опытом. В конце поста оставил ссылку на репозиторий, если кто захочет потрогать своими руками.
Основная сложность заключалась в том, что в игре используется камера с видом от третьего лица. Следовательно, при перемещении в пространстве нужно учитывать как модель персонажа, так и камеру.
К тому же игрок не должен догадаться, в какой момент перемещают персонажа и камеру. Для него портал должен работать как обычный проём, через который он просто попадает в другое пространство.
Условно можно разделить задачу на этапы:
- Показать в проеме портала происходящее по ту сторону
- Реализовать перемещение персонажа и камеры через портал
- Сделать перемещение максимально бесшовным
Введение
Камера: OrbitCameraController
Камеру я взял из своего основного проекта (писал про неё тут) и убрал из неё всё лишнее. Оставил только возможность вращения камеры вокруг модели игрока и функцию проверки препятствий между игроком и камерой. При их наличии камера смещается к препятствию, чтобы модель игрока оставалась в кадре
Перемещение: MovementController
Для перемещения написал небольшой контроллер, который просто двигает персонажа вперёд/назад и вправо/влево, отталкиваясь от ориентации камеры
PortalSystem
Это MonoBehaviour, в котором реализована основная логика. Саму реализацию рассмотрим ниже.
Классы: Area и Portal
Area является потомком MonoBehaviour. Portal является потомком Area. К этим ребятам тоже вернемся позже.
Рендер "окошка"
Для того чтобы показать игроку окно портала, нам потребуется несколько компонентов.
1. Вторая камера, которая будет двигаться вокруг выхода точно так же, как основная камера двигается вокруг входа
2. рендер-текстура, на которую эта камера будет выводить изображение.
3. Шейдер, который будет проецировать текстуру на экран с учетом перспективы камеры
Шейдер я делал по этому гайду , но в моём случае используется Universal Render Pipeline (URP), поэтому реализация немного отличается.
С шейдерами я совсем не дружу, поэтому просто повторил за автором видео не особо рефлексируя о том, как это работает внутри.
В репозитории шейдер лежит тут "Assets/Portal/Shader/Portal.shader". Комментариями я отметил фрагменты, которые нужно поправить в стандартном новом шейдере.
Механика перемещения
Корень — это пустой GameObject, который в компонентах имеет только Transform и PortalSystem.
PortalCamera — это та самая портальная камера, про которую мы говорили раньше. Portal_A и Portal_B — это вход и выход. Каждый из них включает в себя плоскость, и раму вокруг неё. На плоскость проецируется противоположная сторона. Рама это физический ограничитель. Также каждый из этих порталов имеет компонент Sphere Collider и скрипт Portal.
Gate_A — это объект, который имеет Box Collider и скрипт Area.
PortalSystem : MonoBehaviour
Сразу обозначим все поля класса, которые нужно заполнить в инспекторе
Для начала нужно заставить портальную камеру вращаться вокруг выхода для корректного рендера.
Положение портальной камеры в пространстве управляется в следующем методе
Позиция и ориентация объекта transformB выставляется относительно объекта anchorB в соответствии с позицией и ориентацией объекта transformA относительно anchorA.
Вызов выполняется в LateUpdate; в качестве якорей передаются поля _anchorForPlayerCamera и _anchorForGhostCamera.
Эти поля проинициализированы в Start следующим образом:
Когда камера будет телепортироваться, якоря для будут также меняться местами.
Теперь мы видим в портале то, что происходит с другой стороны.
Нужно создать копию игрока и двигать её относительно портальной камеры так же, как настоящая модель двигается вокруг основной камеры.
Здесь создаём копию:
Перемещается клон с помощью SetRelativeByAnchors, которую мы уже видели. Только в качестве якорей для объектов персонажей выступают камеры.
Если бы мы работали с камерой от первого лица, логика была бы следующая: персонаж идёт внутрь портала и наблюдает перед собой меш, на котором рендерится та сторона. Потом он попадает внутрь ворот — мы этот момент отлавливаем и переносим его на ту сторону.
Отслеживать попадание персонажа можно с помощью коллайдера с установленным флагом Trigger, а бесшовность перехода обеспечит правильное положение меша относительно триггера ворот.
В случае с камерой от третьего лица процесс перехода через портал имеет несколько этапов. Более того, проходить через портал можно в разном порядке, или игрок вообще передумает и решит вернуться в момент, когда модель персонажа уже по ту сторону, а камера ещё по эту.
Немного систематизируем все эти ситуации. Для начала условимся, что есть три зоны: Out (сторона входного портала), In (пространство возле выхода) и Gate (область, через которую нужно пройти, чтобы попасть на ту сторону). Объекты, которые будут перемещаться между этими зонами, — это камера и игрок. Таким образом система имеет девять вариантов состояний, вот они
Area : MonoBehaviour
Теперь немного о том, как эти состояния будут переключаться. В этом будут принимать непосредственное участие класс Area и Portal, который является его потомком. Класс небольшой, поэтому приведу его полностью:
Когда что-то попадает в коллайдер объекта Area, мы запоминаем сам объект и точку входа — она пригодится позже, и сообщаем в PortalSystem о том, что нужно обновить состояние.
Когда объект покидает коллайдер, мы удаляем его и также сообщаем в PortalSystem.
С помощью метода Contains мы можем опросить экземпляр Area о том, имеется ли в нём сейчас конкретный объект.
Для того, чтобы экземпляры Area отслеживали взаимодействие с камерами на портальную камеру и на обычную вешаем Rigidbody с выключенной физикой и SphereCollider.
В PortalSystem при выполнении UpdatePortalState() просто выставляется флаг, который указывает на необходимость пересчёта состояния.
Эта система гарантирует синхронность и последовательность расчета при смене состояний. Все могут независимо друг от друга сообщать о событиях OnTriggerEnter и OnTriggerExit, но состояние будет рассчитываться один раз в FixedUpdate класса PortalSystem.
Я решил, что телепортировать персонажа на ту сторону буду в момент, когда он оказывается из Out в Gate. Таким образом, если по ту сторону портала есть какие-то объекты, персонаж уже будет корректно с ними взаимодействовать.
Камера же должна телепортироваться, в момент когда она перемещается из Gate в In либо из Out в In (такое возможно, если игрок зайдёт внутрь за какой-то объект, пока камера остаётся снаружи).
Если камера или игрок совершают обратный переход, то мы вновь меняем их местами возвращая оригинал на место.
В момент когда Игрок в In и Камера в In, то есть оба объекта оказались по ту сторону, мы меняем порталы местами, таким образом выход становится входом.
Есть одна проблема, которую я не смог решить. В случае, если я разрешаю камере проходить ворота первой, возникают разные глитчи и состояния сменяются некорректно, поэтому я решил просто закрыть эту возможность. На схеме переходы, которые в таком случае отпадают, покрашены в красный.
В функции ClosePortal для gate Назначается слой ClosedPortal. Взаимодействие этого слоя и слоя камеры IgnoreClipcheck отключено, поэтому коллайдер перестаёт реагировать на коллайдер камеры, и камера просто упирается в портал как в обычную стенку.
Для непосредственного изменения положения объектов в пространстве используются два метода
Для movementController нужно менять камеру, чтобы в промежуточном состоянии управление не менялось для игрока.
Метод CalculateTeleportPhysics вызывается для того, чтобы переориентировать ускорение RigidBody персонажа в соответствии с тем, как порталы расположены друг относительно друга в пространстве.
К примеру, если выход смотрит в противоположную от входа сторону, игрок должен при телепортации поменять вектор на противоположный. Этого можно не делать, если объект персонажа не обладает физикой.
В FixedUpdate по флагу обновляется состояние, а в случае, если игрок вообще покинул коллайдеры порталов они отключаются
Здесь рассчитываются флаги _cameraGate и _playerGate. Это признак того, что соответственно камера/портальная камера и игрок/клон игрока находятся в gateArea.
Помимо этого вычисляется признак _gateBetween — он означает, что между камерой и её таргетом находится коллайдер gateArea, но при этом ни камера, ни игрок не находятся в gateArea.
Если этот признак изменился с момента последнего FixedUpdate, то мы должны пересчитать состояния даже если ни один из экземпляров Area не объявлял о необходимости пересчета состояния. Пример такой ситуации уже приводился выше: камера была перемещена за объект в процедуре checkClips().
В UpdateStateNow сперва проверяется текущее состояние игрока, затем рассчитываем состояние камеры, в том числе в зависимости от состояния игрока.
В результате, если состояние системы изменилось, принимается решение о том, нужно ли перемещать игрока или камеру. Также переключаются слои у клона, чтобы он корректно отображался на камерах.
Стоит обратить внимание на расчёт _otherSideCamera — это признак, который показывает, что камера находится по другую сторону от точки входа игрока.
Он необходим в промежуточной ситуации, когда персонаж находится внутри gateArea, а камера произвольно перемещается.
В любой момент мы понимаем, с какой стороны вход, и в соответствии с этим можем корректно принять решение, в какой момент свапать камеру.
Чисто механически теперь всё работает, но нужно обеспечить бесшовность перехода.
Полировка перехода
Игрок и так перемещается достаточно гладко — с этим проблем нет. Что делать с камерой?
Камера должна до последнего наблюдать рендер-текстуру на меше и перемещаться до того, как её пересечёт. В идеале для этого нужно отодвинуть меш от коллайдера gateArea на расстояние чуть больше диаметра SphereCollider у камеры.
Но по факту этого не хватает. Физика в Unity рассчитывается внутри FixedUpdate, и в случае, если камера движется достаточно быстро, она успевает пролететь текстуру, от чего картинка моргает в момент перехода.
В итоге я просто подобрал значение _meshPositionOffset равное десяти радиусам SphereCollider камеры плюс половина толщины gateArea (поскольку сам коллайдер gateArea находится посреди портала).
В функции UpdatePortalSurfaceOffset меш двигается в обоих порталах на это расстояние в противоположную от камеры сторону.
Если сделать коллайдеры камеры и gateArea слишком маленькими, физика не всегда будет успевать рассчитываться. Между физическими тиками коллайдеры могут проходить друг сквозь друга, не вызывая событий триггеров.
Поэтому размеры должны быть ощутимыми, и расстояние _meshPositionOffset становится приличным. Из-за этого портал получился не совсем элегантным: вместо аккуратного окошка мы имеем коридор. Эту проблему мне решить не удалось. Не очень приятно, но не критично.
Остался последний штрих. Пока игрок гуляет вокруг входа в портал может возникнуть ситуация, когда между портальной камерой и выходом окажется какой-то объект. В этом случае иллюзия ломается. Вот пример:
Для решения этой проблемы нужно отсечь для камеры рендер всего, что ограничено областью выходного портала, это делается с помощью установки свойства projectionMatrix в функции UpdatePortalClippingPlane, которую мы вызываем в LateUpdate.
Теперь колонна, которая маячит перед портальной камерой не рендерится ею, и мы не видим её в плоскости входного портала.
Теперь порталы готовы и полноценно функционируют.
Спасибо за внимание!
Прилагаю ссылку на репозиторий и мой телеграм-канал. Если будет интересно следить за моим проектом обязательно подписывайтесь!