본문 바로가기

Unity

[Unity] 개인프로젝트 - 클라이언트 최적화

개요

D2의 기준 디바이스는 안드로이드의 경우 갤럭시 S5 / Note3 급이며, 최소 디바이스은 갤럭시 S4 급이다.
iOS의 기준 디바이스는 iPhone 5s 이며, 최소 디바이스는 iPhone 5 이다.
 
참고로, 안드의 메모리는 2GB 이상, iOS의 메모리는 1GB 이상이 메모리 최소 요구사항이다.
 
CBT 때 어느 정도 안정화 되었던 프레임이 소프트런칭 직전 갤럭시 노트3 단말기에서 들쭉날쭉 아주 춤을 추는 상황이 생겼다.
분명 최소한의 장치는 했다고 생각했는데, CBT 이후 개발이 급격히 진행되면서 여러 가지가 망가져 있었던 것이다.
 
소프트런칭까지 2주 남은 시점에서 진행했던 최적화 작업들에 대해 기록을 남긴다.
 

목표

기준 디바이스에서의 적어도 40fps를 안정적으로 제공하는 것이 최적화의 목표였다.
그럴려면, CPU의 frame time이 25ms 이내로 소요되어야 한다는 결론이 나온다.
 
하여, 다음의 영역별 frame time 분배를 일단 목표로 하였다.
 
  Time (ms)
Render 10
Skinning 5
Animation 3
Script 5
Overhead 2
Total 25
 
또한, frame당 drawcall은 최대 100 이하로 억제시키는 것이 목표였다.
 

방법들

 
1. C# 코드 최적화
 
워낙에 많은 글들이 있기에 굳이 따로 정리하진 않겠다.
[Unity] 각종 최적화 링크 모음의 내용들을 봐도 충분하다.
 
얼마나 충실하게 지키느냐의 싸움이지, C# 코드 최적화 방법론은 어느 정도 정립이 된 듯 하다.
 
2. 유니티 PlayerSetting
 
다음의 항목들을 손 봤는데, 모두 어느 정도 효과가 있었다.
 
1) Use-32bit Display Buffer
 
원래는 체크가 활성화 되어 있었는데, 이 녀석을 꺼주었다.
효과가 크다고 할 순 없지만, 없다고 할 수도 없어서 계속 꺼주는 것으로 진행했다.
 
이 녀석만 Resolution and Presentation 탭에 있고 이후의 녀석들은 모두 Other setting에 위치한다.
 
2) Static Batching
 
하지 않을 이유가 무엇이냐? 꼭 해줘라.
 
3) Dynamic Batching
 
긴가민가 했었는데, 적지 않은 효과를 봤다.
최소 프레임이 갤럭시 노트3 기준으로 3-4프레임은 보정되는 결과를 보여 주었다.

 

 
정적/동적 배칭 관련해서는 위의 유니티 레퍼런스 문서를 읽어보면 자세한 내용을 확인할 수 있다.
 
4) GPU Skinning
 
기대를 했던 녀석인데, 되려 안 좋은 결과가 나왔다.
GPU에 부하가 많이 올라가는지 발열이 심해졌고, 이로 인해 게임을 시작한 지 얼마되지 않아 쓰로틀링 등의 문제가 발생하였다.
 
5) Enable Internal Profiler
 
Shipping 빌드라면 당연히 꺼주어야 한다.
 
6) Optimize Mesh Data
 
잘을 모르지만, 불필요한 메쉬 컴포넌트를 날려준다길래 켰다. 체감되는 효과는 없었다.
 

3. 각종 오브젝트 Pooling

파일 시스템으로부터 자원을 로드하는 것은 언제나 무겁다. 특히나 모바일에서 전투중에는 더욱 심각해 진다.
따라서, 이들을 미리 로드하고 사용하는 것은 어떤 최적화보다 중요하다 할 수 있다.
 
D2에서는 크게 오브젝트 풀을 2개의 개념으로 분리하였다.
  • DataObject : 컷씬, 메쉬 등등의 오브젝트들
  • Effect : 이펙트 관련 오브젝트들
 
1) DataObject
  • Player : 캐릭터에 특정 메쉬를 붙였다뗐다 할 때
  • Temporary : 해당 스테이지에 필요한 컷씬 데이터 등을 풀링
 
2) Effect
  • Permanent : 영구히 보존할 녀석을 게임 시작과 동시에 모아둔다. 공통 피격 이펙트, 보물상자 이펙트 등등에 썼다.
  • Player : 플레이어에 관련된 스킬 이펙트 위주
  • Temporary : 해당 스테이지에 등장하는 NPC 들이 사용하는 스킬의 이펙트 위주
 
Temporary에 모여진 것들은 해당 스테이지가 종료되면 모두 클리어 시킨다.
Player에 모여진 것들은 캐릭터 선택 화면으로 진입할 때 모두 클리어 시키고, 캐릭터를 선택할 때부터 하나씩 모으게 되어 있다.
 

4. Shader pre-rendering(load)

프로파일러로 관찰하다 보면, 게임을 시작한 후 새로운 Npc가 스킬을 쓸 때 hiccup이 많이 발생하는 것을 확인하였다.
프레임이 급락할 때 주요 원인은 파티클에 사용되는 머티리얼들의 셰이더를 로드하는데 발생하는 비용이었다.
 
대부분의 그래픽 디바이스에서 셰이더는 렌더링되기 전까지 로드되지 않는다.
 
그렇기에 pre-load라기 보다 pre-rendering이라고 표현하는 게 더 정확하다.
따라서, 어떻게든 전투에 진입하기 전에 사용될 셰이더들을 로드시켜야 했다.
 
이를 위해 다음 2가지 작업을 하였고, 체감 효과는 아주 컸다.
 
1) Shader.WarmupAllShaders
 
해당 함수의 설명은 Shader.WarmupAllShaders 링크를 읽어보면 된다.
 
이 함수의 단점은 고사양 폰에서도 4-5초 걸릴 정도로 무겁다는 것이다.
따라서, 저사양 기기에서는 이를 수행하지 않도록 별도로 처리를 해 주어야 했다.
 
저사양 기기를 구별했던 방법에 대해서는 "[Unity] D2 - 저사양 단말기 구분하기" 페이지를 참고하기 바란다.
 
2) 직접 해당 이펙트들을 렌더링하기
 
Shader.WarmupAllShaders 만으로는 전투중 frame hiccup을 완전히 막을 수 없었다.
그래서, 전투 로드 단계에서 Collect되었던 PlayerEffect를 강제로 렌더링시키는 작업을 추가하였다.
  1. StartScene에 게임 오브젝트를 하나 만들고 그 밑에 UI - Canvas를 추가
  2. Canvas 밑에 UI - RawImage 추가 (렌더를 할 밑판)
  3. 콜렉트된 PlayerEffect 오브젝트들을 하나씩 돌면서, 해당 오브젝트를 Instantiate
  4. 생성된 객체의 ParticleSystem 컴포넌트를 얻어와서, ParticleSystem.Simulate(0.5f, true) 실행
  5. 해당 객체들을 preRenderedPool에 수집
 
위의 루프가 모두 수행되면, 렌더가 충분히 될 때까지 기다리기 위해 해당 코루틴 함수는 3초간 대기한다.
이후 preRenderedPool에 수집된 오브젝트들을 Destroy 시켜준다.
 
이 두 가지 방법을 이용해 전투중 hiccup을 완전히 없앴진 못했지만, 현저하게 줄어드는 것을 확인할 수 있었다.
 

5. 아트 리소스 버짓 준수 (가장 중요)

아무리 테크에서 최적화를 한다고 발버둥 쳐봤자 리소스 버짓을 함부로 사용한다면 뭐 답이 없다.
이렇기에 각 리소스별 버짓을 명확하게 정의할 필요가 있고, 아트를 설득시킬 의무가 있다.
 
D2에서의 리소스 버짓은 "다음 문서의 페이지"를 참고하면 된다.
 

6. Particle 동시 최대 Emit 개수 제어

다수의 적을 한꺼번에 타격하거나 죽일 수 있는 액션 RPG의 특성상 하나의 파티클이 동시 다발적으로 터질 수 있다.
이렇게 한꺼번에 터지는 파티클 수량이 제어 안 되는 상황이 발생하면, 급격히 Drawcall이 오르고 결국 프레임 급감으로 이어진다.
 
이를 위해 이펙트 시스템을 만들 때, 하나의 파티클당 동시에 emit 될 수 있는 최대치를 걸어 두었다.
 

7. Shipping 빌드 Log 제거

개발하다보면 각종 디버깅을 위한 로그를 많이 남기게 되는데, 이것을 제거하지 않고 shipping 빌드를 만들었을 때 의외로 성능에 지대한 영향을 끼치드라.
배포 버전엔 꼭 로컬 디버깅 로그를 블록하고 내보내는 것이 좋다. 
Unity 5.3 부터는 다음처럼 릴리즈 빌드에서 디버깅 로그가 꺼지도록 해주자.
// 끄고
Debug.logger.logEnabled = false;
// 켠다
Debug.logger.logEnabled = true;
 
 
// 유니티 엔진의 Debug.logger는 릴리즈 빌드라 해서 자연스게 꺼지지 않는다.
// 따라서, 다음처럼 Debug 모드가 아닐 때는 동작하지 않도록 하는 것이 성능상 꽤 많은 도움이 된다
Debug.logger.logEnabled = Debug.isDebugBuild;