[Unity] AudioSource에 풀링 적용하기
댓글 0
댓글을 작성하려면 로그인이 필요합니다.
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요.
타워 디펜스처럼 여러 공격과 스킬이 동시에 발생하는 게임에서는 짧은 효과음이 매우 자주 재생된다. 처음에는 효과음이 필요할 때마다 AudioSource를 생성하거나 PlayClipAtPoint를 사용하는 방법을 생각할 수 있다.
하지만 전투가 커질수록 오브젝트 생성과 제거가 반복되고, 많은 효과음이 한꺼번에 겹치는 문제도 생긴다. 이를 해결하기 위해 프로젝트에 전투 효과음 전용 AudioSource 풀을 구현했다.
효과음을 사용하는 코드가 풀의 내부 구조까지 알 필요는 없도록 CombatSfx.Play()라는 공용 진입점을 만들었다.
public static class CombatSfx
{
public static CombatSfxHandle Play(
AudioClip clip,
Vector3 worldPosition,
bool loop = false,
float volumeScale = 1f,
CombatSfxPriority priority = CombatSfxPriority.Normal)
{
return clip == null
? default
: CombatSfxPool.Instance.Play(
clip,
worldPosition,
loop,
volumeScale,
priority);
}
}
스킬에서는 다음처럼 효과음과 월드 위치만 전달하면 된다.
CombatSfx.Play(
impactClip,
hitPosition,
volumeScale: 0.8f,
priority: CombatSfxPriority.High);
호출하는 쪽에서는 어떤 AudioSource가 사용되는지, 재생이 끝난 소스가 어떻게 반환되는지 신경 쓰지 않는다.
풀은 게임 시작 시 16개의 AudioSource를 미리 생성한다. 필요하다면 최대 32개까지 늘어날 수 있도록 구성했다.
private const int InitialVoiceCount = 16;
private const int MaxVoiceCount = 32;
private void Awake()
{
for (int i = 0; i < InitialVoiceCount; i++)
CreateVoice();
}
private void CreateVoice()
{
var go = new GameObject($"Voice {voices.Count}");
go.transform.SetParent(transform, false);
AudioSource source = go.AddComponent<AudioSource>();
source.playOnAwake = false;
source.spatialBlend = 0f;
source.volume = 0f;
go.SetActive(false);
voices.Add(new Voice
{
GameObject = go,
Source = source
});
}
여기서 Voice는 AudioSource와 함께 우선순위, 재생 순서, 볼륨 등의 상태를 보관하는 객체다.
효과음을 재생할 때는 먼저 사용하지 않는 보이스를 찾는다. 빈 보이스가 없지만 최대 개수에 도달하지 않았다면 새 보이스를 추가한다.
private int AcquireVoice(CombatSfxPriority requestedPriority)
{
for (int i = 0; i < voices.Count; i++)
{
if (!voices[i].Active)
return i;
}
if (voices.Count < MaxVoiceCount)
{
CreateVoice();
return voices.Count - 1;
}
return FindVoiceToSteal(requestedPriority);
}
재생이 끝난 효과음은 다시 비활성화하여 다음 재생에 사용한다.
private static void Release(Voice voice)
{
voice.Source.Stop();
voice.Source.clip = null;
voice.Source.loop = false;
voice.Source.volume = 0f;
voice.Active = false;
voice.GameObject.SetActive(false);
}
덕분에 전투 중 AudioSource 오브젝트를 계속 생성하고 제거하지 않아도 된다.
현재는 스킬 효과음만 CombatSfx를 통해 재생하지만, 풀 자체는 특정 시스템에 종속되어 있지 않다. 따라서 타워 데이터에 발사음과 착탄음을 추가하고, 실제 발사와 명중이 확정되는 시점에 같은 API를 호출하면 된다.
[System.Serializable]
public class TowerSoundData
{
public AudioClip fireClip;
public AudioClip impactClip;
[Range(0f, 1f)]
public float volume = 1f;
}
타워가 투사체 생성을 성공한 뒤에는 포신 위치에서 발사음을 재생한다.
if (!anyFired)
return false;
CombatSfx.Play(
sound.fireClip,
firePoint.position,
volumeScale: sound.volume,
priority: CombatSfxPriority.Low);
Owner.RaiseFired();
return true;
착탄음은 투사체가 적에게 실제 피해를 적용한 시점에 재생하면 된다.
target.TakeDamage(damageInfo);
CombatSfx.Play(
sound.impactClip,
target.HitPosition.position,
volumeScale: sound.volume,
priority: CombatSfxPriority.Low);
다만 타워는 스킬보다 공격 횟수가 많기 때문에 모든 투사체에서 효과음을 재생하면 소리가 쉽게 겹친다. 샷건처럼 한 번에 여러 발을 발사하는 타워는 투사체마다 재생하지 않고, 공격 한 번당 발사음을 한 번만 재생해야 한다.
공격 속도가 빠른 타워에는 별도의 재생 간격을 두는 방법도 사용할 수 있다.
if (Time.time >= nextSfxTime)
{
CombatSfx.Play(
fireClip,
firePoint.position,
volumeScale: 0.5f,
priority: CombatSfxPriority.Low);
nextSfxTime = Time.time + 0.1f;
}
타워 효과음은 기본적으로 Low 우선순위를 사용하고, 보스 공격이나 중요한 스킬에는 High 우선순위를 부여하면 풀이 가득 찼을 때도 중요한 소리를 우선해서 남길 수 있다.
결과적으로 기존 풀링 코드는 수정하지 않고, 다음 세 가지만 추가하면 타워까지 확장할 수 있다.
이처럼 효과음 재생 경로를 공용 API로 만들어두면 스킬뿐만 아니라 타워, 몬스터 공격, 폭발 오브젝트에도 같은 방식으로 확장할 수 있다.
효과음이 동시에 32개 이상 발생하면 무조건 새로운 소스를 만드는 대신, 기존 보이스 하나를 재사용한다.
재사용 대상은 다음 순서로 선택했다.
private static bool IsBetterStealCandidate(
Voice candidate,
Voice current)
{
bool candidateSilent = candidate.LastGain <= 0f;
bool currentSilent = current.LastGain <= 0f;
if (candidateSilent != currentSilent)
return candidateSilent;
if (candidate.Priority != current.Priority)
return candidate.Priority < current.Priority;
return candidate.StartedOrder < current.StartedOrder;
}
또한 새로 요청된 효과음보다 우선순위가 높은 소리는 뺏지 않는다. 예를 들어 일반 공격음 때문에 중요한 스킬 착탄음이 중단되는 상황을 방지할 수 있다.
이 구조는 단순히 성능만 최적화하는 것이 아니라, 전투에서 중요한 소리를 선별하는 역할도 한다.
이번 프로젝트는 직교 카메라를 사용한다. 그래서 Unity의 기본 3D 거리 감쇠 대신 카메라 줌과 화면상의 위치를 기준으로 직접 볼륨을 계산했다.
먼저 월드 좌표를 뷰포트 좌표로 변환한다.
Vector3 viewport =
camera.WorldToViewportPoint(worldPosition);
float dx = viewport.x - 0.5f;
float dy = viewport.y - 0.5f;
float edgeDistance =
Mathf.Max(Mathf.Abs(dx), Mathf.Abs(dy)) / 0.5f;
화면 중앙은 볼륨이 크고, 화면 가장자리에 가까워질수록 작아진다. 화면 밖에 있는 효과음은 재생 상태를 유지하더라도 실제 볼륨은 0으로 만든다.
if (edgeDistance >= 1f)
return false;
float screenGain =
Mathf.SmoothStep(0f, 1f, 1f - edgeDistance);
카메라 줌도 볼륨에 반영했다.
float zoomGain = Mathf.SmoothStep(
0f,
1f,
Mathf.InverseLerp(
SilentOrthoSize,
FullVolumeOrthoSize,
camera.orthographicSize));
최종 볼륨은 화면 위치 감쇠, 개별 효과음 크기, 사용자가 설정한 SFX 볼륨을 곱해서 계산한다.
source.volume =
screenGain
* zoomGain
* volumeScale
* settingsVolume;
이를 통해 화면 중심의 가까운 전투는 명확하게 들리고, 카메라를 멀리 줌 아웃하거나 화면 가장자리에서 발생한 전투는 작게 들리도록 만들었다.
좌우 위치는 스테레오 패닝에도 반영했다.
source.panStereo =
Mathf.Clamp(dx * 2f, -1f, 1f);
반복 효과음은 나중에 직접 중지할 필요가 있다. 하지만 풀의 슬롯은 다른 소리에 재사용될 수 있다.
예를 들어 과거에 받은 핸들로 Stop()을 호출했을 때, 같은 슬롯에서 새로 재생 중인 효과음까지 꺼질 수 있다. 이를 막기 위해 슬롯이 재사용될 때마다 세대 번호를 증가시켰다.
voice.Generation++;
return new CombatSfxHandle(
this,
index,
voice.Generation);
핸들이 가진 세대 번호와 현재 보이스의 세대가 일치할 때만 중지할 수 있다.
if (!candidate.Active ||
candidate.Generation != generation)
{
return false;
}
작은 처리이지만 풀링된 객체를 외부 핸들로 제어할 때 발생할 수 있는 문제를 방지해준다.
AudioSource 풀링을 적용하면서 얻은 효과는 오브젝트 생성 비용 감소만이 아니었다.
풀의 최대 크기를 제한해 효과음이 무한히 겹치는 것을 막았고, 우선순위를 이용해 중요한 소리를 보존했다. 여기에 카메라 줌과 화면 위치에 따른 감쇠를 추가하면서 플레이어가 보고 있는 전투에 소리가 집중되도록 만들 수 있었다.
결국 전투 효과음 풀링은 단순한 성능 최적화라기보다, 복잡한 전투 상황에서 어떤 소리를 들려줄지 관리하는 시스템에 가까웠다.
데칼 프로젝터란
![[Unity] URP 물 거품 버그 해결](https://data.develog.develrocket.com/upload/develog/user_1777093328305/1787578510018-8yk4m8/_____2026-08-24_223424.png)
URP 거품 버그 해결
SerializeReference
![[Unity] URP 물 거품 버그 해결](https://data.develog.develrocket.com/upload/develog/user_1777093328305/1787578510018-8yk4m8/_____2026-08-24_223424.png)