슈팅 게임 만들기 2 - 오브젝트 풀

Jonghwan Choi·2023년 7월 7일
0

포트폴리오

목록 보기
5/5

오브젝트 풀이란

이 프로젝트의 원형인 Unity Learn 예제는 적과 총알을 화면에 등장시키기 위해 그때그때 오브젝트를 생성하고, 화면을 벗어난 적과 총알을 제거하기 위해 그때그때 오브젝트를 파괴하는 방법을 썼다.

그러나 이 방법을 쓰면 힙 메모리에 계속해서 새로운 인스턴스가 할당되고 그 인스턴스 대부분이 일회용으로 사용된 후 곧바로 가비지가 되기 때문에, 어떤 게임 엔진이든 마찬가지지만 특히 가비지 컬렉터 성능이 좋지 않은 유니티에서는 지양해야 할 방식이다. 유니티 가비지 컬렉터의 문제점은 이 글을 참고할 것.

같은 모양의 적과 총알 객체를 반복적으로 활용하는 이번과 같은 경우, 계속 새로운 인스턴스를 생성하는 것보다는 미리 충분한 수의 적과 총알 객체를 생성해 놓고 계속해서 같은 객체를 재활용해서 쓰는 것이 효과적이다. 이 작업을 위해, 미리 생성한 객체들을 담을 용도의 컬렉션과 그 객체 및 컬렉션을 제어하는 기능들을 모아 놓은 클래스를 따로 만드는 게 좋다. 이 클래스를 보통 '오브젝트 풀' 이라 부른다.

오브젝트 풀 만들기

오브젝트 풀은 워낙 많이 쓰이기 때문에 유니티에서 아예 빌트인 라이브러리로 만들어놓았다. 리스트, 딕셔너리, 링크드리스트, 스택, 해시셋 등 객체를 담는 컬렉션 종류에 따라서 다양한 형태의 오브젝트 풀이 준비되어 있다. 그러나 개념적으로 그렇게 어려운 주제도 아니고 이 기회에 오브젝트 풀을 좀 공부해볼까 하는 마음에, 이번에는 인터넷 자료를 참고해서 오브젝트 풀을 직접 구현했다.

구조 참고 글

처음엔 위 글을 참고해서 오브젝트 풀 클래스를 똑같이 만들어놓고 사용했다. 그러다 프로젝트에 맞춰서 이런저런 변형을 하다 보니 결과적으로 뼈대만 같을 뿐 세부사항은 꽤 달라지게 되었다.

큐 대신 리스트 사용

전체적으로는 먼저 객체를 담는 컬렉션을 큐에서 리스트로 교체했다. 원작자의 경우 풀의 객체 제공 기능이 호출되면 제공할 객체를 Dequeue()를 써서 아예 큐에서 제거해버리고, 객체를 풀로 회수할 때는 이 객체가 원래 풀에 담겨 있었던 객체인지 판단하지 않고 그냥 Enqueue()로 큐에 넣는다. 이렇게 하면 오브젝트 풀 입장에서 현재 씬에서 사용중인 객체를 추적하지 못한다는 단점은 있지만, 대신 객체 제공 기능이 호출됐을 때 풀에 있는 객체 중 어떤 객체를 꺼낼지 판단할 필요가 없는 간단한 구조를 만들 수 있다.

그러나 내 경우 스테이지가 끝나면 화면에 나와 있는 모든 적과 총알 객체를 한꺼번에 풀로 회수하는 기능이 필요했다. 그냥 큐를 사용하더라도 현재 화면에 나와 있는 객체들을 검색한 후 풀의 회수 기능을 호출하는 식으로 기능을 구현할 수 있었을 것이다. 그러나 전체 회수가 필요할 때마다 일일이 모든 대상을 검색하기보다는, 애초에 객체 사용 요청이 들어왔을 때 컬렉션에서 객체를 빼내지 말고 계속 들고 있는 쪽이 훨씬 편할 거라고 생각했다. 그래서 컬렉션에서 객체를 빼내지 않아도 다음 객체에 접근할 수 있는 리스트를 큐 대신 사용했다.

코드

오브젝트 풀 초기화 기능

오브젝트들을 생성해서 오브젝트 풀(리스트)에 집어넣는다.
오브젝트 풀은 한번 초기화해 놓고 계속 사용하는 것이 가장 효율적이지만, 내 경우 게임이 5개의 스테이지로 되어 있고 스테이지가 진행될수록 출현하는 적의 종류가 늘어난다. 그래서 오브젝트 풀을 쓰는 의미가 약간 퇴색되는 것을 감수하고, 매 스테이지가 시작할 때마다 오브젝트 풀에 있는 객체를 전부 부수고 새로 초기화하는 식으로 코드를 짰다.

또한 일반적으로 오브젝트 풀은 게임 어디서든 접근할 수 있도록 싱글턴 패턴으로 만드는 경우가 많다고 한다. 내가 참고한 예제도 싱글턴으로 만들어져 있다. 하지만 나는 총알 오브젝트와 적 오브젝트를 위한 2가지 오브젝트 풀을 따로 만들어야 했고, 또 굳이 오브젝트 풀 클래스를 2개 작성하고 싶지도 않았다. 그래서 오브젝트 풀 자체는 그냥 일반적인 클래스로 만들고, 이미 싱글턴 클래스로 만들어뒀던 GameManager를 통해 게임 어디서든 그 2개의 오브젝트 풀 인스턴스를 참조할 수 있도록 구조를 짰다.

타입파라미터설명
intnumOfPrefabTypes풀에 들어갈 프리팹의 갯수
intpoolingCount생성할 오브젝트 갯수

파라미터 numOfPrefabTypes 는 스테이지가 진행될수록 적 종류가 추가되는 것을 구현하기 위한 특수한 장치다.

우선 스테이지에 관계없이 모든 적 프리팹을 인스펙터를 통해 배열로 받았다.
그리고 스테이지마다 이 배열의 몇 번째 인덱스까지 풀에 넣을지를 다르게 설정해서 적 종류가 추가되는 것을 구현했다.
예를 들면 스테이지 1의 오브젝트 풀에는 0, 1, 2번째 프리팹까지 들어가고, 스테이지 3의 풀에는 0, 1, 2, 3번째 프리팹까지 들어가는 식이다.
이 '몇 번째 인덱스까지' 를 나타내는 변수가 numOfPrefabTypes 이다.

public void SetPool(int numOfPrefabTypes, int poolingCount)
{
	// 풀에 들어갈 프리팹 갯수가 애초 인스펙터로 받은 프리팹 갯수보다 많다면 에러
	if (numOfPrefabTypes > prefabsToPool.Length)
	{
		throw new IndexOutOfRangeException(
			"parameter numOfObjectKind should not be larger than the number of prefabs you attached on Prefabs To Pool array in inspector."
		);
	}
	this.numOfPrefabTypes = numOfPrefabTypes;

	if (pooledObjects == null)
	{
		pooledObjects = new List<GameObject>(poolingCount);
	}

	for (int i = 0; i < poolingCount; i++)
	{
		GameObject newObj = CreateRandomObject();
		pooledObjects.Add(newObj);
	}
    // SetPool() 호출 전에 다른 기능을 사용하는 것을 막기 위한 플래그
	isInitialized = true;
}

// 풀에 넣을 새 오브젝트 생성
private GameObject CreateRandomObject()
{
	int randomIndex = UnityEngine.Random.Range(0, numOfPrefabTypes);
	GameObject newObj = Instantiate(prefabsToPool[randomIndex]);
	newObj.SetActive(false);
	newObj.transform.SetParent(transform);
	return newObj;
}

오브젝트 제공 기능

풀에 있는 오브젝트를 씬의 지정한 위치에 지정한 각도로 띄워 준다.

나는 오브젝트 풀을 총알 객체 제공, 적 객체 제공이라는 2가지 용도로 사용했다.
총알은 어차피 프리팹이 하나뿐이라 어떤 객체를 제공할지 판단할 필요 없이 인덱스 순서대로 출력하면 충분했다.

그러나 적의 경우 그냥 순서대로 출력한다면 적 스폰 패턴이 고정되므로 재미가 반감될 것이라 판단해서, 인덱스를 랜덤으로 선택해서 오브젝트를 출력하도록 코드를 짰다. 이렇게 하자 랜덤 인덱스로 이미 사용중인 오브젝트까지 선택하는 버그가 예상되었고, 이를 막기 위해 오브젝트가 사용중인지를 확인해서 미사용 오브젝트가 선택될 때까지 while 루프를 도는 코드가 추가되었다.

타입파라미터설명
Vector3position오브젝트 활성화 위치
Quaternionrotation오브젝트 활성화 각도

인덱스 순서대로 제공 (총알 오브젝트용)

public GameObject GetNextObject(Vector3 position, Quaternion rotation)
{
	if (!isInitialized) throw new Exception("You should call SetPool() method first.");

	currentIndex++;
	if (currentIndex >= pooledObjects.Count) currentIndex = 0;

	GameObject obj;
	if (!pooledObjects[currentIndex].activeInHierarchy)
	{
		obj = pooledObjects[currentIndex];
	}
	else
	{
		obj = CreateRandomObject();
		pooledObjects.Add(obj);
	}
	obj.transform.SetPositionAndRotation(position, rotation);
	obj.SetActive(true);
	obj.transform.SetParent(null);

	return obj;
}

랜덤으로 제공 (적 오브젝트용)

public GameObject GetRandomObject(Vector3 position, Quaternion rotation)
{
	if (!isInitialized) throw new Exception("You should call SetPool() method first.");

	GameObject obj;
	while (true)
	{
		int index = UnityEngine.Random.Range(0, pooledObjects.Count);
		if (!pooledObjects[index].activeInHierarchy)
		{
			obj = pooledObjects[index];
			currentIndex = index;
			break;
		}
	}
	if (obj == null)
	{
		obj = CreateRandomObject();
		pooledObjects.Add(obj);
		currentIndex = pooledObjects.Count - 1;
	}

	obj.transform.SetPositionAndRotation(position, rotation);
	obj.SetActive(true);
	obj.transform.SetParent(null);

	return obj;
}

오브젝트 회수 기능

파라미터로 받은 오브젝트를 비활성화시킨다.
화면 밖으로 나가서 제 역할을 다한 오브젝트를 계속 렌더링하는 낭비를 피하기 위해 사용한다.
지금 생각해보니 Return 보다는 Deactivate 가 더 직관적이지 않았을까 싶다.

타입파라미터설명
GameObjectobj회수할 오브젝트
public void ReturnObject(GameObject obj)
{
	obj.SetActive(false);
	obj.transform.SetParent(transform);
}

오브젝트 파괴 기능

풀 내부의 리스트에 존재하는 모든 게임오브젝트를 파괴한다.
화면상의 모든 오브젝트를 지우는 목적도 있고,
스테이지가 바뀔 때 적 오브젝트 풀을 새로 채울 목적으로 사용한다.

public void DestroyAll()
{
	foreach (GameObject obj in pooledObjects)
	{
		Destroy(obj);
	}
	currentIndex = -1;
	pooledObjects.Clear();
	isInitialized = false;
}

소감

오브젝트 풀의 메커니즘을 직접 체험해볼 수 있어서 좋은 경험이었다. 그러나 오브젝트 풀 같은 건 한번 잘 만들어두면 정말 다양한 곳에서 재활용할 수 있었을 텐데, 이번 코드는 프로젝트에 너무 맞춤형으로 만들다 보니 확장성이 부족한 코드가 된 것 같다. 이 점은 반성해야겠다는 생각이 들었다. 다시 오브젝트 풀을 쓰게 된다면 그냥 빌트인 라이브러리를 쓰게 되지 않을까?

부록: 전체 코드

using System;
using System.Collections.Generic;
using UnityEngine;

public class ObjectPool : MonoBehaviour
{
	// 인스펙터를 통해 오브젝트 풀에 넣을 프리팹들 배열로 받기
	[SerializeField] private GameObject[] prefabsToPool;

	// 오브젝트를 담아둘 컬렉션
    // 여기서는 List<T> 를 썼지만 오브젝트 풀의 용도에 따라 적절한 컬렉션을 선택하면 됨
	private List<GameObject> pooledObjects;
    
	private int numOfPrefabTypes;
	private bool isInitialized = false;
	private int currentIndex = -1;

	// 오브젝트 풀 초기화
	public void SetPool(int numOfPrefabTypes, int poolingCount)
	{
		if (numOfPrefabTypes > prefabsToPool.Length)
		{
			throw new IndexOutOfRangeException(
				"parameter numOfObjectKind should not be larger than the number of prefabs you attached on Prefabs To Pool array in inspector."
			);
		}
		this.numOfPrefabTypes = numOfPrefabTypes;

		if (pooledObjects == null)
		{
			pooledObjects = new List<GameObject>(poolingCount);
		}

		for (int i = 0; i < poolingCount; i++)
		{
			GameObject newObj = CreateRandomObject();
			pooledObjects.Add(newObj);
		}
		isInitialized = true;
	}

	// 풀에 넣을 새 오브젝트 생성
	private GameObject CreateRandomObject()
	{
		int randomIndex = UnityEngine.Random.Range(0, numOfPrefabTypes);
		GameObject newObj = Instantiate(prefabsToPool[randomIndex]);
		newObj.SetActive(false);
		newObj.transform.SetParent(transform);
		return newObj;
	}

	// 인덱스 순서대로 오브젝트를 제공하는 기능
	public GameObject GetNextObject(Vector3 position, Quaternion rotation)
	{
		if (!isInitialized) throw new Exception("You should call SetPool() method first.");

		currentIndex++;
		if (currentIndex >= pooledObjects.Count) currentIndex = 0;

		GameObject obj;
		if (!pooledObjects[currentIndex].activeInHierarchy)
		{
			obj = pooledObjects[currentIndex];
		}
		else
		{
			obj = CreateRandomObject();
			pooledObjects.Add(obj);
		}
		obj.transform.SetPositionAndRotation(position, rotation);
		obj.SetActive(true);
		obj.transform.SetParent(null);

		return obj;
	}

	// 임의의 순서대로 오브젝트를 제공하는 기능
	public GameObject GetRandomObject(Vector3 position, Quaternion rotation)
	{
		if (!isInitialized) throw new Exception("You should call SetPool() method first.");

		GameObject obj;
		while (true)
		{
			int index = UnityEngine.Random.Range(0, pooledObjects.Count);
			if (!pooledObjects[index].activeInHierarchy)
			{
				obj = pooledObjects[index];
				currentIndex = index;
				break;
			}
		}
		if (obj == null)
		{
			obj = CreateRandomObject();
			pooledObjects.Add(obj);
			currentIndex = pooledObjects.Count - 1;
		}

		obj.transform.SetPositionAndRotation(position, rotation);
		obj.SetActive(true);
		obj.transform.SetParent(null);

		return obj;
	}

	// 오브젝트 회수 기능
	public void ReturnObject(GameObject obj)
	{
		obj.SetActive(false);
		obj.transform.SetParent(transform);
	}

	// 풀에 들어있는 모든 오브젝트를 파괴하는 기능
	public void DestroyAll()
	{
		foreach (GameObject obj in pooledObjects)
		{
			Destroy(obj);
		}
		currentIndex = -1;
		pooledObjects.Clear();
		isInitialized = false;
	}
}
profile
유니티 게임 클라이언트 개발자를 꿈꾸는 뉴비

0개의 댓글