게임 개발을 하다보면 게임 내에 각종 데이터들이 들어가게 된다.

 

이 데이터들을 csv, json, binary 각각 어떤 방식으로 사용하는 게 효율적일지 궁금하여 테스트해보았고, 이 결과를 정리해보고자 한다.

 

 

예를 들면 스토리 대사 같은 데이터들이 필요할 수 있다. 보통 이런 건 구글 스프레드 시트에서 데이터를 작성, 관리하여 파일로 변환해 유니티로 가져와 사용한다.

 

 

위 내용을 csv로 저장하면 이런 식으로 저장된다. 쉼표로 데이터들이 구분되며 줄줄이 나열되는 방식이다.

 

 

위 데이터들을 코드로 나타내면 이런 식으로 나타낼 수 있을 것이다. (밑의 Synchronize 부분은 신경쓰지 않아도 괜찮다)

 

 

이 데이터를 json으로 Serialize하면 이런 느낌으로 저장된다.

 

각 변수들의 이름과 값을 저장하고, 나중에 읽을 때 이름이 같은 걸 찾아서 해당 값을 가져오는 방식이다.

 

 

위에 있는 함수는 Json으로 변환하는 함수인데, 매우 간편하다. Newtonsoft.Json을 활용하면 List 데이터도 손쉽게 Serialize 가능하다.

 

아래 함수는 Binary로 데이터를 기록하기 위한 함수인데, 말 그대로 Binary로 저장하는 거다보니 파일을 직접 열어서 읽기는 어렵다.

 

 

직접 파일을 열어보면 대략 이런 느낌이다.

 

아무튼 각 함수들은 매우 간단하고 단순하게 csv, json, byte 파일로 각각 저장하게 만들어놨다.

 

 

CSV를 읽는 함수를 먼저 만들었는데, 빠른 테스트를 위해 대충 만들었는데 일단 csv 파일의 모든 라인을 읽고, 쉼표로 분리해서 각 데이터를 parsing하고 DialogData로 만들어서 저장하는 방식이다.

 

 

Json을 사용하면 로딩도 매우 간편하다.

 

 

Binary는 이런 식으로 CSV와 비슷하게 읽어오도록 했다. 주의할 점은 읽을 때 데이터가 얼마나 들어있는지 모르기 때문에 쓸 때 미리 몇 개의 데이터가 있는지를 저장해놔야 한다는 점이다. Write와 Read를 동일한 방식으로 만들어주면 어렵지 않게 Binary 형식으로 읽고 쓸 수 있다.

 

 

자 이제 측정의 시간이다. Stopwatch로 단순히 10000번 반복하여 각각에 걸리는 시간을 측정해보았다.

 

 

결과는 Binary > CSV > Json 순서로 소요시간이 짧았다.

 

 

심지어 저장 용량도 Binary > CSV > Json 순으로 작았다.

 

결론은 Binary가 가장 적합한 것으로 보인다. 다만 테이블을 바로 Binary로 가져올 수는 없으니 구글 스프레드 시트에서 테이블을 관리하고, CSV로 받아와야 할 것이다. CSV도 속도 측면에서 큰 차이는 없으니 빌드해서 나갈 때 정도만 Binary 파일로 바꿔서 나가기만 해도 괜찮지 않을까 싶다.

유니티로 모바일 게임을 개발하다보면 메모리 용량을 확보해야 하는 상황이 온다. (여기서 말하는 메모리는 RAM이다) 이뿐만 아니라, 디스크 용량 확보도 중요한 요소이다. 게임의 용량이 작은 것을 유저들이 더 선호하기도 하며, 다운로드 용량이 클수록 부담해야 하는 비용도 늘어나기 때문이다.

 

 

일례로, AssetBundle 다운로드에 AWS를 많이 사용하게 될 텐데, AWS의 S3 요금제를 살펴보면 위와 같다.

 

많이 사용할수록 가격이 감소하지만, 최저 요금을 기준으로 봐도 1GB당 $0.108로 2024년 10월 27일 현재 환율로 150원 정도의 비용이 들어가는 셈이다.

 

10만명의 유저가 다운로드를 받는다고 가정했을 때 다운로드 용량을 1GB 줄이는 경우, 최저 요금을 기준으로 봐도 $10,800, 현재 환율로 약 1500만원을 절약할 수 있다.

 

특히 모바일 게임은 업데이트를 자주 하기 때문에 다운로드 용량을 줄이면 지속적으로 비용을 절감하는데 크게 도움이 될 것이다.

 

에셋 최적화는 사실 대부분 압축인데, 아주 조금일지라도 퀄리티 저하가 발생할 수 있다.

그러나 최적화는 불가피하게 해야 하는 경우가 많기 때문에 사람이 인지하지 못할 정도의 퀄리티 저하를 감수하면서 용량을 적당히 줄일 수도 있고, 인지 가능한 정도의 퀄리티 저하를 감수하며 용량을 크게 줄일 수도 있다.

 

아트팀에서는 이런 퀄리티 저하를 못마땅하게 여길 가능성이 높기 때문에, 최적화로 인한 퀄리티의 변화에 대해 어느정도 선까지 최적화를 해도 괜찮을지 아트팀과 협의를 할 필요가 있을 것이다.

 

나는 현재 참여 중인 프로젝트에서 에셋 최적화/압축을 통해 아래와 같은 효과를 볼 수 있었다. (어느 정도는 최적화가 되어있던 프로젝트이다.)

 

초기 다운로드 용량: 2.0GB -> 750MB (63.4% 감소)

전체 번들 용량: 2.54GB -> 1.50GB (41% 감소)

 

https://www.youtube.com/live/52ehLUfk3DQ?si=luUhzZLe4butzuKw

 

아마 대부분 내용이 위 영상에 나와있을 것 같은데, 유니티 코리아 공식 영상이니 위 영상을 참조하면 더 좋을 것이다.

 

일단 생각나는 것들 위주로 적어볼 예정이다. 간단하게 어떤 것들이 있는지 정도만 적을 예정이니 상세한 내용은 직접 찾아보면서 적용하면 될 것이다. 적용 난이도와 효과를 기준으로 주관적인 우선순위를 매겨 순서대로 적어보겠다.

 

==============================================================

 

1. Texture 최적화

 

1-1. ASTC - 텍스처 압축 방식이 굉장히 많은데, 오늘날에는 ASTC 포맷이 제일 좋다는 것 같다. AOS, iOS 가리지 않고 다 쓸 수 있으며 웬만한 구기기가 아니면 다 적용이 되기 때문이다. 특히나 최신 (내가 확인한 것으로는 2022.3 이상 버전) 버전에서는 Alpha 채널을 사용하는지 아닌지도 알아서 판단해서 압축을 해주기 때문에 사용도 엄청나게 간편하다. 특히나 용도에 따라서 압축을 얼마나 할 것인지를 유동적으로 조절할 수도 있기 때문에 ASTC 포맷을 사용하는 것을 추천한다.

 

4x4 block ~ 12x12 block의 포맷이 있는데, 압축할 때 1블럭의 크기를 어느 정도로 할 것인지 정할 수 있는 셈이다. 4x4 block을 했을 때 가장 퀄리티가 좋고, 12x12 block을 했을 때 가장 압축 효율이 좋다.

 

4x4 block의 경우에는 압축을 하지 않았을 때와 퀄리티 차이를 찾기가 어려울 정도로 퀄리티가 좋기 때문에, UI에 표시되는 Sprite의 경우는 4x4 block으로 설정하고, 이외에는 10x10 block으로 일괄 설정을 해놓으면 편리하다. 물론 용도에 따라 더 세분화를 하면 퀄리티와 효율을 더 챙길 수 있을 것이다. 때문에 이런 용도에 따라서 에셋을 폴더 별로 분리해놓으면 관리하기가 매우 수월하다.

 

코드를 하나 만들어서, 전체 파일을 돌면서 jpg, png 등등 파일에 대해서 TextureImporter를 가져오고 세팅해주면 된다. 아마 찾아보면 참고할만한 코드들이 있을 것이다.

 

1-2. Mipmap - Mipmap은 3D 게임에 주로 쓰이는데, 멀리 있는 텍스처에는 저해상도 텍스처를 적용해주는데 사용된다. 이렇게 하면 대역폭도 절약할 수 있고, 고해상도 텍스처를 보여주는 것보다 퀄리티가 오히려 좋게 보일 수도 있다. 단점은 미리 생성을 해놓기 때문에 텍스처 크기가 33% 늘어난다는 것이다. 때문에 UI에 사용되는 경우와 같이 텍스처를 멀리서 보여줄 일이 없다면 꺼놓는 것이 메모리와 디스크 용량을 절약할 수 있는 방법이다.

 

1-3.  Read/Write Enabled - 텍스처를 코드 상에서 읽고 쓰며 변경해준다면 필요할 수 있는 기능이지만, 대부분의 경우는 필요없을 것이다. 이게 켜지게 되면 GPU 메모리뿐만 아니라 CPU 메모리에도 텍스처가 할당되어 메모리 용량을 2배 차지하게 된다. 아마 폰트를 Dynamic으로 설정한 경우, 읽고 쓰는 기능이 필요하여 이 기능이 자동으로 켜지고, 때문에 메모리 용량을 2배로 차지하는 것 같다. (때문에 폰트 Texture에서는 켜고 끄는 기능을 지원하지 않는 것 같고, 아마 Static으로 해놓는다면 용량을 2배로 차지하지 않을 것 같다.)

 

==============================================================

 

2. Audio 최적화

 

2-1. 오디오 압축: Compression Format을 Vorbis로 하고, Quality를 40 정도로 하면 용량은 크게 줄어들지만 오디오 퀄리티에는 크게 영향이 없다. (아마 경우에 따라 다를 것이다) 좋은 퀄리티로 들려줘야 할 사운드가 있다면 따로 설정해놔도 좋을 것이다. Load type은 대부분 Decompress On Load로 충분한 것 같다. 메모리를 상대적으로 많이 차지하기는 하지만, 다른 옵션의 경우 압축을 지속적으로 해제하면서 사용할 거라 CPU에 약간 부하를 줄 수 있을 것이다. 메모리가 부족한 게 아니라면 Decompress On Load로 해도 괜찮을 듯 하고 메모리를 줄여야 한다면 용량이 큰 BGM의 경우 Streaming으로 설정해주면 좋을 것 같다. 다른 옵션들은 압축을 해제하는데 시간이 약간 걸려서 사운드 타이밍이 중요한 효과음의 경우는 용량도 작으니 Decompress On Load를 사용하는 것이 좋겠다.

 

==============================================================

 

3. Mesh 최적화

 

3-1. Mesh 압축: Mesh 압축에는 2가지 방식이 있는데, Vertex Compression과 Mesh Compression이다. Vertex Compression은 전역적으로 적용되는 Mesh 압축인데, Player Settings에서 설정이 가능하고, 기본적으로 켜져있다. 유니티에서 권장하는 압축 방식이 버텍스 압축이다. 아마 실제로 버텍스의 수를 줄이는 것 같아서 GPU 성능이 약간 향상될 수도 있다는 것 같다. 압축 효율은 경우에 따라 다르지만 대략 30% 정도 줄어드는 것 같다. 단, Vertex Compression은 제한 사항이 많은데, Mesh가 Skinned Mesh면 적용이 안 되고(Skinned Mesh Renderer에서 사용되는 경우) Read/Write Enabled가 켜져있으면 안 되는 등의 조건이 있다. (자세한 내용은 유니티 Documentation 참고) 또, Mesh에서 직접 Mesh Compression을 설정하는 경우에도 적용되지 않는다.

 

Mesh Compression은 Mesh에서 직접 설정을 하는데, Low, Medium, High 3가지 옵션이 있다. Skinned Mesh거나 Read/Write Enabled가 켜져있어도 적용되는 것 같다. 대략 Low일 때 47% 정도 감소, Medium일 때 53% 정도 감소, High일 때 60% 정도 감소? 대략 이 정도로 용량이 줄어들었던 것 같다. 메시에 따라 다르게 적용될 수 있으니 대략적인 경향만 참고하면 좋을 것 같다.

 

Mesh Compression은 메모리에는 영향을 끼치지 않는다고 되어있다. 찾아보면 메모리 용량도 줄었다는 얘기가 있는데 실제로 어떠한지는 모르겠다. 다만 얘기로는 데이터 상에서 압축을 해놓고, 사용할 때 압축을 해제하는 방식이라는 듯. 때문에 최초 로딩 시에 시간이 소요될 수 있다고 하는데 딱히 체감할 정도는 아니었다. 압축 정도가 높을수록 모델 퀄리티에 영향을 주는데, High로 압축하는 경우 Vertex가 많은 부분, 특히 캐릭터의 입술이 부분이 다소 일그러지는 모습을 볼 수 있었다. Medium으로 압축해도 High와 용량에는 큰 차이가 없고, Medium의 경우에는 압축을 하지 않은 경우와 퀄리티 차이가 크게 없었기 때문에 Medium으로 일괄 압축을 적용한 상태이다.

 

Mesh Compression을 변경하는 경우, 모델을 다시 Import하기 때문에 에디터에서 시간이 많이 소요될 수 있다는 점을 참고하길 바란다. (대략 2만개의 모델에서 1~2시간 정도 소요되었던 것 같다.)

 

==============================================================

 

4. Animation 최적화

 

4-1. Animation 압축: 이거는 찾아보다가 나왔는데 이미 적용이 되어있어서 효과는 잘 모르겠지만, 애니메이션도 압축이 가능하다는 것 같다.

 

Animation Compression in Unity - techarthub

 

Animation Compression in Unity - techarthub

An in-depth look at Unity's available animation optimization methods with some practical examples thrown in.

techarthub.com

 

위 링크를 참조하거나 다른 내용을 찾아서 적용해보면 좋을 것이다.

 

==============================================================

 

5. Dependencies 관련 최적화

 

5-1. 불필요한 Dependencies 제거하기: 예를 들면... (우리 프로젝트의 예시지만) 대부분 번들에서 Shader 번들을 참조하는데, Shader 번들에 누군가가 만들어놓은 Material이 Effect 번들의 Texture를 참조하고 있고, 이 Effect 번들에서는 각종 몬스터의 Texture를 참조하고 있다고 하면, 대부분 번들에서 Shader - Effect - 각 Monster들에 대한 Dependencies를 갖게 되고, 이건 불필요한 참조 관계 데이터를 저장하게 될 뿐 아니라, 로드할 때도 굉장히 비효율적이게 될 것이다. (Dependencies가 있는 번들들을 미리 로드할 테니 말이다.) 이 부분은 우리의 경우 약간의 에셋 중복이 발생하더라도 복제해서 각 번들에 넣어주는 방식으로 해결했다.

 

5-2. 많은 번들에서 참조하는 에셋을 Bundle에 포함시켜주기: 특정한 에셋에 대해, 번들이 지정되어 있지 않은 경우, 이 에셋에 Dependency를 가지는 번들에 복사되어 들어간다. 그런데 이 에셋이 용량이 큰 경우거나 많은 번들에서 참조하는 경우에 문제가 발생할 수 있다. 예를 들어서 1MB짜리 텍스처가 있다고 치고, 번들 A, B, C, D, E, F, G 총 7개의 번들에서 이 텍스처를 사용한다고 하면, 각각의 번들에 하나씩 들어가기 때문에 7MB만큼 디스크 용량이 늘어나게 될 것이다. 또, 이 번들들에 들어간 텍스처는 각기 다른 에셋으로 취급되기 때문에 이전에 다루었던 메모리 중복도 발생할 수 있다. 그러나 이 1MB짜리 텍스처를 H라는 번들에 명시적으로 포함시켜주면, A B C D E F G 각 번들은 H 번들에 대한 Dependency를 갖게 되고, 디스크에서 차지하는 용량은 1MB가 되며 메모리 상에서 중복도 일어나지 않을 것이다.

 

==============================================================

 

6. 불필요한 Asset 제외

 

이건 굉장히 막연한 부분이지만, 가장 확실한 방법이기도 하다. 일례로 내가 참여하고 있는 프로젝트에서 용량이 큰 번들들을 하나씩 체크하고 있었는데, 개당 5.3MB에 달하는 bmp 파일이 디스크 상에서 총 약 800MB 가량을 차지하고 있었다. 맵 관련 텍스처로 보여서 맵 담당하시는 분께 여쭤보니 splat 맵이라고, 맵 만들 때 사용하시는 툴에서 데이터를 뽑아서 유니티에 옮길 때 1번만 사용하는 파일이라고 하셨다. 즉, 지워도 된다는 말이다. 아직 서비스 전이니 굳이 말하자면 문제는 아니었지만, 이런식으로 눈 먼 파일들이 게임에 포함되는 경우가 꽤 많을 것 같고, 아직도 있을 것 같다. 하나하나 체크하기가 힘들어서 그렇지. ㅋㅋ... 용량이 큰 번들들을 확인하면서 용량이 큰 불필요한 파일들을 걸러주는 것도 꽤나 큰 효과를 볼 수 있을 것이다.

 

==============================================================

 

7. Shader Variant Stripping

 

이건 메모리 용량을 줄이는 데에도 아주 효과적인데, 알고보니 메모리 용량이 줄어드는 만큼 디스크 용량도 줄일 수 있는 방법이었다. Shader 용량이 총 350MB 정도 된다고 쳤을 때, Shader Variant Stripping을 하면 메모리 용량과 더불어 디스크 용량도 300MB 가량 확보할 수 있는, 매우매우 훌륭하고 좋은 방법이다.

 

단, 단점이 있다면 적용하기가 상당히 까다롭고 위험성이 존재한다는 점이다.

 

Shader Variant Stripping에 대해 간단히 설명하자면 Shader에서 Material을 어떻게 보여줄 것인지 결정하는데, 이때 Keyword라는 것이 사용된다. 이 Keyword의 조합에 따라 Shader Variant 라는 게 만들어지는데, 사용되지 않는 Shader Variant들이 워낙에 많기 때문에 이걸 제거해줌으로써 최적화를 하는 방식이다.

 

Shader Variant Stripping을 하는 방식은 크게 2가지가 있을 것 같은데, 1번째는 "사용하지 않는 키워드가 포함된 Shader Variant들을 제거하는 방식"이다. 다만 사용하지 않는 키워드를 사용하게 되었을 때 적절히 대응해주지 않으면 Material들이 깨져보일 수 있다는 점이나, 여전히 사용하지 않는 Shader Variant들이 다수 포함될 수 있다는 점이 문제이다. 2번째는 "사용하는 Shader Variant를 제외한 Shader Variant들을 제거하는 방식"이다. 유니티 2022부터 제공하는 Strict Shader Variant Matching 기능을 사용하면 이전에 비해 다소 쉽게 적용할 수 있지만, 문제가 있다면 사용되는 Shader Variant들을 직접 수집해야 한다는 점이고, 이 과정에서 누락되는 경우 Material들이 깨져보일 수 있다는 점이다. Material이 깨지는 경우, 아예 투명하게 안 보이는 경우도 있고 마젠타 색으로 보이는 경우도 있다.

 

우리는 2번째 방식을 사용하는 중인데, 덕분에 Shader 용량은 90% 이상 줄어들었지만, 지금도 종종 미수집된 Shader Variant들이 발견되기도 하며, 모바일 게임이다보니 업데이트가 종종 있을 것이고, 이때마다 새로운 Shader Variant들이 추가될 가능성이 높은데, 이걸 완벽히 수집할 수 있을까? 라는 걱정이 있다.(지금은... 일례로 어떤 직업에 빛나는 구체를 날리는 스킬이 추가되었을 때, 이 구체가 Shader Variant에 영향을 준다면, 모든 맵에서 이 스킬을 사용해보지 않는 이상 모든 Shader Variant가 수집될 것이라는 보장이 없다. 업데이트가 없는 경우라면 꼼꼼한 수집 + 정기적인 업데이트 적용으로 누락된 Shader Variant들을 추가해주면 어느정도 해결이 되겠지만, 지속적으로 추가되는 경우에는 문제가 될 수 있을 것 같다. 이전에 모든 Material들을 로드해서 수집하는 방식을 사용하기도 했었는데, 이게 맵에 따라서나 주변 구조물에 따라서 등 동적으로 변경되는 경우가 상당히 많아서 완벽한 수집이 어려웠다. 만약에 나중에 처음부터 새로 개발을 할 수 있다면, 이렇게 동적으로 바뀔 수 있는 Keyword에 대해서 컨트롤할 수 있으면 좋겠다는 생각이 든다. 지금은 현실적으로 어려운 상황이지만 개발 초기부터 TA와의 긴밀한 협력을 통해 모든 multi_compile 키워드에 대해 관리를 할 수 있으면 Shader Variant Stripping을 더 안전하고 효과적으로 할 수 있지 않을까? 라는 생각이 든다.

최근에 Android SDK 버전 지원 문제로 프로젝트의 Unity 버전을 업그레이드 하였다.

(예로 2020.1 버전은 API level 30까지만 지원, 2022.3 버전은 현재 API level 35까지 지원 중)

 

그러면서 C# 버전 관련해서도 테스트를 하며 잠시 변경이 있었는데, nullable을 활용해서 조금 더 효율적이고 안전하게 개발할 수 있는 방법이 있는 듯 하여 정리해보기로 했다.

 

 

개발을 하다보면 필연적으로, 자주 만나게 되는 오류가 있는데 바로 NullReferenceException이다.

 

 

line 14와 line 27을 확인해보면, Test2함수를 실행할 때 null을 넘겨줬는데 내부에서 null  체크를 하지 않고 classA라는 객체에 접근했기 때문에 NullReferenceException이 발생한 것이다.

 

Test 함수에서는 classA가 null이 아닌 경우에만 classA에 접근했기 때문에 이런 문제가 발생하지 않는다.

 

코드를 읽어보면 NullReferenceException이 발생할 수 있다는 것을 알 수 있지만, 컴파일러가 경고를 표시해주지는 않고 있다. 때문에 실수가 발생할 여지가 있다.

 

개발 규모가 작다면 null을 다루는 것이 크게 어렵지 않을 수 있다. 어떤 객체가 null일지 아닌지 알기가 쉬우니까. 그러나 개발 규모가 커지고, 개발에 참여하는 인원이 많아지면 본인이 아닌 다른 사람들이 작업한 코드를 활용할 일이 많아지고, 이 경우 null 관련해서 문제가 발생할 가능성이 높아진다.

 

예로 몬스터의 이름을 알고 싶은데, Monster.GetName()이라는 Method가 있어서 사용했다고 치자.

monster가 null이 아니라는 걸 체크하고 사용해서 NullReferenceException이 발생하지 않을 줄 알았는데, 다른 개발자가 Monster가 죽을 때 이름이 필요없어질 거라고 생각하고 이때 name에 null을 할당해버린 사실을 몰랐다면 NullReferenceException이 발생할 수 있다.

 

위는 단순한 예이고, 개발을 하면서 이와 같이 null 체크와 관련해서 내적 갈등이 생기는 경우가 꽤 있다.

 

여기서 null 체크를 해야 하는가? 하지 않아도 되는가? 와 같은 갈등이다.

 

null이 올 수 있거나, 혹여나 null이 와서 크리티컬한 문제가 발생할 수 있다면 당연히 null 체크를 해줘야 하지만, 구조상 null이 오면 안 되는 곳도 있기 때문에 이런 곳에서까지 null 체크를 해줘야 하는가? 와 같은 생각을 하곤 한다.

 

C# 8.0부터 제공하는 Nullable 기능을 사용하면 이런 고민을 크게 줄일 수 있다.

 

(블루 아카이브의 자동 제조 기능이 다소 아쉬운 점이 있어서 테이블 읽기도 직접 구현해보고, 자동 제조를 내가 만든다면 어떻게 만들었을까 싶어서 해보면 재밌겠다는 생각 중이다)

 

위와 같이 프로젝트 루트 폴더에 Directory.Build.props 라는 파일을 만들고

 

<Project>
    <PropertyGroup>
        <Nullable>enable</Nullable>
    </PropertyGroup>
</Project>

 

내용을 위와 같이 채워넣으면 Nullable 기능을 활용할 수 있다.

 

 

이러면 스크립트에서 아까와는 다르게 노란색 경고 표시가 나타난다.

 

 

경고를 살펴보면 위와 같다.

 

위는 nullable로 지정되어 있지 않은데 null을 사용해서 경고를 표시하는 것이고,

아래는 nullable로 지정되어 있지 않은데 null 체크를 하려고 해서 경고를 표시하는 것이다.

 

 

Test 코드는 매개변수 타입을 ClassA -> ClassA? 로 수정하여 ClassA가 null일 수 있다는 것을 명시적으로 표시하여 경고가 사라졌다.

 

 

이로 인해 Test 함수의 경고가 사라졌는데, Test2의 경우도 매개변수가 null일 수 있다는 것을 명시적으로 표시하였지만 이 경우 내부에서 null체크 없이 classA에 접근하려고 하면 null일 수 있다는 경고를 표시한다.

 

 

위와 같이

<WarningsAsErrors>Nullable</WarningsAsErrors>

 

를 사용하면 Nullable 경고를 컴파일 에러로 변경할 수 있다.

 

 

그러면 위와 같이 nullable 객체에 대해서 null 검사를 하지 않으면 컴파일 에러가 나타나게 된다.

 

현재 참여 중인 프로젝트에서는 이미 개발이 많이 진행되어 변경할 사항이 많아서 도입하긴 어렵겠지만, 나중에 개발을 시작할 때 Nullable을 사용하도록 하면 개발할 때 좋을 것 같다.

Unity로 모바일 게임을 개발하다보면 메모리가 부족해지는 경우가 생긴다.

 

메모리 관리에 대한 탐구 (5) - 메모리 프로파일러를 통한 메모리 사용량 분석 :: 메피카타츠의 블로그 (tistory.com)

 

메모리 관리에 대한 탐구 (5) - 메모리 프로파일러를 통한 메모리 사용량 분석

오늘은 유니티에서 Package Manager를 통해서 제공하는 Memory Profiler에 대해서 소개할 예정이다. 유니티 2021에서 제공하는 0.7.1 버전을 기준으로 소개를 할 것인데, 현재는 1.0도 나와있다. UI나 세부적

mepkatatsu.tistory.com

 

이전에 언급했던 메모리 프로파일러를 사용하면 메모리에서 오브젝트들이 차지하고 있는 용량을 확인할 수 있다.

 

 

이전에 올렸던 사진 중에서도 최상위에 있는 것이 바로 폰트의 Atlas 텍스쳐로, 무려 각각 32MB씩이나 차지를 하고 있다.

 

다국가 서비스를 하는 경우에 빌드에 다양한 나라의 언어에 대한 폰트가 포함될 수 있고, 이에 따라 용량이 큰 폰트의 수가 늘어날 수밖에 없다.

 

이런 와중에 폰트가 메모리 내에 중복으로 적재되는 경우가 발생하는데, 이 경우에 메모리가 심각하게 부족해져 게임이 튕기게 되는 문제가 발생하곤 한다.

 

폰트 용량을 절감하면 메모리 뿐만 아니라, apk의 빌드 용량도 감소시킬 수 있다.

 

아래는 현재 참여 중인 라이브 프로젝트에서 폰트 용량 절감으로 얻은 효과이다.

 

apk 용량: Mono 빌드 기준 120.5MB -> 84.5MB 로 30% 감소

메모리 내 폰트 용량: 피크 기준 714MB -> 53.2MB로 92.5% 감소 (Objects And Allocations 기준)

전체 메모리 사용량: 피크 기준 1.06GB -> 0.62GB 로 41.5% 감소 (프로파일러 좌측에 나타나는 기기 전체 사용량 기준)

 

메모리 내 폰트 용량 감소와 전체 메모리 사용량 감소에 다소의 차이가 있는데 아마 줄어든 만큼 완전히 줄어드는 게 아니라 어느정도 메모리를 확보해놓는 것 때문에 그런 것 같기도 하다. (전체 메모리 사용량이 0.62GB인데 이중 200MB는 여유 공간이라든가 등)

 

그러면 apk 용량 감소 / 메모리 절감을 위해 확인해야 할 요소들을 살펴보자.

 

=========================================================================

 

1. 폰트 Atlas 용량 줄이기

 

Unity로 개발을 하면서 텍스트를 표시할 때는 TextMeshPro를 주로 사용할 것이다.

 

 

 

TextMeshPro를 사용할 때는 위와 같이 ttf라고 하는 폰트 파일을 TMP(Text Mesh Pro)에서 제공하는 기능을 사용해서 SDF 에셋으로 만들어야 한다.

 

여기서 유심히 봐야 할 것은 아래 2가지이다.

 

Sampling Point Size: 글자를 Atlas에 저장할 사이즈, 클수록 글자가 선명해지지만 Atlas의 용량이 커진다.

Atlas Width & Atlas Height: 글자를 저장할 Atlas의 사이즈, 클수록 용량이 커진다.

 

위의 세팅을 말로 풀어쓰자면

 

DungGeunMo폰트의 글자를 60이라는 크기로 샘플링해서 4096x4096짜리 Atlas에 담아서 저장하겠다. 라는 의미이다.

 

 

그 결과가 이거다. 저만큼의 폰트를 쓰려고 쓸데없이 방대한 양의 Atlas를 생성해놓은 셈이다. (...)

 

이 16MB짜리 텍스쳐는 이유는 잘 모르겠지만 메모리에 올라가면 2배인 32MB로 올라가게 된다.

 

(2024/10/27 추가 - 아마 Dynamic으로 설정해놓으면 Texture에 읽고 쓰는 기능이 필요할 거라 Read/Write 옵션을 자동으로 사용하고, 때문에 CPU, GPU 양쪽 메모리에 적재되어 2배를 차지하는 것으로 추정된다.)

 

 

Atlas Width & Atlas Height를 256x256으로 줄여주면...

 

 

위와 같이 64KB짜리 텍스쳐로 줄어들게 된다.

축하한다! 당신은 16MB짜리 텍스쳐를 64KB로 줄였다. 무려 99.6%나 감소시킨 셈이다.

 

여기서 Sampling Point Size를 문제되지 않는 수준까지 줄이면 더 줄일 수도 있다.

 

(*추가: 단, 주의해야 할 점은 dynamic 폰트를 사용한다면 플레이 중에 새로운 글자를 보여주는 경우 이 텍스쳐에 글자들이 새로 추가된다는 점이다. 그러면 텍스쳐가 여러 장으로 분리되고 각 글자마다 참조하는 텍스쳐가 다른 경우 드로우콜이 늘어날 수 있기 때문에 해당 폰트에 사용될 글자수를 예측하여 적절한 크기로 할당하여 균형을 잡는 것이 중요하다.)

 

 

줄인 이후 asset 용량이 32,831KB -> 146KB로 줄어든 것도 확인할 수 있다.

 

Atlas Population Mode를 Dynamic으로 설정하고, Multi Atlas Textures에 체크한 다음 Atlas의 사이즈를 줄여서 사이즈를 더 줄일 수도 있지만, 이 경우 드로우콜이 늘어나고 오버헤드가 발생할 수 있어 최대 2개 정도로만 쪼개지도록 해놨다. (용량이 큰 폰트만)

 

내가 읽은 설명 상으로는 Dynamic으로 해놓으면 글자를 사용하기 전까지는 Atlas에 그려지지 않는 것으로 알고 있는데, 실제로 메모리를 찍어보니 처음부터 전체 텍스쳐가 한꺼번에 메모리에 올라가 있는 것을 확인할 수 있었다.

 

* Dynamic은 사전에 미리 Character Table에 등록해놓지 않은 경우에도 ttf에서 실시간으로 글자를 불러와서 Atlas에 추가하는 식으로 작동되는 것 같다. (이 때문에 에디터에서 플레이를 하고 저장하면 Font Asset에 계속 변경사항이 생기는 문제가 있었다.) 아예 비워놓는 게 메모리 측면에서는 가장 이득이겠지만, 그러면 모든 글자에 대해 동적인 로딩이 발생할 테니 자주 사용하는 글자 정도는 추가해두는 게 성능과도 타협을 볼 수 있는 방법일 것 같다.

 

* 드로우콜을 줄이기 위해서라도 지속적으로 UI에 나타나는 글자들은 같은 Atlas에 담는 것이 좋을 것 같다. 이를 위해서는 직접 글자들을 수집해서 저장할 수도 있겠고, 아예 비워놓으면 플레이하면서 알아서 첫 Atlas에 저장될 것 같다. (때문에 Atlas 크기를 너무 작지 않게 하는 것이 좋겠다.) 이전에 한글 XXXX자를 미리 생성해서 넣어놓는 방식을 사용하기도 했었는데, 확인해보니 실제로 사용되지 않는 글자들이 많을 뿐더러 Dynamic으로 글자를 가져오는 게 그렇게 느리지도 않은 것 같다. 효율적인 메모리 관리와 배칭을 위해서 Dynamic을 사용해야 하는 경우, Atlas에 최소한의 ASCII 문자들만 넣어놓는 것도 좋은 선택일 것 같다.

 

============================================

 

2. 폰트 중복 제거하기

 

 

메모리 프로파일러로 찍어서 확인을 해보면 위와 같이 같은 폰트의 Atlas가 중복으로 메모리에 올라가 있는 모습을 확인하게 될 수도 있다.

 

이 문제는 몇 가지 경우로 나뉜다.

 

1. 에셋 번들을 빌드할 때 Addressables를 사용하는 중인가?

-> (아마도 내가 아는 선에서는) 폰트 중복 문제를 완벽하게 해결할 수 있을 것이다. 폰트를 앱 빌드할 때 포함되도록 하면 해결된다고 알고 있다.

 

2. 에셋 번들을 빌드할 때 Scriptable Build Pipeline이 아닌 기본 AssetBundle의 Pipeline을 사용하는 중인가?

-> 이전에 언급했던, 번들을 다른 옵션으로 빌드하기 위해 분리해서 빌드하는 경우 Dependencies가 제대로 참조되지 않아서 폰트가 중복될 수 있다.

 

예시)

폰트 데이터가 포함된 Font 번들을 LZ4 형식으로 압축하여 빌드

해당 폰트를 사용하는 A, B, C 번들을 LZMA 형식으로 압축하여 빌드

 

Scriptable Build Pipeline으로 빌드하는 경우 한꺼번에 빌드가 되어서 A -> Font / B -> Font / C -> Font 각각 번들을 잘 참조하지만, 기본 AssetBundle의 Pipeline을 사용하는 경우 Font 번들과 A, B, C 번들이 따로 빌드되기 때문에 (Font) / A(A + Font) / B(B + Font) / C(C + Font) 로 각각 나뉘어 빌드된다.

 

이 경우, Font 번들이 로드된 상태에서 A 번들을 로드하면, A 번들을 로드하면 A 번들은 A 번들을 빌드할 때 포함된 Font 데이터를 참조한다. A 번들 내에 들어있는 Font 데이터는 Font 번들에 있는 Font 데이터와는 별개로 인식되기 때문에 메모리에 중복으로 적재된다. B / C 번들을 로드할 때도 동일한 현상이 발생해서 이 경우 최대 4개의 폰트 데이터 중복 적재가 일어나게 된다.

 

특히나 폰트에 Fallback을 사용해서 32MB짜리 폰트 3개를 묶어서 사용 중이라면 순식간에 300MB에 달하는 메모리 내 중복이 발생하는 것이다.

 

3. Addressables 외에 Scriptable Build Pipeline 혹은 AssetBundle의 Pipeline을 사용하는 중인가?

-> 이 경우 AssetBundle에 포함되는 폰트와, 앱 빌드 시 포함되는 폰트를 하나로 합칠 수 없기 때문에 강제로 분리하여 사용하게 된다. 게임을 시작할 때 AssetBundle을 받기 전 텍스트를 보여줘야 할 텐데, 이때 보여주는 폰트는 앱 빌드 시 포함되는 폰트이고, 이후 AssetBundle에서 로드하여 보여주는 폰트는 AssetBundle에 포함된 폰트라 서로 다른 폰트이다. 폰트 데이터는 씬을 넘어가야 사라지는 것으로 추정되니 AssetBundle에 포함된 폰트를 보여주는 경우 맨 처음 씬과 분리해야 폰트 중복을 막을 수 있을 것이다.

 

============================================

 

[주의해야 할 점]

 

1. TMP Settings를 사용하는 경우 Fallback으로 등록되는 듯 하니 주의하자.

 

 

TMP Settings라는 Asset이 존재한다. Default Font Asset은 기본적으로 어떤 폰트를 사용할 것이냐?에 해당하는 부분인데, 문제는 얘가 모든 폰트에 대해 Fallback으로 지정되는 것으로 추정된다는 점이다.

 

즉, 얘를 앱 빌드할 때 포함시킨다면...

 

Default Font Asset에 들어간 폰트가 앱 빌드에 포함되고, 모든 폰트에 대해서 Fallback으로 지정되기 때문에 에셋 번들에 포함된 폰트만 사용하더라도 항상 앱 빌드에 포함된 폰트가 메모리에 같이 적재되는 현상이 발생한다.

 

이걸 피하기 위해서 Default Font Asset과 Fallback을 지워준 후에 Default Font Asset에 해당하는 폰트들을 각각의 폰트의 Fallback으로 달아주었는데, 지금 생각해보니 TMP Settings를 에셋 번들에 포함시키면 될 것 같기도 하다. (이렇게 해도 앱 빌드에 포함되지 않는다는 전제 하에. 이거는 테스트를 해 봐야 할 것 같다.)

=> 이 방법은 유효하지 않았다. 아마 TMP Settings가 필수적으로 저 위치에 포함되어야 하고, 무조건 앱 빌드에 포함되었던 것으로 기억함. (이것까진 확실하지 않음)

 

2. AssetBundle과 Resources 폴더의 폰트 배치에 주의하자.

 

2-1. Resources 폴더에만 폰트를 넣고 AssetBundle을 빌드하는 경우

 

먼저 Resources 폴더에 있기 때문에 앱을 빌드할 때 포함된다. 그리고 AssetBundle를 빌드할 때도 Resources 폴더의 폰트를 함께 빌드한다. 얘네는 정확히 어떤 식으로 Dependencies가 지정될지 확인은 안 해봤지만, AssetBundle에 명시적으로 포함하는 것이 좋을 것으로 추정된다.

 

2-2. AssetBundle에만 폰트를 넣지만, 빌드에 포함되는 씬에서 해당 폰트를 사용하는 경우

 

AssetBundle에 폰트가 정상적으로 포함되고, 앱 빌드에도 폰트가 들어간다.

 

2-3. AssetBundle과 Resources 폴더에 동일한 폰트를 각각 넣는 경우

 

특정 경우에 문제가 발생할 수 있다.

예를 들어서...

 

1. AssetBundle에서 폰트 A, B, C, D를 사용하고 있다.

2. A의 Fallback으로 B, C, D가 등록되어 있다.

3. 빌드에 포함된 씬에서 폰트 A를 사용하고 있으니 Fallback 폰트인 B, C, D를 Resources 폴더에 따로 넣어준다.

 

위의 경우에 문제가 발생할 수 있다.

이미 에셋 번들에 포함된 A를 앱 빌드할 때 포함시키면서 Fallback 폰트인 B, C, D도 같이 앱 빌드에 포함된다.

그리고 Resources 폴더에 들어있는 B, C, D는 별개의 폰트이기 때문에 앱 빌드에 별도로 포함해서 빌드한다.

 

즉, 폰트 A를 사용하면서 Fallback으로 사용되는 B, C, D는 AssetBundle 폴더에 들어있는 B, C, D이고, Resources 폴더에 넣어준 B, C, D는 실제로는 사용하지 않으면서 앱 빌드에만 포함되는 셈이다. 이 경우 메모리에는 적재되지 않겠지만, apk 빌드 사이즈가 커질 수 있으니 조심해야 한다.

 

이외에도 SDF를 만들 때 필요한 글자만 지정해서 만들 수 있는데, AssetBundle을 받기 전에 보여주는 글자는 어느 정도 정해져 있으니까 해당 글자만 담아서 따로 폰트를 만드는 방식으로 apk빌드 파일을 줄이는 방법도 있을 것이다.

public long SomeMethod(int id)
{
    long newId = id << 32 + id;
    return newId;
}

 

위 식에서 id가 10000이라면 무슨 값이 나올까?

 

 

 

 

long newId = 10000 << 32 + 10000;

 

일테니, 10000을 왼쪽으로 32번 시프트하고, 10000을 더한 값이 들어갈 것이라고 생각할 수 있다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

하지만, 실제로는 비트 연산자의 우선 순위가 더하기 연산자보다 우선순위가 낮기 때문에

 

long newId = 10000 << (32 + 10000);

 

 

결과적으로는 위 식과 같은 식이며, 좌측으로 10032번 시프트를 한 결과가 나오게 된다.

 

이 녀석의 무서운 점은 에러 따위를 띄우지 않는다는 점이다. -_-;; 코드만 보고 id를 32비트만큼 왼쪽으로 옮긴 후 더해서 Unique한 새로운 id를 만드는 역할인가보다 라고 생각하면 큰코를 다칠 수가 있다.

 

비트 연산자를 총 비트의 수보다 더 많이 시프트하면 나오는 결과는 시스템마다 다를 수 있다고 한다.

 

유니티 에디터에서는 이렇게 많은 시프트 연산을 해도 문제가 발생하지 않았고, 아이폰에서도 문제없이 작동했던 것으로 봤다.

 

그런데 안드로이드에서는 간헐적으로 문제가 발생했다. 평소엔 잘 되다가 어떨 때는 문제가 발생하고, 껐다 켜면 괜찮아지기도 하고, 다시 껐다 켜면 이상해지기도 하고(...)

 

원인은 시프트 연산을 너무 많이 해서 비정상적인 값이 출력된 것으로 확인되었다. 디버그를 찍어보니 id가 다른데도 동일한 결과가 나오고 있던 것. (0이 나올 때도 있었고, 마이너스로 큰 값이 나올 때도 있었다.)

 

public long SomeMethod(int id)
{
    long newId = (id << 32) + id;
    return newId;
}

 

때문에 위 식으로 수정했고, 이후 정상적으로 동작하게 되었다.

 

비트 연산자와 다른 연산자를 같이 사용할 때는 우선순위에 주의하도록 하자.

한 줄 요약: Time.realtimeSinceStartup은 정확도가 떨어지니 Time.unscaledTime을 사용합시다.

 

 

Time~ 으로 시작하는 Debug 로그는 while(true) 문 안에서 10초를 대기하며 반복적으로 찍은 사진이다.

 

yield return new WaitForSeconds(10); 을 사용해서 코루틴이 못미더웠는데, 실제로는 Time.realtimeSinceStartup이 문제였다.

 

프로젝트에서 시간을 누적할 때 Time.realtimeSinceStartup를 사용했는데, 대략적인 시간은 맞지만 간혹 오차가 발생하는 경우가 있었다.

 

Time: { Time.realtimeSinceStartup }, Double: { Time.realtimeSinceStartupAsDouble }, unscaled: { Time.unscaledTime }

 

위 로그는 이렇게 찍은 로그이고, 위 사진 1 -> 2번째 로그에서 문제가 발생한 것을 확인할 수 있다.

 

Time.realtimeSinceStartup: 9.91145초 경과

Time.realtimeSinceStartupAsDouble: 9.9114010000012초 경과

Time.unscaledTime: 10.0143초 경과

 

yield return new WaitForSeconds(10); 으로 10초를 대기했으나, Time.realtimeSinceStartup, Time.realtimeSinceStartupAsDouble은 10초가 경과하지 않았다!

 

코루틴이 못미더워서 Update에서 deltaTime으로 시간을 누적해서 확인해보기도 했는데, 실제로는 10초가 지난 상태였다.

 

즉 Time.realtimeSinceStartup가 정확하지 않다는 소리이다.

Time.realtimeSinceStartupAsDouble도 미묘하게 정확해보이지만 부정확하다는 것을 알 수 있다.

 

대략 0.1초 이내로 오차가 발생하는데, 짧으면 5분 / 길면 30분에 1번 꼴로 발생한다.

 

Time.unscaledTime을 사용하는 것으로 대체했고, 이후 오차가 발생하는 것은 아직까지 발견하지 못했다.

 

Unity - Scripting API: Time.realtimeSinceStartup (unity3d.com)

 

Unity - Scripting API: Time.realtimeSinceStartup

This is the time in seconds since the start of the application, and is not constant if called multiple times in a frame. Time.timeScale does not affect this property. In almost all cases you should use Time.time or Time.unscaledTime instead. If you do need

docs.unity3d.com

 

유니티 문서에서도 웬만하면 Time.unscaledTime을 대신 사용하라고는 되어있지만... 기존에 잘 돌아가던 코드 + Unity에서 제공하는 기능이라 어느정도 믿음이 있었는데 발등 찍혔다.

 

시간의 정확도가 중요하다면 Time.realtimeSinceStartup 대신에 Time.unscaledTime을 사용하도록 하자.

Mepkatatsu/TSCB_OnlyScript: Tales Saga Chronicle Blast의 Script만이 포함된 Repository (github.com)

 

GitHub - Mepkatatsu/TSCB_OnlyScript: Tales Saga Chronicle Blast의 Script만이 포함된 Repository

Tales Saga Chronicle Blast의 Script만이 포함된 Repository - GitHub - Mepkatatsu/TSCB_OnlyScript: Tales Saga Chronicle Blast의 Script만이 포함된 Repository

github.com

 

이전 글에서 언급했던 수정사항들을 일부 적용했다. 내용을 요약하면 아래와 같다.

 

[AudioManager]
변수명, 접근자 수정
코루틴 사용을 좀 더 깔끔하게 수정
내부에서 불필요하게 Getter Setter 사용하고 있던 부분 수정
인스펙터에서 BGM FadeOut 시간 정할 수 있도록 수정

볼륨이 항상 1초에 걸쳐서 FadeOut 되도록 수정

[BulletController -> MidoriBullet] (이름 변경)
미도리 총알에 붙는 스크립트라 이름 변경

[ButtonManager] (삭제됨)
GameManager, StoryManager, OptionWindow로 기능 분리

[DialogManager]
\n -> \r\n 수정
+csv로 관리할 예정임

[EnemyController -> ShootingGameEnemy] (이름 변경)
좀 더 직관적인 이름으로 변경
일부 변수 이름 수정

[FontLocalizer] (신규)
TMP_Text와 함께 사용함으로써 텍스트의 로컬라이징을 담당하는 스크립트
언어가 바뀔 때 텍스트의 위치를 조정할 수 있음
추후 테이블로 뺄 예정이나 일단 임시로 한국어/일본어 텍스트를 저장&변경 하고 있음.

+RequireComponent 추가해야 할 듯

[GameManager]
변수명 수정
옵션에 해당하는 기능 분리(OptionWindow로 이동)
Find 사용하던 것들 일부 삭제, 인스펙터에서 추가하도록 수정
버튼 OnClick 스크립트로 추가하도록 수정
로컬라이징 부분을 LocalizeManager와 FontLocalizer로 분리
스토리 선택 창을 SelectStageWindow로 분리함.

[JoystickController]
기존에 안 올렸었던 것 같은데 화면 터치로 조이스틱 조작할 수 있도록 해주는 스크립트

[LazerController -> LaserController] (이름 변경)
Lazer -> Laser 전체적인 오타 수정

[LocalizeManager] (신규)
로컬라이징을 담당하는 Manager로 언어가 변경되면 여기서 처리되며, 언어별 폰트를 관리함.
기본적으로는 UI가 켜질 때 로컬라이징이 처리되기 때문에 켜진 FontLocalizer를 저장
언어를 변경할 때 등록된 FontLocalizer들을 로컬라이징 해준다.

[MidoriPlaneController -> MidoriPlane]
불필요한 Controller를 이름에서 제외

[OptionWindow] (추가됨)
옵션 창을 관리함. 옵션에 해당하는 기능들은 전부 여기서 관리
Button의 OnClick이나 Slider의 OnValueChanged도 코드로 관리
이제 언어를 변경할 때 게임을 종료하지 않고, 실시간으로 변경할 수 있도록 수정하였음.

[SelectStageWindow]
스테이지를 선택하는 창을 분리하였음
세팅 방식이 깔끔해지긴 했으나 storyStartText 등은 여전히 개선이 필요함.

[SliderController] (삭제됨)
옵션에 사용되던 것이라 OptionWindow로 기능 분리

[Singleton]
게임 종료할 때 파괴된 것 생성하느라 오류가 떠서 그 부분 삭제함.

[StarColorController]
변수 이름 수정
float 수정된 것 끊겨서 다시 지정해줘야 함 -_-;;

[StoryManager]
사용하지 않던 변수 삭제
버튼 OnClick 스크립트로 추가하도록 수정
Find 부분 상당 부분 삭제. Episode와 Story의 용어에 혼동이 있어서 Story로 통일함.

 

 

 

얼마간 상당한 대공사를 했는데, 결과가 나쁘지 않은 것 같다.

 

가장 큰 변경은 로컬라이징으로, 시작할 때 한 번에 처리하지 않고 실시간으로 처리할 수 있도록 LocalizeManager와 FontLocalizer를 두어서 게임 종료 없이 언어 변경을 처리할 수 있도록 만든 점이다.

 

다만 아직 테이블로 처리하는 부분을 구현하지 않아 일부 언어별 텍스트를 인스펙터에 넣어 놓은 상태이고, 스토리 대사도 테이블 처리가 아직이라 좀 더티하게 처리되는 부분이 아쉽다고 할 수 있겠다. 이외에도 여전히 스크립트 내에 하드 코딩으로 박아놓은 내용들이 많은데, csv 테이블로 처리할 수 있도록 계획하고 있다.

 

그리고 GameManager에서 로컬라이징, 스토리 선택 창, 옵션 창 등 다양한 것들을 맡고 있었는데 이것들을 분리해주어서 훨씬 깔끔해졌다.

 

전체적인 코드도 버튼이나 Slider에 스크립트 내에서 이벤트를 추가해줌으로써 관리가 훨씬 편해졌고, Find를 상당 부분 줄여 가독성도 좋아졌고 성능도 꽤나 개선됐다. 몇몇 처리를 할 때 내부 클래스들을 추가해서 향후 뭔가 하나씩 추가될 때마다 하나씩 코드를 작성할 필요 없이 유연하게 대응 가능하도록 짠 부분도 있다.

 

 

여전히 개선해야 할 부분들이 많은데, 아래와 같다.

 

1. Dialog와 UI의 텍스트 등을 csv 테이블로 만들어서 처리하기

2. ObjectPooling을 맡아주는 스크립트 만들기

3. 스토리 연출을 일일이 코드로 작성하지 않고 툴로 만들어 json파일로 저장하고, 이걸 읽어서 처리할 수 있는 기능 만들기

4. Addressables를 활용하면 좋을 듯

5. ShootingGameManager의 기능들도 분리해야 할 듯

 

이외에도 아직 구현이 안 된 대화 로그? 그런 것들도 추가해보면 좋을 것 같다.

 

현재 Dialog를 구글 스프레드 시트에 작성하고 csv로 만들기는 했는데, csv를 읽어오는 처리를 어떻게 할지가 약간 고민이다. 그냥 어거지로 읽어와서 대충 누더기로 파싱하고 처리하는 건 충분히 가능하겠지만, 깔끔하게 처리하고 싶어서... 좀 더 고민이 필요할 것 같다.

Mepkatatsu/TSCB_OnlyScript: Tales Saga Chronicle Blast의 Script만이 포함된 Repository (github.com)

 

GitHub - Mepkatatsu/TSCB_OnlyScript: Tales Saga Chronicle Blast의 Script만이 포함된 Repository

Tales Saga Chronicle Blast의 Script만이 포함된 Repository - GitHub - Mepkatatsu/TSCB_OnlyScript: Tales Saga Chronicle Blast의 Script만이 포함된 Repository

github.com

 

벌써 TSCB를 마지막으로 건드린지도 5달 가까이 지났다.

이전에 라이더 글을 쓰면서 코드를 봤는데, 막연하게 굉장히 쓰레기같은 코드를 짜놨을 거라 생각했는데, 의외로 괜찮은 부분도 있었고 구조적으로 문제가 있는 부분도 꽤나 있었다.

 

이번 기회에 작성했던 코드들을 보면서 어떤 문제가 있었고, 어떤 방식으로 대체하면 좋을지를 적어보고자 한다. 그리고... 가능하다면 그 개선 작업도 차근차근 할 수 있으면 좋겠다. ㅋㅋ.

 

루트 폴더부터 알파벳 순으로 정리할 예정이다.

 

Script 폴더

[AudioManager.cs]

FadeOutMusic()

볼륨을 0.01f씩 빼는 게 아니라 (볼륨 / 100)을 변수로 둬서 항상 1초에 걸쳐서 페이드 아웃되도록 수정하면 좋을 것 같다.

 

어차피 SetBGMVolume, GetVolume 1번씩만 할 거면 굳이 따로 volume 변수 만들 필요 없을 것 같다.

 

_isBGMChanged 말고 FadeOutMusic을 저장해뒀다가 PlayBGM할 때 코루틴을 중단하는 것이 좀 더 깔끔할 것 같음.

 

매개변수에 p_ 없애기

 

Sound를 SerializeField에 때려박는 건 별로 안 좋은 것 같다. 테이블 만들어서 관리하면 좋을 듯.

 

 

[ButtonManager]

굳이 버튼 매니저까진 필요 없을 듯 한데... 그냥 관련 스크립트에 추가해서 쓰는 것이 직관적일 듯.

 

현재는 유니티 에디터에서 OnClick을 설정해주는데, 어떤 버튼이 어디서 사용되는지 추적이 힘들고, 함수 변경할 때마다 참조가 끊어지니 재설정을 해줘야 한다. 코드에서 추가하도록 변경하자.

 

[DialogManager]

테이블로 관리하자.

 

[GameManager]

Find는 인스펙터에서 매번 등록하기가 번거롭고 종종 참조가 끊어지는 경우가 있어서 사용했었는데, 구조 변경에 너무 취약하니 빼고 인스펙터에 등록하자.

 

변수 이름들 수정하기.

 

\n부분 \r\n으로 변경하자.

 

로컬라이징도 테이블로 하자. (키값으로 언어 설정에 맞는 텍스트 받아오도록)

 

PlayerPrefs.HasKey("Stage1Cleared")

이게 1~4까지 있는데, 현재 몇 스테이지까지 진행했는지만 가지고 있으면 될 것 같다

 

FadeInImage, FadeOutImage가 GameManager에 있을 이유가 없을 것 같다. 이외에도 필요 없는 것 있으면 분리하기.

 

필요없는 것들 수정하면 코드가 상당히 짧아질 것 같음. GameManager보다는 좀 더 적절한 이름이 있을 것 같다. IntroManager... 싱글톤도 필요 없을 수도. 그러면 IntroController쯤 되려나?

 

[SliderController]

Awake에만 초기화가 있어도 될 것 같은데...

아마 Awake 이전에 다른 함수들이 호출되었던 것 같다. OnEnable을 하면 해결되려나?

 

슬라이더도 인스펙터에 추가하고 이벤트 달아줄 수 있으면 좋을 것 같다.

 

[StoryManager]

음... 굉장히 끔찍한 코드다. 일단 DoStoryAction()만 다르게 처리되어도 상당히 짧아질 수 있을 것 같다.

 

한 씬에 때려박는 것보다 씬 전환이 나을 것 같다.

 

선택지 관련도 뭔가 테이블로 처리할 수 있으면 좋겠다. 선택지 뿐만 아니라 스토리 전체적으로 가능하면 좋을 것 같은데 약간 어려울 것 같다. 기똥찬 방법을 고민해봐야 할 듯. 몇 가지 행동들을 정의해놓고 에디터에서 진행을 구성할 수 있도록 툴을 만들고, 이걸 json으로 바꿔서 번호를 매긴 다음에 각 번호에 해당하는 스토리를 진행할 때 이걸 다시 파싱해서 스토리 액션을 취하는 방식?

 

원래도 이런 걸 하고 싶었는데 그땐 모르는 게 너무 많아서 못했었다. 지금이라면 해볼 수 있을 것 같다.

 

전체적인 구조 개선이 필요할 것 같은데 굉장히 힘든 작업이 될 것 같다. 천천히 해보면 좋은 경험이 될 듯.

 

Script/Game1 폴더

 

[BulletController]

아군의 공격만 해당되는 것 같아서 MidoriBullet이라고 이름을 바꾸면 좋을 듯

 

[EnemyController]

StopAllCoroutines() 말고 명확하게 코루틴을 저장해놓고 정지해주는 방법이 좋을 것 같다.

 

BossController는 분리하면 좋을 것 같은데... EnemyController를 상속받아서 사용하게끔 하면 될 듯?

 

보스 스킬은 숫자 4 이런식말고 최소한 const로 선언해서 하든가 하는 게 좋을 듯

 

[LazerController]

함수가 좀 긴 것 같아서 분리 가능하면 분리하면 좋을 것 같다.

 

총알 사라지는 처리는 나쁘지 않은데 레이저는 살짝 짤리는 문제가 있어서 아예 왼쪽 오른쪽 공간을 덮어버리면 어떨지 확인해보기

-> 아마도 스토리에서 바로 보여주는 식이라 미도리 일러스트 순서때문에 그랬던 것 같은데... 잠깐 부모를 바꾼다거나 하는 식으로 하면 어떨까 싶음

 

[MidoriPlaneController]

굳이 Controller라는 이름을 붙여야만 했을까? 떼도 괜찮을 듯.

EnemyController도 걍 ShootingGameEnemy라고 하면 될 것 같다.

 

[ShootingGameManager]

ㅋㅋ 기능 분리가 좀 필요할 것 같다. 슈팅 게임 만들면서 사용할 기능들을 전부 하나에 때려박아서... 오브젝트 풀링이나 LookRotation2D 등은 밖으로 꺼내서 활용해도 될 것 같음.

 

CreateNewMidoriHalo() 등은 거의 비슷한 코드들이라 하나로 묶어서 처리하도록 처리하면 좋을 것 같다.

 

여기는 기능은 유지하되 전체적으로 코드 정리가 필요할 것 같다.

 

[StarColorController]

별들 하나하나마다 반짝거리는 시퀀스를 추가해줘서 성능상 좀 부담이 있을까 싶기도 한데 폰으로 했을 때도 딱히 렉걸리는 건 없었던 것 같아서 이대로 유지해도 괜찮을 것 같음.

 

단, DOTween.Sequence의 Loop가 GameObject의 active 상태가 false가 되면 멈춘다는 확인이 필요할 것 같다.

 

 

이외에도 Canvas scaler로 화면 비율도 조절해줘야 할 듯

옵션은 따로 OptionManager 만드는 편이 깔끔할 것 같다.

 

 

생각보다 고칠 부분이 엄청 많지는 않을 것 같다.

 

테이블 추가해서 연동하고 사용

스토리 관련 툴 만들고 따로 처리하는 과정

필요하다면 Addressables 사용하는 것

 

 

정도가 큼직한 변경이 될 것 같다.

 

일단은 쉽게 처리할 수 있는 것들부터 차근차근 수정해야겠다.

+ Recent posts