Content Directories 는 등록할 때 실선까지만 올린다
콘텐츠를 Player 밖 폴더로 빼면, 게임은 그 폴더에 무엇이 있는지 모릅니다. 파일 이름은 해시라 경로로 찾을 수도 없고, 무엇을 언제 메모리에 올릴지도 정해 줘야 합니다.
Content Directories 는 이것을 두 단계로 나눴습니다. 등록할 때 매니페스트를 읽어 전체 지도를 만들고 Root 와 그 실선만 올립니다. 나머지는 Loadable 을 Load() 할 때 그 지도를 보고 필요한 파일만 엽니다.
등록은 지도를 만들고 Root 와 실선만 올린다
ContentLoadManager.RegisterContentDirectory(경로) 는 폴더의 매니페스트를 읽습니다. 매니페스트에는 파일마다 “같이 열어야 하는 파일” 목록과 실제 파일 이름이 있어서, 이것 하나로 어떤 콘텐츠 파일도 열지 않고 전체 로딩 계획을 세울 수 있습니다.
그다음 매니페스트의 RootAssets 에 적힌 Root 와, Root 가 일반 필드로 가리키는 실선 아래만 올립니다. Root 는 타입으로 꺼냅니다.
ContentDirectoryHandle handle = ContentLoadManager.RegisterContentDirectory(path);
var root = ContentLoadManager.GetRootAssets<ContentPackRootAsset>()[0];
Debug.Log(root.metadata.description); // 실선이라 이미 올라와 있다
Debug.Log(root.bonusPrefab.Status); // None. Loadable 은 아직이다
이 프로젝트에서 등록 직후 Root 는 1개, 메타데이터는 읽히고, Cube 의 Status 는 None 이었습니다. 에디터와 Player 가 같았습니다.
경로가 없으면 FileNotFoundException 을 던집니다. 에디터에서는 빌드 출력 폴더를, Player 에서는 Application.streamingAssetsPath 아래를 등록해야 해서 경로를 나눠 둡니다.
Load 는 지도를 보고 실선 끝부터 연다
Loadable<T>.Load() 는 자기 안의 ID 로 매니페스트의 Loadables 표를 찾아 어느 파일의 어느 오브젝트인지 알아냅니다. 그 파일을 열기 전에, 그 파일이 같이 열어야 하는 파일을 끝까지 따라가 안쪽부터 엽니다. Cube 하나를 올릴 때 이 프로젝트의 매니페스트는 이렇게 이어져 있었습니다.
에디터에서 Load() 직후 셰이더 수를 세면 3개가 늘었습니다. 파일 3, 16, 2 의 Lit, FallbackError, FallbackShader 입니다. Cube 가 쓰지 않는 씬 머티리얼은 하나도 안 올라왔습니다.
Load() 는 그 자리에서 끝나는 동기 호출이고, 같은 일을 프레임을 넘겨 하는 LoadAsync() 도 있습니다. 실패하면 null 을 돌려주고 Status 가 Failed 가 됩니다.
어느 길로 올려도 같은 에셋은 하나다
같은 에셋이 씬에도 쓰이고 Loadable 로도 잡혀 있으면 두 벌이 올라올까요. InDoor 씬이 쓰는 Gold.mat 을 Root 에 Loadable<Material> 로 하나 더 걸어 두고 두 순서로 올려 봤습니다.
| 순서 | 단계 | 콘텐츠 머티리얼 | 결과 |
|---|---|---|---|
| 씬 먼저 | 씬 로드 | 8 | 씬의 Gold 가 올라옴 |
Gold 핸들 Load() |
8 (늘지 않음) | 씬의 것과 같은 객체 | |
| 씬 언로드 | 3 | Gold 만 남음. 핸들이 잡고 있다 | |
| 핸들 먼저 | Gold 핸들 Load() |
3 | |
| 씬 로드 | 8 | 핸들의 것과 같은 객체 | |
Gold Release() |
8 | 씬이 쓰고 있어 남음 |
어느 쪽을 먼저 올려도 객체는 하나이고, 씬과 핸들이 각자 그 객체를 쓰는 쪽으로 셉니다. 둘 다 놓아야 내려갑니다. 같은 씬을 Additive 로 여러 번 올렸을 때도 씬 오브젝트만 늘고 머티리얼은 늘지 않았습니다. 같은 빌드 안의 에셋은 파일이 하나라서, 열린 파일을 다시 쓰는 것입니다.
씬은 SceneManager 로 올리고 Scene 으로 내린다
씬은 Loadable 이 아니라 LoadableSceneId 로 가리키고, 올리는 것은 기존 SceneManager 입니다.
// 같은 씬을 두 번 올리지 않게 먼저 확인한다
if (!SceneManager.GetSceneByLoadableSceneId(root.bonusLevel).isLoaded)
SceneManager.LoadSceneAsync(root.bonusLevel, new LoadSceneParameters(LoadSceneMode.Additive));
// 내릴 때는 LoadableSceneId 가 아니라 Scene 으로 내린다
Scene scene = SceneManager.GetSceneByLoadableSceneId(root.bonusLevel);
SceneManager.UnloadSceneAsync(scene);
Loadable 과 달리 씬에는 Status 가 없고, UnloadSceneAsync 에는 LoadableSceneId 를 받는 오버로드가 없습니다. 같은 ID 로 씬을 여러 개 올릴 수 있어서 어느 것을 내릴지 Scene 으로 정하게 한 것으로 보입니다. 중복 로드를 막는 일도 쓰는 쪽 몫입니다. 위 코드의 isLoaded 는 비동기 로드가 끝나기 전에는 false 라서, 빠르게 두 번 부르면 둘 다 통과합니다.
직접 확인하는 테스트 코드
등록 직후와 Load() 직후의 상태, 씬과 핸들의 객체 공유를 한 번에 찍습니다. goldMaterial 은 씬이 쓰는 머티리얼을 Root 에 Loadable<Material> 로 걸어 둔 필드입니다.
IEnumerator Start()
{
ContentDirectoryHandle handle = ContentLoadManager.RegisterContentDirectory(ContentPath());
var root = ContentLoadManager.GetRootAssets<ContentPackRootAsset>()[0];
Debug.Log($"등록 직후 Cube: {root.bonusPrefab.Status}, 머티리얼 {CountMaterials()}");
root.bonusPrefab.Load();
Debug.Log($"Load 직후 Cube: {root.bonusPrefab.Status}, 머티리얼 {CountMaterials()}");
var op = SceneManager.LoadSceneAsync(root.bonusLevel, new LoadSceneParameters(LoadSceneMode.Additive));
while (!op.isDone) yield return null;
Scene scene = SceneManager.GetSceneByLoadableSceneId(root.bonusLevel);
Material fromHandle = root.goldMaterial.Load();
Material fromScene = FindMaterial(scene, fromHandle.name);
Debug.Log($"같은 객체? {ReferenceEquals(fromHandle, fromScene)}");
root.goldMaterial.Release();
root.bonusPrefab.Release();
yield return SceneManager.UnloadSceneAsync(scene);
ContentLoadManager.UnregisterContentDirectory(handle);
}
static string ContentPath()
{
#if UNITY_EDITOR
return "Builds/ContentDirectories/ContentPack";
#else
return Application.streamingAssetsPath + "/ContentPack";
#endif
}
static Material FindMaterial(Scene scene, string name)
{
foreach (GameObject go in scene.GetRootGameObjects())
foreach (Renderer r in go.GetComponentsInChildren<Renderer>(true))
foreach (Material m in r.sharedMaterials)
if (m != null && m.name == name) return m;
return null;
}
// 에디터 내장이나 URP 내부 머티리얼을 빼고 센다
static int CountMaterials()
{
int n = 0;
foreach (Material m in Resources.FindObjectsOfTypeAll<Material>())
if (m.hideFlags == HideFlags.None && !m.name.StartsWith("Hidden/")) n++;
return n;
}
Start() 는 첫 렌더링 전이라 URP 가 내부 머티리얼을 만들기 전입니다. 기준값을 정확히 보려면 첫 프레임이 지난 뒤에 찍습니다.
주의할 점
1. 등록 경로를 에디터와 Player 에서 나눈다
안 지키면 생기는 문제. 에디터용 빌드 출력 경로를 그대로 쓰면 Player 에는 그 폴더가 없어서 등록이 FileNotFoundException 으로 끝납니다.
이렇게 쓴다. 에디터는 빌드 출력 폴더, Player 는 Application.streamingAssetsPath 아래로 나누는 도우미 하나를 두고 등록은 그것으로만 합니다.
2. 같은 씬을 두 번 올리지 않게 직접 막는다
안 지키면 생기는 문제. 씬에는 Status 도 참조 수도 없습니다. 버튼을 빠르게 두 번 누르면 isLoaded 가 둘 다 false 라 씬이 두 개 올라옵니다.
이렇게 쓴다. LoadSceneAsync 가 돌려준 작업을 필드에 들고, 그것이 끝나기 전에는 다시 부르지 않습니다.
3. 등록을 풀기 전에 올린 것을 먼저 놓는다
안 지키면 생기는 문제. 매뉴얼에 따르면 열린 파일이 남아 있는 디렉터리를 UnregisterContentDirectory 하면 에러 로그가 찍힙니다.
이렇게 쓴다. Loadable 을 전부 Release 하고 콘텐츠 씬을 내린 뒤 등록을 풉니다. 여러 디렉터리면 등록의 반대 순서로 풉니다.
정리
Content Directories 는 등록 때 지도를 만들고 Root 와 실선만 올린 뒤, 나머지는 Load() 가 그 지도로 필요한 파일만 엽니다.
- 등록은 매니페스트 하나로 로딩 계획을 세우고 Root 와 일반 필드만 올립니다.
Loadable은None으로 기다립니다. Load()는 같이 열어야 하는 파일을 안쪽부터 엽니다. Cube 하나에 셰이더 셋이 따라오고 씬 머티리얼은 안 올라옵니다.- 씬과 핸들로 각각 올린 같은 에셋은 하나의 객체이고, 둘 다 놓아야 내려갑니다. 씬의 중복 로드는 쓰는 쪽이 막습니다.