BGMやSEを鳴らす(Unityメモ)
いわゆるサウンドシステムの話
必要な機能(要件)
ゲームにはBGMやSE(効果音)がつきものです。BGMで作品の雰囲気やシナリオ内の感情を言葉によらず伝えられます。またSEがあると操作の意味を伝えたり、操作に楽しさを付け足すことができます。これらBGMやSEを操作するのがいわゆるサウンドシステムです。
必須機能
・マルチチャネル再生できるようにする
・BGM
・UI用SE(決定、キャンセルなど)
・戦闘用SE(打撃、エフェクトなど)
(・キャラボイス:現状不使用だが予約しておく)
・再生関係の機能
・再生、停止
・ループ再生
4~50年前のゲームではSEを鳴らすとBGMが止まったりしましたが、さすがに現代のゲームではその制約は許されないでしょう。SE同士も複数同時に鳴らしたいです。また現状はボイス対応するつもりはないのですが、一応チャネルとしては予約することにします。ゲームは人間が遊ぶものなので、ボイスも人間の声優に収録を依頼した方が適切だと思いますが、AIでボイスを(私が調声して)量産できる時代もすぐそこです。
追加機能
・空間エフェクト
・一時停止と再開
戦闘用SEの再生中に左右の定位を動かすなど、音声に空間エフェクトを適用できると、戦闘の臨場感を高められます。
またBGMを一時停止し、後で続きを再開できると、演出の幅が広がりそうです。こちらは多くの音声制御ライブラリで機能が用意されています。
ユーザに提示する音像のイメージ
ユーザが感じる定位や距離感などを含んだ音声を音像と言います。3Dゲームでは音像が重視されます。特にFPSやアクションでは敵から出る音の定位と距離感がプレイングの正確さに直結します。
RPG系のゲームにおいて音の定位感はプレイング上重要ではないですが、音像により場の雰囲気を作ることができます。本ゲームではゲーム内の音像に加え、作品世界の手前にUIが存在するような音像を作りたいです。
音量GUI
以下の設定をGUIに実装します。
・種類ごとの音量設定とミュート
・全体の音量設定とミュート
長くゲームを遊んでいると、「BGMを大きめにしたい」「ボイス不要」などプレイングに合わせて種別ごとの音量調整がしたくなります。それと「一時的にBGMを止めたい」などミュート機能が欲しくなります。また全体の音量を一括調整できると便利です。この場合、個々の音量設定の役目は種別間のバランス調整となります。
ミュート表示については、見た目で音声のON/OFFが分かるようにするため、音声ON時に色付け、ミュート(音声OFF)時に色を抜く仕様とします。
よくある提示ではミュート時に禁止マーク(🚫)を表示します。音声ON時は禁止マークを消すか、禁止マークをグレーアウトする表現になると思いますが、初見で分かりづらいです。マークが消えている場合はマークがあることが伝わらず、グレーアウトしている場合はマーク自体は有効な(ミュート機能が無効になっているわけではない)ことが伝わりにくいです。
そもそもミュートは音声のON/OFFとは逆の動作なので、ON/OFFだけを提示すると見た目の情報と動作結果が矛盾します。
ミュートのGUI設計については以下の記事が参考になります。
基礎知識
Unityの音声システム(Audio System)
ゲームのいわゆるサウンドシステムを、UnityではAudio Systemと呼びます。
Unityは3Dゲームエンジンであり、プレイヤーに聞こえる音像の定位感を計算できます。具体的には音源(AudioSource)とマイク(AudioListener)の空間上の位置関係を使って、3D空間エフェクト(特に左右の定位感)を音源にかけられます。
AudioSourceとAudioListenerの位置は、いずれもアタッチされたGameObjectの位置を用います。空間エフェクトもGameObjectの移動に追従します。
AudioListenerはデフォルトではメインカメラのGameObjectにアタッチされています。メインカメラの位置にマイクがあり、プレイヤーと一緒にマイクも移動するイメージです。一方、AudioListenerはメインカメラ以外のGameObjectにアタッチしてもかまいません。アタッチしたGameObjectが動かないなら定位置での収音となり、敵のGameObjectにアタッチした場合は、その敵の動きに応じた収音となります。
周回ゲームの場合もメインカメラにアタッチしますが、メインカメラは基本的に固定です。この状況もいろいろと解釈が出来ます。
・ゲーム内世界を内部から定点観測するような状況(無生物的)
・ゲーム外からゲーム内世界を俯瞰するような状況(第四の壁)
本ゲームでは後者の、ゲーム外からゲームを俯瞰するような状況を想定します。音像の設計もこれに合わせます。
Unityにおける音声信号の流れ
Unityにおける音声信号の流れとしては、上の図のようになります。
AudioClipが音声ファイルを表します。AudioClipからAudioSourceを経由し、AudioListenerで音声が重ねられ、最終出力が作られます。AudioListenerの対応物について、音の定位感の計算ではマイクに相当しましたが、音声信号の流れにおいてはスピーカやヘッドホン等にあたります。
AudioSourceから出る信号は、AudioMixerを通して複数の音声を重ねることもできます。AudioMixerでも音量調整等のエフェクトをかけられます。これによりMixer単位で音像を設定できます。
またAudioSourceでは先述のようにゲーム上の位置に応じた空間エフェクトがかかります。それに加えて、3D空間エフェクトとは別のエフェクトをかけることも可能です。
そしてAudioMixerGroupはネスト可能です。これによってDTMのフォルダトラックのように複数の系統の音声をまとめることができます。残念なことにWebGLではAudioMixerGroupを使用できないのですが…
ゲームシステムでのルーティング
信号の流れをルーティングと呼びます。ゲームシステムでも音声信号のルーティングを考えると、必要な音声システムを実装しやすくなります。
音声機能の要件をそのままルーティングに起こしたものが左図です。本ゲームでは単純に音声の種別ごとにトラックを設け、AudioMixerで信号を1本にまとめてAudioListenerへ出力、AudioListenerで最終出力が作られてスピーカで再生されます。1本のトラックが1本のAudioSourceに対応します。必要に応じてAudioMixerをネストさせれば、ミキシングの拡張性も得られます。
しかし、WebGLではAudioMixerに対応していないため、そのまま実装することが出来ません。そのため右図のようにルーティングを変更しました。ebGLでは各トラックをそのままAudioListenerへ出力し、AudioListenerで信号が1本にまとまって最終出力となります。
左右のルーティングの違いにより、音量GUIの全体音量の実装が違ってきます。左図の場合はAudioMixerの音量を調整すると全体音量を調整できますが、WebGL版ではAudioListenerの音量を調整します。
UnityのWebGL対応でサポートしている音声APIの説明です。
WebGLでの機能制限は、Chromeの自動再生ポリシーが関係しています。
ちなみにWebGLを考えない場合、Unityではエディタ上でミキシング設定ができます。
クラス設計
クラス間の関係
WebGL版のルーティングを元に、音声システムのクラス構造を設計します。以下の概念が主要なものとなります。
・マイク(AudioListener)
・音源(AudioSource)
・音声ファイル(AudioClip)
・トラック(BGM、SEなど音声の各系統)
・ミキサ(全体音量を操作する仕組み)
・音量GUI
音声システムは一つのシステムですが、音声処理の本体とGUI周りという複数のコンテキスト(ドメイン駆動設計における「境界付けられたコンテキスト」)が関わります。そのため、クラス設計でもコンテキストを分けて考えると、クラスの関係性を整頓しやすくなります。
ヤード間のインタフェース
音声システムは外部から操作を受けるシステムなので、外部とのインタフェースが必須となります。この「インタフェース」は単なるC#のinterfaceキーワードではなく、外部と共有する情報という概念を指します。
特に音声の再生に関して、そのパラメータを外部ヤード(演出オブジェクトやシーンUI)から指定する必要があります。
・音声ファイル
・再生に用いるトラック名
・音量や定位感などのパラメータ
この再生パラメータは表現こそ値オブジェクトですが、その役割は外部とトラックとのインタフェースです。
ここで、上記のトラック名は外部から指定します。つまり外部ヤードはトラック名に依存します。一方、トラックオブジェクトは音声システム本体に属します。問題となるのは、以下の2点について公開インタフェースかシステム内部のどちらに所属させるのかです。
・トラック名
・トラック名とトラックオブジェクトとを関連付ける連想配列
結論としては以下の通りです。
・トラック名、トラックオブジェクトともにViewヤードに所属
・トラック名は規約で固定し、公開インターフェースに所属
・トラックオブジェクトはシステム内部に所属
・トラック名からトラックオブジェクトへの連想配列はシステム内部に所属
依存関係を文章上で考えるとややこしいのですが、図で書くと考えやすくなります。
まず、保守できるトラックの種別を決めるのは、ハードウェアの制約を持つViewヤード側です。そのためトラックオブジェクトはViewヤードに所属します。トラックを指定するための連想配列は外部から参照されますが、連想配列はトラックオブジェクトを持つので、連想配列もViewヤードに所属します。
一方で外部ヤードがどのトラックを利用するのかを指定するには、連想配列のうち外部に公開できる要素が必要です。しかも実用上はマップシーンやホームシーンなど別のコンテキストに属する別のヤードから参照されます。
そこで連想配列のうちトラック名を外部に公開します。ただしViewヤード側でトラック名を一方的に変更されると、トラック名を用いる外部ヤード側が困ります。そのため、トラック名は単なる文字列ではなく、Viewヤードや外部ヤードの両方が守る規約として作成し、一度決めたら変更しないようにします。具体的にはトラック名をEnumとして実装・公開します。
インスタンス間の関係
クラス間の関係とは別に、実行時のインスタンス間の関係もまとめておきます。各コンテキスト内のインスタンスが、コンテキストに対応するトラック内インスタンスに参照を持ちます。コンテキスト×トラックの表のような形の図を書くと、自分の中では分かりやすくなりました。
Unityエディタとの兼ね合い
Unity特有の事情として、エディタ上でパラメータを入力できると作業しやすくなるので、それを考慮してViewヤードとContentヤードに所属するクラスを設計します。具体的な論点は以下の通り。
・MonoBehaviourの派生クラスとして定義するクラスと、System.Serializable属性をつけるクラスを整理
・トラックオブジェクトの連想配列の持たせ方
・AudioSourceをどのGameオブジェクトに持たせるか(AudioSourceはコンポーネントとして実装されているため)
・AudioSource側のパラメータの初期設定と、トラックオブジェクトへの反映方法
連想配列はMonoBehaviour上では(プラグインなし*では)表示できないので、Behaviour上ではキー名を持たせたオブジェクトのリストとして定義し、ゲーム起動時に連想配列を生成するようにします。
各トラックは個々に音量GUIを持つ、つまりGameObjectを持つので、トラックのAudioSourceコンポーネントはトラックの音量GUIにアタッチすることにします。全体音量のGUIにはAudioSourceをアタッチしません。AudioSource側のパラメータの初期設定はエディタ上で行い、ゲーム起動時にトラックオブジェクトへ反映します。
イベントチャート
クラス設計と並行して、処理の流れとオブジェクト間の情報伝達を検討します。クラス設計の変更をイベントチャートに適用したり、逆にイベントチャート上で処理の流れが煩雑になる場合はクラス設計を変更したりします。
本ゲームでは、Viewヤードをその外部から扱いたい場合、窓口であるSceneDirectorを呼び出す設計にしています。これによりView内部のBehaviourに関連を持たなくてよいようにしています。
情報伝達の流れを考えておくのは、コールバックが必要な箇所を明らかにするためです。音量GUI側から音量を変更する処理は、依存関係の方向がトラック→ウィジェットに対して、情報伝達の方向はウィジェット→トラックと逆になるので、Setコールバックが必要だと分かります。
なおUnityではGUIの操作とAudioSourceのパラメータ変更を直接連結できるのですが、パラメータ変更をトラックオブジェクトに反映させないと音声システム上の一貫性を保てません(特にデータセーブが絡む場合)。そのため、トラックオブジェクトを介して音量GUIの操作をAudioSourceの音量パラメータに反映させています。
音声操作メソッドの種類(現状)
現時点で本ゲームシステムが使えるメソッドは以下の通りです。Unityのメソッドをほぼそのまま呼び出しています。
・Play():主にBGM用。音声を先頭から再生。AudioSource側のループ再生に対応
・PlayOneShot():主にSE用。音声を1回だけ再生。ループしないが、音声を重ねて再生できる
・Stop():音声再生を停止し、再生位置を先頭へ移動
・Pause():音声再生を一時停止。UnPause()で一時停止を解除可
・UnPause():一時停止を解除し、再生を再開
Unityの音声操作メソッドには注意点がいろいろあります。(Unityに限った話ではないですが)
・Pauseした音声をPlayすると、先頭からの再生
・Stopした音声は、その後PlayやUnPauseしても先頭からの再生
またPlayOneShotにも注意点がいろいろとあります。
・引数で指定したAudioClipとvolumeは、AudioSourceに設定されない
・AudioSource側でループONにしていても、PlayOneShotでループ再生はしない
全体音量調整に関して、WebGL版への対応ではAudioListenerのプロパティを直接変更します。厳密にはプロジェクト設定を操作しています。
・pauseプロパティ:一時停止するかどうか。
・volumeプロパティ:ゲームシステム全体の音量。
AudioListener.pauseプロパティに関しては注意点があります。一時停止後に解除すると、停止時点の音声の続きを再生します。一時停止中の時間経過は反映されません。(特にBGMに関して注意)
作ったもの
GUIとインスペクタ上の設定
作った音量GUIとパラメータ設定は以下のような感じ。
トラック名、再生有効(Speaking)ボタン、音量スライダーを設けました。まだ仮の見栄えとはいえ、デザインセンスは私にはない。
GUIのウィジェットのイベントハンドラには、音量およびSpeaking状態のSetコールバックを呼び出すプロパティを登録します。イベントハンドラの登録は、メソッドではなくプロパティを与えておかないと上手く動作しないので要注意です。
コード実装(抜粋)
/** ミキシングなど音声処理を行うBehaviour **/
public class MixerBehaviour : MonoBehaviour
{
[SerializeField] private SceneDirector _SceneDirector = null;
[SerializeField] private Track _GlobalVolumeTrack = null;
[SerializeField] private List<Track> _TrackList = new();
private Dictionary<TrackType, Track> _TrackDict = new();
void OnEnable()
{
//GUI操作時のコールバックを設定する
_GlobalVolumeTrack.VolumeSlider.SetVolumeCallBack = SetGlobalVolumeLevel;
_GlobalVolumeTrack.VolumeSlider.SetSpeakingCallBack = SetGlobalSpeaking;
//全体音量GUIに反映させる
_GlobalVolumeTrack.SyncParamToSlider();
//連想配列を設定済みの場合は何もしない
if (_TrackDict.Count > 0)
{
return;
}
//連想配列を設定
foreach (var track in _TrackList)
{
_TrackDict.Add(track.TrackType, track);
track.SetCallback();
track.SyncParam();
}
}
//ゲーム全体音量を設定。AudioListenerに反映させる
public void SetGlobalVolumeLevel(float db_volume)
{
if (_GlobalVolumeTrack == null)
{
// スキップ
return;
}
else
{
//境界値判定は省略
// ...
//dBから振幅に変換
float volume = _GlobalVolumeTrack.DB2Amp(db_volume);
//SceneDirectorから全体音量を設定
_SceneDirector.SetGlobalVolume(volume);
}
}
//ゲーム全体の再生・ミュートを設定。AudioListenerに反映させる
public void SetGlobalSpeaking(bool speak)
{
if (_GlobalVolumeTrack == null)
{
// スキップ
return;
}
else
{
//SceneDirectorから全体再生を設定
_SceneDirector.SetGlobalSpeaking(speak);
}
}
}
//一部のメソッドを抜粋
public class SceneDirector : MonoBehaviour
{
//ゲーム全体の音量を設定
public void SetGlobalVolume(float volume)
{
if (volume < 0.0f)
{
//下限0.0fに丸める
volume = 0.0f;
}
else if (volume > 1.0f)
{
//上限1.0fに丸める
volume = 1.0f;
}
AudioListener.volume = volume;
}
//ゲーム全体の再生・一時停止を設定。trueで全体を再生
public void SetGlobalSpeaking(bool speak)
{
AudioListener.pause = !speak;
}
}
/** 演出オブジェクトとトラックとのインタフェース **/
//トラックが扱えるパラメータ
[System.Serializable] public class TrackParam
{
public bool Speaking = true;
public float Volume = 1.0f;
public float Pan = 0.0f;
public float Reverb = 0.0f;
public float Spread = 0.0f;
}
//トラックの種別
public enum TrackType
{
None = 0,
BGM,
UI,
BattleEffect,
Voice,
}
//音声再生時にMixerに指定するパラメータ
[System.Serializable] public class PlayParam
{
public AudioClip AudioClip;
public TrackType TrackType;
public TrackParam Override;
}
/** トラックの定義 **/
[System.Serializable] public class Track
{
public TrackType TrackType;
public AudioSource AudioSource; //音声の出力先となるオブジェクト
public VolumeSliderAccessor VolumeSlider; //音量GUI
[SerializeField] private TrackParam _Param;
//プロパティは省略
//再生時のパラメータを設定する。現状はAudioSourceの数値仕様をそのまま与える
private void SetParam(TrackParam param)
{
_Param = param;
//与えられたparamをAudioSourceに反映させる
AudioSource.volume = _Param.Volume;
AudioSource.panStereo = _Param.Pan;
AudioSource.reverbZoneMix = _Param.Reverb;
AudioSource.spread = _Param.Spread;
AudioSource.mute = !_Param.Speaking; //muteはtrueでミュートとなるので、真偽を反転
}
//設定済みのパラメータをAudioSourceとVolumeSliderに反映させる
public void SyncParam()
{
SetParam(_Param);
//VolumeSliderに反映
SyncParamToSlider();
}
public void SyncParamToSlider()
{
//VolumeSliderに反映
VolumeSlider.ChangeVolume(VolumeSliderLevel(_Param.Volume)); //振幅→dBに変換
VolumeSlider.ChangeSpeaking(_Param.Speaking);
}
//GUI操作時のコールバックを設定する
public void SetCallback()
{
VolumeSlider.SetVolumeCallBack = SetVolumeLevel;
VolumeSlider.SetSpeakingCallBack = SetSpeaking;
}
/** 音声再生系メソッド **/
public void PlayOneShot(AudioClip se, float volume = 1.0f)
{
AudioSource.PlayOneShot(se, volume);
}
//以下略
}
/** 音量GUI、スライダーバーの処理 **/
public class VolumeSliderAccessor : MonoBehaviour
{
public Action<float> SetVolumeCallBack;
public Action<bool> SetSpeakingCallBack;
[SerializeField] private Slider Slider;
[SerializeField] private Toggle Speaking;
public float MinValue => Slider.minValue;
public float MaxValue => Slider.maxValue;
//イベントハンドラとして使用。プロパティにしないと意図通り動作しない
public float SetBackVolume { set { SetVolumeCallBack(value); } }
public bool SetBackSpeaking { set { SetSpeakingCallBack(value); } }
public void ChangeVolume(float volume){ Slider.value = volume; }
public void ChangeSpeaking(bool speak) { Speaking.isOn = speak; }
}
参考:音量の振幅と音圧レベル(dB)
Unityで設定する音量値は振幅にかかる係数です。一方、音声処理で一般的な数値は音圧レベルです。これは振幅の対数に比例する値で、dB(デシベル)単位で扱われます。音圧レベルでの変化の方が人間の聞こえ具合に近いといわれています。実際に、振幅よりdB単位での調整の方が小さい音量を調整しやすいです。
小野測器 技術レポート「dB(デシベル)とは」https://www.onosokki.co.jp/HP-WK/c_support/newreport/decibel/index.htm
・音圧レベルは対数に比例
・音圧レベルは $${20log_{10}}$$で計算している
・ほぼ-6dBで振幅が1/2(-12dBで1/4)
この3つを覚えていれば、日常生活では足りると思います。
音量GUIのスライダーはdB値で変化させる方が人間にとって音量調整しやすくなります。一方、AudioSourceの音量設定は振幅ベースなので、コード内でGUIから取得したdB値を振幅に変換させる必要があります。
参考:Spreadエフェクトと音像操作
UnityのAudioSourceにはSpreadというエフェクトをかけられます。自身の学習も兼ねて、このエフェクトの挙動をまとめました。
Spreadは音源の音像を広げるエフェクトです。一点の箇所に位置する音源にSpreadをかけると、点ではなく広がりがあるように聞こえます。またSpreadは音像を広げる角度を指定するのですが、180度を超えると音像は「後ろ」に回り込みます。この仕様が、後述の謎の挙動を理解する上でのポイントです。
注意点として、ふつうSpreadは中央に定位する音源に対して使います。
Spreadは音源とユーザを結ぶ線に対して左右対称にかかります。スピーカが2ch(ステレオ)の場合、中央に位置する音に対してSpreadの効果が最も分かりやすくかかります。
ここで、中央にない音源にSpreadをかけた場合の音像を考えてみます。Spreadの音像は音源とユーザを結ぶ線を中心線として音が広がり、180度を超えると、前後左右の2D空間上で回り込みます。
スピーカが5.1chの場合は音像で前後の区別が出来るのですが、2ch(ステレオ)の場合、前後の区別ができません。このとき、Spreadの効果のうち左右の成分しか音像の変化として知覚できません。
そのため、中央にない音源にSpreadをかけた場合、一般的なステレオのスピーカやイヤホンでは、Spreadの効果をあまり感じにくくなります。
ここで、ユーザのちょうど右にある音源にSpreadをかけた場合を考えます。かなり混乱した挙動がうかがえます。私も混乱しました。
実はUnityのマニュアルの記述もこの場合の聞こえ方を書いたものです。
Spread の仕様を正確に書くと、以下のようになります。
・Spreadは音源とユーザを結ぶ線を基準線として、左右対称にかかる
・Spreadは基準線に対して音像を広げる角度を指定する。このとき、音像は基準線に対する垂直線の方向に広がることになる。
→180度を超えると音像は垂直線の「後ろ」に回り込む
この仕様を理解しておくと、ユーザの右にある音源にSpreadをかけた場合の聞こえ方を説明できるようになります。
まず、音源とユーザを結ぶ基準線は、ユーザに対して左右方向の線となります。そしてその垂直線は、ユーザに対して前後方向の線です。
Spreadによる音像は垂直線の方向に対して広がります。つまり、ユーザに対して前後方向に広がることになります。
これを踏まえたうえで、Spreadのかかり方を考えます。太字がブログ記事内の検証やマニュアルに書かれた聞こえ方です。
Spread 0度:音像は広がらない
→音像はユーザの右に位置したまま
→スピーカが片側だけ鳴っている=モノラルのように聞こえるSpread 90度:基準線の垂直線の方向に音像が広がる
→音像はユーザの前後方向に広がる。右方向の定位は中央寄りになる
→ステレオの場合、前後は区別できず、やや中央寄りの右側に聞こえるSpread 180度:基準線に対する垂直線の位置に音像が位置する
→ユーザのちょうど前後に音像が位置する
→ステレオの場合、前後は区別できない。音は中央に聞こえるSpread 270度:垂直線から回り込んで音像が広がる
→音像はユーザの前後方向に広がり、左寄りの定位となる
→ステレオの場合、前後は区別できず、やや中央寄りの左側に聞こえるSpread 360度:ユーザに対して反対側に音像が位置する
→音がユーザの左に位置して聞こえる
参考:ヤードによる設計手法の一部見直し
ユーザと直接関与するシステム層のヤード(保守分掌)は現状ではViewヤードにまとめていますが、その中でもいくつか細かな区分が必要になってきました。そこで、ドメイン駆動設計における「境界付けられたコンテキスト」を元に分類すると整理しやすくなりました。
コンテキストはシステムの設計時の意味付けを表していて、ヤードは保守時の分担を表しているので、コンテキストとヤードは独立に考えられる概念です。とはいえ、分類の趣旨としては意味付けの方を上位に持ってきた方がよいです。そのため、コンテキストを上位・ヤードを下位として分類するように設計手法を見直したいです。
ちょうど似たようなことを考えている記事があったので、これを参考にしたいです。記事の内容はディレクトリ分割の方針ですが、その意図は同じです。