Сложности начинаются когда звуков в игре становится много. Их все нужно расставить, прописать приоритеты. Звуки отдельно, музыку отдельно. При регулировке громкости звуков и музыки раздельно тоже сложности. Можно, конечно, регулировать громкость разных каналов в AudioMixer, но он не работает в WebGL. А Webplayer сейчас считается устаревшим.
А если какой то звук повторяется несколько раз подряд(например игрок быстро нажимает на кнопку и играет звук клика), то хорошо бы чтобы тот не обрывался на середине, а начинался новый, не мешая предыдущим. Да еще и при включении паузы звуки игры нужно ставить на паузу, а звуки меню нет. Из коробки такая возможность в Unity есть, но почему то доступна только из скрипта и не все о ней знают.
В общем хочется простой и удобный SoundManager, создание которого я и опишу. Для крупных проектов он не подойдет, а вот для прототипов и небольших игр вполне.
Итак что же должен представлять собой SoundManager? Ну во первых им должно быть удобно пользоваться. То есть никаких «найти объект на сцене», «присоеденить компонент» и прочего для пользователя, все внутри. Так что сразу делаем его синглтоном(Код сокращен, чтобы выделить суть).
private static SoundManager _instance;
public static SoundManager Instance
{
get
{
if (_instance == null)
{
_instance = (SoundManager)FindObjectOfType(typeof(SoundManager));
if (_instance == null)
{
GameObject singleton = (GameObject)Instantiate(Resources.Load<GameObject>(PrefabPath));
_instance = singleton.GetComponent<SoundManager>();
singleton.name = "(singleton) " + typeof(SoundManager).ToString();
DontDestroyOnLoad(singleton);
}
}
return _instance;
}
}
Теперь менеджер сам создаст себя на сцене, так что добавлять его самостоятельно не нужно(и не рекомендуется). Создается он по префабу, путь до которого прописан в коде, так что перемещать префаб не стоит. Можно создавать и с помощью new GameObject() и AddComponent() если хочется. Кроме того объект сразу помечается с помощью DontDestroyOnLoad. Нужно это для того чтобы музыка и звуки продолжали играть без перебоев при перезагрузках сцен.
Теперь к любым методам можно обращаться просто написав SoundManager.Instance.Method(). Чтобы еще немного сократить эту запись для всех методов я дописал статический враппер:
public static void PlayMusic(string name)
{
Instance.PlayMusicInternal(name);
}
Так что писать можно даже еще короче SoundManager.Method().
Объект есть, работать с ним удобно. Дальше добавляем функционал. Самая необходимая функция это PlaySound:
void PlaySoundInternal(string soundName, bool pausable)
{
if (string.IsNullOrEmpty(soundName)) {
Debug.Log("Sound null or empty");
return;
}
int sameCountGuard = 0;
foreach (AudioSource audioSource in _sounds)
{
if (audioSource.clip.name == soundName)
sameCountGuard++;
}
if (sameCountGuard > 8)
{
Debug.Log("Too much duplicates for sound: " + soundName);
return;
}
if (_sounds.Count > 16) {
Debug.Log("Too much sounds");
return;
}
StartCoroutine(PlaySoundInternalSoon(soundName, pausable));
}
IEnumerator PlaySoundInternalSoon(string soundName, bool pausable)
{
ResourceRequest request = LoadClipAsync("Sounds/" + soundName);
while (!request.isDone)
{
yield return null;
}
AudioClip soundClip = (AudioClip)request.asset;
if (null == soundClip)
{
Debug.Log("Sound not loaded: " + soundName);
}
GameObject sound = (GameObject)Instantiate(soundPrefab);
sound.transform.parent = transform;
AudioSource soundSource = sound.GetComponent<AudioSource>();
soundSource.mute = _mutedSound;
soundSource.volume = _volumeSound * DefaultSoundVolume;
soundSource.clip = soundClip;
soundSource.Play();
soundSource.ignoreListenerPause = !pausable;
_sounds.Add(soundSource);
}
Для начала несколько проверок звука. Что он не пустой и что таких звуков не стало слишком много(Если где то в цикле по ошибке вызывается). После чего загружаем звук из ресурсов, ждем загрузки, создаем новый объект на сцену, добавляем AudioSource, настраиваем его и запускаем. Функция LoadClipAsync запускает асинхронную загрузку звукового файла из ресурсов по имени. Так что файл надо будет положить в папку «Resources/Sounds/Sounds». Создание объекта происходит по префабу, который загружен из ресурсов. Так что часть параметров(вроде приоритета звука), можно установить префабу из инспектора. Громкость устанавливается так же у каждого объекта отдельно. В отличие от установки громкости AudioListener-у это позволяет регулировать громкость звуков и музыки раздельно. Сохраним объект в списке звуков _sounds, чтобы иметь возможность регулировать его громкость и уничтожать по окончанию.
Параметр pausable нужен чтобы разделить UI звуки и игровые звуки. Первые должны играться всегда и никогда не ставиться на паузу. Вторые приостанавливаются во время паузы и продолжаются при возобновлении игры. Делается это автоматически с помощью флага soundSource.ignoreListenerPause, который почему то недоступен из Inspector-а.
Далее нам нужен метод для добавления музыки в игру. В целом код похож на добавление звука, но используется другой префаб(с дургим приоритетом и настройкой loop).
void PlayMusicInternal(string musicName)
{
if (string.IsNullOrEmpty(musicName)) {
Debug.Log("Music empty or null");
return;
}
if (_currentMusicName == musicName) {
Debug.Log("Music already playing: " + musicName);
return;
}
StopMusicInternal();
_currentMusicName = musicName;
AudioClip musicClip = LoadClip("Music/" + musicName);
GameObject music = (GameObject)Instantiate(musicPrefab);
if (null == music) {
Debug.Log("Music not found: " + musicName);
}
music.transform.parent = transform;
AudioSource musicSource = music.GetComponent<AudioSource>();
musicSource.mute = _mutedMusic;
musicSource.ignoreListenerPause = true;
musicSource.clip = musicClip;
musicSource.Play();
musicSource.volume = 0;
StartFadeMusic(musicSource, MusicFadeTime, _volumeMusic * DefaultMusicVolume, false);
_currentMusicSource = musicSource;
}
В большинстве неболших проектов достаточно одного трека проигрывающегося в данный момент, так что запуск новой музыки останавливает предыдущие треки автоматически, так что на каждой сцене достаточно вызвать лишь SoundManager.PlayMusic(«MusicForCurrentScene»); Кроме того при создании и остановке музыки добавляется плавное нарастание громкости и плавное угасание. Это позволяет сделать переход плавным и не бьет по слуху. Само плавное изменение громкости можно делать Tween-ом, но можно и ручками, чтобы было меньше зависимостей.
Дальше нам нужна возможность поставить паузу. Так как у всех звуков уже проставлена настройка ставятся ли они на паузу при паузе AudioListener-а, то методы получаются очень простыми.
public static void Pause()
{
AudioListener.pause = true;
}
public static void UnPause()
{
AudioListener.pause = false;
}
Либо можно настроить автоматическое включение паузы.
void Update()
{
if (AutoPause)
{
bool curPause = Time.timeScale < 0.1f;
if (curPause != AudioListener.pause)
{
AudioListener.pause = curPause;
}
}
}
Дальше нам потребуются методы установки и получения громкости.
void SetSoundVolumeInternal(float volume)
{
_volumeSound = volume;
SaveSettings();
ApplySoundVolume();
}
float GetSoundVolumeInternal()
{
return _volumeSound;
}
void SaveSettings()
{
PlayerPrefs.SetFloat("SM_SoundVolume", _volumeSound);
}
void LoadSettings()
{
_volumeSound = PlayerPrefs.GetFloat("SM_SoundVolume", 1);
ApplySoundVolume();
}
void ApplySoundVolume()
{
foreach (AudioSource sound in _sounds)
{
sound.volume = _volumeSound * DefaultSoundVolume;
}
}
Тут все просто. Сохраняем и читаем настройки с помощью PlayerPrefs, при изменении пробегаемся по звукам и применяем новую громкость. Аналогично можно сделать настройку mute и все тоже самое для музыки.
Ну вот и все. SoundManager, которым легко пользоваться готов. Так как мы вынесли шаблоны для звуков и музыки в префабы, то к ним легко можно подключить output из AudioMixer-а. Кроме того есть еще небольшой класс, упрощающий вызовы нужных методов из анимаций, обработчиков кнопок и пр, чтобы не нужно было писать скрипт из-за одной строчки.
Плюсы полученного менеджера:
+ Простота в использовании
+ Чистый код и объекты сцены. Не нужно вешать компоненты звука нигде, искать и вызывать их из кода
+ Музыка, которая не прерывается при загрузке сцены и меняется плавно
+ Геймплейные и UI звуки
+ Поддержка паузы
+ Поддержка AudioMixer
+ Работа на всех платформах, включая не поддерживающие AudioMixer (например WebGL)
+ Поддержка голоса рассказчика(в статье не упомянуто, но в полном коде реализовано)
Ограничения текущей реализации(Пока нету):
— Пока нет позиционного 3d звука
— Изменения pitch-а звука, чтобы много кратное повторение одинаковых звуков не приедалось
— Загрузка звука при использовании может приводить к лагам(Незаметно на мелких проектах и небольших звуках)
— Нет регулировки громкости отдельно взятого звука
— Нет зацикленных звуков, вроде амбиента
Полный код менеджера можно посмотреть на моем GitHub-е:
https://github.com/Gasparfx/SoundManager
Наш проект использующий этот менеджер на GreenLight:
http://steamcommunity.com/sharedfiles/filedetails/?id=577337491
Комментарии (9)
elmortem
05.01.2016 23:22Замените
if (_sounds.Count > 16) {
на
if (sameCountGuard > 16) {
P.S. Магические числа смотрят на вас… с желанием превратиться в константы или настраиваемые параметры.Gasparfx
06.01.2016 02:32Спасибо за поправку. Заменил эту проверку на две. На количество таких же звуков и на общее количество.
А на счет констант. Я вынес в константы то, что с чем можно играться(AutoPause, MusicFadeTime). Мне не хотелось, чтобы кто то увидел это число в константах и решил что его стоит тюнить под себя. Все же это не настройка менеджера в моем понимании, а защита от зацикливаний.
Leopotam
06.01.2016 01:40+1Я разделяю FX-ы (спецэффекты, короткие звуки, включая UI) и фоновую музыку. Учитывая, что на мобилках параллельное проигрывание очень сильно сажает производительность, нужно ограничивать количество параллельно проигрываемых звуков (я использую 1 канал для фоновой музыки и 3 канала для FX-ов).
Проигрывание музыки (если совпадает с уже проигрываемой, то прерывания нет — переключение между сценами происходит бесшовно по звуку):
public void PlayMusic (string music, bool isLooped = false) { } public void StopMusic () { }
Путь указывается от корня Resources, что позволяет переносить данный класс между проектами без изменений.
Проигрывание короткого звука:
public enum SoundFXChannel { First = 0, Second = 1, Third = 2 } ... public void PlayFX (AudioClip clip, SoundFXChannel channel = SoundFXChannel.First, bool forceInterrupt = false) { } public void StopFX (SoundFXChannel channel) { }
Сам класс SoundManager является кросс-сценовым синглтоном с ленивой инициализацией без необходимости дополнительной настройки в редакторе, все управление для дизайнера ведется через 3 вспомогательных класса:
public sealed class MusicOnStart : MonoBehaviour { public string Music = null; public bool IsLooped = true; IEnumerator Start () { yield return null; if (SoundManager.Instance.MusicVolume == 0f) { SoundManager.Instance.StopMusic (); } SoundManager.Instance.PlayMusic (Music, IsLooped); } }
и
public sealed class SoundOnStart : MonoBehaviour { public AudioClip Sound = null; public SoundFXChannel Channel = SoundFXChannel.First; public bool IsInterrupt = false; IEnumerator Start () { yield return null; SoundManager.Instance.PlayFX (Sound, Channel, IsInterrupt); } } public sealed class SoundOnEnable : MonoBehaviour { public AudioClip Sound = null; public SoundFXChannel Channel = SoundFXChannel.First; public bool IsInterrupt = false; void OnEnable () { SoundManager.Instance.PlayFX (Sound, Channel, IsInterrupt); } }
Настройки громкости разделены на громкость музыки и громкость FX-ов и хранятся отдельно в настройках пользователя, тут все стандартно.Gasparfx
06.01.2016 02:48На счет музыки не совсем понял. То есть при смене музыки трек меняется резко, без приглушения? У нас композитор явно настаивал на плавном угасании первого трека и нарастании второго. Иначе может по слуху бить.
А вот идея с указанием в какой канал воспроизводить звук мне не очень понятна. Вот у вас эти классы по умолчанию в первый канал пишут, так если их два запустится, то один прибьется сразу, хотя два канала пустые еще? Зачем указывать номер канала, не лучше ли просто ограничить число каналов до 3 и играть если есть свободные. Причем так можно сделать даже лучше. При запуске звука указывать его приоритет и прибивать менее приоритетные звуки если свободных каналов нет.
Но в целом я думаю, что ограничивать звук 3 каналами смысла мало. Более менее больших звуков в один момент времени больше 1-2 быть не должно. А мелких звуков, вроде клика может быть много, но их можно импортировать без сжатия в wav. Тогда их проигрывание не будет просаживать производительность сильно. Какое то ограничение, конечно нужно, но тут уже экспериментально и от таргет устройства зависит.Leopotam
06.01.2016 11:19+1То есть при смене музыки трек меняется резко, без приглушения? У нас композитор явно настаивал на плавном угасании первого трека и нарастании второго. Иначе может по слуху бить.
За фейдинг между сценами отвечает другой менеджер — FadeManager, который так же может управлять громкостью AudioListener-а в сцене. Те если известно, что в новой сцене будет другая мелодия, то в фейдинг добавляется флаг, уменьшающий громкость «уха» вслед за угасанием экрана. В новой сцене музыка начнет играться как обычно, а фейдер плавно покажет экран и так же плавно нарастит громкость. Переключения мелодии внутри одной сцены не планировалось.
Вот у вас эти классы по умолчанию в первый канал пишут, так если их два запустится, то один прибьется сразу, хотя два канала пустые еще?
Они не пишут все в один канал, его можно выбирать. Каналы — это по сути 3 параллельных источника звуков. Тут уже дизайнер / разработчик должен решить, какие звуки куда идут. Например, UI идет в первый канал, взрывы — во второй, голоса — в третий. И они будут играться без тормозов на бюджетках (у меня просто основное направление — мобильные устройства, а не десктоп, где ресурсов много и можно не контролировать).
Но в целом я думаю, что ограничивать звук 3 каналами смысла мало.
Раньше даже при 4 параллельных звуках звук начинал «пищать» на части китайфонов + сильно тормозить сам процесс (прошло 4 года, ситуация стала немного получше, но не кардинально).
Более менее больших звуков в один момент времени больше 1-2 быть не должно.
Мобилки поддерживают аппаратное декодирование только одного потока, поэтому канал «музыки» один и грузится из ресурсов по имени. Сам аудио-клип настраивается как стриминговый — не ест много памяти, тянется с устройства хранения напрямую, высока вероятность того, что будет декодироваться аппаратно (если у игрока не играет фоновая музыка через itunes, например). Так же если музыка зациклена, то ее нужно хранить в wav с дальнейшим выбором формата сжатия в unity (впрочем, как и все остальные ресурсы) — это уберет глюк с щелчком при переходе из конца в начало (в mp3 юнити обрезает последние несколько мс и получается неприятный эффект).
Какое то ограничение, конечно нужно, но тут уже экспериментально и от таргет устройства зависит.
Ну вот, собственно, такое ограничение и есть. Обычно хватает 2 каналов — музыка + FX, в который идет все короткие звуки. 3 FX канала — это уже жир.
З.Ы. Надо всех десктопных «инди» посадить на бюджетные мобилки и заставить переписать их продукты так, чтобы на самом плохом девайсе (типа iphone4) они шли без долгих фризов и хотя бы на 30фпс — вот тогда начнут думать об оптимизации и неуемной трате ресурсов. А то «х*як — х*як и в продакшн» так и будет процветать…henryjamesmoodyjunior
07.01.2016 18:24Так все хорошо излагаете. И не только по звуку. О всяких менеджерах интересно было бы почитать. И о «ресурсной экономике», поделитесь опытом. Может статью напишете?
Leopotam
07.01.2016 19:11К сожалению, писать там особо нечего — просто набор триков по жесткой экономии (по сути шишки, полученные на своем опыте) и постоянные тесты на максимально дохлом железе. Если запускается и функционирует сносно, то на более мощном железе будет просто летать (или не летать, но хотя бы работать не медленнее). На самом деле конечное решение сильно зависит от того, насколько программная часть сможет договориться с дизайнерской частью (программист vs дизайнер) и прийти к компромиссу.
henryjamesmoodyjunior
Спасибо, ваш класс хорош, для новичка, и как база для написания «под себя».
P.S. Я бы если не переписал PlayMusicInternal(string musicName) и PlaySoundInternalSoon(string soundName, bool pausable) так добавил бы методы использующие AudioClip и GameObject чтобы избавится от файндов и лоадов…
Gasparfx
Спасибо!
На счет передачи AudioClip. Я хотел единообразия кода и хранения всех звуковых файлов в одном месте. А с передачей по Clip-у его можно будет положить куда угодно. Начнешь класть в другую папку, а потом захочется этот же звук по имени вызвать и не заработает. В общем то дело вкуса и класс легко можно дописать по нраву.