Programming
-
오브젝트 풀링 기법
작성 기준 버전 :: 2019.2
프로그래밍에서 오브젝트를 생성하거나 파괴하는 작업은 꽤나 무거운 작업으로 분류된다. 오브젝트 생성은 메모리를 새로 할당하고 리소스를 로드하는 등의 초기화하는 과정으로, 오브젝트 파괴는 파괴 이후에 발생하는 가비지 컬렉팅으로 인한 프레임 드랍이 발생할 수 있다.
이러한 문제를 해결하기 위해서 사용되는 기법이 바로 오브젝트 풀링(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);
}
}
오브젝트 풀링 기법을 구현하는 기초적인 코드의 전체는 위의 코드와 같다.
public static ObjectPool Instance;
private void Awake()
{
Instance = this;
Initialize(10);
}
이 코드를 하나씩 살펴보자. 우선 오브젝트 풀의 경우 오브젝트를 생성하는 어떤 코드에서든 접근이 가능해야하는 경우가 많기 때문에 싱글톤 패턴으로 구현되는 경우가 많다.
[SerializeField]
private GameObject poolingObjectPrefab;
그리고 생성해야할 오브젝트의 프리팹을 가지고 있다. 프리팹을 가지고 있어야 하는 이유는 처음 생성해둔 오브젝트를 모두 꺼내주고 나서, 꺼내준 오브젝트를 미처 돌려받기 전에, 오브젝트를 요청받았을 때, 새로운 오브젝트를 생성해서 꺼내주기 위한 것이다.
Queue<Bullet> poolingObjectQueue = new Queue<Bullet>();
큐(Queue)를 이용해서 생성된 총알 오브젝트를 순서대로 관리한다. 만약 오브젝트 풀에서 오브젝트를 꺼내주는 순서와 돌려받는 순서를 제대로 관리하지 못하면, 하나의 오브젝트를 여러 곳에 빌려주는 등의 문제가 발생할 수 있다.
private void Awake()
{
Instance = this;
Initialize(10);
}
게임 오브젝트가 생성되면 제일 먼저 실행되는 Awake() 함수에서는 싱글톤 패턴에서 사용되는 오브젝트 풀 인스턴스에 자신을 할당하는 일과 오브젝트 풀을 초기화하는 Initialize() 함수를 호출한다.
private void Initialize(int initCount)
{
for(int i = 0; i < initCount; i++)
{
poolingObjectQueue.Enqueue(CreateNewObject());
}
}
Initialze() 함수에서는 매개변수로 받은 초기화 갯수 값에 따라서 CreateNewObject() 함수에서 만들어진 새로운 총알 오브젝트를 pooling Object Queue에 넣어준다. 이렇게 게임이 시작하기 전에 사용될 게임 오브젝트를 미리 적절한 갯수를 만들어 줌으로써 게임 플레이 도중에 발생할 과부하를 게임 준비 과정인 로딩으로 돌릴 수 있다. 모두 잘 알겠지만, 플레이어는 아주 조금 더 긴 로딩은 당장 용납하지만, 플레이 도중에 발생하는 렉은 용납하지 못한다.
private Bullet CreateNewObject()
{
var newObj = Instantiate(poolingObjectPrefab).GetComponent<Bullet>();
newObj.gameObject.SetActive(false);
newObj.transform.SetParent(transform);
return newObj;
}
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로 인한 작업이 발생하지 않는 것을 볼 수 있다.
[유니티 어필리에이트 프로그램]
아래의 링크를 통해 에셋을 구매하시거나 유니티를 구독하시면 수익의 일부가 베르에게 수수료로 지급되어 채널의 운영에 도움이 됩니다.
[투네이션]
[Patreon]
[디스코드 채널]
'Unity3D > Programming' 카테고리의 다른 글
[Unity3d] Input - 키보드 입력과 캐릭터 이동 구현하기 (0) | 2020.02.17 |
---|---|
[Unity3D] Transform - 게임 오브젝트의 공간 정보 (1) | 2020.02.12 |
[Unity3D] Programming - 로딩 씬(Loading Scene) 구현하기(커튼 방식) (0) | 2019.10.31 |
[Unity3D] Programming - 유니티에서의 싱글톤 패턴 활용 (2) | 2019.10.29 |
[Unity3D] DontDestroyOnLoad - 파괴하지 않을 게임 오브젝트 만들기 (0) | 2019.10.29 |