Аннотация
Всем привет. Относительно недавно я написал статью Генерация окружения на основе звука и музыки в Unity3D, в которой привел несколько примеров игр, задействующих механику генерации контента на основе музыки, а так же рассказал про базовые аспекты подобных игр. В статье практически не было кода и я пообещал, что будет продолжение. И вот оно, перед вами. На этот раз мы попытаемся создать трассу для 2D гонки, в стиле Hill Climb, из вашей музыки. Посмотрим, что у нас получится..
Введение
Я напомню, что этот цикл статей рассчитан для начинающих разработчиков и для тех, кто только недавно начал работать с звуком. Если вы в уме делаете быстрое преобразование Фурье, то, вероятно, тут вам будет скучно.
Вот наш Road Map на сегодня:
- Рассмотреть, что такое дискретизация
- Выяснить, какие данные мы можем получить из Audio Clip Unity
- Понять, как мы можем работать с этими данными
- Узнать, что мы можем генерировать исходя из этих данных
- Научиться тому, как из всего этого сделать игру (ну, или что-то похожее на игру)
Итак, поехали!
Дискретизация аналогового сингала
Как многие знают, чтобы использовать сигнал в цифровых системах, нам нужно его преобразовать. Один из шагов преобразования — это дискретизация сигнала, при котором аналоговый сигнал разбивается на части (временные отчеты), после которого каждому отчету присватывается то значение амплитуды, которое было в выбранный момент.
Буквой Т обозначен период дискретизации. Чем меньше период, тем точнее будет преобразование сигнала. Но чаще всего говорят об обратной величине: Частотой дискретизации (логично, что это F = 1/T). Для телефонного сингала хватит 8 000 Гц, а, например, один из вариантов формата DVD-Audio требует частоты дискретизации 192 000 Гц. Стандарт в цифровой записи (в игровых редакторах, музыкальных редакторах) равняется 44 100 Гц — это частота CD Audio.
Числовые значения амплитуды хранятся в так называемых сэмплах и именно с ними мы будем работать. Значение сэмпла float и оно может быть от -1 до 1. Упрощенно это выглядит вот так.
Отрисовка звуковой волны (статично)
Базовая информация
Волновая форма (или аудио-форма, а в простонародье — "рыба") — визуальное представление звукового сигнала во времени. Волновая форма может показать нам, в каком моменте звука происходит активная фаза, а где наступает затухание. Часто волновая форма представлена для каждого канала отдельно, например, вот так:
Представим что у нас уже есть AudioSource и скрипт, в котором мы работаем. Давайте разберемся, что нам может дать Unity.
// Получаем AudioSource от нашего объекта
AudioSource myAudio = gameObject.GetComponent<AudioSource>();
// Сохраняем частоту дискретизации нашего аудиофайла. По умолчанию она равна 44100.
int freq = myAudio.clip.frequency;
Выбор кол-ва отчетов
Прежде чем мы пойдем дальше, нужно немного поговорить о глубине отрисовки нашего звука. При частоте дискретизации 44100 Гц в каждую секунду нам доступно для обработки 44100 отчетов. Допустим, нам нужно отрисовать трек длиной в 10 секунд. Каждый отчет мы будем рисовать линией в пиксель шириной. Получается, наша осциллограмма будет длинной 441000 пикселей. Получится очень длинная, вытянутая и мало понятная звуковая волна. Но, в ней вы сможете разглядеть каждый конкретный отчет! А еще вы страшно нагрузите систему, независимо от того, как вы это будете рисовать.
Если вы не делаете профессиональный аудио-софт, вам такая точность не нужна. Для общей аудио-картины мы можем разбить все семплы на более крупные периоды и брать, например, среднее по каждым 100 семплам. Тогда наша волна будет иметь вполне внятную форму:
Конечно же, это не совсем точно, так как вы можете пропустить пики громкости, которые вам могут быть нужны, поэтому можно попробовать не среднее значение, а максимальное из данного отрезка. Это даст немного другую картину, но ваши пики не пропадут.
Подготовка к получению аудиоданных
Давайте определим точность нашей выборки, как quality, а итоговое количествово отчетов, как sampleCount.
int quality = 100;
int sampleCount = 0;
sampleCount = freq / quality;
Пример расчета всех цифр будет ниже.
Далее нам нужно получить сами сэмплы. Это можно сделать из аудиоклипа, с помощью метода GetData.
public bool GetData(float[] data, int offsetSamples);
Этот метод принимает в себя массив, куда он записывает семплы. offsetSamples — параметр, который отвечает за начальную точку чтения массива данных. Если вы читаете массив с начала, то там должен быть ноль.
Чтобы записать сэмплы, нам нужно подготовить для них массив. Например, вот так:
float[] samples;
float[] waveFormArray; //Сюда мы запишем уже усредненные данные
samples = new float[myAudio.clip.samples * myAudio.clip.channels];
Почему мы умножили длину на количество каналов? Сейчас расскажу...
Информация об аудиоканалах в Unity
Многие знают, что в звуке мы обычно используем два канала: левый и правый. Кто-то знает, что есть системы 2.1, а так же 5.1, 7.1 в которых источники звука окружают со всех сторон. Тема каналов хорошо описана на вики. Как это устроено в Unity?
При загрузке файла, при открытии клипа, можно найти вот такое изображение:
Тут как раз показано, что у нас есть два канала, и можно даже заметить, что они отличаются друг от друга. Unity записывает сэмплы этих каналов друг за другом. Получается вот такая картина:
Именно поэтому нам нужно в два раза больше места в массиве, чем просто для количества сэмплов.
Если вы выберете опцию клипа Force To Mono, то канал будет один и весь звук будет в центре. Превью вашей волны сразу поменяется.
Получение аудиоданных
Вот что у нас выходит:
private int quality = 100;
private int sampleCount = 0;
private float[] waveFormArray;
private float[] samples;
private AudioSource myAudio;
void Start()
{
myAudio = gameObject.GetComponent<AudioSource>();
int freq = myAudio.clip.frequency;
sampleCount = freq / quality;
samples = new float[myAudio.clip.samples * myAudio.clip.channels];
myAudio.clip.GetData(samples,0);
// создаем массив, куда запишем усредненные сэмплы. Из него мы будем рисовать волну
waveFormArray = new float[(samples.Length / sampleCount)];
//Дальше проходим по нашему массиву и находим среднее значение в каждой группе сэмплов
for (int i = 0; i < waveFormArray.Length; i++)
{
waveFormArray[i] = 0;
for (int j = 0; j < sampleCount; j++)
{
//Abs тут использован для создания "красивой" и зеркально отраженной волны. См. ниже
waveFormArray[i] += Mathf.Abs(samples[(i * sampleCount) + j]);
}
waveFormArray[i] /= sampleCount;
}
}
Итого, если трек идет 10 секунд и он двухканальный, то мы получаем следующее:
- Количество сэмплов в клипе (myAudio.clip.sample) = 44100 * 10 = 441000
- Длинна массива сэмплов для двух каналов (samples.Length) = 441000 * 2 = 882000
- Количество отчетов (sampleCount) = 44100 / 100 = 441
- Длинна финального массива = samples.Length / sampleCount = 2000
В итоге, мы будем работать с 2000 точками, которых нам вполне хватит для отрисовки волны. Теперь нужно включать фантазию и думать, как мы можем использовать эти данные.
Отрисовка аудиоинформации
Создание простой аудиодорожки с помощью Debug-средств
Как многие знают, в Unity есть удобные средства для вывода разного рода Debug-информации. Толковый разработчик на основе этих средств может сделать, например, весьма мощные расширения для редактора. Наш случай показывает весьма нетипичное использование Debug-методов.
Для отрисовки нам нужна линия. Её мы можем сделать с помощью вектора, который будет создан из значений нашего массива. Учтите, чтобы сделать красивую зеркальную аудиоформу, нам нужно "склеить" две половинки нашей визуализации.
for (int i = 0; i < waveFormArray.Length - 1; i++)
{
//Создание вектора для верхней половины аудиоформы
Vector3 upLine = new Vector3(i * .01f, waveFormArray[i] * 10, 0);
//Создание вектора для нижней половины аудиоформы
Vector3 downLine = new Vector3(i * .01f, -waveFormArray[i] * 10, 0);
}
Далее просто используем Debug.DrawLine для отрисовки наших векторов. Цвет можете выбрать любой. Все эти методы нужно вызывать в Update, так мы будет обновлять информацию каждый кадр.
Debug.DrawLine(upLine, downLine, Color.green);
Если хотите, можно добавить "бегунок", который будет показывать текущую позицию играемого трека. Эту информацию можно получить из поля "AudioSource.timeSamples".
private float debugLineWidth = 5;
//Создание "бегунка" на аудиоформе. Положение привязано к текущему временному сэмплу
int currentPosition = (myAudio.timeSamples / quality) * 2;
Vector3 drawVector = new Vector3(currentPosition * 0.01f, 0, 0);
Debug.DrawLine(drawVector - Vector3.up * debugLineWidth, drawVector + Vector3.up * debugLineWidth, Color.white);
Итого, вот наш скрипт:
using UnityEngine;
public class WaveFormDebug : MonoBehaviour
{
private readonly int quality = 100;
private int sampleCount = 0;
private int freq;
private readonly float debugLineWidth = 5;
private float[] waveFormArray;
private float[] samples;
private AudioSource myAudio;
private void Start()
{
myAudio = gameObject.GetComponent<AudioSource>();
//Базовые расчеты
freq = myAudio.clip.frequency;
sampleCount = freq / quality;
//Получение аудиоданных
samples = new float[myAudio.clip.samples * myAudio.clip.channels];
myAudio.clip.GetData(samples, 0);
//Создание массива с данными для отрисовки аудиоформы
waveFormArray = new float[(samples.Length / sampleCount)];
for (int i = 0; i < waveFormArray.Length; i++)
{
waveFormArray[i] = 0;
for (int j = 0; j < sampleCount; j++)
{
waveFormArray[i] += Mathf.Abs(samples[(i * sampleCount) + j]);
}
waveFormArray[i] /= sampleCount;
}
}
private void Update()
{
for (int i = 0; i < waveFormArray.Length - 1; i++)
{
//Создание вектора для верхней половины аудиоформы
Vector3 upLine = new Vector3(i * 0.01f, waveFormArray[i] * 10, 0);
//Создание вектора для нижней половины аудиоформы
Vector3 downLine = new Vector3(i * 0.01f, -waveFormArray[i] * 10, 0);
//Отрисовка Debug информации
Debug.DrawLine(upLine, downLine, Color.green);
}
//Создание "бегунка" на аудиоформе. Положение привязано к текущему временному сэмплу
int currentPosition = (myAudio.timeSamples / quality) * 2;
Vector3 drawVector = new Vector3(currentPosition * 0.01f, 0, 0);
Debug.DrawLine(drawVector - Vector3.up * debugLineWidth, drawVector + Vector3.up * debugLineWidth, Color.white);
}
}
А вот результат:
Создание плавного звукового ландшафта с помощью PolygonCollider2D
Прежде чем приступить к данному разделу, хочу отметить следующее: кататься по сгенерированной из музыки трассе, конечно же, весело, но с точки зрения геймплея практически бесполезно. И вот почему:
- Для того, чтобы трасса была проходимой, нужно сгладить наши данные. Все пики пропадают и вы практически перестаете "чувствовать вашу музыку"
- Обычно музыкальные треки весьма сильно скомпрессированы и представляют собой звуковой кирпич, который плохо подходит для 2D-игры.
- Не решенный вопрос скорости нашего транспорта, который должен подходить под скорость трека. Этот вопрос я хочу рассмотреть в следующей статье.
Поэтому, в качестве эксперимента, такой тип генерации довольно забавный, но реальную геймплейную фичу на его основе сделать сложно. В любом случае, продолжим.
Итак, нам нужно сделать PolygonCollider2D с помощью наших данных. Это сделать просто. У PolygonCollider2D есть публичное поле points, которое принимает Vector2[]. Нужно для начала перенести наши точки в вектора нужного вида. Сделаем функцию для перевода массива наших сэмплов в векторный массив:
private Vector2[] CreatePath(float[] src)
{
Vector2[] result = new Vector2[src.Length];
for (int i = 0; i < size; i++)
{
result[i] = new Vector2(i * 0.01f, Mathf.Abs(src[i] * lineScale));
}
return result;
}
После этого, просто передаем наш полученный массив векторов в коллайдер:
path = CreatePath(waveFormArray);
poly.points = path;
Смотрим результат. Вот начало нашего трека… хм… выглядит не сильно проходимым (о визуализации пока что не думайте, комментарии будут позже).
У нас слишком резкая аудиоформа, поэтому трасса выходит странной. Нужно её сгладить. Здесь нам пригодится алгоритм скользящего среднего. Более подробно про него можно прочитать на Хабре, в статье Алгоритм скользящего среднего (Simple Moving Average).
В Unity алгоритм реализуется следующим образом:
private float[] MovingAverage(int frameSize, float[] data)
{
float sum = 0;
float[] avgPoints = new float[data.Length - frameSize + 1];
for (int counter = 0; counter <= data.Length - frameSize; counter++)
{
int innerLoopCounter = 0;
int index = counter;
while (innerLoopCounter < frameSize)
{
sum = sum + data[index];
innerLoopCounter += 1;
index += 1;
}
avgPoints[counter] = sum / frameSize;
sum = 0;
}
return avgPoints;
}
Модифицируем наше создание пути:
float[] avgArray = MovingAverage(frameSize, waveFormArray);
path = CreatePath(avgArray);
poly.points = path;
Проверяем...
Теперь наша трасса выглядит вполне нормально. Я использовал ширину окна равную 10. Вы можете модифицировать этот параметр, чтобы подобрать нужное вам сглаживание.
Вот полный скрипт данного раздела:
using UnityEngine;
public class WaveFormTest : MonoBehaviour
{
private const int frameSize = 10;
public int size = 2048;
public PolygonCollider2D poly;
private readonly int lineScale = 5;
private readonly int quality = 100;
private int sampleCount = 0;
private float[] waveFormArray;
private float[] samples;
private Vector2[] path;
private AudioSource myAudio;
private void Start()
{
myAudio = gameObject.GetComponent<AudioSource>();
int freq = myAudio.clip.frequency;
sampleCount = freq / quality;
samples = new float[myAudio.clip.samples * myAudio.clip.channels];
myAudio.clip.GetData(samples, 0);
waveFormArray = new float[(samples.Length / sampleCount)];
for (int i = 0; i < waveFormArray.Length; i++)
{
waveFormArray[i] = 0;
for (int j = 0; j < sampleCount; j++)
{
waveFormArray[i] += Mathf.Abs(samples[(i * sampleCount) + j]);
}
waveFormArray[i] /= sampleCount * 2;
}
//Получаем сглаженный массив, с шириной окна frameSize
float[] avgArray = MovingAverage(frameSize, waveFormArray);
path = CreatePath(avgArray);
poly.points = path;
}
private Vector2[] CreatePath(float[] src)
{
Vector2[] result = new Vector2[src.Length];
for (int i = 0; i < size; i++)
{
result[i] = new Vector2(i * 0.01f, Mathf.Abs(src[i] * lineScale));
}
return result;
}
private float[] MovingAverage(int frameSize, float[] data)
{
float sum = 0;
float[] avgPoints = new float[data.Length - frameSize + 1];
for (int counter = 0; counter <= data.Length - frameSize; counter++)
{
int innerLoopCounter = 0;
int index = counter;
while (innerLoopCounter < frameSize)
{
sum = sum + data[index];
innerLoopCounter += 1;
index += 1;
}
avgPoints[counter] = sum / frameSize;
sum = 0;
}
return avgPoints;
}
}
Как я уже говорил в начале раздела, при таком сглаживании мы перестаем чувствовать трек, кроме того, скорость машинки не привязана к скорости музыки (BPM). Эту проблему мы разберем в следующей части данного цикла статей. Кроме того, там же мы затронем тему спец. эффектов под бит. Машинку я, кстати, взял из этого бесплатного ассета.
Наверное, многие из вас, глянув на скрины, задались вопросом, как я нарисовал саму трассу? Ведь коллайдеров не видно.
Я воспользовался мудростью интернета и нашел способ, с помощью которого вы можете превратить полигон коллайдер в меш, которому вы можете присвоить любой материал, а line renderer сделает стильный контур. Подробно этот способ описан вот тут. Triangulator вы можете взять на Unity Community.
Завершение
То, что мы с вами изучили в этой статье — это базовый набросок для музыкальной игр. Да, в таком виде он, пока что, немного неказистый, но вы уже можете смело сказать "Ребята, я сделал так, чтобы машинка ездила по аудио-дорожке!". Чтобы сделать из этого реальную игру, нужно приложить много сил. Вот список того, что мы можем тут сделать:
- Привязать скорость машинки к BPM трека. Игрок сможем управлять только наклоном автомобиля, но не скоростью. Тогда музыка намного сильнее будет чувствоваться в процессе прохождения трассы.
- Сделать бит-детектор и добавить спец. эффекты, которые будут срабатывать под бит. Кроме того, можно добавить анимацию на корпус автомобиля, который будет подпрыгивать на ударе бита. Тут уже всё зависит от вашей фантазии.
- Вместо скользящего среднего нужно более грамотно обработать трек и получить массив данных так, чтобы пики не пропадали, но при этом трассу было строить легко.
- Ну, и, конечно же, нужно сделать геймплей интересным. Можно разместить на каждый удар бита монетки, добавить опасные зоны и т.п.
Все это и многое другое будем изучать в остальных частях данного цикла статей. Всем спасибо за чтение!
Автор: TimTim