에셋 번들 (AssetBundle)
이론
에셋번들이란?
에셋(모델, 텍스쳐, 프리팹, 씬...)들을 묶은 아카이브 파일이다.
에셋번들을 이용하면 이걸 런타임에 불러서 쓸 수 있다.
그래서 DLC를 제공하거나, 컨텐츠를 패치하거나, 모바일 게임에서 초기 인스톨 사이즈를 줄이기 위해서 사용된다.
예를 들어, 다음과 같은 구 오브젝트가 있고, 이것을 런타임에 생성하려 한다.
가장 간단한 방법은 해당 오브젝트에 대한 레퍼런스를 직접 연결해서 생성하는 것이다.
public class SpawnFromRefer : MonoBehaviour
{
public GameObject prefab;
[ContextMenu("Spanw")]
public void Spawn()
{
if (prefab != null)
Instantiate(prefab);
}
}
이런 방식은 참조하는 오브젝트가 많아지면 메모리 문제가 생길 수 있다.
그래서 리소스 폴더를 사용하기도 하는데, 이걸 사용하면 메모리 관리가 복잡해지고 어플리케이션 시작 시간과 빌드 시간이 증가한다.
왜냐?
프로젝트를 빌드할 때 모든 에셋은 시리얼라이즈된 컴포넌트로 저장이 된다. 메타데이터도 생기고.
그래서 씬에서 사용하지 않는 오브젝트들의 메타데이터도 일단 메모리에 올라간다. 그래서 리소스 폴더에 리소스들이 많아지면 게임 씬에 아무것도 없어도 메타데이터 때문에 메모리가 차지된다.
그리고 저사양 기기라면 이런 메모리 차지도... 좀 그럴 수 있다.
그래서 보통 프로토타이핑이나 빈번히 쓰이는 애들만 여기에 둠.
그래서 에셋 번들이란 것을 이용하여 사용할 에셋이 있는 번들을 로드하고 꺼내 쓰는 방식을 쓰게 된다.
그럼 에셋과 유니티 오브젝트는 뭔 차이일까?
에셋은 디스크 상의 파일이다. (PNG, JPG...)
반면 유니티 엔진 오브젝트는 유니티가 시리얼라이즈한 데이터의 모음이다(sprite, mesh...)
그래서 에셋을 추가하면 유니티가 임포팅하는 과정을 거치는 것이다. 이 과정에서 에셋을 플랫폼에 맞는 적절한 오브젝트로 컨버팅한다.
이런 과정은 오래 걸리기 때문에 라이브러리에 에셋의 임포트 결과가 캐싱되어 저장된다. (단일 바이너리 파일로 시리얼라이즈돼서)
에셋 번들은 원본 파일이 아니라 유니티에서 쓸 수 있도록 '시리얼라이즈된' 오브젝트이다.
아무튼 이런 에셋 번들을 로드하면 모바일 환경에서는 헤더 정보만 로드하고 요청이 들어오면 그 때 실제 에셋 데이터를 로드한다. 반면에 에디터에서는 번들 통째로 메모리에 올리기 때문에 메모리 프로파일링을 할 때 에디터에서 하는 건 의미가 없어진다. 그리고 모바일에서 번들의 헤더 정보만 올린다고 하더라도 어쨋든 메모리를 꽤 먹으니 마구잡이로 번들을 로드해서도 안된다.
의존성
에셋 번들은 서로 의존을 하기도 하는데... 예를 들어 위의 초록색 구는 Bundle A에 있다고 하자.
그런데 초록색 구가 Bundle B에 있는 초록색 메테리얼을 참조하고 있다면 번들 A가 B에 의존하고 있는 것이다.
그래서 번들을 로드할때는 이런 의존관계를 잘 살펴야 한다.
씬 전환과 메모리
씬 전환할 때 메모리가 스파이크 되는 문제가 있다.
예를 들어, 씬A에서 씬B로 전환을 했다고 하자. 씬을 전환하면 내부적으로
Resource.UnloadUnusedAsset()
를 호출하는데, 문제는 이 호출 타이밍에 있다.
A에서 B로 씬을 전환하는 과정은 다음과 같기 때문에
씬A 로드 => 씬B 로드 => 씬A 제거
순간적으로 씬A,B가 동시에 로드되는 상황이 생기고 메모리 사용량이 순간적으로 치솟을 수 있는 것이다.
그래서 중간 중간에 empty씬이나 로딩씬같은 가벼운 씬들을 넣어주면 이런 문제를 해결할 수 있다.
압축
에셋 번들의 압축에는 대표적으로 두가지가 있는데 다음과 같다
- LZMA : LZ4대비 압축 효율이 좋지만 로드 시간이 느리다. 1에셋 1번들이 원칙이며 번들이 한번 압축 이 풀리면 디스크에 풀린상태로 캐싱이 된다.
- LZ4 : 청크 단위의 압축이므로 사용하려는 부분만 압축을 해제하면 된다. 따라서 LZMA에 비하여 빠르지만 압축효율이 떨어진다.
기타 에셋 번들 전용 그래픽툴이나 프로파일러가 존재하니 참고할 것.
간단한 실습
에셋번들 만들기
이런 초록색 구를 에셋 번들로 만들어보자.
먼저 적당히 에셋을 만든다음 인스펙터 최하단에
이렇게 에셋 번들의 이름을 추가해주자.
그리고 에셋 번들을 빌드하는 코드를 작성해준다. (에디터 어셈블리에 포함되도록 Assets/Editor 폴더에 추가해야함)
public class CreateAssetBundle
{
[UnityEditor.MenuItem("Assets/Build All Asset Bundles")]
static void BuildAllAssetBundles()
{
string assetBundleDir = "Assets/StreamingAssets";
if (!Directory.Exists(Application.streamingAssetsPath))
{
Directory.CreateDirectory(assetBundleDir);
}
BuildPipeline.BuildAssetBundles(assetBundleDir, BuildAssetBundleOptions.None,
EditorUserBuildSettings.activeBuildTarget);
}
}
이제 빌드한 에셋 번들은 StreamingAssets 폴더에 저장이 된다.
메뉴 버튼을 눌러 빌드를 해주면
요렇게 번들이 생긴다.
manifest 파일은 번역하면 적재목록 정도 되는데 일종의 메타데이터이다. 안에 Dependency나 에셋 및 버전 정보등등이 들어간다.
나는 테스트를 위해서 메테리얼을 testmaterial이라는 다른 번들에 포함시켰다. 따라서 testsphere번들이 testmaterial에 의존한다.
번들에서 오브젝트 생성하기
이제 번들을 로드해서 구를 생성해보자
로컬에서 비동기적으로 로드
public void LoadFromLocalAsync()
{
StartCoroutine(LoadFromLocalAsyncProcess());
}
private IEnumerator LoadFromLocalAsyncProcess()
{
AssetBundleCreateRequest asyncBundleRequest =
AssetBundle.LoadFromFileAsync(Path.Combine(Application.streamingAssetsPath, bundleName));
yield return asyncBundleRequest;
AssetBundle localAssetBundle = asyncBundleRequest.assetBundle;
if (localAssetBundle == null)
{
Debug.LogError("번들 로드 실패");
yield break;
}
AssetBundleRequest assetRequest = localAssetBundle.LoadAssetAsync<GameObject>(assetName);
yield return assetRequest;
var prefab = assetRequest.asset as GameObject;
Instantiate(prefab);
localAssetBundle.Unload(true);
}
위와 같이 적당히 코드를 짜준다. 참고할 점은 material에 대한 번들은 로드하지 않았다는 점이다. 따라서 구가 참조하는 메테리얼이 로드되지 않았으므로, 메테리얼이 제대로 안 보일 것을 기대할 수 있다.
역시나 missing으로 뜬다.
이번에는 로컬에서 동기적으로 로드하는 법을 알아보자.
동기적인 로드 방식은 비동기적인 로드 방식에 비해 무조건 1프레임 이상 빠르다. 이번에는 메테리얼 번들도 로드하겠다.
public void LoadFromLocal()
{
AssetBundle localAssetBundle =
AssetBundle.LoadFromFile(Path.Combine(Application.streamingAssetsPath, bundleName));
AssetBundle materialbundle
= AssetBundle.LoadFromFile(Path.Combine(Application.streamingAssetsPath, bundle2));
if (localAssetBundle == null)
{
Debug.LogError("번들 로드 실패");
return;
}
var asset = localAssetBundle.LoadAsset<GameObject>(assetName);
Instantiate(asset);
localAssetBundle.Unload(false);
materialbundle.Unload(false);
}
메테리얼 번들은 단순히 로드만 하고 아무것도 하지 않을 것이다.
실행 결과 메테리얼이 잘 잡혔다.
그러면 이번에는 웹에서 번들을 로드해보자.
여기서 testsphere을 구글 드라이브에 올린다음, 공유 링크를 생성하고,
Direct Link를 생성해주는 페이지에서 링크를 만들어준다. (누르면 바로 다운되는 링크)
public void LoadFromWeb()
{
StartCoroutine(LoadFromWebProcess());
}
private IEnumerator LoadFromWebProcess()
{
var webRequest = UnityWebRequestAssetBundle.GetAssetBundle(bundleUrl, version : 0, crc : 0);
yield return webRequest.SendWebRequest();
if (webRequest.result == UnityWebRequest.Result.ConnectionError)
{
Debug.Log(webRequest.error);
}
else
{
AssetBundle bundle = DownloadHandlerAssetBundle.GetContent(webRequest);
var prefab = bundle.LoadAsset<GameObject>(assetName);
Instantiate(prefab);
bundle.Unload(true);
}
}
참고로 GetAssetBundle 인자에 version을 넘겨주면, 웹에서 다운로드 받은 번들을 로컬에 캐싱한다. 만약 캐싱된 번들이 있다면 다운로드를 진행하지 않는다.
어쨋든 얘도 메테리얼 번들을 로드하지 않았기 때문에 위와 같이 메테리얼 없는 구가 나온다.
처음 구를 생성할 때는 번들을 다운로드 하느라 꽤 시간이 소요되는데, 그 다음부터는 캐싱된 번들을 불러오므로 속도가 빠르다.
만약 version을 넘기지 않으면 캐싱을 사용하지 않는다.
그리고 캐시된 번들은 에디터에서는 Appdata/LocalLow/Unity 에 들어가보면 있는데
새로운 버전을 사용한다고 해서 이전 버전의 에셋 번들이 지워지지는 않는다. 그래서 아마 구 버전을 삭제해주는 코드를 따로 작성해줘야 할 것 같다.
이부분은 유니티의 Caching 클래스의 정적 메서드들을 활용하면 된다.
참고 :
1. '에셋번들이 번들번들 에셋번들 실용 가이드' , 유튜브, 2017년, https://youtu.be/Z9LrkQUDzJw
2. Introdunction to Asset Bundles, UnityLearn, 2021년, https://learn.unity.com/tutorial/introduction-to-asset-bundles#