유니티 엔진에 아직 숙달되지 않은 개발자들은 초반에 작업할 때, 위의 이미지와 같이 프로젝트 뷰에서 직접 인스펙터 뷰의 프로퍼티에 드래그&드롭으로 리소스를 할당해주는 방식을 사용할 것이다.
이렇게 드래그&드롭으로 리소스를 할당하는 방법은 매우 직관적이고 간편한 방법이지만, 지금처럼 하나의 오브젝트가 아닌 10개의 오브젝트를 세팅해야한다면 어떨까?
이렇게 네 종류의 리소스를 하나의 게임 오브젝트에 끼워넣는 작업에 네 번의 드래그&드롭 작업을 했으니 10개의 게임 오브젝트에 세팅 작업을 하려면 무려 40번의 드래그&드롭 작업을 해야한다.
거기에 만약 작업할 게임 오브젝트의 수가 리그 오브 레전드의 챔피언 숫자처럼 100개가 넘어간다면 400번이 넘는 반복 작업을 해야할 것이다.
생각만 해도 아득해지고 손목이 아파오는 작업이다.
그리고 이렇게 프로젝트 뷰에서 인스펙터 뷰의 프로퍼티로 끌어다가 할당하는 작업은 일종의 정적인 할당에 가깝기 때문에 처음부터 끝까지 수동으로 사람의 손을 거쳐야만 해서 생각보다 많은 작업 시간을 소모하게 만든다.
그래서 이런 반복 작업에 대해서 프로그래머들이 말하는게 있다.
"10번만 반복해야해도 자동화시켜라."
하지만 지금처럼 손으로 끌어다 놓는 방법은 어떻게 해도 자동화가 불가능할 것이다.
그럼 이 문제를 해결하기 위해서 프로젝트에 포함된 애셋을 프로젝트 뷰에서 가져오는 방법을 알아야 한다.
프로젝트 뷰에 Resources라는 이름의 폴더를 만들고 아까 게임 오브젝트에 할당해줬던 애셋들을 생성한 폴더 안으로 이동시킨다.
void Start()
{
prefab = Resources.Load<GameObject>("prefab");
material = Resources.Load<Material>("material");
texture2D = Resources.Load<Texture2D>("texture");
textAsset = Resources.Load<TextAsset>("text");
}
Resources.Load 함수를 사용하면 Resources 폴더 안에 들어있는 애셋들을 스크립트에서 불러와서 사용할 수 있게 된다. 그리고 <> 괄호 안에는 게임 오브젝트는 GameObject, 머티리얼은 Material, 텍스쳐는 Texture2D, 텍스트 파일은 TextAsset과 같이 해당 애셋의 타입에 맞는 클래스 이름을 넣어주면 된다.
그리고 매개변수로는 Resources 폴더 아래에 있는 애셋의 경로를 넣어주면 되는데, 지금의 예시에서는 애셋들이 폴더 바로 아래에 있어서 애셋 파일의 이름만 넣어주면 된다. 예를 들어서 만약 애셋 종류별로 폴더를 나눠서 정리하기로 해서 texture 애셋을 Texture 폴더 안에 넣기로 했다면, "Texture/texture"로 경로를 적어주면 된다.
코드를 모두 작성하면 자동으로 할당되는지 테스트 해보기 위해서 이미 모든 프로퍼티가 할당되어 있는 컴포넌트를 삭제하고 새로 컴포넌트를 붙여서 프로퍼티를 비워준다.
그 다음 플레이 버튼을 눌러서 게임을 시작해보면 플레이가 시작되는 순간에 비어있던 프로퍼티들이 Resources 폴더 안에서 불러온 애셋들로 채워지는 것을 볼 수 있다.
사실 이 Resources 폴더는 유니티 엔진의 예약 폴더라는 것으로 Resources 클래스를 이용하면 유니티 프로젝트에 포함된 Resources라는 이름의 폴더에 접근할 수 있게 해주는 기능이다.
이 Resources 폴더가 어디에 있든 이렇게 A 폴더 아래에 있든 B 폴더 아래에 있든 Resources.Load 함수를 사용하면 언제든지 그 아래에 있는 애셋을 가지고 올 수 있다. 게다가 A 폴더와 B 폴더, 둘 다 Resources 폴더를 가지고 있어도 무리없이 하나의 Resources 폴더처럼 동작한다.
이렇게 편리하게 프로젝트 안에 있는 애셋을 불러올 수 있는 기능이지만, 한 가지 명심해야할 점이 있다. Resources 폴더에 들어있는 애셋들은 게임이 실행되면 무조건 메모리에 적재된다.
그렇기 때문에 게임이 과도한 메모리를 소모하는 문제를 발생시키고 싶지 않다면, 비교적으로 메모리를 적게 소모하면서 빠르고 손쉽게 가져와야하는 애셋만을 Resources 폴더에 넣어둘 것을 권장한다.
[유니티 어필리에이트 프로그램]
아래의 링크를 통해 에셋을 구매하시거나 유니티를 구독하시면 수익의 일부가 베르에게 수수료로 지급되어 채널의 운영에 도움이 됩니다.
유니티 엔진에서는 게임 속에서 사용되는 월드 좌표계와 로컬 좌표계로 대표되는 월드 공간 외에도 스크린 공간이 있다.
스크린 공간은 단어 그대로, 게임 화면 상에서의 좌표 공간을 의미한다. 그리고 일반적인 UI의 위치와 마우스 위치는 이 스크린 공간의 좌표를 기준으로 동작한다.
에디터에서는 게임 뷰 화면이 스크린 공간이고, 만약 전체화면인 게임이라면 모니터 전체가 스크린 공간이 된다.
우선 스크린 공간의 좌표를 테스트 하기 전에 게임 뷰의 Free Aspect라고 적힌 드롭다운 메뉴를 클릭해서 드롭다운 메뉴를 열어보자. 이것은 게임 뷰에서 보일 게임 화면의 해상도를 설정할 수 있는 곳이다. + 버튼을 누르면 해상도를 설정할 수 있는 대화상자가 표시된다.
1920 x 1080 해상도로 설정해보자.
설정하고나면 16:9 비율로 보이게 된다.
public class ScreenSpaceTest : MonoBehaviour
{
void Update()
{
Debug.Log(Input.mousePosition);
}
}
그럼 이제 C# 스크립트를 하나 생성해서, 마우스 좌표를 출력하는 코드를 작성한다.
그리고 플레이 버튼을 누른 뒤 게임 뷰의 화면 위에서 마우스를 움직여 보면 왼쪽 아래로 갈수록 출력되는 좌표가 (0, 0)에 가까워지고 오른쪽 위로 갈수록 아까 설정한 화면 해상도에 가까운 (1920, 1080)에 가까워지는 것을 알 수 있다. 지금 볼 수 있는 화면 해상도로 표현되는 공간이 바로 스크린 공간이다.
처음에 언급했듯이 이것을 통해서 UI의 위치를 컨트롤 하거나 마우스의 위치를 알아낼 수 있다.
공간 전환
게임의 기능을 구현할 때는 이 스크린 공간의 좌표를 월드 공간의 좌표로 변환하거나 그 반대로 변환하는 방법 역시 자주 사용하게 된다.
스크린 공간 -> 월드 공간
먼저 스크린 공간의 좌표를 월드 공간의 좌표로 변경하는 법을 알아보자. 스크린 공간의 좌표를 월드 공간으로 전환하는 예시는 주로 마우스를 클릭한 위치로 캐릭터를 이동시킨다던가 하는 종류의 처리를 하는데 사용된다.
스크린 공간의 좌표를 월드 공간의 좌표로 전환하는 방법을 배우기 전에 씬을 세팅해보자.
씬에 평면 하나를 만들고 그 평면을 내려다 보도록 카메라를 배치한다.
그리고 하이어라키 뷰에 우클릭하고 스피어 게임 오브젝트를 하나 만든다.
그 다음 생성된 스피어 게임 오브젝트를 프로젝트 뷰에 드래그해서 프리팹으로 만든다. 그 다음엔 씬에 배치되어 있는 스피어 게임 오브젝트는 삭제한다.
public class ScreenToWorld : MonoBehaviour
{
public GameObject prefab;
void Update()
{
if(Input.GetMouseButtonDown(0))
{
var ray = Camera.main.ScreenPointToRay(Input.mousePosition);
그리고 업데이트 함수에서 위와 같이 코드를 작성한다. 이 코드는 W/S 키를 누르면 게임 오브젝트의 Z축을 앞뒤로 움직이고, A/D 키를 누르면 좌우로 움직이게 만드는 코드이다.
맵 만들기
그리고 코드를 모두 작성하면 빈 공간에서는 캐릭터 역할을 할 게임 오브젝트가 아무리 움직여도 티가 안나기 때문에 캐릭터가 움직이는 느낌을 주기 위해서 에디터로 가서 씬 뷰에 바닥을 만들고 적당히 큐브를 깔아 장애물을 만들어 준다.
번외. 게임 오브젝트 색 변경하기
번외로 처음 오브젝트를 만들면 모두 같은 색이라 색상이 구별이 안될 수도 있는데, 게임 오브젝트에 색을 넣어주기 위해서는 프로젝트 뷰에 우클릭한 뒤 [Create > Material] 항목을 선택해서 새 머티리얼을 만들고,
생성한 머티리얼을 선택하고 알베도(Albedo) 값을 원하는 색으로 바꾼 뒤, 씬 뷰에 있는 게임 오브젝트에 넣어주면 된다.
캐릭터 만들기
다시 본론으로 돌아가서 다른 게임 오브젝트와 구별되는 Character 게임 오브젝트를 하나 만든 뒤, Character Controller 컴포넌트를 부착해준다. 그리고 장애물과 캐릭터가 충돌하게 만들기 위해서 Rigidbody 컴포넌트도 추가해준다.
세팅이 끝나고 플레이 버튼을 눌러 게임을 실행한 뒤, WASD 키를 눌러보면 구형 캐릭터가 이리저리 움직이면서 장애물과 충돌하는 것을 볼 수 있다.
문제점 보완하기
카메라가 캐릭터 따라가게 만들기
하지만 아직 만족스러운 상태는 아닌데, 캐릭터를 한 방향으로 계속 움직이면 카메라가 캐릭터를 따라가지 않고, 캐릭터가 화면 밖을 벗어나 버리는 것을 볼 수 있다.
이 문제를 해결하기 위해서 카메라를 캐릭터의 자식 컴포넌트로 만들고 카메라가 위에서 아래로 캐릭터를 내려다보게 만들어보자.
대각선 이동 속도 문제 해결하기
카메라 문제 이외에도 아직 문제는 남아있다.
그 문제는 바로 대각선 이동을 위해서 두 키를 동시에 누르면 발생하는 문제다. 한 번에 한 방향의 이동키만 누르면 속도가 일정하지만, 대각선 방향의 두 개의 키를 동시에 누르면 두 개의 벡터가 합쳐져서 한 방향 키만 눌렀을 때의 이동 벡터보다 길이가 약간 길어져버린다. 이 때문에 정방향 이동보다 대각선 이동의 속도가 더 빨라져버리는 문제가 발생한다.
그렇기 때문에 두 개의 키를 동시에 눌렀을 때 발생하는 이동 벡터의 길이를 한 방향 키만 눌렀을 때 발생하는 벡터의 길이와 같게 만들어 주어야 한다.
GetAxis() 함수를 이용한 이동 방식 구현은 위의 코드와 같이 구현된다. GetAxis() 함수는 유니티 엔진의 프로젝트 세팅에 정의된 축의 값을 가져오는 역할을 하는데, 그 정의된 축에는 "Vertical"과 "Horizontal"이 있다. 이것은 수직과 수평을 의미한다.
앞에서 구현한 WASD의 입력을 일일이 구현하는 방식에 비해서, 이렇게 GetAxis() 함수를 이용해서 축을 가져오는 방식은 여러 가지 장점이 있다. 우선은 간단히 코드만 살펴봐도 앞의 코드에 비해서 구현해야할 코드의 길이가 월등히 짧다. 그리고 WASD의 입력을 일일이 구현하는 방법은 WASD 키를 누를 때만 작동하며 게임 패드의 입력을 구현하려면 따로 구현해야 하지만 GetAxis() 함수를 사용하면 별도의 구현없이 게임 패드의 이동 입력까지 자동으로 지원한다.
[유니티 어필리에이트 프로그램]
아래의 링크를 통해 에셋을 구매하시거나 유니티를 구독하시면 수익의 일부가 베르에게 수수료로 지급되어 채널의 운영에 도움이 됩니다.
기본적인 하이어라키 뷰(Hierarchy View) 상태는 스타트 씬과 비슷하지만 게임이 진행되는 씬이기 때문에 플레이어와 연관된 에셋인 Player Assets 파트와 레벨을 구성하고 있는 Level Assets 파트가 추가되어 있는 것을 알 수 있다.
시스템(System)
시스템으로 분류된 게임 오브젝트는 씬 컨트롤러(Scene Controller), 트랜지션 스타트(Transition Start), 트랜지션 데스티네이션(Transition Destination), VFX 컨트롤러(VFX Controller), 백그라운드 뮤직 플레이어(Background Music Player), 피직스 헬퍼(Physics Helper)가 있다.
이 중에서 씬 컨트롤러와 그리고 백그라운드 뮤직 플레이어는 지난 섹션에서 다루었으니 넘어가도록 하고, 지난 섹션에서 다루기는 했으나 트랜지션 포인트(Transition Point) 컴포넌트를 부착하고 있는 트랜지션 스타트 게임 오브젝트는 약간의 차이가 있으나 가볍게 다루고 넘어가도록 한다.
트랜지션 스타트 게임 오브젝트(Transition Start Game Object)
지난 섹션에서 다루었다시피 트랜지션 스타트 게임 오브젝트는 트랜지션 포인트 컴포넌트가 부착되어 있으며 플레이어의 캐릭터가 콜라이더에 닿으면 플레이어를 다른 씬으로 보내는 역할을 한다.
다만, 스타트 씬에서의 트랜지션 포인트와 다른 점은, 스타트 씬에서는 콜라이더에 닿은 캐릭터가 존재하지 않기 때문에 외부에서 트랜지션 포인트를 호출해서 씬을 전환하는 방식을 사용했다면, 이제부터는 본래의 방식에 맞게 캐릭터가 콜라이더에 닿으면 다른 씬을 보내도록 구성되어 있다는 점이다.
실제로 씬에서 트랜지션 스타트 게임 오브젝트를 선택해서 보면 발판에서 캐릭터가 뛰어내리면 콜라이더에 닿을 수 있게 배치되어 있는 것을 확인할 수 있다.
그리고 원래의 스타트 씬에서는 Trasitioning Game Object 프로퍼티가 비어있었는데 지금은 Ellen이라는 게임 오브젝트가 할당되어 있는 것을 알 수 있다. 이 게임 오브젝트는 씬에 배치 되어있는 플레이어의 캐릭터로, 다른 물체나 몬스터 등의 다른 캐릭터가 아닌 플레이어의 캐릭터만 닿았을 때, 씬을 이동시키기 위해서 할당해둔 것이다.
void OnTriggerEnter2D (Collider2D other)
{
if (other.gameObject == transitioningGameObject)
{
m_TransitioningGameObjectPresent = true;
if (ScreenFader.IsFading || SceneController.Transitioning)
return;
if (transitionWhen == TransitionWhen.OnTriggerEnter)
TransitionInternal ();
}
}
트리거 설정된 콜라이더에 충돌이 발생했을 때 호출되는 OnTriggerEnter2D 콜백 함수를 보면 확실히 알 수 있다. 매개변수로 넘어온 충돌체의 게임 오브젝트와 미리 할당해둔 transitioningGameObject와 비교하여 같을 경우에만, 다른 씬으로 이동시키는 구조이다.
이렇게 하지 않으면 트랜지션 포인트의 콜라이더에 플레이어의 캐릭터가 아닌 총알이나 몬스터가 닿기만 해도 씬이 이동되는 상황을 보게 될 것이다.
유니티 콘텐츠 팀에서 선택한 방법에도 약간의 단점이 있다. 그것은 트랜지션 스타트에 미리 캐릭터를 할당해두는 방식이기 때문에 나중에 캐릭터를 선택할 수 있는 기능을 넣게 된다면, 다른 캐릭터로 시작하면 그 캐릭터는 이 콜라이더에 닿아도 다른 씬으로 이동하지 못할 수도 있다.
이것은 간단하게 만들어진 예시이기 때문에 발생한 문제로, 플레이어 캐릭터에 태그나 레이어를 설정하고, 태그나 레이어로 비교해서 통과시키는 방법으로 해결할 수 있다.
트랜지션 데스티네이션 게임 오브젝트(Transition Destination Game Object)
트랜지션 데스티네이션 게임 오브젝트는 씬 트랜지션 데스티네이션 컴포넌트(Scene Transition Destination Component)와 캐릭터 스테이트 세터 컴포넌트(Character State Setter Component)로 구성되어 있다. 이 게임 오브젝트는 플레이어의 캐릭터가 씬 이동을 할 때 목적지 역할을 한다. 첫 번째 게임 플레이 씬인 Zone1에 두 개가 배치되어 있는데, 처음 게임이 시작되었을 때 배치되는 위치인 트랜지션 데스티네이션 스타트(Transition Destination Start)와 두 번째 게임 씬인 Zone2로부터 넘어왔을 때의 도착 지점인 트랜지션 데스티네이션 프롬 Zone2(Transition Destination From Zone2)가 그것이다.
public class SceneTransitionDestination : MonoBehaviour
{
public enum DestinationTag
{
A, B, C, D, E, F, G,
}
public DestinationTag destinationTag;
[Tooltip("This is the gameobject that has transitioned. For example, the player.")]
public GameObject transitioningGameObject;
public UnityEvent OnReachDestination;
}
씬 트랜지션 데스티네이션 컴포넌트에는 사실상 큰 기능 자체는 존재하지 않고, 씬 컨트롤러(Scene Controller)에서 씬이 전환된 직후에 씬 안에 존재하는 씬 트랜지션 데스티네이션 컴포넌트가 부착된 모든 게임 오브젝트를 가지고 와서 데스티네이션 태그(Destination Tag)를 비교해 일치하는 트랜지션 데스티네이션 게임 오브젝트의 위치에 플레이어의 캐릭터를 이동시키기 위한 표지판 역할을 한다.
캐릭터 스테이트 세터 컴포넌트(Character State Setter Component)
캐릭터 스테이트 세터 컴포넌트는 씬 트랜지션 테스티네이션 컴포넌트와 함께 트랜지션 데스티네이션 게임 오브젝트에 부착된 컴포넌트로, 씬 트랜지션 데스티네이션 컴포넌트가 도착 위치를 지정하는 역할을 한다면 캐릭터 스테이트 세터 컴포넌트는 씬에 도착한 직후의 캐릭터의 상태를 설정하는 역할을 한다.
기본적으로 공개되어 있는 프로퍼티는 Set Character Velocity, Set Character Facing Contents, Set State, Set Parameter가 있으며 Set Character Velocity는 씬에 진입했을 때의 캐릭터의 속도를 설정할 수 있고, Set Character Facing Contents는 캐릭터가 바라볼 방향, Set State는 캐릭터의 애니메이션, Set Parameter는 캐릭터 애니메이터의 매개변수 값을 설정하는 옵션이다.
캐릭터 스테이트 세터 컴포넌트에서 눈여겨 볼 점은 [그림 5]에서와 같이 프로퍼티가 선택되지 않았을 때는 해당 프로퍼티에 연관된 옵션이 보이지 않다가 프로퍼티 값이 true로 설정되면 [그림 6]과 같이 프로퍼티와 연관된 옵션이 보이도록 에디터가 커스터마이징되어 있다는 점이다. 프로젝트 뷰에서 CharacterStateSetterEditor를 검색해서 CharacterStateSetterEditor.cs 파일을 확인해보면 어떤 식으로 프로퍼티 값에 따라서 보여줄 옵션을 설정할 수 있는지 배울 수 있다.
이렇게 유니티 에디터에서 필요하거나 사용되는 옵션만 보여주는 것 만으로도 에디터에서 작업하는 디자이너 개발자의 작업 효율을 크게 상승시킬 수 있다.
[Header("Character Velocity")]
public bool setCharacterVelocity;
public Vector2 characterVelocity;
[Header("Character Facing")]
public bool setCharacterFacing;
public bool faceLeft;
public Animator animator;
[Header("Character Animation State")]
public bool setState;
public string animatorStateName;
[Header("Character Animation Parameter")]
public bool setParameters;
public ParameterSetter[] parameterSetters;
여기에 더불어 Header 어트리뷰트를 사용하면 [그림 7]과 같이 프로퍼티의 분류를 훨씬 명확하게 인지하도록 만들 수 있다.
VFX 컨트롤러 게임 오브젝트(VFX Controller Game Object)
VFX 컨트롤러 게임 오브젝트는 VFX 컨트롤러 컴포넌트(VFX Controller Component)가 부착되어 있다. 이 게임 오브젝트의 목적은 캐릭터가 점프하거나 달릴 때 먼지가 일너나는 들의 이팩트를 관리하는 것이다.
VFX 컨트롤러 컴포넌트(VFX Controller Component)
VFX 컨트롤러 컴포넌트는 이펙트를 관리하는 역할의 컴포넌트이다. 이것은 다른 시스템 컴포넌트와 마찬가지로 싱글톤 패턴으로 만들어졌으며, 같은 이펙트 게임 오브젝트들이 계속해서 생성되고 파괴됨으로써 발생하는 성능 저하를 막기 위해서 오브젝트 풀링 기법을 사용하고 있다. 오브젝트 풀링 기법은 기초적인 최적화 기법으로 오브젝트가 생성/파괴될 때 발생하는 오버헤드를 막기 위해서 오브젝트를 재사용하는 기법을 말한다.
VFX 컨트롤러 컴포넌트에서는 게임에서 사용될 VFX 프리팹들을 리스트에 담아서 관리하는데, 만약 같은 방식으로 생성되어야 하는 VFX지만 상황에 따라서 다른 VFX를 생성해야 경우, 예를 들어 캐릭터가 달릴 때 풀 바닥에서는 풀이 날리는 VFX가 발생해야 하지만, 흙 바닥에서 달릴 때는 흙 먼지가 날리는 VFX가 생성되도록 하기 위해서, Vfx Override로 특별한 경우를 정의하고 있다.
피직스 헬퍼 게임 오브젝트(Physics Helper Game Object)
피직스 헬퍼 게임 오브젝트는 피직스 헬퍼 컴포넌트(Physics Helper Component)를 가진 게임 오브젝트로서 게임 내에서 불리적인 처리에 도움을 주는 역할을 한다.
피직스 헬퍼 컴포넌트(Physics Helper Component)
피직스 헬퍼 컴포넌트는 게임이 플레이되는 상황에서 어디서든지 호출될 수 있기 때문에 싱글톤 패턴으로 작성되어 있으며, 캐릭터가 밟고 서는 플랫폼이나 타일맵에 관련된 물리적인 처리를 담당하고 있다.
사실 피직스 헬퍼 같은 컴포넌트는 개발자가 편의에 따라서 작성하기 나름인 스크립트이다. 게임마다 필요한 물리 연산이나 처리는 모두 다르기 마련이라, 필요한 물리 작업에 따라서 피직스 헬퍼의 내용은 천차만별로 달라질 수 있다. 다만, 이렇게 자주 사용되는 기능을 분류 별로 묶어서 어디서든지 호출할 수 있게 헬퍼 클래스로 만드는 작업은 관련된 기능을 이리저리 흩뜨려 놓는 것보다 확실히 더 나은 효율적인 작업을 보장한다.
프로그래밍에서 오브젝트를 생성하거나 파괴하는 작업은 꽤나 무거운 작업으로 분류된다. 오브젝트 생성은 메모리를 새로 할당하고 리소스를 로드하는 등의 초기화하는 과정으로, 오브젝트 파괴는 파괴 이후에 발생하는 가비지 컬렉팅으로 인한 프레임 드랍이 발생할 수 있다.
이러한 문제를 해결하기 위해서 사용되는 기법이 바로 오브젝트 풀링(Object pooling)이다.
오브젝트 풀링의 개념
오브젝트 풀링의 개념은 아주 간단하다.
먼저 풀링을 할 오브젝트를 담을 오브젝트 풀을 구성한다.
그 다음 풀링할 오브젝트를 생성해서 오브젝트 풀에서 그 오브젝트들을 관리하게 만든다.
외부에서 해당 오브젝트가 필요하면 오브젝트 풀에서 꺼내간다.
오브젝트 풀에서 꺼낸 오브젝트의 사용이 끝나면 오브젝트를 풀에 돌려준다.
오브젝트 풀에서 오브젝트를 가져오려고 할 때, 모든 오브젝트가 이미 사용중이라면 새로운 오브젝트를 생성해서 꺼내준다.
이것이 바로 오브젝트 풀의 개념이다. 게임에 필요한 오브젝트를 미리 생성해서 필요할 때마다 꺼내쓰고 사용이 끝나면 오브젝트 풀에 돌려주는 것이다. 이렇게 함으로써, 오브젝트가 필요할 때마다 생성하고 다 쓰면 파괴하는 것이 아니라, 게임이 시작될 때, 필요한 만큼의 오브젝트만 생성하고, 모자라면 추가로 생성하고, 게임이 끝나면 파괴하는 방식으로 오브젝트의 생성/파괴 횟수를 줄일 수 있게 된다.
오브젝트 풀링 기법은 특히 메모리가 부족하고 CPU의 성능이 낮은 디바이스의 사양이 낮았던 예전에 주로 사용되었던 기법이지만, 현재에 와서도 게임의 최적화를 위해서 많이 사용되며, 모바일 게임이 많이 제작되면서 PC에 비해서 모자란 디바이스 성능으로 인해서 재조명 받는 기법이다.
보통 자주 생성되었다가 파괴되어야 하는 총알이나, 캐릭터가 뛸 때 발생하는 먼지 이펙트 같은 곳에 많이 사용되는 기법이다.
유니티 엔진에서의 구현
기본 세팅
기본적인 세팅은 다음과 같다.
씬 한 가운데 캐릭터의 역할을 할 초록색 박스를 배치했다.
public class Shooter : MonoBehaviour
{
[SerializeField]
private GameObject bulletPrefab;
private Camera mainCam;
void Start()
{
mainCam = Camera.main;
}
void Update()
{
if(Input.GetMouseButton(0))
{
RaycastHit hitResult;
if(Physics.Raycast(mainCam.ScreenPointToRay(Input.mousePosition), out hitResult))
{
var bullet = Instantiate(bulletPrefab).GetComponent<Bullet>();
var direction = new Vector3(hitResult.point.x, transform.position.y, hitResult.point.z) - transform.position;
bullet.transform.position = direction.normalized;
bullet.Shoot(direction.normalized);
}
}
}
}
그리고 박스 게임 오브젝트에는 마우스 클릭 방향으로 총알 게임 오브젝트를 생성해서 발사하는 Shooter 컴포넌트가 부착되어 있다.
public class Bullet : MonoBehaviour
{
private Vector3 direction;
public void Shoot(Vector3 direction)
{
this.direction = direction;
Destroy(gameObject, 5f);
}
void Update()
{
transform.Translate(direction);
}
}
그리고 지정된 방향으로 5초 간 날아가다 자동으로 소멸하는 총알 프리팹 역시 만들어두었다.
이것을 테스트 해보면 마우스를 클릭하면 엄청난 양의 총알 오브젝트가 새로 생성되고 시간이 지나면 모두 파괴되는 것을 볼 수 있다.
이 상황을 프로파일러를 통해서 확인해보면 총알을 생성하는 Instantiate와 파괴하는 Destroy가 어느 정도 성능을 잡아먹고 있는 것을 확인할 수 있다.
오브젝트 풀 구현
public class ObjectPool : MonoBehaviour
{
public static ObjectPool Instance;
[SerializeField]
private GameObject poolingObjectPrefab;
Queue<Bullet> poolingObjectQueue = new Queue<Bullet>();
private void Awake()
{
Instance = this;
Initialize(10);
}
private void Initialize(int initCount)
{
for(int i = 0; i < initCount; i++)
{
poolingObjectQueue.Enqueue(CreateNewObject());
}
}
private Bullet CreateNewObject()
{
var newObj = Instantiate(poolingObjectPrefab).GetComponent<Bullet>();
newObj.gameObject.SetActive(false);
newObj.transform.SetParent(transform);
return newObj;
}
public static Bullet GetObject()
{
if(Instance.poolingObjectQueue.Count > 0)
{
var obj = Instance.poolingObjectQueue.Dequeue();
obj.transform.SetParent(null);
obj.gameObject.SetActive(true);
return obj;
}
else
{
var newObj = Instance.CreateNewObject();
newObj.gameObject.SetActive(true);
newObj.transform.SetParent(null);
return newObj;
}
}
public static void ReturnObject(Bullet obj)
{
obj.gameObject.SetActive(false);
obj.transform.SetParent(Instance.transform);
Instance.poolingObjectQueue.Enqueue(obj);
}
}
게임 오브젝트가 생성되면 제일 먼저 실행되는 Awake() 함수에서는 싱글톤 패턴에서 사용되는 오브젝트 풀 인스턴스에 자신을 할당하는 일과 오브젝트 풀을 초기화하는 Initialize() 함수를 호출한다.
private void Initialize(int initCount)
{
for(int i = 0; i < initCount; i++)
{
poolingObjectQueue.Enqueue(CreateNewObject());
}
}
Initialze() 함수에서는 매개변수로 받은 초기화 갯수 값에 따라서 CreateNewObject() 함수에서 만들어진 새로운 총알 오브젝트를 pooling Object Queue에 넣어준다. 이렇게 게임이 시작하기 전에 사용될 게임 오브젝트를 미리 적절한 갯수를 만들어 줌으로써 게임 플레이 도중에 발생할 과부하를 게임 준비 과정인 로딩으로 돌릴 수 있다. 모두 잘 알겠지만, 플레이어는 아주 조금 더 긴 로딩은 당장 용납하지만, 플레이 도중에 발생하는 렉은 용납하지 못한다.
CreateNewObject() 함수는 poolingObjectPrefab으로부터 새 게임 오브젝트를 만든 뒤 비활성화해서 반환하는 역할을 한다. 이렇게 곧바로 비활성화하는 이유는 아직 오브젝트 풀이 꺼내주지 않은 오브젝트는 비활성화된 것으로써 플레이어의 눈에 띄지 않아야하기 때문이다.
public static Bullet GetObject()
{
if(Instance.poolingObjectQueue.Count > 0)
{
var obj = Instance.poolingObjectQueue.Dequeue();
obj.transform.SetParent(null);
obj.gameObject.SetActive(true);
return obj;
}
else
{
var newObj = Instance.CreateNewObject();
newObj.gameObject.SetActive(true);
newObj.transform.SetParent(null);
return newObj;
}
}
GetObject() 함수는 오브젝트 풀이 가지고 있는 게임 오브젝트를 요청한 자에게 꺼내주는 역할을 한다. 단, 모든 오브젝트를 꺼내서 빌려줘서 poolingObjectQueue에 빌려줄 오브젝트가 없는 상태라면 CreateNewObject() 함수를 호출해서 새로운 오브젝트를 생성해서 빌려준다.
public static void ReturnObject(Bullet obj)
{
obj.gameObject.SetActive(false);
obj.transform.SetParent(Instance.transform);
Instance.poolingObjectQueue.Enqueue(obj);
}
ReturnObject() 함수는 빌려준 오브젝트를 돌려받는 함수이다. 돌려받은 오브젝트를 비활성화한 뒤 정리하는 일을 처리한다.
무엇이든지 빌려줬으면 돌려받아야 한다. 이 원칙은 프로그래밍에서도 매우 중요한 원칙으로 사용한 오브젝트를 제대로 돌려받지 않으면 GetObject() 함수를 호출할 때, 계속해서 새로운 오브젝트를 생성하고 사라지지 않은 오브젝트들이 씬에 쌓이게 된다. 이것이 바로 메모리 누수이다.
코드 작성이 끝나면 씬에 새 게임 오브젝트를 배치하고 오브젝트 풀 컴포넌트를 부착하고 pooling Object Prefab 프로퍼티에 풀링해야할 게임 오브젝트의 프리팹을 할당해주면 된다.
그 다음에는 총알 오브젝트를 생성하는 부분과 파괴하는 부분을 수정해야 한다.
public class Shooter : MonoBehaviour
{
void Update()
{
if(Input.GetMouseButton(0))
{
RaycastHit hitResult;
if(Physics.Raycast(mainCam.ScreenPointToRay(Input.mousePosition), out hitResult))
{
var bullet = ObjectPool.GetObject(); // 수정
var direction = new Vector3(hitResult.point.x, transform.position.y, hitResult.point.z) - transform.position;
bullet.transform.position = direction.normalized;
bullet.Shoot(direction.normalized);
}
}
}
}
우선 생성하는 부분은 Shooter 클래스의 Update() 함수에 Instanitate()로 총알을 생성하던 부분을 ObjectPool.GetObject()로 대체하면 된다.
public class Bullet : MonoBehaviour
{
private Vector3 direction;
public void Shoot(Vector3 direction)
{
this.direction = direction;
Invoke("DestroyBullet", 5f);
}
public void DestroyBullet()
{
ObjectPool.ReturnObject(this);
}
void Update()
{
transform.Translate(direction);
}
}
그 다음 파괴하는 부분은 Bullet 클래스에서 DestoryBullet() 함수에서 ObjectPool.ReturnObject()를 호출해서 오브젝트 풀에 돌려주도록 구현하고 Shoot() 함수에서 Destory() 함수를 호출하던 부분을 Invoke()로 5초 뒤에 DestoryBullet() 함수를 호출하도록 수정한다.
플레이를 해서 테스트 해보면 총알이 일정 갯수만큼 생성되고 나면 더 이상 생성되지 않으며, 시간이 지나면 소멸되어야 하는 총알 오브젝트들이 비활성화되서 오브젝트 풀로 반환되는 것을 확인할 수 있다.
프로파일러를 통해서 확인해보면 최대 생성량에 도달한 이후에는 Instatiate와 Destroy로 인한 작업이 발생하지 않는 것을 볼 수 있다.
[유니티 어필리에이트 프로그램]
아래의 링크를 통해 에셋을 구매하시거나 유니티를 구독하시면 수익의 일부가 베르에게 수수료로 지급되어 채널의 운영에 도움이 됩니다.
이런 식으로 각각의 콜라이더가 따로 충돌을 감지하는 것이 아니라 두 콜라이더가 하나로 취급되어 충돌을 감지하고자 할 때, 컴포지트 콜라이더 2D를 사용하게 된다.
컴포지트 콜라이더 2D 컴포넌트를 사용하는 방법은 위와 같이 통합하고자하는 콜라이더들의 상위에 컴포지트 콜라이더를 추가해주고,
[그림 7]과 같이 하위에 속하는 콜라이더 컴포넌트들의 Used By Composite 프로퍼티를 true로 설정해주면 된다.
그렇게 하면 상위 오브젝트를 선택했을때, [그림 8]과 같이 하위의 콜라이더들이 하나로 통합되어서 보이는 것을 볼 수 있다.
로그에서도 상위 오브젝트에서의 충돌 체크만 발생하는 것을 알 수 있다.
리지드바디 2D(Rigidbody 2D)
컴포지트 콜라이더 2D 컴포넌트를 게임 오브젝트에 부착하면 자동으로 리지드바디 2D 컴포넌트 역시 게임 오브젝트에 부착된다. 때문에 지형지물의 요소에 컴포지트 콜라이더 2D를 사용할 때는 이 리지드바디 2D 컴포넌트의 옵션에 신경써야 한다.
옵션에 신경쓰지 않을 경우, [그림 11]처럼 캐릭터가 지형에 닿자마자 지형이 떨어지거나, 게임이 시작하자마자 지형이 추락해서 지형이 사라지는 모습을 보게 될 수도 있다.
이렇게 컴포지트 콜라이더 2D 컴포넌트를 적용한 게임 오브젝트를 고정된 지형요소로 사용하고자 한다면 리지드바디2D의 바디타입을 Static으로 설정해야한다.
생성 타입(Generation Type)
컴포지트 콜라이더 2D 컴포넌트에는 생성 타입이라는 프로퍼티가 존재하는데 Synchronous와 Manual 옵션이 존재한다.
Synchronous는 [그림 15]처럼 하위 콜라이더들이 변동되는 때마다 바로 통합된 콜라이더를 새로 만드는 옵션이고,
Manual은 컴포지트 콜라이더 2D 컴포넌트가 처음 부착되는 시점 호은 콜라이더 재생성(Regenerate Collider) 버튼을 누른 시점에만 콜라이더를 만드는 옵션이다.
[그림 17]과 같이 아래에 있는 노란 원이 애니메이션에 의해서 위치가 바뀌어도 콜라이더는 따라서 움직이지 않기 때문에 빈 공간에 충돌하는 것을 알 수 있다.
위의 설명에서 알 수 있듯이 Synchronous 옵션은 통합된 콜라이더가 변경되는 애니메이션에 따라서 지속적으로 업데이트가 되어야하는 경우에 사용하고, 반대로 Manual은 통합된 콜라이더가 일반 지형과 같이 변경되는 경우가 없는 경우에 사용된다.
지오메트리 타입(Geometry Type)
그 다음 중요한 옵션은 지오메트리 타입이다. 이 프로퍼티는 콜라이더를 생성할 때 어떤 형태로 생성할 것인가를 결정한다. 옵션 값은 Outlines와 Polygons가 있다.
Outlines로 콜라이더를 생성하면 [그림 19]와 같이 내부에 아무선 선이 없이 생성되고, Polygons로 콜라이더를 생성하면 [그림 20]과 같이 내부에 선이 그어져서 나온다. 이 차이가 의미하는 것은 Outlines 옵션의 경우에는 콜라이더가 외부에 선만 그어져 있고 속은 비어있다는 뜻이고, Polygons 옵션은 내부가 꽉 차있다는 뜻이다.
콜라이더의 내부를 굳이 채울 필요가 있냐고 생각할 수도 있지만, 이것은 확실히 필요한 개념이다. 게임 내에서의 발생하는 충돌 감지나 레이캐스트는 주로 옆에서 날아오기 때문에 Outlines 옵션 만으로도 충분하지만 화면 밖에서 들어오는 터치나 사용자의 클릭 같은 이벤트는 개념적으로 위에서 들어오기 때문에 콜라이더의 내부가 차있는 것이 좋다.
public class Picker : MonoBehaviour
{
private void Update()
{
if (Input.GetMouseButtonDown(0))
{
var hitResult2D = Physics2D.Raycast(Camera.main.ScreenToWorldPoint(Input.mousePosition), transform.up, 0.1f);
Debug.Log("2D Raycast Result :: " + hitResult2D.collider.name);
}
}
}
위와 같이 마우스를 클릭한 지점에서 레이캐스트를 발사해서 콜라이더를 검출하는 코드를 작성해서 Outlines 옵션으로 만들어진 컴포지트 콜라이더와 Polygons 옵션으로 만들어진 컴포지트 콜라이더를 클릭하는 테스트를 진행해보면 그 차이를 명확하게 인지할 수 있다.
[유니티 어필리에이트 프로그램]
아래의 링크를 통해 에셋을 구매하시거나 유니티를 구독하시면 수익의 일부가 베르에게 수수료로 지급되어 채널의 운영에 도움이 됩니다.
이제까지 타일맵에 사용될 타일 이미지를 만들고 가져오는 방법, 타일맵을 만들고 사용하는 방법들을 배웠다. 이제 만들어낸 타일맵을 이용해서 맵을 그려내면 게임 레벨이 될 것이다. 하지만 여기에 아직 부족한 점이 있다.
지금까지 배운 것들로는 맵을 그리기만 할 수 있다. 무슨 말인가 하면, 2D RPG 류의 게임에서는 어떤 타일은 벽이 되서 캐릭터가 이동하는 것을 막는 장애물이 되어야 하고, 플랫폼 게임(Platform Game)에서는 타일이 캐릭터가 딪고 설 바닥이 되어주어야 한다. 즉, 타일에 콜라이더를 추가해서 물리적인 작용이 가능하게 만들어야 한다는 뜻이다.
플랫폼 게임을 만드는데 노란 공이 떨어져서 바닥에 닿으면 튕기게 만들고 싶다고 가정해보자.
노란 공에 물리효과를 주기 위해서 Circle Collider 2D 컴포넌트와 Rigidbody 2D 컴포넌트를 부착해주었다. 그리고 꽤 그럴듯하게 공처럼 튀기게 만들기 위해서 물리 머티리얼(Physics Material)까지 넣어주었다.
하지만 타일맵에 물리적인 컴포넌트가 아무것도 없는 상태이기 때문에 플레이를 시작하면 떨어지는 공은 타일맵을 그냥 통과해버린다.
타일맵 콜라이더 2D 컴포넌트(Tilemap Collider 2D Component)
이전 섹션을 진행해왔다면 하이어라키 뷰에 존재하는 타일맵은 게임 오브젝트하나로 존재하기 때문에 어떻게 콜라이더를 배치해야할지 난감할 수도 있다.
타일맵을 위한 콜라이더를 유니티에서는 이미 제공하고 있다. 타일맵 콜라이더 2D 컴포넌트(Tilemap Collider 2D Component)가 바로 그것이다.
타일맵 컴포넌트가 붙어있는 게임 오브젝트에 타일맵 콜라이더 2D 컴포넌트를 부착하면 씬에서 위의 이미지와 같이 각 타일마다 콜라이더가 생겼음을 알 수 있다.
타일맵에 콜라이더 컴포넌트를 붙인 상태로 다시 게임을 플레이해보면 떨어진 공이 바닥에 맞고 튕기는 것을 볼 수 있다.
[그림 8]을 보면 타일맵 컴포넌트 2D를 이용해서 생성된 콜라이더가 각 타일마다 따로 생성되어 있는 것을 볼 수 있다. 이렇게 분할된 콜라이더는 퍼포먼스 상의 문제와 가끔 이동하는 캐릭터가 콜라이더에 끼어서 움직이지 못하게 되는 등의 문제가 발생할 수 있다.
그런 문제를 해결하기 위해서 제공되는 것이 컴포지트 콜라이더 2D 컴포넌트이다. 이 컴포넌트는 해당 컴포넌트가 붙어있는 게임 오브젝트의 하위에 존재하는 콜라이더들을 하나로 묶어주는 역할을 한다.
컴포지트 콜라이더 2D 컴포넌트를 사용하기 위해서는 타일맵 콜라이더 2D 컴포넌트를 부착한 컴포넌트에 컴포지트 콜라이더 2D 컴포넌트를 부착하고 타일맵 콜라이더 2D 컴포넌트의 Used By Composite 프로퍼티를 체크해주면 된다.
그렇게 하고 나서 씬 뷰에서 타일맵 게임 오브젝트를 선택해보면 초록색 콜라이더 박스가 타일마다 나누어지지 않고 하나로 합쳐져 있는 것을 확인할 수 있다.
하지만 아직 설정이 다 끝나지 않았다. 플레이를 눌러보면 타일맵이 공과 함께 떨어지는 어이없는 상황이 발생한다. [그림 9]를 보면 그 이유를 조금 짐작할 수 있는데 컴포지트 콜라이더 2D 컴포넌트를 추가할 때, 리지드바디 2D 컴포넌트(Rigidbody 2D 컴포넌트)가 자동으로 추가된 것을 알 수 있는데, 리지드바디 컴포넌트는 게임 오브젝트가 외부의 힘이나 토크를 받아 사실적인 물리적인 운동을 보이도록 도와주는 컴포넌트이다.
자동으로 추가된 리지드바디 2D 컴포넌트를 보면 바디 타입(Body Type)이 다이나믹(Dynamic)으로 설정있는 것을 알 수 있다. 즉 타일맵의 리지드바디가 고정된 것이 아니기 때문에 공과 함께 떨어지는 것이다.
바디 타입을 고정(Static)으로 변경하고 실행해보면 [그림 14]와 같이 타일맵이 떨어지지 않고 정상적으로 동작하는 것을 확인할 수 있다.
다만 컴포지트 콜라이더 2D를 사용하는 경우에 주의할 점은 하위에 있는 모든 콜라이더를 하나로 통합하기 때문에, 플랫폼 게임을 만들 때 벽 타일의 콜라이더와 바닥 타일의 콜라이더가 플레이어와 충돌 시 다른 동작을 하게 만들고 싶다면 벽 타일의 타일맵과 바닥 타일의 타일맵을 분리하거나, 캐릭터가 충돌한 방향을 검출해서 벽인지 바닥인지를 검출하는 등의 추가 작업이 필요하다.