Делаем свой джойстик для Unity3D с батчингом и спрайтами

в 17:35, , рубрики: Без рубрики

Некоторое время назад мне понадобился мобильный джойстик для управления персонажем. Посмотрев на стандартный джойстик из включенного в дистрибутивную версию Unity3D пакета я понял, что это не совсем то, что мне нужно.
Во-первых, там очень сильно закрученная и мудреная система вложенных друг в друга объектов.
Во-вторых, джойстик не «подскакивает» к пальцу при нажатии.
В-третьих, почему-то он ограничивается квадратом, а не кругом.
Далее, у него нет красивой стандартной подложки и он не умеет «подскакивать» под палец пользователя.

Чтобы не изобретать велосипед, решил поискать бесплатный джойстик в местном Asset Store. Меня очень удивило, если не сказать поразило, отсутствие бесплатных джойстиков. Из 40 найденных позиций были джойстики по 5-100 долларов, при этом, судя по рейтингам и комментариям, большинство из них работали очень криво. (Единственный бесплатный джойстик я нашел намного позже, но об этом подробнее дальше)

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

Делаем свой джойстик для Unity3D с батчингом и спрайтами

Какие спрайты и батчинг?

Unity3D версии 4.3 наделала много шума добавлением нативной поддержки разработки 2D игр. Одним из новых компонент являлся SpriteRenderer, который позволил с легкостью делать 2D игры без дополнительных библиотек. Однако основной его особенностью является то, что разные спрайты из одного атласа батчатся в 1 Draw Call независимо от относительного изменения размеров через Scale. В мобильной разработке принято экономить на Draw Call в любом месте, где это возможно и SpriteRenderer дает нам эту возможность — если упаковать все используемые контроллеры на экране в один атлас, то отрисовку всего интерфейса можно вместить в один Draw Call.

Проблема стандартного джойстика была еще и в том, что используемая для отрисовки GUITexture не батчится, на каждую GUITexture на экране тратится ровно 1 Draw Call.
Когда я таки нашел бесплатный джойстик в магазине, оказалось что он использует аналогичный подход. В общем я здорово загорелся идеей бесплатного джойстика из спрайтов с батчингом и принялся за дело.

Основная идея

Делаем свой джойстик для Unity3D с батчингом и спрайтами
Если разместить наш джойстик перед камерой на расстоянии, скажем, 0.5 юнитов, спрайты не будут «врезаться» в другие объекты сцены и все время будут на переднем плане. Вместе со SpriteRenderer пришла система Z-сортировки внутри определенных заранее слоев, но она распространяется только на сами спрайты и системы частиц (насколько я смог разобраться, поправьте если не прав).

Этот же принцип, по-моему, используется в системах вроде NGUI (я не работал с ней, не могу сказать точно). В любом случае, картина получается следующая:
Делаем свой джойстик для Unity3D с батчингом и спрайтами

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

frustumHeight = 2.0f * distance * Mathf.Tan(camera.fieldOfView * 0.5f * Mathf.Deg2Rad);
frustumWidth = frustumHeight * camera.aspect;

Окей, с этим понятно. Теперь к самому джойстику. Если вы играли в игры типа Dungeon Hunter 4, то замечали, что джойстик подпрыгивает в точку нажатия, и управление идет относительно этой точки. Причем есть «подложка» под джойстик и собственно сам джойстик.

Я собрал простой джойстик с «рабочей зоной». При нажатии на любую точку внутри зеленого коллайдера (простой Box Collider), джойстик должен прыгнуть туда и управляться относительно этой точки.

Делаем свой джойстик для Unity3D с батчингом и спрайтами
Объект состоит из трех элементов, главный компонент с Box Collider и два вложенных объекта со SpriteRenderer

Обычно проверка нажатия на каком-либо объекте в Unity3D производится с помощью Physics.Raycast(..). Из камеры пускается луч в точку нажатия, и проверяется, не попал ли этот луч в какой-либо объект — коллайдер. Плюсами такого подхода является то, что очень просто определить реальные (мировые) координаты точки нажатия, то есть перевести координаты экрана в координаты нашего сечения пирамиды. Однако я не хотел привязываться к этой системе потому-что:

  • Рейкастинг — это довольно дорогая операция
  • Не будет возможности найти координаты нажатия за пределами основного коллайдера

В итоге я решил использовать Physics.Raycast только для определения начального положения джойстика, а потом преобразовывать координаты касания на экране в координаты точки на сечении пирамиды и далее в локальные координаты джойстика. В целом логика ясна — проверяем, не нажали ли мы на BoxCollider с помощью Physics.Raycast, и если нажали, перемещаем весь джойстик в эту точку (подложку и сам джойстик, но не BoxCollider) и используя перевод экранных координат нажатия в координаты сечения пирамиды и локальные координаты джойстика находим конечное положение самого джойстика. Подложка при этом остается на своем месте. После того, как игрок отпускает палец от экрана, просто ставим джойстик в исходное положение. Вроде все просто.

Для превода экранных координат в координаты точки на сечении пирамиды я решил пойти простым путем — находим процентное отношение координат нажатия к размерам экрана, получаем два числа от 0 до 1, соответствующих координатам X и Y. Затем умножаем эти числа на ширину и высоту сечения пирамиды и получаем локальные координаты точки на этом сечении. После чего, если джойстик расположен не в центре, нужно вычесть из полученных координат относительный центр коллайдера. В итоге, без проверки выхода джойстика за рамки подложки, получается следующая картина. При этом мы уже можем найти относительное направление джойстика и нормировать его.
Делаем свой джойстик для Unity3D с батчингом и спрайтами
Используя разницу векторов центра подложки и центра джойстика можно найти длину вектора. Для проверки этого значения нам нужно знать радиус самой подложки. На помощь нам приходит свойство Pixels to Unit при импорте спрайтов. Фактически это свойство говорит о том, сколько пикселей исходного спрайта уместится в 1 юнит расстояния. Чем больше это значение, тем меньше выглядит спрайт. К сожалению, я не нашел адекватного способа определения этого свойства во время выполнения кода, поскольку требуется класс TextureImporter и его свойства, а он обычно доступен только для расширений редактора (скорее всего его физически можно использовать из рантайма, но по-моему это не совсем адекватный вариант). Пока решением остается ручное копирование значения Pixels to Unit в паблик свойство контроллера джойстика.
Посчитав фактическую величину подложки в юнитах, мы можем проверить, выходит ли джойстик за ее рамки или нет.
Для оптимизации проверки в данном случае нам здорово поможет свойство Vector3.sqrMagnitude, определяющее квадрат модуля вектора. Если сравнивать квадрат модуля с квадратом необходимого расстояния, можно избежать операции вычисления квадратного корня, что немного ускорит выполнения кода.
Общее условие выглядит следующим образом — если квадрат модуля вектора относительного направления меньше или равен квадрату радиуса подложки, джойстик находится под положением нажатия. В противном случае нормируем вектор относительного направления, умножаем на радиус подложки и ставим джойстик в точку с получившимися координатами.
Делаем свой джойстик для Unity3D с батчингом и спрайтами
Получается такая картина:
Делаем свой джойстик для Unity3D с батчингом и спрайтами

Можно сразу сделать так, чтобы относительный вектор был нормирован от 0 до 1 для использования в дальнейшем. Тут заморачиваться не надо, используются простые формулы нормировки.

Если прикрутить получившийся на данный момент джойстик к заготовке персонажа, получится нечто такое:
Делаем свой джойстик для Unity3D с батчингом и спрайтами

(На самом деле он ходит, просто неоднородная текстура, на которой видно передвижение, здорово увеличивает размер GIF файла)

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

Подводные камни

Все стало очень интересно, когда я начал делать снеппинг джойстика к углам экрана.
Поскольку локальные координаты точки нажатия на сечение не соответствуют локальным координатам самого джойстика, при перемещении самого джойстика (вместе с коллайдером) в точку, отличную от левого нижнего угла экрана, приходится пересчитывать дополнительное слагаемое локальных координат. Для привязки к верхнему левому углу выглядело это примерно так:
Делаем свой джойстик для Unity3D с батчингом и спрайтами
С этой проблемой я залип на очень длительное время, долго пытался понять мозгом, что и с чем нужно складывать и из чего вычитать. Оказалось, что все довольно сложно.
Прежде всего, как бы мы ни двигали BoxCollider, точка отсчета локальных координат в джойстике всегда будет в центре подложки (в обычном положении, без нажатия).
Оказалось что свойства collider.bounds считаются всегда в мировых координатах, поэтому для адекватного нахождения реальных размеров пришлось для начальных вычислений ставить объект джойстика в положение Reset, то есть в нулевую позицию с нулевым поворотом, а потом ставить обратно.
Общий вид систем отсчетов выглядел следующим образом:
Делаем свой джойстик для Unity3D с батчингом и спрайтами

Конечно, в расчет приходилось брать и размеры коллайдера. В общем я здорово заморочился, но все таки разобрался с этой системой.

Но это был не конец.

Еще одним подводным камнем оказалась поддержка мультитача.
Представим следующий сценарий:
У нас есть два джойстика и два пальца, пусть это будет Д1, Д2, П1 и П2.
Пользователь нажимает П1 на Д1, после чего нажимает П2 на Д2. Индекс П1 в массиве нажатий равен 0, индекс П2 в массиве нажатий равен 1. Если после этого пользователь отпускает П2, индекс П1 все еще остается равен 0, все хорошо. Но если вместо П2 пользователь отпустит П1, то индекс П2 станет равен 0, и Д1 будет думать, что его П1 переместился в точку П2, и получится так, что один П2 управляет двумя джойстиками.
Стоит отметить, что до этого я мало работал с мультитачами, и следующее предложение может показаться кому-то смешным и несуразным. Я сразу ринулся проверять дельту перемещений. Но это был слишком кривой костыль, он не спасал от случая, когда мы сводим два пальца вместе. Оба джойстика прилипают к одному пальцу и снова та же самая картина.
Потом у меня хватило ума посмотреть документацию и прочитать про волшебное свойство Touch.fingerId. Оно хранит индекс нажатого пальца.
Я поменял привязку джойстика к индексу в массиве касаний на индекс пальца и стал проверять, присутствует ли еще в списке присутствующих касаний нужный нам палец. Все стало работать просто отлично.
Делаем свой джойстик для Unity3D с батчингом и спрайтами

Итог:
Я проверил производительность джойстика по профайлеру, оказалось что он здорово превосходит стандартный (простой одновременный твикинг, без применения перемещения на цель, проверял через Unity Remote):
Делаем свой джойстик для Unity3D с батчингом и спрайтами

  • Батчинг никуда не делся.
  • С подобным подходом не нужно переживать о размерах текстур относительно размера экрана, GUITexture приходилось вручную шкалировать под разные разрешения.
  • Но самое главное — своими руками и бесплатно. Нестоит недооценивать роль коммунизма в Open-Source разработке!

Пакет ждет одобрения в AssetStore, обновлю ссылку как только он там появится (ссылка на Git репозиторий будет чуть позже).

Пока писал статью, понял как сделать джойстик без коллайдера и Physics.Raycast(..)
Я определенно буду продолжать работать над этим продуктом, планируется сделать простой тачпад и ABC кнопки.

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

Оригинальный пак экранных контроллеров можно найти ТУТ

Автор: KumoKairo

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js