22 января 2015 в 20:05

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



Мой странный творческий путь занес меня в разработку игр. Благодаря отличной студенческой программе от 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, вы сможете быстро склонировать нашу игру, разрушив мечты, жизни и веру трех бедных студентов в человечество. Я надеюсь, эта статья сэкономит вам много времени в будущем и поможет найти не совсем очевидные нарушения законов производительной физики в своих проектах.

Читайте документацию и экспериментируйте, даже если пользуетесь простыми и интуитивно понятными инструментами.
Константин @HurrTheDurr
карма
62,0
рейтинг 0,2
Похожие публикации
Самое читаемое Разработка

Комментарии (32)

  • +2
    Не совсем понял, зачем поворачивать весь уровень?
    Можно ведь на самом деле двигать игрока и камеру вместе с ним.
    А про физику, Transform и производительность — спасибо, этот момент обычно как-то замалчивается.
    • 0
      В статье про это написано. Игра бесконечна и координаты объектов, в определенный момент, могут начать обрубаться из-за того, что хранятся в float, что приведет к ужасающим багам. Как вариант — можно периодически телепортировать игрока обратно в (0;0;0), но это, опять же, приводит к лагам, в статье про это тоже написано :)
      • +1
        Если я правильно понял, предлагается уровень только двигать, а игрока и камеру только вращать.
        • 0
          Да, можно, как вариант. Почему-то не додумался до такого, спасибо. Возможно, так будет даже производительнее, возможно — нет, уже не имею профайлера, чтобы сравнить.
          Но не суть важно, как это сделано конкретно в нашей игре, она в статье использована лишь как пример соблюдения и осознанного несоблюдения основных законов. И полное вращение уровня — хорошая демонстрация гибкости физики в Unity на низком уровне.
  • НЛО прилетело и опубликовало эту надпись здесь
    • 0
      С движением вперед и прыжком все правильно, а вот повороты нужно делать, временно отклоняя вектор скорости.
      • НЛО прилетело и опубликовало эту надпись здесь
        • 0
          Я бы сделал повороты изменением вектора скорости. Но можно придумать много других вариантов, это зависит от того, как вы себе представляете повороты в вашей игре. Идеального общего решения нет.
    • 0
      Для этих целей гораздо лучше подойдет CharachterController с его методом Move()
  • +2
    А как игра-то называется?
    • 0
      Пока есть только рабочее название, мы еще не релизились. Сейчас ищем издателя.
  • +2
    Хинты про производительность были особенны полезны.
    Это как-раз тот опыт который экономит время.
    Спасибо :)
  • 0
    Я верно понял, что все актёры сцены должны генерироваться «в чулане» (далеко от сцены) сразу при загрузке уровня и телепортироваться на нужное место уровня во время игры по мере надобности?
    • +2
      Да, автор рекомендует использовать фабрику, или точнее пул объектов (Pool Objects).
    • 0
      Да. Даже не обязательно далеко от сцены, объекты могут находиться выключенными (gameObject.SetActive(false)) и не потреблять ресурсов где угодно.
  • 0
    Я вот тоже делаю свой первый endless runner (не на Unity) и разбираюсь с такими же вопросами. Тоже изначально сделал движение уровня относительно игрока, но вижу, что производительности не хватает даже на моем Nexus 5. Теперь переделываю на движение игрока (и камеры вместе с ним) по генерируемым перед камерой участкам уровня. То есть, случайным образом, берется один из вариантов бэкграунда (которые бесшовно в друг друга переходят), на нем, опять же случайно, расставляются препятствия, а после прохождения игрока сегмент очищается и возвращается в пул для повторного использования. Думаю, размерность float значительно больше расстояния, которое игрок будет способен пройти даже для самой длинной игровой сессии. Ну а после рестарта уровня, телепортируемся в ноль-ноль, понятно.
    • 0
      Если правильно и со знанием законов сделать — тормозить не будет, 100%. А вот про float — не факт, есть корейцы и роботы… в остальном подход правильный.
  • 0
    Кинематические тела следуют упрощенным правилам симуляции — на них не воздействуют никакие внешние силы, гравитация — в том числе. Они свободно могут проходить через что угодно.

    Кинематические тела не должны свободно проходить через что угодно. По крайней мере, все именно так в Box2D, который используется в Unity. Возможно, вы что-то напутали в настройках слоев?
    • 0
      Да, спасибо, неправильно выразился, сейчас исправлю. Они проходят через друг-друга и обычные коллайдеры, не-кинематические твердые тела они будут расталкивать.
  • 0
    Если использовать isKinematic то можно смело обращаться к transform и не будет необходимости работать через Rigidbody.
    Если вы используете физические расчеты перемещения то применяйте силы в FixedUpdate, а не в Update.
    • 0
      Надеюсь, Unity не сильно обидится на меня за еще один триал…
      Можно, да, но с падением производительности, где-то в два раза. Таков третий закон. Точно такая же картина с не-кинематическими телами.
      Тест на 20 box-коллайдеров (больше лень было создавать). Слева — движение через rigidbody2d.MovePosition, справа — движение через transform.position.



      Использованный для теста код
      void FixedUpdate() {
          float v = Mathf.Sin( Time.timeSinceLevelLoad );
          Vector3 newPosition = stockPos + new Vector3( v, v );
      
          if( byTransform ) {
              cachedTransform.position = newPosition;
          } else {
              cachedRigidbody.MovePosition( newPosition );
          }
      }
      
      • 0
        Что забавно — в 3D-физике, аналогичный код через transform работает быстрее. Разные движки, разные особенности…
  • 0
    слишком сложно, для такой простой игры…
    • +2
      Все игры кажутся простыми, если ты их не делал :(
      Ну и это все же обучающая статья, если разработчик переживет эти проблемы — дальше ему будет проще.
      • +3
        Я вполне адекватно представляю как ее сделать. Я не считаю себя мега крутым разработчиком, но кое что из своей жизни я понял. Чем проще, тем лучше. Я не спорю, всегда что то кажется проще, пока ты не делал, это наш оптимизм ;)
        Но я имел введу, что я бы сделал совсем по другому, и многие вопросы для меня просто не появились бы во время разработки :)
        Тем не менее! Я рад что Вы поделились своим опытом с хабра-сообществом, я вот почему то не осилил и даже не думаю что это будет кому то интересно.
        • 0
          Само собой. Просто если не знать особенностей инструментария — иногда простые решения могут не работать, или работать неожиданно медленно :)
          Как бы вы сделали, если не секрет? С учетом обхода очевидных проблем, описанных в статье, конечно же.
          • +1
            Для начала, я бы не использовал юнити, это лишнее обременение. Cocos2d-x тут бы хватило за глаза. Группировал рендер в батчи и двигал бы мир(он генерируемый, а значит ему существовать целиком не обязательно), вернее пулы кусочков мира. Использовал бы Box2D или нет, сложный вопрос, скорее нет, чем да. Мобильные платформы обременяют на размерность текстур и как мы помним переключение текстур до сих пор тяжелая операция. Поэтому максимум атласов текстурных. Могу с уверенностью сказать что даже самый галимый девайс на андройде весело срендерит батчинг на одной текстуру 1024х1024 нежели несколько 64х64, постоянно переключаясь.

            Если вам интересно то можете найти в эпсторе (плей маркет) игру Выкрутасы. Хоть я там давно и не работаю, а мое имя в создателях потерли после обновления графики(да и кода наверно), я свой код все равно узнаю. Так вот, из за технических ограничений мне пришлось прокидывать видео с камеры через память озу. Это довольно дорогая операция, но я постарался максимально оптимизировать. Эта игра может показаться проще вашей, но как вы говорите «если ты их не делал» ;) хотя ничего сложного, там действительно нет. Больше грабли и сложности связанные с не нативной разработкой и желанием записывать видео игры. (особенно интересно было объединение видео и текста, у меня осталось время даже для этого)
  • 0
    А можно вопросик по поводу gameObject.SetActive? Имеется пул. Что будет лучше(и быстрее), перемещать объект в Infinity координаты и отключать только пользовательские компоненты или делать gameObject.SetActive? Объект имеет сложную иерархию.

    Недавно товарищ столкнулся с тем, что имея на объекте Animator при SetActive терял очень много времени именно на нем. Хотя в том же официальном примере object-pool-а используется SetActive.
    • 0
      Мы используем SetActive и проблем с ним не было. Иерархия блоков и врагов тоже сложная бывает, скриптов куча, отключать каждый компонент — не вариант.
      Аниматоры еще весьма прожорливы в режиме ожидания (значит, лучше их все же отключать) и действительно долго инициализируются. Но я с ними плотно не работал и не могу с уверенностью дать совет. Возможно, стоит их создавать активными, давать им возможность проинициализироваться, а потом выключать?
      У нас в игре аниматор только один на весь UI, все мелкие движения на скриптах.
  • 0
    В документации написано, что если на обьекте есть коллайдер и его нужно двигать, то на нем обязан быть RigidBody — иначе будет серьезное падение в производительности.
    Скорее всего это у вас и было — проверьте профайлером коллайдеры еще раз, но сначала на обьект повесьте RigidBody (Иначе получается глупо — зачем нужны коллайдеры, если обьекты нельзя двигать?)
    • 0
      Да, это у нас и было, и статья именно про это :)
      • 0
        Разве? Прочитав пункт номер 1: Коллайдеры не должны двигаться, вращаться, включаться\выключаться и менять размер. я понял что если на обьекте висит коллайдер, то всё — двигать его уже нельзя :)

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.