SE가 동시에 발생할 시 문제가있다.

예를 들어 슈팅게임에서 폭탄을 이용해 화면안에있는 100기의 적을 '한 프레임'에 기분좋게 파괴했다고 해보자. 이 때, 만약 이 적에게 폭발 SE가 달려 있다면 한 프레임에 동시에 100개의 폭발SE가 겹쳐서 울리게 된다. 이게 어떻게 되냐면 PC에 따라 다르겠지만 폭발 소리라고 생각되지 않는 지독한 소리가 난다.

왜 그렇게 되는걸까? 왜냐하면 PC의 소리는 "디지털 합성"이기 때문이다. 소리 데이터는 최종적으로 파형의 형태를 수치화하고 나열한 배열로 되어있다.  2개 이상의 소리를 합성할 때는 같은 시각에 해당하는 값을 합산한다. 이것은 파도의 합성과 같다.

하지만 아날로그음과 다르게 디지털음은 수치의 한계가 존재한다. 16bit 샘플링음의 상한(하한)은 +-37768이다. 합성의 결과값이 더 높은 수치가 될 경우 아마 대부분의 사운드 드라이버는 이 상한치에서 파형을 고정한다. 그림으로 그리면 이런 이미지다.

2개의 똑같은 파형을 합성하면, 중앙의 그림과 같이 파도의 높이가 두드러지게 된다. 테두리를 벗어난 부분은 디지털에서는 표현할 수 없기 때문에 고정된다. 결과적으로 오른쪽의 같이 파형의 꼭대기 부분이 구형파(역 : Square wave, y값이 1과 0으로 이루어진 그래프)와 같이 된다. 이렇게 겹치면 겹칠수록 파형은 구형파가 된다. 하물며 100개나 겹친다면 극단적인 구형파와 큰 음량 때문에 변질된 소리가 되어버리고 만다.

서론이 길어졌지만, SE가 비슷한 타이밍에 여러번 중첩될 가능성이 있는 경우 정직하게 합성하면 귀에 거슬리는 소리가 되어버리고 만다. 이를 막기 위해서는 합성하는 SE의 수를 줄여야한다.


1. 직접 중첩해서 듣고 비교해보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}
cs

12번째 라인은 지정한 이름의 AudioClip이 있는지 체크하는 곳으로 실질적으로 AudioSource.PlayOneShot 메소드에서 소리를 순식간에 낼 뿐이다. 그러니까 for문이라도 돌려 같은 SE를 동시에 내는것은 간단하게 할수 있다.

여기서 테스트로써 440Hz(음계 라)의 사인파를 준비하고 이것을 1, 5, 10, 30, 60, 100개의 겹친 소리를 듣고 비교해보자.

사인파 중첩 테스트(링크)

하나일 때엔 부드러운 소리가 나지만 5개만 겹쳐져도 날카로운 소리가 난다. 30개정도 되자 더욱 날카로우지며 구형파에 가깝다는걸 알 수 있다. Square는 기계적으로 작성한 440Hz의 구형파이다. 100개와 같은 음질로 들리는것을 확인할 수 있다.


2. 지금 울리고 있는 SE를 알아보자

같은 소리가 중복된다면 소리가 심하게 변질된다. 이것을 막아야한다. 가장 간단한 방법은 100개가 동시에 울린다고해도 고작 몇개정도만 울리게 한정한다면 변질을 막을 수 있다. SoundPlayer.playSE 메소드의 인수에는 소리를 내고싶은 SE의 이름이 들어온다. 그리고 지금 울리고있는 SE의 수를 알 수 있다면 그것을 제한하면된다. 지금 울리고있는 SE를 알려면 AudioSource.isPlaying속성을 체크하면 되지만 문제가 하나 있다. Audisource.isPlaying속성은 소리가 나고 있는지만 확인 할수 있을 뿐 몇개가 나고 있는지 알려주지 않는다. 즉, Audisource.PlayOneShot메소드가 실행된 후 그런 경과정보를 갖고있지 않다.

하나 생각할 수 있는 방법으론 AudioSource로 내는 소리를 하나로 제한하는 방법이 있다. 이거라면 AudioSource가 어떤 SE를 울리고 있는지 알 수 있고 isPlaying속성으로 소리가 끝났는지도 알 수 있다. 하지만 이방법엔 사용하기 망설여지는 점이 하나 있다. 그건 SE를 낼 때 마다 AudioSource를 만들어야 하는 점이다. AudioSource는 컴포넌트라 GameObject에 붙어있지 않으면 존재 할수 없다. 소리를 낼 때 마다 GameObject를 만들어 하이어라키에 붙이고 끝나면 Destory한다. 좀 거창한 느낌도 들고 퍼포먼스쪽도 생각해볼 필요가 있다.

여기서 SE가 울린 순간 그 SE의 시간을 취득 한 후 SE시간 리스트에 추가하는 것을 생각해보자. 리스트는 매 프레임 체크해 일일이 시간을 줄이고 만약 시간이 0이하가 된다면 그 SE는 끝났기 때문에 리스트에서 삭제한다. 이렇게 하면 리스트의 요소수가 현재 울리고있는 SE의 수가 된다.


3. SE시간 리스트

SoundPlayer.PlaySE메소드의 안에서 소리를 낼 SE로부터 길이를 가져온다. AudioClip.length속성에 있다. SoundPlayer클래스에서 각 SE를 AudioClipInfo라는 서브클래스에서 관리하고 있으므로 거기서 얻을 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
    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;
    }
cs

AudioClipInfo클래스 안에 playingList라는 float형 리스트를 맴버로 추가하고 각 SE별로 울리는 음의 수를 관리하도록 했다. 하는김에 최대로 동시에 발생하는 소리의 수를 maxSENum로 해서 SE마다 설정 할수 있도록 하였다.

각 AudioClipInfo.playingList는 매 프레임 체크해 갱신한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
    }
}
cs

루프문에서 리스트의 요소를 제거하는 것은 일반적으로 금기이다.(역 : 리스트의 사이즈 변화로 잘못된 참조 에러를 낼 가능성이 생긴다 - 이터레이터로 해결 가능, Update 안에선 GC가 생기는 foreach를 사용하는 것보다는 for문을 사용하는 것이 효과적) 그래서 임시 리스트를 만들어 "다음에 남길 요소"만 추가한다. 그리고 현재 playingList를 덮어쓰기 한다. 이것은 "더블 버퍼"라고 하는 방법으로 갱신할 때 자주 쓰인다.

이로써 SE 수에 제한을 둘 수 있게 되었다. 남은 440Hz 사인파에 대해 제한을 5개로 한 후 테스트를 만들어 보았다.

사인파 중첩 테스트 : 5개 제한 (링크)

5times도 약간 거친 소리가 들리지만 그 이상으로 재생시켜도 5times와 다르지 않게 되었다. 확실히 동시 발생 수를 억제 시키고 있기 때문이다. 조금 진정되었다. 하지만 5times소리도 역시 조금 거친느낌이다. 이걸 해결할 수 있는 방법은 없을까?


4. 겨우 원래 음량으로

소리가 거칠게 느껴지는 것은 처음에도 언급했듯이 파형이 깨지기 때문이다. 그렇다면 "파형이 깨지지 않는 것"을 보증하면 좋지 않을까?

하나의 방법으로 "중첩소리의 음량을 낮추기"가 있다고 생각한다. 파형의 높이는 음량을 뜻한다. 중첩된 후 가장 음량이 커진 최대치를 넘기는 곳을 기준으로 전체의 음량을 낮추어 주면 파형이 망가지지 않고 소리를 합성할 수 있다. 이는 'Normalize(정규화)'라고 하는 방법으로 파형을 편집하는 소프트등에는 대개 있다. 다만 이 방법은 오프라인(역 : 영화와 같이 결과물이 정해진)에서는 가능하지만, 게임과 같이 리얼타임 환경에선 무리이다. 그러므로 다른 방법을 생각해야한다.

다른 방법으로서 동시에 울리는 소리의 음량을 처음엔 0.5 그 다음은 0.25... 이런식으로 만들어 보자. 이렇게 하면 무한으로 겹쳤다고 해도 음량의 원래의 소리의 음량을 넘는 일이 생기지 않는다. 이것을 수식으로 표현하면,

"무한 등비 급수의 합"으로 고등학교때 배웠던 것이다.

하지만 처음 하나의 소리의 음량이 절반으로 되버린다. 이것은 별로 좋지 않기 때문에 첫소리의 음량을 v, 동시발생하는 소리의 갯수를 n이라고 하고 전부 동시에 소리가 나도 원래의 음량이 되는 감쇠치를 구하도록하자. 갑자기 수학의 이야기가 되엇지만.

감쇠율은 p로 두자. 위의 예에서의 감쇠율은 p=0.5이다. 처음의 음량이 v여서 2번째는 v*p, 3번째는 v*p*p와 같이 계산한다. 동시 발생 수가 n이란 것은 가장 작은 소리는 v*(p*p*...*p)에서 p가 n-1번 곱해진것이다. 이것들을 모두 합하면 1(원래 음량)을 향해 간다. 앞의 식과 같은 계산식으로 나타내보자.

이전까지는 무한이였지만 지금부터는 상한이 있기 때문에 유한이다. 유한 등비 급수의 합은 위와 같이 일반식이 된다.

이 S가 1이고 v는 미리 정한 정수이기 때문에

이 되고, 이 p를 구하면 좋겠지만... 이거 아무래도 해석적으론 못 풀겠다. 그래서 뉴턴법을 써서 근사치를 구해보자.

뉴턴법은 방정식의 해답을 구하는 수치 계산 방법이다. 핵심 부분만 말하면

와 같은 기본식과 미분식에서

라는 점화식에서 pi+1을 구하도록 하자. f(pi)가 충분히 0에 다가가면 식을 그만 두겠다. 근사 속도는 구하는 방식마다 다르지만 위의 식처럼 단순하다면 여러 차례 점화식을 돌리면 답을 얻을 수 있다.

뉴턴법을 실행할 클래스는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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;
    }
}
cs

Run메소드의 인수에 델리게이트 함수 f(x)와 미분 f'(x)를 각각 건내면 알아서 해를 찾아준다.

SoundPlayer.AudioClipInfo클래스에 최대 동시 발생 수 및 초기 음량(첫 번째 음량)을 등록한 후 이 뉴턴법으로 개수에 대한 감쇠율 p를 구해보자. 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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(
            delegatefloat p ) {
                return ( 1.0f - Mathf.Pow( p, n ) ) / ( 1.0f - p ) - 1.0f / initVolume;
            },
            delegatefloat p ) {
                float ip = 1.0f - p;
                float t0 = -* Mathf.Pow( p, n - 1.0f ) / ip;
                float t1 = ( 1.0f - Mathf.Pow( p, n ) ) / ip / ip;
                return t0 + t1;
            },
            0.9f, 100
        ); 
    }
}

cs

요점은 합성할 때 감쇠율 attenuate를 동시에 발생하는 최대 갯수와 첫번째의 음량으로부터 구한것 뿐이다.

여기서 SE가 겹치고 소리가 나는 경우, 예를 들면 5번째의 중복소리의 볼륨은 initVolume*Mathf.Pow(attenuate, 5)와 같이 계산한다.


5. SE를 해당하는 음량으로 낸다.

여기까지 조금 복잡하게 되었으므로 조금 정리를 해보자. SE가 동시에나게되면 거친 구형파의 소리가 나버린다. 그것을 피하기 위해 최대로 발생하는 소리의 수를 제한하였다(SoundPlayer.AudioClipInfo.maxSENum). 하지만 음량이 큰 SE를 여러개 거듭하면 역시 거칠어진다. 여기에 최대로 발생하는 소리의 수가 될 때 음량이 1(원래의 음량)이 되도록 SE의 음량을 조정한게 4번 챕터이다. 결과로 예를 들면 아래의 그래프와 같은 SE내에 소리 번호와 음량을 volume = initVolume * Mathf.Pow(attenuate, i)라는 간단한 식에서 구할수 있다는 것을 알게 되었다.

이 그래프는 첫번째 볼륨을 0.6으로 하고 최대로 발생하는 소리의 수를 12개로 한 경우의 음량을 계산한 것이다. 자세히 보면 5번째 정도에서 이미 들리지 않을 정도로 작은 소리가 되는것으로 나타난다. 이 부분의 최적화는 나중이야기로 각 SE에게 "소리를 내라"라고 요구가 왔을때 어떤 음량으로 내면 좋을지 이 그래프를 보면 즉시 계산을 할 수 있다.

그런데 지금 SoundPlayer의 SE관리는 AudioClipInfo.playingList라는 리스트에 현재 나고있는 시간을 저장했다. "지금 몇 개의 소리가 나고 있는지"는 알지만 "몇 번째 소리가 나고 있는지"는 모른다.

1
2
3
4
5
class SEInfo {
    public index;
    public curTime;
    public volume;
}
cs

라는 작은 클래스를 만들어 이것을 stockList라는 SE를 저장하고있는 리스트에 등록해 소리가 날때는 playingList에 등록하는 구조로 확장하기로하자.

stockList는 'SortedList형' 이라는 컨테이너로 하자. SortedLists는 문자 그대로 "정렬 리스트" 라는 컨테이너로, 등록할때 알아서 번호순으로 정렬한다. SE를 낼때 이 SortedList에게 "비어있는 번호중 가장 작은 번호를 주세요"라고 한다면 그것을 줄것이다. 이렇게 하는 것으로 지금 소리를 낼 수 있는 가장 큰 SE를 항상 선택 할 수있다.

AudioClipInfo클래스에 stockList를 추가하고 생성자에서 초기화한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 );
        }
    }
 
    ....
}
cs

playingList도 float형에서 SEInfo형으로 바뀌는것에 주의하자. SoundPlayer.playSE 메소드로 실제로 소리를 낼 때 stockList부터 비어있는 번호중 가장 작은 번호인 SEInfo를 얻어오자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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;
}
cs

info.stockList.Values[0]라고하면 제일 작은 번호를 받을 수 있다. 그리고 재생시간(len)을 등록해 playingList에 추가하자. stockList에서 지금 사용한 SEInfo를 지우는것(Remove)을 잊지말자. AudioSource.PlayOneShot메소드는 두번째 인수는 볼륨의 비율이므로 여기에 지금 얻은 볼륨 비율을 전달하자.

이것으로 소리가 나게 되었다. 그리고 재생이 끝난 SE의 회수도 잊지말자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
    }
 
}

cs

현재 재생되는 SE의 나머지 시간을 계산하여 재생이 끝나면 stockList를 회수하고 있다.

이상으로 개선을 한다면 100개가 겹쳐도 소리가 구형파처럼 거친소리가 나지 않는다.

사인파 중첩 테스트 : 종합개선(링크)

이 테스트의 소스는 440Hz의 사인파, 최대로 발생하는 SE수 100개, 첫번째 음량은 0.2로 위의 알고리즘으로 중첩하였다. 이것을 들어보면 100개를 중첩하여도 부드러운 사인파가 되어 있으며 1개일때와 비교해도 음량도 커지고 있다. 다만 30번째 정도에서 최대 음량과 비슷하게 들리는데 이 이유를 알아보면 위의 설정으론 30개가 겹쳐지면 전체음량의 99%를 차지하고 있고 나머지 70개는 단 1%음량을 올리는데 쓰이고 있다. 즉, 같은 SE를 동시에 100를 하는것은 큰 의미가 없기 때문에 10개 정도 중첩하는것으로 충분하다는 것을 알수있다.

이번 글에선 SE를 동시에 중첩하여 재생할때 발생하는 문제를 해결해보는 것을 생각해보았다. 이것으로 좋은 느낌의 사운드를 낼수 있게 되었다.

출처 : http://marupeke296.com/UNI_SND_No5_NumberOfSE.html

=========================================================

Thanks IKD for allowing me to translate and disclose the original text.

=========================================================

+ Recent posts