Заикание в ритм-играх - симптомы и лечение на примере Lofi Ping Pong

Привет, Gamin. Меня зовут Коля и я хочу рассказать тебе о проблеме, с который ты можешь столкнуться при разработке ритм-игры (и с которой точно столкнешься, если разработка будет под мобильные устройства). Ну и о её решении тоже поведаю, конечно.

В марте 2019 я выпустил в Steam свою первую ритм-игру Lofi Ping Pong. Это настольный теннис, в котором мяч надо отбивать в такт треку. Летом 2020 мне захотелось отдохнуть от разработки второй музыкальной поделки, так что решил портировать Пинг понг на мобилки и Switch. Тут-то и появились сложности — мяч вдруг начал лететь отрывисто, «заикаться». Чтобы вкратце разобраться с недугом, требуется вступление.

YT видео геймплея

_gameplay3.png

Особенность музыкальных игр

Не буду рассказывать про архитектуру ритм-игр в целом (собираюсь сделать это после выпуска второго проекта), но пояснить за основную идею я обязан.
В большинстве игр обязательно есть какое-либо движение — будь то носящийся по всей карте Meat Boy или падающий с потолка ящик в Portal. Любое перемещение становится плавным, когда мы добавляем один волшебный ингредиент — Delta Time, то есть время между фреймами. С помощью него, как известно, мы перестаем зависеть от выдаваемого фпс, что делает, к примеру, скорость персонажа всегда одинаковой.

DhbHkSZ

И тут тебе приходит сумасшедшая мысль сделать игру, в которой что-то происходит в такт музыке. Ты набросал на бумаге идею, лезешь в любимый движок, чтобы воплощать её в жизнь. К примеру, захотелось сделать настольный теннис, в котором мяч отбиваешь в такт треку, как метроном. Что может быть проще?
У нас есть расстояние между началом и концом полета мяча S. Чтобы рассчитать время полёта T, требуется знать скорость песни — её BPM (beats per minute). Это количество ударов (долей) в минуту, как если бы вы отстукивали темп песни ладошкой по коленке и записали количество шлепков за 60 секунд. Количество чего-либо в единицу времени есть частота, значит, чтобы найти время одного удара (период), достаточно перевернуть её с ног на голову, не забыв перевести в секунды, умножив на 60 (T = 60 / BPM).
В школе вроде учили, что скорость V = S / T. Не забудем добавить в формулу наш любимый delta time, и готово!

P6lqXeC

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 секунд. После этого начнётся сильнейший рассинхрон между играющей песней и скачущим мячом — каждый новый удар будет всё дальше удаляться от реального бита песни.
Проблема в том, что ты не следишь за настоящей позицией трека. Да, мы включили в формулу его скорость, но этого недостаточно. Правильным методом окажется перемещение мяча с помощью интерполяции по положению музыки (точнее даже будет сказать «экстраполяции»).
Позиция трека. Все переменные будут ниже в коде.

NyM7IQV

Тебе надо следить за позицией трека и считать, как много времени прошло с предыдущего бита. Время между битами — как расстояние между start point и end point. Delta between beats будет отображать позицию нашего мяча. Но это довольно странно ставить знак равно между временем (delta between beats) и координатой (позиция мяча). Понятнее будет перевести всё в доли- поделив delta between beats на time between beats мы получим процент между соседними битами. Этот процент будет таким же у мяча между начальной и конечной позицией.
Ниже аналогия в игре.

0NsqPJD

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 нам не нужен.

7e6aDUG

Проявление заиканий и решение

Разобравшись с главным концептом, ты делаешь основную игровую петлю, тестируешь на ПК, все идёт прекрасно. До того момента, как ты решишь запустить игру на мобильном устройстве.
Тут наступает ужасное — мяч летит отрывисто, заикается, как будто игра идёт в 15 фпс. Ты профайлишь игру, но все показатели в норме, да и остальные элементы игры, не зависящие от хода музыки, ведут себя адекватно. Может, мы рано решили избавиться от Delta time?

SbWdyz1

Ты начинаешь дебажить позицию песни каждый кадр — и что же ты видишь! Оказывается, позиция трека не обновляется покадрово, а скачет, как ей вздумается! Вместо того, чтобы в окне дебага видеть «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. Надеюсь, было полезно и хоть немного интересно, ребятки. Оставлю ссылки на всевозможные сторы, если захотите посмотреть. Хоть выдохнуть могу!

Nintendo Switch

Steam

iOS

Android