Fusion 2 학습내용 정리

이오싶·2025년 12월 21일

Fusion 2란?

Photon은 특히 유니티에서 활용할 수 있는 멀티플레이어 관련 라이브러리 및 호스팅 서비스를 제공하는 패키지를 제공 및 서비스중에 있다.

Fusion 2는 그 중에서도 실시간 멀티플레이어를 위한 랙 보정/예측 보간에 특화된 멀티프레이어 게임 엔진이다.

멀티플레이어 게임 개발이 처음이라 부딛혀가면서 굼벵이같이 개발을 진행중인데, 그 과정에서 알게 된 부분에 대해 적어보겠다.

Fusion 2의 네트워크 토폴로지

Fusion 2는 다양한 네트워크 토폴로지에 대응이 가능한 모드를 제공하는데, 동일한 라이브러리지만 각 모드에 따라 개발 과정 및 동작 방식이 다소 다른 편이다.

  • Dedicated Server: 게임 관리 및 플레이어 접속을 관리하는 서버와 이에 접속하는 클라이언트가 완전히 분리. 치팅에 가장 안전하나 세팅 및 구현이 복잡함
  • Host 모드: 게임에 참여하는 클라이언트(피어) 중 한 명이 서버 역할을 함께 담당하고 나머지 피어가 호스트에 접속. 중요한 게임 데이터를 Host가 쥐므로 Host를 제외하고는 치팅에 안전
  • Shared 모드: 명확한 Host 없이 모든 클라이언트가 네트워크 Authority를 분배받음. 모든 피어의 접속/접속 해제가 자유롭고 구현이 간편하나 치팅에 취약

현재 개발중인 Shotgun Project는 1v1 FPS 프로젝트로,

  • 플레이어 수가 2인(매우 적음)
  • 치팅 방지가 어느정도 필요
  • 레이턴시/랙 보정 등이 필요
  • 프로젝트 규모가 작아 Dedicated Server를 사용하기에는 개발 코스트가 빡셈

위와 같은 이유로 Host Mode를 사용하기로 결정하였다.

Host 모드와 Authority에 대하여

Host 모드는 하나의 클라이언트(피어)가 게임 데이터와 로직을 독점한다. 치팅(데이터 조작)의 방지와 데이터의 동기화를 위해 Fusion2는 State Authority를 가진 피어(호스트)가 데이터 및 게임의 동작을 담당할 것을 요구한다.

  • 멀티플레이어에선 모든 피어에서 동일한 빌드가 작동하게 된다. 이 때 동일한 빌드라고 하더라도 어떤 피어(호스트)에서는 게임 데이터를 수정하여 전파하고, 다른 빌드(클라이언트)에선 이 데이터를 전달받아 게임의 상태를 동기화해야 한다. 이 때 게임 빌드는 현재 빌드가 동작중인 네트워크 피어가 StateAuthority를 가지고 있는지에 따라 어떤 동작을 할 지를 결정하도록 구현된다.
  • 다른 피어에서 발생한 입력은 호스트로 전달되어 호스트가 각 피어의 입력에 따라 상태를 결정한다. 이러한 동작 방식의 넷코드를 상태 전송(State Transfer) 넷코드라고 한다.

NetworkObject와 NetworkBehaviour

Fusion2는 Unity의 Monobehaviour 기반 디자인을 활용할 수 있도록 파생 클래스인 NetworkBehaviour를 제공한다.

  • Network Runner는 멀티플레이어의 Room을 생성하고 Network Object를 생성 및 관리한다.
  • Network Object는 Photon의 서버에 의해 생성/파괴/활성화 상태가 동기화된다.
  • Network Object는 NetworkBehaviour 파생 컴포넌트를 컴포넌트로 가질 수 있다.

Network Object와 Authority

Network Runner가 Network Object를 생성하고 피어가 접속하면 모든 피어에 대응되는 네트워크 오브젝트가 생성된다. 이렇게 생성된 각 네트워크 오브젝트는 다음과 같은 Authority를 가질 수 있다.

  • State Authority
    • 동기화를 위한 데이터를 관리하는 권한이다.
    • Host 모드에서 Host에서 생성된 모든 NetworkObjectState Authority를 가진다.
  • Input Authority
    • 클라이언트의 입력을 전달받고 관리하는 권한이다.
      • 클라이언트에서 발생한 입력을 자신과 호스트 상의 동기화된 NetworkObject로 전달한다.
    • Input Authority를 가진 Network Object는 자신이 조작하는 오브젝트(플레이어 캐릭터)가 된다.
  • State Authority, Input Authority를 모두 가지지 않는 오브젝트는 Proxy라고 한다.
    • Proxy는 호스트로부터 전달받은 데이터를 통해 자신의 상태를 동기화하기만 하도록 구현된다.

예시

  • 피어에서 게임에 접속하면, 호스트의 NetworkRunner가 어떤 피어에서 접속했는지 파악하고 플레이어 캐릭터인 NetworkObject를 생성하도록 구현한다.
    • NetworkObject는 모든 피어에서 생성되며 각 피어에 대해 상태가 동기화된다.
    • 호스트는 생성된 NetworkObject에 대해 StateAuthority를 가진다.
    • 접속한 피어는 생성된 NetworkObject에 대해 InputAuthority를 가진다.
  • NetworkRunner를 관리하는 컴포넌트의 접속 로직(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}");    
        }
    }
    

NetworkBehaviour의 특징

  • MonoBehaviour의 이벤트 함수(리플렉션 기반) 외에 서버에 의해 관리되는 별도 라이프사이클을 가지며, 각 시점에 호출되는 별도 이벤트 메서드(override하여 사용)를 제공한다. 중요 이벤트 메서드는 다음과 같다.
    • Spawned: NetworkRunner에 의해 네트워크 오브젝트가 생성되었을 때 호출
    • FixedUpdateNetwork: NetworkBehaviour 버전 FixedUpdate.
      • 서버에서 일정 시간 간격마다 호출된다. 즉, 모든 피어에서 호출 시점이 동기화된다.
      • 동기화가 필요한 서버 단위 게임 로직 및 물리 효과를 대부분 담당한다.
      • FixedDeltaTime 대신 서버의 처리 시간 단위인 Runner.Tick를 사용한다.
    • Render: NetworkBehaviour 버전 Update.
      • 서버와 무관하게 렌더 시점마다 호출된다.
      • 서버에서 전파된 데이터에 따른 결과를 각 클라이언트에서 표현하기 위해 사용한다.
  • Networked Property를 사용할 수 있다.
    • 네트워크 프로퍼티는 State Authority에서 수정된 데이터가 다른 피어의 연동된 NetworkObject로 자동으로 동기화되는 자동구현 프로퍼티이다.
      • 값이 변경된 시점에 특정한 메서드를 호출하는 것도 가능하다.
    • State Authority가 아닌 다른 피어의 NetworkBehaviour에서 네트워크 프로퍼티를 수정하면 State Authority에서 전달한 값으로 덮어씌워진다.
  • 클라이언트의 입력을 전달받아 전파한다.
    • 호스트를 포함한 모든 클라이언트는 입력이 발생했음을 네트워크로 전달할 수 있어야 게임을 조작할 수 있다.
    • TryGetInputForPlayer를 통해 StateAuthority로 어떤 입력이 발생했는지를 전달할 수 있다.
  • RPC를 통해 다른 피어에서 호스트의 메서드를 호출할 수 있다.
    • 그러나 메신저 기능 등과 같은 제한적인 경우를 제외하고 Fusion2는 Network 프로퍼티의 사용을 권장한다.

예제

단순한 체력 시스템 및 공격 기능만을 가지는 1v1 멀티플레이어 게임을 구현한다고 해보자. 이를 위해 두 피어에서 각 플레이어가 서버에 접속하였다.

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

Player2에서 공격 키를 입력해 Player1을 공격할 경우, 위 도표와 같이 다음 순서로 동작한다.

  • Client의 Player2(InputAuthority)에서 입력을 StateAuthority로 전달한다.
    • 입력은 InputAuthority에서도 전달받을 수 있으므로, 필요 시 공격 표현을 위한 별도 로직을 구현한다.
  • Host의 Player2에서 공격 메서드를 호출하여 Player1을 공격한다.
  • Host의 Player1이 공격을 받아 체력을 감소하는 메서드를 호출하고 Health를 변화시킨다.
  • 변화한 Health의 값이 다른 Peer로 전파된다.
    • 필요 시 각 Peer에서 Health의 변화를 확인하고 UI 수정 등 필요한 로직을 수행한다.

실 구현에서 동작 발생 시 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가 가능한 타입

Networked 프로퍼티로 선언하거나 네트워크를 통해 전달하기 위해선 다음 조건들을 만족해야 한다.

  • Blittable Type, 즉 고정된 길이를 가진 타입이어야 한다.
    • reference 타입은 전송이 불가하다.(클래스 불가!)
    • reference 타입을 멤버로 가지는 value 타입도 불가하다.
  • C#의 대부분의 primitive 타입 및 Unity에서 제공하는 Blittable 구조체 타입(Vector3, Color 등), 열거형 등은 가능하다.
    • bool의 경우 크기가 변화할 수 있다. 따라서 Fusion2의 경우 고정 길이인 NetworkBool 타입을 제공한다.
  • 직접 구현한 구조체의 경우 INetworkStruct를 구현해야 한다.
  • 레퍼런스 타입인 string 및 C# 컬렉션은 모조리 사용 불가하므로, Fusion2 제공 구조체를 사용한다. 이 때, Capacity(n) 어트리뷰트를 통해 크기를 컴파일 타임에 결정한다.
    • NetworkArray<T>
    • NetworkDictionary<K, V>
    • NetworkLinkedList<T>
    • NetworkString<_size>

KCC

기본적으로 Fusion2는 클라이언트가 입력을 보내고 서버가 전체 상태를 계산한 뒤 결과를 다시 전파하는 상태 전송 넷코드 (State Transfer)를 기반으로 한다.

그러나 FPS를 포함한 액션 게임에선 입력 - 동작 간에 적은 레이턴시로 동작하는 것이 중요하다. 따라서 일단 입력을 받은 클라이언트가 우선 시뮬레이션을 실행하고(Client-side Prediction), 이후 서버에 의해 연산된 값을 전달받아 만약 결과가 다를 경우 재시뮬레이션(Resimulation)을 통해 오차를 보정한다.

이 과정을 특히 조작을 통한 움직임에 Native하게 구현하면 동기화 과정에서의 보간 오류 등으로 조작감이 매우 불쾌해지는데, Fusion 2는 이러한 입력 레이턴시 최소화 및 재시뮬레이션 과정에서의 오류 보정을 최적화한 솔루션을 KCC라는 이름으로 제공한다.

Simple KCC의 경우 기본적인 기능은 CharacterController와 유사하며, 필요 시 KCC를 직접 사용하여 더 복잡한 동작을 구현하는 것도 가능하다. 웬만하면 Fusion2 환경에선 이동 조작을 별도 구현하는 것보단 이놈을 쓰는 게 편하다!

시행착오

NetworkDictionary를 사용할 때 특히나 주의할 점이 바로 Key의 해싱이다.

  • Dictionary는 기본적으로 Key 값을 해시 함수로 변환한 값을 Value를 저장하는 주소로 사용한다.
  • NetworkDictionary도 Key를 해싱하여 사용하는 것은 동일하다.
    • Key로 다른 INetworkStruct를 사용할 경우 해당 구조체가 IEquatable도 구현하여야 한다. 즉, GetHashCode를 오버라이드하여야 한다.
    • 그러나 GetHashCode를 구현할 시 HashCode.Combine을 사용할 경우 같은 값을 실행 환경에 따라 해시 값이 달라진다(비결정론적이다)!
      • 따라서 Dictionary 및 Key 값이 바르게 동기화되더라도 서로 다른 피어에서 탐색이 실패할 수 있다.
  • 따라서 키 값으로 사용하는 구조체의 GetHashCode 구현 시 반드시 결정론적으로 구현하여야 한다.
    • 이는 동일한 상태의 구조체는 동일한 Hash를 반환해야 함을 의미한다.
    • 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}]";
          }
      }
profile
Iosif2510

0개의 댓글