올해 론칭한 '프로젝트 A'는 피시와 모바일을 지원하는 PVP 중심의 액션 대전 게임이다. 2022년 가을부터 모바일 플랫폼 개발을 시작하였고, 나도 그 시기에 서울지사로 이동하여 모바일 개발을 담당하게 되었다.
사전에 듣기론, 프로젝트에 모바일 서드파티 SDK 정도만 달면 된다고 해서 "아 뭐 크게 어려운 건 없겠구나"란 생각을 했다. 그리고 서울지사로 딱 왔는데, 아 이럴 수가 나 빼고 모바일 게임 개발자가 없었다. 1명도

그렇게 언리얼 엔진 프로젝트 모바일 게임 개발을 하게 되었다.
이 글은 내가 언리얼 엔진 모바일 프로젝트의 최적화 작업을 진행하면서 기억에 남거나, 이야기하고픈 것을 담았다. 그냥 후기글이기에 상세한 내용은 적지 아니하겠다.
우리가 모바일 게임을 서비스하며 메모리 부족으로 인한 앱 강제종료는 흔히 겪는 일이다. 특히 iOS 단말기는 같은 시기 출시한 안드로이드폰 대비 적은 램을 사용하고, 최근까지 보급형 아이폰/아이패드에서는 램 3GB를 장착해 출시하고 있다.
또한 iOS는 하나의 앱이 사용할 수 있는 최대 가용 메모리 크기가 정해져 있는데, 가령 아이폰 14 프로(6GB)가 아이폰 X(3GB)보다 물리적인 램이 3GB 더 많다고 해서 단순히 그 차이만큼 더 사용할 수 있는 것은 아니다.
다음 데이터를 보면 아이폰 12 프로는 6GB의 램을 달고 있지만, 실제 가용 메모리는 3GB 남짓이다.
crash amount/total amount/percentage of total
iPhone X: 1392/2785/50% (iOS 11.2.1)
iPhone XS: 2040/3754/54% (iOS 12.1)
iPhone XS Max: 2039/3735/55% (iOS 12.1)
iPhone XR: 1792/2813/63% (iOS 12.1)
iPhone 11: 2068/3844/54% (iOS 13.1.3)
iPhone 11 Pro Max: 2067/3740/55% (iOS 13.2.3)
iPhone 12 Pro: 3054/5703/54% (iOS 16.1)
메모리 최적화 작업을 할려면 먼저 지원하는 최저사양 아이폰을 선정하고 그 아이폰의 가용 메모리를 파악해 앱의 최대 메모리 마지노선을 정해야 한다. 런타임 중 앱의 점유 메모리가 마지노선보다 높게 올라간다면 무언가 문제가 있다고 생각해야한다.
프로젝트 A의 경우, iOS의 최저사양을 아이폰 8+(3GB)로 선정하였고, 마지노선은 1.6GB로 잡았다.(경험상 최대 가용 메모리는 1.75GB 정도이다)
다음은 메모리 사용량을 줄이기 위해 고민한 것과 도움이 되는 도구를 소개해 보겠다.
경험상, 게임의 메모리를 아끼는 최고의 방법은 적절한 텍스쳐 포맷을 사용하는 것이다.
iOS의 표준포맷은 PVRTC, Android는 ETC1/2이며, 모바일 게임 엔진들의 기본 포맷이기도 하다. 하지만 두 플랫폼을 서비스할 시 포맷이 이분화되면 플랫폼 별 텍스쳐 품질 관리가 어려울 수 있기 때문에 하나의 통일된 포맷을 쓰는 것이 좋다.
이에 ASTC 포맷을 추천한다.
ASTC 포맷은 대부분의 플랫폼에서 사용 가능하고, 최신 압축방식이라 기존 포맷과 비교하여 압축률이 뛰어나다. 주의할 것은 iOS는 A8 프로세서(아이폰 6), Android는 OpenGL 3.2(갤럭시 S6) 이상의 환경에서 이 포맷을 지원하나, 요즘 게임들은 최저사양 급이 많이 올라갔기 때문에 동남아시아 시장까지 고려해보아도 사용에 큰 문제는 없다고 본다.
언리얼 엔진에서 제공하는 ASTC 포맷은 다음 압축 타입들을 제공한다.
| block size | bits per pixel |
|------------|----------------|
| 4x4 | 8.00 |
| 6x6 | 3.56 |
| 8x8 | 2.00 |
| 10x10 | 1.28 |
| 12x12 | 0.89 |
언리얼 엔진은 ASTC 포맷을 사용하게 된다면, 기본으로 6x6 블록 사이즈를 사용하게 되는데 이는 DefaultASTCQualityBySize 옵션 설정으로 확인할 수 있다.
[/Script/UnrealEd.CookerSettings]
...
DefaultASTCQualityBySize=3
다음 언리얼 엔진 소스코드를 확인해보면, DefaultASTCQualityBySize 옵션이 3일 때 PF_ASTC_6x6 포맷으로 할당됨을 알 수 있다.
static EPixelFormat GetQualityFormat(int32 OverrideSizeValue=-1)
{
// convert to a string
EPixelFormat Format = PF_Unknown;
switch (OverrideSizeValue >= 0 ? OverrideSizeValue : GetDefaultCompressionBySizeValue())
{
case 0: Format = PF_ASTC_12x12; break;
case 1: Format = PF_ASTC_10x10; break;
case 2: Format = PF_ASTC_8x8; break;
case 3: Format = PF_ASTC_6x6; break;
case 4: Format = PF_ASTC_4x4; break;
default: UE_LOG(LogTemp, Fatal, TEXT("Max quality higher than expected"));
}
return Format;
}
PF_ASTC_4x4 포맷은 사이즈가 크고 PF_ASTC_8x8 옵션은 알파채널이 포함된 텍스쳐에선 열화가 심하기 때문에 프로젝트의 보편적 포맷은 PF_ASTC_6x6이 적당하다. 얼추 2k 텍스쳐에 1.8MB
iOS는 여기서 좀 더 텍스쳐 메모리를 아껴야 하기에 PF_ASTC_8x8 포맷 사용을 고려해볼 수 있는데, 특히 3D 게임일 경우, 월드 관련 텍스쳐에 사용하는 것을 추천한다. 이는 텍스쳐 품질이 떨어져도 화면이 작은 모바일 기기 특성상 열화가 크게 체감되진 않으며, 메모리풀의 대부분을 차지하는 텍스쳐이기에 메모리 저감을 크게 기대해볼 수 있다.
언리얼 엔진은 텍스쳐 그룹별로 특정 텍스쳐 포맷을 지정할 수 있으며, 월드 관련 텍스쳐는 다음과 같다.
enum TextureGroup
{
TEXTUREGROUP_World UMETA(DisplayName="ini:World"),
TEXTUREGROUP_WorldNormalMap UMETA(DisplayName="ini:WorldNormalMap"),
TEXTUREGROUP_WorldSpecular UMETA(DisplayName="ini:WorldSpecular"),
...
다음은 최저사양 단말기에 유용한 환경설정 옵션을 소개하고자 한다.
MaxNumTexturesToStreamPerFrame 옵션은 1프레임마다 메모리에 적재된 텍스쳐를 GPU로 전달하는 최대 장수를 제한하는 옵션이다. 보통 급격히 많은 텍스쳐들을 읽어들일 때, 제한을 걸어 CPU 히칭을 막는 용도로 쓰이는데, 이는 다음 레벨을 로드할 때 텍스쳐의 로드도 지연시켜 언리얼 엔진의 텍스쳐 풀 매니저가 그 사이 여유공간을 확보하는 시간도 벌 수 있었다. 따라서, 메모리 사용량의 급격한 상승을 막을 수 있는 부수적인 효과도 가지고 있다.
언리얼 엔진에선 기본적으로 스칼라빌리티 텍스쳐 퀄리티(배틀그라운드로 치면, 그래픽-텍스쳐품질)를 0(매우 낮음)을 선택하게 되면 자동으로 활성화된다.
[TextureQuality@0]
r.Streaming.AmortizeCPUToGPUCopy=1
r.Streaming.MaxNumTexturesToStreamPerFrame=1
만일 가용 메모리(Memory Bucket)가 적은 단말기에서 이 옵션을(또는 다른 옵션들도) 자동으로 활성화하고 싶다면, 다음 내용이 유용할 것이다.
IOS(Android)Engine.ini 파일은 공용으로 사용하는 DefaultEngine.ini과 달리, 모바일 환경에서 별도로 사용할 옵션을 재정의 할 수 있다.
여기서 우리는 PlatformMemoryBucket이라는 걸 재정의 할 수 있는데, 언리얼 엔진 소스코드를 보면 다음과 같이 타입이 열거되어 있다.
#define PLATFORM_MEMORY_SIZE_BUCKET_LIST(XBUCKET) \
/* not used with texture LODs (you can't use bigger textures than what is cooked out, which is what Default should map to) */ \
XBUCKET(Largest) \
XBUCKET(Larger) \
/* these are used by texture LODs */ \
XBUCKET(Default) \
XBUCKET(Smaller) \
XBUCKET(Smallest) \
XBUCKET(Tiniest) \
여기서 나는 프로젝트에 맞게 이 타입들을 다음과 같은 룰을 만들었는데
이를 위해 IOS(Android)Engine.ini 환경설정 파일에서 메모리 버킷 사이즈 규칙을 다음과 같이 재정의하였다.
[PlatformMemoryBuckets]
LargestMemoryBucket_MinGB=6
LargerMemoryBucket_MinGB=4
DefaultMemoryBucket_MinGB=3
SmallerMemoryBucket_MinGB=2
SmallestMemoryBucket_MinGB=1
이렇게 설정해두면 언리얼 엔진은 내부적으로 피지컬 메모리 크기와 정의된 메모리 버킷 사이즈와 비교하여 현재 메모리 버킷 타입 상태를 결정한다.
uint32 TotalPhysicalGB = (uint32)((Stats.TotalPhysical + 1024 * 1024 * 1024 - 1) / 1024 / 1024 / 1024);
uint32 AddressLimitGB = (uint32)((Stats.AddressLimit + 1024 * 1024 * 1024 - 1) / 1024 / 1024 / 1024);
int32 CurMemoryGB = (int32)FMath::Min(TotalPhysicalGB, AddressLimitGB);
// if at least Smaller is specified, we can set the Bucket
if (SmallerMemoryGB > 0)
{
if (CurMemoryGB >= SmallerMemoryGB)
{
Bucket = EPlatformMemorySizeBucket::Smaller;
}
else if (CurMemoryGB >= SmallestMemoryGB)
{
Bucket = EPlatformMemorySizeBucket::Smallest;
}
else
{
Bucket = EPlatformMemorySizeBucket::Tiniest;
}
}
if (DefaultMemoryGB > 0 && CurMemoryGB >= DefaultMemoryGB)
{
Bucket = EPlatformMemorySizeBucket::Default;
}
if (LargerMemoryGB > 0 && CurMemoryGB >= LargerMemoryGB)
{
Bucket = EPlatformMemorySizeBucket::Larger;
}
if (LargestMemoryGB > 0 && CurMemoryGB >= LargestMemoryGB)
{
Bucket = EPlatformMemorySizeBucket::Largest;
}
최종적으로 환경설정 파일에서 사용가능한 여러 옵션들을 메모리 버킷 사이즈를 활용해 다음 규칙으로 옵션들을 설정할 수 있다.
CVars_[Smallest/Smaller/Default/..]=옵션이름=옵션값
예를 들어, MaxNumTexturesToStreamPerFrame 옵션을 아이폰 최저사양(램 3GB이하) 단말기에서 사용하고자 한다면 DeviceDeviceProfile.ini 환경설정 파일에서 다음과 같이 정의한다.
[IOS DeviceProfile]
...
+CVars_Smaller=r.Streaming.AmortizeCPUToGPUCopy=1
+CVars_Smaller=r.Streaming.MaxNumTexturesToStreamPerFrame=1
[IOS DeviceProfile]
...
+CVars_Smaller=r.Streaming.PoolSize=400
+CVars_Smaller=r.Streaming.LimitPoolSizeToVRAM=1
메모리 버킷 사이즈에 따라, PoolSize 스트리밍 텍스쳐 풀링 사이즈를 정의할 수 있다. 이 사이즈는 런타임에서 치트로 적절하게 값을 변경해보면서 프로젝트에 적합한 값을 찾아서 설정한다.
MobileContentScaleFactor 는 모바일에서 해상도 스케일을 조절하는 값이다. 이는 메모리에 고정적으로 할당되는 프레임버퍼 사이즈에 영향을 주기에, 프로젝트에 맞게 설정해야 한다.
iOS는 앱 실행 시, MobileContentScaleFactor 값을 읽어들여 프레임버퍼를 다음과 같이 계산한다.
프레임버퍼 = 단말기 해상도 * MobileContentScaleFactor / (Native)ScaleFactor (단 MobileContentScaleFactor = 0이면, 단말기 해상도 사용)
일반적으로 고사양 폰에선 비교적 고해상도를 쓰고, 저사양 폰에선 저해상도를 쓰는 것이 좋다. 허나 iOS는 동시기 안드로이드 대비 프로세서 성능이 높고 메모리 절약이 더 시급하기에, 가용메모리가 널널하면 고해상도를 쓰고 부족하면 저해상도 쓰는 전략이 더 좋을 수 있다.
그리고 이는 다음으로 간단히 설정할 수 있다.
[IPHONE DeviceProfile]
...
-CVars=r.MobileContentScaleFactor=2
+CVars_Largest=r.MobileContentScaleFactor=2.5
+CVars_Larger=r.MobileContentScaleFactor=2.5
+CVars_Default=r.MobileContentScaleFactor=2.2
+CVars_Smaller=r.MobileContentScaleFactor=2
+CVars_Smallest=r.MobileContentScaleFactor=2
[IPAD DeviceProfile]
...
-CVars=r.MobileContentScaleFactor=1.5
+CVars_Largest=r.MobileContentScaleFactor=1.8
+CVars_Larger=r.MobileContentScaleFactor=1.8
+CVars_Default=r.MobileContentScaleFactor=1.6
+CVars_Smaller=r.MobileContentScaleFactor=1.5
+CVars_Smallest=r.MobileContentScaleFactor=1.5
단말기의 가용 메모리 크기가 클수록 고사양 단말기로 판단해 아이폰의 경우 2~2.5(기본값 2), 아이패드는 1.5~1.8(기본값 1.5)까지 적용된다.
그리고 언리얼 엔진 소스코드(IOSView.cpp)를 수정하여 MobileContentScaleFactor 값이 단말기의 ScaleFactor 값을 초과하지 않도록 한다.
- (bool)CreateFramebuffer:(bool)bIsForOnDevice
{
...
else
{
// 슈퍼 리졸루션 방지
if (NativeScale < RequestedContentScaleFactor)
{
RequestedContentScaleFactor = NativeScale;
}
// for TV screens, always use scale factor of 1
self.contentScaleFactor = bIsForOnDevice ? RequestedContentScaleFactor : 1.0f;
}
...
앱 실행 시, MobileContentScaleFactor 값을 읽어들여 프레임버퍼를 다음과 같이 계산한다.
렌더 버퍼 크기 = (1280x720) * MobileContentScaleFactor (단 MobileContentScaleFactor = 0이면, 단말기 해상도 사용)
Android는 프로세서의 성능을 고려해 다음 예처럼 고사양 폰에선 고해상도를 쓰고, 저사양 폰에선 저해상도를 쓰도록 한다. 값의 범위는 1.0~1.5(720~1080p)가 적당하다고 본다.
...
[Android_Adreno5xx_Low DeviceProfile]
DeviceType=Android
+CVars=r.MobileContentScaleFactor=1.0
[Android_Adreno5xx DeviceProfile]
DeviceType=Android
+CVars=r.MobileContentScaleFactor=1.0
[Android_Adreno6xx DeviceProfile]
DeviceType=Android
+CVars=r.MobileContentScaleFactor=1.5
...
네비게이션 메쉬는 월드상 경로탐색을 위한 영역을 설정해, 자동으로 길을 찾는 등의 용도로 자주 쓰인다. 할당된 영역만큼 선형적으로 메모리에 누적되는데, 꽤나 용량을 차지하는 편이기 때문에 사용에 주의해야 한다.

프로젝트 A의 경우, 심리스 월드에서 네비게이션 메모리가 1GB씩 잡혀 가용 메모리를 까먹는 주요 원인 중 하나였다. 모바일에서 굳이 필요없다면 월드 세팅으로 네비게이션 시스템을 비활성화하자.


엔진에서 제공하는 프로파일러 외에, iOS에서 메탈 렌더링을 사용한다면 XCode 메탈개발자도구를 사용할 수 있다. 렌더링과 관련된 여러 프로파일링 정보를 체크해볼 수 있다.

특히 텍스쳐 메모리 사용량을 추적하고, 측정된 시점에 메모리에 로드된 모든 텍스쳐의 정보를 확인해볼 수 있기 때문에 의도되지 않은 텍스쳐의 로드나 잘못 적용된 포맷과 사이즈가 있는지 쉽게 체크해볼 수 있다.
프로젝트 A는 동남아시아에 소프트론칭을 함에, 그 시장은 주로 안드로이드 저사양 단말기를 사용하는 편이었다. 이에 저사양 단말기에서 안정적인 플레이를 할 수 있는 프레임을 확보하는 것이 팀의 선결 과제였는데, 이러한 프레임 최적화 작업은 참으로 어려운 것이라, 이를 토로해 보자면
보기에 최적화 작업은 문제를 찾고 개선하는 것보다, 작업 후 성능이 얼마나 개선되었는지 측정하는 법을 먼저 고려해야한다. 아니라면 추후 누군가 얼마나 개선되었는지 물을 때 "어.. 전보단 좀 나아진 거 같은데요"라고 할 뿐이다.
따라서, 프레임 최적화 작업을 시작할 때 프로젝트 QA팀에서 사용할 수 있는 통계 치트를 만들었는데 이는 다음과 같은 목록을 매 프레임마다 수집하고

화면 좌측에 별도 UI를 통해 출력하고, 측정을 종료하면 이 데이터 파일들을 메일로 전송하고자 했다.
이후 측정이 필요하면 QA팀에 요청하고 수집된 어떤 항목에서 팀에 정해진 커트라인을 벗어나면 최적화가 필요하다고 판단해 개발팀에 전달할 수 있도록 하였다.
+--------+-----------+------------------------+
| UNIT | CUTLINE | FAIL COND |
+========+===========+========================+
| Temp | 45 | < X MAX |
| FPS | 30 | > X MIN |
| Frame | x | |
| Game | x | |
| Render | x | |
| GPU | x | |
| Draws | 500 ~ 700 | < X MAX |
| Primis | x | |
| Memory | 1600MB | < X MAX(less then 4GM) |
+--------+-----------+------------------------+
추가로 QA팀의 공수를 줄이기 위해 통계 수집 시, 컨텐츠 별로 일정한 동작을 하도록 자동화하였는데, 예를 들어 4vs4 대전 컨텐츠에서는 모두 AI를 할당해 플레이를 대리하거나, 심리스 월드에서는 렌더 부하가 있는 장소와 카메라를 지정해 맵 여러곳을 순회하는 식이다.

Profiler 프로파일러는 게임 데이터를 수집하고 모니터링하는데 사용된다. 데이터 수집을 위해 다음 치트를 사용할 수 있다.
stat startfile (측정 시작)
stat stopfile (측정 종료)
이는 시작 시 단말기 내부에 ue4stats 확장자의 파일이 생성되고 이 파일을 Profiler에 로드해 사용하는 것인데, 다른 프로파일링 도구와 비교해 게임의 성능 저하가 적고 사용법이 간단하기 때문에 권장하는 도구이다. 프로젝트에서는 위의 자동화된 통계수집도구와 결합해 사용하였다.
그리고 프로젝트의 소스코드에서 연산이 오래 걸리거나 틱 구간에는 다음 매크로를 추가해 런타임 시, 함수의 실행속도나 횟수를 측정할 수 있다.
void SomeTask()
{
SCOPE_CYCLE_COUNTER(STAT_SOME_TASK);
...
}
이 매크로는 팀원 개개인이 작업할 때 잊지말고 사용할 수 있도록 교육하는 것이 좋다.
다음은, Profiler 도구를 사용해 발견한 개발버전의 프로젝트 A의 프레임 부하 사례를 소개하고자 한다.

프로젝트에서 데디케이티드 서버를 사용한다면, 통신간 패킷비용을 아낄 수 있도록 신경써야한다. 이는 위 NetTickTime 항목에서 확인할 수 있다.
RPC 함수는 실행시간을 최소화하고, 효과음 재생같은 건 Unreliable 프로퍼티를 사용한다. 인자를 사용하는 함수는 꼭 필요한 인자만 넘겨서 패킷크기와 압축해제비용을 낮추도록 한다.

저사양용 파티클을 따로 제작하거나, 모바일에선 비활성화하는 식으로 대응한다. 주로, 오파시티 연산부하인 경우가 많다.

모바일 환경에서 GC 퍼지 시, 프레임이 끊어지는게 체감될 정도로 부하가 걸린다. gc.TimeBetweenPurgingPendingKillObjects 옵션을 조절해 프로젝트에 적합한 퍼지 간격을 세팅하고 한번에 많은 오브젝트들이 정리하지 않도록 한다.

플레이 중에 (텍스쳐)에셋을 로드하는 건 히칭의 주요 원인이다. 인게임에서 주로 사용하는 에셋들은 컨텐츠 시작 전에 사전 로드될 수 있도록 한다. 파일의 IO는 늘 비싼 비용임을 기억하라.

슈퍼마리오의 코인처럼 빈번히 스폰시키는 액터들은 오브젝트풀링을 적극 사용해 스폰비용을 절감할 수 있도록 한다.

모바일 환경에서 맵 여러 곳을 지나다보면 원인을 알 수 없지만 프레임이 훅 떨어지는 구간이 있었는데 이 경우 오파시티 렌더 부하가 걸리는 경우가 좀 있었다.

예를 들면, 이런 화단 액터는 바람에 풀이 흔들리는 효과를 주기 위해 오파시티를 사용했는데 저사양 단말기에선 꽤 큰 렌더 부하를 가진 것이다.
오파시티는 표면에 깊이나 색감을 주기 좋기에 TA팀에서 자주 사용하고 싶은 옵션일 것이나 모바일 환경에선 렌더 비용이 크기 때문에 사용에 주의가 필요하다.
이 글에는 LOD, 디스턴스 컬링, 액터 병합, 월드 컴포짓 등 프로젝트에 적용한 후기를 더 쓰고 싶었으나 20000자가 넘어가 이만 줄이고자 한다.
보통 최적화 작업은 개발 막바지나 유저의 불만이 접수되면 그 시점에 급하게 들어가는 경우가 많을 것으로 예상된다. 이 글을 보는 독자에게 작은 도움이 되는 바람이다.
안녕하세요. 글 잘 읽었습니다. 궁금한 점이 있는데 Unreal 5.3에서 프로젝트-> config -> DefaultEngine.ini는 존재하는데 iOS(Android)Engine.ini가 보이지 않습니다. 혹시 DefaultEngine.ini에 아래에 옵션을 추가해도 될까요? [PlatformMemoryBuckets]