はじめに
この記事はサイバーエージェント 26卒内定者エンジニア アドベントカレンダーの6日目の記事です。
有志の内定者が記事を書いてくれていますので、ぜひご覧ください。
この記事について
この記事は現在サウンドプログラマーを目指している、私がサウンドマネージャーに必要な機能や自分の考えを記載します。
個人開発で使用しているサウンドマネージャーをベースとして話しますが、プログラムは今も改修し続けています。
そのため完璧ではないと思いますが、考えるきっかけになれば幸いです。
自己紹介
サイバーエージェント、ゲームクライアントエンジニア内定者のあつあつです!金沢工業大学の4年で、CirKitという団体でリーダーをしていました。
サイバーエージェントでは、内定者間のコミュニケーションを促進する「活性化チーム」を立ち上げ、本アドベントカレンダーの主催などを担当しました。
前提
紹介するサウンドマネージャーはミドルウェアを使用せず、2Dのゲームで採用しているものです。
そのため立体音響などの実装は含まれません。ただし、後半でそこについても考察していきます。
サウンドマネージャーって?
サウンドマネージャーとはBGM・SE・VOICEなどあらゆるゲーム内のサウンドを統括するマネージャークラスのことです。
基本的な実装
私が実際に個人開発で使っているサウンドマネージャーがこちらです。
具体的な実装は省いていますが、ほとんどこのような実装だと思います。
public sealed class SoundManager : SingletonMonoBehaviour<SoundManager>
{
private Queue<AudioSource> _bgmSources;
private Queue<AudioSource> _seSources;
private Queue<AudioSource> _voiceSources;
private List<VoiceHandle> _voiceHandles;
protected override void OnAwake()
{
_bgmSources = FillAudioSource(4);
_seSources = FillAudioSource(10);
_voiceSources = FillAudioSource(4);
_voiceHandles = new(3);
}
public static async UniTask PlaySeAsync(string SEAddress)
{
}
public static async UniTask<VoiceHandle> PlayVoiceAsync(string voiceAddress, int voiceIndex, bool isUnique = true)
{
}
public static async UniTask<AudioHandle> PlayBgmAsync(string BGMAddress)
{
}
public static async UniTask FadeBgmAsync(AudioHandle currentHandle, string newBgmAddress, float fadeTime)
{
}
public static void StopBgm(in AudioHandle handle)
{
}
private Queue<AudioSource> FillAudioSource(in int count)
{
var result = new Queue<AudioSource>(count);
for (var i = 0; i < count; i++)
{
result.Enqueue(gameObject.AddComponent<AudioSource>());
}
return result;
}
}
SingletonMonoBehaviourの実装
public abstract class SingletonMonoBehaviour<T> : MonoBehaviour where T : Component
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
instance = (T)FindAnyObjectByType(typeof(T));
if (instance == null)
{
SetupInstance();
}
}
return instance;
}
}
public virtual void Awake()
{
RemoveDuplicates();
OnAwake();
}
private static void SetupInstance()
{
instance = (T)FindAnyObjectByType(typeof(T));
if (instance == null)
{
GameObject gameObj = new GameObject();
gameObj.name = typeof(T).Name;
instance = gameObj.AddComponent<T>();
DontDestroyOnLoad(gameObj);
}
}
private void RemoveDuplicates()
{
if (instance == null)
{
instance = this as T;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
protected virtual void OnAwake() {}
}
サウンドマネージャーで大切にしたこと
このサウンドマネージャーの大切にしたことは以下のとおりです。
- プロジェクトが大きくなっても大丈夫(スケーラビリティ)
- フェードの実装は絶対
- サウンド再生時にオーバーヘッドを最低限に
プロジェクトが大きくなっても大丈夫
一部の実装では、PlayXXXなど音源ごとにメソッドを用意している場合もありましたが、プロジェクトの拡大でコンフリクトなどの発生を考え、アドレス(ここではAddressablesを使用している)を渡して読み込むようにしています。
フェードの実装は絶対
フェードはサウンド実装の基本だと思います。(にしては考えること結構多いですが)
滑らかな画面遷移とともにサウンドが切り替わるだけで、体験は向上するでしょう。
また、フェードの時間を呼び出し側で定義できるのも大切です。
サクサク切り替わった方が良い場面と、そうではない場面はあると考えています。
サウンド再生時にオーバーヘッドを最低限に
これは基本的なことですが、事前にコンポーネントを一定の数用意することでサウンド再生時のオーバーヘッドを簡単にしています。
サウンドの指定をどうするか
本題からは少し離れますが、上記でPlayXXXという音源ごとにメソッドを作らない方針にしたことを記述しましたが、文字列の指定ではタイプミスなどが起きやすいです。
そこで採用したのは以下のOSSです。
サイバーエージェントのOSSのAddressDefinitionGeneratorです。とても便利。
あまり言うとただの宣伝になってしまうので、やめます。
本題
さて、ここからは実装を行った時にサウンドマネージャーの実装において問題になった部分を記載し、考察していきます。
AudioHandleは必要なのか
本サウンドマネージャーではメソッドを呼び出すと返り値として、AudioHandleを返して一定サウンドを呼び出し側で管理できるようにしています。
そのためのラッパーがAudioHandleです。
ただこのプロジェクトだとサウンドを監視してなにかをするということは全くありませんでした。
これはプロジェクトによるとは思いますが、ループSEなど開始・停止の管理をやるためのものだと考えられます。
一方で、必要な規模のプロジェクトになってくるとサウンドミドルウェア(CRI, Wwise)などの導入も進みそうですし、どちらにせよ輝く機会は減りそうです。
サウンドの波形や周波数などを利用してなにかを動かすというときには真に輝きそうですね!!
そういった実装はあまりみられませんが、やる分にはとても楽しそうです。
私の結論
必要になるケースは少なめだが、拡張性を考えるとあった方が良さそう。
待機可能である必要はある?
次に待機についてです。このプロジェクトではサウンドをロードする時には待機するような思想で使用していました。
しかし、ロードされていないサウンドだとロード時間で画面遷移が遅延するなど問題が発生しました。
非同期的に動いてくれることは大切ですが、それを待機する運用にするのは良くないかもしれません。
一方で上記のAudioHandleにも通じますが、サウンドを用いた表現を行うには待機したいです。
そのため、そもそもUI基盤側にグラフィックスとサウンドの並行処理(同時に行っているようにみせる処理)を行うような機能が備わっているとよりスムーズな遷移を実現できるのかもしれません。
私の結論
サウンドを前提としたUI基盤が欲しそう?
サウンドの個数が多くなり過ぎた場合は?
サウンドの個数が多くなって、事前に用意したオーディオソースが枯渇した時を考えます。(プロジェクトでは多くなり過ぎてはいないが、聞こえづらい音がありました。)
単にエンジニア的に解決するのであればオブジェクトプールのようにオーディオソースを追加すれば良いですが、音が多くなると一番聴かせたい音が潰れる現象が起きます。
この時は優先順位をつけてあげることが重要でしょう。
SoundManagerにサウンドごとのPriorityを導入して、優先度が高いものを鳴らすのです,,,だんだんとミドルウェアが欲しくなってきますね。
私の結論
たくさん鳴らしても仕方ない、優先順位をつけよう。
現在のBGMはサウンドマネージャーで管理すべき?
当初プロジェクトでは現在再生しているBGMはなにかなど、BGMの管理はサウンドマネージャーでは行っていませんでした。
SoundManagerの責務は音の再生・停止を一元化することにあると考えていたためです。
逆に何がなっていることが正解なのかはゲーム側に持たせるべきと考えていました。
しかし、その思想だとBGMのフェード処理がとても大変でした。
また、BGMが二重で再生されるなど意図しない問題も多発しました。これは再生ロジックに問題がありますが、そもそもそれが可能な状態は健全ではないと判断しています。
そのため後半ではBGMの管理もしてもらいました。
私の結論
難しいところですが、今は現在のBGMと前のBGMを保持してフェードのキャンセルなどを行えるようにしています。
さらに深く
ここからはよりサウンドの実装が求められる場所に関して、現在の考えを説明していきます。
3Dサウンドに関しては内定者バイトで触った程度ですが頑張って考えます。
事前ロードはどう実装すべきか
SEやBGMは再生タイミングがズレると致命的になることがあります。
こういった場合はSoundManagerにPreloadのようなメソッドを実装し、事前にロードするしかないでしょう。
しかし、緻密なメモリ管理が求められるプロジェクトでは事前ロードなどはメモリの圧迫につながります。
そのためシーンやUI基盤、タイムラインなどに対して使用するサウンドを定義・収集し使用後に破棄するといった管理が良いのでしょうか。
同じアセットを次の画面でも使用するのに破棄してしまうなどが起こり得て、大変そうですね...
私の結論
わからん。
間違いなく事前ロードは必要ですが、管理方法は難しいですね。
アセット管理はゲーム開発の難しいところの1つだと思うので、今後も向き合っていきたいです。
3Dサウンドはサウンドマネージャーで管理すべき?
さて、3Dサウンドで音源があるときその音もSoundManager経由で再生すべきなのでしょうか?
そもそもSoundManagerは再生・停止をプログラムからやりやすくする機構ですが、3Dサウンドでは独自のサウンド管理コンポーネントが生まれる傾向にあります。
そのため、管理コンポーネントからSoundManagerを経由する必要はないかもしれません。
ただし優先度などいくつか考慮すべきことはあり、Unity単体で実装していくことを考えるとSoundManagerを経由すべきでしょう。
ただし、専用のメソッドが必要になるためサウンドマネージャーの肥大化にはつながりそうです。
私の結論
Unity標準機能のみなら経由すべき、ミドルウェア導入なら経由しなくても良いかも?
まとめ
実装コードそのものより考察がメインの記事になりました。サウンド、楽しい。
ただ、普段サウンドに関してしっかり考える機会がない人も多いと思っており、考えるきっかけを提供できたなら幸いです。
明日もアドベントカレンダー公開予定ですし、自分もほかのアドベントカレンダー含めあと2記事ほど書く予定ですのでお楽しみに!