Photon은 특히 유니티에서 활용할 수 있는 멀티플레이어 관련 라이브러리 및 호스팅 서비스를 제공하는 패키지를 제공 및 서비스중에 있다.
Fusion 2는 그 중에서도 실시간 멀티플레이어를 위한 랙 보정/예측 보간에 특화된 멀티프레이어 게임 엔진이다.
멀티플레이어 게임 개발이 처음이라 부딛혀가면서 굼벵이같이 개발을 진행중인데, 그 과정에서 알게 된 부분에 대해 적어보겠다.

Fusion 2는 다양한 네트워크 토폴로지에 대응이 가능한 모드를 제공하는데, 동일한 라이브러리지만 각 모드에 따라 개발 과정 및 동작 방식이 다소 다른 편이다.
현재 개발중인 Shotgun Project는 1v1 FPS 프로젝트로,
위와 같은 이유로 Host Mode를 사용하기로 결정하였다.
Host 모드는 하나의 클라이언트(피어)가 게임 데이터와 로직을 독점한다. 치팅(데이터 조작)의 방지와 데이터의 동기화를 위해 Fusion2는 State Authority를 가진 피어(호스트)가 데이터 및 게임의 동작을 담당할 것을 요구한다.

Fusion2는 Unity의 Monobehaviour 기반 디자인을 활용할 수 있도록 파생 클래스인 NetworkBehaviour를 제공한다.
Network Runner가 Network Object를 생성하고 피어가 접속하면 모든 피어에 대응되는 네트워크 오브젝트가 생성된다. 이렇게 생성된 각 네트워크 오브젝트는 다음과 같은 Authority를 가질 수 있다.
OnPlayerJoined)public void OnPlayerJoined(NetworkRunner runner, PlayerRef player)
{
if (runner.IsServer) // Check if this client is Host client
{
// Runs in host
// Create a unique position for the player
var spawnPosition = new Vector3((player.RawEncoded % runner.Config.Simulation.PlayerCount) * 3, 1, 0);
var networkPlayerObject = runner.Spawn(playerPrefab, spawnPosition, Quaternion.identity, player);
// Keep track of the player avatars for easy access
_spawnedCharacters.Add(player, networkPlayerObject);
Debug.Log($"Runner is host, spawned player object for {player}");
}
else
{
// Runs in joined client
Debug.Log($"Runner is client, player joined: {player}");
}
}
Spawned: NetworkRunner에 의해 네트워크 오브젝트가 생성되었을 때 호출FixedUpdateNetwork: NetworkBehaviour 버전 FixedUpdate.Render: NetworkBehaviour 버전 Update.TryGetInputForPlayer를 통해 StateAuthority로 어떤 입력이 발생했는지를 전달할 수 있다.단순한 체력 시스템 및 공격 기능만을 가지는 1v1 멀티플레이어 게임을 구현한다고 해보자. 이를 위해 두 피어에서 각 플레이어가 서버에 접속하였다.

각 NetworkBehaviour는 체력을 나타내는 Networked Property Health를 가진다.

Player2에서 공격 키를 입력해 Player1을 공격할 경우, 위 도표와 같이 다음 순서로 동작한다.
실 구현에서 동작 발생 시 StateAuthority에서 실행한 연산 결과에 따라 다른 Peer에서의 동작을 결정할 필요가 있을 경우 동기화가 필요한 데이터와 더불어 동작 발생 횟수(혹은 틱)을 Networked Property로 정의하고 사용한다.
PelletShoot의 구현사례[Networked, HideInInspector] public int LastFireTick { get; set; } = -1; // 발사가 일어난 틱 (이펙트 재생 트리거)
[Networked, Capacity(32)] public NetworkArray<RayHitData> HitResults { get; }
[Networked, HideInInspector] public int HitCount { get; set; } // 이번 틱에 발생한 실제 히트 개수
private int _lastVisibleFireTick;
public void ShootPellets(Shell shell, Gun gun)
{
if (!HasStateAuthority) return; // Called on State Authority only
var pelletCount = shell.PelletCount;
HitCount = 0;
if (showRay) lastRays.Clear();
Random.InitState(Runner.Tick * unchecked((int)Object.Id.Raw));
SingleShootAuth(shell, muzzle.forward, gun.MaxRange);
for (var i = 1; i < pelletCount; i++)
{
var direction = RandomSpreadDirectionFast(muzzle.forward, shell.PelletSpreadAngle * spreadAngleCoefficient);
SingleShootAuth(shell, direction, gun.MaxRange);
}
LastFireTick = Runner.Tick; // 발사 시점이 동기화 및 전파
}
public override void **Render()**
{
// LastFireTick이 최근 발사 렌더 틱보다 더 이후인지 확인
if (LastFireTick <= _lastVisibleFireTick) return;
_lastVisibleFireTick = LastFireTick;
// 동기화된 히트 결과를 기반으로 이펙트 재생
for (var i = 0; i < HitCount; i++)
{
var hit = HitResults.Get(i);
effectDrawer.RenderEffect((EffectType)hit.EffectType, hit.HitPosition + hit.HitNormal * 0.001f, hit.HitNormal);
}
}Networked 프로퍼티로 선언하거나 네트워크를 통해 전달하기 위해선 다음 조건들을 만족해야 한다.
string 및 C# 컬렉션은 모조리 사용 불가하므로, Fusion2 제공 구조체를 사용한다. 이 때, Capacity(n) 어트리뷰트를 통해 크기를 컴파일 타임에 결정한다.NetworkArray<T>NetworkDictionary<K, V>NetworkLinkedList<T>NetworkString<_size>기본적으로 Fusion2는 클라이언트가 입력을 보내고 서버가 전체 상태를 계산한 뒤 결과를 다시 전파하는 상태 전송 넷코드 (State Transfer)를 기반으로 한다.
그러나 FPS를 포함한 액션 게임에선 입력 - 동작 간에 적은 레이턴시로 동작하는 것이 중요하다. 따라서 일단 입력을 받은 클라이언트가 우선 시뮬레이션을 실행하고(Client-side Prediction), 이후 서버에 의해 연산된 값을 전달받아 만약 결과가 다를 경우 재시뮬레이션(Resimulation)을 통해 오차를 보정한다.
이 과정을 특히 조작을 통한 움직임에 Native하게 구현하면 동기화 과정에서의 보간 오류 등으로 조작감이 매우 불쾌해지는데, Fusion 2는 이러한 입력 레이턴시 최소화 및 재시뮬레이션 과정에서의 오류 보정을 최적화한 솔루션을 KCC라는 이름으로 제공한다.

Simple KCC의 경우 기본적인 기능은 CharacterController와 유사하며, 필요 시 KCC를 직접 사용하여 더 복잡한 동작을 구현하는 것도 가능하다. 웬만하면 Fusion2 환경에선 이동 조작을 별도 구현하는 것보단 이놈을 쓰는 게 편하다!
NetworkDictionary를 사용할 때 특히나 주의할 점이 바로 Key의 해싱이다.
AmmoStruct 구현 사례[Serializable]
public struct AmmoStruct : INetworkStruct, IEquatable<AmmoStruct>
{
public int AmmoType;
public int AmmoIndex;
public bool Equals(AmmoStruct other)
{
return AmmoType == other.AmmoType && AmmoIndex == other.AmmoIndex;
}
public override bool Equals(object obj)
{
return obj is AmmoStruct other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
// Deterministic Hash for Every Environment(No Random Seed!)
// Implementing this is IMPORTANT when using Networked Hash based Collections (i.e. NetworkDictionary)
int hash = 17;
hash = hash * 31 + AmmoType;
hash = hash * 31 + AmmoIndex;
return hash;
}
}
public override string ToString()
{
return $"{(AmmoType)AmmoType}[{AmmoIndex}]";
}
}