}

유니티 Fusion2 Network Object Pooling을 만들어 보자!

Object Pooling을 왜 써야 할까?

런타임 중 매번 오브젝트를 생성하고 파괴(Instantiate / Destroy)하면 CPU 부하와 GC(가비지 콜렉션)가 크게 늘어납니다.

Network 오브젝트도 예외는 아닙니다. Fusion에서 Spawn() / Despawn()을 호출할 때마다 내부적으로 Instantiate/Destroy가 일어나므로, 총알·적 몬스터처럼 빈번히 생성되는 오브젝트는 풀링이 필수적입니다.

 

Unity Pooling System (최적화 구웃!)

Pooling이란? Unity의 풀링 시스템은 런타임 중에 게임 오브젝트를 효율적으로 관리하고 재사용하는 데 사용되는 기술로, 특히 총알, 적, 파티클과 같은 오브젝트를 자주 생성하고 소멸해야 할 때

wlsdn629.tistory.com

 

 

유니티 오브젝트 풀(Object Pool) 매니저를 제네릭 타입으로 제작해보았다

22년도에 Pool System에 대해서 언급한적이 있습니다. Pooling에 관한 내용은 아래 포스팅에 작성했으니 참고하시면 되겠습니다. 이때 소개한 Pool의 경우 하나의 오브젝트에 대해서만 관리할 수 있

wlsdn629.tistory.com

이번 시간에는 Fusion2 전용으로 Network Object Pooling를 만들것이므로 Netcode를 쓰시는 분은 아래 포스팅을 참고해주세요!

 

유니티 Netcode for GameObject, Object Pooling

Object Pooling NGO에는 Object Pooling기능을 제공하고 있습니다! 2022.09.04 - [나만의 꿀팁] - Unity Pooling System (최적화 구웃!) Unity Pooling System (최적화 구웃!) 서론 없이 바로 코드부터 투척!! 먼저 TestPooling을

wlsdn629.tistory.com


Fusion2의 NetworkObjectProviderDefault란?

 

Runner가 "Spawn() / Despawn()"을 처리할 때 "어떻게 오브젝트를 만들고 없앨지" 결정하는 인터페이스 구현체입니다.

기본적으로 아래 함수를 통해 동작됩니다.

  • AcquirePrefabInstance → Instantiate(prefab)
  • ReleaseInstance → Destroy(gameObject)

AcquirePrefabInstance와 ReleaseInstance는 Runner가 Network Object를 "Spawn() / Despawn()"할 때 내부적으로 호출하는 핵심 훅(Hook) 함수입니다.

 

하지만, 기본 Provider는 단순히 매번 새로 만들고 파괴할 뿐, 재사용 로직은 포함되어 있지 않습니다.


NetworkObjectProviderDefault를 상속받는 커스텀 PooledNetworkObjectProvider를 만들어봅시다!

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

namespace Fusion
{
    public class PooledNetworkObjectProvider : NetworkObjectProviderDefault
    {
        private int maxPoolCount = 20;

        private readonly Dictionary<NetworkObject, Queue<NetworkObject>> pool = new();

        public void SetMaxPoolCount(int count) => maxPoolCount = Mathf.Max(0, count);

        public void Prewarm(NetworkRunner runner, NetworkObject prefabAsset, int count)
        {
            if (!prefabAsset)
            {
                Debug.LogWarning("[Pool] Prewarm 요청에 null 프리팹이 전달되었습니다.");
                return;
            }

            if (!pool.TryGetValue(prefabAsset, out var queue)) 
                pool[prefabAsset] = queue = new Queue<NetworkObject>();

            int needToCreate = Mathf.Max(0, count - queue.Count);
            if (needToCreate == 0) return;

            for (int i = 0; i < needToCreate; i++)
            {
                var inst = base.InstantiatePrefab(runner, prefabAsset);
                runner.MoveToRunnerScene(inst.gameObject);

                inst.gameObject.SetActive(false);
                queue.Enqueue(inst);
            }
        }

        public override NetworkObjectAcquireResult AcquirePrefabInstance(NetworkRunner runner, in NetworkPrefabAcquireContext context, out NetworkObject instance)
        {
            instance = null;

            if (DelayIfSceneManagerIsBusy && runner.SceneManager.IsBusy)
                return NetworkObjectAcquireResult.Retry;

            NetworkObject prefabAsset;
            try
            {
                prefabAsset = runner.Prefabs.Load(context.PrefabId, context.IsSynchronous);
            }
            catch (Exception ex)
            {
                Debug.LogError($"[Pool] Prefab load 실패: {ex}");
                return NetworkObjectAcquireResult.Failed;
            }

            if (!prefabAsset) return context.IsSynchronous ? NetworkObjectAcquireResult.Failed : NetworkObjectAcquireResult.Retry;

            if (pool.TryGetValue(prefabAsset, out var queue))
            {
                while (queue.Count > 0 && !instance)
                {
                    var pooledObj = queue.Dequeue();
                    if (!pooledObj) continue;       

                    try
                    {
                        pooledObj.gameObject.SetActive(true);
                        instance = pooledObj;
                    }
                    catch (MissingReferenceException)
                    {
                        // 파괴된 오브젝트
                    }
                }
            }

            if (!instance)
                instance = base.InstantiatePrefab(runner, prefabAsset);

            if (context.DontDestroyOnLoad)
                runner.MakeDontDestroyOnLoad(instance.gameObject);
            else
                runner.MoveToRunnerScene(instance.gameObject);

            return NetworkObjectAcquireResult.Success;
        }

        public override void ReleaseInstance(NetworkRunner runner, in NetworkObjectReleaseContext context)
        {
            if (!context.IsBeingDestroyed && context.TypeId.IsPrefab)
            {
                var prefabId    = context.TypeId.AsPrefabId;
                var prefabAsset = runner.Prefabs.Load(prefabId, true);

                if (prefabAsset && context.Object)
                {
                    if (!pool.TryGetValue(prefabAsset, out var queue))
                        pool[prefabAsset] = queue = new Queue<NetworkObject>();

                    if (maxPoolCount <= 0 || queue.Count < maxPoolCount)
                    {
                        var go = context.Object.gameObject;
                        if (go)
                        {
                            go.SetActive(false);
                            queue.Enqueue(context.Object);
                            return; 
                        }
                    }
                }
            }
            base.ReleaseInstance(runner, context);
        }

        public int GetPoolCount(NetworkObject prefabAsset) => pool.TryGetValue(prefabAsset, out var q) ? q.Count : 0;

        public void ClearPool(NetworkObject prefabAsset)
        {
            if (pool.TryGetValue(prefabAsset, out var q)) 
                q.Clear();
        }
    }
}

 

스크립트를 하나 만들어주시면 됩니다.


사용 방법

아래 예시 코드는 저의 프로젝트에 맞춰져 있는 코드이므로 프로젝트에 맞게 수정하셔야 합니다!


#1. Runner 생성 직후 Provider 추가

public async UniTask Connect()
{
    Runner = Instantiate(runnerPrefab).GetComponent<NetworkRunner>();
    Runner.AddCallbacks(this);
    await Runner.JoinSessionLobby(SessionLobby.Shared);

    objectProvider = Runner.gameObject.AddComponent<PooledNetworkObjectProvider>();
    objectProvider.SetMaxPoolCount(30);
}

#2. StartGameArgs에 할당

public async UniTask CreateRoom()
{
    var start = await Runner.StartGame(new StartGameArgs
    {
        SessionName      = matchId,
        GameMode         = GameMode.Shared,
        ObjectProvider = objectProvider,    //여기!!!
        SessionProperties = new Dictionary<string, SessionProperty>
        {
            ....
        },
    });
}

#3. Prewarm으로 초기 풀링

public override void Spawned()
{
    if (Runner.ObjectProvider is PooledNetworkObjectProvider provider)
    {
        provider.Prewarm(Runner, PigPrefab, prewarmCount);
        provider.Prewarm(Runner, BadPigPrefab, prewarmCount);
    }
}

#4. Spawn/Despawn 호출

private void SpawnNewPig()
{
     pigObj = Runner.Spawn(prefab, basePos, ProxyPigs[spawnIdx].transform.rotation);
}

 

 

StartGameArgs.ObjectProvider에 커스텀 Provider를 지정하면, Runner는 Spawn() 호출 시 내부적으로 AcquirePrefabInstance를, Despawn() 호출 시 내부적으로 ReleaseInstance를 자동 호출합니다.

 

개발자는!!!!! Runner.Spawn() / Runner.Despawn()만 호출하면 되고, 풀링 로직은 Provider가 알아서 처리해줍니다!!