개발단에 가입하여 베르의 게임 개발 유튜브를 후원해주세요! 

 

베르의 게임 개발 유튜브

안녕하세요! 여러분들과 함께 게임 개발을 공부하는 베르입니다! 게임 개발에 도움이 되는 강좌들을 올리는 채널입니다! [투네이션 후원] https://toon.at/donate/637735212761460238 [유니티 어필리에이트

www.youtube.com

안녕하세요! 여러분들과 함께 게임 개발을 공부하는 베르입니다!

이번에는 유니티에서 공식 지원하는 오브젝트 풀링을 알아봅시다.

 

[패키지 다운로드]

https://github.com/WERGIA/WerGameDevChan/blob/main/Unity/unity-object-pool.unitypackage

 

사용 엔진 버전 : 2021.3

 

타임라인

0:00 인트로

0:10 오브젝트 생성과 파괴 방식의 문제

2:26 오브젝트 풀링의 개념

3:02 유니티 오브젝트 풀링

6:14 아웃트로

스크립트

인트로

안녕하세요. 여러분들과 함께 게임 개발을 공부하는 베르입니다.

이번에는 유니티 2021부터 정식 기능으로 포함된 오브젝트 풀링에 대해서 알아보도록 하겠습니다.

오브젝트 생성과 파괴 방식의 문제

오브젝트 풀링이 무엇인지는 이전에 만든 오브젝트 풀링 영상에서 이야기했었지만 그래도 한 번 더 간단하게 설명해보도록 하겠습니다.

먼저 오브젝트 풀링을 사용하지 않고 오브젝트를 생성하거나 파괴하면 두 가지 문제가 발생할 수 있습니다.

첫 번째 문제는 가비지 컬렉팅으로 인한 프레임 드랍입니다.

프로그래밍에서 오브젝트를 생성하거나 파괴하는 작업은 꽤나 무거운 작업으로 분류됩니다.

오브젝트 생성은 메모리를 새로 할당하고 리소스를 로드하는 등의 초기화 과정이 필요하고, 오브젝트 파괴는 파괴 이후에 발생하는 가비지 컬렉팅으로 인한 프레임 드랍이 발생할 수 있습니다.

가비지 컬렉팅이란 유니티 스크립팅의 기반이 되는 C# 프로그래밍에서 제공되는 기능입니다.

우리가 Destroy 함수로 게임 오브젝트를 파괴한다고 선언하면 곧바로 메모리에서 사라지는 것이 아닙니다.

그 게임 오브젝트는 게임에서 보이지 않게 되지만 가비지 콜렉터라고 부르는 것이 그것을 수거해서 파괴하기 전까지 메모리에 남아있습니다.

그래서 오브젝트를 파괴한다고 선언하면 일정 사이클이 지난 이후에 가비지 컬렉터가 메모리를 뒤져서 파괴 선언된 오브젝트를 수거해서 파괴합니다.

이렇게 파괴 선언된 오브젝트가 적으면 수거해서 파괴하는 시간이 짧지만, 많은 양의 오브젝트가 쌓여있다면 당연히 수거하고 파괴하는 데 더 많은 시간이 걸리게 됩니다.

거기에 컴퓨터는 이렇게 파괴한 오브젝트를 수거해서 정리하는 작업만 진행하고 있는게 아닙니다.

게임이 동작하는 로직도 처리하고 있고 게임 화면을 보여주기 위해서 렌더링 작업까지 진행하고 있습니다.

그렇기 때문에 파괴된 오브젝트가 많다면 많은 양의 메모리를 정리하기 위해서 게임의 프레임이 일시적으로 떨어지는 문제가 발생합니다.

두 번째 문제는 메모리 파편화입니다.

프로그램에서는 처음부터 정해진 메모리의 양도 있지만, 동작하는 도중에 필요한 양에 따라 새로 할당받는 경우도 있습니다.

그런데 이 예시 이미지처럼 64바이트의 A, B, C, D를 할당받은 상태에서 A와 C를 할당 해제하면 3번과 같이 빈 공간이 생겨납니다.

이 때 128바이트의 오브젝트를 생성하려고 하면 A와 C 자리에는 공간이 부족에서 D의 뒤에 있는 공간에 할당해야 합니다.

이 할당 이 후에 128바이트 짜리 오브젝트를 하나 더 생성하려고 하면, 메모리 빈 공간만 따졌을 때는 128바이트의 공간이 존재하지만, 연결된 크기가 128바이트인 공간이 없어서 오브젝트 생성에 실패합니다.

한마디로 메모리는 남아 있는데 메모리가 부족한 역설적인 상황이 발생하는 것입니다.

이런 문제들을 해결하기 위해서 고안된 개념이 바로 오브젝트 풀링입니다.

오브젝트 풀링의 개념

오브젝트 풀링의 개념은 생각보다 간단합니다.

먼저 오브젝트를 담아둘 오브젝트 풀을 만들고 여기에 빌려줄 오브젝트를 생성해서 담아둔 뒤 외부에서 이 오브젝트가 필요하면 오브젝트 풀에서 빌려가는 갑니다.

그리고 빌려온 오브젝트를 모두 사용하고 나면 다시 오브젝트 풀에 돌려주는 겁니다.

그런데 만약 외부에서 오브젝트 풀 안에 있는 모든 오브젝트를 다 빌려가서 더 빌려줄 수 없는 상황이 되면 그때서야 새로운 오브젝트를 생성해서 꺼내줍니다.

그리고 이렇게 새로 만든 오브젝트 역시 사용이 끝나서 돌려 받으면 파괴하지 않고 오브젝트 풀이 보관하고 있으면 됩니다.

이게 바로 오브젝트 풀링의 개념입니다.

유니티 오브젝트 풀링

유니티 오브젝트 풀링을 사용하는 방법을 알아보기 전에 영상 하단의 링크에서 자료를 다운로드 받아서 프로젝트에 임포트합니다.

패키지를 임포트한 다음에 Pooling Test Scene을 열고 플레이시켜보면 클릭하는 동안에 많은 수의 총알 오브젝트가 생성되는 모습을 볼 수 있습니다.

총알을 발사하는 역할의 Shooter 클래스를 보면 지금은 오브젝트 풀을 사용하지 않고 총알을 생성하고 있습니다.

이제 이 총알 생성과 파괴 부분을 유니티 오브젝트 풀로 대체해보겠습니다.

유니티 오브젝트 풀을 사용하기 위해서는 UnityEngine.Pool 네임스페이스가 필요합니다.

그리고 오브젝트 풀을 적용하기 위해서는 풀링의 대상이 될 오브젝트의 클래스와 풀링된 오브젝트를 관리할 클래스, 두 군데에 작업을 해줘야합니다.

먼저 풀링의 대상이 될 Bullet 클래스로 이동합니다.

그리고 클래스의 상단에 UnityEngine.Pool 네임스페이스를 선언해줍니다.

IObjectPool<Bullet> 타입으로 이 총알 오브젝트를 관리 중인 풀을 캐싱할 _ManagedPool 멤버 변수를 선언합니다.

그리고 SetManagedPool 함수를 만들고 매개변수로 받은 풀을 _ManagedPool에 저장하도록 만들어줍니다.

그 다음에는 DestroyBullet 함수를 만들고 호출되면 총알 오브젝트를 풀에 반환하도록 코드를 작성합니다.

그리고 Shoot 함수에서 총알을 5초 후에 파괴하는 부분을 5초 후에 DestroyBullet 함수를 호출하도록 수정합니다.

그 다음에는 Shooter 클래스로 이동해서 상단에 UnityEngine.Pool 네임스페이스를 선언해줍니다.

그리고 멤버 변수로 총알 오브젝트들을 관리할 IObjectPool<Bullet> 타입의 _Pool 변수를 선언해줍니다.

그리고 Awake 함수를 만들어서 _Pool 멤버 변수에 Object Pool을 생성해서 넣어줍니다.

여기서 생성자의 매개 변수에 넣어줄 것이 좀 많습니다.

먼저 총알 오브젝트를 생성할 때 호출될 함수인 CreateBullet 함수를 만들고 가지고 있는 프리팹으로 총알 오브젝트를 생성한 다음 생성된 bullet 오브젝트에 자신이 등록되어야할 풀을 알려주고 반환합니다.

그리고 풀에서 오브젝트를 빌려올 때 사용될 OnGetBullet 함수와 오브젝트를 풀에 돌려줄 때 사용될 OnReleaseBullet 함수를 선언하고 코드를 작성합니다.

마지막으로 풀에서 오브젝트를 파괴할 때 사용될 OnDestroyBullet 함수 역시 작성해줍니다.

그리고 이렇게 만든 함수들을 풀의 생성자 매개변수에 넣어주고, 생성가능한 오브젝트 최대 갯수 역시 넣어줍니다.

오브젝트 풀과 관련된 코드를 모두 작성한 다음에는 Update 함수로 가서 프리팹을 인스턴스화해서 총알 오브젝트를 생성하는 부분의 코드를 오브젝트 풀을 사용하도록 코드를 수정합니다.

코드를 모두 작성한 다음에는 저장하고 에디터로 돌아갑니다.

그리고 게임을 플레이해서 테스트해보면 처음에는 총알 오브젝트가 없지만 총알을 발사할 때 생겨납니다.

그리고 총알이 사라질 때가 되면 풀에 설정해놓은 최대 갯수만 남아서 비활성화되고 나머지는 완전히 파괴되는 모습을 볼 수 있습니다.

다시 한 번 총알을 발사해보면 비활성화된 총알들이 재활용되고 모자란 총알들만 생성되는 모습을 볼 수 있습니다.

아웃트로

이번 영상에서는 2021 버전부터 정식 지원되는 유니티 오브젝트 풀링에 대해서 알아보았습니다.

이 강좌는 시청자 여러분들의 시청과 후원으로 제작되었습니다.

이상 베르의 게임 개발 유튜브였습니다. 감사합니다.

 

[유니티 어필리에이트 프로그램]

아래의 링크를 통해 에셋을 구매하시거나 유니티를 구독하시면 수익의 일부가 베르에게 수수료로 지급되어 채널의 운영에 도움이 됩니다.

 

에셋스토어

여러분의 작업에 필요한 베스트 에셋을 찾아보세요. 유니티 에셋스토어가 2D, 3D 모델, SDK, 템플릿, 툴 등 여러분의 콘텐츠 제작에 날개를 달아줄 다양한 에셋을 제공합니다.

assetstore.unity.com

 

Easy 2D, 3D, VR, & AR software for cross-platform development of games and mobile apps. - Unity Store

Have a 2D, 3D, VR, or AR project that needs cross-platform functionality? We can help. Take a look at the easy-to-use Unity Plus real-time dev platform!

store.unity.com

 

Create 2D & 3D Experiences With Unity's Game Engine | Unity Pro - Unity Store

Unity Pro software is a real-time 3D platform for teams who want to design cross-platform, 2D, 3D, VR, AR & mobile experiences with a full suite of advanced tools.

store.unity.com

 

[투네이션]

 

-

 

toon.at

[Patreon]

 

 

WER's GAME DEVELOP CHANNEL님이 Game making class videos 창작 중 | Patreon

WER's GAME DEVELOP CHANNEL의 후원자가 되어보세요. 아티스트와 크리에이터를 위한 세계 최대의 멤버십 플랫폼에서 멤버십 전용 콘텐츠와 체험을 즐길 수 있습니다.

www.patreon.com

[디스코드 채널]

 

Join the 베르의 게임 개발 채널 Discord Server!

Check out the 베르의 게임 개발 채널 community on Discord - hang out with 399 other members and enjoy free voice and text chat.

discord.com

 

[참고자료]

https://luv-n-interest.tistory.com/1368

https://docs.unity3d.com/ScriptReference/Pool.ObjectPool_1.html

https://workinprogress.kr/wiki/programming/do-not-feed-the-gc/

반응형
  1. 개양반 2022.08.10 00:45 신고

    오웃... 유니티에서 만들어준거니 최적화가 사용법이 더 쉽겠죠!?

UI 비법서 (5) 

캔버스의 분할


작성 기준 버전 :: 2019.2


유니티에서 모든 UI는 캔버스(Canvas) 위에서 그려진다. 아직 유니티 엔진을 이용한 개발에 익숙하지 못한 개발자들은 모든 UI를 한 캔버스에 만드는 경우가 많다.


하지만 모든 UI를 한 캔버스로 몰아넣으면 모든 UI를 그리는 과정에서 불필요한 낭비가 발생하게 된다.


[그림 1]

 

[그림 1]을 보면 하나의 캔버스 안에 여러 개의 이미지가 포함되어 있는 것을 볼 수 있다. Checker 오브젝트는 흰 색과 검은 색으로 이루어진 이미지이고, 그 뒤에 Background Slide라는 이름의 회색 이미지가 크게 배치되어 있다.


[그림 2]

 

우선 이 상태에서 UI는 어떤 과정을 통해서 그려지는지 확인하기 위해서 프레임 디버거(Frame Debugger)를 실행해보자. 프레임 디버거는 게임에서 각 프레임이 그려질 때 어떤 과정을 거쳐서 그려지는지 보여주는 디버깅 툴이다. 이것을 통해서 어떤 렌더링 과정에서 시간을 소모하는지를 확인할 수 있는 좋은 도구이다. 프레임 디버거를 실행하기 위해서는 유니티 상단 메뉴에서 [Windows > Analysis > Frame Debugger]를 선택하면 된다.


[그림 3]


그러면 [그림 3]과 같은 프레임 디버거 창이 열린다.


[그림 4]

 

프레임 디버거 창을 띄운 후, 플레이를 시작하고 프레임 디버거 창의 Enable 버튼을 누르면 한 프레임이 그려지는데 어떤 과정으로 몇 단계나 거쳐서 실행되는지 확인할 수 있다.


[그림 5]

 

이 상태에서 7 of 7 옆에 있는 넘기기 버튼을 눌러서 확인해보면 총 7단계를 거쳐서 화면이 그려지고 있고 그 중에 UI는 6, 7단계 두 단계를 거쳐서 먼저 회색 바탕이 그려지고 그 위에 체크 무늬 이미지가 그려지는 것을 볼 수 있다.


using UnityEngine;

using UnityEngine.UI;


public class FillingImage : MonoBehaviour

{

    private Image image;


    void Start()

    {

        image = GetComponent<Image>();

    }


    bool isFill = false;

    float timer = 0f;


    void Update()

    {

        if(timer >= 1f)

        {

            timer = 0f;

            isFill = !isFill;

        }

        timer += Time.deltaTime;

        image.fillAmount = isFill ? timer : 1f - timer; 

    }

}

 

그렇다면 이번에는 회색 바탕의 이미지인 Background Slide 오브젝트에 위와 같은 코드를 추가해서 이미지가 계속해서 채워졌다가 사라지는 동작을 반복하도록 만들어보자.


[그림 6]


코드를 모두 작성했다면 Background Slide 게임 오브젝트에 컴포넌트로 붙여준다.


[그림 7]

 

이것을 실행해보면 [그림 7]와 같이 보여진다. 하지만 단순히 겉으로만 보이는 상황으로는 어떤 낭비가 발생하는지 알 수 없다. 그러면 어떤 낭비가 발생하는지 확인하기 위해서 다시 프레임 디버거로 살펴보자.


[그림 8]

 

프레임마다 분명 7단계였던 렌더링 과정이 회색 이미지가 체커와 겹치게 되면서 8단계로 늘어나는 것을 볼 수 있다.


[그림 9]

 

이 8단계로 늘어난 렌더링 과정을 살펴보면 7단계일 때는 분명 회색 바탕을 먼저 그리고 체크 무늬 이미지를 그렸던 과정이 회색 바탕과 겹치지 않은 체크 무늬 이미지를 그리는 과정, 회색 바탕 이미지를 그리는 과정 그리고 회색 바탕과 겹친 체크 무늬 이미지를 그리는 과정으로 나누어진 것을 볼 수 있다. 이것은 렌더링 과정이 아주 작게 늘어난 예시로, UI 캔버스의 구조가 복잡해지면 복잡해질 수록 어떤 성능의 낭비를 가져올지 알 수 없게 된다.


이것을 해결하기 위한 방법이 바로 이번 글의 주제인 캔버스의 분할이다.


[그림 10]

 

[그림 10]을 보면 매 프레임 변동이 발생하는 Background Slide와 늘 고정되어 있는 Checker를 다른 캔버스로 나누어 배치한 것을 볼 수 있다.


[그림 11]

 

이렇게 캔버스를 분할한 뒤 다시 실행해서 매 프레임의 렌더링 단계를 살펴보면 다시 7단계로 줄어든 것을 확인할 수 있다.


[그림 12]

 

렌더링 단계 역시 살펴보면 회색 바탕을 먼저 그리고 회색 바탕과 체크 무늬 이미지가 겹치는 것과 상관없이 한꺼번에 체크 무늬 이미지를 그리는 것을 볼 수 있다.


이렇게 캔버스를 분할하는 것 역시 렌더링 최적화를 위한 하나의 방법이 될 수 있다. 가능하면 변동되는 타이밍이 비슷한 UI끼리 그룹을 지어서 캔버스로 묶는 것이 좋다.

반응형

UI 리소스 최적화로 메모리와 용량 최적화 잡기


최근 게임을 제작할 때, PC를 타깃으로 한 평범한 수준의 게임은 유저들의 평균적인 메모리 사양이 4~8GB 가량 되기 때문에 메모리에 크게 구애받는 일은 적은 편이지만, 모바일을 타깃으로 하거나, PC나 콘솔 타깃이더라도 고사양의 게임은 메모리에 대한 최적화가 필요하다.

특히 모바일의 경우, 하이엔드 모델은 3~4GB 이상의 넉넉한 메모리를 지원하는 모델이지만, 대다수의 사용자들이 사용하는 보급형 모델은 1~2GB 수준으로 메모리 최적화를 고려하지 않고 게임을 만들었다면, 저사양의 유저들은 게임을 원활하게 게임을 플레이할 수 없을 것이다.

물론 애초에 고사양 타깃으로 만들어진 게임이면 어쩔 수 없는 일이지만, 중저사양의 모델 역시 타깃으로 잡았고 메모리를 제외하면 분명 중저사양에서도 돌아갈 수 있는 게임임에도 불구하고 메모리 최적화 때문에 중저사양 모델에서 돌리지 못한다면 문제가 있다.

이런 메모리 최적화 문제에 대한 해결책은 여러가지가 있지만 그 중에서도 제일 먼저 살펴보아야할 부분이 UI다. 그럼 UI 텍스처 최적화를 통한 메모리 최적화에 대해서 알아보도록 하자.





유니티의 텍스처 압축 지원 받기


유니티에서는 텍스처 리소스에 대해서 기본적인 압축 기능을 자체적으로 제공한다. 이러한 압축을 지원받는 것 만으로도 압축되지 않은 리소스에 비해서 상당한 수준의 용량을 아낄 수 있게 된다.

유니티의 텍스처 압축은 모든 텍스처에 적용되는 것은 아니고 한 가지 제약사항이 존재한다.


그것은 바로 텍스처의 너비와 높이 둘 다 4의 배수가 되어야 한다는 것이다. 만약 너비나 높이 둘 중 하나라도 4의 배수가 되지 못하면, 해당 텍스처는 무압축 상태로 빌드된다.



위의 이미지를 보면 텍스처 압축을 지원받지 못한 900x359 크기의 텍스처는 1.2MB지만 900x360 크기의 텍스처는 RGBA Compressed DXT5 포맷으로 압축되어 316.4KB로 엄청나게 크기가 줄어든 것을 확인할 수 있다.


단, DXT5 포맷의 압축 방식은 iOS에서는 지원되지 않기 때문에, 다른 압축 포맷을 직접 지정하는것이 좋다.





스프라이트 패킹(Sprite Packing)


UI 텍스처 압축을 통해 용량을 아꼈다면 이번에는 메모리를 아껴볼 차례다.



유니티에서 이미지는 메모리에 올라갈 때, 정사각형의 형태나 각 변의 길이가 POT(2의 승수, 128 256 512 같은..)인 형태로 올라가게 되는데, 만약 이미지가 정사각형이 아니거나 각 변의 길이가 POT인 형태라면 이미지의 가로변과 세로변의 길이 중 긴 변의 길이에 맞춰서 정사각형의 크기의 메모리를 할당받기 때문에 낭비되는 메모리 공간이 많아진다. 이 문제는 한 쪽변이 다른 한 쪽변의 길이에 비해서 매우 길어질 수록 심해진다.



이러한 문제를 해결하기 위한 것이 바로 스프라이트 패킹이다. 스프라이트 패킹이란 여러개의 이미지를 같은 패키지로 패킹해서 메모리에 함께 올리는 것으로 모든 UI 텍스처를 따로 메모리에 올렸을 때보다 메모리의 낭비를 많이 줄일 수 있게 된다.


유니티에서 스프라이트 패킹 방법으로 레거시 스프라이트 패커(Legary Sprite Packer)와 스프라이트 아틀라스(Sprite Atlas) 두 가지를 제공한다.





슬라이스드 이미지 사용하기(Sliced Image)


사실 UI 리소스의 경우에는 특별한 이미지가 많이 사용된다기 보다는 사용되는 이미지가 반복되어서 사용되는 경우가 많다. UI를 묶어주는 패널(Pannel), 입력을 받는 인풋 필드(Input Field), 버튼(Button) 등이 여기에 속한다.


이런 것에 사용되는 리소스는 재사용성을 높여야 되지만, 초보 개발자나 초보 UI 디자이너는 예쁘거나 멋지게 만들어야 되는다는 집착에 빠지거나, 최적화에 대한 신경을 못쓰고 만들어서, 패널이나 버튼에 들어갈 이미지를 크기에 맞춰서 필요한 모든 사이즈 별로 만드는 경우가 종종 있다.



예를 들어 760x960 크기의 UI가 필요해서 거기에 해당하는 리소스를 만들어 냈다고 해보자. 이걸 게임에 그대로 적용해버리면 패널을 꾸미기에 따라서는 UI가 예뻐보일 수는 있을 것이다. 하지만 압축된 리소스임에도 불구하고 0.7MB라는 엄청난 용량을 자랑하는 것을 볼 수 있다. 이게 겨우 하나여서 0.7MB지, 여러 종류의 많은 UI를 띄워야 하는 게임이어서 해당 UI의 크기 별로 패널 리소스를 새로 만들어서 적용한다면 그리고 그 와중에 몇몇 리소스가 압축되지 않는 불상사가 발생한다면 게임의 용량이나 메모리 소모는 엄청난 수준이 될 것이다.


버튼이나 패널 같은 UI의 리소스의 경우에는 일반적으로 리소스의 모서리 부분을 제외한 중심 부분은 반복되는 경우가 많다. 슬라이스드 이미지란 바로 이 점에서 착안한 아이디어로 모서리 부분과 반복될 중심 부분 조금만 있으면 유니티 엔진이 중심 부분을 자동으로 채워주는 기능이다. 그렇기 때문에 패널 리소스 중에서도 모서리 부분과 반복될 중심 부분 약간 만으로 리소스를 만들면 위 이미지와 같은 커다란 패널 UI 리소스는 아래 이미지와 같이 아주 작게 줄일 수 있다.



불필요한 중심 부분을 제거하는 것만으로도 이미지의 크기와 용량이 700분의 1로 줄어들었다.




리소스의 중심 부분을 제거한 뒤에는 유니티의 스프라이트 에디터(Sprite Editor)에서 원형을 유지할 부분과 크기가 늘어났을 때 자동으로 채워질 부분을 설정해주면된다. 위의 그림처럼 설정하면 UI의 크기가 늘어났을 때 모서리 부분은 원형을 유지하고 가운데 십자 부분만 자동으로 채워지는데 씬에서 UI Image를 추가할 때, Image Type를 Sliced로 변경해주면 작은 이미지가 전혀 확대되거나 이상한 모양으로 늘어지지 않음을 확인할 수 있다.






오른쪽의 패널이 슬라이스드 이미지를 사용한 UI이고 오른쪽은 그냥 패널 이미지를 통째로 사용한 것이다. 둘이 차이가 없음을 확인할 수 있다. 하지만 패널 리소스에 그라데이션이나 디자인이 들어갔다면 그 퀄리티는 다를 수도 있다. 하지만 그 부분은 저사양에서는 단순한 패널만 보여주고 유저가 고사양을 선택한다면 패널 UI 위에 디자인 이미지를 올려서 보여줄 수도 있다.

반응형

최적화란 무엇인가?




만약, 지금 당신에게 사고 싶은 게임이 두 개가 있고 그 중 하나의 게임만을 구매할 기회가 있다고 가정해보자. 이 두 개의 게임은 비슷한 수준의 재미를 가지고 있으며, 당신이 이 두 게임에게 가진 흥미 또한 비슷하다면 이 두 개의 게임 중에 어떤 게임을 사야할지 깊은 고민에 빠지게 될 것이다.


그런데 깊은 고민에 빠진 당신이 두 게임의 평점을 살피던 중에 게임 B의 평가에서 "이 게임은 너무 용량을 많이 잡아먹는다", "이 게임은 메모리를 너무 많이 사용한다", "이 게임은 프레임 드랍이 너무 심하다"와 같은 평가를 발견하게 된다면 당신의 마음은 어느 쪽으로 기울겠는가? 당연히 마음은 게임 A를 구매하는 쪽으로 기울게 될 것이다. 게임 B의 구매를 완전히 포기하지는 않더라도 "다음에 세일할 때"라던가 "최적화 되었다는 소식이 들리면" 사야되겠다는 쪽으로 마음을 굳히게 될 것이다.



유저가 구매를 결정하는 단계라면 위의 이야기처럼 구매 의사를 접거나 후로 미루게 될 것이지만, 불안정한 최적화(이후에는 간단하게 "발적화"라고 부르도록 하자)에 대한 소식을 듣지 못했거나, 다른 누군가가 발적화에 대한 소식을 올리기도 전에 누구보다 빠르게 게임을 구매한 유저라면 게임사에 격렬하게 항의를 하거나 게임에 대해서 안좋은 평가를 내리게 되고 그 평가는 앞서 말한 구매를 결정하는 단계의 유저들의 발목을 잡게 된다.


위와 같은 순환을 거쳐서 최적화는 유저가 게임을 구매를 결정하는데 생각보다 많은 영향을 미친다(물론 아무리 최적화가 잘 되어있다고 해도 게임 자체가 재미가 없다면 아무런 의미가 없다).


그에 대한 좋은 예시가 바로 PC판 배트맨 : 아캄 나이트다. PS4판이나 XBox One판은 시리즈 전체에 비하면 낮지만 평범한 수준의 평가를 받았지만 PC 판은 심각한 수준의 발적화로 인해 초토화에 가까운 낮은 평가를 받아들어야만 했다.

이러한 최적화는 한마디로 표현하자면, "더 적은 자원으로 높은 효율을 보여주는 것"이다. 더 적은 리소스로 더 좋아보이게, 복잡해 보이는 처리를 더 빠르게 하는 것이 최적화다.


최적화는 개발자의 경험이 미치는 영향이 아주 커서 최적화를 많이 해본 개발자가 그렇지 못한 개발자보다 더 최적화를 잘 진행하는 편이다.


경험이 많지 않은 초보 개발자들의 경우에는 주로 뒤에서 돌아가는 것보다는 눈 앞에 보이는 것을 우선하는 경향과 일단 만들어보자 하는 면이 있기 때문에, 한 장면만 놓고 봤을 때는 와 그래픽이 좋다라는 평가가 나올 수 있지만, 실제로 게임을 작동했을 때는 메모리를 과다하게 잡아먹거나 프레임이 뚝뚝 떨어지는 현상을 쉽게 목격할 수 있는데, 초보 개발자는 여기서 혼란에 빠지게 된다. 지금 보이는 화면의 퀄리티가 떨어지는 것을 최대한 막으면서 성능을 나아지게 하려면 어느 부분을 덜어내고 어느 부분을 남겨놔야 할지, 어느 부분을 개선해야 최적화가 될지에 대한 감이 전혀없기 때문이다.





최적화 할 수 있는 부분들


최적화를 하는 부분은 대부분 정해져있다.


1. 비효율적인 알고리즘


최적화의 전제 조건은 효율적인 알고리즘이다. 비효율적인 알고리즘을 사용하는 상태에서는 어떤 최적화 기법을 사용한다고 해도 성능은 획기적으로 개선되지 못한다. 예를 들어 컨테이너 안에 든 자료들을 정렬하는 모든 작업에 버블 정렬을 사용한다고 가정해보자. 과연 그 프로그램이 다른 부분을 개선한다고 해서 얼마나 빨라질 수 있을까? 하지만 정렬 알고리즘을 버블 정렬보다 효율적인 알고리즘으로 교체하는 것만으로도 굉장한 성능 개선을 보일 수 있을 것이다.


2. 병목현상 제거


대부분의 프로그램은 모든 부분이 느리다기 보다는 어느 특정한 시점에서 굉장히 느려지는 등의 병목이 발생하는 경우가 많다. 병목 현상이란 특정한 시점에 계산이나 데이터 전송이 몰려서 처리하는 속도보다 요구가 쌓이는 속도가 더 빨라서 느려지는 것을 의미한다. 이러한 병목 현상을 제거해주는 것만으로도 상당한 성능 개선을 기대할 수 있다.


3. 레벨 디자인과 그래픽


게임을 제작할 때, 좋은 그래픽을 보여주기 위한 욕심으로 고화질의 텍스처, 많은 버텍스를 가진 모델, 레벨에 과다하게 배치된 사물들 역시 많은 성능을 잡아먹는 요소가 된다. 최적화를 위해서는 어쩔 수 없이 유저의 시선이 잘 닿지 않는 곳이나 먼 곳 등은, 텍스처의 해상도를 낮추거나 버텍스를 줄이고, 적절한 수의 사물을 배치하는 것 역시 좋은 최적화 요소가 된다.


4. 기획 덜어내기


기획자들은 항상 욕심이 많다. 기획자들은 하나의 게임 내에서 더 많은 것들과, 더 다양한 시스템들을 보여주고 싶어한다. 하지만 프로그래머가 최적화하는데도 한계가 있을 수 밖에 없기 때문에, 과도한 연산이 필요한 시스템이나 과도한 스케일의 기획은 덜어내는 수 밖에 없다. 일반적인 기획자들은 덧붙이기만 하지만 뛰어난 기획자들은 덜어낼 줄 안다.





최적화, 비용과 그 결과의 딜레마


최적화 과정 역시 프로그램 개발 과정의 일부로서 당연히 비용과 시간이 소모된다. 최적화 작업은 초기에는 들인 비용에 비해 개선되는 성능이 높은 편이지만, 과정이 진행되면 진행될 수록 개선되는 성능의 폭이 기하급수적으로 감소한다. 즉, 들인 시간과 비용에 비해서 성능 개선이 너무 적게되는 순간이 반드시 온다는 것이다.


분명 최적화는 하면 할 수록 낮은 사양의 유저들까지 수용할 수 있게 되지만, 그것도 어느 정도의 한계선은 분명히 존재하며, 최고의 성능 개선을 하는데 드는 비용과 시간은 굉장한 수준이다. 그렇기 때문에 최적화 작업에 들어갈 때는 항상 어느 정도 수준까지 개선할 것인지 목표를 잡되, 그 목표가 비현실적이어서는 안된다.

반응형

기본적인 네트워크 전송량 최적화


일반적으로 게임을 제작할 때 최적화라는 요소는 매우 중요하다. 적당한 성능의 컴퓨터에서 훌륭한 퍼포먼스를 보여주는 것은 얼마나 좋은 일인가. 하지만 고사양의 컴퓨터에서도 모자란 퍼포먼스를 보여주게 된다면 그 게임은 유저들에게 상당한 비평을 받게 될 것이다. 그와 마찬가지로 멀티플레이를 지원하는 게임의 경우에는 네트워크 전송량의 최적화가 필요하다. 월정액으로 사용되는 국내 인터넷 환경상 PC 멀티플레이 게임의 경우에는 그 중요성이 조금은 덜하겠지만, 데이터 사용량에 따라 요금이 달라지는 환경이나, 매월 사용할 수 있는 데이터량이 제한되어 있는 3G/LTE 같은 경우에 게임 중에 네트워크 전송량이 너무 많다면 성능이 발적화된 게임만큼이나 많은 유저들의 불만을 불러올 것이 틀림이 없다.


최근에 간단한 네트워크 게임을 프로토타입으로 만들면서 네트워크 전송량을 거의 최적화 하지 않은 상태로 테스트를 한 적이 있었다. 그 때 10분간 진행된 게임으로 무려 24MB나 되는 데이터를 사용했었다. 실시간으로 많은 수의 캐릭터가 움직이는 게임인 것을 감안하더라도 상당히 많은 데이터 소모였다. 그렇다면 이번 섹션에서는 네트워크를 사용하는 게임이 데이터를 과식하지 않도록 네트워크 사용량을 다이어트 하는 방법에 대해서 알아보자.





너무 자주 전송하고 있지는 않은가?


첫 번째로 확인해보아야 할 것은 전송 빈도가 너무 짧지 않은가 하는 것이다.


using UnityEngine.Networking;

[NetworkSettings(channel = 0, sendInterval = 0.1f)]
public class Player : NetworkBehaviour
{
    // Player 클래스 코드
}


유니티 네트워크의 네트워크 매니저에 의해 관리되는 NetworkBehaviour를 상속받는 모든 클래스는 NetworkSetting이라는 어트리뷰트를 통해서 동기화나 원격액션을 보내는 채널과 전송 빈도를 설정할 수 있는데, 기본적으로 이 전송 빈도는 0.1초당 한 번씩 전송되게 되어 있다. 0.1초라는 기본값은 얼핏 보기에는 나쁘지 않은 빈도로 보이는데, 여기서 사람의 욕심이 모든 문제를 발생시킨다.


1초에 10번을 전송하는 것은, 캐릭터의 상태나 체력, 공격력 같은 스탯을 전송하는데에는 나쁘지 않은 값이지만, 위치 동기화에는 조금 부족해 보일 것이다. 유니티 네트워크에서 제공하는 기본 NetworkTransform 클래스를 사용해보면 기본 전송 빈도가 초당 9회로 설정되어 있는데, 테스트를 해보면 동기화를 해주는 측에서는 부드러운 움직임을 보이겠지만, 동기화를 받는 측에서는 움직임이 뚝뚝 끊어져서 보이게 될 것이다. 그리고 기본 NetworkTransform에는 최대 전송 빈도가 초당 29회로 제한되어 있는데 이것 역시 테스트를 해보면 움직임이 미세하게 끊어져서 보이는 것을 확인할 수 있다.


이러한 문제를 해결하기 위해서, 단순한 해결책을 동원하게 되는데, 그것이 바로 전송 빈도를 매우 짧게 잡는 것이다. 초당 30프레임을 맞추기 위해서 sendInterval을 0.03333초로 잡거나, 더 과한 욕심으로 초당 60프레임의 동기화를 하기 위해 0.01666초로 맞추게 되는 것이다.


단순하게 계산해봐도, 0.1초의 전송 빈도에 비해서 0.03333초는 3배, 0.01666초는 6배로 네트워크 전송량이 증가하게 되는 것이다. 일반적으로 위치 동기화에는 Vector3 타입의 변수를 사용하게 되는데 4byte인 float 타입의 x, y, z 3개의 변수가 들어 있으니 1회 전송에 최소 12byte가 사용되는 것이니, 초당 120byte(사실 이것도 많은 편이다)면 되는 것이 360byte, 1080바이트까지 늘어나게 되는 것이다. 무려 1초에 1KB나 되는 데이터를 소모하게 된다.


무작정 전송 빈도를 짧게 설정하는 것은 좋지 않은 선택이라는 것을 알 수 있다. 그리고 전송 빈도의 마지노선인 30프레임도 아무런 처리 없이는 움직임이 미세하게 끊어져 보이는 현상이 있다.


그렇다면 전송 빈도를 길게 하면서 움직임을 부드럽게 하는 방법은 무엇인가?





추측항법(Dead Reckoning)


첫 번째로 제시되는 방법은 추측항법이다. 추측항법이란 최근에 확인한 실제 위치에 현재 움직이는 방향과 속력, 즉 속도를 이용해서 현재 위치를 추정해서 움직이는 것이다. 서버에선 새로 동기화 되어야할 위치와 속도(움직이는 방향과 속도)를 전송해주고 클라이언트에선 위치를 적용한 뒤 다음 동기화가 오기 전까지 오브젝트를 속도에 맞춰 이동시켜야 한다.



이렇게 서버가 다음 위치를 보내주기 전까지 그 사이의 움직임을 클라이언트가 계산해서 처리해주기 때문에 비교적 전송되는 텀이 길더라도 부드러운 움직임을 구현할 수 있게 된다. 위의 그림을 보면 첫 번째 빨간 원이 서버로부터 동기화 받은 위치이고 화살표가 오브젝트가 이동하는 방향과 속력을 의미한다. 처음 위치와 이동할 방향과 속력을 알고 있다면 클라이언트는 오브젝트가 이동해야할 위치를 알 수 있기 때문에 동기화 신호가 오지 않은 구간에서 클라이언트가 오브젝트를 이동시켜서 부드럽게 움직이는 것처럼 보이게 만든다.


이 방법의 경우에는, 처음 위치와 속도가 정확하게 동기화되었다면, 서버와 클라이언트 양쪽에 존재하는 오브젝트의 위치가 매우 정확한 수준으로 동기화 될 수 있다는 장점이 있다.


하지만 단점도 있는데, 이 섹션이 네트워크 전송량 최적화라는 것을 생각해본다면, 일반 위치 동기화와 같은 전송 빈도라고 비교했을 때, 더 많은 데이터를 소모한다는 것이다. 일반 위치 동기화라면 위치, 즉 Vector3 하나만 전송하면 되지만, 추측항법은 위치와 속도, Vector3 두 개를 전송해야 한다. 데이터 소모가 2배로 늘어난다는 뜻이다. 그렇기 때문에 추측항법을 사용하기 위해서는 일반 위치 동기화를 사용할 때와 비교해서 적절한 수준의 전송 빈도를 잘 계산해서 사용해야만 한다.





보간법(Interpolation)


네트워크 전송량을 줄이면서 부드러운 움직임을 보이는 두 번째 방법은 보간법이다. 보간법이란 두 위치 사이의 비어있는 위치를 알고 있는 두 위치를 이용하여 채워넣는 것이다. 일반적으로 두 위치 사이를 직선으로 채워넣는 선형 보간법이 사용된다.


보간법의 경우, 다음 이동할 위치를 받은 즉시 오브젝트를 그 위치로 이동시키지 않고 그 다음 위치 동기화가 오는 시간동안 현재 위치에서 동기화 받은 위치로 Lerp를 통해서 이동시킨다. 이동시키는 도중이나 그 다음에 다음 위치가 동기화 된다면 다시 현재 있는 위치에서부터 새로 동기화 받은 위치를 향해서 Lerp를 시키는 것이다.


보간법은 일반 위치 동기화와 같이 동기화할 위치만 전송하면 되기 때문에 전송 빈도만 일반 위치 동기화보다 길게 잡으면 데이터 전송량이 쉽게 줄어든다는 장점이 있지만, 보간법의 경우에는 서버가 위치를 알려주면 클라이언트의 오브젝트가 그 위치를 뒤늦게 따라가는 방식이기 때문에 서버와 클라이언트 간의 오브젝트의 실제 위치가 차이가 발생할 수 있다.





불필요한 데이터를 전송하고 있지는 않은가?


전송 빈도 다음으로 살펴볼 것은 정말로 필요한 데이터만 전송하고 있는가다. 가장 많이 동기화 되는게 위치 동기화기 때문에 이번에도 예시는 위치 동기화를 위주로 하게 될 것이다.


위치 동기화의 경우에 Vector3를 기본으로 사용한다는 것은 앞의 파트에서도 이야기했었다. Vector3라면 x, y, z 값 float 3개를 가지기 때문에 최소 12바이트를 전송하게 되는 것도 이야기를 했다. 그렇다면 만약 게임이 높낮이 없이 평면 상에서만 움직이는 게임이라면 과연 Vecter3를 이용해서 x, y, z의 모든 좌표를 동기화해야만 하는 것일까? 아니다. 높이 값이 필요없다면 그것을 Vector3를 이용하지 않고 Vector2를 이용해서 전송하는 것만으로도 단순 계산으로 데이터 전송량의 33%를 감소시킬 수 있다.


using UnityEngine;
using UnityEngine.Networking;

public class Player : NetworkBehaviour
{
    [SyncVar(hook = "ChangePosVect3")]
    Vector3 posV3;
    void ChangePosVect3(Vector3 pos)
    {
        posV3 = pos;
        transform.position = posV3;
    }

    [SyncVar(hook = "ChangePosVect2")]
    Vector2 posV2;
    void ChangePosVect2(Vector2 pos)
    {
        posV2 = pos;
        transform.position = new Vector3(pos.x, 0f, pos.y);
    }

    public void SyncPos()
    {
        posV3 = transform.position;
        posV2 = new Vector2(transform.position.x, transform.position.z);
    }
}


위의 코드는 Vector3를 이용하여 위치 동기화를 할 때와 Vector2를 이용하여 위치 동기화를 할 때의 차이를 보여준다. 분명 Vector3를 이용하여 동기화를 할 때에 비해서 무언가 처리해야할 것이 늘어나는 것은 사실이지만, 네트워크 최적화라는 것이 원래 네트워크의 부담을 줄이기 위해 그 부담을 서버나 클라이언트로 옮기는 것이다.


33% 감소의 효율을 보여주는 Vector2를 이용하는 위치 동기화만으로는 아직 만족스럽지 못할 수도 있다. 그렇다면 보다 좀 더 극단적인 효율을 보여주는 부분이 있는데, 바로 Rotation 동기화다.


만약 탑뷰 시점의 캐릭터가 마우스 방향을 바라보는 게임을 만든다고 가정해보자. 일반적으로 로테이션 동기화에는 유니티에서는 Quaternion 타입이 사용되는데 Quaternion 타입은 x, y, z, w로 무려 float 4개로 한 번 동기화 하는데 16byte가 사용된다. 탑뷰 시점에서 캐릭터가 마우스 방향으로 바라본다고 하면 y축의 각도만 전송하면 된다는 것을 생각해봤을 때, 무려 12byte가 낭비되고 있는 것이다.


using UnityEngine;
using UnityEngine.Networking;

public class Player : NetworkBehaviour
{
    [SyncVar(hook = "ChangeRotQuat")]
    Quaternion rotQuat;
    void ChangeRotQuat(Quaternion rot)
    {
        rotQuat = rot;
        transform.rotation = rotQuat;
    }

    [SyncVar(hook = "ChangeRotfloat")]
    float rotY;
    void ChangeRotfloat(float rot)
    {
        rotY = rot;
        transform.rotation = Quaternion.Euler(0f, rotY, 0f);
    }

    public void SyncRot()
    {
        rotQuat = transform.rotation;
        rotY = transform.rotation.eulerAngles.y;
    }
}


Quaternion을 사용하는 로테이션 동기화를 float 하나로 변경하는 것만으로도 데이터 사용량을 75%를 줄일 수 있게 된다.


이렇게 네트워크 통신에서 필요하지 않은 데이터를 배제하는 것만으로도 상당한 양의 네트워크 전송량을 감소시킬 수 있다.





그 외의 방법


위에서 언급한 방법 이 외에도 여러 가지의 아이디어나 테크닉이 있을 수 있다. 간단하게 예를 들자면 좌표(좌표를 하나의 변수에 압축해서 넣을 경우에는 일정 수준의 정밀도를 포기해야 한다)나 캐릭터의 여러 스탯을 하나의 변수에 압축하여 전송한 뒤 다시 분할해서 사용하는 방법을 사용하려 전송량을 감소시킬 수 있다.

반응형
  1. su 2018.09.03 16:26

    안녕하세요 유니티 networkmanager가 같은 네트워크 대역끼리만 접속가능한가요? 외부 ip로 접속하고싶은데 방법을 잘 몰라서요.

    • wergia 2018.09.04 10:00 신고

      Unet은 멀티플레이 게임 네트워크를 위해서 만들어진 기능이기 때문에 외부 ip에서도 충분히 접속 가능합니다.

  2. su 2018.09.05 09:47

    답변 감사합니다. 리눅스 서버에서 서버기능만하는 거를 돌리고싶은데 어떻게 하는지 아시나요? 리눅스로 빌드를 했더니 x86_64 파일이 나와서 서버에서 실행했는데 잘 안되서요.

    • wergia 2018.09.06 10:37 신고

      어떤 방식으로 구현하셔서 빌드했는지를 모르니 어떠한 이유로 실행이 잘 안되는지 자세히 설명드리기 어렵습니다.

      일단 서버 세션의 경우에는 실행하면 자동으로 세팅하고 서버를 시작하고 네트워크 처리를 할 수 있게 플래그 같은 걸로 나눠서 코드를 작성해두셨나요?

  3. hoho 2018.11.30 20:55

    안녕하세요, 글 보면서 대단히 도움 많이 받고있어요!

    한가지 궁금한것이 있는데요,
    작성자님께서는 NetworkTransform 컴포넌트를 사용하지 않고 동기화하고 계신것으로 이해해도 되는걸까요?

    만약 그렇다면, unet자체의 networktransform 컴포넌트를 사용하는것에 비하여 어떤 장단점이 있을까요?

    예상해보기로는,
    단점은 직접 위치정보를 쏴주고 받아서 처리하는과정을 직접해주어야되는것이고,
    장점은 보간등의 커스텀 처리가 가능할 것 같은데요...

    저는 현재 networktransform을 사용하여 위치를 동기화하고있는데, 뚝뚝 끊어짐을 해결하기위해 보간처리를 커스텀하기가 어려워서요 ㅠㅠ

    궁금한 부분을 정리하자면..
    1. 보간을 적용하려면, 직접 보간개념을 적용한 위치동기화 전송과정을 구현해야하는것인가?
    2. 그게 아니라면 네트워크 트랜스폼 컴포넌트를 사용하면서 보간을 추가적으로 처리할수 있는가?

    입니다.

    도움이 절실합니다~ ㅠ_ㅠ


    • wergia 2018.11.30 21:41 신고

      위치동기화는 모든 게임에 똑같은 방식으로 만들어지는게 아니고 게임의 특성에 따라달라질 수 있는 것이라, 저는 UNet에서 제공하는 NetworkTransform은 잘 사용하지 않는 편입니다.

      커스텀 위치동기화 클래스를 만드신다면 클라이언트 측에서 보간하는 과정을 직접 구현하셔야 합니다.

      UNet에서 제공하는 Network Transform의 경우 자세히는 모르지만 Interpolate Move Factor라는 옵션이 있습니다. 이부분이 내간법을 이용해서 보간처리를 해주는 옵션으로 추측됩니다.

+ Recent posts