유니티 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}");
}
}
}
}
이 코드에서 중점적으로 봐야할 부분은 아래와 같습니다.
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이 존재하게 되는 경우 아래와 같이 에러가 발생합니다.
그래서...! 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();
}
}
}