[Unity] 오브젝트 풀링(Object Pooling)

민지홍·2024년 10월 22일

Unity

목록 보기
3/4
post-thumbnail

랩실에서 진행 중인 게임 개발 프로젝트에서 계속 생성되고 삭제되어야 하는 NPC를 구현하기 위해 Instantiate(오브젝트 생성)와 Destroy(오브젝트 파괴)를 사용하였지만, 이 두 함수는 상당히 비용을 크게 먹는다. Instantiate은 메모리를 새로 할당하고 리소스를 로드하는 등의 초기화 과정이 필요하고, Destroy는 파괴 이후에 발생하는 가비지 컬렉팅으로 인한 프레임 드랍이 발생할 수 있다. 그래서 오브젝트 풀링(Object Pooling)이라는 방법을 사용했다. 자주 사용하는 오브젝트를 미리 생성해 놓고 이 것을 사용할 때마다 새로 생성 삭제하는 것이 아닌 사용할 때는 오브젝트 풀한테 빌려서 사용하고 삭제할 때는 오브젝트 풀한테 돌려줌으로써 단순하게 오브젝트를 활성화와 비활성화만 하는 개념이다.

오브젝트 풀링 매니저

ObjectPoolingManager.cs

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

public class ObjectPoolingManager : MonoBehaviour
{
    // 싱글톤 인스턴스
    public static ObjectPoolingManager Instance;

    // 최대 외래 환자와 의사 수
    public int maxOfOutpatient;
    public int maxOfDoctor;

    // 비활성화된 외래 환자와 의사 오브젝트를 저장하는 큐
    public Queue<GameObject> outpatientQueue = new Queue<GameObject>();
    public Queue<GameObject> doctorQueue = new Queue<GameObject>();

    void Awake()
    {
        // 싱글톤 패턴 구현
        Instance = this;
        // 의사와 외래 환자 초기화
        DoctorInitialize();
        OutpatientInitialize();
    }

    private void OutpatientInitialize()
    {
        // 외래 환자 프리팹 로드
        GameObject[] OutpatientPrefabs = Resources.LoadAll<GameObject>("Prefabs/RealPatient");
        for (int i = 0; i < maxOfOutpatient; i++)
        {
            // 프리팹 리스트에서 랜덤으로 하나 선택하여 생성
            GameObject newOutPatient = Instantiate(OutpatientPrefabs[Random.Range(0, OutpatientPrefabs.Length)]);
            outpatientQueue.Enqueue(newOutPatient);
            newOutPatient.SetActive(false);
        }
    }

    private void DoctorInitialize()
    {
        // 의사 프리팹 로드
        GameObject[] DoctorPrefabs = Resources.LoadAll<GameObject>("Prefabs/Doctor");
        for (int i = 0; i < maxOfDoctor; i++)
        {
            // 프리팹 리스트에서 랜덤으로 하나 선택하여 생성
            GameObject newDoctor = Instantiate(DoctorPrefabs[Random.Range(0, DoctorPrefabs.Length)]);
            newDoctor.name = "Doctor " + i;
            DoctorController doctorController = newDoctor.GetComponent<DoctorController>();

            // 의사 사무실 할당
            DoctorOffice spawnArea = GameObject.Find("DoctorWaypoints").transform.Find("Ward (" + i / 5 + ")").Find("Doctor'sOffice (" + i + ")").GetComponent<DoctorOffice>();
            spawnArea.doctor = newDoctor;
            doctorController.waypoints.Add(spawnArea);

            // 외래 환자 대기 구역 할당
            GameObject parentObject = GameObject.Find("OutPatientWaypoints");
            DoctorOffice waypointrange = parentObject.transform.Find("Doctor'sOffice (" + i + ")").GetComponent<DoctorOffice>();
            waypointrange.doctor = newDoctor;
            doctorController.waypoints.Add(waypointrange);

            newDoctor.SetActive(false);
        }
    }

    // 외래 환자 비활성화 및 초기화
    public void DeactivateOutpatient(GameObject outpatient)
    {
        outpatient.GetComponent<Person>().status = InfectionState.Normal;
        OutpatientController outpatientController = outpatient.GetComponent<OutpatientController>();
        outpatientController.waypoints.Clear();
        outpatientController.isWaiting = false;
        outpatientController.waypointIndex = 0;
        outpatientController.signal = false;
        outpatientQueue.Enqueue(outpatient);
        outpatient.SetActive(false);
    }

    // 외래 환자 활성화 및 위치 설정
    public GameObject ActivateOutpatient(Vector3 position)
    {
        GameObject newOutpatient = outpatientQueue.Dequeue();
        newOutpatient.transform.position = position;
        newOutpatient.SetActive(true);
        return newOutpatient;
    }

    // 의사 비활성화 및 초기화
    public void DeactivateDoctor(GameObject doctor)
    {
        DoctorController doctorController = doctor.GetComponent<DoctorController>();
        doctorController.patientCount = 0;
        doctorController.isWaiting = false;
        doctor.SetActive(false);
    }

    // 의사 활성화 및 위치 설정
    public GameObject ActivateDoctor(GameObject newDoctor)
    {
        DoctorController doctorController = newDoctor.GetComponent<DoctorController>();
        newDoctor.transform.position = doctorController.waypoints[0].GetRandomPointInRange();
        doctorController.age++; // 의사의 나이 증가 (아사 현상 방지)
        newDoctor.SetActive(true);
        return newDoctor;
    }
}

외래 환자 오브젝트 풀링

시연 영상

OutpatientCreator.cs

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

public class OutpatientCreator : MonoBehaviour
{
    public static int numberOfOutpatient = 0;
    public List<Waypoint> spawnAreas = new List<Waypoint>();
    public float infectionRate = 0.03f;
    public float spawnDelay = 0.1f; // 대기 시간을 설정 가능하게 만듦
    private bool isWaiting = false;

    // Start는 처음 프레임이 업데이트되기 전에 호출됩니다.
    void Start()
    {
        GameObject go = GameObject.Find("Gateways");
        if (go != null)
        {
            for (int i = 0; i < go.transform.childCount; i++)
            {
                Waypoint waypointRange = go.transform.GetChild(i).GetComponent<Waypoint>();
                if (waypointRange != null)
                {
                    spawnAreas.Add(waypointRange);
                }
                else
                {
                    Debug.LogError("Gateways 자식 오브젝트에 Waypoint 컴포넌트가 없습니다.");
                }
            }
        }
        else
        {
            Debug.LogError("Gateways 게임 오브젝트를 찾을 수 없습니다.");
        }
    }

    // Update는 매 프레임마다 호출됩니다.
    void Update()
    {
        if (!isWaiting && numberOfOutpatient < ObjectPoolingManager.Instance.maxOfOutpatient)
        {
            StartCoroutine(SpawnOutpatient());
        }
    }

    IEnumerator SpawnOutpatient()
    {
        isWaiting = true;

        Vector3 spawnPosition = spawnAreas[Random.Range(0, spawnAreas.Count)].GetRandomPointInRange();
        GameObject newOutpatient = ObjectPoolingManager.Instance.ActivateOutpatient(spawnPosition);
        if (newOutpatient != null)
        {
            Person newOutPatientPerson = newOutpatient.GetComponent<Person>();
            if (newOutPatientPerson != null)
            {
                if (Random.value < infectionRate)
                {
                    if (StageManager.Instance.stage == 1)
                    {
                        newOutPatientPerson.status = InfectionState.Stage1;
                    }
                    else if (StageManager.Instance.stage == 2)
                    {
                        newOutPatientPerson.status = InfectionState.Stage2;
                    }
                }
                newOutPatientPerson.role = Role.Outpatient;
                numberOfOutpatient++;
            }
            else
            {
                Debug.LogError("새 외래 환자에 Person 컴포넌트가 없습니다.");
            }
        }
        else
        {
            Debug.LogError("새 외래 환자를 활성화하는 데 실패했습니다.");
        }

        yield return new WaitForSeconds(spawnDelay);
        isWaiting = false;
    }
}

OutpatientController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
using System.Linq;

public class OutpatientController : MonoBehaviour
{
    // 컴포넌트 참조
    private Animator animator;
    private NavMeshAgent agent;

    // 웨이포인트 관련 변수
    public List<Waypoint> waypoints = new List<Waypoint>();
    public int waypointIndex = 0;

    // 상태 플래그
    public bool isWaiting = false;
    public bool isWaitingForDoctor = false;
    public bool signal = false;

    // 씬 오브젝트 참조
    GameObject parentObject;
    GameObject gatewayObject;
    int randomWard;

    private void Awake()
    {
        // 컴포넌트 초기화
        animator = GetComponent<Animator>();
        agent = GetComponent<NavMeshAgent>();
        agent.avoidancePriority = Random.Range(0, 100);

        // 씬 오브젝트 찾기
        parentObject = GameObject.Find("OutPatientWaypoints");
        gatewayObject = GameObject.Find("Gateways");
        randomWard = Random.Range(0, 6);
    }

    private void OnEnable()
    {
        // 첫 번째 웨이포인트 추가
        AddWaypoint(parentObject.transform, $"CounterWaypoint ({randomWard})");
    }

    private void Update()
    {
        // 애니메이션 업데이트
        UpdateAnimation();

        // 대기 중이면 이동 처리하지 않음
        if (isWaiting || isWaitingForDoctor)
        {
            return;
        }

        // 목적지에 도착했는지 확인
        if (!agent.pathPending && agent.remainingDistance < 0.5f)
        {
            if (waypointIndex == 4)
            {
                // 모든 웨이포인트를 방문했으면 비활성화
                ObjectPoolingManager.Instance.DeactivateOutpatient(gameObject);
                OutpatientCreator.numberOfOutpatient--;
                return;
            }
            else
            {
                // 다음 웨이포인트로 이동
                StartCoroutine(MoveToNextWaypointAfterWait());
            }
        }
    }

    // 애니메이션 업데이트 메서드
    private void UpdateAnimation()
    {
        if (!agent.isOnNavMesh)
        {
            animator.SetFloat("MoveSpeed", 0);
            animator.SetBool("Grounded", false);
            return;
        }

        float moveSpeed = agent.remainingDistance > agent.stoppingDistance ? agent.velocity.magnitude / agent.speed : 0;
        animator.SetFloat("MoveSpeed", moveSpeed);
        animator.SetBool("Grounded", !agent.isOnOffMeshLink && agent.isOnNavMesh);
    }

    // 다음 웨이포인트로 이동하는 코루틴
    private IEnumerator MoveToNextWaypointAfterWait()
    {
        isWaiting = true;
        yield return new WaitForSeconds(1.5f);
        isWaiting = false;

        // 현재 웨이포인트 인덱스에 따라 다음 웨이포인트 추가
        AddNextWaypoint();

        if (waypointIndex < waypoints.Count)
        {
            if (waypoints[waypointIndex] is DoctorOffice doctorOffice)
            {
                StartCoroutine(WaitForDoctorOffice(doctorOffice));
            }
            else if (waypointIndex > 0 && waypoints[waypointIndex - 1] is DoctorOffice doc)
            {
                doc.is_empty = true;
            }
            if (!isWaitingForDoctor)
            {
                agent.SetDestination(waypoints[waypointIndex++].GetRandomPointInRange());
                StartCoroutine(UpdateMovementAnimation());
            }
        }
    }

    // 다음 웨이포인트 추가 메서드
    private void AddNextWaypoint()
    {
        switch (waypointIndex)
        {
            case 0:
                AddWaypoint(parentObject.transform, $"CounterWaypoint ({randomWard})");
                break;
            case 1:
                AddWaypoint(parentObject.transform, $"SofaWaypoint ({randomWard})");
                break;
            case 2:
                if (waypoints.Count < 3)
                {
                    AddWaypoint(parentObject.transform, $"Doctor'sOffice ({randomWard * 5})");
                }
                break;
            case 3:
                AddWaypoint(gatewayObject.transform, $"Gateway ({Random.Range(0, 2)})");
                break;
        }
    }

    // 의사 사무실 대기 코루틴
    private IEnumerator WaitForDoctorOffice(DoctorOffice doctorOffice)
    {
        isWaitingForDoctor = true;
        while (!signal)
        {
            yield return new WaitForSeconds(1);
        }
        doctorOffice.is_empty = false;
        isWaitingForDoctor = false;
    }

    // 웨이포인트 추가 메서드
    private void AddWaypoint(Transform parent, string childName)
    {
        Transform waypointTransform = parent.Find(childName);
        if (waypointTransform != null)
        {
            Waypoint comp = waypointTransform.gameObject.GetComponent<Waypoint>();
            if (comp is DoctorOffice)
            {
                // 의사 사무실 선택 로직
                SelectDoctorOffice(parent, childName);
            }
            else
            {
                if (!waypoints.Contains(comp))
                {
                    waypoints.Add(comp);
                    Debug.Log($"Added waypoint: {childName}");
                }
            }
        }
        else
        {
            Debug.LogWarning($"Can't find waypoint: {childName}");
        }
    }

    // 의사 사무실 선택 메서드
    private void SelectDoctorOffice(Transform parent, string childName)
    {
        // 의사 사무실 번호 추출
        string strNum = childName.Substring(childName.Length - 3, 2);
        int num = 0;
        if (!int.TryParse(strNum, out num))
        {
            num = childName[childName.Length - 2] - '0';
        }

        // 가능한 의사 사무실 목록 생성
        Dictionary<DoctorOffice, int> countDic = new Dictionary<DoctorOffice, int>();
        for (int i = num / 5 * 5; i < num + 5; i++)
        {
            string objectName = "Doctor'sOffice (" + i + ")";
            DoctorOffice doctorOffice = parent.Find(objectName).GetComponent<DoctorOffice>();
            GameObject searchedDoctor = doctorOffice.doctor;
            if (!searchedDoctor.activeInHierarchy)
            {
                continue;
            }
            int patientCount = searchedDoctor.GetComponent<DoctorController>().patientCount + doctorOffice.waitingQueue.Count;
            if (patientCount < DoctorController.patientMaxCount)
            {
                if (!countDic.ContainsKey(doctorOffice))
                {
                    countDic.Add(doctorOffice, patientCount);
                }
            }
        }

        // 최적의 의사 사무실 선택
        if (countDic.Count > 0)
        {
            DoctorOffice searchedOffice = countDic
                .OrderBy(kvp => kvp.Key.doctor.GetComponent<DoctorController>().age)
                .ThenBy(kvp => kvp.Value)
                .FirstOrDefault().Key;
            searchedOffice.waitingQueue.Enqueue(this);
            waypoints.Add(searchedOffice);
            countDic.Clear();
        }
        else
        {
            Debug.LogError("의사를 찾을 수 없습니다.");
        }
    }

    // 이동 애니메이션 업데이트 코루틴
    private IEnumerator UpdateMovementAnimation()
    {
        while (true)
        {
            animator.SetFloat("MoveSpeed", agent.velocity.magnitude / agent.speed);
            yield return null;
        }
    }
}

오브젝트가 생성될 때 모든 웨이포인트를 추가하고 진료 대기 큐에 넣으니 비활성화 되었다가 다시 활성화 되었을 때 진료 대기 큐에 들어가지 않는 현상이 발생하여 해결하였다. 그리고 의사가 5명 중 3명 씩 돌아가면서 근무를 하는데, 의사가 존재하는 진료실만 방문해야하기 때문에 각 웨이포인트에 도착했을 때 다음 웨이포인트를 추가해주는 방향으로 로직을 수정했다.

환자를 받은 수가 가장 적은 의사에 배정해주었지만 교체 직 후 카운트가 0이라 계속 그 진료실로만 배정되는 현상(아사 현상)이 발생하여 의사들마다 age 변수를 두어 교대가 더 많이 된 의사에게는 덜 배정되게 구현하였다.

의사 오브젝트 풀링

의사는 1개의 병동 당 5명이 있는데 이 중 3명씩 돌아가면서 근무를 한다. 이를 구현하기 위해 슬라이딩 윈도우 방식으로 3명씩 의사가 활성화와 비활성화 되게 코드를 짰다.

처음엔 10초에 한 번씩 교대를 하게 구현하였지만 로직 상 교대 시간 타이밍을 맞추기 쉽지 않을 것 같아서 의사가 환자를 받은 횟수를 Count하여 10회 받으면 교대하는 방식으로 구현하였다.

그러기 위해 각 진료실 웨이포인트에 의사 오브젝트 정보를 저장해주었다.

DoctorController.cs

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

public class DoctorController : MonoBehaviour
{
    private Animator animator;
    private NavMeshAgent agent;
    public List<Waypoint> waypoints = new List<Waypoint>();
    public bool isWaiting = false;
    public int patientCount = 0;
    public static int patientMaxCount = 10;
    public int age = 0;

    // Start is called before the first frame update
    void Start()
    {
        animator = GetComponent<Animator>();
        agent = GetComponent<NavMeshAgent>();

        Person newDoctorPerson = gameObject.GetComponent<Person>();
        newDoctorPerson.role = Role.Doctor;
    }

    // Update is called once per frame
    void Update()
    {
        // 애니메이션
        if (!agent.isOnNavMesh)
        {
            if (animator.GetFloat("MoveSpeed") != 0)
                animator.SetFloat("MoveSpeed", 0);
            if (animator.GetBool("Grounded"))
                animator.SetBool("Grounded", false);
            return;
        }

        if (agent.remainingDistance > agent.stoppingDistance)
        {
            if (animator.GetFloat("MoveSpeed") != agent.velocity.magnitude / agent.speed)
                animator.SetFloat("MoveSpeed", agent.velocity.magnitude / agent.speed);
        }
        else
        {
            if (animator.GetFloat("MoveSpeed") != 0)
            {
                animator.SetFloat("MoveSpeed", 0);
            }

        }

        if (animator.GetBool("Grounded") != (!agent.isOnOffMeshLink && agent.isOnNavMesh))
            animator.SetBool("Grounded", !agent.isOnOffMeshLink && agent.isOnNavMesh);

        if (patientCount >= patientMaxCount && waypoints[1] is DoctorOffice doctorOffice)
        {
            if (doctorOffice.waitingQueue.Count == 0 && doctorOffice.is_empty)
            {
                DoctorCreator.Instance.ChangeDoctor(gameObject);
                return;
            }
        }

        if (isWaiting)
        {
            return;
        }
        StartCoroutine(MoveToNextWaypointAfterWait());

    }
    private IEnumerator MoveToNextWaypointAfterWait()
    {
        isWaiting = true;
        yield return new WaitForSeconds(Random.Range(1.0f, 2.0f));
        isWaiting = false;
        if (waypoints[1] is DoctorOffice doctorOffice)
        {
            if (doctorOffice.is_empty)
            {
                agent.SetDestination(waypoints[0].GetRandomPointInRange());
            }
            else
            {
                agent.SetDestination(waypoints[1].GetRandomPointInRange());
            }
        }
        StartCoroutine(UpdateMovementAnimation());
    }
    private IEnumerator UpdateMovementAnimation()
    {
        while (true)
        {
            animator.SetFloat("MoveSpeed", agent.velocity.magnitude / agent.speed);
            yield return null;
        }
    }
}

DoctorOffice.cs

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

public class DoctorOffice : Waypoint
{
    public Queue<OutpatientController> waitingQueue = new Queue<OutpatientController>();
    public bool is_empty = true;
    public GameObject doctor;
    // Start is called before the first frame update
    void Start()
    {
    }

    // Update is called once per frame
    void Update()
    {
        if (is_empty && waitingQueue.Count > 0)
        {
            OutpatientController next = waitingQueue.Peek();
            if (next.isWaitingForDoctor)
            {
                next = waitingQueue.Dequeue();
                doctor.GetComponent<DoctorController>().patientCount++;
                is_empty = false;
                next.signal = true;
            }
        }
    }
    public void enter(OutpatientController outpatient)
    {
        waitingQueue.Enqueue(outpatient);
    }
    public void exit()
    {
        is_empty = true;
    }
}

DoctorCreator.cs

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

public class DoctorCreator : MonoBehaviour
{
    public static DoctorCreator Instance;
    public int numberOfDoctor = 0;
    private List<GameObject> rootObjects = new List<GameObject>();
    private List<int> doctorCount = new List<int> { 3, 3, 3, 3, 3, 3 };

    // Start is called before the first frame update
    void Start()
    {
        Instance = this;

        // 루트 오브젝트를 캐싱합니다.
        var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
        rootObjects.AddRange(scene.GetRootGameObjects());

        // 처음 0부터 2번째 Doctor를 활성화합니다.
        for (int i = 0; i < 6; i++)
        {
            for (int j = 0; j < 3; j++)
            {
                foreach (var rootObject in rootObjects)
                {
                    if (rootObject.name == "Doctor " + (i * 5 + j))
                    {
                        ObjectPoolingManager.Instance.ActivateDoctor(rootObject);
                    }
                }
            }

        }
    }

    private void Update()
    {

    }
    public void ChangeDoctor(GameObject endDoctor)
    {
        string name = endDoctor.name;
        string strNum = name.Substring(name.Length - 2, 2);
        int num = 0;
        if (!int.TryParse(strNum, out num))
        {
            num = name[name.Length - 1] - '0';
        }
        while (true)
        {
            int ward = num / 5;
            foreach (var rootObject in rootObjects)
            {

                if (rootObject.name == "Doctor " + (ward * 5 + (doctorCount[ward] % 5)) && !rootObject.activeInHierarchy)
                {
                    ObjectPoolingManager.Instance.DeactivateDoctor(endDoctor);
                    ObjectPoolingManager.Instance.ActivateDoctor(rootObject);
                    doctorCount[ward]++;
                    return;
                }
            }
            doctorCount[ward]++;
        }

    }
}

시연 영상

업로드중..

환자들끼리 충돌하여 엉키는 상황이 아니면 계속 진료실에 들어가는 것을 볼 수 있다.

0개의 댓글