유니티 메모리 관리와 Drawcall

2017. 1. 12. 11:21기술/유니티 스터디

반응형

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading;
 
namespace CShapMemoryCheck
{
    class DataClass
    {
        private int[] m_data = new int[1000000];
        public void Set(int index, int value)
        {
            m_data[index] = value;
        }
 
        public void Run()
        {
            int[] data = new int[1000000];
            data[0= 1;
            long mem = GC.GetTotalMemory(false);
            Console.WriteLine("Data.Run 함수 안 메모리 : {0}", mem);
 
          //  data = null;
            mem = GC.GetTotalMemory(false);
            Console.WriteLine("Data.Run 함수 안 null, GC 이후 메모리 : {0}", mem);
 
            //    Console.WriteLine("data[0] = ", data[0]);
        }
    }
 
    class Program
    {
        static void Main(string[] args)
        {
            long mem = GC.GetTotalMemory(false);
            Console.WriteLine("초기 메모리 : {0}", mem);
 
            Run();
            
            mem = GC.GetTotalMemory(false);
            Console.WriteLine("Run() 이후 메모리 : {0}", mem);
 
            // 메모리 Clean Up
            GC.Collect(0, GCCollectionMode.Forced);
            GC.WaitForFullGCComplete();
            //Thread.Sleep(5000);
            mem = GC.GetTotalMemory(false);
            Console.WriteLine("GC.Collect 이후 메모리 : {0}", mem);
            
            Console.ReadLine();
        }
 
        static void Run()
        {
            var obj = new DataClass();
            obj.Set(110);
            long mem = GC.GetTotalMemory(false);
            Console.WriteLine("Run 함수 안 메모리 : {0}", mem);
            //obj = null;
            Console.WriteLine("Run 함수 안 객체 null 이후 메모리 : {0}", mem);
            GC.Collect(0, GCCollectionMode.Forced);
            //Thread.Sleep(5000);
            Console.WriteLine("Run 함수 안 객체 null, GC.Collect 이후 메모리 : {0}", mem);
 
            for (int i = 0; i < 100++i)
                obj.Run();
 
            obj = null;
            //   obj.Run();
            GC.Collect(0, GCCollectionMode.Forced);
        }
    }
}
cs

일단 예제 코드는 위와 같습니다.


C#의 장점이 단점이 될 수는 없다.

C#의 가장 큰 특징중 하나는 가비지 컬렉터가 자동으로 메모리를 관리해 준다는 것입니다.

장점으로 만들어 놓은 것인데, "왜 수동으로 메모리 해제를 못하는거야!"라며 괴로워 하면 안될 것 같습니다.

이 장점은 프로그래머의 장점이지 게임을 하는 유저들에게 장점이 될 수는 없을 것 같습니다. 왜냐하면 가비지 컬렉터 때문에 프레임 렉이 발생할 수 있기 때문입니다.


C#의 가비지 컬렉터를 믿어도 될까요?


위 코드에서 DataClass가 있고 이 클래스 안 Run() 함수에서 또 힙 영역에 메모리 할당을 해주고 있습니다.

DataClass 안의 Run()함수를 한 번만 실행시켜 주었을 경우 메모리 해제가 되지 않습니다.

하지만 DataClass의 Run()함수를 100번 루프로 돌렸을 경우 가비지 컬렉터가 작동하는 것을 볼 수 있습니다.



위 코드에서 DataClass 안 Run() 함수에서 메모리 할당을 하지 않고 obj = null을 해주면 가비지 컬렉터에서 바로 해제 합니다. 이렇게 계속 메모리에 대해 신경 쓰게 되다 보면 오히려 C#의 장점이 단점이 되어 버릴 것입니다. 하지만 명시적으로 obj = null 코드 작성을 습관화 하는것이 좋겠습니다. 그나마 가비지 컬렉터의 일을 덜어주는 셈이 될 테니까요. obj = null을 명시적으로 작성해 주면 다른 프로그래머가 그 객체에 접근하려 할 때, null pointer exception이 날 것입니다. 해제하면 안돼는 객체들은 private로 선언하고 GetObject()의 함수를 만들어서 다른 프로그래머가 접근할 수 있도록 해야겠습니다.


아마 위와같이 = null로 지정하여 메모리 해제를 해주려고 해도 메모리 해제가 되지 않다가 한꺼번에 메모리 해제를 하며 가비지 컬렉터에서 렉을 발생하나 봅니다. 유니티로 제작된 게임 전투를 플레이 하다보면 굉장히 최적화 잘 되어 있고 잘 만들어진 게임이라도 프레임 끊김 현상 꼭 있습니다. 이는 유니티 엔진과 프로그래머의 잘못이라기 보다 C# 언어의 특징이라고 생각합니다. 유니티 엔진 내부는 C++로 만들어져 있으니 말입니다. 모바일 그래픽스는 유니티와 언리얼 엔진 모두 OpenGL ES이겠지요. ( 최근 나무위키를 살펴보니 엔진 자체 최적화도 중요한 요소라고 할 수 있겠네요. )


어떤 프로그램 루프가 있을 때, 지속적으로 어떤 함수에서는 메모리 할당을 하고 특정 순간에 가비지 컬렉터가 참조되지 않은 변수나 객체를 메모리 해제 합니다. 이런 작업이 반복됩니다. 가비지 컬렉터가 수행 되었을 때, 유니티 프로파일러를 켜보면 CPU 점유율이 순식간에 쭉 오르는 것을 볼 수 있습니다.



가비지 컬렉터를 언제 호출해야 하는가?

굳이 가비지 컬렉터를 호출해야 한다면 UI를 관리하는 UIMediator를 하나 만들고 UI의 X 버튼을 누를 때, UIMediator에서 프리팹 객체를 SetActive(false) 해 준다음 가비지 컬렉터를 한번 실행해 주는 것이 좋을 것이라고 생각합니다.

Update() 함수내에서 몇 초마다 가비지 컬렉터를 실행시켜 주는 방법은 유니티 렌더링에 렉이 발생하는 것으로 확인되었습니다. 실제로 프로파일러를 켜고 테스트 해 보시면 됩니다.

약간의 렉이 발생하여도 상관없을 때, 가비지 컬렉터를 실행해주면 될 것 같습니다.


유니티 2019.2 버전에서 점진적 가비지 컬렉션 메모리 관리 방식으로 엔진이 개선되고 있다고 합니다.


그렇다면 총 사용 메모리는?

한꺼번에 화면에 표시해 주어야 하는 텍스처라든가 데이터가 많으면 그만큼 많은 메모리가 필요합니다.


CharacterInfo 라는 클래스가 있고 이 클래스가 500명의 캐릭터의 정보를 가지고 있다고 합시다. CharacterInfo 클래스는 4byte 변수를 100개 사용한다고 하면 400byte가 됩니다. 이 클래스를 500개 사용하면 200,000byte = 195KB, 0.19MB가 됩니다. 게임 구동 사양 메모리를 최소 1GB로 만들려고 한다면 그만큼 신경써 주어야 하는 부분들이 많을 것입니다. 


안좋은 극단적인 예로 서로 다른 머터리얼을 사용하는 몬스터를 한 화면에 200개 이상 출력해줄 경우 렌더링 렉이 발생할 것입니다. 성능에 굉장히 않좋아 질 것입니다. 물론 맵상의 머터리얼들을 모두 합쳐서 말입니다.


무엇을 말 하고자 하냐면 변수에 할당되는 메모리와 그래픽스 메모리등을 잘 생각해봐야 한다는 것입니다.

데이터 통신량을 최적화 하려면 byte, ushort를 사용해도 됩니다.


에셋 번들을 사용할 때?

에셋 번들 관리를 전담 한다면 그때 알아보도록 하겠습니다.


보통 Asset을 다운로드하여 이용하게 되는데, 아래 텍스쳐 리소스를 수동으로 관리해 주는 함수가 있을 것입니다.

C# 특성상 객체의 모든 참조가 0이 되어야 객체가 파괴되며 메모리가 해제 되기 때문에 사용하는 텍스쳐에 참조가 되어 있지 않아야 합니다.


리소스 로딩을 수동으로 관리하기


약간의 노동이 필요하지만 수동으로 Asset을 현재 Scene에 Load할 방법도 당연히 존재한다. Resources 라는 폴더를 만들어서 Asset들을 그 안에 위치시키면 해당 Asset들은 Resources.Load(resourcePath)함수를 통해 수동으로 Load할 수 있게 된다.  이 Asset들은 텍스쳐, 오디오파일, 프리팹, 매터리얼등이다. 

해당 자원에 대한 이용이 끝났다면 그 자원에 할당된 메모리를 Resources.UnloadUnusedAssets() 함수를 호출하여 강제로 해제할 수 있는데, 유니티가 해당 자원을 해제하기 위해서는 스크립트에서 해당 자원에 대한 참조가 모두 없어져야 한다. (Destroy혹은 null을 할당해서) 그리고 이 함수는 Resources.Load() 함수를 통해서 할당된 자원만 해제를 하고 유니티가 Scene을 Load하면서 자동으로 같이 Load한 자원은 해당되지 않는다는 점에 주의할 필요가 있다.


Draw Call이란 CPU가 OpenGL이나 DirectX와 같은 그래픽스 API에 그리기 요청을 하는것을 말합니다.

Draw Call이 증가하는 경우는 다른 머터리얼을 사용할 때 입니다.

당연히 파티클을 사용하거나 UITexture를 사용해도 하나씩 올라갑니다.

패널을 하나 늘려주면 패널의 Draw Call만큼 늘어납니다. 한 패널에 5라면 패널 2개에 10

카메라에 들어오지 않는 객체 또는 파티클은 컬링되므로 성능에 도움을 줍니다.


객체수와 성능

GameObject 파괴시 Texture Memory는 당연히 변함이 없습니다.  Object Count만 감소한 것을 알 수 있습니다.

4개의 패널들을 카메라에서 보이지 않게 x위치를 10000으로 설정하면 Rendering 성능만 좋아집니다.

패널을 SetActive(false) 하거나 Destroy()하면 CPU Useage가 좋아집니다.


보시면 수직동기화 기능 VSync라고 CPU를 사용하는것이 보입니다. 이 기능을 꺼 주면 속도가 훨씬 빨라집니다.

Edit > Project Setting > Quality


Texture Memory

유니티에서 메모리는 3가지 코드 영역, Managed Heap 영역, Native Heap 영역이 있다고 합니다. 코드 영역은 그다지 신경쓸 필요가 없고 Managed Heap 영역이 코드상에서 할당한 메모리, Native Heap 영역이 텍스쳐나 사운드를 올려놓기 위해 OS에서 할당한 메모리 영역이라고 합니다. 참조


2019-10-07

Resources.Load로 로딩한 이미지가 씬을 넘겨도 TextureMemory에서 해제 되지 않는다. 해결 방법


테스트 : 

bg_0, bg_1은 bg_atlas로 만듭니다.

나머지 bg_2 ~ bg_8까지는 개별 스프라이트로 사용합니다.


UnLoadTexture, LoadTexture, New Scene, Destroy GameObject, Create GameObject 버튼을 만듭니다.


Profiler를 열어서 메모리를 테스트합니다.


Draw Call과 Unity Atlas


테스트 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
 
public class ChangeImageSprite : MonoBehaviour
{
    public List<Image> ListImage = new List<Image>();
 
    void Start()
    {
        OnClickLoadTextures();
    }
 
    public void OnClickUnLoadTextures()
    {
        for (int i = 0; i < ListImage.Count; ++i)
            ListImage[i].sprite = null;
 
        Resources.UnloadUnusedAssets();
    }
 
    public void OnClickDestroyPrefabResTest()
    {
        GameObject prefabResTest = transform.Find("PrefabResTest").gameObject;
        if (prefabResTest != null)
            Destroy(prefabResTest);
    }
 
    public void OnCLickCreatePrefabResTest()
    {
        GameObject goTestPrefab = Instantiate(Resources.Load<GameObject>("Background/PrefabResTest"), transform);
    }
 
    public void OnClickLoadTextures()
    {
        // 1 Draw Call Atlas
        ListImage[0].sprite = Resources.Load<Sprite>("Background/bg_0");
        ListImage[1].sprite = Resources.Load<Sprite>("Background/bg_1");
        ListImage[2].sprite = Resources.Load<Sprite>("Background/bg_1");
        ListImage[3].sprite = Resources.Load<Sprite>("Background/bg_0");
 
        ListImage[4].sprite = Resources.Load<Sprite>("Background/bg_2");
        ListImage[5].sprite = Resources.Load<Sprite>("Background/bg_3");
        ListImage[6].sprite = Resources.Load<Sprite>("Background/bg_4");
        ListImage[7].sprite = Resources.Load<Sprite>("Background/bg_5");
        ListImage[8].sprite = Resources.Load<Sprite>("Background/bg_6");
        ListImage[9].sprite = Resources.Load<Sprite>("Background/bg_7");
        ListImage[10].sprite = Resources.Load<Sprite>("Background/bg_8");
    }
 
    public void OnClickNewScene()
    {
        UnityEngine.SceneManagement.SceneManager.LoadScene("New Scene");
    }
 
}
 
cs


결론

Resources.Load로 로딩한 것들은 게임오브젝트를 파괴해도 씬을 넘겨도 메모리에서 해제되지 않습니다.


ListImage[i].sprite = null; 로 해당 스프라이트를 널로 확실히 지정 해 주어야 메모리가 오르락 내리락 하는 것을 볼 수 있습니다.

이렇게 하면 씬을 넘기지 않고도 텍스쳐 메모리를 관리 할 수 있습니다.


메모리를 해제하는 시점은 OnDestroy()가 호출 되기 전입니다. (OnDestroy()함수 안에서 OnClickUnLoadTextures를 호출 하면 메모리 해제가 되지 않습니다.) 

즉, 어떤 UI를 닫기 전에 파괴 하기 전에 위 함수를 호출해서 메모리 해제를 한 후 Destroy 함수를 호출해서 파괴 시켜야합니다.


이는 캐릭터 게임오브젝트 등을 Resources.Load로 로딩하여 사용할 경우 메모리 해제 할 때는 스프라이트(.sprite)를 null로 세밀하게 지정해 주어서 메모리 해제가 확실하게 되는지 씬에서 테스트 해 본 후 사용해야 합니다.


장점은 씬을 넘기면서 텍스쳐 메모리를 관리하는 것 보다 프로젝트 구조가 훨씬 좋아집니다.


실무에서

예를 들어 PanelShop이라는 프리팹이 있다고 합시다. 보통 실무에서 한 UI패널을 하나의 프리팹으로 만들고

거기에는 많은 배경이미지들이 결합되어 있을 수 있습니다.

이 로드한 PanelShop을 어떤 스크립트에서 GameObject m_panelShop = Resources.Load("..");로 들고 있다면

Resources.UnloadUnusedAssets()을 호출시에 로드한 프리팹의 모든 메모리가 해제되지 않습니다.

그러므로 텍스쳐 메모리를 해제할 때 m_panelShop = null;로 로드한 프리팹의 포인터 링크를 끊어야 메모리가 제대로 해제됩니다.


해제되는 메모리가 클 수록 약간의 딜레이가 생기므로 로딩바라든가 하는 처리를 해주어야 합니다.


- 2019-10월 최신 이슈 -

최근에 유니티 엔진에 에셋번들 관리 기능인 Addressable 기능이 추가되었습니다. (참조1, 참조2해석해서)

위에서도 Addressable로 로드한 에셋이 모두 Release 되지 않는다면 씬을 넘길 때에도 메모리가 해제 되지 않는다고 합니다.

Addressable은 에셋에 주소를 붙여서 읽어 들인다는 방식입니다.


Addressable로 생성한(InstantiateAsync) 리소스들 (현재 Prefab GameObject) 참조 갯수(Reference Count)의 증감(12개)을 확인할 수 있습니다.


코드 수정 부분


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    public void OnClickDestroyPrefabResTest()
    {
        Transform prefabResTest = transform.Find("PrefabResTest(Clone)");
        
        if (prefabResTest != null)
        {
            Addressables.ReleaseInstance(prefabResTest.gameObject);
            Destroy(prefabResTest.gameObject);
        }
    }
 
    public void OnCLickCreatePrefabResTest()
    {
        //GameObject goTestPrefab = Instantiate(Resources.Load<GameObject>("Background/PrefabResTest"), transform);
 
        // Resources.Load나 AssetBundleManager 대신 Addressable을 사용합니다.
        Addressables.InstantiateAsync("Assets/Resources_moved/Background/PrefabResTest.prefab", transform); // .Completed 하고 함수 지정 가능
    }
cs


Addressable을 사용해서 이미지를 로드 해 보아도 참조카운트가 0임에도 불구하고 Profiler에서 Texture Memory가 증감 되는 것을 확인할 수 없고 별로 편리한 점도 찾을 수 없습니다. AsyncComplete 함수를 따로 다 연결해 주어야 하므로 따로 관리자를 만들어야 하고 코드가 복잡해지게 됩니다.


Addressable을 사용하면 참조카운트를 또 신경써야 합니다. 그냥 Resources.Load 이용해서 Texture Momory의 증감을 확인하는게 훨씬 편하다는 결론입니다. 2019-10-08일 기준.


개인적인 생각은 그런데 조금 더 연구해 보아야겠네요.


에셋 관리는 Asset bundle browser를 통해 하는데 이미지나 머터리얼은 따로 폴더를 지정해 주어야 할 것 같습니다. (참조)


다음은 AssetBundle을 이용하여 네이버 블로그에 빌드한 에셋 번들 파일을 올리고 텍스쳐 메모리를 관리하는 방법입니다.

(유니티 매뉴얼 에셋 번들 워크플로)

Resources.Load 보다 AssetBundle.LoadAsset을 사용하는게 프로젝트 장기적으로는 좋다고 판단합니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    public void OnClickUnLoadTextures()
    {
        //for (int i = 0; i < ListImage.Count; ++i)
        //    ListImage[i].sprite = null;
 
        //Resources.UnloadUnusedAssets();
        bgBundle.Unload(true);
    }
    AssetBundle bgBundle = null;
    IEnumerator DownLoadAssetBundle()
    {
        string url = "http://blogattach.naver.net/40d55cecf9a3a47857b5d3e7de3f433a9acb37d6e7/20191008_176_blogfile/hkn10004_1570514191596_aU52lu_/background.";
 
        UnityWebRequest req = UnityWebRequestAssetBundle.GetAssetBundle(url, 0);
 
        yield return req.SendWebRequest();
 
        // Download Complted.
        bgBundle = DownloadHandlerAssetBundle.GetContent(req);
 
        // Load Asset from AssetBundle.
        ListImage[0].sprite = bgBundle.LoadAsset<Sprite>("bg_0");
        ListImage[1].sprite = bgBundle.LoadAsset<Sprite>("bg_1");
        ListImage[2].sprite = bgBundle.LoadAsset<Sprite>("bg_1");
        ListImage[3].sprite = bgBundle.LoadAsset<Sprite>("bg_0");
    }
cs


에셋 번들의 재다운로드 여부는 UnityWebRequestAssetBundle.GetAssetBundle 함수에 대해 알면 됩니다.


다운로드한 캐쉬 파일이 저장되는 경로는 

C:\Users\(username)\AppData\LocalLow\Unity\DefaultCompany_AssetSystem\background1_ver1 입니다.


다운로드한 캐쉬 파일 폴더를 삭제하는 방법은

Caching.ClearAllCachedVersions("background1_ver1"); 입니다.


유니티 에디터에서 프로파일러는 비정확할 수 있습니다. 실제로 안드로이드나 아이폰 기기에서 테스트 해보면 훨씬 정확한 메모리 증감을 확인할 수 있습니다. 특정 UI가 열릴 때 특정 객체가 생성되고 파괴 될 때 프로파일링을 통해 메모리 증감을 확인하여 메모리 관리를 할 수 있습니다.

반응형