Content Directories 는 번들을 에셋 단위로 쪼갠다
콘텐츠를 AssetBundle 로 나누면 번들을 어떻게 묶을지 고민이 끝나지 않습니다. 텍스처 하나를 쓰려고 번들 하나와 그 번들이 기대는 번들까지 열고, 공용 셰이더는 번들마다 복사되고, 그걸 막으려고 중복 방지용 번들을 따로 만듭니다.
Unity 6.6 의 Content Directories 는 번들이라는 묶음을 없애고, 에셋 하나를 파일 하나로 두어 따로 찾고 따로 올리고 따로 내립니다. 번들 하나가 떠맡던 일을 Root, 디렉터리, Loadable 셋으로 나눈 것입니다.
번들 하나가 세 가지 일을 떠맡고 있었다
Unity 는 Content Directories 를 소개하는 글에서 AssetBundle 의 문제를 이렇게 짚었습니다. 번들 하나가 배포, 저장, 로드의 나눌 수 없는 단위라는 것입니다. 의존성도 번들 단위로 따라가서, 함께 받고 함께 올리고 함께 내려야 하는 큰 덩어리가 됩니다. 그래서 번들을 어떻게 나누느냐가 로딩 성능과 다운로드 크기를 좌우했고, 이건 AssetBundle 을 직접 쓰든 Addressables 를 쓰든 같았습니다.
매뉴얼도 같은 이야기를 합니다. 에셋 하나를 로드해도 그 번들과 의존 번들 전체를 재귀로 열어야 하고, 그 에셋이 쓰지 않는 것까지 같이 올라옵니다. 공용 에셋은 번들마다 복사될 수 있고, 그걸 막으려고 중복을 피하는 것 말고는 하는 일이 없는 번들이 생깁니다.
같은 Cube 프리팹 하나를 꺼낼 때 무엇이 열리는지 두 방식으로 보면 차이가 보입니다.
포장만 다르고 파일 안의 직렬화 포맷은 같습니다. AssetBundle 안에도 직렬화 파일과 .resS 가 들어 있고, Content Directories 는 그것을 번들로 묶지 않고 에셋마다 낱개 파일로 둡니다.
셋으로 나눴다: Root, 디렉터리, Loadable
번들 하나가 하던 “무엇이 들어 있나를 정리하는 일, 파일로 나가는 일, 올리고 내리는 일” 을 셋이 나눠 맡습니다.
| 나눈 것 | 맡는 일 | 따로 할 수 있는 것 |
|---|---|---|
| Root | 목차. 콘텐츠로 들어가는 입구 | 타입으로 Root 를 골라 꺼내기 |
| 디렉터리 | 빌드, 배포, 등록 | 따로 빌드하고 Player 에 넣을지 정하기 |
Loadable<T> |
메모리 | 에셋 하나를 따로 Load 하고 Release 하기 |
Root 는 직접 만든 ScriptableObject 입니다. Player 빌드가 Scene List 에서 출발해 닿는 것을 담듯, 콘텐츠 빌드는 Root 에서 출발해 닿는 것을 전부 담습니다. 런타임에는 ContentLoadManager.GetRootAssets<T>() 로 꺼내는 입구가 됩니다. 파일 이름이 해시라 경로로는 찾을 수 없기 때문입니다.
public class ContentPackRootAsset : ScriptableObject
{
public ContentPackMetadata metadata; // 일반 참조: Root 와 같이 올라온다
public Loadable<GameObject> bonusPrefab; // Load() 를 불러야 올라온다
public LoadableSceneId bonusLevel; // SceneManager 로 올린다
}
디렉터리 는 빌드 한 번의 결과 폴더입니다. 이 폴더를 RegisterContentDirectory 로 등록해야 엔진이 안에 무엇이 있는지 압니다. 같은 빌드 안에서는 같은 에셋이 한 번만 들어갑니다.
Loadable<T> 는 에셋 하나의 수명 손잡이입니다. 빌드에는 들어가지만 Load() 를 부를 때까지 메모리에 안 올라오고, Release() 하면 그 에셋만 내려갑니다. 문자열 경로 대신 타입이 붙은 참조라서 이름을 바꾸거나 옮겨도 끊기지 않습니다.
이 프로젝트의 Root 하나로 빌드하면 Root 에서 닿는 전체는 2.85 MB 인데, 등록할 때 바로 올라오는 것은 Root 와 일반 참조인 메타데이터, 스크립트 정보까지 6.93 KB 입니다. 나머지는 Loadable 과 LoadableSceneId 를 넘어갈 때만 올라옵니다.
기존 Addressables 프로젝트에서 숫자로 확인됐다
Unity 는 같은 글에서 Addressables 를 쓰던 Slime Rancher 2 를 코드를 바꾸지 않고 Content Directories 로 돌린 결과를 냈습니다. Unity 6.6 베타, MacBook Pro M5 Max 기준입니다.
| 항목 | AssetBundle | Content Directories |
|---|---|---|
| 증분 빌드 | 32분 16초 | 3분 4초 |
| 클린 빌드 | 58분 6초 | 37분 13초 |
| Player 크기 | 4 GB | 2.88 GB |
| 부팅에서 게임플레이까지 | 45초 | 30초 |
다만 매뉴얼은 “클린 빌드는 AssetBundle 보다 느릴 수 있다” 고 적고 있어서, 클린 빌드 숫자는 프로젝트 구성에 따라 달라질 수 있습니다.
대가도 있습니다. 6.6 은 Player 와 함께 넣는 로컬 콘텐츠만 다룹니다. 원격으로 받아 오는 배포는 아직 없고, Unity 는 이것을 Unity 7 세대에 에셋 단위 다운로드로 넓히겠다고 예고했습니다. 또 Loadable 의 Load 와 Release 짝을 맞추는 일은 쓰는 쪽 몫입니다.
직접 확인하는 테스트 코드
Root Asset 하나를 빌드하고 등록해서 꺼내 보는 최소 코드입니다. 위 저장소의 BuildContentPackRootAsset.cs 와 ContentDirectoryManager.cs 를 줄인 것입니다. 에디터에서는 빌드 출력 폴더를, Player 에서는 StreamingAssets 를 등록합니다.
// 에디터 스크립트: Root 에서 닿는 것을 전부 콘텐츠 디렉터리로 빌드한다
[MenuItem("Build/Content Directories/Content Pack")]
public static void Build()
{
var parameters = new BuildContentDirectoryParameters
{
outputPath = "Builds/ContentDirectories/ContentPack",
rootAssetPaths = new[] { "Assets/ContentPacks/ContentPackRootAsset.asset" },
options = BuildContentOptions.None,
};
BuildPipeline.BuildContentDirectory(parameters);
}
// 런타임: 등록하고, Root 를 꺼내고, Cube 하나만 올렸다 내린다
public class ContentBootstrap : MonoBehaviour
{
ContentDirectoryHandle m_Handle;
ContentPackRootAsset m_Root;
void Start()
{
#if UNITY_EDITOR
string path = "Builds/ContentDirectories/ContentPack";
#else
string path = Application.streamingAssetsPath + "/ContentPack";
#endif
m_Handle = ContentLoadManager.RegisterContentDirectory(path);
m_Root = ContentLoadManager.GetRootAssets<ContentPackRootAsset>()[0];
Debug.Log($"메타데이터: {m_Root.metadata.description}"); // 등록만으로 올라와 있다
Debug.Log($"Cube 상태: {m_Root.bonusPrefab.Status}"); // None. 아직 안 올라왔다
GameObject cube = m_Root.bonusPrefab.Load();
Debug.Log($"Cube 상태: {m_Root.bonusPrefab.Status}"); // Loaded
}
void OnDestroy()
{
m_Root.bonusPrefab.Release();
ContentLoadManager.UnregisterContentDirectory(m_Handle);
}
}
등록 직후 메타데이터는 읽히고 Cube 는 None 으로 나오면, Root 와 일반 참조만 올라오고 Loadable 은 기다린다는 것이 확인됩니다.
주의할 점
1. 메모리를 나누려면 Root 가 아니라 Loadable 로 끊는다
안 지키면 생기는 문제. Root 를 여러 개로 나누면 정리는 되지만 메모리는 그대로입니다. 디렉터리를 등록하면 그 안의 Root 전부와 그 Root 들이 일반 참조로 쥔 것이 한꺼번에 올라옵니다.
이렇게 쓴다. 나중에 올려도 되는 것은 Root 의 필드를 Loadable<T> 나 LoadableSceneId 로 바꿉니다. Root 는 목록이 너무 길어져 찾기 어려울 때 나눕니다.
2. 코드는 콘텐츠에 들어가지 않는다
안 지키면 생기는 문제. 콘텐츠에 새 컴포넌트나 새 Root 클래스를 넣고 콘텐츠만 다시 빌드하면, Player 에 그 클래스가 없어서 객체를 만들지 못합니다. 콘텐츠 파일에는 클래스 이름표만 들어가고 코드 본체는 늘 Player 의 DLL 에 있습니다.
이렇게 쓴다. 새 스크립트가 생긴 변경은 Player 도 다시 빌드합니다. 콘텐츠만 갈아 끼우는 변경은 기존 클래스와 필드 안에서만 합니다.
정리
Content Directories 는 번들이 한꺼번에 하던 일을 Root, 디렉터리, Loadable 로 나눠 에셋 하나를 단위로 다룹니다.
- 에셋 하나가 파일 하나라 의존 관계가 파일 → 파일이고, 쓰는 것만 열리고 놓은 것만 내려갑니다.
- Root 는 입구, 디렉터리는 빌드와 배포 단위,
Loadable은 메모리 단위입니다. 등록하면 Root 와 일반 참조만 올라옵니다. - 대신 6.6 은 로컬 콘텐츠 전용이고,
Load와Release의 짝은 쓰는 쪽이 맞춥니다.