AssetBundle 의 Unload 는 어느 쪽이든 사고가 난다
번들을 다 썼으면 AssetBundle.Unload 로 내립니다. 인자는 bool 하나뿐인데, false 를 고르면 메모리가 안 줄거나 오히려 늘고, true 를 고르면 화면에서 물건이 사라집니다.
Unload 의 인자는 번들에서 꺼낸 에셋까지 같이 지울지를 정합니다. false 는 번들과 에셋 사이의 연결만 끊고 에셋은 남겨서 다시 열면 사본이 생기고, true 는 에셋을 쓰는 곳이 있든 없든 지웁니다. 어느 쪽도 “아무도 안 쓰면 내린다” 를 해 주지 않습니다.
Unload(false) 는 연결만 끊어서 다시 열면 사본이 생긴다
Unload(false) 는 번들 파일과 그 헤더는 내리지만, 번들에서 꺼낸 머티리얼이나 텍스처는 메모리에 남겨 둡니다. 쓰고 있는 오브젝트가 깨지지 않으니 안전해 보입니다. 문제는 그 뒤에 같은 번들을 다시 열 때입니다.
| 단계 | 메모리의 Lit 머티리얼 |
native 증가 |
|---|---|---|
| 열고 go1 생성 | 1개 (2683) | +5.54 MiB |
두 번들 Unload(false) |
1개 (2683), go1 정상 | |
| 다시 열고 go2 생성 | 2개 (2683, 2687), 셰이더도 2개 | +7.10 MiB |
go1 파괴 후 UnloadUnusedAssets |
1개 (2687) |
2017.4 매뉴얼은 이 경우를 그대로 적어 두었습니다. Unload(false) 뒤 다시 로드하면 기존 사본에 다시 연결하지 않고 사본이 두 개가 된다는 것입니다. 지금 매뉴얼에는 “중복을 막으려면 Unload(true) 를 써라” 까지만 남아 있습니다.
남은 옛 사본은 번들 쪽에서는 더 이상 내릴 방법이 없습니다. 쓰는 오브젝트를 없앤 뒤 Resources.UnloadUnusedAssets() 를 부르거나, 씬을 Single 로 바꿔서 그 안에서 불리게 해야 정리됩니다.
Unload(true) 는 쓰는 중이어도 지운다
Unload(true) 는 번들에서 꺼낸 에셋까지 지웁니다. 사본이 생길 일이 없으니 매뉴얼이 권하는 쪽입니다. 대신 그 에셋을 쓰는 오브젝트가 아직 있어도 묻지 않고 지웁니다. Cube 인스턴스를 그대로 둔 채 머티리얼이 든 mat 번들을 Unload(true) 했습니다.
머티리얼은 메모리에서 0개가 됐고, 인스턴스의 머티리얼 슬롯은 null 이 됐습니다. 이어서 프리팹이 든 cubea 번들까지 Unload(true) 해도 인스턴스와 메시는 살아 있었습니다. 큐브 메시는 엔진 내장 리소스라 번들에 없기 때문입니다. 사라진 원인은 머티리얼입니다.
그리는 결과는 URP 의 GPU Resident Drawer 설정에 따라 달랐습니다. 꺼져 있으면 마젠타로 그리고, 켜져 있으면 아예 그리지 않았습니다. 내부 이유는 확인하지 못했습니다. 어느 쪽이든 경고와 에러 로그는 하나도 없었습니다. 측정 코드가 Warning 이상의 로그를 전부 받도록 걸어 두었고, Player 로그에도 없었습니다.
UnloadUnusedAssets 는 씬 전체를 훑는다
두 경우 모두 마지막 정리는 Resources.UnloadUnusedAssets() 입니다. 이 함수는 메모리의 에셋을 누가 쓰는지 전부 따라가 보고, 아무도 안 쓰는 것을 내립니다. 그래서 비용이 씬의 오브젝트 수에 따라 늡니다. 비활성 GameObject 를 늘려 가며 호출부터 완료까지 쟀습니다.
| 씬 GameObject | 세 번 잰 시간 |
|---|---|
| 1 | 2.47 / 1.68 / 1.71 ms |
| 1,000 | 1.75 / 1.57 / 1.60 ms |
| 10,000 | 5.20 / 3.92 / 3.73 ms |
| 50,000 | 32.96 / 24.66 / 21.97 ms |
1,000개까지는 차이가 없고 1만 개부터 늘어서, 5만 개에서는 한 프레임 예산(16.7 ms)을 넘었습니다. 게임 도중 아무 때나 부를 함수가 아닙니다.
직접 확인하는 테스트 코드
Unload(false) 뒤 사본이 생기는 것과, Unload(true) 가 쓰는 중인 머티리얼을 지우는 것을 차례로 봅니다. cubea 번들은 mat 번들에 기댑니다.
IEnumerator UnloadFalseThenReload(string root)
{
AssetBundle mat = AssetBundle.LoadFromFile(Path.Combine(root, "mat"));
AssetBundle cube = AssetBundle.LoadFromFile(Path.Combine(root, "cubea"));
GameObject go1 = Instantiate(cube.LoadAsset<GameObject>("Cube"));
yield return null;
mat.Unload(false);
cube.Unload(false);
mat = AssetBundle.LoadFromFile(Path.Combine(root, "mat"));
cube = AssetBundle.LoadFromFile(Path.Combine(root, "cubea"));
GameObject go2 = Instantiate(cube.LoadAsset<GameObject>("Cube"));
yield return null;
var lits = Resources.FindObjectsOfTypeAll<Material>().Where(m => m.name == "Lit").ToArray();
Debug.Log($"Lit {lits.Length}개: {string.Join(", ", lits.Select(m => m.GetInstanceID()))}"); // 2개
}
IEnumerator UnloadTrueWhileInUse(string root)
{
AssetBundle mat = AssetBundle.LoadFromFile(Path.Combine(root, "mat"));
AssetBundle cube = AssetBundle.LoadFromFile(Path.Combine(root, "cubea"));
GameObject go = Instantiate(cube.LoadAsset<GameObject>("Cube"));
yield return null;
mat.Unload(true);
yield return null;
yield return null;
Debug.Log($"살아있음:{go != null}, 머티리얼:{go.GetComponent<Renderer>().sharedMaterial}"); // True, null
}
두 함수는 각자 새로 실행한 Player 에서 돌립니다. 앞 함수가 남긴 사본이 뒤 함수의 결과에 섞이지 않게 하려는 것입니다.
주의할 점
1. Unload(false) 한 번들을 다시 열지 않는다
안 지키면 생기는 문제. 남겨 둔 에셋과 새로 읽은 에셋이 따로 올라와 같은 머티리얼과 셰이더가 두 벌이 됩니다. 옛 것은 번들로는 못 내리고 UnloadUnusedAssets 를 기다려야 합니다.
이렇게 쓴다. Unload(false) 는 그 번들을 다시 열지 않을 때만 씁니다. 다시 열 수 있는 번들은 쓰는 오브젝트를 다 치운 뒤 Unload(true) 로 내립니다.
2. Unload(true) 전에 그 번들의 에셋을 쓰는 오브젝트를 치운다
안 지키면 생기는 문제. 인스턴스가 남아 있으면 머티리얼만 지워져, 화면에서 사라지거나 마젠타가 됩니다. 로그가 없어서 어디서 지웠는지 찾기 어렵습니다.
이렇게 쓴다. 번들마다 그 번들에서 만든 인스턴스를 기록해 두고, 전부 Destroy 한 뒤 Unload(true) 합니다. 다른 번들이 기대는 공용 번들은 그 번들들이 다 내려간 뒤에 내립니다.
3. UnloadUnusedAssets 는 로딩 화면에서 부른다
안 지키면 생기는 문제. 씬에 오브젝트가 많으면 한 번에 수십 ms 가 걸려 게임 중에 프레임이 튑니다.
이렇게 쓴다. 씬 전환이나 로딩 화면처럼 멈춰도 되는 자리에서 부르고, 끝날 때까지 기다린 뒤 다음으로 넘어갑니다.
정리
AssetBundle 의 Unload 는 에셋을 같이 지울지만 정하고, 아무도 안 쓰는지는 확인해 주지 않습니다.
Unload(false)뒤 같은 번들을 다시 열면 옛 객체와 새 객체가 같이 있고, 옛 것은UnloadUnusedAssets로만 내려갑니다.Unload(true)는 쓰는 중이어도 지워서, 인스턴스의 머티리얼이 경고 없이null이 됩니다.- 정리 수단인
UnloadUnusedAssets는 씬 전체를 훑어서, 오브젝트 5만 개에서 한 프레임을 넘겼습니다.