본문 바로가기

게임 프로그래밍/유니티

Asset Bundle 사용해보기

번들을 서버에 업로드

 

번들을 서버에서 다운로드

유니티 공식 메뉴얼 에 있는 에셋 번들에 대한 설명 사진이다.

 

리소스를 에셋 번들이라는 이름으로 묶어서 외부로 뺄 수 있는 기능으로

특히나 모바일 게임에서 apk 자체의 용량을 줄이기 위해 매우 유용하게 사용된다.

 

데스크탑의 경우에도 예외는 아니며 버그 조금 수정했다고 전체 게임을 다시 받게 할 수는 없는 노릇이기에

이미지, 사운드, 모델 등으로 분류를 해서 번들로 관리를 한다면

필요한 리소스들만 버전 체크로 업데이트해서 유저, 개발자 서로 편할 수 있다.

 

또한 DownLoadable Content 와 같은 기능을 구현할 때도 매우 유용하게 사용할 수 있다.

 

이제 이 좋은 기능을 한번 사용해보자.


우선 에셋 번들을 에셋에 설정하는 법은 간단하다.

 

옆 사진과 같이 인스펙터 하단의 AssetBundle 에서

New 를 눌러서 새로 추가해주면 된다.

 

리소스 자체 또는 폴더에 설정이 가능하며

폴더에 지정할 경우 하위 파일이 전부 포함된다.

 

 

또한 위와 같이 / 를 추가할 경우

자동으로 트리구조로 구성할 수 있으며

번들을 추출할 때도 폴더가 생성된다.

 

 

 

 

 

에셋을 지정했으니 이제 서버로 올릴 형태로 빌드를 해줘야 한다.

 

using System.IO;
using UnityEditor;

public class CreateAssetBundles
{
    [MenuItem("Assets/Build AssetBundles")]
    static void BuildAllAssetBundles()
    {
        string dir = "Assets/AssetBundles";
        if (!Directory.Exists(dir))
            Directory.CreateDirectory(dir);
        BuildPipeline.BuildAssetBundles(dir, BuildAssetBundleOptions.None, EditorUserBuildSettings.activeBuildTarget);
    }
}

 

에디터 스크립트로 Editor 폴더에 넣고

[MenuItem] 의 메뉴창에서 빌드를 하면 dir 경로에 번들 파일이 생성된다.

 

생성된 파일중 .manifest 는 필요한 경우 (버전 체크 등) 포함시키고

확장자가 없는 파일만 다운로드 서버로 업로드시키면 된다.

 

여기서 AssetBundles 는 없어도 되며

아마 스트리밍 형태로 처리할 때 사용하는 게 아닌가 싶다. (확실히 모르겠다.)

 

 

 

마지막으로 리스트 형태로 여러 개 처리가 가능하게 대충 구성해본 에셋 매니저 코드이다.

 

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.Networking;

public struct AssetBundleData
{
    public string Name;
    public string Url;

    public AssetBundleData(string name, string url = null)
    {
        Name = name;
        Url = url;
    }
}

public class AssetBundleManager : SingletonStatic<AssetBundleManager>
{
    readonly string AssetBundleDirectory = Application.streamingAssetsPath + "/AssetBundles/";

    Dictionary<string, AssetBundle> bundles = new Dictionary<string, AssetBundle>();

    /// <summary>
    /// AssetBundle 을 다운받아 로컬에 저장합니다.
    /// </summary>
    public void DownloadAssetBundle(string name, string url, Action<float> progressFunc = null, Action completeFunc = null)
    {
        StartCoroutine(DownloadAndCache(name, url, progressFunc, completeFunc));
    }

    /// <summary>
    /// AssetBundle 을 순차적으로 다운받아 로컬에 저장합니다.
    /// </summary>
    public void DownloadAssetBundle(List<AssetBundleData> bundles, Action<float> progressFunc = null, Action<int> indexFunc = null, Action completeFunc = null)
    {
        if (bundles.Count >= 2)
        {
            void LoopFunc(int index)
            {
                indexFunc?.Invoke(index);
                Debug.Log(index);
                if (index == bundles.Count - 1)
                    DownloadAssetBundle(bundles[index].Name, bundles[index].Url, progressFunc, completeFunc);
                else if (index < bundles.Count)
                DownloadAssetBundle(bundles[index].Name, bundles[index].Url, progressFunc, () => LoopFunc(index + 1));
            }
            LoopFunc(0);
        }
        else if (bundles.Count > 0)
            DownloadAssetBundle(bundles[0].Name, bundles[0].Url, progressFunc, completeFunc);
    }

    IEnumerator DownloadAndCache(string name, string url, Action<float> progressFunc, Action completeFunc)
    {
        using (var req = UnityWebRequest.Get(url))
        {
            var oper = req.SendWebRequest();
            yield return DownloadProgress(oper, progressFunc);
            if (req.result != UnityWebRequest.Result.Success)
                Debug.LogError(req.error);
            else
            {
                string dir = Path.GetDirectoryName(AssetBundleDirectory + name);
                if (!Directory.Exists(dir))
                    Directory.CreateDirectory(dir);
                File.WriteAllBytes(AssetBundleDirectory + name, req.downloadHandler.data);
            }
        }
        completeFunc?.Invoke();
    }

    /// <summary>
    /// AssetBundle 을 로컬에서 로드합니다.
    /// </summary>
    public void LoadAssetBundle(string name, Action<float> progressFunc = null, Action completeFunc = null)
    {
        if (!File.Exists(AssetBundleDirectory + name))
        {
            Debug.LogError($"AssetBundles : {name} 는 존재하지 않습니다.");
            return;
        }
        if (bundles.ContainsKey(name))
        {
            Debug.LogError($"AssetBundles : {name} 는 이미 로드되었습니다.");
            return;
        }

        StartCoroutine(LoadFromLocal(name, progressFunc, completeFunc));
    }

    /// <summary>
    /// AssetBundle 을 순차적으로 로컬에서 로드합니다.
    /// </summary>
    public bool LoadAssetBundle(List<AssetBundleData> bundles, Action<float> progressFunc = null, Action<int> indexFunc = null, Action completeFunc = null)
    {
        bool error = false;
        for (int i = 0; i < bundles.Count; i++)
        {
            if (!File.Exists(AssetBundleDirectory + bundles[i].Name))
            {
                Debug.LogError($"AssetBundles : [{i}]{bundles[i].Name} 는 존재하지 않습니다.");
                error = true;
            }
            if (this.bundles.ContainsKey(bundles[i].Name))
            {
                Debug.LogError($"AssetBundles : [{i}]{bundles[i].Name} 는 이미 로드되었습니다.");
                error = true;
            }
        }

        if (error)
            return false;

        if (bundles.Count >= 2)
        {
            void LoopFunc(int index)
            {
                Debug.Log(index);
                indexFunc?.Invoke(index);
                if (index == bundles.Count - 1)
                    StartCoroutine(LoadFromLocal(bundles[index].Name, progressFunc, completeFunc));
                else if (index < bundles.Count)
                    StartCoroutine(LoadFromLocal(bundles[index].Name, progressFunc, () => LoopFunc(index + 1)));
            }
            LoopFunc(0);
        }
        else if (bundles.Count > 0)
            StartCoroutine(LoadFromLocal(bundles[0].Name, progressFunc, completeFunc));

        return true;
    }

    IEnumerator LoadFromLocal(string name, Action<float> progressFunc, Action completeFunc)
    {
        var bundle = AssetBundle.LoadFromFileAsync(Path.Combine(AssetBundleDirectory, name));
        yield return DownloadProgress(bundle, progressFunc);
        bundles[name] = bundle.assetBundle;
        completeFunc?.Invoke();
    }

    IEnumerator DownloadProgress(AsyncOperation operation, Action<float> progressFunc)
    {
        while (!operation.isDone)
        {
            progressFunc?.Invoke(operation.progress);
            yield return null;
        }
    }

    public T Load<T>(string bundleName, string path) where T : UnityEngine.Object
    {
        if (!bundles.ContainsKey(bundleName))
        {
            Debug.LogError($"AssetBundles : {bundleName} 는 없습니다.");
            return default;
        }

        T value = bundles[bundleName].LoadAsset<T>(path);

        if (value == null)
        {
            Debug.LogError($"AssetBundles : {path} 는 없습니다.");
            return default;
        }

        return value;
    }
}

 

bundles = new List<AssetBundleData>();
bundles.Add(new AssetBundleData("image/background", "경로"));
bundles.Add(new AssetBundleData("image/sprite", "경로"));
bundles.Add(new AssetBundleData("model", "경로"));

bool LocalLoad()
{
    return AssetBundleManager.Instance.LoadAssetBundle(bundles,
        progressFunc: ProgressUpdate,
        indexFunc: (i) => currentIndex = i,
        completeFunc: Complete);
}

if (!LocalLoad())
{
    Debug.Log("로컬 실패. 서버에서 다운로드");
    AssetBundleManager.Instance.DownloadAssetBundle(bundles,
        progressFunc: ProgressUpdate,
        indexFunc: (i) => currentIndex = i,
        completeFunc: () => LocalLoad());
}

그림이 왜 저건지는 중요하지않다.

필자의 경우 다운로드 서버는 테스트기 때문에 대충 NAS 에 올려서 사용했다.

(다이렉트 링크 지원이 안돼서 적절하진 않다.)

 

위 코드를 활용한다고 하면 버전 체크 정도만 추가하면 되지 않을까 싶다.

 

 

추가로 혹시나 해서 적어두지만

게임 빌드 시에는 에셋 번들로 지정한 리소스나 빌드한 리소스는 자동으로 제외되기 때문에

빌드 전에 옮겨놓거나 할 필요는 없다.

 

다만, 테스트 도중 생긴 StreamingApplication.streamingAssetsPath 에 있는 건

같이 빌드가 되기 때문에 삭제해줘야 한다.

'게임 프로그래밍 > 유니티' 카테고리의 다른 글

시간 제어에 대한 간단한 테스트  (0) 2021.01.01