[2025/07/30]TIL

오수호·2025년 7월 30일

TIL

목록 보기
52/60

어드레서블을 사용한 오디오 매니저 구현

목표 구현 사항

  1. 항상 로드해야 하는 SFX는 항상 로드

  2. SFX는 여러 사운드가 '동시에' 들릴 수 있음

  3. 씬이 바뀌면 기존에 로드해놨던 사운드를 릴리즈 해주고 새로운 사운드를 로드

  4. 다른 부분에서 쉽게 SFX를 사용할 수 있을 것

구현 방법

오디오매니저

오디오 매니저에서는 사운드 조절기능을 BGM과 SFX를 따로 할 수 있도록 만든다.
런타임에 로드해온 오디오 클립을 딕셔너리로 저장하고, 이를 꺼내사용한다.
SFX를 재생할 때 오디오 소스를 오브젝트 풀 매니저에서 꺼내서 사용한 후 오브젝프 풀 매니저에 반납하여 재사용할 수 있도록 만든다.

소스코드

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using DG.Tweening;
using UnityEngine.SceneManagement;

public enum AudioType
{
    BGM,
    SFX
}

public class AudioManager : SceneOnlySingleton<AudioManager>
{
        /* 사운드 조절 기능 */
    [SerializeField][Range(0, 1)] private float soundEffectVolume = 1f;
    [SerializeField][Range(0, 1)] private float soundEffectPitchVariance = 0.1f;
    [SerializeField][Range(0, 1)] private float musicVolume = 0.5f;

    /* 모든 사운드 저장 */
    /* 저장된 사운드를 꺼내쓰기 쉽도록 Dictionary에 저장 */
    public Dictionary<string, AudioClip> AudioDictionary = new();
    protected ObjectPoolManager objectPoolManager;
    private string sfxPlayerPoolName = "sfxSource";
    
    [SerializeField] private AudioSource bgmAudioSource;
    [SerializeField] private GameObject sfxAudioSourcePrefab;
    protected override void Awake()
    {
        base.Awake();
        InitializeAudioManager();
    }

    protected void Start()
    {
        objectPoolManager = ObjectPoolManager.Instance;
    }

    private void InitializeAudioManager()
    {
        bgmAudioSource = GetComponent<AudioSource>();
        if (bgmAudioSource == null)
        {
            bgmAudioSource = gameObject.AddComponent<AudioSource>();
        }
        //LoadAssetManager.Instance.OnLoadAssetsChangeScene(SceneManager.GetActiveScene().name);
        LoadAssetManager.Instance.LoadAudioClipAsync(SceneManager.GetActiveScene().name + "BGM", clip =>
        {
            PlayBGM(clip);
        });
        LoadAssetManager.Instance.LoadAssetBundle(nameof(AlwaysLoad.AlwaysLoadSound)); // 항상 로드해와야 하는 사운드
        LoadAssetManager.Instance.LoadAssetBundle(SceneManager.GetActiveScene().name + "Assets"); // 특정 씬에서 로드해와야 하는 사운드
        
        bgmAudioSource.volume = musicVolume;
        bgmAudioSource.loop = true;
    }


    /* 볼륨 조절 기능. 나중에 옵션으로 사운드를 BGM,SFX 따로 조절할 수 있도록 만든 형태 */
    public void SetVolume(AudioType type, float volume)
    {
        volume = Mathf.Clamp01(volume);

        if (type == AudioType.BGM)
        {
            musicVolume = volume;
            if (bgmAudioSource != null)
            {
                bgmAudioSource.volume = musicVolume;
            }
        }
        else if (type == AudioType.SFX)
        {
            soundEffectVolume = volume;
        }
    }

    /* BGM은 Loop를 돌며 계속해서 반복 재생 */
    public void PlayBGM(string clipName, bool isLoop = true)
    {
        if (bgmAudioSource == null || AudioDictionary == null)
        {
            Debug.LogError("SoundManager: BGM 재생 실패 - AudioSource 또는 AudioDictionary가 null입니다.");
            return;
        }

        if (AudioDictionary.ContainsKey(clipName))
        {
            bgmAudioSource.Stop();
            bgmAudioSource.clip = AudioDictionary[clipName];
            bgmAudioSource.loop = isLoop;
            bgmAudioSource.Play();
        }
        else
        {
            Debug.LogError($"SoundManager: PlayBGM - {clipName}은 존재하지 않는 오디오 클립입니다.");
        }
    }

    /* BGM을 정지할 때 쓰는 메서드 */
    public void StopBGM()
    {
        if (bgmAudioSource != null)
        {
            bgmAudioSource.Stop();
        }
    }

    /* 효과음 재생용 메서드 */
    public void PlaySFX(string clipName)
    {
        if (string.IsNullOrEmpty(clipName))
        {
            Debug.LogError("SoundManager: PlaySFX - clipName이 null 또는 빈 문자열입니다.");
            return;
        }

        if (objectPoolManager == null)
        {
            Debug.LogError("SoundManager: SoundPoolManager를 찾을 수 없습니다.");
            return;
        }

        if (AudioDictionary != null && AudioDictionary.ContainsKey(clipName))
        {
            GameObject sfxPlayer = objectPoolManager.GetObject(sfxPlayerPoolName);
            if(sfxPlayer == null) Instantiate(sfxAudioSourcePrefab);
            PoolableAudioSource sfxSource = sfxPlayer.GetComponent<PoolableAudioSource>();
            if (sfxPlayer != null)
            {
                sfxSource.Play(AudioDictionary[clipName], soundEffectVolume);
            }
            else
            {
                Debug.LogError("SoundManager: SoundSource 객체를 가져올 수 없습니다.");
            }
        }
        else
        {
            Debug.LogError($"SoundManager: PlaySFX - {clipName}은 존재하지 않는 오디오 클립입니다.");
        }
    }

    /* 효과음 재생 후 해당 효과음 제어를 위해 만들어진 효과음 Prefab을 Return받는데 사용되는 메서드 */
    public PoolableAudioSource PlaySfxReturnSoundSource(string clipName)
    {
        if (string.IsNullOrEmpty(clipName))
        {
            Debug.LogError("SoundManager: PlaySfxReturnSoundSource - clipName이 null 또는 빈 문자열입니다.");
            return null;
        }
        if (objectPoolManager == null)
        {
            Debug.LogError("SoundManager: SoundPoolManager를 찾을 수 없습니다.");
            return null;
        }

        if (AudioDictionary != null && AudioDictionary.ContainsKey(clipName))
        {
            GameObject sfxPlayer = objectPoolManager.GetObject(sfxPlayerPoolName);
            if(sfxPlayer == null) Instantiate(sfxAudioSourcePrefab);
            PoolableAudioSource sfxSource = sfxPlayer.GetComponent<PoolableAudioSource>();
            if (sfxPlayer != null)
            {
                sfxSource.Play(AudioDictionary[clipName], soundEffectVolume);
            }
            else
            {
                Debug.LogError("SoundManager: SoundSource 객체를 가져올 수 없습니다.");
            }

            return sfxSource;
        }
        else
        {
            Debug.LogError($"SoundManager: PlaySFX - {clipName}은 존재하지 않는 오디오 클립입니다.");
            return null;
        }

    }
}

어드레서블에 등록한 사운드 키값 자동화

어드레서블에 등록한 오디오클립을 자동으로 enum에 올려서 쓸 수 있도록 만들었다.

using System.IO;
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.AddressableAssets;
#endif
using UnityEngine;

public class LoadDataEnum
{
#if UNITY_EDITOR
    [MenuItem("Tools/Generate SFXName Enum From Group")]
    public static void GenerateSFXEnumFromGroup()
    {
        string groupName = "SFX"; // 생성 기준이 되는 그룹 이름
        var settings = AddressableAssetSettingsDefaultObject.Settings;

        var group = settings.FindGroup(groupName);
        if (group == null)
        {
            Debug.LogError($"그룹 '{groupName}'을(를) 찾을 수 없습니다.");
            return;
        }

        var entries = group.entries;
        if (entries == null || entries.Count == 0)
        {
            Debug.LogWarning($"그룹 '{groupName}'에 에셋이 없습니다.");
            return;
        }

        string enumPath = "Assets/2. Scripts/Suho/SFXName.cs";
        Directory.CreateDirectory(Path.GetDirectoryName(enumPath));

        using (StreamWriter writer = new StreamWriter(enumPath))
        {
            writer.WriteLine("public enum SFXName");
            writer.WriteLine("{");

            foreach (var entry in entries)
            {
                string address = entry.address;
                string enumName = SanitizeEnumName(address);
                writer.WriteLine($"    {enumName},");
            }

            writer.WriteLine("}");
        }

        AssetDatabase.Refresh();
        Debug.Log("SFXName enum 자동 생성 완료 (그룹 기준)");
    }


    [MenuItem("Tools/Generate BGMName Enum From Group")]
    public static void GenerateBGMEnumFromGroup()
    {
        string groupName = "BGM"; // 생성 기준이 되는 그룹 이름
        var settings = AddressableAssetSettingsDefaultObject.Settings;

        var group = settings.FindGroup(groupName);
        if (group == null)
        {
            Debug.LogError($"그룹 '{groupName}'을(를) 찾을 수 없습니다.");
            return;
        }

        var entries = group.entries;
        if (entries == null || entries.Count == 0)
        {
            Debug.LogWarning($"그룹 '{groupName}'에 에셋이 없습니다.");
            return;
        }

        string enumPath = "Assets/2. Scripts/Suho/BGMName.cs";
        Directory.CreateDirectory(Path.GetDirectoryName(enumPath));

        using (StreamWriter writer = new StreamWriter(enumPath))
        {
            writer.WriteLine("public enum BGMName");
            writer.WriteLine("{");

            foreach (var entry in entries)
            {
                string address = entry.address;
                string enumName = SanitizeEnumName(address);
                writer.WriteLine($"    {enumName},");
            }

            writer.WriteLine("}");
        }

        AssetDatabase.Refresh();
        Debug.Log("BGMName enum 자동 생성 완료 (그룹 기준)");
    }

    // 주소를 enum으로 안전하게 변환 (공백 제거, 숫자 시작 등 처리)
    private static string SanitizeEnumName(string address)
    {
        string name = Path.GetFileNameWithoutExtension(address)
            .Replace(" ", "_")
            .Replace("-", "_");

        if (char.IsDigit(name[0]))
            name = "_" + name;

        return name;
    }
#endif

}

이제 에디터 상단의 Tools메뉴에서 메뉴를 선택하면 enum을 자동으로 만들어 넣어준다. 이를 nameof()메서드를 사용하여 string형태로 만들어서 전달하면 키값으로 사용할 수 있다.

LoadAssetManager

씬 별로 필요한 오디오 클립이 있을 것이고, 항상 필요한 오디오 클립이 있을 것이며 특정상황에만 필요한 오디오 클립이 있을 것이다.

씬별로 필요하거나 항상 필요한 오디오클립은 레이블로 구분해서 로드해올 수 있도록 하고, 특정 상황에만 필요한 오디오클립은 어드레서블 키값을 직접 받아 로드해 올 수 있도록 만들었다.

추가적으로 씬이 바뀌는 상황에서는 로드해왔던 사운드들이 필요없어졌을 경우이기때문에 핸들러를 캐싱해놓았다가 릴리즈 해주는 메서드도 만들었다.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.ResourceManagement.ResourceLocations;

public class LoadAssetManager : Singleton<LoadAssetManager>
{
    
    //비동기 로딩 시 사용할 핸들
    private List<AsyncOperationHandle<AudioClip>> loadAudioClipHandles = new();
    private List<AsyncOperationHandle<IList<AudioClip>>> loadSceneAudiohandles = new();
    
    // 레이블을 사용해서 에셋번들의 로케이션을 받아오는 메서드
    public void LoadAssetBundle(string labelName)
    {
        Addressables.LoadResourceLocationsAsync(labelName).Completed +=
            (handle =>
            {
                if (handle.Status == AsyncOperationStatus.Succeeded)
                {
                    var locations = handle.Result;
                    Debug.Log($"로케이션 {locations.Count}개 가져옴");
                    OnLoadAssetsChangeScene(labelName, locations);
                }
                else
                {
                    Debug.LogError($"로케이션 로드 실패: {labelName}");
                }
            });
    }
    
    // 받아온 로케이션을 통해 에셋을 로드해오는 메서드
    public void OnLoadAssetsChangeScene(string lableName, IList<IResourceLocation> locations)
    {   
        //스테이지에 필요한 사운드레이블을 가져오기
        Addressables.LoadAssetsAsync<AudioClip>(locations,null).Completed +=
            (handle =>
            {
                loadSceneAudiohandles.Add(handle);
                foreach (AudioClip clip in handle.Result)
                {
                    AudioManager.Instance.AudioDictionary.TryAdd(clip.name, clip);
                }
            });
    }
    
    //비동기 오디오클립 로드 메서드
    public void LoadAudioClipAsync(string assetName,Action<string> onLoaded)
    {
        Addressables.LoadAssetAsync<AudioClip>(assetName).Completed += (handle) =>
        {
            if (handle.Status == AsyncOperationStatus.Succeeded)
            {
                var clip = handle.Result;
                loadAudioClipHandles.Add(handle);
                AudioManager.Instance.AudioDictionary.TryAdd(assetName, handle.Result);
                onLoaded?.Invoke(assetName); // 로드 완료 후 콜백 호출
            }
            else
            {
                Debug.LogError($"AudioClip 로드 실패: {assetName}");
                onLoaded?.Invoke(null); // 실패했을 때 처리
            }
        };
    }


    // 메모리에 올라온 오디오클립을 릴리즈
    private void ReleaseAudioClips()
    {
        foreach (var handle in loadAudioClipHandles)
        {
            Addressables.Release(handle);
        }

        foreach (var handle in loadSceneAudiohandles)
        {
            Addressables.Release(handle);
        }
        loadAudioClipHandles.Clear();
        loadSceneAudiohandles.Clear();
        AudioManager.Instance.AudioDictionary.Clear();
    }
    
    
    
}

트러블 슈팅

문제는 어드레서블을 통해 로드해오는 과정이 모두 비동기적으로 이루어지기때문에 발생했다.

기존에 레이블을 통해 IList을 가져온 뒤, 이 Location 리스트를 가지고 에셋들을 로드해오려고 했었다. 문제는 Location을 가져오는 메서드도 비동기적으로 진행되기때문에 이를 한번에 하려고 하면 Location을 가져오기도 전에 에셋을 로드해오려고 시도하게 되어 에러가 발생한다.

즉, Location을 가져오는 동작이 Completed가 되고 나면 그 다음에 에셋을 로드해오도록 만들어야한다.

profile
게임개발자 취준생입니다

0개의 댓글