Pull to refresh

Борьба с 2D-физикой в Unity на примере бесконечной игры

Reading time 9 min
Views 83K


Мой странный творческий путь занес меня в разработку игр. Благодаря отличной студенческой программе от IT-компании, название которой СостоИт из одной Греческой МАленькой буквы, сотрудничающей с нашим университетом, удалось собрать команду, родить документацию и наладить Agile разработку игры под присмотром высококлассного QA-инженера (здравствуйте, Анна!)

Без особо долгих размышлений, в качестве движка был выбран Unity. Это замечательный движок, на котором действительно быстро и легко можно сделать очень плохую игру, в которую, в здравом уме, никто и никогда не будет играть. Чтобы создать хорошую игру, все же придется перелопатить документацию, вникнуть в некоторые особенности и набраться опыта разработки.

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

Игра


Гифка с игрой

Пару слов о том, как она сделана.
Сделана с помощью Blender и пары скриптов на питоне. На время съемки, в углу экрана находились 16 квадратиков, цвет которых кодировал 32 бита числа с плавающей запятой — вращение телефона в данный момент времени. R, G — данные, B — четность. 0 — 0, 255 — 1. Снятое на компьютере видео разбивалось на кадры с помощью ffmpeg, каждому кадру рендера в соответствие ставился расшифрованный угол. Такой формат позволил пережить любое сжатие в процессе съемки и поборол тот факт, что все программы имеют несколько разные представления о течении времени. В реальности игра играется так же как и на рендере.

Самолетик летит по бесконечной и непредсказуемой пещере, в которой есть бонусы, всякие монетки и враги, в которых можно стрелять самонаводящимися ракетами. Врезался в стену — сразу проиграл.
Отличительная особенность игры в том, что уровень прибит к горизонту и управление в ней гироскопическое, причем, абсолютное. Наклонил телефон на 45 градусов — самолетик полетел под углом 45 градусов. Нужно сделать мертвую петлю — придется крутить планшет. Никакой чувствительности нет, только хардкор.
Выделим две основные и очевидные проблемы для разработчика:

Проблема 1: Бесконечность

Unity хранит и обрабатывает координаты объектов в виде обычных 32-битных float, имеющих точность где-то до 6 знака после запятой. Проблема в том, что игра у нас бесконечная и, если мы достаточно долго будем лететь, начнутся различного рода безумные баги, вплоть до телепортации сквозь стены. Есть несколько подходов к решению этой проблемы:

  1. Игнорирование. В Minecraft, например, ошибки округления лишь сделали игру интереснее, породив феномен «Далеких Земель».
  2. Телепортация в (0;0;0) при слишком сильном удалении самолетика от начала координат.
  3. Смена точки отсчета. Движется не самолет, а уровень вокруг него.

В нашем случае, единственный допустимый вариант — третий, который и был реализован. О реализации — чуть позже.
Первый — игнорирование — абсолютно недопустим. Создание робота, который сможет вечно играть в нашу игру — интересная (и весьма простая) задача, которую кто-нибудь решит. Да и обычных корейских игроков недооценивать не стоит — самолетик быстрый, уровень генерируется непредсказуемо. И если до прохождений сквозь стены лететь и лететь, то куда более точная стрельба начнет очевидно подглючивать уже через 5 минут полета.
Второй — телепортация игрока и всего мира — ставит мобильные устройства на колени, в некоторых случаях — где-то на полсекунды. Это очень заметно, а потому — недопустимо. Но это вполне приемлемый вариант для простеньких бесконечных игр для ПК.

Проблема 2: Генерация уровня



Есть несколько основных подходов к строительству endless runner'ов:

  1. Использование готовых сегментов уровня, которые стыкуются случайным образом. Так сделано, например, в Subway Surfers. Это просто реализовать, но игрок к этому быстро привыкает и знает, к чему готовиться, что скучно.
  2. Уровень — просто прямая, на которой случайным образом расставляются препятствия. Так сделано в Joypack Joyride и Temple Run. В нашем случае, это сильно ограничило бы количество маневров.
  3. Все генерируется случайным образом. Самый сложный, непредсказуемый и интересный для игрока вариант.

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

Структура уровня



Летим мы в пещере, она имеет пол и потолок — пару блоков, элементарных строительных единиц. Блоки объединяются в сегменты, которые бесшовно стыкуются друг с другом. Сегменты, как единое целое, вращаются вокруг самолета и двигаются по его вектору скорости, создавая иллюзию полета. Если сегмент выходит из поля зрения камеры — он очищается от блоков, пристыковывается к последнему сегменту уровня и заполняется новыми блоками, согласно указаниям генератора. Совокупность таких сегментов — и есть уровень.

Опытные Unity-разработчики могли вполне оправданно поморщиться, прикинув объем работ и все возможные подводные камни. Но на словах все просто, а опыта разработки у меня не было…

Основные Законы Физики в Unity


За месяц разработки, экспериментов и чтения документации, я выделил три основных закона физики в Unity. Их можно нарушать, но плата за нарушение — производительность. Движок никак не будет предупреждать вас о допущенной ошибке, а без профайлера вы можете никогда о них и не узнать. Несоблюдение этих законов может замедлить вашу игру в десятки раз. Как я понял, нарушение любого закона приводит к тому, что физический движок помечает коллайдер-нарушитель как некорректный и пересоздает его на объекте, с последующим пересчетом физики:

1. Коллайдеры не должны двигаться, вращаться, включаться\выключаться и менять размер.

Как только вы добавили коллайдер на объект — забудьте про какое-либо воздействие на него или объекты, в которых он содержится. Обычный коллайдер — исключительно статический объект. Дерево, например, может быть с одним коллайдером. Если дерево может упасть на игрока — дерево будет падать вместе с производительностью. Если это дерево растет из волшебного питательного облака, которое коллайдера не имеет, но может перемещаться — это будет сопровождаться падением производительности.

2. Если объект движется или вращается — он должен быть твердым телом т.е. иметь компонент Rigidbody.

Про это написано в документации, да. Которую не обязательно вдумчиво читать, чтобы начать делать игру, потому Unity очень прост и интуитивно понятен.
Rigidbody меняют отношение физического движка к объекту. На него начинают воздействовать внешние силы, он может иметь линейную и угловую скорости, а самое главное — твердое тело может двигаться и вращаться средствами физического движка, не вызывая полный пересчет физики.
Существует два типа твердых тел — обычные и кинематические. Обычные тела взаимодействуют друг с другом и обычными коллайдерами — одно тело не может пройти сквозь другое. Кинематические тела следуют упрощенным правилам симуляции — на них не воздействуют никакие внешние силы, гравитация — в том числе. Они свободно могут проходить друг через друга и коллайдеры, а вот обычные твердые тела они отталкивают, как будто имея бесконечную массу.
Если объекты не жалко отдать под контроль физического движка — используйте обычные твердые тела. Например, если вам нужно красиво скатить камни со скалы. Если ваши скрипты или аниматоры управляют объектом напрямую — используйте кинематические тела, так вам не придется постоянно бороться с движком и случайными столкновениями объектов. Например, если у вас анимированный персонаж или управляемая ракета, взрывающаяся при контакте с чем-то.

3. Если объект является твердым телом — двигаться и вращаться он должен через методы твердого тела.

Забудьте про прямое обращение к Transform'у объекта сразу же после добавления к нему коллайдера. Отныне и навсегда, Transform — ваш враг и убийца производительности. Перед тем как написать transform.position =… или transform.eulerAngles = ..., произнесите фразу «я сейчас абсолютно четко понимаю, что делаю, меня устраивают те тормоза, которые будут вызваны этой строкой». Не забывайте про иерархические связи: если вы, вдруг, сдвинете объект, содержащий твердые тела — произойдет пересчет физики.

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

— Самый высокий и, следовательно, естественный, уровень — через силы. Это методы AddForce и AddTorque. Физический движок учтет массу тела и правильно посчитает результирующую скорость. Все взаимодействия тел происходят на этом уровне.
— Средний уровень — изменение скоростей. Это свойства velocity и angularVelocity. На их основе вычисляются силы, влияющие на тела при их взаимодействии, а также, очевидно, их положения в следующий момент времени. Если у твердого тела очень маленькая скорость — оно «засыпает», для экономии ресурсов.
— Самый низкий уровень — непосредственно координаты объекта и его ориентация в пространстве. Это методы MovePosition и MoveRotation. На следующей итерации вычисления физики (это важно, поскольку каждый последующий вызов метода в рамках одного кадра заменяет вызов предыдущего) они выполняют телепортацию объекта в новое положение, после которой он живет как раньше. В нашей игре используется именно этот уровень, и только он, потому что он предоставляет полный контроль над объектом.

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

Есть закон, не влияющий на производительность, но влияющий на работоспособность: твердое тело не может быть частью твердого тела. Родительский объект будет доминировать, поэтому ребенок будет или стоять на месте относительно родителя, или вести себя непредсказуемо и неправильно.

Есть еще одна особенность Unity, не относящаяся к физике, но достойная упоминания: динамическое создание и удаление объектов через методы Instantiate/Destroy — БЕЗУМНО медленный процесс. Я боюсь себе даже представить, что там происходит под капотом во время создания объекта. Если вам нужно создавать и удалять что-то динамически — используйте фабрики и заправляйте их нужными объектами во время загрузки игры. Instantiate должен вызываться в крайнем случае — если у фабрики вдруг закончились свободные объекты, а про Destroy забудьте навсегда — все созданное должно использоваться повторно.

Применение законов на практике


(в этом разделе находится ход рассуждений при создании игры и ее особенности)



Уровень, очевидно, должен вращаться и двигаться.
Облегчим себе жизнь навечно, разместив ось вращения уровня — самолетик — в начале координат. Теперь мы сможем вычислять расстояние от точки до него, вычисляя длину вектора координат точки. Мелочь, а приятно.
Совместное движение объектов легко реализуется через иерархию объектов в Unity, потому что дети являются частью родителя. Например, описанная структура уровня логично реализуется следующим образом:
- Ось вращения
- - \ Уровень
- - - \ Сегмент 1
- - - - \ Блок 1 (Collider)
- - - - \ ...
- - - - \ Блок N
- - - \ Сегмент 2 ...
- - - \ Сегмент 3 ...
- - - \ Сегмент 4 ...
(Можно даже обойтись без объекта уровня)

Скрипт на оси получает данные с гироскопа и выставляет ей соответствующий угол… И нарушает сразу множество правил, потому что вращение передастся по иерархии на коллайдеры, что сведет физический движок с ума. Придется делать ось твердым телом и вращать ее через соответствующий метод. Но что с движением уровня? Очевидно, что ось вращения и объект уровня перемещаться не будут, каждый сегмент нужно двигать персонально, иначе мы сталкиваемся с проблемой бесконечности. Значит, твердыми телами должны быть сегменты. Но у нас уже есть твердое тело выше в иерархии и твердое тело не может быть частью твердого тела. Логичная и элегантная иерархия не подходит, все придется делать руками — и вращение, и перемещение, без использования объекта для оси вращения. Будьте готовы к такому, если у вас уникальные геймплейные фичи.

Если двигать непосредственно сегменты пришлось бы и так, то вращать их придется вынужденно. Основная сложность в том, что в физическом движке Unity нет метода «вращать объект вокруг произвольной точки» (он есть у Transform, но не искушайтесь). Есть только «вращать вокруг своего центра». Это логично, потому что вращение вокруг произвольной оси — одновременно и вращение, и движение, а это две разные операции. Но его можно имитировать. Сначала вращаем сегмент вокруг своей оси, потом вращаем координаты «своей оси» вокруг самолета. Благодаря тому, что самолет у нас в начале координат, не придется вспоминать даже школьную геометрию и лезть в википедию, в Unity уже все есть. Достаточно перевести угол поворота в кватернион и умножить его на координаты точки. Кстати, узнал я об этом прямо во время написания статьи, до этого использовалась матрица поворота.

У нас есть враги, которые отталкивают самолет в стену, надеясь убить. Есть щит, который отталкивает самолет от стен, помогая выжить. Реализовано это тривиально — есть вектор смещения, который каждый кадр прибавляется к координатам каждого сегмента и сбрасывается после этого. Любой желающий пнуть самолетик, через специальный метод, может оставить вектор своего пинка, который прибавится к этому вектору смещения.

В конечном итоге, настоящие координаты сегмента, каждый кадр, вычисляются центром управления движением уровня как-то так:
Vector3 position = segment.CachedRigidbody.position;
Vector3 deltaPos = Time.deltaTime * Vector3.left * settings.Speed;
segment.truePosition = Quaternion.Euler( 0, 0, deltaAngle ) * ( position + deltaPos + movementOffset );

После всех вычислений и костылей, необходимых для работы точной стыковки сегментов при регенерации, segment.truePosition отправляется в метод MovePosition твердого тела сегмента.

Выводы


Насколько все это быстро работает? На старых флагманах — Nexus 5 и LG G2 — игра летает на 60 FPS, с еле заметной просадкой во время включения новых коллайдеров во время генерации сегмента (это неизбежно и никак не обходится) и выдвигания червяков из земли (можно нагородить какой-то ад, чтобы это обойти, но сейчас там осознанное нарушение третьего закона). 40 стабильных FPS выдает любое устройство с гироскопом, которое нам попадалось. Без знания и учета всех законов, производительность была, мягко сказать, неудовлетворительной и телефоны перегревались. Настолько, что я думал написать свой простенький специализированный движок для 2д-физики. К счастью, физика в Unity оказалось достаточно гибкой, чтобы все проблемы можно было обойти и создать уникальную игру, достаточно было лишь пары недель экспериментов.

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

Читайте документацию и экспериментируйте, даже если пользуетесь простыми и интуитивно понятными инструментами.
Tags:
Hubs:
+42
Comments 32
Comments Comments 32

Articles