
오디오를 복제하거나 샘플에 직접 접근하는 대신, 언리얼 엔진이 제공하는 이펙터만으로 공간 음향을 구현해보았다.

언리얼 엔진에서 제공하는 오디오 이펙터는 크게 서브믹스 이펙트와 소스 이펙트로 나뉜다.
소스 이펙트는 사운드 큐나 메타사운드 등 사운드 에셋에 바로 적용 가능한 이펙터이며, 서브믹스 이펙트는 서브믹스에 적용 가능한 이펙터이다.
둘마다 같은 종류의 이펙터가 있는 것도 아니고, 한 쪽에만 있는 이펙터도 있다.


서브믹스 이펙트 중 TapDelay란 게 있는데, 하나의 이펙터에 여러 Delay를 줄 수 있다. 이때 하나의 Delay 정보를 Tap이라고 부르는 듯 하다.

입력으로 들어온 소스 몇 초 분량을 캐싱해서 각 Delay만큼 이전의 소스를 읽어 출력해주는 원리인 듯하다. 이때 입력으로 다시 들어가는 피드백은 없다.
덕분에 직접음 및 초기 반사음 구현에 적절해 보였고, 이를 이용하기로 했다.
Whitted RayTracing을 따라간다면, 초기 Ray 256개에 4번의 반사만으로 1024개의 HitPoint가 일어날 수 있으며 각 HitPoint로부터 음원까지의 거리를 통해 1024개의 Delay 정보가 생겨난다. 심지어 각 Delay 정보는 매 프레임마다 변할 수 있다.
그만큼의 Delay 이펙터를 걸어줄 순 없는 노릇이다. TapDelay라도 한계가 있어 보였다.
때문에 수많은 Ray를 몇 개의 파라미터로 줄일 수 있는 과정을 도입하였다.


위쪽 사진처럼 많은 Ray(256*16개)중 아래와 같은 Ray(최대 64개,보통 20개 내외)만 뽑아 적용하고 있고, 이펙터는 사운드별로 TapDelay와 Reverb만을 사용하고 있다.
우선 각 Ray 정보 중 Delay와 Volume을 시간-크기 축에 산점도로 그려 보았다. 아래는 스프레드시트로 그린 여러 산점도이다.




보다시피 특정 구간에 점들이 몰려 있다. 이를 시간순으로 히스토그램을 그릴 수 있을 터다.
아래는 마지막 산점도의 Weight-Histogram이다. (bin=0.005s)

Weight-Histogram이란 일반적인 히스토그램이 구간별로 개수 하나씩을 더하는 것과 달리 원소 하나마다 매핑된 가중치 값을 더한다. 여기서는 각 점의 Volume을 더하게 된다.
히스토그램을 보다시피 첫 날카로운 피크가 보인 이후 두번째 피크부턴 천천히 줄어드는 모습을 볼 수 있다. 첫 피크는 직접음, 두번째 피크는 초기 반사음, 이후의 소리는 잔향으로 볼 수 있다.
그래서 피크만 잘 뽑으면 파라미터화가 쉽게 될 것 같았다.
그러나 이건 벽 옆에서 바로 측정했을 때의 결과라서 이쁘게 나온 거고, 벽과 거리가 좀 멀어지면 이런 모습으로 나온다. (bin=0.005, 각 범례는 채널을 나타낸다)

피크가 상당히 많아지며 몇 개의 피크만 뽑기 난감해졌다. 이렇게 히스토그램을 그대로 쓰기엔 값이 불연속적인 경우가 많아 극점을 찾기가 어렵다.
때문에 그래프를 부드럽게 만들 smoothing 과정이 필요하다. 주로 Convolution을 쓰는 것 같은데, 여기선 KDE를 쓰기로 했다.
각 원소의 x값을 중심으로 하는 커널 분포를 모두 더하여 연속적인 분포를 만드는 과정이다. 점마다 가우시안 분포 등의 분포를 더하며 전체 그래프를 구성해나간다고 이해해도 되겠다.
사진 왼쪽이 히스토그램, 오른쪽이 KDE로 추정한 분포 함수다.

위 히스토그램과 같은 데이터를 KDE로 처리한 모습이다. (bin=0.005, 커널 크기는 각각 7, 15)


그래프가 상당히 부드러워지면서 극점을 찾기 훨씬 수월해진 모습이다.
프로젝트에선 bin=0.005, 커널 크기 7로 진행하였다.
공간에서 울려퍼진 소리는 크게 3개의 구간으로 나뉘어진다.

RayTracing을 적용하면 반사 횟수를 구할 수 있으므로 이를 나눌 수 있다. 특히 직접음은 음원과 Listener 사이를 Raycast하여 바로 확인할 수 있다.
다만 초기반사음과 잔향의 구분은 모호하다. 반사 횟수로 구분할 수도 있겠지만, 여기서는 dB을 측정해서 구분하기로 했다.
RT60이란 소리가 원음 대비 -60dB까지 줄어드는 데 걸리는 시간을 의미한다. 많은 Reverb Effector의 파라미터로 주어지는 수치이다.
이를 측정하기 위한 방법 중 하나로 EDC(Energy Decay Curve)를 이용하는 방법이 있다. (Manfred Schröder가 제안했다고 한다)
대충 식은 이렇게 생겼는데, Impulse Response를 끝에서부터 거꾸로 누적하는 그래프라고 생각하면 된다. 아래처럼 생겼다.

여기서 -5dB부터 -35dB의 구간의 기울기를 이용해 RT60을 구할 수 있다. 왜 -5dB부터 -35dB이냐면 ISO-3382에 명시된 방법이라서 그렇다는 듯하다.
여기서는 앞서 구한 KDE를 Impulse Response로 생각하고 EDC를 구해 선형회귀를 통해 RT60을 구한다.
void UATListenerComponent::EstimateReverbPhase(
const TArray<FTraceAudioData>& InTracedDataArray,
float& OutAttackStart,
float& OutReverbStart,
float& OutRT60
)
{
const float bin = 0.005f;
// construct KDE
TArray<float> KDEPlot;
{
TArray<float> TracedDelayArray;
TArray<float> TracedVolumeArray;
for ( const FTraceAudioData& TracedData : InTracedDataArray ) {
// ignore DirectSound
if ( TracedData.ReflectionCount == 0 )
continue;
TracedDelayArray.Add(TracedData.Delay);
TracedVolumeArray.Add(TracedData.Volume);
}
KDEPlot = FATUtils::KernelDistEstimate(TracedDelayArray, TracedVolumeArray, 7, bin);
}
// Make Energy Decay Curve and Estimate Phase Timings
OutAttackStart = 0.1f;
OutReverbStart = 0.2f;
bool bFoundAttack = false;
bool bFoundReverb = false;
{
TArray<float> EDCPlot, RegressionX, RegressionY;
EDCPlot.AddZeroed(KDEPlot.Num() - 1);
EDCPlot.Add(KDEPlot[KDEPlot.Num() - 1]);
for ( int32 i = KDEPlot.Num() - 2; i >= 0; --i ) {
EDCPlot[i] += KDEPlot[i] + EDCPlot[i + 1];
}
for ( int32 i = 0; i < EDCPlot.Num(); ++i ) {
float dB = 10 * FMath::LogX(10, EDCPlot[i] / EDCPlot[0]);
if ( -5.f < dB && dB < -KINDA_SMALL_NUMBER ) {
if ( !bFoundAttack ) {
OutAttackStart = bin * i;
bFoundAttack = true;
}
} else if ( -35.f < dB && dB < -5.f ) {
if ( !bFoundReverb ) {
OutReverbStart = bin * i;
bFoundReverb = true;
}
RegressionX.Add(bin * i);
RegressionY.Add(dB);
}
}
// 예외처리
if (OutReverbStart <= OutAttackStart)
{
OutReverbStart = OutAttackStart = 0.1f;
}
float Slope = -60.f, Intercept = 0.f;
if ( RegressionX.Num() > 2 )
FATUtils::LinearRegression(RegressionX, RegressionY, Slope, Intercept);
OutRT60 = -60 / Slope;
}
}
이를 통해 초기반사음의 시작 시간인 AttackStart, 잔향의 시작 시간인 ReverbStart, 잔향의 크기인 RT60을 측정한다.
KDE를 구성해서 피크를 찾는다. 그러나 1번에서 구한 KDE에는 방향이 고려되어 있지 않다. 방향, 시간을 입력으로 하는 함수 KDE를 구해야 한다.
단순 히스토그램이라면 구간을 나눠서 구해도 되겠지만, KDE는 커널 크기가 있어 가까운 이웃 구간에도 영향을 준다. 시간축은 선형이라 큰 문제가 안 되지만, 방향은 선형적이지 않다. 이웃하는 방향 정보가 필요하다.
때문에 방향을 구간별로 나누고, 이웃 구간의 정보를 미리 캐싱해 두었다.
방향 간의 가깝고 먼 정도는 코사인 거리로 측정하고, 모든 방향에 대해 코사인 거리를 구해 가장 가까운 순으로 정렬한다.
또한 실제 Ray를 쏘는 방향과 각 방향을 구간별로 나눌 Bucket 방향을 따로 구하고 매핑시켰다.
void UATEngineSubsystem::InitAudioDirections()
{
// Initialize Audio Directions
AudioDirections = FATUtils::MakeUniformDirections(RayCount);
// Initialize Audio Dir Buckets and Neighbors
AudioDirBuckets = FATUtils::MakeUniformDirections(RayDirBucketCount);
for ( int32 Idx = 0; Idx < AudioDirBuckets.Num(); ++Idx ) {
const FVector Direction = AudioDirBuckets[Idx];
TArray<TPair<uint16, float>> DirectionAndDotVals;
for ( int32 i = 0; i < AudioDirBuckets.Num(); ++i ) {
if ( Idx == i )
continue;
float dotVal = FVector::DotProduct(Direction, AudioDirBuckets[i]);
DirectionAndDotVals.Add({ i, dotVal });
}
DirectionAndDotVals.Sort([](const TPair<uint16, float>& A, const TPair<uint16, float>& B) {
return A.Value > B.Value;
});
DirectionAndDotVals.RemoveAt(NeighborsOfAudioDirBucketCount, DirectionAndDotVals.Num() - NeighborsOfAudioDirBucketCount, EAllowShrinking::Yes);
AudioDirBucketNeighbors.Emplace(Idx, TArray<uint16>());
for ( const TPair<uint16, float> Pair : DirectionAndDotVals ) {
AudioDirBucketNeighbors[Idx].Add(Pair.Key);
}
}
// Mapping AudioDirection to AudioDirBucket
for ( int32 AudioDirectionIdx = 0; AudioDirectionIdx < AudioDirections.Num(); ++AudioDirectionIdx ) {
float MaxCosDist = -1.f;
int32 NearestDirBucketIdx = INDEX_NONE;
const FVector& AudioDirection = AudioDirections[AudioDirectionIdx];
for ( int32 AudioDirBucketIdx = 0; AudioDirBucketIdx < AudioDirBuckets.Num(); ++AudioDirBucketIdx ) {
const FVector& AudioDirBucket = AudioDirBuckets[AudioDirBucketIdx];
float CosDist = FVector::DotProduct(AudioDirection, AudioDirBucket);
if ( CosDist > MaxCosDist ) {
MaxCosDist = CosDist;
NearestDirBucketIdx = AudioDirBucketIdx;
}
}
if ( INDEX_NONE < NearestDirBucketIdx )
AudioDirectionsToDirBucketMap.Add(AudioDirectionIdx, NearestDirBucketIdx);
}
}
Ray에서 방향과 Delay 정보를 구해 해당하는 구간을 기준으로 커널을 더해주면 된다. 이때 시간축으로는 가우시안 커널을 쓰면 될 것 같지만, 방향 축으로는 뭘 써야 할까?
각도와 같이 주기성을 띄는 차원을 위한 분포로 폰 미세스 분포가 있다고 한다. 대충 이렇게 생겼다고 한다.

이때 는 Bessel function이라고 하는데 다음과 같이 근사할 수 있다는 듯 하다.
double FATUtils::PseudoBesselFunction(double kappa, int terms) {
double sum = 1.0;
double factorial = 1.0;
double k2 = kappa * kappa / 4.0;
double power = 1.0;
for ( int i = 1; i <= terms; ++i ) {
factorial *= i;
power *= k2;
sum += power / (factorial * factorial);
}
return sum;
}
값은 집중도로 정규분포에서 표준편차의 역수에 대응된다고 하는데 아직 적당한 값을 찾지 못했다.
여튼 기준 구간을 찾아서 시간 축으로는 가우시안 커널(bin=0.005s, 커널 크기 7)을, 방향 축으로는 폰 미세스 커널(fibonacci sphere로 생성한 64개의 방향, 가장 가까운 16개의 방향 커널)을 곱한 값을 누적한다.
const float Weight = TracedData.Volume;
const int32 TimeOffsetIdx = (TracedData.Delay - TimeRangeStart) / TimeBin;
{
for ( int t = 0; t < TimeKernelSize; ++t ) {
const float TimeKernelElem = TimeKernel[t];
int32 Idx = TimeOffsetIdx + (t - TimeKernelSize / 2);
if ( Idx < 0 || KDESpace[DirBucketIdx].Num() <= Idx )
continue;
KDESpace[DirBucketIdx][Idx] += Weight * TimeKernelElem;
}
}
for (int32 i = 0; i < NeighborIndices.Num(); ++i)
{
const int32 NeighborDirIdx = NeighborIndices[i];
const float NeighborsKernelElem = NeighborsKernel[i];
for (int32 t = 0; t < TimeKernelSize; ++t)
{
const float TimeKernelElem = TimeKernel[t];
int32 Idx = TimeOffsetIdx + (t - TimeKernelSize / 2);
if (Idx < 0 || KDESpace[NeighborDirIdx].Num() <= Idx)
continue;
KDESpace[NeighborDirIdx][Idx] += Weight * NeighborsKernelElem * TimeKernelElem;
}
}
마지막으로, Ray의 정보들을 각 구간에 대응되는 Bucket에 넣어준다.
if ( 0 <= TimeOffsetIdx && TimeOffsetIdx < TimeElemCount )
TracedDataBuckets[DirBucketIdx][TimeOffsetIdx].Add(TracedData);
이제 KDE에서 피크를 찾는다.
1차원이면 선형 순회 돌면서 극점을 찾아도 됐겠지만, 2차원으로 확장되면서 선형 순회는 무리가 있어졌다.
때문에 경사하강법 비슷한 알고리즘을 도입했다. 임의의 점들로부터 시작해서, 주변 값들 중 최댓값을 계속 찾아가며 부분 극점을 찾는다.
// Find Peaks
TSet<TPair<int32, int32>> PeaksIndices;
{
const int32 StartPointTimeInterval = 5;
const int32 StartPointDirInterval = 4;
struct FPointOnKDE {
int32 DirIdx;
int32 TimeIdx;
};
TArray<FPointOnKDE> Points;
for (int32 DirIdx = 0; DirIdx < DirBucketCount; DirIdx += StartPointDirInterval)
{
for (int32 TimeIdx = 0; TimeIdx < TimeElemCount; TimeIdx += StartPointTimeInterval)
{
if (KDESpace[DirIdx][TimeIdx] > KINDA_SMALL_NUMBER)
Points.Add({ DirIdx, TimeIdx });
}
}
for (FPointOnKDE& Point: Points)
{
float MaxVolume = -BIG_NUMBER;
FPointOnKDE MovePoint = Point;
FPointOnKDE PrevPoint;
uint32 MoveCount = 0;
do {
PrevPoint = MovePoint;
TArray<uint16> NeighborsIdx = ATEngineSubsystem->GetNeighborsOfAudioDirBucket(Point.DirIdx);
for ( int32 i = 0; i < NeighborsIdx.Num(); ++i )
{
int32 DirIdx = NeighborsIdx[i];
for (int32 t = -1; t <= 1; ++t)
{
int32 TimeIdx = MovePoint.TimeIdx + t;
if ( TimeIdx < 0 || TimeElemCount <= TimeIdx )
continue;
float Volume = KDESpace[DirIdx][TimeIdx];
if ( MaxVolume < Volume ) {
MaxVolume = Volume;
MovePoint.DirIdx = DirIdx;
MovePoint.TimeIdx = TimeIdx;
++MoveCount;
}
}
}
} while ( !(MovePoint.DirIdx == PrevPoint.DirIdx && MovePoint.TimeIdx == PrevPoint.TimeIdx) );
PeaksIndices.Add({ MovePoint.DirIdx, MovePoint.TimeIdx });
}
}
KDE에서 피크 구간들을 찾았으면, 그 구간에서 가장 볼륨이 큰 대표 Ray 정보를 찾는다.
TArray<FTraceAudioData> RepresentativeData;
for ( const TPair<int32, int32> Index : PeaksIndices ) {
FTraceAudioData MaxVolumeData = {};
float SumVolume = 0.f;
for ( const FTraceAudioData& data : TracedDataBuckets[Index.Key][Index.Value] ) {
SumVolume += data.Volume;
if ( data.Volume > MaxVolumeData.Volume )
MaxVolumeData = data;
}
if ( MaxVolumeData.Volume > KINDA_SMALL_NUMBER ) {
MaxVolumeData.Volume = SumVolume;
RepresentativeData.Add(MaxVolumeData);
}
}
그리고 이 Ray를 기준으로 파라미터를 추출한다.
음원의 상대 위치(방향, 거리)를 이용해 ILD, ITD를 구한다.
TArray<FRenderAudioData> Result;
FVector Direction = (FindAudioComponentByID(TargetSoundID)->GetComponentLocation() - GetComponentLocation()).GetSafeNormal();
FVector RelativeSoundPosition = DirectSoundData.Distance * Direction;
float ITDLeft = FVector::Dist(RelativeSoundPosition, -GetRightVector() * HalfWidth) / SoundSpeed - DirectSoundData.Delay;
float ILDLeft = (FVector::DotProduct(Direction, -GetRightVector()) + 1.f) / 2.f;
float ITDRight = FVector::Dist(RelativeSoundPosition, GetRightVector() * HalfWidth) / SoundSpeed - DirectSoundData.Delay;
float ILDRight = (FVector::DotProduct(Direction, GetRightVector()) + 1.f) / 2.f;
FRenderAudioData RenderDataLeft;
RenderDataLeft.Delay = DirectSoundData.Delay + ITDLeft;
RenderDataLeft.Volume = DirectSoundData.Volume * ILDLeft;
RenderDataLeft.RT60 = RT60;
RenderDataLeft.Panning = -1;
Result.Add(RenderDataLeft);
FRenderAudioData RenderDataRight;
RenderDataRight.Delay = DirectSoundData.Delay + ITDRight;
RenderDataRight.Volume = DirectSoundData.Volume * ILDRight;
RenderDataRight.RT60 = RT60;
RenderDataRight.Panning = 1;
Result.Add(RenderDataRight);
FAudioDevice->SendCommantToActiveSounds(...)에 람다를 전달해서 각 ActiveSound를 인자로 오디오 스레드에서 람다를 실행시킬 수 있다.
이를 이용해 ActiveSound를 Submix로 자동으로 연결시키고 이펙트를 적용하는 과정을 거쳤다.
AudioDevice->SendCommandToActiveSounds(TargetSoundID, [ATSubSystem = GetWorld()->GetSubsystem<UATEngineSubsystem>(), RenderAudioData](FActiveSound& Sound)
{
TArray<FSoundSubmixSendInfo> SendInfos;
Sound.GetSoundSubmixSends(SendInfos);
// Init Submix
if (SendInfos.IsEmpty())
{
USoundSubmix* Submix = ATSubSystem->GetPoolingSubmix();
FSoundSubmixSendInfo SendInfo = {};
SendInfo.SoundSubmix = Submix;
SendInfo.SendStage = ESubmixSendStage::PreDistanceAttenuation;
Sound.bEnableMainSubmixOutputOverride = false;
Sound.bHasActiveMainSubmixOutputOverride = true;
Sound.bEnableSubmixSendRoutingOverride = true;
Sound.bHasActiveSubmixSendRoutingOverride = true;
Sound.SetSubmixSend(SendInfo);
return;
}
USoundSubmix* SoundSubmix = Cast<USoundSubmix>(SendInfos[0].SoundSubmix);
if ( SoundSubmix == nullptr )
return;
for (TObjectPtr<USoundEffectSubmixPreset> Effector: SoundSubmix->SubmixEffectChain)
{
// TabDelay (for Direct Sound & Early Reflection)
if ( USubmixEffectTapDelayPreset* TapDelay = Cast<USubmixEffectTapDelayPreset>(Effector) )
{
TArray<int32> Indices;
while ( TapDelay->DynamicSettings.Taps.Num() < RenderAudioData.Num() ) {
int32 Idx;
TapDelay->AddTap(Idx);
}
TapDelay->GetTapIds(Indices);
int32 i = 0;
for ( ; i < RenderAudioData.Num(); ++i ) {
const FRenderAudioData& RenderData = RenderAudioData[i];
FTapDelayInfo TapInfo;
int32 Idx = Indices[i];
TapDelay->GetTap(Idx, TapInfo);
TapInfo.DelayLength = RenderData.Delay * 1000.f; // second to millisecond
TapInfo.Gain = 10.f * FMath::LogX(10.f, RenderData.Volume);
TapInfo.TapLineMode = ETapLineMode::SendToChannel;
TapInfo.OutputChannel = (RenderData.Panning < 0) ? 0 : 1;
TapDelay->SetTap(Idx, TapInfo);
}
for ( ; i < Indices.Num(); ++i ) {
FTapDelayInfo TapInfo;
int32 Idx = Indices[i];
TapDelay->GetTap(Idx, TapInfo);
TapInfo.Gain = -60.f;
TapDelay->SetTap(Idx, TapInfo);
}
}
// Reverb (for Late Reflection)
if ( USubmixEffectReverbPreset* Reverb = Cast<USubmixEffectReverbPreset>(Effector) ) {
float RT60Avg = 0.f;
float ReverbDelay = 0.f;
for ( const FRenderAudioData& RenderData : RenderAudioData ) {
RT60Avg += RenderData.RT60;
}
RT60Avg /= RenderAudioData.Num();
if ( RenderAudioData.Num() >= 2 ) {
ReverbDelay = RenderAudioData[RenderAudioData.Num() - 2].Delay - RenderAudioData[0].Delay;
}
FSubmixEffectReverbSettings ReverbSetting = Reverb->GetSettings();
ReverbSetting.bBypassEarlyReflections = true;
ReverbSetting.DecayTime = RT60Avg;
ReverbSetting.LateDelay = ReverbDelay;
ReverbSetting.DryLevel = 0.7071f;
ReverbSetting.WetLevel = 0.7071f;
Reverb->SetSettings(ReverbSetting);
}
}
// Sound.SetVolume(1.0f);
});
TapDelay에서 Delay 값이 바뀌면 보간되면서 소리 높낮이가 변하게 되는데, Ray마다 Delay가 제각기 다르게 적용되다 보니 높낮이가 와리가리하는 현상이 있다.
RenderData와 Tap 간에 매핑을 시켜주는 접근은 생각나지만 뾰족한 수가 떠오르지 않는다.