[Перевод] Синхронизация ритма в музыкальных играх
Недавно я начал работу в Unity над битбоксовой музыкальной игрой Boots-Cut. В процессе прототипирования базовых механик игры я обнаружил, что довольно сложно правильно синхронизировать ноты с музыкой. В Интернете по этой теме нашлось довольно мало статей. Поэтому в своей статье я постараюсь дать наиболее важные подсказки по разработке музыкальной игры (особенно в Unity).
Выяснилось, что самыми важными являются следующие три аспекта:
- Использование
AudioSettings.dspTime
вместоTime.timeSinceLevelLoad
для отслеживания позиции в песне. - Нужно всегда использовать позицию в песне для обновления движений.
- Не обновляйте ноты в каждом кадре по разнице во времени, интерполируйте их.
Учтём это и приступим к работе!
Основной класс
Необходимо создать класс
SongManager
для отслеживания позиции в песне, создания нот и других функций управления песней.Отслеживание позиции
Во всех музыкальных играх нужно отслеживать позицию в песне, чтобы знать, какую ноту следует создать. Ниже представлены поля, необходимые для отслеживания позиции в песне:
//текущая позиция в песне (в секундах)
float songPosition;
//текущая позиция в песне (в ударах)
float songPosInBeats;
//длительность удара
float secPerBeat;
//сколько времени (в секундах) прошло после начала песни
float dsptimesong;
Мы инициализируем эти поля в функции
Start()
: void Start()
{
//вычисление количества секунд в одном ударе
//объявление bpm выполняется ниже
secPerBeat = 60f / bpm;
//запись времени начала песни
dsptimesong = (float) AudioSettings.dspTime;
//начало песни
GetComponent().Play();
}
Для удобства мы преобразуем
bpm
в secPerBeat
. Позже secPerBeat
будет использоваться для вычисления позиции в песне в ударах, что очень важно для создания нот.Кроме того, мы записываем время начала песни в dsptimesong
. Мы используем AudioSettings.dspTime
вместо Time.timeSinceLevelLoad
, потому что Time.timeSinceLevelLoad
обновляется только в каждом кадре, а AudioSettings.dspTime
обновляется чаще, так как это таймер аудиосистемы. Чтобы сохранять темп песни, нужно использовать таймер аудиосистемы. Таким образом нам удастся избежать задержки, вызванной разницей во времени между обновлениями кадров и обновлениями аудио.
В функции Update()
вычисляется позиция в песне с помощью AudioSettings.dspTime
:
void Update()
{
//вычисление позиции в секундах
songPosition = (float) (AudioSettings.dspTime - dsptimesong);
//вычисление позиции в ударах
songPosInBeats = songPosition / secPerBeat;
}
Мы вычисляем позицию в секундах вычитанием из текущего
AudioSettings.dspTime
времени начала песни (dsptimesong
). Мы получили позицию в секундах, однако в мире музыки ноты записываются в ударах. Поэтому лучше преобразовать позицию в секундах в позицию в ударах. Разделив songPosition
на secPerBeat
(секунда / (секунда/удар)), мы получим позицию в ударах.Посмотрите на рисунок:
Позиция нот в ударах: 1, 2, 2.5, 3, 3.5, 4.5, а длительность удара — 0,5 с. Поэтому, если после начала песни прошло 1,75 с (songPosition == 1.75
), то мы знаем, что находимся в позиции 1.75 (songPosition
) / 0.5 (secPerBeat
) = 3.5 удара, и необходимо создать ноту удара 3.5.
Информация о песне
Перейдём к полям, в которые мы записали информацию о песне:
//количество ударов в минуту
float bpm;
//сохранение всех позиций нот в ударах
float[] notes;
//индекс ноты, которую нужно создать следующей
int nextIndex = 0;
Для простоты я демонстрирую песню только с одной дорожкой нот (в Guitar Hero Mobile сделано три дорожки, а в Taikono Tatsujin — всего одна).
bpm
— это количество ударов в минуту. Как мы видели, для удобства они преобразуются в secPerBeat
.
notes
— это массив, в котором хранятся все позиции нот в ударах. Например, для представленных на рисунке нот массив notes
будет содержать {1f, 2f, 2.5f, 3f, 3.5f, 4.5f}
:
И, наконец, nextIndex
— это целое число, нужное для обхода массива. Оно инициализируется со значением 0, потому что следующая создаваемая нота будет первой нотой песни. При создании ноты счётчик nextIndex
увеличивается на единицу.
Создание нот
Мы определяем, должна ли создаваться нота, в функции
Update()
. Однако сначала нужно определить, сколько ударов будет показываться заранее.Например, для следующей дорожки:
текущая позиция в ударах равна 1, но удар 3 уже создан. Это означает, что заранее показываются 3 удара.
Добавим под songPosInBeats = songPosition / secPerBeat;
, следующие строки:
if (nextIndex < notes.Length && notes[nextIndex] < songPosInBeats + beatsShownInAdvance)
{
Instantiate( /* префаб ноты */ );
//инициализация полей ноты
nextIndex++;
}
Сначала нужно проверить, не осталось ли нот в песне (
nextIndex < notes.Length
). Если ноты ещё остались, то мы проверяем, достигла ли песня удара, при котором должна создаваться следующая нота (notes[nextIndex] < songPosInBeats + beatsShownInAdvance
). Если достигла, создаём ноту и увеличиваем nextIndex
, чтобы отслеживать следующую ноту, которую нужно создать.Движение нот
Наконец, поговорим о том, как перемещать созданные ноты в соответствии с темпом песни. Это довольно просто, если вспомнить пункт «Не обновляйте ноты в каждом кадре по разнице во времени, интерполируйте их».
Всегда обновляйте движение по позиции в песне, потому что:
- Таймер аудиосистемы имеет разницу во времени с таймером кадров
- Удары могут находиться ровно посередине двух кадров (что приводит к разнице во времени)
Итак, как же двигать ноты? Интерполяцией!
Для упрощения я вырежу весь код в классе MusicNote
и оставлю только функцию Update()
, в которой мы двигаем каждую ноту:
//функция обновления нот
void Update()
{
transform.position = Vector2.Lerp(
SpawnPos,
RemovePos,
(BeatsShownInAdvance - (beatOfThisNote - songPosInBeats)) / BeatsShownInAdvance
);
}
На представленной ниже схеме это чётко видно:
Заключение
Я рассказал об основах программирования музыкальной игры. Следуя этим принципам, можно создавать игры с синхронизацией. В играх с несколькими дорожками можно создавать вложенные массивы
notes
, удаление нот выполняется проверкой позиции относительно линии удаления, ноты большой длительности реализуются отслеживанием начального и конечного удара, и т.д.Благодарю за прочтение статьи, надеюсь, она будет полезна. Моя собственная музыкальная игра Boots-Cuts будет готова в следующем году, следите за информацией.
Комментарии (5)
25 марта 2017 в 10:40
0↑
↓
Спасибо за статью, и сразу вопрос) А что если в песне bpm будет меняться?25 марта 2017 в 14:12
0↑
↓
Еще одно.
Делайте в настройках регулируемую задержку между звуком и изображением, в идеале небольшой кнопочкой перед запуском уровня. Т.к. человек в разном состоянии имеет разную скорость реакции «глаз/ухо-мозг-обработка-руки», то при полностью синхронном выводе изображения и звука обязательно получится ощущаемый рассинхрон.26 марта 2017 в 10:22
0↑
↓
Я так и не понял, как из аудиофайла вычислить ноты и bps.
26 марта 2017 в 17:53 (комментарий был изменён)
0↑
↓
Я увы тоже (
Но это лишь перевод…
Сам дошёл лишь до «эквалайзер вид сверху»…26 марта 2017 в 21:01
0↑
↓
Если в кратце, то никак:)В ритм играх (допускаю, что конкретно в Guitar Hero это может быть не точно так, как написал), как правило, есть аудиотрек и так называемый simfile — мета-файл в котором и описаны:
- GAP (OFFSET) — задержка от начала аудиофайла до первой ноты. Авторы часто подгоняют до первой ноты после вступления
- BPM — темп. Что очень важно, его надо считать очень точно, желательно 5–7 цифр после запятой
- BPM Changes — обычно время=новый_темп, время=новый_темп и т.д.
- Delay/Pause — остановки в треке. С нимим есть проблемы точности: паузы вычисляются со своей точностью, которая не совпадает с делением на доли ритма в треке. Их как правило стараются заменить на кратную смену темпа на что-то < 5 и обратно компенсируют. Для таких дел даже скрипт написан
- Массив данных — собственно, в каждый шаг трека какая нота должна быть сыграна
Вот пример для кнопочной ритм-игры: DDR (ITG).
Там 4 кнопки — поэтому 4 символа в слове для состояния. Разумеется есть редактор, вручную набирать не приходиться:Cookie Thumper.sm#TITLE: Cookie Thumper;
#SUBTITLE:;
#ARTIST: Die Antwoord;
#TITLETRANSLIT:;
#SUBTITLETRANSLIT:;
#ARTISTTRANSLIT:;
#GENRE:;
#CREDIT:;
#BANNER:…/banner.png;
#BACKGROUND:…/bg.png;
#LYRICSPATH:;
#CDTITLE:;
#MUSIC: Cookie Thumper.ogg;
#OFFSET:0.003;
#SAMPLESTART:48.350;
#SAMPLELENGTH:12.000;
#SELECTABLE: YES;
#BPMS:0.000=134.000,107.000=67.000,143.000=134.000;
#STOPS:92.000=0.448
;
#BGCHANGES:;
#KEYSOUNDS:;//---------------dance-single — Nix----------------
#NOTES:
dance-single:
Nix:
Challenge:
10:
0.631,0.711,0.437,0.164,0.665:
0000
0000
0000
0000
,
0000
0000
0000
0000
,
0000
0000
0000
0000
,
0000
0000
0000
0000
,
0000
0000
0000
0000
,
0000
0000
0000
0000
,
2001
0000
3100
1000
0010
0100
0000
0110
,
0000
0100
0001
0010
1000
0000
0010
0000
,
0011
0000
0001
0100
1000
0100
0000
0110
,
0000
0010
1000
0100
0010
0000
0001
0000
,
1000
0000
1000
0100
0000
0100
0010
0000
0001
0000
0100
0010
1000
0000
0100
0000
,
0010
0001
0010
0100
0000
0100
1000
0010
0100
0000
0001
0000
1001
0000
0000
0001
,
0100
0010
1000
0010
0000
0010
0100
0001
0010
0000
0100
0000
1100
0000
0000
1000
,
0020
0000
0230
0000
0301
0100
0001
0000
0101
0000
1001
0000
2020
0000
3030
0000
,
0120
0000
0130
0001
0100
0010
1000
0000
1100
0000
0110
0000
0011
0000
0000
0000
,
0001
0000
0100
0010
1000
0100
0010
0001
1000
0000
0010
0000
0100
0000
0001
0000
,
0010
0100
1000
0001
0000
0001
1000
0010
0100
0000
0001
0000
0020
0000
0000
0000
,
0030
0000
0000
0000
0100
1000
0100
0010
0001
0000
0100
0000
0110
0000
0000
0000
,
1000
0010
0100
0001
1000
0100
1000
0001
0010
0000
0100
0000
0020
0000
0031
0000
,
0100
0010
0000
1000
0000
0010
0000
0010
0100
0000
0001
0000
1000
0000
0100
0010
,
0001
0010
0100
1000
0000
1000
0001
0100
0010
1000
0010
0000
0100
0000
0000
0000
,
0001
0010
0000
1000
0000
0010
0000
0100
1000
0010
0100
0000
0001
0000
0000
0000
,
1000
0100
0010
0100
0001
0100
0010
0000
0022
0000
0000
0000
0033
0000
1000
0000
,
1010
0000
0010
0100
0001
0000
1000
0000
1100
0000
0000
0000
0100
0000
0100
0010
,
0001
0010
0100
1000
0001
0000
0100
0000
0020
0000
0000
0000
0030
0000
0100
0000
,
0001
0000
0000
0000
1000
0000
1000
0010
0100
0001
0100
0000
0220
0000
0330
0000
,
2002
0000
0000
0000
0000
0000
3003
0000
0001
0000
1000
0001
1000
0000
0000
1000
,
0001
0100
0000
0110
0000
0010
1000
0100
0001
0000
0100
0000
1100
0000
0000
1000
,
0010
0100
0000
0110
0000
0100
0001
0010
1000
0000
0010
0000
0110
0000
0100
1000
,
0010
0100
0000
0101
0000
0001
0010
1000
0001
0000
0100
0000
1100
0000
0100
0010
,
0001
0000
0100
0000
0000
0000
0101
0000
0000
0000
0001
0000
1000
0010
1000
0010
1000
0000
0001
0100
0001
0000
0001
0000
0101
0000
0000
0000
0000
0000
0100
0000
,
0010
1000
0000
1100
0000
0100
0001
1000
0010
0000
0100
0000
1100
0000
1000
0010
,
0100
0001
0000
0011
0000
0010
1000
0010
0001
0000
0100
0000
0110
0000
0010
0001
,
0010
1000
0000
1010
0000
0010
0001
1000
0100
0000
0001
0000
1001
0000
1000
0100
,
0001
0000
0100
0000
0000
0000
1100
0000
0000
0000
1000
0000
0001
0010
0001
0010
0001
0000
1000
0100
1000
0000
1000
0000
0200
0000
0000
0000
0000
0000
M0MM
0000
,
0000
0000
0000
0000
0000
0000
M0MM
0000
0000
0000
0000
0000
0000
0000
M0MM
0000
0000
0000
0000
0000
0300
1000
0001
0000
0101
0000
0000
0000
0000
0000
0000
0000
,
0000
0000
0101
1001
1010
1000
0100
0010
,
0001
0000
0100
0001
1000
0000
0010
0000
0110
0000
0000
0000
0100
0001
1000
0000
,
0100
0000
0000
0010
0000
0000
0001
0000
0000
0010
0000
0000
0100
0000
0000
0000
0000
0000
1000
0000
0000
0000
0000
0000
0010
0000
0000
0000
1000
0000
0100
0000
0000
0000
0000
0000
1100
0000
0000
0000
0000
0000
0000
0000
0000
0110
0000
0000
,
0000
0000
0011
0000
0000
1000
0001
0000
1011
0000
0000
1000
0100
0000
0200
0000
,
0310
0000
0001
0000
0100
0000
1000
0000
0100
0000
0000
0000
0001
0010
0000
1000
,
0000
0001
0000
0100
0010
0000
0001
0000
0100
0000
0000
0000
0010
1000
0000
0001
,
0000
0100
0010
1000
0100
0000
0010
0000
0001
0000
0000
0000
1000
0100
0000
0001
,
0000
0100
1000
0010
1000
0000
0100
0000
0010
0000
0000
0010
0001
0000
0100
0000
,
0010
0000
1000
0010
1000
0000
0100
0000
0010
0000
0000
0000
0010
0000
0100
1000
,
0001
0100
0010
1000
0100
0000
0010
0000
0001
0000
0000
1000
0100
1000
0010
0000
,
0100
0001
1000
0000
0100
0000
0010
0000
0001
0000
0000
0000
0101
0000
0110
0000
,
0010
0000
1000
0010
1000
0010
0100
0000
0001
0000
0010
0000
1000
0000
0010
0000
,
0100
0000
1000
0000
0010
0100
0010
0000
1000
0000
0000
0000
0101
0000
0001
0000
,
0010
0000
0100
1000
0001
1000
0010
0000
0100
0000
0110
0000
1010
0000
0001
0010
,
0100
1000
0100
0010
0001
0000
1000
0000
0010
0000
0100
0000
0001
0010
0001
0000
,
0010
0100
1000
0010
0200
0000
0000
0000
,
0300
0000
0000
0000
;