- PVSM.RU - https://www.pvsm.ru -
Всем привет. Относительно недавно я написал статью Генерация окружения на основе звука и музыки в Unity3D [1], в которой привел несколько примеров игр, задействующих механику генерации контента на основе музыки, а так же рассказал про базовые аспекты подобных игр. В статье практически не было кода и я пообещал, что будет продолжение. И вот оно, перед вами. На этот раз мы попытаемся создать трассу для 2D гонки, в стиле Hill Climb, из вашей музыки. Посмотрим, что у нас получится..
Я напомню, что этот цикл статей рассчитан для начинающих разработчиков и для тех, кто только недавно начал работать с звуком. Если вы в уме делаете быстрое преобразование Фурье, то, вероятно, тут вам будет скучно.
Вот наш Road Map на сегодня:
Итак, поехали!
Как многие знают, чтобы использовать сигнал в цифровых системах, нам нужно его преобразовать. Один из шагов преобразования — это дискретизация сигнала, при котором аналоговый сигнал разбивается на части (временные отчеты), после которого каждому отчету присватывается то значение амплитуды, которое было в выбранный момент.
Буквой Т обозначен период дискретизации. Чем меньше период, тем точнее будет преобразование сигнала. Но чаще всего говорят об обратной величине: Частотой дискретизации (логично, что это 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];
Почему мы умножили длину на количество каналов? Сейчас расскажу...
Многие знают, что в звуке мы обычно используем два канала: левый и правый. Кто-то знает, что есть системы 2.1, а так же 5.1, 7.1 в которых источники звука окружают со всех сторон. Тема каналов хорошо описана на вики [2]. Как это устроено в 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 секунд и он двухканальный, то мы получаем следующее:
В итоге, мы будем работать с 2000 точками, которых нам вполне хватит для отрисовки волны. Теперь нужно включать фантазию и думать, как мы можем использовать эти данные.
Как многие знают, в 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 с помощью наших данных. Это сделать просто. У 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) [3].
В 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). Эту проблему мы разберем в следующей части данного цикла статей. Кроме того, там же мы затронем тему спец. эффектов под бит. Машинку я, кстати, взял из этого бесплатного ассета [4].
Наверное, многие из вас, глянув на скрины, задались вопросом, как я нарисовал саму трассу? Ведь коллайдеров не видно.
Я воспользовался мудростью интернета и нашел способ, с помощью которого вы можете превратить полигон коллайдер в меш, которому вы можете присвоить любой материал, а line renderer сделает стильный контур. Подробно этот способ описан вот тут [5]. Triangulator вы можете взять на Unity Community [6].
То, что мы с вами изучили в этой статье — это базовый набросок для музыкальной игр. Да, в таком виде он, пока что, немного неказистый, но вы уже можете смело сказать "Ребята, я сделал так, чтобы машинка ездила по аудио-дорожке!". Чтобы сделать из этого реальную игру, нужно приложить много сил. Вот список того, что мы можем тут сделать:
Все это и многое другое будем изучать в остальных частях данного цикла статей. Всем спасибо за чтение!
Автор: TimTim
Источник [7]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/muzy-ka/302040
Ссылки в тексте:
[1] Генерация окружения на основе звука и музыки в Unity3D: https://habr.com/company/everydaytools/blog/429872/
[2] на вики: https://ru.wikipedia.org/wiki/%D0%9E%D0%B1%D1%8A%D1%91%D0%BC%D0%BD%D1%8B%D0%B9_%D0%B7%D0%B2%D1%83%D0%BA
[3] Алгоритм скользящего среднего (Simple Moving Average): https://habr.com/post/134375/
[4] бесплатного ассета: https://assetstore.unity.com/packages/tools/physics/2d-car-73763
[5] тут: http://answers.unity3d.com/questions/835675/how-to-fill-polygon-collider-with-a-solid-color.html
[6] Unity Community: http://wiki.unity3d.com/index.php/Triangulator
[7] Источник: https://habr.com/post/432134/?utm_campaign=432134
Нажмите здесь для печати.