ホーム < ゲームつくろー! < Unity/サウンド編

サウンド編
その5 SEの同時発生数問題を考えてみる


 前章まででSoundPlayerシングルトンを介してSEとBGMが鳴るようになりました。サウンドの登録周りがまだ適当ですが、その前に解決しておきたい問題があります。それは「SEの同時発生」です。

 例えば、STGでボムを使って画面内にいる100体のザコ敵を「同じフレーム」で爽快に破壊したとしましょう。この時、もしザコ敵に爆発SEを仕込んでいたとすると、同じフレームで同時にその爆発SEが100個分重なって鳴る事になります。これ、どうなるかというと、PCにもよりますが、「ギャギガガガガ!!!」という爆発音とは到底思えないような酷い音が鳴ります。

 何故そんな事になるのか?それはPCの音声が「デジタル合成」であるためです。音データは最終的には波形の形を数値化して並べた配列になっています。2つ以上の音声を合成する時は、同じ時刻に該当する数値を足し算します。これは波の合成と同じ考え方です。

 しかし、アナログな音と違い、デジタルな音には「数値の限界」があります。16bitのサンプリング音であれば、上限(下限)は±37768です。合成の結果波の高さがこれ以上の数値になった場合、多分殆どのサウンドドライバはこの上限値で波形の形をクランプします。図に描くとこういうイメージでしょうか:

2つの全く同じ波形を合成すると、真ん中の図のように波の高さが際立つ事になります。枠からはみ出た部分はデジタルでは数値的に表現できないため、クランプされてしまいます。結果として右のような波のてっぺんが平らな矩形波のような波になるわけです。これが3つ4つと重なれば重なるほど、波形は矩形波になります。まして100個も重なった日には極端な矩形波&大音量となるため、「ギャギガガガガ!!!」という変質した音になってしまうのです。

 前置きが長くなりましたが、SEが同時に近いタイミングで沢山重なる可能性がある場合に、そのまま素直に合成してしまうととても耳障りな音になってしまいます。これを防ぐには、合成するSEの数を減らす必要があります。前章までのSoundPlayerのSE再生は何も工夫していない状態であるため、何とかしなければなりません(-_-;



@ 実際に重ねて聞き比べてみよう

 SoundPlayerクラスのplaySEメソッドの中身はこうなっています:

SoundPlayer.playSE
public bool playSE( string seName ) {
    if ( audioClips.ContainsKey( seName ) == false )
        return false; // not register

    AudioClipInfo info = audioClips[ seName ];

    // Load
    if ( info.clip == null )
        info.clip = (AudioClip)Resources.Load( info.resourceName );

    // Play SE
    audioSource.PlayOneShot( info.clip );

    return true;
}

 太文字までの所は指定した名前のAudioClipがあるかをチェックしている所でして、実質的にはAudioSource.PlayOneShotメソッドで音を瞬時に鳴らしているだけです。このため、どこかでforループでも回して同じSEを同時に鳴らす事が簡単にできます。

 そこでテストとして、440Hz("ラ"の音)のサイン波を用意して、これを1、5、10、30、60、100個重ねた音を聞き比べてみましょう:

 サイン波重ね合わせテスト:UNI_SND_No5_Test01.html

"only 1"が440Hzのサイン波です。ぽわ〜っとした柔らかい音ですよね。これが5個重なるだけで、何だかとげとげしくなります。30個ほどになると、もうギシギシとした棘のある矩形波に近くなっているのがわかります。"Square"は機械的に作成した440Hzの矩形波です。"100 times"も同じような音質で聞こえるのが確認できると思います。

 このように、純粋なサイン波ですら重ねると棘のある矩形波な音質に変わってしまいます。これが爆発音などの様々な周波数が含まれる音となると、さらに激しく音が変質してしまいます。



A 今鳴っているSEを知るには?

 同じ音の重なりが起こると音が酷く変質する。これは防がなければなりません。一番簡単な方法は、100個同時に鳴るように命令されたとしてもせいぜい数個だけ鳴るように限定すれば、上の"5 time"以下の変質で抑えられます。

 SoundPlayer.playSEメソッドの引数には鳴らしたいSEの名前が飛び込んできます。ですから、今鳴っているSEの数がわかれば数を制限できます。今なっているSEを知るにはAudioSource.isPlayingプロパティをチェックすれば良いのですが、ここで問題が噴出します。AudioSource.isPlayingプロパティは音が鳴っているか鳴っていないかだけがわかるのみで、「いくつ鳴っているか」は教えてくれないのです。つまり、AudioSouce.PlayOneShotメソッドに流した後にそういう経過情報を取る事は出来ません。

 一つ考えられる方法としては、AudioSourceで鳴らす音を1つに限定するというのがあります。これならばどのAudioSourceがどのSEを鳴らしているかわかりますし、isPlayingプロパティで終端検知もできます。しかし、この方法はちょっと「ん〜〜」と思う所があります。それは、SEを鳴らす度にAudioSourceを作らなければならないという点です。AudioSourceはコンポーネントなので、GameObjectにくっついていないと存在できません。音を鳴らす度にGameObjectを作ってヒエラルキに投げ、鳴り終わったらDestroyする。ちょっと仰々しすぎる感じがしますし、パフォーマンス面でも考えものです。

 そこで、SEを鳴らした瞬間にそのSEから鳴る時間を取得し、それをSE時間リストに追加するというのを考えてみます。リストは毎フレームチェックして、逐一時間を減らしていきます。もし時間が0以下になったら、そのSEはもう鳴り終わっているはずなので、リストからそれを外します。こうすると、リストの要素数が現在鳴っているSEの数になります。



B SE時間リスト

 SoundPlayer.playSEメソッドの中で鳴らす予定のSEからその長さを取得します。これはAudioClip.lengthプロパティで取得できます。SoundPlayerクラスでは各SEをAudioClipInfoというサブクラスで管理していましたので、そこから得る事ができます:

SEの長さを取得
public bool playSE( string seName ) {
    if ( audioClips.ContainsKey( seName ) == false )
        return false; // not register

    AudioClipInfo info = audioClips[ seName ];

    // Load
    if ( info.clip == null ) {
        info.clip = (AudioClip)Resources.Load( info.resourceName );
        if ( info.clip == null )
            Debug.LogWarning( "SE " + seName + " is not found." );
    }

    float len = info.clip.length;

    if ( info.playingList.Count < info.maxSENum ) {
        info.playingList.Add( len );

        // Play SE
        audioSource.PlayOneShot( info.clip );

        return true;
    }

    return false;
}

AudioClipInfoクラスの中にplayingListというfloat型のリストをメンバとして追加して、各SE毎の鳴っている音数を管理してもらう事にしました。ついでなので最大同時発生音数をmaxSENumとしてSE毎に設定できるようにもします。

 各AudioClipInfo.playingListは毎フレームチェックして更新します:

SEの長さを更新
public void update() {

    // playing SE update
    foreach ( AudioClipInfo info in audioClips.Values ) {
        List<float> newList = new List<float>();
        foreach ( float len in info.playingList ) {
            float newLen = len - Time.deltaTime;
            if ( newLen > 0.0f )
                newList.Add( newLen );
        }
        info.playingList = newList;
    }

}

ループ中のリストの要素消去は一般的にご法度です。そこで上では空のテンポラリリストを作り、「次に残したい要素」を追加しています。そして現在のplayingListを上書きしてしまいます。これは「ダブルバッファ」と呼ばれる手法で、更新方法として良く使われます。

 これでSE数に上限を設けられたはずです。先の440Hzのサイン波について制約数を5としたテストを作ってみました:

 サイン波重ね合わせテスト(制約5):UNI_SND_No5_Test02.html

 "5 times"までは音が同時に重なるのでちょっと棘っぽく聴こえます。しかしそれ以上にしても"5 times"と変わりません。ちゃんと同時発生数が抑えられているためです。ちょっと進展しました(^-^)。ただ、"5 times"の音はやはりちょっときつく感じます。これを軽減する方法は無いものでしょうか?



C せいぜい元の音量で

 音がどぎつく感じるのは冒頭にあるように波形が壊れるためです。であれば「波形が壊れない」事を保証すれば良いのではないでしょうか?

 一つの方法として「重ね合わせる音の音量を下げる」というのがあると思います。波形の高さは音量を表します。重ね合わせた後で、最も音量が大きくなっている最大値を超えた所を基準に全体の音量を等しく下げてあげれば、波形が壊れる事無く音を合成出来ます。これは「Normalize(正規化)」と呼ばれたりする方法で、波形編集ソフトなどには大抵ついています。ただ、この方法はオフラインでは出来るのですが、ゲームのようなリアルタイムな発音環境では無理です。そのため、別の方法を考えなければなりません。

 別の方法として、同時に鳴らす音の音量を最初は0.5に、次は0.25に…というように、2で割った数にしていきます。こうすると無限に重ねたとしても音量が元の音の音量を超える事はありません。ちょっと数式を書くと、

「無限等比級数の和」というやつで、高校で習いましたよね。

 ただ、最初の1音でも音量が半分になってしまいます。これはちょっと厳しい物がありますので、最初の1音目の音量をvとして、同時発音数をnとして、全部が同時になっても元の音量になるような減衰値を求める事にしましょう。いきなり算数のお話になってきました(^-^;

 減衰率をpとしておきます。上の例では減衰率はp=0.5ですね。最初の音量がvなので、2番目はv*p、3番目はv*p*pと計算できます。同時発音数がnという事は、一番小さい音はv*(p*p*p*...*p)とpがn-1個掛け算される事になります。これらを全部足して1(元の音量)になるようにしてあげたいというわけです。先の式と同様の計算式で表してみます:

先程は無限でしたが、今回は上限があるので有限です。有限等比級数の和は一番下のような一般式になります。

 このSが1で、vは予め決めている定数なので、

となり、このpを求めれば良いのですが…これ、多分解析的には解けません(T-T)。なのでニュートン法を使って近似してしまいましょう。

 ニュートン法は方程式の解を求める数値計算方法です。詳しい方法はこちらで示すとしまして、ざっくり核心部分だけ言えば、

と元の式と微分式から、

という漸化式でpi+1を求めて行きます。f(pi)が十分に0に近付いたら計算をやめます。近似速度は式によって異なりますが、上の式のように単純なものであれば数回漸化式を回すだけで答えに辿りつきます。

 ニュートン法を行うクラス(NewtonMethod)はこんな感じでしょうか:

ニュートン法クラス
using UnityEngine;
using System.Collections;

public class NewtonMethod {
    public delegate float Func( float x );

    public static float run( Func func, Func derive, float initX, int maxLoop ) {
        float x = initX;
        for ( int i = 0; i < maxLoop; i++ ) {
            float curY = func(x);
            if ( curY < 0.00001f && curY > -0.00001f )
                break;
            x = x - curY / derive(x);
        }
        return x;
    }
}

runメソッドの引数にデリゲータで関数f(x)と微分関数f'(x)をそれぞれ渡すと、勝手に解を求めてくれます。

 SoundPlayer.AudioClipInfoクラスに最大同時発生数及び初期音量(1個目の音量)を登録する時に、このニュートン法で個数に対する減衰率pをさくっと求めてしまいましょう。該当箇所はこんな感じになります:

ニュートン法で合成時減衰率pを求める
class AudioClipInfo {

    public int maxSENum = 10;        // 同時最大発音数
    public float initVolume = 1.0f;  // 1個目のボリューム
    public float attenuate = 0.0f;   // 合成時減衰率

    public AudioClipInfo( string resourceName, string name, int maxSENum, float initVolume ) {
        this.maxSENum = maxSENum;
        this.initVolume = initVolume;
        attenuate = calcAttenuateRate();
    }

    float calcAttenuateRate() {
        float n = maxSENum;
        return NewtonMethod.run(
            delegate( float p ) {
                return ( 1.0f - Mathf.Pow( p, n ) ) / ( 1.0f - p ) - 1.0f / initVolume;
            },
            delegate( float p ) {
                float ip = 1.0f - p;
                float t0 = -n * Mathf.Pow( p, n - 1.0f ) / ip;
                float t1 = ( 1.0f - Mathf.Pow( p, n ) ) / ip / ip;
                return t0 + t1;
            },
            0.9f, 100
        );
    }
}

 ごにょごにょと書いていますが、要は合成時減衰率attenuateを同時最大発音数と1個目のボリューム値から求めただけです。

 ここから、そのSEが重なって鳴る場合に、例えば5つ目の重なり音のボリュームは initVolume * Mathf.Pow( attenuate, 5 ) と計算できます。



D SE内の空き発音番号に該当する音量で音を鳴らす

 さて、ちょっと煩雑になって来ましたのでちょっと整理をします。SEが同時になると刺々しい矩形波で音が鳴ってしまういます。それを避けるために最大同時発音数を設けました(SoundPlayer.AudioClipInfo.maxSENum)。しかし、音量の大きいSEを数個重ねてもやっぱり歪む。そこで、最大同時発音数分重ねた時に音量が1(元の音の音量)になるように、重なるSEの音量を調整しようとしたのがCです。結果として、例えば下のグラフにあるような、SE内の発音番号と音量を volume = initVolume * Mathf.Pow( attenuate, i ) という簡単な式から求められる事がわかりました:

これは1個目の音量を0.6とし、最大同時発音数を12個とした場合の各発音番号に対するボリューム値を計算したものです。これを見るとこの条件だと5個目くらいでもう聴こえない程小さい音になるのがわかります。この辺りの最適化は後のお話として、各SEに対して「音を鳴らして下さい!」と要求が来た時にどういう音量で鳴らせばよいかは、このグラフの横軸にあたる「空き発音番号(横軸)」がわかればたちどころに計算できるというわけです。

 所で、今SoundPlayerのSEの発音管理はAudioClipInfo.playingListというリストに現在の発音時間を刻む事で行っていたのでした。これは「今いくつの音が鳴っているか」はわかるのですが、「今何番目の音が鳴っているのか」はわかりません。

 そこで、

class SEInfo {
    public index;
    public curTime;
    public volume;
}

という小さなクラスを作り、これをstockListという空きSEを保持するリストに登録し、発音している時にはplayingListに登録し直すという仕組みに拡張する事にしましょう。

 stockListは「SortedList型」というコンテナにします。SortedListはその名の通り「ソート済みリスト」というコンテナの一つで、登録した時に勝手に番号順に並べ替えてくれます。SEを鳴らしたい時に、このSortedListに「一番番号の若い空き発音番号を下さい」と言えば、それを出してくれるわけです。こうする事で、今鳴らせる一番大きなSEを常に選択できます。

 AudioClipInfoクラスにstockListを追加し、コンストラクタでそれを初期化します:

class AudioClipInfo {
    public SortedList<int, SEInfo> stockList = new SortedList<int, SEInfo>();
    public List<SEInfo> playingList = new List<SEInfo>();
    public int maxSENum = 10;
    public float initVolume = 1.0f;
    public float attenuate = 0.0f;

    public AudioClipInfo( string resourceName, string name, int maxSENum, float initVolume ) {
        this.resourceName = resourceName;
        this.name = name;
        this.maxSENum = maxSENum;

        this.initVolume = initVolume;
        attenuate = calcAttenuateRate();

        // create stock list
        for ( int i = 0; i < maxSENum; i++ ) {
            SEInfo seInfo = new SEInfo( i, 0.0f, initVolume * Mathf.Pow( attenuate, i ) );
            stockList.Add( seInfo.index, seInfo );
        }
    }

    ....
}

playingListもfloat型からSEInfo型に格納する型が変わる事に注意です。SoundPlayer.playSEメソッドで実際に音を鳴らす時には、stockListから空で且つ番号が一番若いSEInfoを貰い受けます:

public bool playSE( string seName ) {

    ....

    float len = info.clip.length;
    if ( info.stockList.Count > 0 ) {
        SEInfo seInfo = info.stockList.Values[0];
        seInfo.curTime = len;
        info.playingList.Add( seInfo );

        // remove from stock
        info.stockList.Remove( seInfo.index );

        // Play SE
        audioSource.PlayOneShot( info.clip, seInfo.volume );

        return true;
    }
    return false;
}

 info.stockList.Values[0]とすると一番若い空き番号を貰えます。それに発音時間(len)を登録してあげて、playingListに追加ます。stockListから今渡したSEInfoを空き番号をキーとして消しておく(Remove)事を忘れずにです。AudioSource.PlayOneShotメソッドは第2引数にボリュームの割合を渡せますので、ここに今の空き番号が持つボリューム値を引き渡します。

 これで音は鳴ります。で、鳴り終わったSEの回収もしなければなりませんね:

public void update() {

    // playing SE update
    foreach ( AudioClipInfo info in audioClips.Values ) {
        List<SEInfo> newList = new List<SEInfo>();

        foreach ( SEInfo seInfo in info.playingList ) {
            seInfo.curTime = seInfo.curTime - Time.deltaTime;
            if ( seInfo.curTime > 0.0f )
                newList.Add( seInfo );
            else
                info.stockList.Add( seInfo.index, seInfo );
        }
        info.playingList = newList;
    }

}

 現在鳴っているSEの残り時間を計算して、鳴り終わっていたらstockListに回収しています。

 以上の改良を施すと、100個重ねても音が矩形波のように棘っぽくならなくなります。テストはこちら:

 サイン波重ね合わせテスト(重ね合わせ音量調整):UNI_SND_No5_Test03.html

テストは440Hzのサイン波を最大発音数100、1個目の音量0.2で、先のアルゴリズムで重ね合わせしています。これを聞くと100個重なってもちゃんとサイン波の柔らかい音になっていますし、1個の時と比べると音量も大きくなっています。ただ、30個目くらいでも音量として殆ど最大に近く聞こえます。これ、何故かなぁと調べてみるとびっくり、初期音量0.2で最大発音数100の場合、最初の30個で全体音量の99%を占めているのがわかりました。残り70個はたった1%の音量を上げる為だけに使われます。つまり、同じSEの同時重ね合わせを100個もするのは実は無駄で、せいぜい10個も重なれば十分である事がわかります。


 この章では同時発音数の問題を解決する方法について考えてきました。SoundPlayerクラスもこれでパワーアップ(^-^)。良い感じでサウンドを鳴らせるようになってきました。