Заикание в ритм-играх - симптомы и лечение на примере Lofi Ping Pong
Привет, Gamin. Меня зовут Коля и я хочу рассказать тебе о проблеме, с который ты можешь столкнуться при разработке ритм-игры (и с которой точно столкнешься, если разработка будет под мобильные устройства). Ну и о её решении тоже поведаю, конечно.
В марте 2019 я выпустил в Steam свою первую ритм-игру Lofi Ping Pong. Это настольный теннис, в котором мяч надо отбивать в такт треку. Летом 2020 мне захотелось отдохнуть от разработки второй музыкальной поделки, так что решил портировать Пинг понг на мобилки и Switch. Тут-то и появились сложности — мяч вдруг начал лететь отрывисто, «заикаться». Чтобы вкратце разобраться с недугом, требуется вступление.
Особенность музыкальных игр
Не буду рассказывать про архитектуру ритм-игр в целом (собираюсь сделать это после выпуска второго проекта), но пояснить за основную идею я обязан.
В большинстве игр обязательно есть какое-либо движение — будь то носящийся по всей карте Meat Boy или падающий с потолка ящик в Portal. Любое перемещение становится плавным, когда мы добавляем один волшебный ингредиент — Delta Time, то есть время между фреймами. С помощью него, как известно, мы перестаем зависеть от выдаваемого фпс, что делает, к примеру, скорость персонажа всегда одинаковой.
И тут тебе приходит сумасшедшая мысль сделать игру, в которой что-то происходит в такт музыке. Ты набросал на бумаге идею, лезешь в любимый движок, чтобы воплощать её в жизнь. К примеру, захотелось сделать настольный теннис, в котором мяч отбиваешь в такт треку, как метроном. Что может быть проще?
У нас есть расстояние между началом и концом полета мяча S. Чтобы рассчитать время полёта T, требуется знать скорость песни — её BPM (beats per minute). Это количество ударов (долей) в минуту, как если бы вы отстукивали темп песни ладошкой по коленке и записали количество шлепков за 60 секунд. Количество чего-либо в единицу времени есть частота, значит, чтобы найти время одного удара (период), достаточно перевернуть её с ног на голову, не забыв перевести в секунды, умножив на 60 (T = 60 / BPM).
В школе вроде учили, что скорость V = S / T. Не забудем добавить в формулу наш любимый delta time, и готово!
void Start ()
{
distance = endPoint — startPoint;
}
void MoveBall ()
{
float timeBetweenBeats = 60 / bpm;
Vector3 velocity = distance / timeBetweenBeats;
transform.position += velocity * Time.deltaTime;
}
Как только мяч долетает до нужной позиции, мы нажимаем кнопку, меняя end point и start point местам, и движение продолжается, но в обратную сторону. И всё идёт прекрасно, пока ты так не поиграешь 10, 30, 60 секунд. После этого начнётся сильнейший рассинхрон между играющей песней и скачущим мячом — каждый новый удар будет всё дальше удаляться от реального бита песни.
Проблема в том, что ты не следишь за настоящей позицией трека. Да, мы включили в формулу его скорость, но этого недостаточно. Правильным методом окажется перемещение мяча с помощью интерполяции по положению музыки (точнее даже будет сказать «экстраполяции»).
Позиция трека. Все переменные будут ниже в коде.
Тебе надо следить за позицией трека и считать, как много времени прошло с предыдущего бита. Время между битами — как расстояние между start point и end point. Delta between beats будет отображать позицию нашего мяча. Но это довольно странно ставить знак равно между временем (delta between beats) и координатой (позиция мяча). Понятнее будет перевести всё в доли- поделив delta between beats на time between beats мы получим процент между соседними битами. Этот процент будет таким же у мяча между начальной и конечной позицией.
Ниже аналогия в игре.
void MoveBall ()
{
float trackPosition = audioSource.time;
float timeBetweenBeats = 60 / bpm;
float deltaBetweenBeats = trackPosition — timeBetweenBeats * lastBeat;
float percent = deltaBetweenBeats / timeBetweenBeats;
transform.position = startPoint + distance * percent;
}
Весь этот блок был написан, чтобы показать зачем и как использовать в качестве двигателя мяча именно сам трек, а не просто его скоростную характеристику. Это и есть то самое ядро, на котором строится ритм игра. Помимо этого, как и в других играх, есть куча нюансов, типа начального оффсета у песни или как учесть визуальный/аудио лаг у игрока в перемещении мяча. Самое главное, мы поняли, что Delta Time нам не нужен.
Проявление заиканий и решение
Разобравшись с главным концептом, ты делаешь основную игровую петлю, тестируешь на ПК, все идёт прекрасно. До того момента, как ты решишь запустить игру на мобильном устройстве.
Тут наступает ужасное — мяч летит отрывисто, заикается, как будто игра идёт в 15 фпс. Ты профайлишь игру, но все показатели в норме, да и остальные элементы игры, не зависящие от хода музыки, ведут себя адекватно. Может, мы рано решили избавиться от Delta time?
Ты начинаешь дебажить позицию песни каждый кадр — и что же ты видишь! Оказывается, позиция трека не обновляется покадрово, а скачет, как ей вздумается! Вместо того, чтобы в окне дебага видеть «0, 16, 33, 49, 65, 80…» (мс), показывается вот это «0, 0, 0, 48, 48, 65, 65, 65…». Аудиодвижок просто-напросто живёт своей жизнью и отказывается подчиняться обновлению каждый кадр (те кто работают в Гамаке знают, что если во время теста игры она у вас крашнется, то аудио продолжит работать в отрыве от картинки).
ПК, как известно, платформа помощнее, чем мобильные устройства, и эти фризы там не так заметны (хотя они есть, если знаешь, с чем сравнивать).
Что ж, значит нам придётся вручную «догонять» позицию трека, чтобы она плавно переходила от одного значения к следующему. Плавно… где-то я это слышал… delta time! Почему бы здесь нам не использовать нашего старого друга, ведь всё же мы будем увеличивать позицию искусственным путём.
Но есть куча неправильных и один правильный метод, как это сделать. Оба метода я попробовал на уже выпущенной игре, так что смогу показать примеры работоспособности прямо от игроков.
Пример первый, неправильный. Давайте введём новую переменную для отслеживания позиции трека в предыдущем фрейма lastFrameTrackPosition. Тогда мы каждый кадр можем сравнивать нынешнюю позицию песни и её позицию на предыдущем кадре. Если они совпадают, значит положение песни «не прибавилось», и мы сделаем это сами. Если позиция трека так долго не обновлялась, что lastFrameTrackPositon убежала вперед, то мы сами её увеличим.
private float FixTrackPosition ()
{
float trackPosition = audioSource.time;
if (trackPosition == lastFrameTrackPosition)
{
trackPosition += Time.deltaTime;
}
else if (trackPosition < lastFrameTrackPosition)
{
float delt = lastFrameTrackPosition — trackPosition;
trackPosition = lastFrameTrackPosition + delt;
}
lastFrameTrackPosition = trackPosition;
return trackPosition;
}
void MoveBall ()
{
float trackPosition = FixTrackPosition ();
float timeBetweenBeats = 60 / bpm;
float deltaBetweenBeats = trackPosition — timeBetweenBeats * lastBeat;
float percent = deltaBetweenBeats / timeBetweenBeats;
transform.position = startPoint + distance * percent;
}
Этот метод будет работать уже лучше, но всё ещё не идеально, а самое главное — будут случаться непредвиденные действия со стороны мяча. Например, он может развить огромную скорость и улететь за пределы уровня.
Можно вновь обвинить Delta time и сказать, что дело в нём, но это не так. Точнее, мы просто слегка неправильно его используем.
Давайте оставим переменную lastFrameTrackPosition и введём ещё одну — trackPositionContainer, которая поможет нам не изменять позицию трека напрямую через прибавку delta time, но с помощью постепенного приближения (известного как easing). Мы опять начнём со сравнения положения песни в текущий и предыдущий кадр. Делаем только одно сравнение — не равны ли они, и если они и правда отличаются, то мы приблизим значение trackPositionContainer к позиции трека с помощью среднего арифметического. И возвращать в качестве позиции песни для MoveBall () мы будем именно приблИженное значение контейнера, но не самой рваной позиции трека.
private float FixTrackPosition ()
{
float trackPosition = audioSource.time;
if (trackPosition != lastFrameTrackPosition)
{
trackPositionContainer = (trackPositionContainer + trackPosition) / 2f;
lastFrameTrackPosition = trackPosition;
}
float trackPositionToReturn = trackPositionContainer;
trackPositionContainer += Time.deltaTime;
return trackPositionToReturn;
}
void MoveBall ()
{
float trackPosition = FixTrackPosition ();
float timeBetweenBeats = 60 / bpm;
float deltaBetweenBeats = trackPosition — timeBetweenBeats * lastBeat;
float percent = deltaBetweenBeats / timeBetweenBeats;
transform.position = startPoint + distance * percent;
}
Сравнение с фиксами и без: https://youtu.be/FImUjpqzqmk
На видео разницы между 1 м и 2 м почти не видно, там дело больше в багах с вылетом мяча.
Теперь, наконец, гештальт закрыт. Я перерыл старый код, заново переписал игру, сделал порты на мобилки и сегодня выходит последний порт на Nintendo Switch. Надеюсь, было полезно и хоть немного интересно, ребятки. Оставлю ссылки на всевозможные сторы, если захотите посмотреть. Хоть выдохнуть могу!
- 09 декабря 2020, 01:12
- 015
Вроде на Гамасутре была статейка про ритм-игры, там чет похожее было. Но да, вопрос интересный. Как-то сам на джем пробовал пилить ритм-игру. Основное, что понял - что привязка должна идти к текущему времени трека, а не просто к времени.
столкнулся с побной проблемой когда щас типо катсцену делал... Именно так и решил =)
открыл свой старый проект со светомузыкой, там какая-то страшная магия, но в общем я привязывал ритм к децибелам, считал по ним бит. А еще AudioSettings.dspTime, и вот м.б. полезно будет
Красавчик! Пиши еще на гамин
А не проще один раз пройтись по сырому аудио-файлу, составить массив временных отрезков, через который надо отбивать мяч и ориентироваться только по нему (а не по звуковому файлу), используя deltaTime? Чтобы не зависеть от реализации воспроизведения музыки на разных устройствах.
Или во время игры получается так что трек воспроизводится каждый раз с разной, плавающей, скоростью?
Если ты имеешь в виду опираться на системный таймер, а не на аудиодвижок, то ты прав. Если нет, то проблема была не в том.
Да, самому засечь время между нужными точками и самому считать таймер, а не полагаться на текущее время аудиодорожки.
невыполнимо для подгружаемых треков. куча ненужной ручной работы для заготовленных. особенно с учетом того, что темп трека часто намеренно плывет
можно продолжать анализировать звуковую дорожку во время прогрузки и дополнять массив таймеров прямо во время игры
Можно и таймер ускорять/замедлять.
Интересно было, но в общем случае это не так:
В случае добавления "волшебного" дельта-тайма, движение плавным не становится, мы просто его ускоряем там где оно тормозит, взамен чего лишаем игрока возможности управлять чем-либо в игре в те кадры, которые графический движок не успел отрендерить. Хотя, казалось бы, насколько надо раздуть игру об одном музыкальном файле, двух ракетках и одном мячике, чтобы она тормозила на железе текущих мобилок, которое сопоставимо с железом домашних компьютеров 2000-2010 годов, которые как минимум в несколько десятков раз сильнее железа, действительно необходимого для вывода наблюдаемой картинки.
это "управлять чем-либо в игре в те кадры, которые графический движок не успел отрендерить" и это "в несколько десятков раз сильнее железа, действительно необходимого для вывода наблюдаемой картинки" не взаимоисключающие вещи?
потом, не лишаем управления, а выполняем команду с учетом пропущенного количества кадров. что вместо замедления игры аж на 10% и критичного рассогласования всех таймингов при незаметном для глаза падении FPS с 60 до 54, гарантирует ровный геймплей и позволяет поддерживать одну и ту же скорость игры независимо от производительности
Безусловно да. Я намекаю что нечего брать тормозные движки для простых задач. Телескоп и гвозди.
Какую команду, если мы её не словили?
Почему это вообще должно происходить в такой простой игре? Это же не 3D MMO экшен, ну.
> Почему это вообще должно происходить в такой простой игре? Это же не 3D MMO экшен, ну.
обновление запустилось или трансляция стрима идет.
Для стримов нужно брать железо сильнее, это по-моему хорошо известно. Обновлять во время игры - зачем?
А на смартфоне и игровой консоли это вообще не аргумент.
это же может произойти по миллиону независящих от игры причин, ну. А если не происходит, то разницы между дельта и тайм.тайм нет. Когда случится, то дельта станет гарантией корректного расчета
"Какую команду, если мы её не словили"
чой-то? команда управления уже словлена и выполнится в первом кадре с поправкой на задержку. А когда для ловли всякого критического, столкновений, нпрмр, не использован фикседтайм или рейкасты - это проблема, но не дельты и не движка. Рейкаст при этом вообще малополезен без дельтатайма.
Только при чем тут возможность игрока управлять - она к таким событиям не относится. А управление без дельты ловит все косяки скачущего фпс. Дай реальный пример
"тормозные движки"
без замеров такое се утверждение
Тормозить на отрисовке пяти (5, больше чем 4 и меньше чем 6) пиксель-с-кулак спрайтов - это в любом раскладе несерьёзно. Я имею в виду две фигурки, мячик, фон и слой спецэффектов. А, всё таки шесть, там же море ещё? Ну тогда конечно ладно. Шесть это много. Не то что пять.
Дело не в игре, а в мобилках (и скорее всего в движке). Если создать простейший прототип с квадратом, бегающим от точки до точки по позиции песни, то этот квадрат всё равно не будет плавно двигаться без особой магии. Проверял на Xiaomi Mi A1, iPhone 6 и iPhone 7+. Самое интересное, что на андроиде квадрат летел намного более гладко, чем на более айфонах. То есть дело в том, как их внутреннее устройство пережёвывает аудиофайл.
Не знаю, будет ли такая проблема, если попробовать сделать такой прототип на самописном движке, а не на Unity и иже с ним. Таким я не занимался.
Самописном это если уж совсем что-то нагруженное и специфическое. Юнити - совсем другой движок чем ГМ, например.
И...
Щас я вот чего-то не понял.
Игра сделана на ГМе, вроде как участвует в распродаже Made with Game Maker.
В посте код на C#.
???
Steam версия игры была написана на Гамаке. Для порта я переписал на Unity по 2ум причинам: я хотел потренироваться (на тот момент я изучал Unity только год), и я хотел переделать с нуля интерфейс, чтобы он мог работать и в горизонтальном и в вертикальном режиме (в гамаке такое делать было бы очень больно). Хотя все равно считаю Гейммейкер отличным движком.
В трейлере опечатка - "procceed" пишется с одним "c". Шутка про сплит-скрин зашла, жизненно.