에셋 번들을 이용한 리소스 로딩 시스템

어드레서블 등록이 손이 많이 가서 만든 에셋번들 로딩
에셋번들AssetBundleAddressableAssetsAssetBundleUnity
avatar
2025.03.24
·
13 min read

안녕하세요!
오늘은 에셋 번들을 이용해서 리소스 로딩 매니저를 만들어 봤습니다.

어드레서블이 아닌 에셋 번들을 이용한 리소스 로딩 시스템을 만든 이유는 다음과 같습니다.

  1. 리소스가 추가될 때 마다 어드레서블에 등록하는게 너무 손이 많이간다.

  2. 프로젝트 파일 Asset 이하 파일을 모두 다른 프로젝트로 이주했더니, 이전에 있던 어드레서블 키 관련 로딩시스템이 남아있어서 지우기 쉽지않았다.

    1. 이 부분은 어드레서블 폴더를 복붙하면 데이터 끌고오면서 갱신될 거 같긴하다는 생각이 듭니다.

아무튼 2번은 프로젝트를 포크떠서 이주하는 케이스 아니면 상관이 없었고, 1번이 조금 많이 귀찮았습니다.

1번 케이스의 폐해..


4266

파일 하나하나 어드레서블에 담고, 키 수정해야하고, 뭔가 쉽지않았습니다.
엉키면 로딩도 안돼서 뭔가 개복치 시스템을 만든 제 숙련도가 문제였겠지만, 이 모든 과정을 그냥 코드로 알아서 로딩하게 하는게 마음 편하겠다. 라는 생각이 들었어요.

그러면 어떤 시스템을 만들고자 하는가?


새로운 리소스 로딩 시스템을 만들기 전에, 에셋 번들과, Manifest(매니페스트), 디펜던시 등을 알고 전략을 짜야하긴 합니다.

쉽게보면 에셋 번들은 압축된 리소스 데이터 뭉치고, 매니페스트 들은 리소스들에 대한 참조 데이터 들을 담은 파일인데, 이 참조 데이터들을 디펜던시라고 합니다.

에셋 번들은 여러 번들들을 관리하는 메인번들, 그리고 각각의 번들(여기서는 서브번들 이라고 하겠습니다.) 이 있습니다.

메인 번들은 매니페스트에 각각의 서브번들들에 대한 디펜던시들을 가지고 있습니다.

각각의 서브번들 또한 매니페스트가 있고, 서브번들의 매니페스트는 각각 리소스 디펜던시가 있습니다.

저는 초기에 매니페스트 들을 모두 로딩해서 나중에 리소스의 이름은 알지만, 번들을 모를때, 찾아올 수 있도록 하기 위해 미리 매니페스트들을 넣어두려고 합니다.

탐색에 대한 코스트가 있을지도 모르지만, 실수에 대한 완충 코드라고 생각해서 세팅을 먼저 해둡니다.

로딩 플로우는 ?


먼저 게임의 가장 첫 시작 부분에, MainAssetBundle 에 있는 Manifest를 로딩해, 관련 디펜던시가 있는 모든 에셋번들을 참조해, 에셋번들 내부에 있는 AssetName을 Hash<String> 에 캐싱해줍니다.

    public async UniTaskVoid UTask_LoadingManifest(Action _onCB_Complete)
    {
        CancellationTokenSource _tokenSource = _LoadingCancellation;
        string _mainAssetBundlePath = Application.streamingAssetsPath + "/AssetBundle/AssetBundles";

         AssetBundleCreateRequest _mainAssetBundle_Request = AssetBundle.LoadFromFileAsync(_mainAssetBundlePath);
        // 먼저, 비동기로 에셋 번들을 받아온다.
        // 전체적으로 에셋 번들을 관리하는 AssetBundle은 내부적으로 Manifest를 번들안에 관리하고 있다.

        await UniTask.WaitUntil( () => _mainAssetBundle_Request.isDone == true);

        AssetBundleRequest _assetBundleManifest_Request = _mainAssetBundle_Request.assetBundle.LoadAssetAsync<AssetBundleManifest>("AssetBundleManifest");
        // Main Assetbundle의 manifest는 Assetbundle의 내부에 존재한다.

        await UniTask.WaitUntil(() => _assetBundleManifest_Request.isDone == true);
        // Manifest 로딩 준비

        _m_AssetBunbleManifest = _assetBundleManifest_Request.asset as AssetBundleManifest;

        string[] _strAssetName = _m_AssetBunbleManifest.GetAllAssetBundles();

        AssetBundleCreateRequest _subAssetBundle_Request = null; // 실제 리소스가 들어가 있는 각각의 번들
        StringBuilder _sb = new StringBuilder();

        AssetBundleUnloadOperation _unloadOperation = null;


        for (int i = 0; i < _strAssetName.Length; ++i)
        {
            _sb.Clear();
            _sb.Append(Application.streamingAssetsPath);
            _sb.Append("/AssetBundle/");
            _sb.Append(_strAssetName[i]);

            _subAssetBundle_Request = AssetBundle.LoadFromFileAsync(_sb.ToString());

            await UniTask.WaitUntil(() => _subAssetBundle_Request.isDone == true);

            if (_m_dic_BundleManifest.ContainsKey(_strAssetName[i]) == true)
            {
#if UNITY_EDITOR 
                EditorUtility.DisplayDialog($"Error!!", $"AssetBundle에 중복되는 BundleName이 있습니다.", $"확인");
                EditorApplication.isPlaying = false;
#endif
            }

            string[] _allAssetNames = _subAssetBundle_Request.assetBundle.GetAllAssetNames();

            if (!_m_dic_BundleManifest.ContainsKey(_strAssetName[i]))
                _m_dic_BundleManifest.Add(_strAssetName[i], new HashSet<string>());

            for(int j = 0; j < _allAssetNames.Length; ++j)
            {
                _m_dic_BundleManifest[_strAssetName[i]].Add(_allAssetNames[j]);
            }

            _unloadOperation = _subAssetBundle_Request.assetBundle.UnloadAsync(true);

            await UniTask.WaitUntil(() => _unloadOperation.isDone == true);
        }
    }

실 사용 코드입니다.
로딩과 같은 처리는 비동기로 보통 진행하기 때문에, 비동기 함수와 Unitask를 이용해 싱크를 관리해줍니다.

코드를 보시면 이해하시는 분도 있겠지만, 가장 먼저 메인 에셋번들을 로딩한 뒤, 메인 에셋번들 내부에 있는 Manifest들로 디펜던시를 찾고, 해당 디펜던시로 각각 서브 에셋번들을 찾아들어가 HashSet<String>으로 각 AssetName들을 캐싱해줍니다.

이런 선처리를 한 결과로, 나중에 리소르를 가져올때, 내가 찾는 리소스가 내가 지정한 에셋번들에서 가져오는게 맞는지, 없다면 다른 에셋번들을 뒤져서라도 가져오도록 합니다.

실제로 리소스를 가져올 때는, 실제로 있는 데이터인지, 에셋번들 이름은 맞는지, 에셋 번들안에 있는 에셋을 가져오려고 하는지는 맞는지 등에 대한 예외처리를 진행합니다.

그리고 에셋 번들을 가져오고, 로딩되지 않은 에셋이라면 로딩 후에, 번들 내에서 이름으로 찾아 에셋을 UnityEngine.Object 로 변경한 뒤, 게임오브젝트로 인스턴싱 하게되면 로딩&인스턴싱이 완료됩니다.

이 에셋에 대한 처리는 추후에 맵 로딩이라던지, 이런 부분에서 진행하면 되겠지만, 방향성에 따라 단순히 GC만 클리어할 수 도 있긴해서, 추후 처리에 따라 결정합니다.

public async UniTask UTask_GetResource(string _assetBundleType, string _assetName, Action<UnityEngine.Object> _onCB_Complete)
{
    _assetName = _assetName.ToLower();
    _assetBundleType = _assetBundleType.ToLower();

    if (!IsValidAssetBundleType(_assetBundleType))
        return;
    // 에셋 번들 타입 자체가 없는 경우, 이 경우는 프로그램에 문제가 있다. 찾고 할 문제가 아님.

    HashSet<string> _mHs_AssetNames = null;

    if (!IsValidAssetBundleManifestResource(_assetBundleType, _assetName))
    {
        _mHs_AssetNames = SearchAssetFromAllBundle(_assetName);
    }
    else
    {
        _mHs_AssetNames = _m_dic_BundleManifest[_assetBundleType];
    }
    // 내가 설정한 에셋 번들에 에셋에 없다. 이런 경우는 에셋을 찾아봐야겠다.

    if (_mHs_AssetNames == null || _mHs_AssetNames.Count == 0)
        return;
    // 다시 전체에서 찾았는 경우에도 없다면 종료한다.

    if (_m_dic_Bundles.ContainsKey(_assetBundleType) == false)
    {
        // 에셋 번들이 로딩되지 않았다면 로딩한다.
        await UTask_Loading_AssetBundle(_assetBundleType);
    }

    var _loadedObject =  await UTask_Loading_AssetByAssetBundle(_assetBundleType, _assetName);

    _onCB_Complete?.Invoke(_loadedObject);
}

실제로 리소스를 가지고 오는 부분입니다.

코드 전문을 다 첨부할 수 없어서 파일을 첨부하도록 하겠습니다.
이 리소스 로딩 시스템은 아직 로딩할때의 디펜던시 깨짐 처리가 완료되어있지 않은 시스템입니다.

현재 복잡한 디펜던시를 가진 오브젝트가 없어서 추후 로딩하다가 문제 발생 시 처리하려고 일단 이대로 진행해두었습니다.

문의가 필요하신 내용은 댓글에 적어주시면 아는선에서 답변드리겠습니다~

PS. 파일 추가가 안되어서 전문 달아놓겠습니다

결과


내부적으로 AssetBundle 을 통해 로딩되어 생성되는 캐릭터 입니다.

4277

코드


using Cysharp.Threading.Tasks;
using System.Threading;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using System.Text;
using UnityEditor;

public class AssetBundleResourceManager : MonoBehaviour
{

    public static AssetBundleResourceManager Instance;
    public static AssetBundleResourceManager GetInstance()
    {
        if (Instance == null)
        {
            GameObject obj = new GameObject("AssetBundleResourceManager");
            Instance = obj.AddComponent<AssetBundleResourceManager>();
            Instance.Initialize();
        }

        return Instance;
    }

    public void Initialize()
    {
        _m_dic_BundleManifest = new Dictionary<string, HashSet<string>>();
        _m_dic_Bundles = new Dictionary<string, AssetBundle>();
    }

    // 에셋 번들로 가져오고 싶다. 
    // 왜 ? 어드레서블을 쓰면 좋긴하다. 인정.
    // 근데, 계속 어드레서블을 등록해야돼. 불편해.

    public CancellationTokenSource _LoadingCancellation;

    private AssetBundleManifest _m_AssetBunbleManifest; // 모든 에셋 번들의 Manifest : 전체 디펜던시를 가지고 있을 거임

    private Dictionary<string, HashSet<string>> _m_dic_BundleManifest; // 각 번들 당 내부의 디펜던시를 가지고 있을 것임.
    private Dictionary<string, AssetBundle> _m_dic_Bundles;                                // AssetBundleType 당 가지고 있는 에셋 번들 

    public async UniTaskVoid UTask_LoadingManifest(Action _onCB_Complete)
    {
        CancellationTokenSource _tokenSource = _LoadingCancellation;
        string _mainAssetBundlePath = Application.streamingAssetsPath + "/AssetBundle/AssetBundles";

         AssetBundleCreateRequest _mainAssetBundle_Request = AssetBundle.LoadFromFileAsync(_mainAssetBundlePath);
        // 먼저, 비동기로 에셋 번들을 받아온다.
        // 전체적으로 에셋 번들을 관리하는 AssetBundle은 내부적으로 Manifest를 번들안에 관리하고 있다.

        await UniTask.WaitUntil( () => _mainAssetBundle_Request.isDone == true);

        AssetBundleRequest _assetBundleManifest_Request = _mainAssetBundle_Request.assetBundle.LoadAssetAsync<AssetBundleManifest>("AssetBundleManifest");
        // Main Assetbundle의 manifest는 Assetbundle의 내부에 존재한다.

        await UniTask.WaitUntil(() => _assetBundleManifest_Request.isDone == true);
        // Manifest 로딩 준비

        _m_AssetBunbleManifest = _assetBundleManifest_Request.asset as AssetBundleManifest;

        string[] _strAssetName = _m_AssetBunbleManifest.GetAllAssetBundles();

        AssetBundleCreateRequest _subAssetBundle_Request = null; // 실제 리소스가 들어가 있는 각각의 번들
        StringBuilder _sb = new StringBuilder();

        AssetBundleUnloadOperation _unloadOperation = null;


        for (int i = 0; i < _strAssetName.Length; ++i)
        {
            _sb.Clear();
            _sb.Append(Application.streamingAssetsPath);
            _sb.Append("/AssetBundle/");
            _sb.Append(_strAssetName[i]);

            _subAssetBundle_Request = AssetBundle.LoadFromFileAsync(_sb.ToString());

            await UniTask.WaitUntil(() => _subAssetBundle_Request.isDone == true);

            if (_m_dic_BundleManifest.ContainsKey(_strAssetName[i]) == true)
            {
#if UNITY_EDITOR 
                EditorUtility.DisplayDialog($"Error!!", $"AssetBundle에 중복되는 BundleName이 있습니다.", $"확인");
                EditorApplication.isPlaying = false;
#endif
            }

            string[] _allAssetNames = _subAssetBundle_Request.assetBundle.GetAllAssetNames();

            if (!_m_dic_BundleManifest.ContainsKey(_strAssetName[i]))
                _m_dic_BundleManifest.Add(_strAssetName[i], new HashSet<string>());

            for(int j = 0; j < _allAssetNames.Length; ++j)
            {
                _m_dic_BundleManifest[_strAssetName[i]].Add(_allAssetNames[j]);
            }

            _unloadOperation = _subAssetBundle_Request.assetBundle.UnloadAsync(true);

            await UniTask.WaitUntil(() => _unloadOperation.isDone == true);
        }
    }

    public async UniTask UTask_GetResource(string _assetBundleType, string _assetName, Action<UnityEngine.Object> _onCB_Complete)
    {
        _assetName = _assetName.ToLower();
        _assetBundleType = _assetBundleType.ToLower();

        if (!IsValidAssetBundleType(_assetBundleType))
            return;
        // 에셋 번들 타입 자체가 없는 경우, 이 경우는 프로그램에 문제가 있다. 찾고 할 문제가 아님.

        HashSet<string> _mHs_AssetNames = null;

        if (!IsValidAssetBundleManifestResource(_assetBundleType, _assetName))
        {
            _mHs_AssetNames = SearchAssetFromAllBundle(_assetName);
        }
        else
        {
            _mHs_AssetNames = _m_dic_BundleManifest[_assetBundleType];
        }
        // 내가 설정한 에셋 번들에 에셋에 없다. 이런 경우는 에셋을 찾아봐야겠다.

        if (_mHs_AssetNames == null || _mHs_AssetNames.Count == 0)
            return;
        // 다시 전체에서 찾았는 경우에도 없다면 종료한다.

        if (_m_dic_Bundles.ContainsKey(_assetBundleType) == false)
        {
            // 에셋 번들이 로딩되지 않았다면 로딩한다.
            await UTask_Loading_AssetBundle(_assetBundleType);
        }

        var _loadedObject =  await UTask_Loading_AssetByAssetBundle(_assetBundleType, _assetName);

        _onCB_Complete?.Invoke(_loadedObject);
    }

    public async UniTask UTask_Loading_AssetBundle(string _assetBundleType)
    {
        string _mainAssetBundlePath = Application.streamingAssetsPath + "/AssetBundle/" + _assetBundleType;
        AssetBundleCreateRequest _mainAssetBundle_Request = AssetBundle.LoadFromFileAsync(_mainAssetBundlePath);

        await UniTask.WaitUntil(() => _mainAssetBundle_Request.isDone == true);

        _m_dic_Bundles.Add(_assetBundleType, _mainAssetBundle_Request.assetBundle);
    }

    public async UniTask<UnityEngine.Object> UTask_Loading_AssetByAssetBundle(string _assetBundleType, string _assetName)
    {
        AssetBundleRequest _request = _m_dic_Bundles[_assetBundleType].LoadAssetAsync<UnityEngine.Object>(_assetName);

        await UniTask.WaitUntil(() => _request.isDone == true);

        return _request.asset as UnityEngine.Object;
    }

    public bool IsValidAssetBundleManifestResource(string _assetBundleType, string _assetName)
    {
        HashSet<string> _dependancies = _m_dic_BundleManifest[_assetBundleType];

        if (_dependancies.Contains(_assetName))
            return true;

        return false;
    }
    public bool IsValidAssetBundleType(string _assetBundleType)
    {
        if (_m_dic_BundleManifest.ContainsKey(_assetBundleType) == false)
            return false;

        return true;
    }

    public HashSet<string> SearchAssetFromAllBundle(string _assetName)
    {
        HashSet<string> _ret = null;

        foreach(var _manifestPair in _m_dic_BundleManifest)
        {
            string _key = _manifestPair.Key;

            if(IsValidAssetBundleManifestResource(_key, _assetName) == true)
            {
                _ret = _manifestPair.Value;
                break;
            }
        }

        return _ret;
    }

    public void UnLoadBundles()
    {
        foreach (var bundle in _m_dic_Bundles.Values)
        {
            bundle.Unload(true);
        }
        _m_dic_Bundles.Clear();


        UnityLogger.GetInstance().Log($"모든 번들 언로드 완료");
    }
}







- 컬렉션 아티클