-
멀티 서버 게임오브젝트 넷코드 마스터하기 / Master Unity Netcode for GameObjects유니티 2024. 12. 27. 01:54
넷코드(Netcode)란?
유니티의 서버용 코드입니다.
과거 유넷(Unet)이나 미러(Mirror)의 유니티 최신 상위호환이라고 보면 됩니다.
넷코드 자체는 무료입니다.
넷코드 만으로는 자체 서버를 돌리는 등의 작업이 별도로 필요합니다.
또는 용량제 가격으로 유니티 서비스의 로비 서버와 릴레이 서버를 합해서 이용하는 방법이 있습니다.
https://unity.com/kr/products/gaming-services/pricing
에서 다양한 유니티 게이밍 서비스 가격을 확인하실 수 있습니다.
https://docs-multiplayer.unity3d.com/netcode/current/about/
에서 공식문서를 확인할 수 있습니다.
넷코드의 특징으로는
- 데디케이티드 서버, 호스트, 클라이언트로 연결할 수 있습니다.
- 변수 동기화(NetworkVariable)인데 중간에 들어와도 유지됩니다.
- 함수 동기화(Rpc)인데 대상 설정이나 보낸 정보를 세밀히 할 수 있습니다.
- 기본적으로 서버쪽 권한으로 네트워크 객체들의 동기화를 결정하여 보안이 됩니다.
설치
Window - Package Manager - Unity Registry - Netcode for GameObjects를 설치합니다.
Netcode for Entities는 DOTS를 사용하며 수많은 멀티 오브젝트의 동기화에서 사용되며 난이도가 높습니다.
또 Multiplayer Tools를 설치합니다.
네트워크 오브젝트에 대한 프로파일러나 런타임 상태 모니터 기능이 있습니다.
전체적인 구조
인스펙터 맨 아래 Add Component를 눌러 나온 Netcode 폴더에는 다음이 있습니다.
- NetworkManager: 전체적인 네트워크를 설정합니다.
- UnityTransport: 유니티의 네트워크 전송 계층입니다.
- NetworkObject: 동기화 될 게임오브젝트는 반드시 이 컴포넌트를 가지고 있어야 합니다.
- NetworkTransform: 위치, 회전, 크기 동기화를 합니다.
- NetworkRigidbody, NetworkRigidbody2D: 물리 동기화를 합니다.
- NetworkAnimator: 애니메이터 동기화를 합니다.
- RuntimeNetworkStatsMonotor: 실행중 게임창에서 네트워크 트래픽을 시각화해 보여줍니다.
네트워크 접속
빈 게임오브젝트 NetworkManager라고 이름을 변경하고 NetworkManager 컴포넌트를 넣습니다.
Select transport...를 UnityTransport로 설정합니다.
그리고 Log Level을 개발중 디테일한 로그를 위해 Developer로 합니다.
재생을 하면 NetworkManager 인스펙터에서 해당 버튼을 볼 수 있는데
유니티 에디터에서는 간단하게 호스트, 서버, 클라이언트로 접속할 수 있습니다.
캔버스에 전체화면을 덮는 NetworkManagerUI 빈 게임오브젝트를 만들고
자식으로 ServerBtn, HostBtn, ClientBtn을 만들고 화면과 같이 이름을 변경 및 오른쪽 위를 앵커로 둡니다.
NetworkManagerUI.cs
using Unity.Netcode; using UnityEngine; using UnityEngine.UI; using UnityEngine.Events; public class NetworkManagerUI : MonoBehaviour { [SerializeField] Button serverBtn; [SerializeField] Button hostBtn; [SerializeField] Button clientBtn; void Awake() { serverBtn.AddEvent(() => NetworkManager.Singleton.StartServer()); hostBtn.AddEvent(() => NetworkManager.Singleton.StartHost()); clientBtn.AddEvent(() => NetworkManager.Singleton.StartClient()); } } public static class Extension { public static void AddEvent(this Button btn, UnityAction action) { btn.onClick.RemoveAllListeners(); btn.onClick.AddListener(action); } }
각각 네트워크 매니저에 있는 서버, 호스트, 클라이언트를 실행하도록 연결합니다.
확장 메서드 AddEvent는 클릭 이벤트 등록을 단 한번만 유지하게 합니다.
플레이어 위치 동기화
간단하게 원점(0, 0, 0)에 Plane 바닥을 만들고 크기를 키웁니다.
빈 게임오브젝트 Player를 원점에 만들고 그 자식으로 Capsule을 만들고 Position Y만 1 올립니다.
Player에는 네트워크 동기화를 위해 NetworkObject 컴포넌트를 넣습니다.
또 위치 동기화를 위해 NetworkTransform 컴포넌트를 넣습니다.
그리고 트래픽을 줄일 수 있도록 필요한 기능만 체크합니다. 여기서는 Position X와 Z만 체크합니다.
권한이 기본으로 Server인데 클라이언트 권한으로 하려면 Authority Mode를 Owner로 합니다.
Player를 프리팹으로 만들고 씬에 있는 건 지웁니다.
NetworkObject 컴포넌트가 있는 프리팹이라면 자동으로 네트워크 프리팹에 등록이 됩니다.
프로젝트의 DefaultNetworkPrefabs에 들어가져 있습니다.
연결하자마자 플레이어의 생성을 위해 NetworkManager의 Default Player Prefab에 넣습니다.
Edit - Project Settings... - Player - Resolution and Presentation의 설정을
테스트를 용이하게 창모드 및 백그라운드 실행을 합니다.
File - Build and Run을 합니다.
에디터에서 서버겸 클라이언트인 Host로 접속하였고, 빌드한 화면이 Client로 접속했습니다.
Owner 소유권이 자신인 것만 움직였을때 상대방에게 동기화 되는 걸 볼 수 있습니다.
이렇듯 컴포넌트만으로 동기화가 가능한 것은 NetworkRigidbody, NetworkRigidbody2D, NetworkAnimator 입니다.
네트워크 비헤비어 (NetworkBehaviour)
NetworkPlayer.cs
using Unity.Netcode; using UnityEngine; public class NetworkPlayer : NetworkBehaviour { }
Unity.Netcode 네임스페이스에는 추상클래스 NetworkBehaviour가 있습니다.
MonoBehaviour를 감싸고 있어 유니티 함수 또한 사용 가능합니다.
네트워크 관련된 각종 프로퍼티 및 함수들이 있습니다.
프로퍼티
- NetworkManager NetworkManager: 싱글톤으로 자신의 인스턴스를 가져옵니다.
- RpcTarget RpcTarget: Rpc 대상을 특정해 전달합니다.
- bool IsLocalPlayer: 현재 클라이언트가 해당 네트워크 오브젝트를 제어하고 있는지
- bool IsOwner: 네트워크 오브젝트를 제어할 권한,
기본적으로는 LocalPlayer와 같지만 다른 클라이언트에게 권한을 부여한다면 제어할 수 있음 - bool IsServer: 서버인지
- bool ServerIsHost: 서버가 호스트인지
- bool IsClient: 클라이언트인지
- bool IsHost: 호스트인지
- bool IsOwnedByServer: 서버에게 소유권이 있는지
- bool IsSpawned: 씬에 네트워크 오브젝트가 생성이 됐는지
- NetworkObject NetworkObject: 네트워크 오브젝트
- bool HasNetworkObject: 네트워크 오브젝트를 가지고 있는지
- ulong NetworkObjectId: 세션 단위로 고유한 네트워크 오브젝트 식별 아이디, 생성 순서대로 1부터 부여
- ushort NetworkBehaviourId: 네트워크 오브젝트 아이디의 소유자인 NetworkBehaviour 아이디
- ulong OwnerClientId: 소유자의 클라이언트 아이디, 클라이언트 접속 순서대로 0부터 부여
함수
- NetworkBehaviour GetNetworkBehaviour(ushort behaviourId): NetworkBehaviour 아이디로
다른 NetworkBehaviour 객체를 가져옵니다. - NetworkObject GetNetworkObject(ulong networkId): 네트워크 오브젝트 아이디로
다른 네트워크 오브젝트 객체를 가져옵니다. - virtual void OnNetworkPreSpawn(ref NetworkManager networkManager): 네트워크 오브젝트의
Spawn 전에 호출, Awake 같은 - virtual void OnNetworkSpawn(): 네트워크 오브젝트가 생성시 호출, Start 같은
- virtual void OnNetworkPostSpawn(): 네트워크 오브젝트의 Spawn 후에 호출
- virtual void OnNetworkDespawn(): 네트워크 오브젝트가 제거시 호출, OnDestroy 같은
- virtual void OnGainedOwnership(): 클라이언트가 네트워크 오브젝트의 소유권 획득시 호출
- virtual void OnLostOwnership(): 클라이언트가 네트워크 오브젝트의 소유권 잃을시 호출
- virtual void OnOwnershipChanged(ulong previous, ulong current): 네트워크 오브젝트의
소유권이 다른 클라이언트한테 전환시 호출 - virtual void OnNetworkSessionSynchronized(): 네트워크 세션이 동기화 되었을 때 호출,
게임상태 초기화, 시간 동기화 등의 작업을 할 수 있습니다. - virtual void OnInSceneObjectsSpawned(): 씬에 네트워크 오브젝트가 생성되었을 때 호출
변수 동기화 (NetworkVariable)
넷코드의 변수 동기화는 간편하면서도 강력한 기능을 제공합니다.
클라이언트가 중간에 접속하더라도 바뀐 변수로 보입니다.
NetworkPlayer.cs
using Unity.Netcode; using UnityEngine; public class NetworkPlayer : NetworkBehaviour { NetworkVariable<int> num = new NetworkVariable<int>(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner); public override void OnNetworkSpawn() { num.OnValueChanged += (int prevValue, int newValue) => { Debug.Log($"OwnerClientId: {OwnerClientId}, num: {num.Value}"); }; } void Update() { if (!IsOwner) return; if (Input.GetKeyDown(KeyCode.Alpha1)) { num.Value = Random.Range(0, 100); } } }
NetworkVariable은 제네릭 타입인데 이 변수가 네트워크 동기화 되는 변수입니다.
선언할 때 생성자로 초기값, 읽기권한(기본값 Everyone), 쓰기권한(기본값 Server)이지만 쓰기권한을 Owner로 하였습니다.
읽기권한은 Everyone, Owner가 있으며, 쓰기권한은 Server, Owner가 있습니다.
OnNetworkSpawn에서 변수가 바뀔 때 이벤트로 이전값과 바뀐값을 감지할 수 있습니다.
클라이언트 아이디와 변수 값을 로그로 찍었습니다.
네트워크 오브젝트가 초기화되면 OnNetworkSpawn이 호출되므로 Start에다 변수감지를 하면 안됩니다.
Update에서 소유자가 1을 누르면 네트워크 변수 값에 랜덤 숫자를 넣었습니다.
네트워크 변수에 접근할 때는 .Value로 해야합니다.
Player에 NetworkPlayer 컴포넌트를 붙히고 빌드 테스트 해봅시다.
빌드된 것을 호스트로 먼저 접속하니 클라이언트 아이디 0이 부여됐습니다.
에디터에서는 클라이언트로 접속하니 클라이언트 아이디 1이 부여됐습니다.
빌드된 파일에서 1을 눌러 랜덤 숫자를 부여하면 에디터에서 바뀐 변수가 감지됩니다.
에디터에서 연결을 끊었다 다시 접속해도 클라이언트 0번의 네트워크 변수는 바뀐값으로 유지됩니다.
변수 동기화 할 수 있는 타입
- C#의 struct 타입 (string은 class 타입이므로 제외)
bool, byte, sbyte, char, decimal, double, float, int, uint, long, ulong, short ,ushort
- 유니티의 struct 타입
Vector2, Vector3, Vector2Int, Vector3Int, Vector4, Quaternion, Color, Color32, Ray, Ray2D
- 고정된 string 타입 (Unity.Collections 네임스페이스)
FixedString32Bytes, FixedString64Bytes, FixedString128Bytes, FixedString512Bytes, FixedString4096Bytes
- 모든 enum 타입
- INetworkSerializable을 구현하는 struct 타입
커스텀 구조체를 다음과 같이 만들고 동기화할 수 있습니다.
NetworkPlayer.cs
using Unity.Collections; using Unity.Netcode; using UnityEngine; public class NetworkPlayer : NetworkBehaviour { NetworkVariable<MyCustomData> myCustomData = new NetworkVariable<MyCustomData>( new MyCustomData { myInt = 0, myBool = false, message = string.Empty }, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner); public struct MyCustomData : INetworkSerializable { public int myInt; public bool myBool; public FixedString128Bytes message; public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter { serializer.SerializeValue(ref myInt); serializer.SerializeValue(ref myBool); serializer.SerializeValue(ref message); } } public override void OnNetworkSpawn() { myCustomData.OnValueChanged += (MyCustomData prevValue, MyCustomData newValue) => { Debug.Log($"OwnerClientId: {OwnerClientId}, myInt: {newValue.myInt}" + $"myBool: {newValue.myBool}, message: {newValue.message}"); }; } void Update() { if (!IsOwner) return; if (Input.GetKeyDown(KeyCode.Alpha1)) { myCustomData.Value = new MyCustomData { myInt = Random.Range(0, 100), myBool = true, message = $"Hello{Random.Range(0, 100)}" }; } } }
struct에 INetworkSerializable을 구현해 차례대로 직렬화하면 커스텀 구조체도 동기화가 됩니다.
함수 동기화 (Rpc)
Rpc는 원격 프로시저 호출로 함수를 동기화 합니다.
대상을 지정할 수 있고 일시적이거나 지속적으로 동기화합니다.
NetworkPlayer.cs
using Unity.Netcode; using UnityEngine; public class NetworkPlayer : NetworkBehaviour { void Update() { if (!IsOwner) return; if (Input.GetKeyDown(KeyCode.Alpha1)) { TestRpc("Hello"); } } [Rpc(SendTo.Server)] void TestRpc(string message) { Debug.Log($"TestRPC: {OwnerClientId}, {message}"); } }
Rpc를 할 함수는 반드시 이름이 Rpc로 끝나야 하고 [Rpc(SendTo.대상)] 어트리뷰트가 필요합니다.
일반함수 호출하듯 호출하면 됩니다.[ServerRpc]나 [ClientRpc]를 쓰는 것은 구버전입니다.
SendTo
- Server: 소유권과 관계없이 서버로 전송
- NotServer: 서버를 제외한 모든 사람에게 전송, 호스트에겐 전송안됨
- Authority: 네트워크 오브젝트에서 권한이 있는 사람(여러 클라이언트 일 수 있음)에게 전송
- NotAuthority: 네트워크 오브젝트에서 권한이 없는 사람에게 전송
- Owner: 네트워크 오브젝트에서 소유자(항상 하나의 클라이언트)에게 전송
- NotOwner: 네트워크 오브젝트에서 소유자가 아닌 사람에게 전송
- Me: 로컬에서 실행, 일반함수와 같음
- NotMe: 로컬이 아닌 모든 사람에게 전송
- Everyone: 모든 사람에게 전송
- ClientsAndHost: 호스트모드의 경우 호스트와 클라이언트한테 전송
- SpecifiedInParams: RpcTarget이 정해진 대상에게 전송
RpcTarget
기본적으로 SendTo의 SpecifiedInParams을 제외하고 SendTo와 대상은 같습니다.
RpcParams의 매개변수로 받으며 이를 호출할 때 대상을 정해줍니다.
RpcTargetUse에는 Temp 임시적으로 한 번만 전송과 Persistent 지속적으로 전송하는 방법이 있습니다.
- Single(ulong clientId, RpcTargetUse use): 한 클라이언트 아이디에게만 전송
- Group(ulong[] clientIds, RpcTargetUse use): 특정 클라이언트 아이디들에게 전송
- Not(ulong excludedClientId, RpcTargetUse use): 특정 클라이언트 아이디를 제외하고 전송
- Not(ulong[] excludedClientIds, RpcTargetUse use): 특정 클라이언트 아이디들을 제외하고 전송
NetworkPlayer.cs
using Unity.Netcode; using UnityEngine; public class NetworkPlayer : NetworkBehaviour { void Update() { if (!IsOwner) return; if (Input.GetKeyDown(KeyCode.Alpha1)) { PingRpc(2, RpcTarget.Server); } } [Rpc(SendTo.Server)] void PingRpc(int pingCount, RpcParams rpcParams) { Debug.Log($"Received ping, pingCount: {pingCount}"); PongRpc(pingCount, "PONG!", RpcTarget.Single(rpcParams.Receive.SenderClientId, RpcTargetUse.Temp)); } [Rpc(SendTo.SpecifiedInParams)] void PongRpc(int pingCount, string message, RpcParams rpcParams) { Debug.Log($"Received pong, pingCount: {pingCount}, message: {message}"); } }
런타임 로그를 보려면 무료로는 유니티 에셋스토어의
Log Viewer(https://assetstore.unity.com/packages/tools/integration/log-viewer-12047)를 사용하시면 됩니다.
또는 제가 배포하는
Quick Command(https://goranitv.tistory.com/33)를 사용하시면 됩니다.클라이언트 아이디 1번에서 숫자1을 누르면 PingRpc를 서버한테 보냅니다.
그래서 Host인 부분에 ping 로그가 떴습니다.
넘기는 매개변수로 RpcTarget.Server를 넘겼는데 받는 부분에서 보낸 사람의 클라이언트 아이디를 알 수 있습니다.
서버는 PongRpc를 보낸 클라이언트 아이디 대상 한 사람에게만 Rpc를 보냅니다.
1번에서 보냈기 때문에 서버가 1번에게 다시 보내서 pong로그가 뜨게 됩니다.
네트워크 오브젝트 생성 (NetworkObject)
네트워크로 동기화할 모든 게임오브젝트는 반드시 NetworkObject를 거쳐야만 동기화가 일어납니다.
생성 및 파괴는 항상 서버에서만 일어나야 하므로 서버한테 Rpc를 보내는 방법으로 제어해야합니다.
플레이어는 접속하자마자 바로 스폰이 되었으나 런타임중에 동적으로 생성하고 파괴하는 법을 알아보겠습니다.
플레이어가 숫자1을 누르면 위로 발사되며 3초 뒤에 파괴되는 총알을 구현하겠습니다.
빈 게임오브젝트 Bullet으로 이름을 변경하고, NetworkObject와 NetworkBullet 스크립트를 만들어 넣습니다.
자식으로는 Sphere를 만들고 크기를 0.5로 한 뒤 Bullet을 프리팹으로 만들고 씬에 있는 건 지웁니다.
NetworkPlayer.cs
using Unity.Netcode; using UnityEngine; public class NetworkPlayer : NetworkBehaviour { [SerializeField] GameObject bulletPrefab; void Update() { if (!IsOwner) return; if (Input.GetKeyDown(KeyCode.Alpha1)) { SpawnObjectRpc(transform.position, Quaternion.identity); } } [Rpc(SendTo.Server)] void SpawnObjectRpc(Vector3 position, Quaternion rotation) { Instantiate(bulletPrefab, position, rotation) .GetComponent<NetworkObject>().SpawnWithOwnership(OwnerClientId); } }
인스펙터에는 bulletPrefab에 총알 프리팹을 넣습니다.
Rpc는 서버에게 전송이 되며 Instantiate로 일반적인 게임오브젝트를 생성합니다.
그리고 NetworkObject 컴포넌트에서 생성과 함께 소유권을 자신의 클라이언트 아이디로 부여합니다.
NetworkBullet.cs
using Unity.Netcode; using UnityEngine; public class NetworkBullet : NetworkBehaviour { public override async void OnNetworkSpawn() { if (!IsOwner) return; await Awaitable.WaitForSecondsAsync(3); DespawnObjectRpc(NetworkObject); } void Update() { transform.Translate(Vector3.up * Time.deltaTime * 10); } [Rpc(SendTo.Server)] void DespawnObjectRpc(NetworkObjectReference target) { if (target.TryGet(out NetworkObject targetObject)) { targetObject.Despawn(); } } }
총알은 위로 올라가다가 생성으로부터 3초 뒤에 서버에게 보내는 Rpc로 파괴하라고 합니다.
Rpc의 매개변수로 NetworkObject를 넘기면 NetworkObjectReference 구조체로 받아옵니다.
구조체에서 꺼내어 파괴합니다.
NetworkObject 함수
- Spawn(): 생성하는데 서버가 소유권을 얻습니다.
- SpawnWithOwnership(ulong clientId): 생성과 함께 클라이언트 아이디에게 소유권을 줍니다.
- SpawnAsPlayerObject(ulong clientId): 플레이어로써 생성합니다.
- Despawn(bool destroy = true): 파괴합니다. destroy가 false라면 씬에 남아있습니다.
- ChangeOwnership(ulong newOwnerClientId): 새 클라이언트 아이디에게 소유권을 이전합니다.
- RemoveOwnership(): 소유권을 없애고 서버가 소유권을 얻습니다.
유용한 유틸리티
- NetworkManager.Singleton.ConnectedClientsIds:
연결된 모든 클라이언트 아이디들 - NetworkManager.Singleton.SpawnManager.GetClientOwnedObjects(ulong clientId):
클라이언트 아이디 소유의 모든 네트워크 오브젝트들 - NetworkManager.Singleton.SpawnManager.GetPlayerNetworkObject(ulong clientId):
클라이언트 아이디 소유의 플레이어 네트워크 오브젝트 - NetworkManager.Singleton.SpawnManager.GetLocalPlayerObject():
로컬 플레이어 네트워크 오브젝트 - NetworkManager.Singleton.SpawnManager.PlayerObjects:
모든 플레이어 네트워크 오브젝트들
'유니티' 카테고리의 다른 글