유니티 Fusion2 RPC을 이용할 때 void함수만 가능할 때 #Network Pooling

현재 개발중인 프로젝트는 Fusion2을 이용한 게임을 제작하고 있습니다.

Effect와 같이 자주 사용되는 오브젝트를 Pooling을 이용해서 관리하고자 했습니다.

 

처음에는 Effect같은 객체는 반드시 NetworkObject로 관리해야 하는 줄 알았으나, On/Off 시점만 동기화가 되면 된다는 것을 깨달았습니다.

 

그래서 Pool자체는 NGO을 관리하는 것이 아닌 Unity GameObject을 관리하도록 만들었습니다.

 

Pool System은 크게 Init, Add, Get, Return 이렇게 나뉜다고 생각합니다.

 

RPC을 이용할 때 Init과 Add, Return은 문제가 없었으나 Get이 문제였습니다.

왜냐하면 Init과 Add, Return은 void함수였지만 Get은 return Type이 존재했기 때문입니다.

 

그래서 효율적이진 않을 수 있지만 return 값을 주는 것처럼 흉내를 낼 수 있는 Util함수를 만들어봤습니다.


Pool Code - (Effect Manager)

Pool 관련 코드는 참고용으로만 봐주세요..효율적이라고 생각 안하기 때문입니다..! 

 

이번 포스팅에서는 RpcUtil 함수만 중점적으로 봐주시면 될 것 같습니다.

 

근데 Pool Code가 없으면 이해하기 어려울까봐 같이 올리는 것입니다.

 

아래 코드를 사용하기 위해서는 코드 아래 '에셋'을 이용해야합니다.

using System;
using System.Collections.Generic;
using AYellowpaper.SerializedCollections;
using Fusion;
using UnityEngine;

namespace Ludens
{
    [Serializable]
    public struct PoolData
    {
        public GameObject prefab;
        public int Count;
    }
    
    public class EffectManager : NetworkBehaviour
    {
        public static EffectManager Instance { get; private set; }

        public SerializedDictionary<string, PoolData> Effects = new(); //초반에 초기화를 위한 
        
        private Dictionary<string, Queue<GameObject>> effectPools = new(); //실제로 게임에서 사용할 때 사용하는 Pool

        public Dictionary<string, GameObject> activeEffects = new (); //활성화된 객체를 관리하기 위한 Pool
        
        private int effectCounter { get; set; }

        #region Unity

        private void Awake()
        {
            if (Instance == null)
            {
                Instance = this;
            }
        }
        
        #endregion
        
        public void Init()
        {
            if (HasStateAuthority)
            {
                RPC_InitPool();
            }
        }

        [Rpc(RpcSources.All, RpcTargets.All)] //임시
        private void RPC_InitPool()
        {
            foreach (var effect in Effects)
            {
                if (!effectPools.ContainsKey(effect.Key))
                {
                    Queue<GameObject> objectPool = new Queue<GameObject>();

                    for (int i = 0; i < effect.Value.Count; i++)
                    {
                        GameObject obj = Instantiate(effect.Value.prefab, Vector3.zero, Quaternion.identity);
                        obj.transform.SetParent(transform, false);
                        obj.gameObject.SetActive(false);
                        objectPool.Enqueue(obj);
                    }

                    effectPools[effect.Key] = objectPool;
                }
            }
        }


        [Rpc(RpcSources.All, RpcTargets.All)] //임시
        private void RPC_Add(string effectName) //런타임에 Effect객체가 부족할 시
        {
            GameObject obj = Instantiate(Effects[effectName].prefab, Vector3.zero, Quaternion.identity);

            obj.transform.SetParent(transform, false);
            obj.gameObject.SetActive(false);
            
            effectPools[effectName].Enqueue(obj);
        }

        public void RequestEffect(string effectName, Action<GameObject> callback) //Effect를 요청
        {
            int requestId = RpcUtil.RegisterRequest(callback);
            RPC_RequestEffect(effectName, requestId);
        }


        [Rpc(RpcSources.All, RpcTargets.All)]
        private void RPC_RequestEffect(string effectName, int requestId)
        {
            GameObject effect = null;

            if (effectPools.TryGetValue(effectName, out var objectPool))
            {
                if (objectPool.Count > 0)
                {
                    effect = objectPool.Dequeue();
                    effect.gameObject.SetActive(true);
                }
                else
                {
                    RPC_Add(effectName);
                    effect = objectPool.Dequeue();
                    effect.gameObject.SetActive(true);
                }
        
                string uniqueId = $"{effectName}_{effectCounter++}";

                Debug.Log($"[요청하기] Unique ID : [{uniqueId}]");
                activeEffects[uniqueId] = effect; //현재 사용하는 Effect를 관리해서 아래 TryGetValue에 사용
                
                RpcUtil.CompleteRequest(requestId, effect);
            }
        }
        
        [Rpc(RpcSources.All, RpcTargets.All)]
        public void RPC_Return(string uniqueId)
        {
            if (activeEffects.TryGetValue(uniqueId, out GameObject obj))
            {
                if (obj != null)
                {
                    obj.gameObject.SetActive(false);
                    string effectName = uniqueId.Split('_')[0];

                    effectPools[effectName].Enqueue(obj);
                    activeEffects.Remove(uniqueId);
                }
                else
                {
                    Debug.LogError($"Object with unique ID {uniqueId} is null.");
                }
            }
            else
            {
                Debug.LogError($"No active effect found with unique ID {uniqueId}");
            }
        }
    }
}

 

 

Serialized Dictionary | 유틸리티 도구 | Unity Asset Store

Use the Serialized Dictionary from ayellowpaper on your next project. Find this utility tool & more on the Unity Asset Store.

assetstore.unity.com

 

이 코드에서 중점적으로 봐야할 부분은 아래와 같습니다.

public void RequestEffect(string effectName, Action<GameObject> callback)
{
    int requestId = RpcUtil.RegisterRequest(callback);
    RPC_RequestEffect(effectName, requestId);
}


[Rpc(RpcSources.All, RpcTargets.All)]
private void RPC_RequestEffect(string effectName, int requestId)
{
    GameObject effect = null;

    if (effectPools.TryGetValue(effectName, out var objectPool))
    {
        if (objectPool.Count > 0)
        {
            effect = objectPool.Dequeue();
            effect.gameObject.SetActive(true);
        }
        else
        {
            RPC_Add(effectName);
            effect = objectPool.Dequeue();
            effect.gameObject.SetActive(true);
        }

        string uniqueId = $"{effectName}_{effectCounter++}";

        Debug.Log($"[요청하기] Unique ID : [{uniqueId}]");
        activeEffects[uniqueId] = effect;
        
        RpcUtil.CompleteRequest(requestId, effect);
    }
}

 

위 코드가 뭐가 문제인지 모르겠다고요?!

왜 굳이 RequestEffect함수 따로 만들고...RPC_RequestEffect함수를 따로 만들었을까요? 

 

아래 코드로 설명하겠습니다.

[Rpc(RpcSources.All, RpcTargets.All)]
private GameObject RPC_Test()
{
    return null;
}

 

RPC함수가 위와 같이 return Type이 존재하게 되는 경우 아래와 같이 에러가 발생합니다.

Fusion2에는 RPC함수에서 return 값을 가질 수 없다!

 

그래서...! RPCUtil를 만들어서 return을 주는 것처럼 흉내낼 수 있게 되었습니다.

 


RPCUtil 알아보기!

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

namespace Ludens
{
    public static class RpcUtil
    {
        private static Dictionary<int, Delegate> pendingRequests = new ();

        private static int requestIdCounter = 0;

        public static int RegisterRequest<T>(Action<T> callback)
        {
            int requestId = requestIdCounter++;
            pendingRequests[requestId] = callback;
            return requestId;
        }

        public static void CompleteRequest<T>(int requestId, T result)
        {
            if (pendingRequests.TryGetValue(requestId, out var callback))
            {
                if (callback is Action<T> typedCallback)
                {
                    typedCallback.Invoke(result);
                    pendingRequests.Remove(requestId);
                }
                else
                {
                    Debug.LogError($"Callback for request ID {requestId} is not of expected type {typeof(T)}.");
                }
            }
            else
            {
                Debug.LogWarning($"No pending request found with ID {requestId}");
            }
        }
    }
}

 

쉽게 설명하자면...

Action<T>(여기서 T는 GameObject로 사용되죠) onCallback에 Return받을 함수를 등록시킨 다음에 Pool에서 GameObject가져오고, 그 가져온 것을 CompleteRequest<T>에 result로 넘겨주어 Invoke해서 return해주는 겁니다.

 

Action과 델레게이트 개념을 모르면 힘들 수 있겠네요...🥲

 


EffectTest로 테스트하기!

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

namespace Ludens
{
    public class EffectTest : MonoBehaviour
    {
        public List<Transform> effectPositions; 
        private int currentPositionIndex = 0;
        
        private Dictionary<int, GameObject> activeEffects = new Dictionary<int, GameObject>();

#if UNITY_EDITOR
        private void OnGUI()
        {
            GUILayout.BeginArea(new Rect(Screen.width - 200, 10, 180, Screen.height - 20));
            GUILayout.BeginVertical();

            foreach (var effect in EffectManager.Instance.Effects)
            {
                if (GUILayout.Button(effect.Key, GUILayout.Height(40)))
                {
                    TriggerEffect(effect.Key);
                }
            }

            GUILayout.EndVertical();
            GUILayout.EndArea();
        }
#endif
        
        private void TriggerEffect(string effectName)
        {
            if (effectPositions.Count == 0)
            {
                Debug.LogError("No effect positions defined!");
                return;
            }

            var position = effectPositions[currentPositionIndex];
            currentPositionIndex = (currentPositionIndex + 1) % effectPositions.Count;

            EffectManager.Instance.RequestEffect(effectName, effect =>
            {
                if (effect != null)
                {
                    int instanceId = effect.GetInstanceID();
                    activeEffects[instanceId] = effect; 
                    effect.transform.position = position.position;
                
                    ParticleSystemHelper.RegisterTimedCallback(effect.GetComponent<ParticleSystem>(), 
                        () => HandleParticleSystemStopped(instanceId), 2f, this);
                }
                else
                {
                    Debug.LogError($"Failed to trigger effect: {effectName}");
                }
            });
        }

        private void HandleParticleSystemStopped(int instanceId)
        {
            Debug.Log("파티클 시스템 종료 - [Return]");
            if (activeEffects.TryGetValue(instanceId, out var effect))
            {
                Debug.Log($"[돌려주기] Get Instance ID : [{effect.GetInstanceID()}]");
                string uniqueKey = EffectManager.Instance.activeEffects
                    .FirstOrDefault(kvp => kvp.Value == effect).Key;
                if (uniqueKey != null)
                {
                    EffectManager.Instance.RPC_Return(uniqueKey);
                    activeEffects.Remove(instanceId);
                }
            }
        }
    }
}

 

람다식으로 RequestEffect에 콜백함수를 넘겨주었고, RpcUtil에서 GameObejct를 넘겨받을 수 있게 되었습니다..

참 복잡한 과정으로 RPC함수에서 return type을 가져왔습니다..🥲🥲

 

혹시 ParticleSystmeHelper가 궁금하실까봐... 아래에 달아두겠습니다.


ParticleSystemHelper

using UnityEngine;
using System;
using System.Collections;

namespace Ludens
{
    public static class ParticleSystemHelper
    {
        /// <summary>
        /// 파티클 시스템이 완료될 때 콜백을 호출하는 유틸리티 함수
        /// </summary>
        /// <param name="particleSystem">감시할 파티클 시스템</param>
        /// <param name="callback">파티클 시스템 완료 시 호출할 콜백 함수</param>
        /// <param name="checkInterval">파티클 시스템 상태를 확인할 주기(초 단위)</param>
        /// <param name="owner">Coroutine을 실행할 MonoBehaviour 객체</param>
        public static void RegisterCompletionCallback(ParticleSystem particleSystem, Action callback, float checkInterval, MonoBehaviour owner)
        {
            owner.StartCoroutine(CheckCompletion(particleSystem, callback, checkInterval));
        }
    
        private static IEnumerator CheckCompletion(ParticleSystem particleSystem, Action callback, float checkInterval)
        {
            while (particleSystem.IsAlive(true))
            {
                yield return new WaitForSeconds(checkInterval);
            }

            particleSystem.Stop();
            callback?.Invoke();
        }

        /// <summary>
        /// 일정 시간이 지난 후 콜백을 호출하는 유틸리티 함수
        /// </summary>
        /// <param name="particleSystem">감시할 파티클 시스템</param>
        /// <param name="callback">시간이 지난 후 호출할 콜백 함수</param>
        /// <param name="duration">파티클 시스템의 지속 시간(초 단위)</param>
        /// <param name="owner">Coroutine을 실행할 MonoBehaviour 객체</param>
        public static void RegisterTimedCallback(ParticleSystem particleSystem, Action callback, float duration, MonoBehaviour owner)
        {
            owner.StartCoroutine(TimedCallback(duration, particleSystem, callback));
        }

        private static IEnumerator TimedCallback(float duration, ParticleSystem particleSystem, Action callback)
        {
            yield return new WaitForSeconds(duration);
            if (particleSystem.isPlaying)
            {
                particleSystem.Stop();
            }
            callback?.Invoke();
        }
    }
}