화면에 들어오는 모든 터치 인터랙션을 통괄하고 싶었다. 어떤 기능이 좋을까 생각해보니 Event Trigger가 떠올랐다.

 

 

 

1. Event Trigger란

이벤트 트리거는 유니티의 Event System에서 발생하는 다양한 입력 이벤트(마우스 클릭, 드래그, 호버, 터치 등)를 가로채서 사용자가 지정한 특정 함수를 실행하도록 연결해 주는 UI 컴포넌트이다.

 

 

 

2. 언제 사용할까?

보통 클릭과 같은 인터랙션은 버튼 컴포넌트를 사용하여 넣는 편이다. 하지만 클릭 기능이 없는 이미지나 텍스트를 버튼처럼 쓰고 싶은 순간이 올 수 있다. 이럴 때 컴포넌트에 Event Trigger를 추가하면 마치 버튼처럼 사용할 수 있다.

또는, 단순한 클릭을 넘어 마우스를 올렸을 때(Hover) 색상이 변하거나 툴팁이 뜨게 하고 싶을 때. 혹은 아이템을 드래그 앤 드롭하는 기능을 구현할 때 Event Trigger를 사용할 수 있다.

 

 

 

3. 유니티에서 비슷한 기능을 하는 다른 기능들

첫 번째는 위에서 말했듯이, 버튼 컴포넌트가 있다. 버튼은 '클릭'에만 특화되어 있으며 Event Trigger의 PointerClick과 사실상 동일한 역할을 한다.

두 번째는 이벤트 인터페이스인 IPointerClickHandler, IBeginDragHandler 등을 직접 상속받아 구현하는 것이다.

 

 

4. Event Trigger에 있는 함수

유니티 6.0 lts 기준으로 Event Trigger에서는 총 17개의 메서드를 지원하고 있다. 

 

 

각각의 메서드에 대해서 제미나이가 깔끔하게 정리해준 것을 첨부한다.

포인터(마우스/터치) 기본 이벤트

  • PointerEnter: 마우스 포인터(또는 터치)가 해당 UI 요소의 영역 안으로 들어가는 순간 발생한다. 주로 마우스를 올렸을 때 버튼이 커지거나 색이 변하는 '호버(Hover)' 효과에 사용한다.
  • PointerExit: 마우스 포인터가 해당 UI 요소의 영역 밖으로 빠져나가는 순간 발생한다. 호버 효과를 원래대로 되돌릴 때 사용한다.
  • PointerDown: 마우스 버튼을 누르는 순간(클릭이 아직 끝나지 않은 시점) 발생한다.
  • PointerUp: 누르고 있던 마우스 버튼을 떼는 순간 발생한다.
  • PointerClick: 마우스 버튼을 누른 후, 동일한 UI 요소 위에서 버튼을 뗐을 때 발생한다. 즉, 완전한 클릭이 이루어졌을 때 호출된다.

드래그 앤 드롭 이벤트

  • InitializePotentialDrag: 사용자가 UI 요소를 누르고 있어 드래그가 시작될 가능성이 있는 시점(보통 PointerDown 직후)에 발생한다. 드래그를 위한 초기 설정이 필요할 때 사용한다.
  • BeginDrag: 마우스를 누른 상태로 이동하여 드래그가 본격적으로 시작되는 순간 딱 한 번 발생한다.
  • Drag: 마우스를 누른 상태로 이동(드래그)하는 동안 매 프레임 연속적으로 발생한다. 보통 드래그하는 오브젝트의 위치를 마우스 포인터 위치로 업데이트할 때 사용한다.
  • EndDrag: 마우스 버튼을 떼어 드래그를 종료하는 순간 발생한다. 원래 자리로 되돌리거나, 드롭 위치에 고정하는 로직을 넣는다.
  • Drop: 드래그 중인 다른 요소를 현재 요소 위에서 마우스 버튼을 떼어 내려놓았을 때 발생한다. (예: 인벤토리의 아이템을 슬롯 위로 드롭할 때, '슬롯' 측에서 발생하는 이벤트)

휠 스크롤 이벤트

  • Scroll: 마우스 포인터가 UI 요소 위에 있을 때 마우스 휠을 스크롤하면 발생한다.

선택 및 네비게이션 이벤트 (키보드, 게임패드 조작 시 유용)

  • Select: 요소가 클릭되거나 방향키 이동 등으로 인해 포커스를 얻어 '선택'되는 순간 발생한다.
  • Deselect: 다른 요소가 선택되어 현재 요소가 포커스를 잃고 '선택 해제'되는 순간 발생한다.
  • UpdateSelected: 이 요소가 선택된 상태일 때 매 프레임 지속적으로 발생한다.
  • Move: 요소가 선택된 상태에서 방향키(상하좌우)나 조이스틱 이동 입력이 들어왔을 때 발생한다.
  • Submit: 요소가 선택된 상태에서 확인 버튼(보통 Enter 키, 스페이스바, 게임패드의 A 버튼 등)을 눌렀을 때 발생한다.
  • Cancel: 요소가 선택된 상태에서 취소 버튼(보통 ESC 키, 게임패드의 B 버튼 등)을 눌렀을 때 발생한다.

 

 

 

5. 아이템 드래그 & 드롭

아까 위에서 짧게 언급했던 아이템 드래그&드롭에 대해서 좀 더 자세히 얘기해보고자 한다. 우리는 인벤토리 시스템이나 장비 장착 기능을 만들 때 Event Trigger의 이벤트들을 조합하여 드래그 앤 드롭을 구현할 수 있다. 그전에, 이 기능을 제대로 작동시키려면 움직이는 주체인 '아이템'과 아이템을 내려놓을 목적지인 '슬롯'의 역할을 나누어 설계해야 한다.

흐름을 생각해보자.

이 내용을 참고해보면, 결국에는 BeingDrag -> Drag -> Drop -> EndDrag 순으로 구현이 되야 한다. 여기서 BeingDrag, Drag, EndDrag 모두 아이템을 기준으로 생각하면 되고, Drop만 슬롯을 기준으로 생각하면 된다.

 

마우스를 클릭해 이동을 시작하고(BeingDrag),
마우스를 누른 채 이동하면서 매 프레임이 발생하고(Drag),
드래그 중인 아이템을 슬롯 위에서 떼는 순간, 아이템이 아니라 슬롯 측에서 드롭 이벤트가 발생하고(Drop),
드래그가 완전히 종료된다(EndDrag).

 

이를 구현하는 과정에서 Raycast Target을 잘 고려해서 체크/해제 해주어야 문제없이 드래그&드롭이 잘 작동한다. 인벤토리 관련해서 Raycast Target 얘기가 짧게 언급된 블로그 글을 첨부한다.ㅎㅎ

https://ansohxxn.github.io/unity%20lesson%203/ch5-6/#google_vignette

 

Chapter 5-6. 인벤토리 : 인벤토리 툴팁(Tooltip)

인프런에 있는 케이디님의 [유니티 3D] 실전! 생존게임 만들기 - Advanced 강의를 듣고 정리한 필기입니다. 😀 🌜 강의 들으러 가기 Click

ansohxxn.github.io

 

 

 

6. 리플렉션

https://tsyang.tistory.com/56

 

C# - 리플렉션 (Reflection)

리플렉션 리플렉션은 컴파일 시에 알 수 없었던 타입이나 멤버들을 찾아내고 사용할 수 있게 해주는 메커니즘이다. 그러나 다음의 주요한 단점이 존재한다. 리플렉션을 사용하면 컴파일 시에

tsyang.tistory.com

Event Trigger와 비슷한 기능을 하는 다른 것으로 IPointerClickHandler, IBeginDragHandler를 얘기했다. Event Trigger는 컴포넌트로 간단히 제어할 수 있기 때문에 매우 간편하지만, 프로젝트 규모가 커질수록 오히려 인터페이스를 상속받는 것이 유지보수가 쉬울 수 있다.

하지만, 인터페이스를 상속받는 방법을 채택하는 것이 좋은 이유에는 한 가지가 더 있는데, 그것은 바로 '리플렉션'문제이다.

리플렉션이란 프로그램이 실행 중(Runtime)에 문자열(이름)을 기반으로 자기 자신의 구조를 검색하고 조작할 수 있는 기술이다. 우리가 인스펙터에서 + 버튼을 눌러 특정 스크립트의 OnClickItem이라는 함수를 연결하면, 유니티는 내부적으로 이 함수의 '이름(문자열)'을 저장해 둔다. 그리고 실제 클릭이 발생할 때마다 해당 스크립트를 뒤져서 지정된 이름의 함수를 찾아 실행하는 탐색 과정을 거친다.

따라서 다이렉트로 메모리 주소를 찾아가는 일반적인 방식에 비해(코드로제어), 매번 문자열을 검색하고 매개변수를 검사하는 리플렉션 방식(컴포넌트로 제어)이 연산에 더 많은 비용이 들 수 있다.

 

오늘은 두트윈에 대해서 정리해보았다. 유니티에서 UI에 비주얼적인 재미를 추가하기 위해 가장 먼저 찾게 되는 도구는 바로 트위닝(Tweening) 라이브러리이다.

 

 

 

 

트위닝 라이브러리

https://easings.net/

 

Easing Functions Cheat Sheet

Easing functions specify the speed of animation to make the movement more natural. Real objects don’t just move at a constant speed, and do not start and stop in an instant. This page helps you choose the right easing function.

easings.net

이 라이브러리는 단순히 이미지를 배치하는 것에서 끝나는 것이 아니라, UX를 고려하여 특정 움직임과 효과를 주는 결정적인 차이를 만드는 데 큰 도움을 준다. 트위닝은 In-betweening의 약자로, 애니메이션에서 두 핵심 상태 사이의 중간 과정을 자동으로 생성해주는 기법이다. 단순히 이동할 수도 있고, 크기를 키운다거나, 회전한다거나, 색상을 바꾼다거나 하는 등 다양한 속성을 통해 애니메이션을 줄 수 있다.

 

 

 

 

현업에서는 왜 두트윈을 쓸까?

이유는 간단하다. 최적화도 좋고, 간편하기 때문이다. 두트윈을 쓰면, 복잡한 수학 공식을 사용하지 않아도 코드 한줄로 원하는 연출이 가능하다. 그리고 돈을 쓴다면 코드 한줄도... 필요치 않게 된다.

 

 

 

두트윈 에셋 비교

유니티 에셋 스토어에 'dotween'을 검색하면 3가지 에셋이 나온다. HOTween은 DOTween 이전 세대 에셋이고, 성능적인 부분에서는 DOTween이 앞선다.

이제 가격이다. DOTween은 무료 에셋과 유료 에셋이 있는데, 나는 프로 에셋이 15달러 이상의 가치가 있다고 생각하는 비이다.

왜냐하면, 비주얼 에디터의 유무가 실제 작업 생산성에 영향이 크기 때문이다.

비주얼 에디터란 컴포넌트 형식으로 추가할 수 있는 두트윈 애니메이션(과 두트윈 패스)를 말한다.

이미지 중간 부분을 보면 Previe Controls라는 부분이 있다. 게임을 실행하지 않아도 씬에서 내가 넣은 두트윈 연출을 간단하게 확인할 수 있다는 말이다. 두트윈은 세밀한 작업이기 떄문에 0.1의 값도 차이가 나는데, 비주얼 에디터에서 제공해주는 미리보기 기능이 없다면 매번 실행해서 확인해야 한다는 큰 번거로움이 있다.

그리고 비주얼 에디터가 없다는 말은 곧 코딩으로만 두트윈 연출을 쓸 수 있다는 말이기도 하다.

두 번째는 TextMeshPro와 같은 외부 라이브러리를 지원해주는 것이다. 이번주에 두트윈으로 작업하면서 가장 많이 썼던 기능 중 하나다 두트윈 프로에만 있는 Text 기능이다. 이 기능은 쉽게 이해하자면, ppt에서 흔히 넣는 효과 중 하나인 웨이브 효과다. 텍스트 하나하나에 마치 파도처럼 효과를 줘서 시선을 끌고 읽기 쉽게 만드는 효과 말이다. 그렇게 텍스트에 여러 효과를 넣는 기능을 제공해준다. 만약 ui에 쉴틈없는 꽉꽉 채워진 리액션을 넣어야 한다면 pro가 맞는 선택이다. 

 

 

 

주의할 점

하지만 프로를 쓴다면 주의해야 할 점이 있다. 대규모 UI 작업 시 몇 가지 신경써야 할 것이 있는데, 바로 Capacity이다. 두트윈 컴포넌트를 추가한 UI가 많아지면 아래와 같은 경고가 뜰 수 있다.

DOTWEEN ► Max Tweens reached: capacity has automatically been increased from 200/50 to 500/50. Use DOTween.SetTweensCapacity to set it manually at startup

이는 에러는 아니고 메모리 용량이 꽉 찾기 때문에 확장해줘야 한다는 뜻이다.

간단한 해결 방법은 Awake 메서드에

DOTween.SetTweensCapacity(500, 100);

를 호출해 용량을 미리 확보해두는 방법이다.

 

또한, AutoKill의 체크 여부를 꼭 확인하는 것이다. 기본적으로 켜져 있는 이 옵션은 트윈 종료 후 메모리를 정리해주는 역할을 한다. 하지만 오브젝트를 껏다 켤 때 애니메이션을 재사용하고 싶다면 이 기능을 비활성화해주고, 대신 매니저를 통해 On Enable 메서드에서 Play가 아닌 Restart를 해주면 된다.

 

 

 

효과 종류

두트윈에 있는 효과를 카테고리별로 구분하면 크게 Transform, Visual, Special이다.

 

먼저 Transfrom Tweens는 변형 관련된 트윈들이다. 가장 기본적이고 가장 많이 쓰이는 효과들로, Move / LocalMove / Rotate / LocalRotate / Scale 등이 있다. 여기서 Local 키워드가 붙은 효과들은 '부모 오브젝트'를 기준으로 상대적인 위치로 이동시킬 때 쓰는 효과들이다.

두 번째는, Visual Tweens 즉 시각 및 컬러 관련 트윈들이다. Fade / Color / Gradient 등이 있다. 나는 여기서 Fade를 가장 많이 쓰는 편인데, 이 효과들은 모두 오브젝트의 투명도나 색상을 제어하여 다양한 분위기를 만든다. 참고로, Gradient는 설정된 그라데이션 값에 따라 색상을 시간에 따라 변화시켜주는 효과다.

세 번째는, Special Tweens 즉 특수 및 컴포넌트 전용 트윈들이다. 위에서 말했던 Text부터 Punch / Shake 등이 여기에 해당한다. Punch는 선택한 방향으로 툭 쳤다가 다시 제자리로 돌아오는 효과, Shake는 카메라 흔들림처럼 오브젝트를 떨리게 만드는 효과이다.

 

한편, 이렇게 속성을 골랐다면 그것을 어떻게 적용해줄 것인지를 결정해줘야 하는데, 그것이 바로 Ease 설정이다. 트위닝에서 Ease는 시간에 따른 값의 변화율을 정의하는 공식이다.

 

위의 이미지도 Ease 설정을 캡쳐해온 것인데, 여기서 방향의 차이는 크게 3개로 나뉜다.

In / Out / InOut이다.

- In: 시작은 느리고 끝날 때 빠르다. (가속)

- Out: 시작은 빠르고 끝날 때 부드럽게 멈춘다. (감속)

- InOut: 시작과 끝은 느리고 중간만 빠르다.

 

그리고 이 방향과 함께 붙는 종류이다.

Ease 종류 특징 및 연출 느낌 추천 UI 연출
Cubic / Quad 가장 대중적인 부드러운 곡선. 자연스러운 가감속이 특징. 미니 타이틀 페이드, 패널 슬라이드
Back 목표 지점을 살짝 넘었다가 다시 되돌아오는 탄성. 팝업창 등장, 버튼 클릭 피드백
Bounce 바닥에 공이 튀기듯 통통 튀는 느낌. 보상 아이템 획득, 경고창 흔들림
Elastic 고무줄처럼 팽팽하게 늘어났다 줄어드는 반동. 강조하고 싶은 아이콘, 쫀득한 UI 반응

 

 

 

마무리

처음에 두트윈을 쓴다면 종류가 너무 많아 헷갈리고 다루기 어렵다고 느껴질 수 있다. 하지만 여러 방면으로 모두 도전해보면서 감을 익힌다면, 어느 순간부터는 누군가에게 물어보지 않아도 내 생각만으로 두트윈을 사용한 연출이 완벽해지는 날이 올 것이다. 

오늘 해볼 얘기는 게임의 시각적 완성도를 담당하고 있는 중요한 시스템, 이펙트이다. 에셋 스토어에서 맘에 드는 vfx 에셋을 구매했다. 바로 내 프로젝트에 적용할 수 있다면 best겠지만, 다수는 내 프로젝트에 맞게 커스텀해줘야한다. 그래서 오늘은 유니티 파티클 시스템을 이용해서 내가 원하는 연출을 만드는 방법에 대해서 정리해보았다.

 

 

 

 

 

1. 타겟 오브젝트에 알맞게 사이즈 조정하는 방법

에셋 스토어의 VFX 에셋은 제작 기준이 제각각이다. 내 프로젝트의 캐릭터나 오브젝트 크기에 맞게 이펙트 크기를 키우거나 줄여야 할 때, 단순히 Transform의 Scale을 조절할 수도 있지만 나는 보통 Scale은 잘 건들지 않는 편이다. 그 다음으로는 'Start Size'인데, 값을 잘 조정한다면 여기서 간단하게 원했던 느낌을 해결할 수 있다. 하지만, 그외의 방법도 있다.

 

 

1-1. Particle System의 Scaling Mode를 활용할 수 있다.

  • Hierarchy: 부모 오브젝트의 Scale 값을 그대로 따른다. 이펙트 전체 크기를 한꺼번에 조절할 때 가장 직관적이다. 보통은 이 값으로 설정되어 있다.
  • Local: 부모의 크기와 상관없이 파티클 자체의 Scale 값만 적용한다.
  • Shape: 부모의 Scale 값이 파티클의 방출 범위(Shape)에는 영향을 주지만, 뿜어져 나오는 개별 입자의 크기(Start Size)에는 영향을 주지 않는다.

예를 들어, 랜덤으로 사이즈가 계속 바뀌는 오브젝트에 추가되어야 하는 이펙트라면 유동적으로 그 사이즈에 맞춰서 연출을 해줘야 하고, 이 때 Hierachy로 설정해주면 알아서 그때마다 스케일 값을 계산해서 이펙트를 키우고 줄일 것이다.

 

 

 

1-2. Start Size & Start Speed를 함께 고려한다.

Start Size도 단순히 이값만 고려하는 것이 아니라 Start Speed와 함께 고려해야 한다. 단순히 수치를 입력하는 것이 아니라, 두 값의 상대적 비율에 따라 연출의 성격이 완전히 달라진다.

 

Start Size < Start Speed (속도가 크기보다 압도적으로 빠를 때)
  • 연출 느낌: 날카로움, 빠른 투사체, 긴박함.

입자가 생성되자마자 멀리 뻗어나가기 때문에 '선'의 느낌이 강해진다. 레이저, 총기 발사 시 불꽃, 고속으로 흐르는 물줄기 연출에 적합하다. 값이 너무 극단적이면 입자 사이의 간격이 벌어져 이펙트가 '끊겨' 보일 수 있다. 이때는 Emission의 개수를 늘려 메워야 한다.

 

Case B: Start Size > Start Speed (크기가 속도보다 압도적으로 클 때)
  • 연출 느낌: 웅장함, 묵직함, 몽글몽글함.

입자가 제자리에서 서서히 커지거나 머무는 느낌이 강해져 '면'이나 '덩어리'의 느낌이 난다. 폭발 후 남는 자욱한 연기, 안개, 마법진의 오오라 연출에 적합하다. 속도가 너무 느리면 이펙트가 정적으로 보여 생동감이 떨어질 수 있다.

 

Case C: Scaling 후의 보정 공식

만약 에셋의 전체 스케일을 0.5배로 줄였다면, 물리적인 정합성을 위해 다음을 체크해야 한다.

  • Start Size x 0.5: 입자 개별 크기를 줄여 밀도를 유지한다.
  • Start Speed x 0.5: 이동 거리를 줄여 이펙트가 원래 의도한 범위 내에서 소멸하게 만든다.

 

즉, 속도(Speed)는 이펙트의 '영역(Range)'을 결정하고, 크기(Size)는 이펙트의 '밀도(Density)'를 결정한다. 이펙트가 너무 산만해 보인다면 속도를 줄이고, 너무 빈약해 보인다면 크기를 키우는 것이 기본이다.

 

 

 

 

 

2. 파티클에 들어가는 입자 변경하는 방법

파티클의 '모양'을 결정하는 입자(Texture)를 바꾸는 방법은 크게 두 가지다. 연출하고자 하는 목적에 따라 선택한다.

 

 

2-1. Renderer에서 Material을 교체하는 방법

가장 기본적인 방법으로, Renderer 모듈의 Material 슬롯에 원하는 텍스처가 담긴 머티리얼을 할당한다. 불꽃 하나, 연기 하나처럼 단일한 형태를 반복해서 뿌릴 때 사용한다. 사실 처음에 이 방법만 있는 줄 알았으나, 유료 에셋을 뜯어보면서 또다른 방식이 하나 더 있다는 것을 알게 되었다. (이래서 에셋 분석하는 게 중요한 거 같다 ㅎㅎ)

 

 

 

2-2. Textuer Sheet Animation의 Sprites를 교체하는 방법

여기서도 파티클의 입자를 교체할 수 있다. 하나의 파티클 시스템에서 여러 가지 모양의 입자를 무작위로 섞어서 내보내고 싶을 때 활용한다. 이미지를 보면 cloud02라는 스프라이트 이미지 옆에 +버튼이 있다. 즉, 스프라이트 이미지를 여러개 등록할 수 있다는 뜻이다. 리스트에 여러 이미지를 넣어놓고, Start Frame을 'Random Between Two Constants'로 설정하면, 입자가 생성될 때마다 리스트 중 하나가 무작위로 선택되어 훨씬 풍성한 연출이 가능해진다.

 

 

 

 

 

 

3. Sub Emitters

파티클은 혼자가 아니다. 고퀄리티 이펙트는 여러 개의 파티클 시스템이 유기적으로 연결되어 완성된다. 이때 핵심 역할을 하는 것이 바로 Sub Emitters다. 특정 사건이 일어나는 시점에 '두 번째 파티클'을 터뜨리는 기술이다.

 

  • Birth (생성 시): 파티클이 태어날 때 뒤에 잔상(Trail)을 남기는 효과를 줄 때 사용한다. 미사일이 날아갈 때 뒤에 남는 연기가 대표적인 예다.
  • Collision (충돌 시): 파티클이 벽이나 바닥에 부딪히는 순간 물방울이 튀거나 먼지가 일어나는 효과를 만든다.
  • Death (소멸 시): 파티클의 수명이 다해 사라지는 순간 폭발이 일어나는 연출이다. 폭죽이 하늘 위로 올라가 펑 터지는 효과를 구현할 때 필수적이다.

보통은 이 세가지를 많이 쓰지만, Trigger나 Manual이라는 추가 항목도 있다. 더 복잡한 상호작용이 필요한 경우에 쓴다고 한다.

 

Trigger (트리거 발생 시)

파티클 시스템의 Triggers 모듈과 연동된다. 입자가 특정 Collider 안에 들어오거나(Inside), 나갈 때(Outside), 혹은 충돌하는 순간에 서브 파티클을 발생시킨다. 예를 들어, 독 구름 지역에 들어간 파티클만 불꽃이 튀게 하거나, 특정 구역을 통과할 때만 입자 모양이 바뀌는 정교한 구역 연출에 적합하다.

Manual (스크립트 제어)

엔진의 자동 연산이 아닌, 개발자가 C# 스크립트에서 직접 호출할 때만 발생한다. TriggerSubEmitter(index) 함수를 통해 원하는 타이밍에 자식 파티클을 터뜨릴 수 있다. 게임 로직과 맞물려야 하는 이펙트(예: 특정 리듬 게임의 판정 타이밍, 스킬 콤보 성공 시의 피드백)에서 극한의 제어력을 발휘한다.

 

개인적으로 Manual은 지금 블로그 글을 작성하면서 처음 알게 된 부분인데, 이 부분은 앞으로 유용하게 쓸 수 있을 듯 하다.

 

 

 

 

마무리

오늘은 이렇게 유니티 파티클 시스템에 대해서 정리해보았다. 요즘 파티클 시스템과 함께 쉐이더를 다루고 직접 만들어보며 연구하는 중인데, 이 부분도 정리가 된다면 가져와보도록 하겠다.

 

 

현대차와 유니티

얼마 전 많은 사람들을 놀라게 했던 현대자동차의 로봇쇼를 기억할 것이다. 나 또한 정교하게 움직이는 로봇들의 퍼포먼스에 크게 감탄했었다. 한창 쇼츠에 해당 이슈가 뜰 당시 우연히 알고리즘을 통해 현대차가 유니티와의 협업을 진행했던 '메타 팩토리' 프로젝트를 다시 접하게 되었다.

https://www.youtube.com/watch?v=d7up8GtaByE

현실에 존재하는 공장과 가상 세계를 실시간으로 동기화하는 디지털 트윈 기술 영상을 보고 이러한 기술이 어떤 원리로 가능한 것일까 궁금해졌다.

 

현대차의 메타 팩토리와 같은 실시간 관제 시스템의 핵심은 결국 '데이터의 실시간성과 신뢰성'에 있다. 수많은 센서 데이터를 지연 없이 가상 세계인 유니티 클라이언트로 전달하고, 반대로 웹 관리자 도구에서 내린 명령을 실제 기기에 유실 없이 전송해야 하기 때문이다.

 

특히 산업 현장이나 대규모 행사장처럼 네트워크 환경이 불안정한 곳에서는 데이터가 증발하거나 연결이 끊기는 문제가 빈번히 발생한다. 이러한 기술적 장벽을 어떻게 극복할 수 있을까 구글링하던 중, 그 해답으로 MQTT 프로토콜을 접하게 되었다.

 

 

 

 

MQTT 통신

이미지 출처는 이미지를 클릭

유니티와 웹 페이지를 연동하는 통신 방식에는 HTTP, WebSocket, MQTT 등이 있다. 그중에서도 메타 팩토리와 같은 정교한 시스템에서 만약 MQTT가 채택되었다면 그 이유는 다음과 같을 것이다.

 

 

1. QoS(Quality of Service)

웹 대시보드에서 수십 대의 유니티 클라이언트 상태를 실시간으로 제어해야 한다고 가정해보자.

HTTP Rest API는, 매번 헤더를 포함해 요청을 보내는 방식이기 때문에 실시간성이 떨어지고 배터리와 네트워크 부하가 크다.

WebSocket은 양방향 전이중 통신 방식이기 때문에 연결 유지 비용이 부담스럽고, 끊기면 끝이다.

반면, MQTT는 웹에서 유니티로 중요한 설정값을 보낼 때, 네트워크가 불안정해 데이터가 유실되는 문제가 발생할 수 있는데, 이때 설정을 통해 메시지 전달을 보장할 수 있다.

유니티가 잠시 오프라인 상태로 돌입해도 추후 네트워크 재연결 시 메세지를 받을 수 있도록 설계되었다. 중요 제어 명령이 반드시 전달되어야 하는 관제 시스템에서 필수적인 요소를 유지시켜준다.

 

 

2. 리소스 최적화

유니티는 렌더링과 물리 연산에 많은 리소스를 사용한다. 여기에 통신까지 무거워지면 성능 저하가 발생할 수 있는데, MQTT는 최소 2바이트의 고정 헤더만 사용하기 때문에 성능 최적화에 최적화되어 있다. 매우 가볍기 때문에 덕분에 저대역폭 환경에서도 유니티 클라이언트에 무리를 주지 않고 실시간 데이터를 주고 받을 수 있다.

 

 

3. 발행/구독(Pub/Sub) 구조

이미지 출처는 이미지를 클릭

1:1 연결 중심인 방식과 달리, MQTT는 특정 주제(Topic)를 기반으로 메시지를 뿌리는 구조다. 웹 대시보드 하나에서 수많은 로봇 클라이언트로 상태를 동시에 전파하거나, 필요한 데이터만 골라 구독하는 설계가 매우 용이하다.

 

 

 

 

MQTTX 툴

실제로 MQTT툴을 사용해보았다. 툴은 아래 링크를 통해 설치할 수 있다.

 

 

https://mqttx.app/

 

MQTTX: Your All-in-one MQTT Client Toolbox

MQTTX: A powerful, all-in-one MQTT 5.0 client toolbox for desktop, CLI and WebSocket, it makes developing and testing MQTT applications faster and easier.

mqttx.app

 

 

 

MQTT는 발행자, 구독자, 브로커 3구조 중심으로, 쉽게 이해해보면 우체국 사서함과 같다고 할 수 있다.

  • 발행자(Publisher): 사서함(Topic)에 편지를 넣는 사람.(특정 토픽으로 데이터를 보냄)
  • 구독자(Subscriber): 특정 번호의 사서함을 지켜보다가 편지가 오면 가져가는 사람. (브로커로부터 특정 토픽의 데이터를 전달받음)
  • 브로커(Broker): 사서함들이 모여 있는 우체국 그 자체. (발행자가 보낸 메시지를 받아서, 해당 토픽을 구독 중인 사람들에게 배달)

 

기존의 HTTP가 1:1로 전화를 걸어 "지금 데이터 줘!"라고 요청하는 방식(Request-Response)이라면, MQTT는 누군가 데이터를 던져두면 관심 있는 사람들이 알아서 가져가는 비동기 방식이다.

 

 

그리고 MQTTX라는 프로그램 하나가 발행자와 구독자 역할을 동시에 수행하고 있다.

  • 화면의 오른쪽 상단 초록색 말풍선과 하단 입력창 -> 발행자 영역
  • 화면의 왼쪽 회색 말풍선 왼쪽 상단의 Subscription 목록 -> 구독자 영역
  • 클라이언트가 연결된 대상 서버 -> 브로커

이 상황은 내가 나에게 편지를 쓰고, 내가 그 우체통을 열어본 상황이라고 비유할 수 있겠다.

 

 

다음은 MQTT를 쓰는 주 이유 중 하나인 데이터 보장에 대한 설정창이다.

이 설정값들은 클라이언트가 브로커와 어떤 방식으로 통신을 유지하고 관리할지를 결정한다.

 

 

*아래 내용은 제미나이가 알려주었다.

1. General (기본 설정)

  • Name: 해당 연결의 별칭. 여러 브로커를 사용할 때 구분하기 용이하도록 자유롭게 정함.
  • Host: 브로커 서버의 주소. mqtt:// (일반) 또는 mqtts:// (보안) 프로토콜을 선택하고 도메인이나 IP를 입력.
  • Port: 통신 포트. 기본은 1883이며, 보안 연결(SSL/TLS) 시 보통 8883을 사용.
  • Client ID: 브로커가 클라이언트를 식별하기 위한 고유 이름. 중복되면 기존 연결이 끊길 수 있음.
  • Username / Password: 브로커에 접근 권한이 설정된 경우 입력.
  • SSL/TLS: 보안 연결 사용 여부를 결정. 인증서가 필요한 환경에서 활성화.

2. Advanced (상세 설정)

  • MQTT Version: 통신 규격 버전. 최신인 5.0 또는 널리 쓰이는 3.1.1 중 선택.
  • Connect Timeout: 연결 시도 시 브로커의 응답을 기다리는 최대 시간(초).
  • Keep Alive: 클라이언트가 살아있는지 확인하기 위해 브로커와 신호를 주고받는 주기(초). 이 시간 동안 아무 통신이 없으면 연결을 끊은 것으로 간주.
  • Auto Reconnect: 활성화하면 네트워크 문제 등으로 연결이 끊겼을 때 자동으로 다시 연결을 시도.
  • Reconnect Period: 재연결을 시도하는 간격(ms). 4000이면 4초마다 재시도.
  • Clean Start (또는 Clean Session): * On: 이전 접속 정보를 삭제하고 깨끗한 상태로 새로 시작.
    • Off: 브로커가 이전 접속 정보를 기억하여, 재접속 시 받지 못했던 메시지를 전달받을 수 있음.
  • Session Expiry Interval: 연결이 끊긴 후 브로커가 해당 세션 정보를 얼마나 보관할지 설정(초).

3. Last Will and Testament (유언 메시지)

장치가 갑자기 예기치 못하게 네트워크에서 이탈했을 때 브로커가 대신 발행할 메시지를 설정.
  • Last Will Topic: 기기가 비정상 종료되었을 때 메시지를 보낼 경로 (예: device/status).
  • Last Will Payload: 전송할 내용 (예: {"status": "offline"}).
  • Last Will QoS: 유언 메시지의 서비스 품질 등급을 설정.
  • Last Will Retain: 브로커가 이 유언 메시지를 마지막 값으로 저장할지 여부를 결정.

다양한 항목들을 통해 각각의 프로젝트에 알맞게 커스터마이징할 수 있다.

 

 

 

 

마무리

이러한 이론적 배경을 바탕으로, 실제 MQTT 통신이 어떻게 이루어지는지 확인해 보고자 직접 테스트를 진행해보았다. 비록 전체 시스템을 구축하는 단계는 아니었지만, MQTTX라는 GUI 툴과 공개된 브로커를 활용해 메시지를 발행하고 구독하는 과정을 거치며 데이터의 신뢰성을 직접 검증해 보며 오늘 글을 마친다.

[퍼즐 게임으로 알고리즘 공부하기] 일명 퍼알공! 그 첫 번째 시리즈.

오늘은 2048 게임에 대해서 본격적으로 파헤쳐보겠다.

 

 

 

 

 

개요: 2048 게임이란?

2048은 2014년 가브리엘 칠룰리(Gabriele Cirulli)가 공개한 오픈 소스 퍼즐 게임이다. 게임의 기본 정보는 아래와 같다.

 

게임의 목표

  • 4X4 크기의 그리드 위에서 숫자가 적힌 타일들을 상하좌우로 밀어, 같은 숫자가 적힌 타일끼리 합쳐서 최종적으로 '2048'이라는 숫자를 만드는 것이다.

기본 플레이 규칙

  • 타일 생성: 매 이동 시 빈 공간 중 한 곳에 '2' 또는 '4' 타일이 랜덤하게 생성된다.
  • 이동 (Move): 상, 하, 좌, 우 네 방향 중 하나로 타일을 밀면, 모든 타일이 해당 방향의 끝으로 이동한다.
  • 병합 (Merge): 이동 경로 상에 숫자가 같은 두 타일이 충돌하면, 두 숫자를 합친 하나의 타일로 변한다. (예: 2+2=4, 4+4=8)
  • 단방향 원칙: 한 번의 이동으로 하나의 타일은 한 번만 합쳐질 수 있다. (예: 2-2-4 라인에서 왼쪽으로 밀면 4-4가 되지, 한 번에 8이 되지 않습니다.)

 

게임 오버와 승리

  • 승리 (Win): 타일을 합쳐서 2048 숫자를 하나라도 만들면 승리한다.
  • 패배 (Game Over): 4X4 그리드가 타일로 꽉 차고, 상하좌우 어느 방향으로도 더 이상 합칠 수 있는 타일이 없을 때 게임이 종료된다.

 

 

 

 

 

2048 본격 분석

2048 게임의 본질은 제한된 공간에서 숫자들이 꽉 차지 않도록 효율적으로 합쳐서 생존해가는 것이다. 그리고 그 내부는 '2차원 데이터'를 효율적으로 다루기 위한 논리적인 설계로 이루어져 있다. 이를 데이터, 알고리즘, 게임루프의 3가지 레이어로 분석해 보겠다.

 

데이터 구조

¢

2048의 모든 로직은 숫자가 담긴 16개의 우편함을 관리하는 것에서 시작한다.

먼저 데이터를 담을 그릇을 준비해야 한다. 가로 4칸, 세로 4칸의 2차원 배열을 만든다. 오른쪽 이미지와 같이 배열은 왼쪽 위[0, 0]부터 아래로 내려가며 숫자를 센다. 이를 프로그래밍 관점에서 int[4, 4]로 표현할 수 있다. 각각의 칸은 처음에는 아무것도 없다는 의미로 모두 0으로 채운다. 컴퓨터는 이 배열 속의 숫자를 메모리에 기억하고, 사용자 입력에 따라 실시간으로 수정한다.

 

핵심 알고리즘

데이터는 생성과 병합을 통해 수정된다. 예를 들어, 비어 있는 grid[0, 2]에 숫자 2를 넣거나, grid[0, 0]과 grid[0, 1]이 모두 2일 때 하나로 합쳐 4를 만들고 남은 자리는 다시 0으로 비우는 식이다.

이를 3단계 프로세스로 다시 살펴보자.

  1. 밀기(Shift): 선택한 방향으로 모든 타일을 끝까지 밀어 빈칸(0)을 제거한다.
  2. 병합(Merge): 이동 방향에 인접한 같은 숫자를 하나로 합친다.
  3. 재정령(Re-Shift): 병합 후 생긴 빈칸을 메우기 위해 다시 한번 끝까지 밀어낸다.

      *주의 사항: '철저한 1인 1회 합병 원칙'에 따라, 한 번의 이동 턴에서 이미 합쳐진 타일은 다시 합쳐질 수 없다.

 

게임 루프 및 흐름

이제 로직을 실제 게임 루프로 옮겨보자. 플레이어가 키를 누른 순간부터 다음 턴으로 넘어가는 일련의 과정이다.

  1. 입력 대기(Input): 사용자의 상하좌우 키 입력을 감지한다.
  2. 이동 시도: 선택한 방향으로 위의 3단계 알고리즘을 적용한다.
  3. 변화 감지(Change Check): 매우 중요하다. 타일의 위치가 바뀌거나 숫자가 합쳐지는 등의 '변화'가 있을 때만 턴이 성공한 것으로 간주한다. 변화가 없다면 다음 단계로 넘어가지 않는다.
  4. 랜덤 스폰(Spawn): 턴이 성공했다면 비어 있는 칸 중 한 곳에 '2'(90% 확률) 또는 '4'(10% 확률)를 생성한다.
  5. 종료 판점(Check Over): 보드가 가득 차고, 인접한 칸끼리 더 이상 합칠 수 없는 상태인지 전수 조사한다. 더 이상 움직일 수 없다면 Game Over를 선언한다.

 

 

 

 

 

실제로 구현해보자

0. 에셋 임포트(였던 것...)

유니티 에셋 스토어에서 2048 무료 에셋이 있는지 확인해보았다. 마침 관련 에셋이 하나 있어서 유니티 프로젝트에 임포트했다.

하지만! 알고보니 이건 2048 게임을 만들어놓은 프로젝트였다. 특히나 에셋이 2015년 출시 버전 그대로라 2026년에 이걸 사용하기에는 다소 무리가 있었다... 버전 차이로 인한 레거시 코드 문제가 존재했기 때문이다. 2015년에는 GUIText라는 기능을 제공했는데, 이 기능이 현재의 TextMeshPro, UnityEngine.UI.Text로 대체되었다. 따라서 스크립트에 포함된 GUIText 기능을 사용할 수 없었다.

이 문제를 해결하고 해당 에셋을 레퍼런스로 참고해서 2048 게임을 구현해볼까 고민했다. 하지만 결론적으로는 이 에러는 추후 해결하고 일단 새하얀 도화지에 처음부터 그림을 그리는 편이 좋을 것 같다고 판단을 내렸다. 그래서 레퍼런스를 참고하지 않고 일단 내 생각대로 구현해보기로 했다!

 

 

 

 

 

1. 데이터 레이어 구성하기

1-1. 유니티 프로젝트 구조 잡기

Scene을 만들고, 그 위에 Canvas 하나를 추가한다. 나는 모바일 기준으로 생각해서 1080*1920 사이즈로 설정했다.

 

 

1-2. GridManager 클래스 설계

이제 위에서 분석했던 데이터 구조를 그대로 변수로 옮겨올 차례다. 16개의 우편함을 관리하는 GridManager 클래스를 설계한다.

using UnityEngine;

public class GridManager : MonoBehaviour
{
    // [데이터 레이어] 16개의 우편함(기억 장치)
    private int[,] grid = new int[4, 4];
    
    // [설계 레이어] 한 칸의 크기와 간격 (좌표 매핑용)
    [SerializeField] private float cellSize = 1.2f;

    void Start()
    {
        InitializeGrid();
    }
    
    private void InitializeGrid()
    {
        for (int r = 0; r < 4; r++)
        {
            for (int c = 0; c < 4; c++)
            {
                grid[r, c] = 0; // '0'은 빈칸을 의미함
            }
        }
    }
}

 

 

1-3. 화면에 어떻게 출력해야 할까?

이제 배열 주소를 실제 화면에 어떻게 출력해야 할까? 바로 유니티 게임 오브젝트의 기본 컴포넌트, Transfrom을 활용하는 것이다. 즉, 좌표계 개념을 사용해야 한다. 배열의 [r, c] 주소를 유니티의 (x, y)로 바꾸는 과정이 필요한 것이다.

 

위에서 그렸던 배열 인덱스를 기준으로 매핑을 해본다면,

col이 커질수록 오른쪽(+x)으로 이동
row가 커질수록 아래(-y)로 이동

하는 것이다.

 

이걸 코드로 구현하면 아래와 같다.

// 주소를 입력하면 물리적 위치를 알려주는 통역사 함수
public Vector3 GetWorldPosition(int row, int col)
{
    // row 0이 가장 위쪽(높은 Y값)에 있어야 하므로 (3 - row) 연산을 사용
    float x = col * cellSize;
    float y = (3 - row) * cellSize; 
    
    return new Vector3(x, y, 0);
}

이제 변환 역할을 하는 위 메서드를 GridManager에 추가하고, 테스트를 위한 디버깅 코드도 추가로 작성해준다.

 

[GridManager 클래스]

using UnityEngine;

public class GridManager : MonoBehaviour
{
    // [데이터 레이어] 16개의 우편함(기억 장치)
    private int[,] grid = new int[4, 4];
    
    // [설계 레이어] 한 칸의 크기와 간격 (좌표 매핑용)
    [SerializeField] private float cellSize = 1.2f;

    void Start()
    {
        InitializeGrid();
        Debug.Log($"[0,0] 주소의 월드 좌표: {GetWorldPosition(0, 0)}");
        Debug.Log($"[3,3] 주소의 월드 좌표: {GetWorldPosition(3, 3)}");
    }
    
    private void InitializeGrid()
    {
        for (int r = 0; r < 4; r++)
        {
            for (int c = 0; c < 4; c++)
            {
                grid[r, c] = 0; // '0'은 빈칸을 의미함
            }
        }
    }
    
    // 주소를 입력하면 물리적 위치를 알려주는 통역사 함수
    public Vector3 GetWorldPosition(int row, int col)
    {
        // row 0이 가장 위쪽(높은 Y값)에 있어야 하므로 (3 - row) 연산을 사용
        float x = col * cellSize;
        float y = (3 - row) * cellSize; 
    
        return new Vector3(x, y, 0);
    }
}

 

이제 씬에 'Creat Empty'를 하고 이름을 GridManager라고 바꾼다. 그리고 위에서 작성한 GridManager 스크립트를 컴포넌트로 추가해준다. 그리고 테스트를 해보면 로그가 알맞게 출력된다!

해당 좌표를 가시화해보면 1번과 16번에 해당하는 것이다.

 

 

 

 

 

2. 랜덤 스폰

이제 기본 무대는 준비되었으니, 랜덤 스폰 기능을 구현해보자.

 

 

2-1. 논리 설계

컴퓨터는 어떤 칸이 비어있는지 한눈에 알지 못한다. 따라서 16개의 칸을 전수 조사해서 빈칸들의 주소록을 따로 만들어야 한다.

1. 리스트 만들기: grid[r, c] == 0인 모든 좌표 (r, c)를 찾아서 리스트에 담는다.
2. 무작위 선택: 리스트에 담긴 주소 중 하나를 랜덤하게 고른다.
3. 값 결정: 90% 확률로 2, 10% 확률로 4를 서택해 해당 배열 칸에 대입한다.

이 과정은 간단히 말하면 비어 있는 곳을 모두 조사해서, 그 중 한곳에 '2' 또는 '4'라는 새로운 숫자를 무작위로 스폰(생성)하는 것이다.

 

 

2-2. 코드 구현

GirdManager 클래스에 랜덤 스폰 기능을 담당할 새로운 메서드를 추가해보자. 우리는 (x,y) 좌표 개념으로 관리하고 있기 때문에 좌표를 묶어서 관리하기 위해 유니티의 Vector2Int 구조체를 활용하면 편리하다.

using System.Collections.Generic; // 리스트 사용을 위해 추가

private void SpawnTile()
{
    // 1. 비어 있는 칸(값이 0인 곳)의 주소를 담을 리스트 준비
    List<Vector2Int> emptyCells = new List<Vector2Int>();

    // 2. 2차원 배열 전수 조사
    for (int r = 0; r < 4; r++)
    {
        for (int c = 0; c < 4; c++)
        {
            if (grid[r, c] == 0)
            {
                // 빈칸의 주소(행, 열)를 리스트에 추가
                emptyCells.Add(new Vector2Int(r, c));
            }
        }
    }

    // 3. 빈칸이 있다면 랜덤하게 하나 골라 숫자 채우기
    if (emptyCells.Count > 0)
    {
        int randomIndex = Random.Range(0, emptyCells.Count);
        Vector2Int chosenCell = emptyCells[randomIndex];

        // 90% 확률로 2, 10% 확률로 4 결정
        int spawnValue = Random.value < 0.9f ? 2 : 4;
        grid[chosenCell.x, chosenCell.y] = spawnValue;

        Debug.Log($"[{chosenCell.x}, {chosenCell.y}] 위치에 {spawnValue} 생성!");
    }
}

 

2-3. 초기 세팅 적용

게임이 시작될 때 최소 2개의 타일은 랜덤 스폰되어 있다. 따라서 Start 메서드에서 이 함수를 호출해줘야 한다.

 

[랜덤 스폰까지 포함된 GirdManager 전체 클래스]

using UnityEngine;
using System.Collections.Generic; // 리스트(List) 사용을 위해 필수

public class GridManager : MonoBehaviour
{
    // [데이터 레이어] 16개의 우편함(기억 장치)
    private int[,] grid = new int[4, 4];
    
    // [설계 레이어] 한 칸의 크기와 간격 (좌표 매핑용)
    [SerializeField] private float cellSize = 1.2f;

    void Start()
    {
        // 1. 무대 초기화 (모든 칸을 0으로)
        InitializeGrid();

        // 2. 게임 시작 시 타일 2개 랜덤 생성
        SpawnTile();
        SpawnTile();

        // [테스트] 좌표 매핑 확인 로그
        Debug.Log($"[0,0] 주소의 월드 좌표: {GetWorldPosition(0, 0)}");
        Debug.Log($"[3,3] 주소의 월드 좌표: {GetWorldPosition(3, 3)}");
    }
    
    // 모든 칸을 0(빈칸)으로 비우는 초기화 함수
    private void InitializeGrid()
    {
        for (int r = 0; r < 4; r++)
        {
            for (int c = 0; c < 4; c++)
            {
                grid[r, c] = 0; // '0'은 데이터가 없는 상태를 의미함
            }
        }
    }
    
    // [랜덤 스폰] 비어 있는 칸 중 하나를 골라 2 또는 4를 생성
    private void SpawnTile()
    {
        // 1. 비어 있는 칸(값이 0인 곳)의 주소를 담을 리스트 준비
        List<Vector2Int> emptyCells = new List<Vector2Int>();

        // 2. 2차원 배열 전수 조사하여 빈칸 주소 수집
        for (int r = 0; r < 4; r++)
        {
            for (int c = 0; c < 4; c++)
            {
                if (grid[r, c] == 0)
                {
                    // 빈칸의 주소(행, 열)를 리스트에 추가
                    emptyCells.Add(new Vector2Int(r, c));
                }
            }
        }

        // 3. 빈칸이 있다면 랜덤하게 하나 골라 숫자 채우기
        if (emptyCells.Count > 0)
        {
            // 리스트에서 무작위 인덱스 선택
            int randomIndex = Random.Range(0, emptyCells.Count);
            Vector2Int chosenCell = emptyCells[randomIndex];

            // 90% 확률로 2, 10% 확률로 4 결정
            int spawnValue = Random.value < 0.9f ? 2 : 4;
            
            // 데이터 업데이트: 배열의 x는 row, y는 col 역할을 수행
            grid[chosenCell.x, chosenCell.y] = spawnValue;

            Debug.Log($"[{chosenCell.x}, {chosenCell.y}] 위치에 {spawnValue} 생성!");
        }
    }
    
    // 주소를 입력하면 물리적 위치를 알려주는 통역사 함수
    public Vector3 GetWorldPosition(int row, int col)
    {
        // row 0이 가장 위쪽(높은 Y값)에 있어야 하므로 (3 - row) 연산을 사용
        float x = col * cellSize;
        float y = (3 - row) * cellSize; 
    
        return new Vector3(x, y, 0);
    }
}

 

자, 다시 실행해보자. 그러면 코드에서 설정해주는 Random Value 값에 따라 2 또는 4의 값이 랜덤한 좌표 2곳에서 스폰되는 것을 확인할 수 있다.

3개의 테스트 케이스

그럼 이제 다음 단계로 넘어가보자! 다음 단계는 2048 게임의 핵심, 타일 이동과 병합이다.

 

 

 

 

 

3. 타일 이동과 병합(Move & Merge)

2048의 이동은 단순히 위치를 바꾸는 것이 아니라, 데이터를 압축한다는 관점으로 이해하면 쉽다.

 

 

3-1. 논리 설계

첫 번째, 상하좌우 중 한 방향으로 미는 행위이다(Shift). 선택한 방향에 빈칸(0)이 있다면, 숫자가 있는 타일들을 그 방향의 끝으로 바짝 붙인다.

두 번째, 상하좌우로 인접해 있는 숫자들 중에서 같은 숫자를 합치는 행위이다(Merge). 끝으로 밀려난 타일들 중에서 이동 방향 쪽부터 검사하며, 옆에 나와 똑같은 숫자가 있는지 확인한다. 만약 숫자가 같다면 두 값을 합쳐 하나로 만들고, 원래 자리는 0으로 비운다.

이때 위에서 말한 1인 1회 합병 원칙을 고려한다. 한 번 합쳐진 칸은 해당 턴에서 다시 합쳐질 수 없도록 잠금 장치가 필요하다.

세 번째, 병합하면서 생긴 빈칸을 메우는 행위이다(Re-Shift). 2048 게임에서는 타일을 밀면 중간에 구멍이 뚫려 있지 않고 모든 숫자가 끝까지 딱 붙어 있는다. 따라서 두 번째 단계에서 병합을 하다가 숫자 사이사이에 빈칸이 생긴다면, 다시 한번 그 방향으로 밀어서 중간의 빈칸(0)을 제거해주어야 한다.

 

 

3-2. 코드 구현

'밀기' 행위를 코드로 실현하기 전에 몇가지 고려할 것이 있다. 2048은 보통 모바일로 즐겨하는 게임인데, 모바일 환경에서는 스와이프(Swipe)라는 터치 제스처를 사용한다. 따라서 나는 일단 용이한 테스트를 위해 키보드 기준으로 입력을 감지하고 추후에 수정하는 방향으로 구현하고자 한다.

 

1. 입력 감지

wasd 또는 화살표키로 입력을 감지하는 코드가 필요하다.

void Update()
{
    // 이동 발생 여부를 저장할 변수
    bool moved = false;

    // 1. 위로 이동 (W 또는 위쪽 화살표)
    if (Input.GetKeyDown(KeyCode.W) || Input.GetKeyDown(KeyCode.UpArrow))
    {
        moved = MoveUp();
    }
    // 2. 아래로 이동 (S 또는 아래쪽 화살표)
    else if (Input.GetKeyDown(KeyCode.S) || Input.GetKeyDown(KeyCode.DownArrow))
    {
        moved = MoveDown();
    }
    // 3. 왼쪽으로 이동 (A 또는 왼쪽 화살표)
    else if (Input.GetKeyDown(KeyCode.A) || Input.GetKeyDown(KeyCode.LeftArrow))
    {
        moved = MoveLeft();
    }
    // 4. 오른쪽으로 이동 (D 또는 오른쪽 화살표)
    else if (Input.GetKeyDown(KeyCode.D) || Input.GetKeyDown(KeyCode.RightArrow))
    {
        moved = MoveRight();
    }

    // [변화 감지] 타일이 움직이거나 합쳐졌을 때만 새로운 타일 생성
    if (moved)
    {
        SpawnTile();
    }
}

 

2. 입력값에 따른 상하좌우 이동 및 병합 메서드 추가

// 1. 위쪽으로 이동 (W, UpArrow)
public bool MoveUp()
{
    bool isChanged = false;
    for (int c = 0; c < 4; c++) // 각 열(column)을 순회
    {
        List<int> line = new List<int>();
        // [단계 1: Shift] 위에서 아래 방향(0 -> 3)으로 0이 아닌 숫자 추출
        for (int r = 0; r < 4; r++) if (grid[r, c] != 0) line.Add(grid[r, c]);

        // [단계 2: Merge] 인접한 같은 숫자 병합
        for (int i = 0; i < line.Count - 1; i++)
        {
            if (line[i] == line[i + 1])
            {
                line[i] *= 2;
                line.RemoveAt(i + 1); // [단계 3: Re-Shift] 빈칸 즉시 제거
                isChanged = true;
            }
        }

        // 결과 반영 및 데이터 변화 감지
        for (int r = 0; r < 4; r++)
        {
            int newValue = (r < line.Count) ? line[r] : 0;
            if (grid[r, c] != newValue) { grid[r, c] = newValue; isChanged = true; }
        }
    }
    return isChanged;
}

// 2. 아래쪽으로 이동 (S, DownArrow)
public bool MoveDown()
{
    bool isChanged = false;
    for (int c = 0; c < 4; c++)
    {
        List<int> line = new List<int>();
        // [단계 1: Shift] 아래에서 위 방향(3 -> 0)으로 숫자 추출
        for (int r = 3; r >= 0; r--) if (grid[r, c] != 0) line.Add(grid[r, c]);

        // [단계 2: Merge]
        for (int i = 0; i < line.Count - 1; i++)
        {
            if (line[i] == line[i + 1])
            {
                line[i] *= 2;
                line.RemoveAt(i + 1);
                isChanged = true;
            }
        }

        // 결과 반영 (3부터 거꾸로 채움)
        for (int r = 0; r < 4; r++)
        {
            int newValue = (r < line.Count) ? line[r] : 0;
            if (grid[3 - r, c] != newValue) { grid[3 - r, c] = newValue; isChanged = true; }
        }
    }
    return isChanged;
}

// 3. 왼쪽으로 이동 (A, LeftArrow)
public bool MoveLeft()
{
    bool isChanged = false;
    for (int r = 0; r < 4; r++) // 각 행(row)을 순회
    {
        List<int> line = new List<int>();
        // [단계 1: Shift] 왼쪽에서 오른쪽(0 -> 3)으로 숫자 추출
        for (int c = 0; c < 4; c++) if (grid[r, c] != 0) line.Add(grid[r, c]);

        // [단계 2: Merge]
        for (int i = 0; i < line.Count - 1; i++)
        {
            if (line[i] == line[i + 1])
            {
                line[i] *= 2;
                line.RemoveAt(i + 1);
                isChanged = true;
            }
        }

        // 결과 반영
        for (int c = 0; c < 4; c++)
        {
            int newValue = (c < line.Count) ? line[c] : 0;
            if (grid[r, c] != newValue) { grid[r, c] = newValue; isChanged = true; }
        }
    }
    return isChanged;
}

// 4. 오른쪽으로 이동 (D, RightArrow)
public bool MoveRight()
{
    bool isChanged = false;
    for (int r = 0; r < 4; r++)
    {
        List<int> line = new List<int>();
        // [단계 1: Shift] 오른쪽에서 왼쪽(3 -> 0)으로 숫자 추출
        for (int c = 3; c >= 0; c--) if (grid[r, c] != 0) line.Add(grid[r, c]);

        // [단계 2: Merge]
        for (int i = 0; i < line.Count - 1; i++)
        {
            if (line[i] == line[i + 1])
            {
                line[i] *= 2;
                line.RemoveAt(i + 1);
                isChanged = true;
            }
        }

        // 결과 반영 (3부터 거꾸로 채움)
        for (int r_idx = 0; r_idx < 4; r_idx++)
        {
            int newValue = (r_idx < line.Count) ? line[r_idx] : 0;
            if (grid[r, 3 - r_idx] != newValue) { grid[r, 3 - r_idx] = newValue; isChanged = true; }
        }
    }
    return isChanged;
}

상하좌우 이동에 따른 밀기 및 병합 과정을 각각의 메서드로 구현한다. 이 부분은 추후 하나의 메서드로 통합시키는 과정을 거칠 것이지만, 현재는 구현에 초점을 맞춰서 하드코딩해보겠다.

 

이렇게 [밀기 & 병합 & 빈칸 메우기 과정까지 추가된 GridManager 클래스 코드]는 아래와 같다.

using UnityEngine;
using System.Collections.Generic;

public class GridManager : MonoBehaviour
{
    private int[,] grid = new int[4, 4];
    [SerializeField] private float cellSize = 1.2f;

    void Start()
    {
    	Debug.Log("--- [시스템] 2048 게임 시작 ---");
        InitializeGrid();
        SpawnTile();
        SpawnTile();
        
        Debug.Log($"--- [테스트] 보드 좌상단 [0,0] 좌표: {GetWorldPosition(0, 0)}");
        Debug.Log($"--- [테스트] 보드 우하단 [3,3] 좌표: {GetWorldPosition(3, 3)}");
        
        PrintGrid();
    }

    void Update()
    {
        bool moved = false;

        // 입력 감지 레이어
        if (Input.GetKeyDown(KeyCode.W) || Input.GetKeyDown(KeyCode.UpArrow)) moved = MoveUp();
        else if (Input.GetKeyDown(KeyCode.S) || Input.GetKeyDown(KeyCode.DownArrow)) moved = MoveDown();
        else if (Input.GetKeyDown(KeyCode.A) || Input.GetKeyDown(KeyCode.LeftArrow)) moved = MoveLeft();
        else if (Input.GetKeyDown(KeyCode.D) || Input.GetKeyDown(KeyCode.RightArrow)) moved = MoveRight();

        // 변화가 있을 때만 스폰
        if (moved)
        {
        	Debug.Log("<color=yellow>타일 이동 및 병합 발생!</color>");
            SpawnTile();
            PrintGrid(); 
        }
    }

    // --- 알고리즘 레이어 (상하좌우 메서드) ---

    public bool MoveUp()
    {
    	Debug.Log("위로 이동");
        bool isChanged = false;
        for (int c = 0; c < 4; c++)
        {
            List<int> line = new List<int>();
            for (int r = 0; r < 4; r++) if (grid[r, c] != 0) line.Add(grid[r, c]);

            for (int i = 0; i < line.Count - 1; i++)
            {
                if (line[i] == line[i + 1])
                {
                    line[i] *= 2;
                    line.RemoveAt(i + 1);
                    isChanged = true;
                }
            }

            for (int r = 0; r < 4; r++)
            {
                int newValue = (r < line.Count) ? line[r] : 0;
                if (grid[r, c] != newValue) { grid[r, c] = newValue; isChanged = true; }
            }
        }
        return isChanged;
    }

    public bool MoveDown()
    {
    	Debug.Log("아래로 이동");
        bool isChanged = false;
        for (int c = 0; c < 4; c++)
        {
            List<int> line = new List<int>();
            for (int r = 3; r >= 0; r--) if (grid[r, c] != 0) line.Add(grid[r, c]);

            for (int i = 0; i < line.Count - 1; i++)
            {
                if (line[i] == line[i + 1])
                {
                    line[i] *= 2;
                    line.RemoveAt(i + 1);
                    isChanged = true;
                }
            }

            for (int r = 0; r < 4; r++)
            {
                int newValue = (r < line.Count) ? line[r] : 0;
                if (grid[3 - r, c] != newValue) { grid[3 - r, c] = newValue; isChanged = true; }
            }
        }
        return isChanged;
    }

    public bool MoveLeft()
    {
        Debug.Log("왼쪽으로 이동");
        bool isChanged = false;
        for (int r = 0; r < 4; r++)
        {
            List<int> line = new List<int>();
            for (int c = 0; c < 4; c++) if (grid[r, c] != 0) line.Add(grid[r, c]);

            for (int i = 0; i < line.Count - 1; i++)
            {
                if (line[i] == line[i + 1])
                {
                    line[i] *= 2;
                    line.RemoveAt(i + 1);
                    isChanged = true;
                }
            }

            for (int c = 0; c < 4; c++)
            {
                int newValue = (c < line.Count) ? line[c] : 0;
                if (grid[r, c] != newValue) { grid[r, c] = newValue; isChanged = true; }
            }
        }
        return isChanged;
    }

    public bool MoveRight()
    {
    	Debug.Log("오른쪽으로 이동");
        bool isChanged = false;
        for (int r = 0; r < 4; r++)
        {
            List<int> line = new List<int>();
            for (int c = 3; c >= 0; c--) if (grid[r, c] != 0) line.Add(grid[r, c]);

            for (int i = 0; i < line.Count - 1; i++)
            {
                if (line[i] == line[i + 1])
                {
                    line[i] *= 2;
                    line.RemoveAt(i + 1);
                    isChanged = true;
                }
            }

            for (int r_idx = 0; r_idx < 4; r_idx++)
            {
                int newValue = (r_idx < line.Count) ? line[r_idx] : 0;
                if (grid[r, 3 - r_idx] != newValue) { grid[r, 3 - r_idx] = newValue; isChanged = true; }
            }
        }
        return isChanged;
    }

    // --- 유틸리티 레이어 (초기화, 스폰, 로그) ---

    private void InitializeGrid()
    {
        for (int r = 0; r < 4; r++)
            for (int c = 0; c < 4; c++)
                grid[r, c] = 0;
        Debug.Log("--- [시스템] 그리드 데이터 초기화 완료 (전체 0) ---");
    }

    private void SpawnTile()
    {
        List<Vector2Int> emptyCells = new List<Vector2Int>();
        for (int r = 0; r < 4; r++)
            for (int c = 0; c < 4; c++)
                if (grid[r, c] == 0) emptyCells.Add(new Vector2Int(r, c));

        if (emptyCells.Count > 0)
        {
            int randomIndex = Random.Range(0, emptyCells.Count);
            Vector2Int chosenCell = emptyCells[randomIndex];
            grid[chosenCell.x, chosenCell.y] = Random.value < 0.9f ? 2 : 4;
            Debug.Log($"<color=cyan>[생성]</color> 좌표 [{chosenCell.x}, {chosenCell.y}]에 숫자 '{value}' 배달 완료");
        }
    }

    private void PrintGrid()
    {
        string board = "현재 보드 상태:\n";
        for (int r = 0; r < 4; r++)
        {
            for (int c = 0; c < 4; c++) board += grid[r, c] + " ";
            board += "\n";
        }
        Debug.Log(board);
    }

    public Vector3 GetWorldPosition(int row, int col)
    {
        return new Vector3(col * cellSize, (3 - row) * cellSize, 0);
    }
}

간편한 테스트를 위한 디버깅 로그까지 추가했다. 이제 테스트해보자.

 

 

처음에 게임을 실행하면 아래와 같은 로그가 찍힌다.

게임이 시작되고, 타일이 랜덤 스폰되었다. 디버그 로그를 통해 현재 보드 상태를 확인할 수 있다.

 

이제 아래쪽 화살표를 입력해보겠다.

의도한대로, 타일이 아래로 이동하고, 오른쪽 윗줄에 '2'라는 새로운 타일이 생성되었다.

 

이번에도 아래쪽 방향으로 이동해보겠다.

우리가 아는 2048 게임처럼 타일이 합쳐지고 새로운 타일이 스폰되었다. 우리가 의도한대로 잘 작동되는 것을 확인했으니, 이제 실제 화면에 보이도록 비주얼을 추가해보자.

 

 

 

 

4. 시각화

씬 작업을 할 때가 왔다.

 

 

4-1. 타일 프리팹 만들기

화면에 나타날 '타일'의 생김새를 정의해야 한다. 캔버스 하위의 Background 하위에 타일 하나를 만들고 프리팹화해준다.

 

 

4-2. Tile 클래스 추가

타일 오브젝트가 스스로 자신의 숫자와 색상을 업데이트할 수 있도록 간단한 클래스를 만들고, 씬에서 연결해준다.

using UnityEngine;
using TMPro;
using UnityEngine.UI;

public class Tile : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI numberText;
    [SerializeField] private Image backgroundImage;

    // 타일의 숫자와 시각적 요소를 설정하는 함수
    public void SetValue(int value)
    {
        numberText.text = value.ToString();
        
        if (value == 2) backgroundImage.color = Color.white;
        else if (value == 4) backgroundImage.color = new Color(1f, 0.9f, 0.7f); // 베이지색
    }
}

 

 

4-3. GridManager 클래스 수정

이제 기존에 있던 grid 배열을 실제 타일로 바꾸는 작업을 진행하겠다. 기존 SpawnTile 메서드에서 진짜 타일 프리팹을 생성해주고, 이동이 끝난 후 화면을 새로고침하는 로직이다.

 

using UnityEngine;
using System.Collections.Generic;

public class GridManager : MonoBehaviour
{
    private int[,] grid = new int[4, 4];
    [SerializeField] private float cellSize = 1.2f;

    // --- 비주얼 레이어 ---
    [SerializeField] private GameObject tilePrefab; // 유니티 인스펙터에서 Tile 프리팹을 연결
    [SerializeField] private Transform gridParent;  // 여기에 Canvas 하위의 'Background'를 연결하세요
    private Tile[,] tileObjects = new Tile[4, 4];   // 생성된 타일 오브젝트 관리용 배열

    void Start()
    {
        Debug.Log("--- [시스템] 2048 게임 시작 ---");
        InitializeGrid();
        SpawnTile();
        SpawnTile();
        
        Debug.Log($"--- [테스트] 보드 좌상단 [0,0] 좌표: {GetWorldPosition(0, 0)}");
        Debug.Log($"--- [테스트] 보드 우하단 [3,3] 좌표: {GetWorldPosition(3, 3)}");
        
        PrintGrid();
    }

    void Update()
    {
        bool moved = false;

        // 입력 감지 레이어
        if (Input.GetKeyDown(KeyCode.W) || Input.GetKeyDown(KeyCode.UpArrow)) moved = MoveUp();
        else if (Input.GetKeyDown(KeyCode.S) || Input.GetKeyDown(KeyCode.DownArrow)) moved = MoveDown();
        else if (Input.GetKeyDown(KeyCode.A) || Input.GetKeyDown(KeyCode.LeftArrow)) moved = MoveLeft();
        else if (Input.GetKeyDown(KeyCode.D) || Input.GetKeyDown(KeyCode.RightArrow)) moved = MoveRight();

        // 변화가 있을 때만 스폰
        if (moved)
        {
            Debug.Log("<color=yellow>타일 이동 및 병합 발생!</color>");
            
            // [비주얼] 데이터가 변했으므로 화면의 타일들을 데이터에 맞춰 재배치
            UpdateBoardView();
            
            SpawnTile();
            PrintGrid(); 
        }
    }

    // --- 알고리즘 레이어 (상하좌우 메서드) ---

    public bool MoveUp()
    {
        Debug.Log("위로 이동");
        bool isChanged = false;
        for (int c = 0; c < 4; c++)
        {
            List<int> line = new List<int>();
            for (int r = 0; r < 4; r++) if (grid[r, c] != 0) line.Add(grid[r, c]);

            for (int i = 0; i < line.Count - 1; i++)
            {
                if (line[i] == line[i + 1])
                {
                    line[i] *= 2;
                    line.RemoveAt(i + 1);
                    isChanged = true;
                }
            }

            for (int r = 0; r < 4; r++)
            {
                int newValue = (r < line.Count) ? line[r] : 0;
                if (grid[r, c] != newValue) { grid[r, c] = newValue; isChanged = true; }
            }
        }
        return isChanged;
    }

    public bool MoveDown()
    {
        Debug.Log("아래로 이동");
        bool isChanged = false;
        for (int c = 0; c < 4; c++)
        {
            List<int> line = new List<int>();
            for (int r = 3; r >= 0; r--) if (grid[r, c] != 0) line.Add(grid[r, c]);

            for (int i = 0; i < line.Count - 1; i++)
            {
                if (line[i] == line[i + 1])
                {
                    line[i] *= 2;
                    line.RemoveAt(i + 1);
                    isChanged = true;
                }
            }

            for (int r = 0; r < 4; r++)
            {
                int newValue = (r < line.Count) ? line[r] : 0;
                if (grid[3 - r, c] != newValue) { grid[3 - r, c] = newValue; isChanged = true; }
            }
        }
        return isChanged;
    }

    public bool MoveLeft()
    {
        Debug.Log("왼쪽으로 이동");
        bool isChanged = false;
        for (int r = 0; r < 4; r++)
        {
            List<int> line = new List<int>();
            for (int c = 0; c < 4; c++) if (grid[r, c] != 0) line.Add(grid[r, c]);

            for (int i = 0; i < line.Count - 1; i++)
            {
                if (line[i] == line[i + 1])
                {
                    line[i] *= 2;
                    line.RemoveAt(i + 1);
                    isChanged = true;
                }
            }

            for (int c = 0; c < 4; c++)
            {
                int newValue = (c < line.Count) ? line[c] : 0;
                if (grid[r, c] != newValue) { grid[r, c] = newValue; isChanged = true; }
            }
        }
        return isChanged;
    }

    public bool MoveRight()
    {
        Debug.Log("오른쪽으로 이동");
        bool isChanged = false;
        for (int r = 0; r < 4; r++)
        {
            List<int> line = new List<int>();
            for (int c = 3; c >= 0; c--) if (grid[r, c] != 0) line.Add(grid[r, c]);

            for (int i = 0; i < line.Count - 1; i++)
            {
                if (line[i] == line[i + 1])
                {
                    line[i] *= 2;
                    line.RemoveAt(i + 1);
                    isChanged = true;
                }
            }

            for (int r_idx = 0; r_idx < 4; r_idx++)
            {
                int newValue = (r_idx < line.Count) ? line[r_idx] : 0;
                if (grid[r, 3 - r_idx] != newValue) { grid[r, 3 - r_idx] = newValue; isChanged = true; }
            }
        }
        return isChanged;
    }

    // --- 유틸리티 레이어 (초기화, 스폰, 로그) ---

    private void InitializeGrid()
    {
        for (int r = 0; r < 4; r++)
            for (int c = 0; c < 4; c++)
                grid[r, c] = 0;
        Debug.Log("--- [시스템] 그리드 데이터 초기화 완료 (전체 0) ---");
    }

    private void SpawnTile()
    {
        List<Vector2Int> emptyCells = new List<Vector2Int>();
        for (int r = 0; r < 4; r++)
            for (int c = 0; c < 4; c++)
                if (grid[r, c] == 0) emptyCells.Add(new Vector2Int(r, c));

        if (emptyCells.Count > 0)
        {
            int randomIndex = Random.Range(0, emptyCells.Count);
            Vector2Int chosenCell = emptyCells[randomIndex];
            
            // 1. 생성할 값을 변수에 저장하여 에러 해결
            int spawnValue = Random.value < 0.9f ? 2 : 4;
            grid[chosenCell.x, chosenCell.y] = spawnValue;

            // [비주얼] 실제 타일 오브젝트 생성
            CreateTileObject(chosenCell.x, chosenCell.y, spawnValue);

            Debug.Log($"<color=cyan>[생성]</color> 좌표 [{chosenCell.x}, {chosenCell.y}]에 숫자 '{spawnValue}' 배달 완료");
        }
    }

    // [수정] 부모(gridParent)를 지정하여 타일을 생성하는 함수
    private void CreateTileObject(int row, int col, int value)
    {
        // 'gridParent'를 부모로 지정하여 Background 위에서 생성되도록 함
        GameObject newTileObj = Instantiate(tilePrefab, gridParent); 
        newTileObj.transform.localPosition = GetWorldPosition(row, col); 
        
        Tile tileScript = newTileObj.GetComponent<Tile>(); 
        if (tileScript != null)
        {
            tileScript.SetValue(value); 
            tileObjects[row, col] = tileScript; 
        }
    }

    private void UpdateBoardView()
    {
        // 1. 기존 화면의 모든 타일 제거
        for (int r = 0; r < 4; r++)
        {
            for (int c = 0; c < 4; c++)
            {
                if (tileObjects[r, c] != null)
                {
                    Destroy(tileObjects[r, c].gameObject);
                    tileObjects[r, c] = null;
                }
            }
        }

        // 2. 현재 grid 데이터에 맞춰 다시 타일 생성
        for (int r = 0; r < 4; r++)
        {
            for (int c = 0; c < 4; c++)
            {
                if (grid[r, c] != 0)
                {
                    CreateTileObject(r, c, grid[r, c]);
                }
            }
        }
    }

    private void PrintGrid()
    {
        string board = "현재 보드 상태:\n";
        for (int r = 0; r < 4; r++)
        {
            for (int c = 0; c < 4; c++) board += grid[r, c] + " ";
            board += "\n";
        }
        Debug.Log(board);
    }

    public Vector3 GetWorldPosition(int row, int col)
    {
        // UI 환경이므로 cellSize를 150~200 정도로 인스펙터에서 키워주세요
        return new Vector3(col * cellSize, (3 - row) * cellSize, 0);
    }
}

코드는 위와 같이 수정하고, 특히 타일은 Cavas 하위의 Background에서 생성되도록 코드를 추가해주었다. 씬에 배치된 GridManager에 Tile 프리팹과 타일판을 연결해준다. 이제 테스트를 해보자.

 

거친 느낌이 있지만, 우리가 알고 있는 2048 게임의 프로토타입이 완성되었다.

 

 

 

 

 

다음 내용은...

지금까지 2048을 본격 분석하고 데이터 구조부터 이동/병합 알고리즘, 기본적인 시각화 작업까지 진행해보았다. 다음 포스팅에서는 이번에 작성한 코드를 최적화하고, 타일이 부드럽게 이동하는 애니메이션과 숫자에 따른 다양한 컬러 테이블을 적용하는 비주얼 작업 진행 내용을 들고 오겠다. 알고리즘이 실제 게임으로 바뀌는 과정을 담은 퍼알공 첫 시리즈, 다음편도 기대해주세요!

오랜만에 들고 온 <젤다의 전설> 분석 세 번째 시리즈! 오늘은 '트레루프'에 대해서 전격 분석해보고자 한다.

 

 

 

 

 

3. 트레루프

영상에서 볼 수 있듯이 '트레루프'란, 플레이어 위에 있는 물체를 디딤돌 삼아 통과해 위로 이동하는 스킬이다. 그리고 놀라운 사실은, (나 또한 이글을 작성하면서 첨 알게 된 사실인데) 트레루프는 원래 개발 단계에서 디버그 용으로 쓰였던 '치트'였다는 것이다. 젤다 왕눈은 지상에 총 147개의 동굴이 존재하는데, 개발진들이 테스트 단계에서 동굴에서 지상으로 탈출하기가 번거로워 테스트 시간을 줄이기 위해 만들었던 치트키였다. 그런데 실제로 해보니 트레루프라는 기능이 너무 편하고 재미있어서 정식 기획으로 채택되었다고 한다. 왕눈 유저인 나 또한, 게임을 플레이할 때 트레루프 기능을 정말 요긴하게 사용하는데 이런 비화가 있다는 것이 새롭고 재밌다!

 

그렇다면 이러한 트레루프는 과연 어떻게 구현된 것일까? 그리고 이 스킬 뒤에 숨겨진 역할은 또 무엇일까?

 

 

 

 

 

아마도 이렇게 구현하지 않았을까?

이미지 출처는 이미지를 클릭

트레루프 스킬을 실행하면 위 이미지처럼 링크가 천장을 조준하는 연출이 나온다. 녹색 빛을 띄면 통과할 수 있다는 의미이고, 반대로 붉은 빛을 띄면 통과할 수 없다는 의미이다. 이때 내부적으로는 어떤 연산 과정을 거칠까?

 

아마도, 수직 레이캐스팅 -> 충돌체(콜라이더) 감지 ->  평면 판정 과정을 거칠 것으로 예상된다.

 

레이캐스팅(Raycasting)은 이전에 다른 글들에서 몇번 언급한 적이 있기에 아주 간단히만 설명하자면, 특정 지점에서 특정 방향으로 가상의 선을 쏘아 부딪히는 물체를 찾는 함수이다. 레이저의 원리와 똑같다.

이 과정은 링크의 정수리 방향으로 무한대에 가까운 직선(Ray)를 쏘는 과정이다. 링크는 서 있을 때에만 트레루프 스킬을 사용할 수 있다. 즉 링크 자체를 하나의 직선으로 생각했을 때, 천장과는 수직에 근접한 직선을 쏘는 것이다.

하지만, 항상 수직을 이루지는 않을 것이다. 왜냐하면 맵의 형태는 다양하고 그에 따라 울퉁불퉁한 지형이 많기 때문이다.

이 사진은 야숨 맵에 해당되지만, 왕눈 맵과 유사하기 때문에 첨부해보았다. 이런 식으로 지형이 매우 울퉁불퉁하고 동굴은 이러한 지상의 하위에 위치하기 때문에 실제로는 아주 다양한 각도가 출력될 것이다.

따라서 충돌체를 감지할 때, 위의 이미지처럼 수직을 이루는 형태, 즉 '법선 벡터'를 이룰 때에만 통과할 수 있도록 판정을 설계했을 것이다.

 

즉 깔끔하게 알고리즘화해보자면,

1. 레이가 천장에 충돌하면 해당 지점의 면의 방향(Normal Vector)를 가져온다.
2. 이 벡터(n)와 완전한 수직 아래 방향 벡터(v) 사이의 내적을 계산해 각도를 구한다.
3. 만약 각도가 약 15~20도 이상 벌어지면, 서 있을 수 없는 곳으로 판단하고 트레루프를  False로 처리한다. (이것이 곧 붉은 빛)

이렇다. 여기서 2번 연산에 대해, 두 벡터가 서로 얼마나 어긋나 있는지를 계산할 때, 두 벡터가 거의 일직선상에 있다면 내적값은 1에 가깝다는 것이고, 그 천장은 아주 평평하다는 뜻이다. (참고)

그리고 각도를 확인해서 링크가 올라갔을 때 미끄러지지 않고 서 있을 수 있다면 True(녹색 빛), 서 있을 수 없다면 False(붉은 빛)을 시각적으로 표현해준다.

 

 

 

 

 

연출의 또 다른 역할

트레루프를 실행하면 링크가 초록색 소용돌이 속에서 수영하는 듯한 연출이 나온다. 이 연출에 대해 다수의 유저는 통과하는 지면의 높이(두께)에 따라 달라진다고 생각했고, 나도 게임을 플레이하면서 무의식적으로 그런 느낌을 받았다. 하지만 실제로는 그렇지 않았다.

이는 곧 성능의 차이에 있었다.

젤다 맵은 매우 방대하기에, 지표면 아래(동굴)에 있을 때는 지상 데이터를 보관하고 있을 확률이 크다. 그리고 링크가 천장을 뚫고 올라가는 그 몇초의 시간동안에 (짧게는 1초, 길게는 10초) 링크가 올라갈 지상 지형과 주변의 오브젝트 데이터를 재로딩하는 작업을 수행하는 것이다. 유저에게는 단조로운 로딩 화면 대신, 몰입을 도와주는 역동적인 애니메이션 영상을 통해 로딩 시간을 느끼지 않도록 한다.

 

 

 

 

 

마무리

젤다의 전설 시리즈는 정말 뜯어보면 뜯어볼수록 정말 치밀하게 설계된 게임이 아닐수가 없다는 사실을 깨닫곤 한다.

블로그글 중

 

컴퓨터 그래픽스의 최적화를 이해해보자

게임을 하다가 렉에 걸렸다.최근에 게이밍 노트북으로 Len's Island라는 인디 게임을 하다가 렉이 걸려 몇 초 동안 화면이 멈췄던 적이 있다. 나의 컴퓨터 사양이 해당 게임에서 권고하는 최저 사양

hesther-ssu.tistory.com

지난번 그래픽스 최적화 1편에서 성능 관련 이야기를 하다가 유니티 프로파일러를 간단히 언급한 적이 있다. 오늘은 유니티 프로파일러에 대해서 찬찬히 살펴보고자 한다.

 

 

 

글 시작에 앞서

유니티 프로파일러는 성능 분석 도구이기에 최적화를 떼어놓고는 얘기할 수 없다. 유니티에서 매시기 업데이트되는 LTS에 따라서 매년 업데이트되는 문서들 중 하나가 최적화 문서이다. 오늘 블로그 글은 해당 문서를 참고해서 작성해볼 예정이다. 해당 문서에 포함된 유니티 프로파일러 내용들을 직접 따라해보며 작성한 글이다.

 

 

 

프로파일링 기초

프로파일링이란 무엇인가? 흔히 범죄자의 심리와 행동을 분석해 범인을 찾아내는 '프로파일러'라는 직업이 먼저 떠오른다. 게임 개발에서의 프로파일링도 이와 본질적으로는 비슷하다. 보이지 않는 곳에서 게임의 성능을 깎고 있는 범인, 즉 '병목현상'의 흔적을 추적해나가는 과정이기 때문이다.

 

유니티 프로파일러는 런타임 중 발생하는 지연 현상, 프리징 현상 등의 원인을 감지하고, 특정 시점에 엔진 내부에서 어떤 일이 벌어지고 있는지 이해하도록 돕는 성능 분석 도구이다.

 

프로파일링의 기본은 실제 환경과 동일한 조건에서 데이터를 수집하는 것이다. 유니티 에디터에서의 성능은 실제 기기(안드로이드, iOS)와 다르기 때문에, 반드시 개발 빌드를 타겟 기기에 직접 올려서 테스트해야 한다. 개발 빌드는 아래 과정을 통해서 추출할 수 있다.

 

File > Build Profiles 경로를 통해 확인한다. 빌드 프로파일즈(Build Profiles), 이전 이름은 빌드 세팅이다. Unity 6부터 이름이 개편되었다고 한다. 이 기능을 통해서 여러개의 프로파일을 관리할 수 있다. 이미지를 통해서 알 수 있듯이 macOS, Android, iOS, Widnows, Linux 등등의 다양한 플랫폼에 알맞게 빌드 환경을 설정할 수 있다.

 

Development Build를 체크하면, autoconnect profiler도 체크할 수 있게 된다.

이 설정에서 Development Build와 Autoconnect Profiler를 체크하고 빌드하면 빌드 파일이 기기에서 실행됨과 동시에 프로파일러가 자동으로 연결되어 분석을 시작할 수 있다.

 

프로파일링을 시작할 때는 우선 CPU와 Memory 트랙을 기본으로 활성화한다. 프로파일러는 아래와 같이 확인할 수 있다.

Unity > Preferences > Analysis > Profiler 경로를 통해서 프로파일러 설정을 확인한다. 만약 병목현상의 원인을 더 자세히 추적하고 싶다면, 맨 아래에 있는 'Target Frames Per Second'를 기본 300에서 최대 2,000까지 늘려 더 긴 시간의 기록을 확보할 수 있다.

 

이제 프로파일러 그래프를 볼 차례다. 프로파일러 그래프는 Window > Analysis > Profiler 경로를 통해 확인한다. 지금은 앱이 실행중이지 않기 때문에, 아무런 표시도 없지만 실제로 앱이 실행되면 아래처럼 각각의 항목에 수치가 표시되어 그래프가 완성된다.

 

 

이런 식으로 말이다. 양쪽 이미지는 Target Frame Time에 차이가 있다. 같은 환경, 같은 앱이어도 Target Frame Time에 따라 그래프가 다르게 잡힌다.

 

 

 

Hierachy와 Timeline

이렇게 수집되는 데이터들은 두 가지 관점에서 분석할 수 있다.

Hierachy 뷰는 쉽게 말하면 계층뷰, 즉 그룹화해서 보여주는 것이다. 프로파일 마커(ProfileMarkers)를 그룹화하여, 어떤 함수가 가장 많은 시간을 차지하는지(Time ms), 가비지 컬렉션(GC Alloc)을 얼마나 유발하는지 정렬해서 보기 좋다. 따라서 병목현상의 원인을 찾는데 수월하다.

Timeline 뷰는 시간대별로 확인하는 것이다. 특정 프레임 내에서 연산들이 어떻게 실행되는지 시각적으로 분해해서 보여준다. 위에 첨부됐던 사진들이 Timeline 뷰로 데이터가 표현된 그래프이다. 이를 통해 여러 스레드 간의 관계를 파악하고, 현재 게임이 CPU 연산 때문에 느린건지, 아니면 GPU 렌더링을 기다리느라 느린지를 확실히 구분할 수 있다.

 

 

CPU 사용 프로파일러 모듈 - Unity 매뉴얼

CPU 사용 프로파일러 모듈은 애플리케이션에서 시간을 소비한 위치를 표시하는 차트를 제공합니다. 이 모듈은 애플리케이션이 시간을 소비하는 모든 주요 영역(예: 렌더링, 스크립트, 애니메이

docs.unity3d.com

Timeline 뷰 등 프로파일러 모듈에 대해서 더 자세히 알려주고 있다. 궁금하다면 참고해보자!

 

 

 

심층 분석이 필요할 땐 Profile Analyzer

단일 프레임 현상만 분석해서 원인을 찾는 것이 부족할 수 있다. 이때는 Profile Analyzer라는 한층 심화된 도구를 사용할 수 있다. 이 도구는 유니티에서 제공하는 패키지 형식의 추가 도구이기 때문에 사용하려면 import를 먼저 해주어야 한다.

그런데 유니티 6버전부터는 새롭게 개편된 것들이 많아 일반적으로 곧바로 Package Manager에서 검색해서 찾는다면 나오지 않을 수 있다. 그렇다면 아래와 같은 과정으로 패키지를 임포트해보자.

 

먼저 프로젝트 세팅으로 들어가서 패키지 매니저를 찾는다.

여기서 Advanced Settings로 미리보기 패키지 버전을 보여주는 것에 대한 체크 항목이 있다. 유니티 6 이상 버전에서는 이 옵션을 켜야만 Analyzer를 검색했을 시 목록에 나타난다.

 

오래전에 다운받아놓았던 2020년 버전으로 실제로 따라하며 진행하려니 어려움이 좀 있었다. 찾아보니 Unity 6 버전으로 프로파일링에 대한 문서가 존재했다. (너무 늦게 알았다...)

https://unity.com/kr/blog/use-unity-6-profiling-tools-smart-efficient

 

Unity 6에서 프로파일링 도구를 효율적으로 사용하는 방법 | Unity

Unity 6에서 애플리케이션을 프로파일링하고, 메모리를 관리하며, 전력 소비를 최적화하는 방법에 대한 실행 가능한 조언을 얻으세요.

unity.com

혹시 Unity 6 이상 버전에서의 프로파일링 도구가 궁금하다면 위 사이트를 통해 정보를 얻는 것이 좋을 것 같다.

 

 

 

간단히 그래프를 해석해보자!

프로파일러 그래프를 간단히 분석하고 글을 마무리해보고자 한다.

먼저 중앙에 있는 노란선CPU Usage를 뜻한다. 노란색 그래프를 통해서 현재 연산 속도는 약 15~16ms 내외이며, 30FPS 기준으로 평균선 아래에 위치가히 때문에 안전한 수치라는 것을 알 수 있다.

전체 메모리 상태는 하단에 있는 바 그래프 형식을 통해 확인할 수 있다. 이는 현재 자원 점유율을 보여준다.

가장 왼쪽의 녹색 영역Managed Heap 영역이다. 스크립트와 시스템 객체들이 점유하는 힙 메모리가 전체 1.42GB 중 1.22GB로 전체의 상당 부분을 차지하고 있는 것을 알 수 있다. 따라서 새로운 객체가 생성될 때마다 GC가 발생해 순간적인 끊김을 유발할 위험이 크다.

그 다음의 하늘색 영역Graphics & Graphics Driver로 렌더링에 필요한 그래픽 자원이다.

마지막으로 하단 통계의 Objects stas부분이다. 여기서 Textures 영역을 보면 1,103개의 텍스트가 500MB를 쓰고 있다는 것을 알 수 있다. 모바일 환경이라면 텍스처 압축이나 해상도 최적화가 절실한 수치이다.

 

이렇게 유니티 프로파일러를 활용한다면 우리가 볼 수 없는 정확한 수치와 데이터를 가시적으로 볼 수 있는데 큰 도움을 받을 수 있다. 프로파일러를 자주 들여다보는 개발자가 된다면, 프로젝트의 최적화를 전문적으로 이끌 수 있는 날이 올 것이다.

1편에 이어 2편을 들고 왔다. 오늘은 '렌더링'에 대한 구체적인 이야기를 다루겠다.

 

 

 

비실시간 렌더링(영화, 애니메이션)

출처는 이미지를 클릭

지난 편에서 게임은 실시간 렌더링이라고 했다. 반대로 비실시간(Non-Realtime) 렌더링이라는 것도 있다. 영화나 애니메이션이 여기에 해당한다.

영화나 애니메이션에서는 물리적으로 정확한 라이팅을 표현하기 위해서 물리 기반 라이팅을 적용하거나, 레이트레이싱 알고리즘을 적용하여 사실적인 그래픽을 만들어낸다. (32p)

이는 쉽게 말해, 과거에는 '빛처럼 보이게 흉내 내는 기술'을 썼다면, 이제는 '빛의 물리적 성질을 컴퓨터가 계산하게 만드는 기술'로 진화했다는 뜻이다.

 

 

1. 물리 기반 라이팅(PBR: Physically Based Rendering)

출처는 이미지를 클릭

예전의 그래픽 방식은 빛의 물리적 계산을 생략하고 '조명이 이쪽에서 오면 이쯤에서 밝겠지?'라고 추측해서 텍스처에 미리 밝고 어두운 것을 그려 넣거나 고정된 공식을 썼다. 반면 PBR 방식은 물체에 '성질'을 부여하는 것이다. (성질: 모든 물체는 고유의 반사율과 거칠기 값을 가진다.) 물체가 '빛을 어떻게 반사하고 흡수하는지'에 대한 물리 값을 가지고 있는 것이다. 덕분에 어떤 환경에서도 물리 법칙에 맞춰 자동으로 계산되고 보여지기 때문에 실물처럼 자연스러워 보인다.

 

 

2. 레이트레이싱(Ray Tracing) 알고리즘

출처는 이미지를 클릭

빛(Ray)의 경로를 추적(Tracing)하는 기술이다. 빛이 어디서 와서 어디로 튀는지 끝까지 쫓아가는 것이다. 예전에는 내 눈에 보이는 물체만 대충 그렸다면(마치 연극 무대 조명처럼 보이는 곳만 밝힌 느낌), 레이트레이싱은 수억 개의 빛줄기가 뿜어져 나온다고 가정하고, 그 줄기들이 부딪히고 튕기고 뚫고 지나가는 길을 전부 추적하는 것이다. 반사, 굴절, 간접광까지 모든 빛의 경로를 계산한다.

 

PBR vs. 레이트레이싱

PBR은 물체 표면의 성질에 집중하고, 레이트레이싱은 빛의 이동 그 자체에 집중한다.

PBR은 보통 로컬 라이팅 방식에서 많이 쓰인다. 즉 내눈앞의 물체와 바로 옆에 있는 조명 하나만 계산해도 그럴듯한 질감을 낼 수 있다.(상대적으로 계산량이 적다) 반면, 레이트레이싱글로벌 라이팅 방식이다. 호면에 보이지 않는 저 뒤쪽 조명이 바닥에 튕겨서 내 눈앞의 물체를 밝히는 것까지 계산해야 하므로, 전체 공간을 다 뒤져야 한다.(계산량이 압도적으로 많다)

현대 그래픽스에서는 이 두가지를 함께 사용해 시너지를 낸다. 레이트레이싱이 빛줄기를 추적해서 물체에 딱 닿게 가져다주면, PBR이 그 빛을 받아서 물체의 성질에 맞게 반짝이거나 흡수하도록 표현하는 것이다.

 

 

 

사실적인 그래픽, 슈렉의 법칙

시간이 지남에 따라 훨씬 더 복잡한 계산과 고품질의 렌더링을 위한 연산 시간이 늘어나고 있다. 일명 슈렉의 법칙. 슈렉의 다음 시리즈가 제작될 때마다 이전 편보다 필요한 렌더링 시간(연산량)은 2배 이상 늘어난다.

출처는 이미지를 클릭

시리즈 개봉 연도 총 렌더링 소요 시간 (CPU 시간 기준)
슈렉 1 2001년 약 500만 시간
슈렉 2 2004년 약 1,000만 시간
슈렉 3 2007년 약 2,000만 시간
슈렉 4 2010년 약 4,500만 시간 이상

 

비슷한 맥락으로 아래 이미지는 특수 효과에서 머리카락 및 털 표현 기술의 발전에 관한 인포그래픽이다.

출처는 이미지를 클릭

개봉 연도 작품명 주요 렌더링 대상 1초당 렌더링 소요 시간 비고
1995년 틴 토이 (Tin Toy) 아기 캐릭터의 머리카락 36시간 초창기 CG 기술 적용
2001년 몬스터 주식회사 설리반의 전신 털 (Fur) 8시간 하드웨어 발전으로 일시적 단축
2012년 메리다와 마법의 숲 메리다의 곱슬머리 960시간 물리 시뮬레이션의 복잡도 급증
2013년 몬스터 대학교 수천 명의 캐릭터 털 696시간 물리 시뮬레이션의 복잡도 급증

 

 

 

FPS? Fist Person Shooter(총 게임)가 아니라 Frame per second

Frame per second. 초당 몇 프레임을 렌더링할 수 있느냐(초당 화면이 몇 번 바뀌는가)를 말해주는 단위이다. 흔히 렌더링 속도를 측정하는 기준이다. 기본적으로 FPS가 높을수록, 초당 나타나는 이미지가 많을수록 움직임이 부드러워진다.

게임에서는 장르와 플랫폼에 따라 선호하는 프레임 수치가 다르다.

프레임 수 용도 및 특징
30 FPS 콘솔 게임 및 모바일 게임의 최소 기준. 영화(24fps)보다는 부드럽지만, 빠른 조작에는 약간의 답답함이 느껴질 수 있다. 사양이 낮은 환경에서 타협점으로 선택한다.
60 FPS PC 게임 및 액션 게임의 표준. 인간의 눈이 '부드럽다'고 느끼는 가장 대중적인 기준이다. 조작 반응 속도(Input Lag)가 준수하여 대부분의 개발자가 목표로 삼는 수치이다.
144 FPS+  FPS(슈팅), 레이싱 등 고주사율 모니터 기준. 0.1초의 반응이 중요한 경쟁 게임에서 선호한다. 사양이 매우 높아야 하며, 최적화가 극한으로 되어 있어야 달성 가능하다.

앞서 렉이 걸렸던 경험에 대해서는 연산량이 너무 많아져 GPU가 초당 그려내야 할 프레임 수를 채우지 못했기 때문(저프레임)에 발생한 것이다. 예를 들어 60FPS 기준으로 수치를 잡아놨는데, 그 이하의 프레임이 그려져서 뚝뚝 끊기는 것처럼 느껴지는 것이다.

사람의 눈은 밝기에 따라서 100FPS까지도 인지하지만 보통은 60FPS까지만 인지할 수 있고, 일부는 30FPS까지만 인지하기도 한다. 그래서 대부분의 게임들이 40~60FPS를 목표치로 삼고 있다.

 

 

 

쓰로틀링(throttling)

최근에 PC와 모바일 모두에서 즐길 수 있는 크로스플랫폼 게임 <명일방주: 엔드필드>를 모바일로 즐기면서 핸드폰이 자주 뜨거워지고는 했다. 아무래도 화려한 3D 그래픽과 실시간 전투 시스템 때문에 80GB에 육박하는 게임을 팬(Fan)이 없는 모바일로 즐기려다 보니 한계가 있다고 느꼈다.

쓰로틀링이란 이렇게 기기의 발열이 심해져서 온도가 일정 이상 높아질 때, 발열을 낮추기 위해 자동으로 기기 성능을 낮추는 기능을 의미한다. PC나 콘솔은 쿨러가 장착되어 있어 쓰로틀링 상태로 진입할 일이 거의 없지만, 모바일 기기는 쓰로틀링 상태로 진입하는 경우가 빈번하다. 쓰로틀링 상태에 빠지면 기기의 연산 속도가 느려지기 때문에 모바일 게임에서는 쓰로틀링 상태에 빠지지 않도록 게임을 최적화시키는 것이 중요하다.

 

 

 

그래픽스 API

PC, 모바일 등등 기기에 따라 다양한 벤더들에 의해 제조되는 GPU를 사용한다. 소프트웨어 개발자가 이러한 GPU의 프로토콜을 고려해서 렌더링 소프트웨어를 만들어내는 것이 불가능에 가까운데, 이를 가능케 해주는 것이 있다. 바로 DirectX, OpenGL과 같은 그래픽스 API이다.

출처: 유니티 그래픽스 최적화 스타트업 38p

Metal, OpenGL ES, OpenGL, DirectX 등의 그래픽스 API에 상응하는 GPU 칩셋과 OS를 연결해놓은 그림이다.

유니티 같은 상용 게임 엔진은 이러한 그래픽스 API들을 알아서 처리해주지만, 유니티에서 그래픽스를 최적화하려면 그래픽스 API가 렌더링 처리하는 과정을 알고 있어야 한다. 게임 개발자들에게 익숙한 것은 아무래도 OpenGL일 것이다. 역사도 깊고 그래픽스 파이프라인의 기초를 배우기에는 OpenGL이 가장 좋은 도구 중 하나이기 때문이다.

하지만 요즘은 OpenGL 대신 Vulkan, DirectX12, Metal과 같은 최신 API를 선호하는 경우도 빈번하다. 이유는 전편에서 다뤘던 '병목'현상과 관련이 있다. OpenGL은 알아서 다 해주는 방식이라 편리한 반면, 그만큼 시스템 내부에서 불필요한 계산(오버헤드)이 많아 CPU 병목을 유발한다. 또한 요즘 CPU는 멀티코어인데, OpenGL은 이 시스템과의 최적화가 부족하다.

오버헤드 이슈와 함께 애플에서 만들어낸 것이 바로 iOS 전용 그래픽스 API, Metal이다. 메탈은 범용성을 보장하지 않지만 특정 하드웨어(iOS)에서만 성능을 보장하여 연산 비용을 대폭 줄였다. 이러한 애플의 Metal에 맞서 OpenGL을 만든 크로노스 그룹에서 발표한 차세대 그래픽스 API가 바로 Vulkan이다. Vulkan은 Metal과의 차별성을 두기 위해 안드로이드 및 타이젠 등의 OS에서 지원된다.

 

 

유니티에서의 그래픽스 API 설정

유니티 6000.0.58f2 버전

유니티의 Project Setting>Player에서 그래픽스 API 사항을 확인할 수 있다. 이 옵션이 체크되어 있으면, 유니티는 실행 시 해당 기기의 하드웨어를 감지하여 가장 적합한(안정적인) 그래픽스 API를 자동으로 선택한다.

Windows는 Direct3D 11이 기본으로 사용되며, Mac은 Metal, Linux는 Vulkan이나 OpenGLCore를 사용한다.

 

 

 

렌더링 파이프라인 개요

게임 루프

출처는 이미지를 클릭

게임 루프(실행부터 종료까지)를 간단하게 보면 아래와 같다.

1. 리소스가 생성되는 초기화 과정(Initialization) 과정
2. 물리, 입력, 로직, AI, 네트워크, 애니메이션 등이 연산 되는 업데이트(Update) 과정
3. 오브젝트들을 화면에 그려주는 렌더(Render) 과정
4. 종료하기 위한 해제(decommissioning) 과정

여기서 우리가 집중해야 할 것은 1. 업데이트 과정 후에 렌더링이 이루어진다. 2. 이러한 업데이트와 렌더링이 매번 반복된다. 이다. 유니티 게임 오브젝트의 스크립트 라이프 사이클 또한 이러한 게임 루프와 유사한 흐름이다.

출처는 이미지를 클릭

언젠가 블로그에 유니티 라이프 사이클을 언급한 적이 있었는데, 다시 한번 첨부해본다.

흐름을 살펴보면,

1. 초기화(Initialization)
2. 업데이트(Physics, Input Events, Game Logic)
3. 렌더링(Scene-Gizmo-GUI rendering, End of frame, Pausing)
4. 해제(Disable/enable, Decommissioning)

으로 게임 루프와 똑같다.

 

 

렌더링 루프

화면 내의 많은 오브젝트들은 한번에 렌더링되는 것이 아니라 순차적으로 렌더링된다. 그렇다면 오브젝트들 사이에서는 어떤 기준으로 렌더링 순서가 정해질까? 책에는 나오지 않았지만 궁금해 찾아보았고, 크게 3가지 규칙에 따라서 렌더링 순서가 정해진다고 한다.

 

1. 렌더링 큐(Render Queue)와 불투명/투명 구분

가장 먼저 오브젝트가 불투명한지 투명한지에 따라 큰 순서가 나뉜다. 불투명한 오브젝트는 앞뒤 순서로 그려서 오버드로우를 방지하고, 투명한 오브젝트는 뒤앞 순서로 그리는데 이 과정에서 필레이트 소모가 커진다.

2. 드로우 콜(Draw Call) 최적화

유니티에서는 CPU가 GPU에게 명령을 내리는 횟수인 드로우콜을 줄이기 위해, 같은 머터리얼을 사용하는 오브젝트들을 모아서 한꺼번에 그리려고 한다. 이걸 배칭(Batching, 하나의 묶음)이라고 한다. 배칭에는 정적 배칭, 동적 배칭, GPU 인스턴싱(같은 메쉬를 사용하는 오브젝트(예: 나무, 풀)를 한번에)가 있다.

3. 정렬 레이어(Sorting Layer)와 정렬 순서(Order in Layer)

유니티 6000.0.58f2 버전

주로 2D 게임이나 UI에서 명시적으로 순서를 정하고 싶을 때 사용한다. Sorting Layer와 Order in Layer는 이미지에서처럼 UI를 그리는 Canvas 컴포넌트에서 확인할 수 있다.

 

 

 

렌더링 파이프라인

오브젝트 하나를 렌더링하기 위해 필요한 데이터로는 크게 4가지가 있다.

1. 오브젝트의 입체적 형태를 나타내는 메시(Mesh) 정보
2. 알베도, 노멀, 스페큘러 등의 텍스처(Texture) 정보
3. 라이팅을 어떻게 처리할지 결정하는 쉐이더(Shader) 정보
4. 위치, 회전, 스케일을 정의하는 트랜스폼(Transform) 정보

그리고 이러한 데이터를 갖고 있는 오브젝트를 2D 이미지상으로 그리는 과정(3D 공간의 데이터를 화면(2D)으로 변환하기 위한 일련의 과정)을 렌더링 파이프라인이라고 한다.

 

렌더링 파이프라인은 크게 3가지 파트로 구분해볼 수 있다.

애플리케이션 스테이지 >>> 지오메트리 스테이지 >>> 래스터라이저 스테이지

이 중에서 지오메트리 스테이지에 대해서 집중적으로 정리해본다.

 

지오메트리 스테이지

지오메트리(Geometry). 한국어로 기하학이라는 뜻이고, 게임 그래픽스에서는 오브젝트의 외형을 이루는 수학적 구조를 뜻한다. 지오메트리는 버텍스와 폴리곤으로 구성되어 있고, 지오메트리 스테이지 단계에서는 3D 공간에 존재하는 정점들을 계산해서 우리가 보는 2D 화면상의 적절한 위치에 배치하는 역할을 담당한다. 오브젝트를 이루는 데이터 구성 단위는, 버텍스(Vertex), 엣지(Edge), 폴리곤(Polygon), 메쉬(Mesh) 순으로 구성들이 커진다.

유니티 6000.0.58f2 버전

그중, 폴리곤의 선을 와이어프레임이라고 한다. 유니티에서는 Scene의 Gizmo를 통해서 확인할 수 있다.

 

버텍스 트랜스폼

이제 오브젝트가 실제로 화면에 배치되는 과정을 살펴보겠다. 3D 모델을 구성하는 버텍스들은 여러 공간을 거치며 최종적으로 우리가 보는 화면 좌표로 계산된다.

 

출처는 이미지를 클릭

1. 로컬 스페이스(Local Space)

모델 자체가 주인공인 공간이다. 모델을 처음 제작할 때의 기준점으로, 모델의 중심(Pivot)이 (0, 0, 0)이 된다. 캐릭터가 팔을 흔들거나 고개를 돌려도 로컬 좌표계상의 버텍스 위치는 변하지 않는다.

2. 월드 스페이스(World Space)

게임 속 전체 세상(World)을 기준으로 하는 공간이다.로컬 스페이스의 모델에 트랜스폼(위치, 회전, 스케일) 정보를 적용하여 게임 월드의 특정 좌표에 배치한다. 비로소 다른 오브젝트들과의 상대적인 위치 관계가 형성된다.

3. 뷰 스페이스(View Space)

월드 스페이스에서 끝나는 것이 아니다. 모델은 결국 카메라를 통해 보여진다. 유니티에서 카메라는 기본적으로 원점(0, 0, 0)에 위치한다. 하지만 다른 위치와 방향을 가질수도 있기 때문에 모델의 버텍스들의 좌표는 카메라가 어디에 위치하든 카메라 중심의 좌표로 재계산된다.

4. 프로젝션 스페이스(Projection Space)

오브젝트들은 3차원 공간에 있지만 최종적으로 오브젝트가 렌더링되어 디스플레이되는 공간은 2차원이다. 이렇게 3D ->2D로 매칭하는 과정을 '프로젝션'이라고 한다. 3D 공간에 원근감을 적용하여, 멀리 있는 것은 작게 보이고 가까운 것은 크게 보이도록 수학적 변환을 거친다. 이 과정에서 카메라의 시야 범위(Frustum) 밖에 있는 버텍스들은 잘려 나가는 클리핑(Clipping)이 발생한다.

 

 

이렇게 모델은 4단계를 거쳐서 배치된다. 그렇다면 이 배치 과정, 즉 연산을 누가 할까? 바로 버텍스 쉐이더다.

 

버텍스 쉐이더

모델이 공간을 이동하며 화면에 배치되는 모든 연산 과정은 GPU 내의 작은 프로그램인 버텍스 쉐이더(Vertex Shader)가 담당한다. 버텍스 쉐이더는 오브젝트를 구성하는 모든 정점 하나하나에 접근하여 실시간으로 위치와 형태를 계산한다.

버텍스 쉐이더의 가장 기본적인 임무는 앞서 살펴본 로컬-월드-뷰-프로젝션으로 이어지는 행렬 연산을 수행하는 것이다. 수만 개의 정점을 동시에 처리하는 GPU의 병렬 구조 덕분에, 복잡한 공간 이동 연산을 아주 짧은 시간 안에 마칠 수 있다.

한편 단순히 위치만 옮기는 데 그치지 않고, 수학적 계산을 통해 메쉬의 형태를 실시간으로 왜곡시킬 수 있다. 정점의 방향 정보(Normal)를 따라 버텍스를 확장시키면, 캐릭터의 얼굴이 벌에 쏘여 부어오른 듯한 효과를 낼 수 있다. 바람에 흔들리는 풀잎이나 파도의 출렁임처럼 규칙적인 움직임도 버텍스 쉐이더 내에서 주기적인 함수(Sin, Cos 등)를 계산하여 구현한다.

 

 

 

 

 

마무리

이번 포스팅에서는 비실시간 렌더링실시간 렌더링의 차이부터, 우리 눈에 보이는 화면이 만들어지기까지의 핵심 단계인 지오메트리 스테이지까지 깊이 있게 살펴보았다. 다음편은 배칭과 컬링에 대한 내용을 들고 오겠다.

 

오늘은 게임 서비스 측면에서의 핵심 테스트 기법들을 정리해보고자 한다. 단순히 '실행이 잘 된다.'를 넘어, 발생 가능한 모든 예외 상황을 시뮬레이션하고 서비스의 안정성을 확보하는 과정, 테스트이다.

 

 

 

정적 테스트 vs. 동적 테스트

테스트는 프로그램의 실행 여부에 따라 정적 테스트 또는 동적 테스트로 나뉜다.

 

정적 테스트

프로그램을 실행하지 않고 소스 코드나 기획서를 분석하는 방법이다. 개발 초기 단계에서 버그를 잡을수록 수정 비용이 기하급수적으로 줄어든다. 코드 리뷰, 워크 쓰루(Walk-through), 인스펙션(Inspection) 등이 포함된다.

 

워크쓰루 vs. 인스펙션

워크쓰루는 개발주체(작성자)가 주도해서 동료들에게 자신의 결과물을 설명하고 피드백을 받는 비정형적인 검토 회의다. 본인의 코드나 기획서를 동료들에게 풀어서 설명(Walk-through)하고, 참여자들은 설명을 들으며 의문점을 제기하거나 개선 아이디어는 공유한다.

인스펙션은 워크쓰루보다 체계적인 분위기를 갖추고 있다. 전문적인 역할을 가진 사람들이 참여해 체크리스트(CL)를 바탕으로 결함을 찾아내는 정형화된 검토 회의다. 여기서 '전문적인 역할을 가진 사람들'이란 단순히 직급이 높은 사람을 뜻하는 것이 아니라, 검토 프로세스를 완수하기 위해 분담된 기능적 역할을 의미한다. 중재자, 작성자, 검토자, 기록자, 검사자 등이 있다.

- 중재자: 인스펙션의 전체 과정을 이끄는 가장 중요한 역할이다. 검토 대상이 되는 코드의 대상자가 아닌 제3자가 맡는 것이 원칙이다. 보통 팀의 리드 개발자나 PM이 맡아, 회의를 중재하고 관리하며 정해진 시간 내에 모든 체크리스트를 확인하도록 독려한다.
- 작성자: 검토 대상이 되는 코드나 문서를 직접 만든 사람이다. 인스펙션 결과로 발견된 결함을 회의 종료 후 실제로 수정할 책임을 갖는다.
- 검토자: 회의에서 코드나 기획서를 한 구절씩 읽으며 해석하는 역할이다. 작성자가 의도한 대로가 아닌 '실제로 쓰인 대로' 코드를 해석하게 만들어서, 이 과정에서 작성자의 의도와 실제 구현 사이의 간극(버그)을 발견한다.
- 기록자: 회의 중 발생하는 모든 결함과 이슈를 공식적으로 기록하는 사람이다.
- 검사자: 순수하게 결함을 찾아내기 위해 참여하는 전문가 집단이다. 자신의 전문 분야(보안, 성능, UI/UX 등) 관점에서 CL를 대조하며 문제를 제기한다.

 

 

 

동적 테스트

프로그램을 실제로 실행하여 입력값에 따른 출력값을 확인하는 방법이다. 우리가 흔히 말하는 테스트의 대부분이 여기에 해당한다. 블랙박스 테스트, 화이트박스 테스트, 리그레션 테스트 등등..

 

블랙박스 테스트

내부 코드를 모른 채 입력과 출력값만 확인하는 기법이다. 유저가 마주할 실제 게임 플레이 환경을 대변한다. QA가 CL 기반으로 테스트 결과를 Pass/Fail로 나누는 과정은 주로 기능 테스트에 해당하는데, 이 테스트가 대표적으로 블랙박스 테스트의 범주에 속한다.

기능 테스트는 게임의 각 요소가 기획서대로 동작하는지 확인하는 가장 기본적인 테스트이다. 기획 단계에서 정의된 요구사항들을 세부 항목으로 쪼개어 체크리스트 혹은 테스트 케이스(TC)를 만든다. 이를 하나씩 수행하며 실제 동작이 기획과 일치하는지의 여부에 따라 Pass 또는 Fail을 부여한다.

 

화이트박스 테스트

내부 코드를 모두 들여다본다. 소스 코드의 구조와 경로를 직접 확인하는 기법으로 주로 개발자가 수행한다. 대표적으로 단위 테스트 또는 유닛 테스트라고도 하는 테스트가 화이트박스 테스트의 범주에 속한다.

유닛 테스트에서 유닛은 코드의 최소 단위인 함수나 클래스를 의미한다. 이것들이 독립적으로 정확히 작동하는지 검증하는 것이 유닛테스트다.

구분 유닛 테스트 (Unit Test) 기능 테스트 (Functional Test)
카테고리 화이트박스 테스트 블랙박스 테스트
테스트 대상 코드의 최소 단위 (함수, 클래스) 사용자에게 제공되는 기능 (상점, 전투)
관점 개발자 관점 (로직의 무결성) 사용자/기획자 관점 (비즈니스 요구사항)
기준 문서 상세 설계서, 소스 코드 기획 명세서, 요구사항 정의서
예시 Add(a, b) 함수가 a+b를 반환하는가? '구매' 버튼을 누르면 재화가 깎이고 아이템이 들어오는가?

 

특수 목적 테스트

게임 서비스의 비기능적 품질을 보장하기 위해 수행하는 테스트로 리그레션 테스트, 호환성 테스트 등이 대표적이다.

리그레션 테스트는 Regression Test로 회귀 테스트를 뜻한다. 버그 수정이나 기능을 추가한 후에 기존에 잘 작동하던 기능들이 영향을 받지 않았는지 다시 확인하는 것이다.

호환성 테스트는 말그대로 다양한 하드웨어, OS, 네트워크 환경에서 게임이 균일하게 구동되는지 확인하는 것이다. 화면 해상도에 따라 ui/ux가 다르게 보이고 특히 게임 화면 일부가 잘려나간다거나, OS 버전에 따라 푸시 알람이 미작동하는 오류를 방지한다.

 

 

 

마무리

테스트에 대해서 공부해볼수 있는 자료 몇가지를 첨부한다.

https://learn.microsoft.com/en-us/shows/software-testing-fundamentals/01

 

Software Testing Fundamentals: (01) Testing Fundamentals

Get a basic understanding of programming and application lifecycle management (ALM). [05:30] - Software Testing [14:13] - Fundamentals of Programming Full course outline: Mod 01: Testing Fundamentals Mod 02: Testing Methodologies Mod 03: Creating Software

learn.microsoft.com

https://docs.unity3d.com/kr/Packages/com.unity.test-framework@2.0/manual/index.html

 

Unity 테스트 프레임워크 개요 | Test Framework | 2.0.1-exp.2

Unity 테스트 프레임워크 개요 Unity 테스트 프레임워크를 사용하면 편집 모드와 플레이 모드에서 코드를 테스트할 수 있으며 스탠드얼론, Android, iOS 등과 같은 타겟 플랫폼에서도 테스트할 수 있

docs.unity3d.com

https://books.google.co.kr/books/about/How_Google_Tests_Software.html?id=vHlTOVTKHeUC&redir_esc=y

 

 

최근 프로젝트에서 게임 튜토리얼을 만들다가 구현 과정에서 큰 고민이 생겼다. 화면 전체를 어둡게 덮은 상태에서 특정 버튼이 있는 위치에만 구멍을 낸 것처럼(하이라이트를 해줘서) 시선을 한 곳에 끌도록 해줘야 했다. 유니티 기본 Mask로는 해결이 되지 않아서 방법을 찾아보다가, Unmask라는 것을 알게 되었다. 오늘은 내가 이해한 렌더링의 원리와 가장 편했던 해결 방법을 정리해본다.

 

 

 

스텐실 버퍼

처음에는 단순히 이미지를 투명하게 깎아내는 기능인 줄 알았지만, 스텐실 버퍼라는 개념이 그 가운데에 있었다.

 이는 쉽게 말하면, 화면의 모든 픽셀마다 붙어있는 작은 포스트잇과 같다. 각 픽셀에 0과 1이 적힌 포스트잇이 각각 적혀있는 것이다. 화면을 구성하는 모든 픽셀에는 기본적으로 0이라고 적힌 숫자의 포스트잇이 붙어있다. 그리고 여기에 내가 구멍을 뚫고 싶은 위치에 마스크 이미지를 배치하면, 해당 영역의 픽셀 포스트잇 숫자만 1로 바뀐다.

이제 그 위에 덮을 '어두운 배경 이미지'에 조건을 건다. 보통은 숫자가 1인 곳에만 그리라고 명령하지만, 'Unmask'는 숫자가 1인 곳을 피해서 그리라고 명령하는 것이다. 결과적으로 숫자가 1인 부분은 아무것도 그려지지 않아 뻥 뚫린 것처럼 보이게 된다.

 

 

 

패키지 사용하기

유니티 기본 기능을 수정해서 쓰는 것도 좋지만 나는 잘 만들어진 에셋을 사용했다. unMaskForUGUI다.

https://github.com/mob-sakai/UnmaskForUGUI

 

GitHub - mob-sakai/UnmaskForUGUI: A reverse masking solution for uGUI element in Unity.

A reverse masking solution for uGUI element in Unity. - mob-sakai/UnmaskForUGUI

github.com

이 패키지의 경우는 이름 그대로 마스크를 해제하는 기능을 구현해주고 있었다. 컴포넌트를 간단히 추가함으로써 내가 원하는 하이라이트 효과를 만들 수 있었다.

이런식으로 컴포넌트화되어 있고 추가하면 된다. 마스크 영역 안에서 unmask 컴포넌트가 추가된 객체는 배경을 투과시킨다. 이 에셋은 스텐실 값을 비교하고 Color Mask를 조절하는 설정을 인스펙터에서 간단한 조작으로 해결할 수 있었다.

또한 실제로 사용한 건 아니었지만 unmask 에셋을 찾으면서 이미지의 알파값을 조정해서 부드럽게 그려주는 softmask라는 패키지도 발견할 수 있었다.

https://github.com/mob-sakai/SoftMaskForUGUI

 

GitHub - mob-sakai/SoftMaskForUGUI: Enhance Unity UI (uGUI) with advanced soft-masking features to create more visually appealin

Enhance Unity UI (uGUI) with advanced soft-masking features to create more visually appealing effects! - mob-sakai/SoftMaskForUGUI

github.com

 

 

 

참고 자료

https://www.youtube.com/watch?v=f8Zr9_UoFIg

unmask에 대해 알아보다가 이를 잘 설명해었던 영상도 첨부해본다. 영어로 되어있어서 자동번역으로 영상을 시청했다. 참고로 영상에서는 오늘 블로그 글과는 조금 다른, 셰이더의 Color MaskFMF 0으로 설정해서 색상은 안 그리되 스텐실 기록만 남기는 법을 설명해주고 있다.

 

 

 

Stencil ID

영상에서 픽셀 렌더링 값인 Stencil ID라는 개념도 등장했었는데,

유니티에서도 확인할 수 있었다. 스텐실 아이디는 0 ~ 255 사이의 숫자다. 만약 여러 개의 구멍을 낼 때 스텐실 id 값이 꼬이면 드로우 콜이 증가해서 성능에 영향을 끼칠 수 있다고 한다.

스텐실 ID는 보통 두 가지 설정으로 동작하는데, 바로 쓰기와 읽기다.

마스크 이미지가 해당 픽셀 메모장에 1이라고 숫자를 작성하면, 다음에 그려질 이미지가 그 픽셀을 지나갈 때 메모장을 확인하는 방식인 것이다.

요약하면 스텐실 id는 어떤 마스크가 어떤 이미지에 영향을 줄지 결정하는 식별 번호인 것이다.

 

 

마무리

개발을 하면서 확실히 새로운 것을 알게 될 때 어렵지만 재미가 생기는 것 같다. 낯설다고 익숙한 방법만 쓰기보다 새로운 방법을 시도해본다면 더 도움이 될 것 같다.

+ Recent posts