Будущее за иммерсивными технологиями? Уже сейчас они стремительно развиваются и находят применение в различных сферах жизни — здравоохранении, образовании, развлечениях и бизнесе. Расширенная реальность, XR (Extended Reality) открывает новые горизонты взаимодействия человека с окружающим миром, объединяя виртуальную (VR), дополненную (AR) и смешанную реальность (MR).
Меня зовут Андрей, я С#-разработчик в компании SimbirSoft. В этой статье хочу поделиться практикой создания простых приложений в Unity, где реализуются XR-технологии. Для наглядности и более детального понимания особенностей разработки рассмотрим их применение в промышленной сфере на примере станка TV16. По нашему замыслу (с командой) с помощью XR-технологий можно демонстрировать его потенциальным клиентам, а также обучать новых сотрудников.
Чтобы понять, какой из типов расширенной реальности нам подойдет для создания приложений, давайте обратимся к теории.
Расширенная реальность (XR) — общий термин для всех иммерсивных технологий, которые расширяют реальность. Вот список популярных иммерсивных технологий на данный момент:
-
Дополненная реальность (AR) — технология наложения цифровых объектов на предметы реального мира. По сути, это подсказка или голограмма, нарисованная поверх реального мира.
-
Виртуальная реальность (VR) — технология полного погружения в виртуальный мир за счёт устройств. Она полностью отсекает реальный мир, пользователь видит картинку, нарисованное или спроектированное окружение.
-
Смешанная реальность (MR) — технология, благодаря которой пользователь может взаимодействовать с виртуальными объектами в реальности. Она позволяет видеть взаимодействие реальных и виртуальных объектов. Человек может оценить передний и задний план, расположение объектов относительно друг друга, и самое важное — появляется точка соприкосновения реальных и виртуальных объектов.
Что же из перечисленного выше нам подойдет? Существует множество готовых решений, реализующих XR-технологии, а нам остаётся сфокусироваться на главном — бизнес-логике. Это особенно важно для бизнеса, так как использование готовых, проверенных инструментов позволяет не тратить ресурсы на изобретение велосипеда и сосредоточиться на специфичных задачах клиента. Такой подход снижает затраты на разработку и поддержку продукта.
Одним из лучших инструментов для разработки XR-приложений является движок Unity3D. Он позволяет эмулировать достоверное физическое поведение объектов в 3D-пространстве, что особенно важно для VR и MR, где пользователь должен поверить, что находится в реальном мире.
Для реализации XR-технологий нам помогут следующие готовые наборы инструментов:
-
AR Foundation — для работы с определением плоскостей и пространства в решениях с AR и MR.
-
XR Interaction Toolkit — для создания механик взаимодействия с объектами в VR и MR.
В итоге у нас получится три приложения:
-
AR-приложение для смартфонов с поддержкой Google AR на платформе Android.
-
VR-приложение для очков Meta* Quest 3
-
MR-приложение для очков Meta* Quest 3
*Компания Meta Platforms Inc. признана экстремистской организацией на территории Российской Федерации.
Пример того как могут выглядеть подобные приложения
Все приложения мы будем собирать из одного Unity-проекта версии 2022.3.29f1, которая зарекомендовала себя как стабильная при использовании пакетов AR Foundation и XR Interaction Toolkit.
Не будем углубляться в детали работы с Unity, так как в интернете существует множество полезных курсов, уроков и гайдов. Unity также предлагает обширную базу уроков на платформе Learn Unity, пройдя которые можно получить детальное представление о работе с движком.
Реализация AR-версии с использованием AR Foundation
Для начала реализуем AR-версию приложения с помощью AR Foundation. После запуска редактора откройте диспетчер пакетов через пункт Add package by name и установите версию AR Foundation 5.1.5 (имя пакета: com.unity.xr.arfoundation).
После установки пакета настройте сцену:
-
Щёлкните правой кнопкой мыши (ПКМ) на области просмотра сцены. В выпадающем меню выберите XR → AR Session. На сцену добавится объект с компонентами AR Session и AR Input Manager, которые управляют AR-сессией.
-
Точно таким же способом создайте XR Origin для работы с AR-камерой. Выберите XR → XR Origin (Mobile AR). На объекте Main Camera автоматически появятся компоненты AR Camera Manager, AR Camera Background и Tracked Pose Driver для управления камерой.
Теперь добавим реализацию обнаружения плоскостей с помощью компонентов AR Plane Manager и AR Point Cloud Manager:
-
У компонента AR Plane Manager в поле Detection Mode выберите значение Horizontal, чтобы находить горизонтальные плоскости. В поле Plane Prefab укажите префаб плоскости, который будет отображаться, чтобы пользователь понимал, что эту область можно использовать для размещения объектов.
-
Создайте префаб плоскости. Для этого нажмите ПКМ на области просмотра сцены и выберите XR → AR Default Plane. Настройте объект, выбрав подходящий цвет материала в компоненте Mesh Renderer, а также цвет и толщину линий в Line Renderer. На компоненте AR Plane настройте поле Vertex Changed Threshold. Увеличение значения повышает производительность, но снижает точность, и наоборот. Настройки зависят от ваших ресурсов и потребностей. Готовый объект переместите в папку Prefabs и добавьте ссылку на него в поле Plane Prefab компонента AR Plane Manager.
-
Создайте префаб для AR Point Cloud. Аналогично выберите XR → AR Default Point Cloud. Этот объект содержит компоненты AR Point Cloud и AR Point Cloud Particle Visualizer, которые обеспечивают визуальное отображение частиц во время сканирования поверхности. Настройте объект, сохраните как префаб и добавьте в поле AR Point Cloud Manager.
Основные компоненты для работы с определением плоскостей через AR Foundation настроены.
Реализация логики размещения объекта на поверхности
Теперь рассмотрим, как реализовать размещение объекта на определённой поверхности. Для этого создадим папку Scripts и в ней создадим MonoBehaviour-скрипт с говорящим названием PlacerObjectsOnPlane (укладчик объектов на плоскости).
Для определения касания найденной поверхности нам понадобится компонент ARRaycastManager. Обозначим его как обязательный. Так мы упростим поиск ссылки на ARRaycastManager, поскольку он будет находиться на том же объекте в сцене, что и наш компонент. Получаем ссылку на него в методе Awake():
[RequireComponent(typeof(ARRaycastManager))]
public class PlacerObjectsOnPlane : MonoBehaviour {
private ARRaycastManager _raycastManager;
private void Awake() => _raycastManager = GetComponent<ARRaycastManager>();
}
Добавим ссылку на префаб нашего объекта через сериализованное приватное поле, так как этот префаб используется только в данном компоненте.
Далее напишем метод для размещения объекта:
public UnityEvent OnPlacedObject;
[SerializeField] private GameObject _placedPrefab;
private GameObject _spawnedObject;
private void Placement(Vector2 touchPosition) {
List<ARRaycastHit> hits = new List<ARRaycastHit>();
if (_raycastManager.Raycast(touchPosition, _hits, TrackableType.PlaneWithinPolygon)){
Pose hitPose = _hits[0].pose;
if(_spawnedObject == null){
_spawnedObject = Instantiate(_placedPrefab, hitPose.position, hitPose.rotation);
OnPlacedObject?.Invoke();
}else _spawnedObject.transform.SetPositionAndRotation(hitPose.position, hitPose.rotation);
}
}
Метод Placement принимает позицию нажатия на экран (Vector2 touchPosition). Мы создаём локальную переменную hits, которая заполняется результатами работы метода _raycastManager.Raycast(). Этот метод выпускает луч из заданной позиции на экране и проверяет его пересечение с поверхностью.
Каждый элемент в списке ARRaycastHit представляет пересечение луча с трекабельной поверхностью и содержит информацию о точке пересечения. Мы используем первый элемент списка (hits[0]) для получения позиции и ориентации точки пересечения (Pose).
Если объект _spawnedObject ещё не создан, мы создаём его с помощью метода Instantiate, размещаем в месте пересечения и вызываем событие OnPlacedObject для уведомления других компонентов. Если объект уже существует, мы просто перемещаем его в новую позицию.
Теперь добавим логику, которая будет обрабатывать нажатия на экран. Для этого реализуем метод Update:
private void Update() {
if (Input.touchCount > 0){
Touch touch = Input.GetTouch(0);
if (touch.phase == TouchPhase.Began && IsClickedOnUi(touch) == false) Placement(touch.position);
}
}
Метод Update отслеживает нажатия на экран. Мы запускаем метод Placement только при начальном касании (TouchPhase.Began) и если пользователь не нажал на элемент интерфейса. Это сделано для оптимизации — логика будет запускаться только в момент касания по экрану, а не в момент, когда мы перемещаем по нему палец, например.
Для проверки нажатия на UI добавим следующий метод:
private bool IsClickedOnUi(Touch touch) {
PointerEventData eventDataCurrentPosition = new PointerEventData(EventSystem.current);
eventDataCurrentPosition.position = touch.position;
List<RaycastResult> results = new List<RaycastResult>();
EventSystem.current.RaycastAll(eventDataCurrentPosition, results);
foreach (var item in results) {
if (item.gameObject.CompareTag(TAG_UI)) return true;
}
return false;
}
Этот метод проверяет, попадает ли нажатие на объект с тегом “UI”. Если да, размещение объекта не выполняется. Мы проверяем не тапнул ли пользователь по UI, который, к примеру, загораживал бы плоскость.
Настройка и сборка проекта
-
Разместите скрипт PlacerObjectsOnPlane на объекте XROrigin. При этом автоматически добавится компонент ARRaycastManager.
-
В поле RaycastPrefab компонента ARRaycastManager оставьте значение пустым, если не хотите отображать луч в приложении.
-
В поле _placedPrefab компонента PlacerObjectsOnPlane укажите префаб вашего объекта.
Теперь можно собрать проект для Android. Для этого:
-
Перейдите в File -> Build Settings и выберите Android как платформу.
-
Добавьте сцену в список Scenes in Build.
-
В Project Settings -> XR Plug-in Management активируйте ARCore на вкладке Android.
-
В Player -> Other Settings настройте следующие параметры:
-
Уберите галочку Auto Graphics API и оставьте только OpenGL.
-
Установите Texture Compression Format в значение ASTC.
-
Включите только архитектуру ARM64 в разделе Target Architectures.
-
Убедитесь, что Minimum API Level не ниже 24.
-
Подключите тестовое устройство с активированным режимом разработчика и отладкой.
-
Выберите устройство в поле Run Device и нажмите Build And Run для сборки и запуска приложения.
Мы создали приложение, которое позволяет сканировать поверхности и размещать на них 3D-объекты. Не будем углубляться в реализацию сложного UI, так как в этой статье делаю упор на разработку XR-решений, а не на пользовательский интерфейс. Из UI мы настроим только кнопку, которая фиксирует положение объекта на плоскости, чтобы потом взаимодействовать с ним. Также скрываем обнаруженные поверхности, чтобы они не создавали визуальный шум.
Добавление примитивного UI
Для добавления UI: нажмите ПКМ → UI → Canvas. Помимо Canvas появится объект EventSystem, отвечающий за обработку взаимодействий с UI. Выберите EventSystem, и вы увидите уведомление о необходимости использования новой версии InputSystem вместо стандартной. Нажмите ReplaceWithInputSystemUIInputModule, чтобы заменить компонент на тот, который работает с новой системой ввода. В компоненте Canvas Scaler:
-
В поле UI Scale Mode выберите Scale With Screen Size.
-
В поле Reference Resolution установите X: 1080 и Y: 1920 (для портретной ориентации).
-
В поле Screen Match Mode выберите Match Width Or Height.
-
В поле Match установите значение 0.5, чтобы обеспечить одинаковое влияние ширины и высоты на масштабирование.
Теперь внутри объекта Canvas создайте объект Button с вашим дизайном. Я добавил кнопку с надписью «OK» размером 200×200 и расположил её по центру экрана.
Переключатель поверхностей
Далее создадим скрипт PlacerSwitcher (переключатель поверхностей), который будет отвечать за включение и выключение функции укладки объекта на поверхности:
[RequireComponent(typeof(ARPlaneManager), typeof(ARPointCloudManager), typeof(PlacerObjectsOnPlane))]
public class PlacerSwitcher : MonoBehaviour {
[SerializeField] private Button _buttonForFixingObject;
private ARPlaneManager _arPlaneManager;
private PlacerObjectsOnPlane _placeObjectsOnPlane;
private ARPointCloudManager _arPointCloudManager;
private void Awake() {
_arPlaneManager = GetComponent<ARPlaneManager>();
_placeObjectsOnPlane = GetComponent<PlacerObjectsOnPlane>();
_arPointCloudManager = GetComponent<ARPointCloudManager>();
}
private void OnEnable() {
_placeObjectsOnPlane.OnPlacedObject.AddListener(ShowConfirmPlaceObjectButton);
_buttonForFixingObject.onClick.AddListener(HideARPlane);
}
private void OnDisable() {
_placeObjectsOnPlane.OnPlacedObject.RemoveListener(ShowConfirmPlaceObjectButton);
_buttonForFixingObject.onClick.RemoveListener(HideARPlane);
}
private void Start() => _buttonForFixingObject.gameObject.SetActive(false);
private void ShowConfirmPlaceObjectButton() => _buttonForFixingObject.gameObject.SetActive(true);
private void HideARPlane() {
_placeObjectsOnPlane.enabled = false;
_arPlaneManager.SetTrackablesActive(false);
_arPointCloudManager.SetTrackablesActive(false);
_arPlaneManager.enabled = false;
_arPointCloudManager.enabled = false;
_buttonForFixingObject.gameObject.SetActive(false);
}
}
В сериализованное поле _buttonForFixingObject передаем ссылку на кнопку. Этот компонент будет находиться на объекте XROrigin вместе с ARPlaneManager и PlacerObjectsOnPlane, ссылки на которые мы получаем в Awake.
В методах OnEnable и OnDisable подписываемся и отписываемся от событий установки объекта и нажатия на кнопку фиксации. Когда объект впервые устанавливается на плоскости, мы показываем пользователю кнопку (метод ShowConfirmPlaceObjectButton), с помощью которой он может подтвердить установку. Пока пользователь не нажмет на кнопку, он может выбрать другое место для установки. После нажатия на кнопку (метод HideARPlane) отключаем возможность переустановки объекта, очищаем все найденные плоскости и облака точек, а также выключаем компоненты, отвечающие за обнаружение новых плоскостей. Кнопку подтверждения также скрываем.
Перемещение платформы
Далее мы реализуем перемещение платформы (одного из элементов станка) с помощью касания пальцем по манипулятору, который представлен в виде колеса с ручкой, имитируя реальное взаимодействие с механизмом. Перед тем как приступить к созданию кода, нужно убедиться, что наше окружение в Unity настроено так, чтобы реагировать на касания и перемещения объектов.
Добавим PhysicsRaycaster на камеру, который работает в связке с системой событий Unity. Его задача — позволить системе «стрелять» лучом (raycast) из камеры и определять, какие объекты были пересечены этим лучом. Чтобы понять, куда именно пользователь нажал или провёл пальцем в контесте 3D-сцены, и таким образом взаимодействовать с объектами (коллайдерами) в ней.
Чтобы платформа могла реагировать на луч, ей нужно «сообщить» системе, что она существует в физическом пространстве. Для этого мы добавляем компонент Box Collider или другой подходящий коллайдер на объект. Он нужен, чтобы объект мог взаимодействовать с другими объектами физической системы Unity, в нашем случае — чтобы его можно было «засечь» лучом. Выберите объект платформы и добавьте подходящий коллайдер, например, Box Collider:

Приступим к написанию скрипта. В нашем случае мы хотим понять, в какой момент пользователь нажимает на объект на экране и как он перемещает палец, чтобы на основе этих данных изменить положение объекта. Для этого мы будем использовать интерфейсы IPointerDownHandler и IDragHandler, представляющие собой готовые методы для работы с касаниями и движениями, которые легко интегрировать в любой скрипт. Вместо создания собственной сложной системы отслеживания мы используем эти интерфейсы, чтобы сосредоточиться на логике перемещения объекта. Мы хотим сделать скрипт универсальным, чтобы его можно было использовать для любого элемента станка, который перемещается вдоль направляющих. Назовем скрипт MoverAlongAxis:
public class MoverAlongAxis : MonoBehaviour, IPointerDownHandler, IDragHandler
Чтобы пользователь мог выбрать, вдоль какой оси будет двигаться объект, создадим enum Axis в файле Parametrs:
public enum Axis{ x = 0, y = 1 , z = 2}
Это позволит удобно и наглядно задавать направление движения. Вместо числовых значений мы выберем ось из списка в инспекторе (например, X, Y или Z). Добавим несколько сериализуемых приватных полей в наш скрипт для возможности настройки его из инспектора, но и ограничив изменение этих параметров из других скриптов. Обозначим поле _axis типа enum Axis, описанного нами ранее, – это позволит выбрать направление, вдоль которого объект будет перемещаться и использовать в дальнейших расчетах:
[SerializeField] private Axis _axis;
Задаем минимальную и максимальную позиции объекта вдоль выбранной оси, чтобы ограничить его перемещение с помощью полей _minimumMovementValue, _maximumMovementValue. Поле _smoothingMovementSpeed необходима нам, чтобы объект плавно перемещался к новой позиции, а не мгновенно, что создаст реалистичное ощущение «веса»:
[SerializeField] protected float _minimumMovementValue, _maximumMovementValue;
[SerializeField] private float _smoothingMovementSpeed;
Следующие поля используются только во внутренних расчетах и нет нужды выносить их в инспектор. Для корректного расчета смещения нам нужно знать начальную позицию объекта и точку касания. Эти значения мы будем хранить в виде кортежа starting Values c соответствующими названиями. В поле _target Movement Value мы будем хранить целевую позицию, к которой необходимо переместиться:
private (float dragPointPosition, float movementValue) _startingValues;
private float _targetMovementValue;
После обозначения необходимых полей перейдем к реализации методов интерфейсов. Начнем с OnPointerDown (IPointerDownHandler), который будет фиксировать момент касания. Этот метод вызывается автоматически (Callback), как только пользователь коснётся объекта.
public void OnPointerDown(PointerEventData eventData) {
_startingValues.dragPointPosition = GetValueAxis(eventData.pointerCurrentRaycast.worldPosition);
_startingValues.movementValue = GetValueAxis(transform.localPosition);
}
В нем мы получаем стартовые позиции самого элемента, а также точки касания пальцем по элементу. Чтобы получить точку касания, мы используем eventData.pointerCurrentRaycast – это структура, содержащая информацию о точке касания пользователя. Параметр pointerCurrentRaycast.worldPosition хранит мировую координату той точки, где луч «ударил» по объекту. worldPosition позволяет точно определить, где находится точка касания относительно мира, а не экрана. Это важно, поскольку объект может быть расположен в 3D-пространстве. Но поскольку нам нужно смещение лишь по одной оси X и нас не интересуют остальные две, то мы напишем простой метод, который получает для нас только одно значение согласно выбранной нами оси:
private float GetValueAxis(Vector3 vector) => vector[(int)_axis];
Теперь, когда у нас есть стартовые позиции как точки, так и элемента, исходя из них мы сможем рассчитать смещение пальца и новую целевую позицию для перемещения каретки. Расчеты мы сможем произвести после того, как получим новые позиции с помощью реализации метода OnDrag (IDragHandler). После того как пользователь начал тянуть объект, метод OnDrag позволит определить, как далеко и в каком направлении он перемещает палец:
public void OnDrag(PointerEventData eventData) {
if(eventData.pointerCurrentRaycast.isValid == false) return;
float delta = GetValueAxis(eventData.pointerCurrentRaycast.worldPosition) - _startingValues.dragPointPosition;
_targetMovementValue = Mathf.Clamp(_startingValues.movementValue + delta, _minimumMovementValue, _maximumMovementValue);
}
Для начала убедимся — произошёл ли успешный «удар луча» (попадание по объекту) с помощью проверки eventData.pointerCurrentRaycast.isValid. Если, например, пользователь потянул палец мимо объекта, проверка будет ложной — в этом случае ничего не делаем.
Если палец не соскользнул с нашего объекта, а все еще находится на нем, то зная текущее положение точки пересечения луча от нашего пальца и объекта (не забыв применить метод GetValueAxis, чтобы получить только позицию по X от полученного вектора) и зная первоначальную его позицию, мы получаем их разницу delta, тем самым мы понимаем, сколько прошел палец пользователя и в какую сторону (движение справа налево — отрицательная дельта, а движение слева направо — положительная). Зная эту дельту, мы можем вычислить новую позицию нашей каретки, прибавив к стартовой позиции каретки дельту, а с помощью метода Mathf.Clamp мы ограничим минимальное и максимальное значение для новой целевой позиции.
Теперь, когда у нас есть новая целевая позиция каретки, мы можем плавно переместить ее туда. Для этого используем метод Vector3.Lerp. Напишем отдельный метод SmoothMovement и запустим его в Update:
private virtual void Update() => SmoothMovement();
private void SmoothMovement() => transform.localPosition = Vector3.Lerp(transform.localPosition, GetVector3TargetPosition(_targetMovementValue), Time.deltaTime * _smoothingMovementSpeed);
Но и тут нам нужно будет прибегнуть к вспомогательному методу, который позволит сформировать новый Vector3 на основе целевого значения перемещения:
private Vector3 GetVector3TargetPosition(float targetMovementValue) {
float[] coords = { transform.localPosition.x, transform.localPosition.y, transform.localPosition.z };
coords[(int)_axis] = targetMovementValue;
return new Vector3(coords[0], coords[1], coords[2]);
}
Этот метод создаёт и возвращает новый Vector3, где значения всех координат (x, y, z) остаются такими же, как у текущего положения объекта (transform.localPosition), за исключением той оси, которая была выбрана заранее. Для выбранной оси устанавливается переданное целевое значение targetMovementValue. Это позволяет динамически изменять положение объекта только вдоль заданного направления, сохраняя его положение по другим осям неизменным.
Движение манипулятора
Теперь каретка будет перемещаться по экрану с помощью пальца. Однако, чтобы взаимодействие выглядело более реалистично, нам нужно, чтобы манипулятор (рукоятка) также вращался в зависимости от положения каретки. Для этого мы создадим расширение существующего скрипта MoverAlongAxis, назовём его MoverAlongAxisWithRotateManipulator. Новый скрипт будет наследоваться от MoverAlongAxis и добавлять функциональность вращения манипулятора:
public class MoverAlongAxisWithRotateManipulator : MoverAlongAxis
В нем прежде всего мы добавим сериализованные приватные поля для установки ссылки и значения из инспектора по нашим предпочтениям:
[SerializeField] private Transform _manipulator;
[SerializeField] private float _maxAngleRotationManipulator;
В поле _manipulator мы добавим ссылку на Transform манипулятора, чтобы иметь возможность вращать его, а в поле _maxAngleRotationManipulator зададим максимальный угол поворота манипулятора, который соответствует амплитуде его движения. Это значение определяет, насколько сильно манипулятор отклоняется при достижении кареткой одного из пределов ее движения. Например, если каретка перемещается от минимального к максимальному значению, манипулятор будет плавно вращаться от 0 градусов до значения, указанного в этом поле.
Напишем скрипт вращения:
private void UpdateManipulatorRotation() {
float normalizedPosition = Mathf.InverseLerp(_minimumMovementValue, _maximumMovementValue, _targetMovementValue);
float rotationAngle = Mathf.Lerp(0f, _maxAngleRotationManipulator, normalizedPosition);
_manipulator.localRotation = Quaternion.Euler(0f, rotationAngle, 0f);
}
Опишем задействованные методы, и что они нам посчитают. Mathf.InverseLerp возвратит нормализованное значение (от 0 до 1) текущей позиции объекта относительно _minimumMovementValue и _maximumMovementValue и _targetMovementValue. Эти поля в скрипте MoverAlongAxis мы должны сделать protected, чтобы иметь к ним доступ в потомке.
Mathf.Lerp вычисляет, на какой угол нужно повернуть манипулятор на основе нормализованного значения. Quaternion.Euler задает локальное вращение манипулятора по оси Y на основе рассчитанного угла.
Этот метод мы запускаем в update после отработки метода Update в родителе, который в MoverAlongAxis сделаем виртуальным для возможности модификации в потомке:
protected override void Update() {
base.Update();
UpdateManipulatorRotation();
}
Такие скрипты у нас получились в итоге:
public class MoverAlongAxis : MonoBehaviour, IPointerDownHandler, IDragHandler {
[SerializeField] private Axis _axis;
[SerializeField] protected float _minimumMovementValue, _maximumMovementValue;
[SerializeField] private float _smoothingMovementSpeed;
private (float dragPointPosition, float movementValue) _startingValues;
protected float _targetMovementValue;
public void OnPointerDown(PointerEventData eventData) {
_startingValues.dragPointPosition = GetValueAxis(eventData.pointerCurrentRaycast.worldPosition);
_startingValues.movementValue = GetValueAxis(transform.localPosition);
}
public void OnDrag(PointerEventData eventData) {
if(eventData.pointerCurrentRaycast.isValid == false) return;
float delta = GetValueAxis(eventData.pointerCurrentRaycast.worldPosition) - _startingValues.dragPointPosition;
_targetMovementValue = Mathf.Clamp(_startingValues.movementValue + delta, _minimumMovementValue, _maximumMovementValue);
}
protected virtual void Update() => SmoothMovement();
private void SmoothMovement() => transform.localPosition = Vector3.Lerp(transform.localPosition, GetVector3TargetPosition(_targetMovementValue), Time.deltaTime * _smoothingMovementSpeed);
private Vector3 GetVector3TargetPosition(float targetMovementValue) {
float[] coords = { transform.localPosition.x, transform.localPosition.y, transform.localPosition.z };
coords[(int)_axis] = targetMovementValue;
return new Vector3(coords[0], coords[1], coords[2]);
}
private float GetValueAxis(Vector3 vector) => vector[(int)_axis];
}
public class MoverAlongAxisWithRotateManipulator : MoverAlongAxis {
[SerializeField] private Transform _manipulator;
[SerializeField] private float _maxAngleRotationManipulator;
protected override void Update() {
base.Update();
UpdateManipulatorRotation();
}
private void UpdateManipulatorRotation() {
float normalizedPosition = Mathf.InverseLerp(_minimumMovementValue, _maximumMovementValue, _targetMovementValue);
float rotationAngle = Mathf.Lerp(0f, _maxAngleRotationManipulator, normalizedPosition);
_manipulator.localRotation = Quaternion.Euler(0f, rotationAngle, 0f);
}
}
Теперь добавим скрипт MoverAlongAxisWithRotateManipulator на объект Caret в префабе TV16:

Установим в него нужные значения, к примеру, такие:

Готово! После того как соберем приложение, мы сможем управлять кареткой с помощью движения пальца по манипулятору этого станка на телефоне. Подобным образом можно оживить и остальные рычаги управления и подвижные части этого станка.
Реализация MR-технологии
Теперь перейдем к реализации того же самого, но с помощью MR-технологии. Для этого нам понадобится пакет XRInteractionToolkit версии 2.5.4:

После установки пакета заходим в PakageManager в окне этого пакета во вкладке Samples:

Нужно установить несколько пакетов, которые обеспечат необходимые механизмы для работы с взаимодействием с руками, плоскостями и контроллерами:
-
Starter Asset — пакет, включающий базовые скрипты и механики взаимодействия объектов. Этот пакет также содержит схемы InputAction Hands Interaction Demo, которые предоставляют готовую логику для отслеживания рук и взаимодействия с объектами в сцене с использованием этих рук.
-
XR DeviceSimulator — этот пакет необходим для тестирования VR-сцены прямо в редакторе Unity. Он позволяет симулировать взаимодействие с устройствами VR, не требуя реального устройства, что упрощает процесс разработки и тестирования.
-
HandsInteractionDemo — в этом пакете уже реализована логика взаимодействия с руками, и он зависит от других пакетов, таких как XRHands и ShaderGraph. Их также нужно установить для возможности работать с отслеживанием рук и графическими эффектами, связанными с ними.
-
OpenXRPlugin и Unity OpenXR Meta— эти плагины позволяют интегрировать OpenXR и с Unity. Их установка обеспечит поддержку VR-устройств, таких как Quest, и работу с разными XR-сценариями.
После установки всех необходимых пакетов создаем сцену в папке Scenes и называем ее MRScene. Это будет нашей рабочей сценой, где мы будем размещать все необходимые элементы для MR-взаимодействия.
Чтобы добавить возможность работы с Hand Tracking (отслеживание рук) и интерактивными объектами, нам нужно добавить префаб XR Interaction Hands Setup на сцену. Этот префаб включает все необходимые элементы для работы с взаимодействием через руки, позволяя отслеживать движения рук пользователя и выполнять соответствующие действия с объектами.
Однако, помимо работы с руками, нам также нужно добавить возможность взаимодействия с плоскостями, которые будут определяться шлемом. Для этого добавляем компонент ARSession, который управляет жизненным циклом AR-объектов и взаимодействием с ними. Чтобы добавить ARSession, щелкаем правой кнопкой мыши на области сцены и выбираем XR -> ARSession.
Далее на объект XR Origin добавляем компоненты ARPlaneManager и ARRaycastManager, позволяющие работать с плоскостями, определенными с помощью AR. Эти компоненты отвечают за отслеживание плоскостей в реальном времени и работу с лучами, которые будут определять, где именно на плоскости можно разместить объект.
Для камеры добавляем компонент ARCameraManager, который отвечает за обработку данных от камеры устройства в AR-сценах и взаимодействие с реальным миром. Этот компонент необходим для правильного отображения сцены и взаимодействия с плоскостями.
Теперь, когда мы настроили все компоненты для работы с Hand Tracking и плоскостями, перейдем к созданию скрипта для размещения объектов на плоскости. В отличие от обычного размещения объектов через тап по экрану в Mixed Reality мы будем использовать контроллеры и лучи для взаимодействия с отсканированными плоскостями.
Наш скрипт будет называться PlacerObjectsOnPlaneMR, он будет реализовывать логику для определения того, где на плоскости можно разместить объект. Контроллер будет использовать луч, направленный на плоскость, чтобы указать место, где объект должен быть размещен. Это улучшает взаимодействие в AR-среде, так как позволяет более точно и естественно управлять объектами, находясь в реальном мире.
public class PlacerObjectsOnPlaneMR : MonoBehaviour {
[SerializeField] private XRRayInteractor _rayInteractor;
[SerializeField] private InputActionReference _action;
[SerializeField] private GameObject _placedPrefab;
private GameObject _spawnedObject;
}
Помимо тех же самых полей, что и в скрипте для AR (_placedPrefab, spawnedObject), участвующих в моменте создания и перемещения созданного объекта на плоскости, мы должны добавить еще и поля _action. Добавим сюда ссылку на действие из нового Input System который используется в пакете XRInteractionToolkit. Оно отслеживает нажатие кнопки (например, триггера на контроллере) _rayInteractor. Также нам нужно получить ссылку на компонент XRRayInteractor, который отвечает за управление лучом (Ray) от контроллера. С его помощью мы сможем определить, где луч пересекает осознанную плоскость:
private void OnEnable() => _actionTrigger.action.performed += PressTrigger;
private void OnDisable() => _actionTrigger.action.performed -= PressTrigger;
private void PressTrigger(InputAction.CallbackContext context) => TryPlaceObject();
В OnEnable подписываем PressTrigger на событие performed действия из Input System. Это позволяет вызывать PressTrigger, когда произошло заданное по ссылке действие.
В OnDisable удаляем подписку, чтобы избежать ошибок или утечек памяти, если объект отключается или удаляется. В методе PressTrigger вызываем метод попытки размещения объекта:
private void TryPlaceObject() {
if (_rayInteractor.TryGetCurrentARRaycastHit(out ARRaycastHit hit)) {
Pose hitPose = hit.pose;
Quaternion targetRotation = Quaternion.Euler(0, _rayInteractor.transform.rotation.eulerAngles.y, 0);
if (_spawnedObject == null) {
_spawnedObject = Instantiate(_placedPrefab, hitPose.position, targetRotation);
} _spawnedObject.transform.SetPositionAndRotation(hitPose.position, targetRotation);
}
}
В компоненте _rayInteractor есть метод TryGetCurrentARRaycastHit, который проверяет пересечения луча с AR плоскостями. Для возможности этой проверки нужно убедиться, что ARRaycastManager по факту существует на сцене.
К примеру, я хочу использовать луч на правом контроллере для размещения объекта на плоскости. Тогда я должен установить ссылку на RayInteraction в PlacerObjectsOnPlaneMR, а в самом компоненте, в инспекторе, должен активировать чекбоксы в разделе ARConfiguration:

Далее по тексту мы получаем hitPose, но он отличается от того, который мы использовали в AR-варианте, так как эта реализация не учитывает поворот джойстика. Поэтому мы берем позицию точки пересечения, а для поворота используем поворот джойстика (луча из того же объекта), но только вокруг оси Y, чтобы объект корректно размещался на ровной поверхности. Далее создание или размещение уже созданного объекта не отличается от AR-варианта.
Теперь напишем скрипт, с помощью которого будем выключать плоскости и возможность размещения объекта, для того чтобы перейти к следующему этапу взаимодействия с уже размещенным объектом по аналогии с PlacerSwitcherAR. Но поскольку он будет сильно отличаться логикой, создадим новый и назовем PlacerSwitcherMR. В отличие от AR-варианта здесь не нужно определять момент установки объекта (для отображения UI-кнопки подтверждения) – это действие можно подтвердить кнопкой на контроллере. Кроме того, мы реализуем возможность переключения режима: в любой момент можно снова включить отображение отсканированных плоскостей и переместить объект.
[RequireComponent(typeof(ARPlaneManager), typeof(PlacerObjectsOnPlaneMR))]
public class PlacerSwitcherMR : MonoBehaviour {
[SerializeField] private InputActionReference _action;
private ARPlaneManager _arPlaneManager;
private PlacerObjectsOnPlaneMR _placerObjectsOnPlaneMR;
private bool _arePlanesVisible = true;
private void Awake() {
_arPlaneManager = GetComponent<ARPlaneManager>();
_placerObjectsOnPlaneMR = GetComponent<PlacerObjectsOnPlaneMR>();
}
private void OnEnable() => _action.action.performed += TogglePlanes;
private void OnDisable() => _action.action.performed -= TogglePlanes;
private void TogglePlanes(InputAction.CallbackContext context) {
_arePlanesVisible = !_arePlanesVisible;
_arPlaneManager.enabled = _arePlanesVisible;
_arPlaneManager.SetTrackablesActive(_arePlanesVisible);
_placerObjectsOnPlaneMR.enabled = !_arePlanesVisible;
}
}
Так же, как и в AR в Awake, мы ищем компоненты ARPlaneManager PlacerObjectsOnPlaneMR. ARPointCloudManager не нужен, поскольку именно шлем Quest 3 сканирует пространство заранее и просто показывает нам уже отсканированное – здесь не нужны спецэффекты ожидания сканирования.
Добавляем ссылку на событие из новой системы ввода (_action) и, как в предыдущем скрипте, подписываем и отписываем метод TogglePlanes. Внутри этого метода, используя сохраненное состояние в поле _arePlanesVisible, включаем или выключаем необходимые компоненты, отвечающие за размещение объекта и отображение плоскостей. После написания скриптов размещаем их на XROrigin и назначаем необходимые ссылки. Например, ссылка на действие XRI RightHandInteraction/Activate соответствует нажатию на триггер правого контроллера.

Установим объект по нажатию на триггер на правом контроллере, а при нажатии на триггер на левом контроллере мы будем выключать плоскости и перемещение по ним объекта.
Теперь перейдем к настройке интерактивного взаимодействия. В отличие от AR тут мы будем управлять перемещением каретки непосредственно при помощи манипулятора, чтобы получить большее погружение. Здесь нет таких условностей, как экран смартфона, мы можем перемещать каретку иммерсивно, аналогично тому, как бы мы это делали в реальности. Для этого используем готовые решения из Samples. К примеру, в тех Samples, которые мы скачали для работы с ассетом HandTracking, есть много примеров взаимодействия с объектами: нажатие на кнопки, хват предметов, взаимодействие с UI. Но среди них, к сожалению, нет взаимодействия с вращательными элементами. Чтобы не изобретать велосипед, мы возьмем уже готовый пример из соответствующего расширенного ассета — из него нам нужен только скрипт взаимодействия с вращательным элементом, типа руля XRKnob. Возьмем этот скрипт, добавим к нашему манипулятору и настроим по своим пожеланиям. Далее создадим новый префаб из модели станка TV16, так как он будет во многом отличаться от AR-префаба. Для скрипта XRKnob важно, чтобы он не находился на вращательном элементе, поэтому под объектом Manipulator создаем пустой объект с помощью меню ПКМ->CreateEmpty. Далее выносим его из под Manipulator, а сам объект Manipulator переименовываем в ManipulatorView и устанавливаем чайлдом к этому пустому объекту (его можно переименовать в Manipulator). На него мы назначаем компонент XRKnob, в поле Handle добавляем ссылку на Transform ManipulatorView.
Чекбокс ClampedMotion переключаем в true MaxAngle = 180 MinAngle =-180. Это необходимо для того, чтобы вращение было ограничено 360 градусами от начала и до конца перемещения каретки (по аналогии с AR-приложением), а также, чтобы при достижении ограничений поворота мы больше не смогли повернуть этот манипулятор:

На Manipulator View мы повесим коллайдер и rigidbody (c выключенным Use Gravity и включенным IsKinematic) для возможности работы с ним с помощью XRKnob. Мы настроили только вращение самого манипулятора, теперь нам предстоит реализовать перемещение каретки в зависимости от его поворота. Этот скрипт перемещения несколько похож на скрипт перемещения MoverAlongAxis.Тут мы также реализуем возможность выбора осей, вдоль которых нужно перемещаться, с методом получения целевой позиции на основе выбора оси. Передадим ссылку на компонент, отвечающий за манипуляцию:
public class MoverAlongAxisMR : MonoBehaviour {
[SerializeField] private Axis _axis;
[SerializeField] private XRKnob _manipulator;
[SerializeField] protected float _minimumMovementValue, _maximumMovementValue;
[SerializeField] private float _movementSmoothing;
private Vector3 _targetPosition;
private void OnEnable() => _manipulator.onValueChange.AddListener(OnValueChange);
private void OnDisable() => _manipulator.onValueChange.RemoveListener(OnValueChange);
private void Update() => transform.localPosition = Vector3.Lerp(transform.localPosition, _targetPosition, Time.deltaTime * _movementSmoothing);
private void OnValueChange(float currentValue) {
float targetValue = Mathf.Lerp(_minimumMovementValue, _maximumMovementValue, currentValue);
_targetPosition = GetVector3TargetPosition(targetValue);
}
private Vector3 GetVector3TargetPosition(float targetMovementValue) {
float[] coords = { transform.localPosition.x, transform.localPosition.y, transform.localPosition.z };
coords[(int)_axis] = targetMovementValue;
return new Vector3(coords[0], coords[1], coords[2]);
}
}
В нем гарантируем наличие события onValueChanged, на которое подписываем метод OnValueChange. Внутри него будет происходить расчет новой целевой позиции для перемещения с учетом измененного значения. В методе Update реализуем перемещение с помощью линейной интерполяции позиции с учетом сглаживания, аналогично AR-реализации (с использованием Vector3.Lerp).
Назначаем полученный скрипт на объект Caret:

Заполняем поля (не забудем установить ссылку на полученный префаб в PlacerObjectsOnPlaneMR), теперь можем собирать билд и тестировать его в VR-очках.
Для сборки билда нам необходимо установить некоторые настройки, для этого перейдем в PlayerSettings->XRPlug-in Management -> вкладка Android, установим чекбокс напротив OpenXR (далее у нас появится чекбокс MetaQuest feature Group, если мы не забыли установить пакет Unity OpenXR Meta):

Далее переходим на вкладку OpenXr и устанавливаем необходимые для работы MR и VR feature как на скриншоте:

При этом в Projectvalidation появляются множественные ошибки, которые указывают на необходимые настройки для нашего билда:

Нажимаем FixAll — и они изменятся в автоматическом порядке. Теперь можем собрать билд и запустить (перед запуском убедитесь, что поверхности вашей комнаты отсканированы, — это делается в настройках шлема, комната должна быть именно отсканирована, а не откалибрована, как может показаться):

Реализация VR-технологии
Теперь мы приступим к реализации VR-технологии. Главное отличие от MR в том, что в VR-пользователь полностью погружается в виртуальную среду и не видит окружающую комнату или отсканированные плоскости. Сканирование реального пространства нам здесь не требуется.
Начнём с создания новой сцены. По аналогии с MR найдём префаб XR Interaction Hands Setup и добавим его на сцену. Этот префаб уже содержит основные настройки для взаимодействия в XR-приложениях. После этого добавим на сцену префаб со станком, который мы уже подготовили для MR.
Однако в этот раз мы не будем добавлять AR-компоненты к камере или к XR Origin, так как они не нужны для VR. Вместо этого сосредоточимся на создании полноценного виртуального окружения.
Одной из ключевых особенностей VR является создание контекста, который полностью погружает пользователя. Вместо того чтобы оставить его в знакомой квартире, важно перенести его в соответствующее место, например, на завод. В качестве примера возьмем эти ассеты RPG/FPS GameAssets for PC/Mobile (Industrial Set v3.0)Industrial Props PBR и соберем из них сцену, которая будет похожа на индустриальное окружение. Вы можете создать свою сцену или же взять готовую отсюда: VRScene. При настройке окружения не забудьте расставить коллайдеры, которые ограничат перемещение пользователя.

Для большего реализма в VR мы можем заменить контроллеры на визуализацию рук. Это позволит пользователю увидеть, как он взаимодействует с объектами своими настоящими руками. Например, при захвате предметов пальцы на виртуальных руках будут корректно сжиматься, имитируя хватку.
Найдите 3D-модели рук для VR (по запросам RightHand и LeftHand). Преобразуйте эти модели в префабы: создайте отдельные префабы для правой и левой руки. Внутри каждого префаба поверните модели рук — левую на 90, правую на -90 по оси Z. Это обеспечит их корректное отображение вместо контроллеров.

Теперь на объектах RightController и LeftControlller в компонентах XRController установим ссылки на подготовленные модели рук в поле ModelPrefab:

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

Poke Interaction — компонент отвечает за взаимодействие с объектами через прямое «прикосновение» (например, нажатие кнопок). Если данное взаимодействие не требуется в вашей демо-версии, его можно отключить для упрощения интерфейса и оптимизации производительности.
Ray Interaction — позволяет использовать луч для выбора и взаимодействия с объектами на расстоянии (например, управление UI или выбор объектов в пространстве). Если данная функциональность не является частью вашей демонстрации, её визуальное отображение можно скрыть.
Teleport Interaction — этот элемент предоставляет возможность перемещения через телепортацию. Он обычно используется для перемещения игрока или камеры в VR-пространстве. Если в вашей демо-сцене не предусмотрено перемещение с помощью телепортации, эту функцию тоже стоит отключить.
Затем мы можем проверить настройку нашего контроллера, если в PlayerSettings XRInteractionToolkit установим галочку в чекбокс UseXR Device SimulatorScene:

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

Теперь напишем небольшой скрипт, который будет отвечать за скрытие этих моделей:
public class HandViewer : MonoBehaviour {
[SerializeField] private SideHand _sideHand;
private XRBaseController xRBaseController;
public SideHand SideHand { get => _sideHand; }
private void Awake() => xRBaseController = transform.parent.GetComponent<XRBaseController>();
public void Show() => xRBaseController.model.gameObject.SetActive(true);
public void Hide() => xRBaseController.model.gameObject.SetActive(false);
}
Добавим также в скрипт Parameters новый enum:
public enum SideHand {Right, Left}
Назначим этот скрипт на объект DirectInteraction, так как с помощью него мы будем получать те руки, с которыми мы взаимодействуем. XR Base Controller обязательно должен быть на родительском объекте.
В инспекторе для установленных компонентов укажем, на какой руке (правой или левой) они находятся, затем разместим имитируемые руки на ручке манипулятора в идеальном положении, учитывая обхват пальцами и изгиб запястья. Для этого изменяем положение костей моделей рук, что удобно делать с помощью пакета Animation Rigging. Добавляем префабы рук в иерархию под ручку манипулятора.

Далее добавляем компонент BoneRenderer таким способом:

Это подсветит все кости модели рук, доступные для изменения. Выделяем кости, которые нужно изменить, и с помощью мыши движениями по разным осям, пока не достигнем нужного эффекта хвата рукой. Теперь создадим простой скрипт, который будет скрывать и показывать имитируемые руки в нужный момент:
public class FakeHands : MonoBehaviour {
[SerializeField] private GameObject _fakeHandR;
[SerializeField] private GameObject _fakeHandL;
private XRBaseInteractable _xrBaseInteractable;
private void Awake() => _xrBaseInteractable = GetComponent<XRBaseInteractable>();
private void OnEnable() {
HideAllFakeHands();
_xrBaseInteractable.selectEntered.AddListener(OnSelectEntered);
_xrBaseInteractable.selectExited.AddListener(OnSelectExited);
}
private void OnDisable() {
HideAllFakeHands();
_xrBaseInteractable.selectEntered.RemoveListener(OnSelectEntered);
_xrBaseInteractable.selectExited.RemoveListener(OnSelectExited);
}
private void OnSelectEntered(SelectEnterEventArgs args) {
var handView = GetHandViewer(args.interactorObject.transform);
if (handView == null) return;
handView.Hide();
SetFakeHandVisibility(handView.SideHand, true);
}
private void OnSelectExited(SelectExitEventArgs args) {
var handView = GetHandViewer(args.interactorObject.transform);
if (handView == null) return;
handView.Show();
SetFakeHandVisibility(handView.SideHand, false);
}
private void SetFakeHandVisibility(SideHand sideHand, bool isVisible) {
if (sideHand == SideHand.Right) _fakeHandR.SetActive(isVisible);
else _fakeHandL.SetActive(isVisible);
}
private void HideAllFakeHands() {
_fakeHandR.SetActive(false);
_fakeHandL.SetActive(false);
}
private HandViewer GetHandViewer(Transform interactorTransform) {
var handViewer = interactorTransform.GetComponent<HandViewer>();
if (handViewer == null) Debug.LogError($"HandViewer component was not found on {interactorTransform.name}");
return handViewer;
}
}
С помощью компонента XR Base Interactable и его событий (selectEntered, selectExited) мы можем определить, какой рукой пользователь взаимодействует с объектом, а затем выбрать, какую ложную руку ему показывать.
Напишем небольшой скрипт для синхронизации вращения имитируемых рук с ручкой манипулятора. Руки не должны вращаться вместе с ней, а лишь менять позицию, словно привязаны к ручке манипулятора, сохраняя собственное вращение:
public class PositionSynchronizer : MonoBehaviour {
[SerializeField] private GameObject _objectSynchronize;
private void Update() => transform.position = _objectSynchronize.transform.position;
}
Добавим его на объект FakeHands, а ссылку передадим на объект ручки Handle.
Обязательно добавляем коллайдеры пола и стен на сцену, так как контроллер пользователя перемещается с учетом физики, и это предотвратит его проваливание в нежелательные области.
Сборка проекта под VR ничем не отличается от сборки проекта под MR, поэтому мы с легкостью можем собрать и проверить ее на шлеме (убедившись, что нужная нам сцена находится в BuildScenes).
Заключение
Вот мы и завершили наше практическое путешествие в мир XR-технологий, используя Unity как основной инструмент разработки. В этом туториале мы подробно разобрали основные шаги по созданию трёх приложений для разных направлений: AR (дополненная реальность), MR (смешанная реальность) и VR (виртуальная реальность).
Здесь вы можете скачать готовый проект: XRDemo(UnityProject)
Готовые сборки:
Итак, мы разобрали:
-
чем отличаются AR/VR/MR-технологии;
-
как реализовать эти технологии с помощью Unity;
-
особенности взаимодействия пользователя с цифровыми объектами в рамках каждой XR-технологии.
Эти знания являются основой, на которой вы можете строить свои собственные проекты. Вы можете совершенствовать созданные приложения, добавляя:
-
Дополнительные элементы управления: поддержку жестов, голосовое управление или взаимодействие с помощью AR/VR-контроллеров.
-
Более сложные взаимодействия: например, симуляцию физики или интерактивные сценарии.
-
Детализированную графику: текстуру, освещение и эффекты постобработки для создания более реалистичной атмосферы.
-
Функциональность искусственного интеллекта: дайте вашим объектам или персонажам поведение, чтобы они реагировали на действия пользователя.
-
Интеграцию с внешними сервисами: например, базами данных или API, чтобы приложения могли получать данные в реальном времени.
XR-технологии открывают перед вами огромные возможности. Эти три приложения — лишь начальная точка. Вы можете использовать полученные знания для создания более сложных продуктов: от тренажеров и образовательных решений до игр и приложений для бизнеса.
Помните, что успех разработки в сфере XR заключается в постоянной практике и стремлении экспериментировать. Продолжайте изучать новые инструменты, пробуйте реализовывать необычные идеи, и у вас обязательно получится создавать уникальные и востребованные проекты.
Удачи в ваших будущих разработках!
Спасибо за внимание!
Больше авторских материалов для backend-разработчиков от моих коллег читайте в соцсетях SimbirSoft – ВКонтакте и Telegram.
Автор: SSul