[테크홀릭] 유니티3D(Unity3D)로 게임을 개발하다가 개발사가 완성 단계에서 가장 어려워하면서도 꼭 한번은 거쳐야 하는 과정이 바로 ‘리소스 및 메모리 최적화’다. 모바일기기 특성상 리소스가 넉넉하지 않은 탓에 단 몇 메가라도 메모리를 아끼려는 노력이 필요할 수밖에 없다.
메모리와 리소스 최적화를 해야 하는 이유는 이렇다. 첫째. 용량이 50M를 넘게 되면 다운로드 받는 유저 수가 절반으로 줄어든다는 것. 통계적으로 50MB가 넘는 게임을 내려받는 유저 수가 절반 이상 꺾인다고 한다. 개발사와 퍼블리셔 모두 게임 클라이언트 용량을 가능하면 적게 낮추려고 하는 이유도 여기에 있다.
둘째 메모리를 많이 쓰면 게임 유저 수에 제약이 생긴다는 점. 메모리를 많이 쓰게 되면 저사양 스마트폰의 경우에는 게임상 이미지 자체를 불러올 수 없게 된다. 그 탓에 화면이 깨지거나 아예 작동을 멈추게 된다. 고객의 엄청난 항의와 불만을 몰고 오게 되는 원인 제공자가 되는 것.
마지막은 게임 콘텐츠 자체의 특성상 이미지와 사운드 리소스 양이 방대하다는 것이다. 보통 앱이나 서비스보다 게임은 이미지 리소스를 상당량 보유하고 있다. 따라서 리소스가 최적화되지 않았을 때 발생하는 오버헤드는 일반 앱과는 비교가 안 된다. 그렇다면 이제 구체적으로 리소스 최적화 가이드라인을 알아본다.
◇ 이미지 가로세로 사이즈는 무조건 2의 제곱=게임에서 사용하는 이미지 가로세로 사이즈는 무조건 2의 제곱으로 되어야 한다(Power Of Two). 컴퓨터에서 이미지를 사용할 때에는 개념적으로 “[1번] 디스크에서 이미지 불러오기→[2번] 이미지 압축 포맷 압축 해제→[3번] 1024×1024×32비트 메모리 블록에 해당 이미지 할당” 과정을 거친다.
1024×1024×32비트 RGBA 기준으로 이미지를 압축한 png 용량은 313KB에 불과하다. 하지만 압축을 해제하면 메모리상 이미지 사이즈는 4MB나 된다. 2048×2048이라면 16MB에 이른다.
이렇게 가로세로 사이즈가 2의 제곱으로 된 이미지가 아닌 경우에는 상당한 메모리 낭비가 일어나게 된다. 예를 들어 900×900 사이즈 이미지가 있다고 하자. 메모리상에서 900×900 사이즈 이미지를 도르할 때 해당 이미지를 똑같이 1024×1024로 변환해 다시 메모리에 저장하게 된다. 다시 말해 거의 배에 가까운 이미지 메모리가 낭비되는 것이다.
이런 이유 때문에 유니티3D를 비롯한 여러 게임 개발 엔진에서 아틀라스(Atlas)라는 리소스 단위를 사용하게 된다. 이미지를 POT(2의 제곱) 방식으로 바꿔서 항상 활용하기를 권한다.
◇ 메모리 사용량 프로파일링 직접 해보면…=이번에는 게임 리소스를 최적화할 때 직접 간단한 프로파일러를 돌려보겠다. 현재 씬(Scene)에 있는 모든 이미지 리소스를 모두 조사해 개별 객체가 얼마나 메모리를 차지하고 있는지 체크해 큰 순서대로 나열한다.
var sortedAll = FindObjectsOfTypeIncludingAssets(typeof(Texture2D)).OrderBy(go=>
Profiler.GetRuntimeMemorySize(go)).ToList();
이 명령으로 현재 모든 텍스처2D(Texture2D) 오브젝트를 구할 수 있다. 또 메모리 크기 순서대로 정렬한 상태로 리스트가 나오게 된다. 그렇다면 모든 텍스처2D 리스트를 출력해보도록 하겠다.
var sortedAll = FindObjectsOfTypeIncludingAssets(type).OrderBy(go=>
Profiler.GetRuntimeMemorySize(go)).ToList();
StringBuilder sb = new StringBuilder(“”);
int memTexture = 0;
for(int i = sortedAll.Count-1;i>=0;i–){
if(!sortedAll[i].name.StartsWith(“d_”)){
memTexture += Profiler.GetRuntimeMemorySize(sortedAll[i]);
sb.Append(type.ToString());
sb.Append(” Size#”);
sb.Append(sortedAll.Count - i);
sb.Append(” : “);
sb.Append(sortedAll[i].name);
sb.Append(” / Instance ID : “);
sb.Append(sortedAll[i].GetInstanceID());
sb.Append(” / Mem : “);
sb.Append(Profiler.GetRuntimeMemorySize(sortedAll[i]).ToString());
sb.Append(“B / Total : “);
sb.Append(memTexture/1024);
sb.Append(“KB “);
sb.Append(“\n”);
}
}
Debug.Log(“Texture2D Inspect: “+sb.ToString());
※ 여기에서 string의 + 연산 대신 StringBuilder를 사용한 이유는 연산 성능이 더 효율적이기 때문이다.
이렇게 간단한 C# 코드로 된 프로파일러를 돌려보면 현재 씬에서 어떤 텍스처2D가 괴물같이 많은 메모리를 먹고 있는지 눈으로 확인할 수 있다. 안드로이드(Android) 빌드라면 안드로이드 DDMS의 로그캣(LogCat)으로 로그를 확인할 수 있다. 여러 테스트 기기에서 해당 로그를 직접 확인해보고 싶다면 서버에 로그를 남기는 방식도 가능하다. 필자는 QA 테스트를 할 때 해당 로그를 서버에 직접 보내보면서 테스트를 해봤다. 성능 최적화에 상당한 도움이 된다.
◇ 텍스처 하나하나 최적화로 마무리=마지막은 가장 많은 메모리를 먹고 있는 텍스처부터 작은 것까지 하나하나 최적화를 해나간다. 먼저 쓸데없이 큰 공간을 차지하는 아틀라스의 경우는 몇 개를 묶어 하나로 만들어 남는 여백을 줄인다. 다음으로 필요 없는 이미지는 삭제한다.
눈에 크게 보이지 않는 이미지가 너무 고해상도인 경우 화질을 낮춰서 다시 임포트(Import)하는 것도 잊지 말아야 한다. png의 경우 포토샵 플러그인을 통해서 이미지를 압축해 다시 임포트한다.
오디오 파일은 모바일에서 스테레오 모드가 의미가 없으니 모두 92kb, 모노로 인코딩하도록 설정한다. 이 설정은 유니티 엔진에서 가능하다. 또 이미지가 여러 번 로딩되지 않게 Singleton 패턴을 활용한다.
물론 위와 같은 리소스와 메모리 최적화 방법은 단편적인 솔루션이다. 하지만 게임에서 가장 많은 용량과 메모리를 차지하는 이미지 리소스 최적화에선 아주 기본적인 단계이기도 하다.
전자신문인터넷 테크홀릭팀
송호연 칼럼니스트 techholic@etnews.com