СоХабр закрыт.

С 13.05.2019 изменения постов больше не отслеживаются, и новые посты не сохраняются.

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

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

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

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




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


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

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

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



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

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


Нужно только найти размеры сечения этой пирамиды на заданном расстоянии от камеры.
Документация Unity3D в данном случае здорво помогла, формула оказалось простой:
frustumHeight = 2.0f * distance * Mathf.Tan(camera.fieldOfView * 0.5f * Mathf.Deg2Rad);
frustumWidth = frustumHeight * camera.aspect;


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

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


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

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


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

Для превода экранных координат в координаты точки на сечении пирамиды я решил пойти простым путем — находим процентное отношение координат нажатия к размерам экрана, получаем два числа от 0 до 1, соответствующих координатам X и Y. Затем умножаем эти числа на ширину и высоту сечения пирамиды и получаем локальные координаты точки на этом сечении. После чего, если джойстик расположен не в центре, нужно вычесть из полученных координат относительный центр коллайдера. В итоге, без проверки выхода джойстика за рамки подложки, получается следующая картина. При этом мы уже можем найти относительное направление джойстика и нормировать его.

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

Получается такая картина:


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

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


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

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

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


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

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


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

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

Еще одним подводным камнем оказалась поддержка мультитача.
Представим следующий сценарий:
У нас есть два джойстика и два пальца, пусть это будет Д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. Оно хранит индекс нажатого пальца.
Я поменял привязку джойстика к индексу в массиве касаний на индекс пальца и стал проверять, присутствует ли еще в списке присутствующих касаний нужный нам палец. Все стало работать просто отлично.


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


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

Пакет ждет одобрения в AssetStore, обновлю ссылку как только он там появится.
Ссылка на Repo: github.com/KumoKairo/CNJoystick

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

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

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

комментарии (16)

+2
+3 –1
ZimM ,  

А нужны ли тут коллайдеры вообще? Rect хватило бы, на первый взгляд.

–1
+1 –2
KumoKairo ,  

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

И кстати Rect — это структура, а не компонент, у нее нет отображаемого в инспекторе гизмо :)

+3
+4 –1
ZimM ,  

Как было сказано ниже, для GUI нужно использовать отроганальную проекцию, тогда все координаты у вас прекрано совпадут с пикселями. Проверка нажатия на рабочую область? rect.Contains(myTouch.position), физика и рейкасты тут вообще ни при чем. Остальные преобразования тоже становятся тривиальными.
Да, и Rect прекрасно отображается в инспекторе.

0
ZimM ,  

*ортогональную, конечно же.

0
KumoKairo ,  

Тогда такой вопрос — как использовать ортогональную проекцию для GUI?
Поискал насчет отображения Rect, он отображается в инспекторе, но не на сцене, у этого компонента нет гизмо. Если у BoxCollider видны зеленые края и его можно позиционировать относительно самого джойстика, то как быть с Rect? Менять параметры и смотреть что получилось не очень хочется.

+2
ZimM ,  

Как правило, для GUI создают отдельную камеру, которая рисует только слой GUI. Ортогональная проекция устанавливается свойством Projection компонента Camera. Нарисовать свой гизмо никто не мешает — используйте метод OnDrawGizmos у MonoBehaviour, в нем рисуюются 4 линии через Gizmos.DrawLine

0
KumoKairo ,  

Про две камеры слышал, но меня эта тема немного пугает в плане производительности
Обязательно попробую, спасибо за совет :)

+1
ZimM ,  

Падения призводительности тут нет. Просто сцена отрисовывается в несколько подходов с разными параметрами.

0
valyard ,  

Что, кстати, и так делается и для одной камеры, и называется draw call.
Если правильно настроить камеры, мы просто скажем движку, что эти draw calls делаются с такой-то Model-View-Projection матрицей, а другие с другой.

0
romeo_ordos ,  

Используйте Bounds. Одну из осей можно проигнорировать для 2D. Это тоже структура, но никто вам не мешает инициализировать её в компоненте.

Gizmos.DrawWireCube(bounds.center, bounds.size);

0
KumoKairo ,  

Отлично, спасибо за совет!

+1
+3 –2
jonic ,  

Боже как все стало странно и странно. Я то всегда думал что GUI юзается в ортогональной проекции, особенно 2D… А тут камера. Draw Call фигня, вот переключение буффера(текстуры например), это затратная операция. DrawCall стоит экономить когда их количество выходит на тысячи. Кстати к слову о Draw Call. Интересный факт, каждый элемент интерфейса(буква, кнопочка, звездочка и прочее) в NFS:Underground рисовался за один DrawCall. Вот такие пироги. Блин, ну почему все стало так не интересно? А знаете ли вы что с единичными матрицами(Локальная, Мира, Проекции) при голой отрисовке что бы вывести квад на весь экран, нужно всего 4 вертекса. с координатами левого верхнего угла -1.f и правого нижнего 1.f. А ортогональная матрица проекции нам помогает вместо -1.f..1.f юзать пиксели. Которые замечу попадут в тесели. А вообще что бы гуй всегда был сверху, надо отключать Z-Test и рисовать его последним. Бомбануло у меня, простите.

0
KumoKairo ,  

Спасибо за отзыв :)

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

Да и потом, в данном случае получается система WYSIWYG, а в описанном Вами примере реализация сугубо из кода?

Я был бы очень признателен, если бы Вы посоветовали тематическую литературу по использованию описанных вами методик в Unity3D :)

+1
ZimM ,  

Плюсую всё, вот только Draw Call — фигня только на ПК, мобильные девайсы на порядок чувствительнее.

0
+1 –1
exvel ,   * (был изменён)

Заранее извиняюсь за резкость. Не воспринимайте на счет автора. Просто, крик души.
Ненавижу эти виртуальные джойстики! Понимаю, что, иногда, без них не обойтись… НО
Они не нужны в 80% случаев. Вообще ничего на экране не нужно отображать лишнего.

В стрелялках: Левая половина экрана для передвижения, правая для поворота камеры, тап слева для стрельбы, дабл-тап справа для прыжка. Пример: Dead Space.
В 2D аркадах: В любом месте экрана движение пальцем указывает направление бега, тап — прыжок. Если нужна стрельба, то можно и под это выделить область экрана. Пример: LIMBO.

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

+1
KumoKairo ,  

Учтем, спасибо за отзыв :)

Мне кажется это дело привычки и вкуса. Я играл в пару игр без джойстиков, очень напрягала система отсутствия видимых контроллеров управления, создавалось ощущение того, что нажму не туда, или нажатие не приведет к желаемому эффекту. В общем это субъективный вопрос, вполне адекватным решением проблемы в данном случае будет добавление возможности выбора желаемого способа управления.