유니티에서 지원하는 네트워크 Netcode 라이브러리를 공부해보았습니다.
이 글은 Netcode For GameObjects(이제부터 NGO라고 할게요)를 공부할 수 있는 튜토리얼입니다.
준비물은 2021.3.1 이상의 유니티와 어느정도의 기초만 있으면 구현 가능합니다.
!추가
유니티가 이번에 UNITY 6 출시하면서 NGO 에 더 힘을 쏟은 것 같습니다.
어쩌면 좋은 솔루션일 수 있으니 더 자세한 NGO 개념에 대해서는 아래 깃허브 링크를 들어가보시기바랍니다.
https://github.com/LINEARJUN/FOSS-Unity-Netcode-Explanation?tab=readme-ov-file
주의 점:
- UI 제작 과정은 자세히 설명 안할 예정
- 플레이어의 기본적인 3D 움직임 조작키 코드 설명 안할 예정 (어차피 이동 코드는 간단하게 구현 가능)
- 전문적인 지식이 없어 틀린 내용이 있을 수 있음
이 튜토리얼의 메뉴얼은 아래 링크에 있습니다 ↓
https://docs-multiplayer.unity3d.com/netcode/current/about
#1 준비 단계
1. 설치하기
사용하고 있는 유니티 버전은 2021.3.20f1으로
2021.3.1 이상만 되면 Netcode for GameObjects 에셋을 사용할 수 있습니다.
사용하고 있는 샘플 에셋은 Starter Assets - Third Person Character Controller입니다.
그리고 이건 자유지만 유니티의 URP(Universal Render Pipeline)를 설치해줬습니다.
자세한 URP 설치는 아래 글의 셋팅 부분만 보면됩니다 ↓
우선 Netcode 패키지 설치가 필요합니다.
Unity Registry에서 Netcode for GameObjects를 설치하면됩니다.
2. NetworkManager 세팅하기
이제 빈 오브젝트를 만들어 NetworkManager라고 이름 지어준 다음 Add Component 눌러보면
이런식으로 컴포넌트 부분에 Netcode 탭이 생깁니다.
(사진에서 NetworkManager 제외한 나머지 오브젝트는 신경 안쓰셔도 됩니다)
Netcode탭을 누른 다음 NetworkManager 스크립트를 눌러서 적용해줍시다.
Network Manager 컴포넌트를 적용하면 알림이 이렇게 뜰 겁니다.
말 그대로 Multiplayer Tools 패키지를 설치하라는 말인데 설치를 해야
프로파일러나 runtime stats monitor 같은 기능을 사용할 수 있다고 합니다.
아까 패키지 매니저의 똑같은 Unity Registry 창에서 Multiplayer Tools를 찾아 설치 해줍시다.
설치 다한 후 아까 NetworkManager 오브젝트로 돌아가서 노란색 경고창이 transport를 선택하라고 뜰 겁니다.
그대로 Select transport를 눌러 UnityTransport를 선택해줍시다
Network Transport가 뭔가요? ↓
Unity Network Transport는 Unity에서 제공하는 네트워크 라이브러리 중 하나로, Network Transport를 사용하면 Unity에서 서버와 클라이언트 간의 데이터 통신을 구현할 수 있다.
Unity Network Transport는 Low-level API와 High-level API 두 가지 형태가 있다.
Low-level API는 데이터 패킷을 직접 만들어서 전송할 수 있는 API로,
전송 속도나 데이터 용량 등을 직접 제어할 수 있다는 장점이 있다.
High-level API는 Low-level API를 래핑한 좀 더 추상화된 API로,
간단한 설정으로도 데이터 통신을 구현할 수 있다.
Low와 Hight 레벨을 예전에 설정 창에서 따로 선택이 가능했다고 한다.
더 자세한 내용의 메뉴얼은 아래 링크로 ↓
https://docs-multiplayer.unity3d.com/netcode/current/advanced-topics/transports/index.html
Unity transport vs UNet transport 두 선택지 차이 ↓
Unity Transport와 UNet Transport는 Unity에서 제공하는 네트워크 라이브러리다.
UNet Transport는 Unity의 이전 버전에서 사용되던 네트워크 라이브러리다.
UNet을 사용하여 서버와 클라이언트 간의 데이터 통신을 Unity나 사용자 정의 프로토콜로 구현할 수 있었다.
반면 Unity Transport는 Unity 2019.1 버전 이후에 새롭게 도입된 네트워크 라이브러리다.
Unity Transport는 Low-level API와 High-level API를 모두 제공하며, 서버와 클라이언트 간의 데이터 통신을 처리하기 위해 UDP(User Datagram Protocol) 프로토콜을 쓴다.
UDP 프로토콜은 데이터 손실이 발생할 수 있지만, 대신 전송 속도가 빠르다는 특징이 있다.
Unity Transport는 새로운 Unity 버전에서 기본 네트워크 라이브러리로 사용된다. 따라서 새로운 Unity 프로젝트를 시작하는 경우에는 Unity Transport를 사용하는 것이 좋다.
하지만 기존에 작성된 UNet 프로젝트가 있다면 이전 버전에서 유지보수 위해 사용해도 된다.
3. Player Prefab 플레이어 프리팹
이제 플레이어를 만들어 봅시다.
빈 오브젝트를 Player로 지어준 뒤 아까랑 똑같이 Netcode탭에서 Network Object 스크립트를 붙혀줍니다.
이렇게 만든 플레이어는 프리팹으로 소환하기 위해 프리팹 시켜줘야합니다.
플레이어 오브젝트를 원하는 풀더에 드래그해서 드랍해주면 프리팹이 됩니다.
이제 플레이어 프리팹을 다시 Network Manager의 Player Prefab 탭에 넣어주면 됩니다.
Player Prefab가 뭔가요? ↓
Player Prefab은 게임에 접속한 플레이어의 인스턴스화될 프리팹을 지정하는 것이다.
이 프리팹은 클라이언트와 서버 모두에서 사용되고, 클라이언트가 서버에 접속할 때 Network Manager가 해당 프리팹을 인스턴스화하여 클라이언트의 씬에 생성한다. 그리고 서버에서 새로운 플레이어가 접속할 때마다 Player Prefab을 복제하여 해당 플레이어를 생성한다.
Player Prefab에 들어갈 프리팹은 Network Identity 컴포넌트가 포함된 프리팹이어야 하며, 클라이언트와 서버 모두에서 동일한 경로에 있어야 한다. Player Prefab은 꼭 지정해야하는건 아니지만 NetworkManager가 알아서 처리하는게 더 편리하다.
기본적으로 NetworkPrefabs탭의 리스트에 포함되어야 네트워크 상에 존재하게 됩니다.
따라서 더하기 모양의 Add to the List 버튼을 눌러 아까 우리가 만들어둔 플레이어 프리팹을 지정해줍시다.
4. 호스트, 서버, 클라이언트 버튼 컨트롤할 UI 만들기
NetworkManager 컴포넌트의 밑에 보면 버튼이 세개가 있는데 각각을 설명하자면 다음과 같습니다.
Start Host | 호스트는 게임 서버를 시작하고 클라이언트로 자신도 접속한다. 게임을 개발하거나 테스트하는 데 유용하다. |
Start Server | 게임 서버만 시작하고, 클라이언트는 따로 접속해야 한다. 버튼을 누르면 서버만 시작되고, 클라이언트는 IP 주소를 이용하여 서버에 접속할 수 있다. |
Start Client | 서버에 접속하기 위해 클라이언트로 게임을 실행한다. 클라이언트는 서버의 IP 주소와 포트 번호를 알고 있어야 하기 떄문에 클라이언트로 게임을 실행할 때는 반드시 서버가 먼저 실행되어 있어야 한다. |
이것들을 바탕으로 UI를 만들어 즉각 실행되도록 만들어봅시다.
우선 기본적인 Screen Space - Overlay 렌더모드의 Canvas를 만들어 주세요.
진짜 기본적인 UI를 위한 캔버스고 그냥 비율만 1920 x 1080의 UI Canvas 설정입니다.
위에 설명한 세개의 버튼을 컨트롤할 UI 버튼 세개를 만들어 주면됩니다.
그리고 버튼들을 관리할 NetworkManagerUI 오브젝트를 부모로 두었습니다.
이 NetworkManagerUI 오브젝트랑 똑같은 이름으로 스크립트를 만들어줍시다.
NetworkManagerUI.cs
using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.UI;
using QFSW.QC;
public class NetworkManagerUI : MonoBehaviour
{
//* 각 버튼들을 컨트롤하기 위해 선언해둠
//* SerializeField니까 에디터에서 끌어다가 지정해주면 됨
[SerializeField] private Button serverBtn;
[SerializeField] private Button hostBtn;
[SerializeField] private Button clientBtn;
private void Awake() {
//* 각 버튼을 누르면 발생할 이벤트를 람다식으로 선언
serverBtn.onClick.AddListener(() => {
//* 네트워크 매니저가 싱글톤으로 되있고
//* StartServer 버튼의 기능을 빌려옴
NetworkManager.Singleton.StartServer();
});
hostBtn.onClick.AddListener(() => {
//* StartHost 버튼의 기능을 빌려옴
NetworkManager.Singleton.StartHost();
});
clientBtn.onClick.AddListener(() => {
//* StartClient 버튼의 기능을 빌려옴
NetworkManager.Singleton.StartClient();
});
}
}
단순하게 NetworkManager의 세개 버튼 기능을 UI 버튼들에게 연결한 코드입니다.
5. 디버깅
이제 런타임 중에 네트워크 상태의 다양한 디버깅 정보를 얻기 위해 Log Level을 바꿔줘야합니다.
Log Level 옵션은 네트워크 관련 이벤트 및 오류를 어느 정도로 자세하게 로그로 남길지를 설정할 수 있습니다.
원래는 Normal이지만 테스트 할 땐 Developer로 설정해줍시다.
이렇게 설정한 디버깅 정보는 보통 C드라이브에서
윈도우 계정 이름\AppData\LocalLow\회사 이름\프로젝트 이름
위의 디렉토리를 타고 들어가보면 Player 메모장에 디버깅이 되어 있습니다.
근데 불편한 메모장보단 화면에 쉽게 콘솔 UI로 볼 수 있는 에셋이 있습니다. 아래 링크에 첨부했습니다 ↓
#2 NetworkBehaviour으로 네트워크 동기화
만들어 둔 버튼만으로도 빌드 해서 테스트해보면 잘 작동합니다.
그런데 여러 창으로 켜서 해보면 알겠지만 호스트와 클라이언트가 같은 서버에 연결은 되는데 각자 화면에 있는 PlayerPrefab이 서로의 움직임이 갱신이 안되서 자기 화면에서만 움직여집니다.
먼저 NetworkBehaviour을 통해 플레이어가 생성될 때 일어나는 일을 재정의해서 네트워킹 코드를 작성해봅시다.
NetworkBehaviour부터 간단하게 살펴봅시다.
한번 살펴보면 MonoBehaviour을 상속하고 있고, 각 변수들이 네트워크 상태를 알려줍니다.
이 변수들을 쓰기 위해서 NetworkBehaviour을 상속해봅시다.
NetworkBehaviour을 상속하기 위해선 Unity.Netcode를 선언해야합니다.
using Unity.Netcode;
이제 IsOwner을 사용해서 객체가 로컬 플레이어의 소유인지 로컬 플레이어 그 자체인지를 판단합니다.
로컬 플레이어 그 자체라면 이동 코드를 실행합시다.
using UnityEngine;
using Unity.Netcode;
public class PlayerNetwork : NetworkBehaviour {
void Update()
{
//* 이 오브젝트를 소유하는 클라이언트의 서버가 아니라면 취소
if(!IsOwner) return;
//* 이동 코드 (이 부분은 알아서 자유롭게 구현하셈)
Move();
}
}
1. 플레이어 움직임을 동기화되도록 만들기
빌드해서 보면 알겠지만 호스트 화면에서 플레이어가 잘 움직입니다.
그런데 아직도 클라이언트 화면에서는 호스트가 움직이는걸 동기화 못해서 멈춘걸로 보입니다.
이를 해결하기 위해서는 움직이는 오브젝트에 NetworkTransform 스크립트를 줘야합니다.
오브젝트에 이 스크립트를 주면 보이는 것처럼 Syncing에서 동기화할 값들을 설정 가능합니다.
플레이어가 커지거나 작아질 일은 없으니 Scale 옵션은 모두 체크 해제했습니다.
점프 안하거나 돌지 않는다면 Rotation x,y,z 랑 Position y 축을 체크 해제해주면됩니다.
테스트 결과 ↓
그런데 잘 보면 알겠지만 클라이언트 화면에서는 자기 캐릭터가 안 움직여집니다.
(중요)
이는 Netcode가 Server Authoritative의 특징을 가지고 있기 때문입니다.
Server Authoritative(서버 권한)은 게임에서 중요한 개념 중 하나입니다.
클라이언트에서 발생한 행동을 서버에서 처리하는 개념이입니다.
서버가 게임 상태를 관리하고, 클라이언트가 서버에 행동을 요청하면 서버가 해당 요청을 검증하고 게임 상태를 동기화 시켜서 업데이트 해줍니다.
서버가 클라이언트를 믿어주진 않기 때문에 클라이언트에서 뭘 조작하든 서버에 영향이 없습니다.
클라이언트는 그저 서버가 주는 정보를 받아 자기 화면을 업데이트합니다.
그런데 서버가 주는 정보를 기다리기만 한다면 시간 지연이 발생해서 fps같은 장르에는 어울리진 않습니다.
위에 상황에서는 클라이언트(로컬) 화면에서는 움직일려고 하지만 서버에서 보내주는 패킷을 받으니까 위치가 계속 (0,0,0)으로 초기화가 됩니다. 이를 해결할려면 우리는 클라이언트가 보내는 정보를 신뢰해줘야한다.
그러니 클라이언트의 권한을 상승 시켜주도록 합시다.
메뉴얼에서 찾아보면 Multiplayer Samples Utilities 에셋을 설치해줘야합니다.
(필수는 아니고 밑에서 코드로 구현 하는것도 언급할 예정)
이 깃허브 링크를 통해 설치가 가능합니다.
설치를 해서 기존에 오브젝트에 있던 NetworkTransform 스크립트를 지워줘야합니다.
대신 ClientNetworkTransform을 다시 주도록합시다.
그런데 자세히 보면 ClientNetworkTransform나 기존의NetworkTransform는 거의 똑같이 생겼습니다.
그 이유는 ClientNetworkTransform 스크립트를 열어보면 이해가 갑니다.
using Unity.Netcode.Components;
using UnityEngine;
namespace Unity.Multiplayer.Samples.Utilities.ClientAuthority
{
/// <summary>
/// Used for syncing a transform with client side changes. This includes host. Pure server as owner isn't supported by this. Please use NetworkTransform
/// for transforms that'll always be owned by the server.
/// </summary>
[DisallowMultipleComponent]
public class ClientNetworkTransform : NetworkTransform
{
/// <summary>
/// Used to determine who can write to this transform. Owner client only.
/// This imposes state to the server. This is putting trust on your clients. Make sure no security-sensitive features use this transform.
/// </summary>
protected override bool OnIsServerAuthoritative()
{
return false;
}
}
}
보다시피 NetworkTransform 스크립트를 상속하고 있습니다.
만약 위에 에셋을 설치하기 싫다면 OnIsServerAuthoritative() 함수만 작성해도 구현 가능합니다.
#3 NetworkVariable 네트워크 변수
원래 RPC(Remote Procedure Call)를 이용해서 변수 프로퍼티를 서버와 클라이언트가 공유합니다.
근데 NetworkVariable를 사용하면 커스텀 메시지나 RPC 없이 동기화 되는 변수를 구현가능합니다.
NetworkVariable이 동기화 되는 원리는 다음과 같습니다.
늦게 들어온 클라이언트 ↓
NetworkObject가 생성되면 모든 NetworkVariable의 현재 상태(값)가 클라이언트 측에서 자동으로 동기화됩니다.
연결된 클라이언트 ↓
NetworkVariable 값이 변경되면 NetworkVariable.OnValueChanged를 구독한 모든 클라이언트에 변경을 알립니다.
두 개의 매개변수가 NetworkVariable.OnValueChanged 구독 콜백 메서드에 전달됩니다.
각각 previousValue와 newValue입니다.
참고로 newValue는 NetworkVariable.Value와 같습니다.
우선 사용하기 전에 필요한 것은 다음과 같습니다.
- 위에서 얘기한 NetworkBehaviour를 상속해야만 합니다.
- 부모나 자신이 NetworkObject 컴포넌트가 있어야만 합니다.
NetworkVariable의 주의해야할 점? ↓
값을 생성할려면 프로퍼티 선언 부분이나 Awake() 함수까지 이전에 해야한다는 것이다.
아니면 NetworkObject가 생성되는 동안 까지는 값 세팅을 끝내놔야한다.
왜냐면 클라이언트가 처음 연결되면 NetworkVariable의 현재 값과 동기화가 된다.
그렇기 때문에 클라이언트는 OnNetworkSpawn 메서드 내에서 NetworkVariable.OnValueChanged에 등록해야 한다.
또한 접속 후 OnNetworkSpawn 메서드가 실행되어야 인스턴스가 생성되므로 그 과정에서 Start()가 호출되기 때문이다. 처음 초기화할 때나 생성된 경우에만 NetworkVariable의 값을 설정해야 한다.
연결된 NetworkObject가 생성되기 전이라면 NetworkVariable을 설정하지 않는 것이 좋다.
사용방법은 아래와 같은 모양으로 선언해서 사용하면됩니다.
접근제한자 NetworkVariable<타입> 이름 = new NetworkVariable<타입>(초기값)
NetworkVariable<string> randomString = new NetworkVariable<string>("초기값");
꼭 string 타입이 아녀도 NetworkVariable에 적용되는 타입은 값 타입이면 다 됩니다.
private NetworkVariable<int> randomNum = new NetworkVariable<int>();
그래도 한번 지원하는 값 타입을 정리해보았습니다 ↓
C# 관리되지 않는 기본 형식(버퍼 내/외부에서 직접 memcpy에 의해 직렬화 됨): bool, byte, sbyte, char, decimal, double, float, int, uint, long, ulong, short 및 ushort
Unity 관리되지 않는 기본 제공 유형(버퍼 내/외부에서 직접 memcpy에 의해 직렬화 됨): Vector2, Vector3, Vector2Int, Vector3Int, Vector4, Quaternion, Color, Color32, Ray, Ray2D
모든 enum 유형(버퍼 내/외부에서 직접 memcpy에 의해 직렬화됨).
아니면 strut 유형
INetworkSerialize(NetworkSerialize 메서드를 호출해 직렬화됨)를 구현하는 모든 유형(관리 또는 비관리).
읽기 측면에서 이러한 값은 제자리에서 역직렬화된다.
즉, 기존 인스턴스가 재사용되고 직렬화되지 않은 모든 값이 현재 상태로 남아 있다.
INetworkSerializeByMemcpy를 구현하는 관리되지 않는 모든 구조체 유형(버퍼 안팎으로 전체 구조체의 직접 memcpy에 의해 직렬화됨).
Unity 고정 문자열 유형: FixedString32Bytes, FixedString64Bytes, FixedString128Bytes, FixedString512Bytes 및 FixedString4096Bytes(지능적으로 직렬화되어 사용된 부분만 네트워크를 통해 전송하고 수신된 데이터에 맞게 다른 쪽 문자열의 "길이"를 조정함).
NetworkVariable에서 보이는 제네릭<T>에 관해서는 아래글 참고해주세요 ↓
이렇게 생성한 NetworkVariable을 한번 살펴봅시다.
눈여겨볼만한 것은 Value에 접근해서 값을 변경가능하다는 것입니다.
randomNum.Value = 2;
이런식으로 위에서 선언했던 int형 randomNum.Value로 값을 변경가능합니다.
위 사진에서의 OnValueChanged 사용할 때 주의점 ↓
또한 위에 사진을 보면 OnValueChanged 델리게이트에 구독이 가능한데 Value가 변하면 호출된다.
그런데 위에서도 말했듯이 연결된 NetworkObject가 생성되야 NetworkVariable이 초기화 된다.
때문에 OnValueChanged는 NetworkBehaviour에 있는 OnNetworkSpawn 함수로 구독 하면된다.
NetworkVariable<int> randomNum = new NetworkVariable<int>(1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
public override void OnNetworkSpawn()
{
randomNum.OnValueChanged += OnSomeValueChanged;
}
private void OnSomeValueChanged(int previousValue, int newValue)
{
//값 바뀔 때 수행할 것들
}
이렇게 OnNetworkSpawn 함수를 오버라이딩해서 OnValueChanged를 구독하면 된다.
아니면 람다식으로 구독해도 된다.
randomNum.OnValueChanged += (int previousValue, int newValue) => {
//값 바뀔 때 수행할 것들
};
2. NetworkVariable 권한 설정
이제 직접 테스트 보면 알겠지만 이번에도 클라이언트에서 값을 설정할 수가 없습니다.
NetworkVariable을 클라이언트측에서 Value로 값을 설정하면 에러가 뜹니다.
역시나 이번에도 권한 문제인데 NetworkVariable을 선언할 때 권한 설정이 가능합니다.
사진을 보면 초기값 설정 다음으로 각종 권한 설정이 가능합니다.
권한 설정이 두 종류가 있는데 첫번째 인자로는 읽기 권한, 두번째 인자는 쓰기 권한입니다.
이제 권한을 적어줄려고 하면 사진처럼 선택이 있습니다.
각 권한의 옵션은 다음과 같은 설정이 가능합니다.
Read Permissions (읽기 권한) | Everyone(기본 설정) : NetworkObject의 소유자(owner) 및 비소유자(non-owners)가 값을 읽을 수 있다. 모든 사람이 알고 있어야 하는 '전역 상태'에 유용하다. Everyone 권한을 사용하여 문의 열림 또는 닫힘 상태를 유지하거나 플레이어 점수, 체력 또는 다른 사람 모두가 알아야 할 기타 상태에 이를 사용한다. |
Owner : NetworkObject의 소유자(owenr)와 서버만 값을 읽을 수 있다. NetworkVariable이 서버와 클라이언트만 알아야 하는 클라이언트의 플레이어의 특정 상태를 나타내는 경우에 유용하다. 예를 들어 플레이어의 인벤토리 또는 총의 탄약 수 등이 있다. |
|
Write Permissions (쓰기 권한) | Server(기본 설정) : 값을 쓸 수 있는건 서버뿐이다. 서버 말고는 수정이 안된다. 모든 클라이언트가 인식해야 하지만 변경 안되는 서버 측의 특정 상태에 쓴다. 예를 들어 NPC의 상태(건강, 생존, 사망 등) 또는 날씨나 시간이 있다. |
Owner : NetworkObject의 소유자(owner)만 값을 수정 가능하다. NetworkVariable이 소유 클라이언트만 설정할 수 있어야 하는 클라이언트의 플레이어에 특정한 것을 나타내는 경우에 유용하다. 예를 들어 플레이어의 스킨 또는 기타 악세사리 등이 있다. |
위를 토대로 클라이언트가 값을 바꾸고 다른 사람 모두가 읽을려면 이런식으로 설정하면됩니다.
NetworkVariable<int> randomNum = new NetworkVariable<int>(1,NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
3. 커스텀 NetworkVariable 만들기
위에서 NetworkVariable이 지원하는 타입들 외에 우리가 커스텀 타입을 만들 수 있습니다.
먼저 잘못된 예를 한번 봅시다.
private NetworkVariable<CustomStats> randomNum = new NetworkVariable<CustomStats>(new CustomStats{
health = 10,
isDead = false
}, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
public struct CustomStats
{
public int health;
public bool isDead;
}
보다시피 제네릭 타입에 우리가 자유롭게 만든 struct 구조체를 넣어줄 수 있습니다.
그렇지만 막상 빌드해서 테스트해보면 에러가 뜰 것입니다.
왜냐하면 NetworkManager에서 이 커스텀 NetworkVariable를 어떻게 직렬화할지를 모르기 때문입니다.
해결법은 커스텀 NetworkVariable에 INetworkSerializable를 상속하는 것입니다.
INetworkSerializable를 상속하면 자동으로 인터페이스를 완성해줘야합니다.
아래의 올바른 예를 보면 인터페이스까지 구현되어있습니다.
private NetworkVariable<CustomStats> randomNum = new NetworkVariable<CustomStats>(new CustomStats{
health = 10,
isDead = false
}, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
public struct CustomStats : INetworkSerializable
{
public int health;
public bool isDead;
public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
{
serializer.SerializeValue(ref health);
serializer.SerializeValue(ref isDead);
}
}
4. C#에서 String 값은 참조형식입니다.
만약 커스텀 NetworkVariable에 struct 형식에다가 string 멤버 변수를 넣는다면 에러가 뜹니다.
C#에서 string 변수는 참조형식이기 때문에 역직렬화시 성능에 문제가 생깁니다.
그래도 string 변수를 사용하고 싶다면 Unity.Collections.FixedString 타입을 사용해봅시다.
아래 코드에서 Unity.Collections.FixedString를 사용한 해결방법이 있습니다.
private NetworkVariable<CustomStats> randomNum = new NetworkVariable<CustomStats>(new CustomStats
{
health = 10,
isDead = false,
name = "kim"
}, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
public struct CustomStats : INetworkSerializable
{
public int health;
public bool isDead;
public FixedString128Bytes name;
public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
{
serializer.SerializeValue(ref health);
serializer.SerializeValue(ref isDead);
serializer.SerializeValue(ref name);
}
}
보다시피 Unity.Collections.FixedString를 사용해서 FixedString128Bytes 타입을 사용했습니다.
영어 한글자당 1 바이트이고 한글이나 한자는 한글자당 2바이트입니다. 1byte(바이트) = 8 bits(비트)
때문에 이름같이 짧은건 FixedString32Bytes 타입을 사용해도 됩니다.
에디터에서 자동완성을 보면 FixedString32Bytes와 FixedString32가 있는데 FixedString32같이 뒤에 Bytes가 없는 타입들은 구버전이기 때문에 신경쓰지 맙시다.
#4 RPC ( 원격 프로시저 호출 )
이제 원격 프로시저 호출인 RPC(Remote Procedure Call)를 알아봅시다.
위에서 소개한 NetworkVariable보다 RPC가 더 흔하게 사용되는 개념입니다.
RPC는 네트워크 공간에서 클라이언트와 서버 간의 메시지를 전송합니다.
이 때 해당 메시지가 수행되어야 할 메서드(함수)를 호출하는 방식입니다.
이걸로 게임 오브젝트를 동기화 하고 클라이언트와 서버의 상호작용을 구현 할 수 있습니다.
1. ServerRpc Method (서버RPC 메서드) 구현
ServerRpc는 클래스 내에서 메서드(함수)를 호출(실행)하는 것과 같습니다.
ServerRpc는 클라이언트에 의해서만 호출해서 서버로 정보를 보낼 수 있습니다.
ServerRpc는 항상 서버/호스트에서 수신되고 실행되는 RPC(원격 프로시저 호출)입니다.
우선 사용하기 전에 필요한 것은 NetworkVariable이랑 똑같습니다.
- 위에서 얘기한 NetworkBehaviour를 상속해야만 합니다.
- 부모나 자신이 NetworkObject 컴포넌트가 있어야만 합니다.
이제 한번 선언해보도록합시다.
[ServerRpc]
private void TestServerRpc()
{
//함수 내용
}
보다시피 두가지 조건이 있습니다.
1. 메서드(함수) 위에 [ServerRpc] 속성 추가해줘야합니다.
2. 메서드 이름 끝에 무조건 ServerRpc 라고 적어줘야합니다. 안그러면 Suffix(접미사) 에러가 뜹니다.
선언한 메서드는 메서드 호출처럼 TestServerRpc(); 이런 느낌으로 호출가능합니다.
2. ServerRpc 메서드(함수) 파라미터(매개변수)
ServerRpc 메서드의 파라미터(매개변수)에는 값 형식이면 다 들어갑니다.
그리고 특별히 ServerRpc에는 string 타입도 들어갑니다!
덕분에 NetworkVariable보다 쉽게 string 메세지를 보내기 쉽습니다.
또한 파라미터에 ServerRpcParams를 넣어서 활용이 가능합니다.
ServerRpcParams 정의에 가보면 다음 사진과 같이 나옵니다.
여기서 Receive 변수의 ServerRpcReceiveParams의 정의를 가보면 SenderClientId를 알 수 있습니다.
따라서 SenderClientId로 Server-Side RPC를 보낸 사람의 클라이언트 식별 ID를 알 수 있습니다.
이제 예제 코드로 한번 테스트해봅시다.
private void Update()
{
//* 이 오브젝트를 소유하는 클라이언트의 서버가 아니라면 취소
if (!IsOwner) return;
if (Input.GetKeyDown(KeyCode.H))
{
TestServerRpc(new ServerRpcParams());
}
}
[ServerRpc]
private void TestServerRpc(ServerRpcParams serverRpcParams)
{
UnityEngine.Debug.Log("OwnerClientId : " + OwnerClientId + "; SenderClientID : " + serverRpcParams.Receive.SenderClientId);
}
보다시피 TestServerRpc의 매개변수로 ServerRpcParams를 넣어서 SenderClientId를 출력했습니다.
이걸로 한번 디버깅 해본 결과 ↓
보다시피 각 화면에서 ServerRpc함수를 실행했더니 OwnerClientID와 SenderClientID가 같은걸 볼 수 있습니다.
다시한번 말하자면 ServerRpc는 항상 서버/호스트에서 수신되고 실행되는 RPC(원격 프로시저 호출)입니다.
그렇기 때문에 클라이언트 화면에서 ServerRpc함수를 실행하더라도 출력은 호스트 화면에서 출력이 됩니다.
이렇게 서버로 요청해서 실행하기 때문에 클라이언트에 권한 주기 싫다면 ServerRpc를 활용하도록합시다.
3. ClientRpc Method (클라이언트RPC 메서드) 구현
이번엔 ClientRpc인데 ServerRpc랑 사용방법은 거의 비슷합니다. 다만 목적이 다릅니다.
방식이 서버가 ClientRpc를 호출하여 모든 클라이언트에서 메서드를 실행할 수 있습니다.
그리고 특정 클라이언트의 ID만 있으면 그 클라이언트에서만 메서드 실행하는 것도 됩니다.
서버가 호출해서 클라이언트에서 메서드를 실행한다는 것이 중요합니다.
서버에서 함수 호출할 때 매개변수로 뭔가를 전달했다면 클라이언트에서 그 매개변수를 받을 수 있습니다.
그리고 RPC는 NetworkVariable와 다르게 사진처럼 호출할 때 매개변수 두개를 동시에 전달 가능하다.
사용하기 전에 필요한 것은 NetworkVariable이랑 똑같습니다.
- 위에서 얘기한 NetworkBehaviour를 상속해야만 합니다.
- 부모나 자신이 NetworkObject 컴포넌트가 있어야만 합니다.
이제 선언해봅시다.
[ClientRpc]
private void TestClientRpc()
{
//함수 내용
}
선언할 때 역시나 두가지 조건이 있습니다.
1. 메서드(함수) 위에 [ClientRpc] 속성 추가해야합니다.
2. 메서드 이름 끝에 무조건 ClientRpc라고 적어줘야 Suffix(접미사) 에러가 안뜹니다.
선언한 메서드는 그냥 메서드 호출처럼 TestClientRpc(); 이런 느낌으로 호출가능합니다.
4. ClientRpc 메서드(함수) 파라미터(매개변수)
ClientRpc도 ServerRpc마냥 파라미터에 값 형식이면 다 들어갑니다.
또한 ClientRpcParams도 있어서 이걸로 특정 클라이언트에서만 메서드를 실행 하도록 할 수 있습니다.
ClientRpcParams 정의에 가서 어떤게 있는지 살펴봅시다.
보다시피 Send와 Receive가 있는데 주목해야할건 Send의 ClientRpcSendParams입니다.
ClientRpcSendParams에는 TargetClientIds가 있는데 이걸로 특정 클라이언트에게 전달해 실행할 수 있습니다.
아래는 첫번째 클라이언트만 메서드 실행하는 예제 코드입니다. ↓
[ClientRpc]
private void TestClientRpc(ClientRpcParams clientRpcParams)
{
//함수 내용
}
private void Update()
{
//* 이 오브젝트를 소유하는 클라이언트의 서버가 아니라면 취소
if (!IsOwner) return;
if (Input.GetKeyDown(KeyCode.H))
{
TestClientRpc(new ClientRpcParams
{
Send = new ClientRpcSendParams { TargetClientIds = new List<ulong> { 1 } }
});
}
}
5. 번외) RPC vs NetworkVariable
RPC는 단순해서 일시적인 이벤트에 사용해야한다. 그리고 NetworkVariable은 constant, 잘 안변하는 거에 사용해야합니다.
때문에 게임 진행 도중 들어오는 클라이언트에게 필요한 정보 전달은 NetworkVariable를 씁시다.
근데 게임 진행 도중 다시 못 들어오게 규칙을 계획했다면 역시나 간단한 RPC만 쓰는게 좋습니다.
6. 번외) ServerRpc vs ClientRpc
서버 | 클라이언트 | 호스트 (서버 + 클라리언트) | |
ServerRpc Send | X | O | O |
ServerRpc 실행 | O | X | O |
ClientRpc Send | O | X | O |
ClientRpc 실행 | X | O | O |
#5 Netcode 네트워크 오브젝트 생성 & 파괴
정말 중요한 네트워크 오브젝트를 생성하고 없애봅시다.
원래 Unity에서 인스턴스화해서 Instantiate로 게임 옵젝(오브젝트)을 생성합니다.
그렇게 게임 옵젝을 생성하면 해당 오브젝트가 로컬 시스템에만 생성되서 로컬에서만 보입니다.
Netcode에서 옵젝생성은 서버에 의해 모든 클라이언트 간에 동기화되는 개체를 인스턴스화(생성)하는 것을 의미합니다.
그렇기에 서버에 네트워크 옵젝을 생성하기위해선 NetworkManager에 관리 해야합니다.
이전에 말했듯이 Netcode는 Server Authoritative특징 때문에 개체 생성은 서버 또는 호스트에서만 됩니다.
1. Network Prefab 네트워크 프리팹 세팅
이 글의 처음에 플레이어 프리팹을 만들어서 NetworkManager의 Network Prefab 리스트에 넣었습니다.
Network Prefab 리스트에 들어가는 프리팹들은 서버와 연결된 모든 클라이언트가 모두 가지고 있어야 합니다.
그렇기 때문에 NetworkManager에서 관리를 해서 네트워크 상 생성 및 파괴가 됩니다.
만약 런타임에서 서버에 프리팹을 추가하고 싶다면 AddNetworkPrefab(GameObject) 함수도 사용가능합니다.
다만 클라이언트에 자동으로 추가되지 않기에 RPC를 통해 클라이언트와 서버가 동기화되도록 해야합니다.
어쨋든 네트워크 오브젝트 생성을 하고 싶으면
1. 생성할 대상 오브젝트에 NetworkObject 컴포넌트를 넣어줍시다.
2. Network Prefab 리스트에 프리팹을 만들어서 넣어줍시다.
참고로 NetworkObject는 NetworkBehaviour를 할당하기 위해 자식이나 부모에 무조건 하나만 존재해야합니다.
2. 오브젝트 생성
이제 코드에서 네트워크 오브젝트 생성을 해봅시다.
[SerializeField] private Transform targetObjPrefab;
private Transform spawnedObjTrans;
private void Update()
{
//* 이 오브젝트를 소유하는 클라이언트의 서버가 아니라면 취소
if (!IsOwner) return;
if (Input.GetKeyDown(KeyCode.T))
{
spawnedObjTrans = Instantiate(targetObjPrefab);
spawnedObjTrans.GetComponent<NetworkObject>().Spawn(true);
}
}
간단한 T키를 누르면 오브젝트를 생성하는 코드입니다.
Spawn(true) 부분이 눈에 띄는데 이 매개변수는 씬 이동시 생성한 옵젝을 파괴할지 정하는 bool변수입니다.
public void Spawn(bool destroyWithScene = true);
테스트 해보면 알겠지만 역시나 클라이언트측에서 소환할려하면 에러가 뜹니다.
클라이언트에게 권한이 없기 때문인데 소환하고싶다면 ServerRpc로 서버측에 요청하면 됩니다.
3. 오브젝트 파괴 or 디스폰
서버나 호스트에서 유니티의 기본 Destroy() 함수로 옵젝을 파괴해도 모든 클라이언트에서 자동으로 디스폰됩니다.
그렇지만 파괴가 아닌 잠깐 디스폰만 하고 싶은 오브젝트는 Despawn() 함수를 사용합시다.
서버만 NetworkObject를 디스폰할 수 있고 기본적인 디스폰은 연결된 게임 오브젝트를 파괴하는 것입니다.
예제 코드 ↓
[SerializeField] private Transform targetObjPrefab;
private Transform spawnedObjTrans;
private void Update()
{
//* 이 오브젝트를 소유하는 클라이언트의 서버가 아니라면 취소
if (!IsOwner) return;
if (Input.GetKeyDown(KeyCode.T))
{
spawnedObjTrans = Instantiate(targetObjPrefab);
spawnedObjTrans.GetComponent<NetworkObject>().Despawn(true);
}
}
NetworkObject를 디스폰만 하고 싶으면 NetworkObject.Despawn을 호출하고 false를 매개 변수로 전달해야 합니다.
이 과정은 서버에서 옵젝을 디스폰하고 파괴하면 모든 클라이언트가 항상 알림을 받고 디스폰을 하는 방식입니다.
또한 대상 오브젝트는 파괴되면서 NetworkObjet 구성 요소도 없어집니다.
만약 클라이언트가 연결 끊기면 네트워크 세션 중에 생성된 모든 네트워크 프리팹은 클라이언트 측에서 삭제됩니다.
이게 싫다면 반대로 NetworkObject의 DontDestroyWithOwner 필드를 true로 바꾸면 됩니다.
코드로는 아래와 같이 하면 되고 보통은 인스펙터로 설정합니다.
SpawnedNetworkObject.DontDestroyWithOwner = true;
#6 NetworkAnimator 네트워크 애니메이터
NetworkTransform에서 위치를 동기화 했던 것 처럼 애니메이터도 동기화 가능합니다.
애니메이터의 State 전환과 프로퍼티를 동기화 가능합니다.
다만 주의할 점은 애니메이터 트리거는 이미 연결된 클라이언트만 동기화됩니다.
사용방법은 적용할 애니메이터를 가진 오브젝트에 NetworkAnimator 컴포넌트를 주면 됩니다.
테스트해보면 서버측 플레이어는 잘 작동하지만 클라리언트의 플레이어는 애니메이터가 작동을 안합니다.
역시나 이번에도 권한 문제기 때문에 클라이언트에게 권한을 주도록합시다.
방법은 두가지 있습니다.
1. Network Animator 스크립트 상속해서 아래 코드 추가
protected override bool OnIsServerAuthoritative()
{
return false;
}
2. 이미 만들어진 ClientNetworkAnimator 컴포넌트로 교체하기
#7 네트워크 모니터링 with 프로파일러
이제 서버와 클라이언트가 서로 메시지 주고 받는 걸 프로파일링해봅시다.
1. 유니티 기본 Profiler 이용
단축키로 ctrl+7를 누르면 열리는 Profiler 탭입니다.
여기서 마우스 휠로 내려서 NGO(Netcode for GameObjects) 부분만 보면됩니다.
2. RuntimeNetStatsMonitor
RuntimeNetStatsMonitor은 처음에 우리가 설치한 Multiplayer Tools package에 있는 도구입니다.
줄여서 RNSM(Runtime Network Stats Monitor)라고 부르는 모니터링 도구입니다.
사용자 정의 통계도 포함되고 프로파일러보다 더 보기 쉽다는 장점이 있습니다.
런타임 시 게임 화면에 네트워크 통계를 표시되기 때문에 보기 쉽습니다.
셋팅 방법은 간단합니다.
빈 오브젝트 생성 -> 이름 비슷하게 짓고 -> RuntimeNetStatsMonitor 스크립트 추가
이런식으로 테스트 가능합니다.
그러나 저 그래프가 느리게 갱신되길래 맘에 안들어서 이것저것 만져서 고쳐보았습니다.
아까 위에 RuntimeNetStatsMonitor 컴포넌트 사진에 보면 Configuration 부분에 SO하나가 있습니다.
이걸 Custom으로 바꿔보았습니다.
사진에 보는것처럼 Per Second 부분을 Per Frame으로 바꾸면됩니다.
다만 SO가 버그 때문인지 가보면 수정이 안됩니다.
따라서 오른쪽 위에 점 세개를 눌러서 아무거나 활성화하고 끄면 수정이 됩니다.
마무리
이제 진짜 서버가 있고 멀티가 되는 게임을 만들고 싶다면 아래 메뉴얼을 봅시다. ↓
https://docs-multiplayer.unity3d.com/netcode/current/learn/listen-server-host-architecture
이른바 listen Server라는 방법입니다.
일단 무료고 플레이어는 12명 미만이면 작동을한다고 합니다.
메뉴얼에 있는 여러가지 방법들의 개념들이 생소했습니다.
아래 링크의 글이 굉장히 설명을 쉽게 했기에 한번 확인해보면 좋을 것 같습니다. ↓
'유니티 > 유니티 관련 지식' 카테고리의 다른 글
[1] MVVM 데이터 바인딩이란? (0) | 2024.11.07 |
---|---|
유니티 싱글톤 상속 템플릿 (0) | 2024.10.29 |
유니티 넷코드 성능 비교 보고서 (0) | 2024.10.18 |
유니티 라이트맵 가볍게 굽는 설정 (0) | 2024.10.17 |