Content Directories 는 등록할 때 실선까지만 올린다
Esc

    Content Directories 는 등록할 때 실선까지만 올린다

    –Unity메모리

    콘텐츠를 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 하나에 셰이더 셋이 따라오고 씬 머티리얼은 안 올라옵니다.
    • 씬과 핸들로 각각 올린 같은 에셋은 하나의 객체이고, 둘 다 놓아야 내려갑니다. 씬의 중복 로드는 쓰는 쪽이 막습니다.