Netcode 물리 기반 멀티플레이 동기화
멀티플레이 게임에서 물리 기반 상호작용은 게임의 재미와 공정성을 좌우하는 핵심 요소입니다. 그러나 네트워크 지연, 동기화 오류, 결정론적 시뮬레이션의 부재로 인해 물리 시스템을 올바르게 구현하기는 매우 어렵습니다.
Unity의 Netcode for GameObjects는 네트워크 게임 개발을 단순화하기 위해 설계된 고수준 네트워킹 라이브러리입니다. 이 가이드에서는 Netcode를 사용하여 안정적이고 공정한 물리 기반 멀티플레이를 구현하는 방법을 심화 다룹니다.
1. 핵심 개념: 서버 권한 물리 시뮬레이션 (Server-Authoritative Physics)
Section titled “1. 핵심 개념: 서버 권한 물리 시뮬레이션 (Server-Authoritative Physics)”1.1 기본 원리
Section titled “1.1 기본 원리”멀티플레이 물리 게임의 기본 원칙은 서버에서만 물리 시뮬레이션을 수행하고, 클라이언트는 서버가 보낸 위치 업데이트를 받아 표시하는 것입니다.
이 방식의 장점:
- 공정성 보장: 모든 물리 계산이 서버에서 수행되어 조작 불가능
- 일관성: 모든 클라이언트가 동일한 물리 상태를 볼 수 있음
- 디버깅 용이: 모든 상태 변화가 서버에 집중됨
1.2 NetworkRigidbody 컴포넌트
Section titled “1.2 NetworkRigidbody 컴포넌트”Netcode for GameObjects는 NetworkRigidbody 컴포넌트를 제공하여 자동으로 서버 권한 물리를 관리합니다.
using Unity.Netcode;using UnityEngine;
public class PhysicsObject : NetworkBehaviour{ [SerializeField] private Rigidbody rb; private NetworkRigidbody networkRb;
void Start() { networkRb = GetComponent<NetworkRigidbody>(); }
// 서버에서만 호출되어야 함 [ServerRpc(RequireOwnershipCheck = false)] public void ApplyForceServerRpc(Vector3 force) { if (IsServer) { rb.AddForce(force, ForceMode.Impulse); } }}NetworkRigidbody의 동작 방식:
- 권한 보유 인스턴스: Rigidbody를 Dynamic 모드로 유지하며 물리 시뮬레이션 수행
- 비권한 인스턴스: Rigidbody를 Kinematic 모드로 전환하여 직접 조작 불가
- 동기화: 권한 인스턴스의 위치와 회전을 네트워크를 통해 비권한 인스턴스에 전송
2. 멀티플레이 물리 동기화의 도전 과제
Section titled “2. 멀티플레이 물리 동기화의 도전 과제”2.1 네트워크 지연 (Latency)
Section titled “2.1 네트워크 지연 (Latency)”물리 객체가 지연된 위치에서 나타나 부자연스러워 보이는 문제:
// ❌ 문제: 지연된 위치로 이동하며 부자연스러움public void UpdatePhysicsTransform(Vector3 networkPosition){ rb.position = networkPosition; // 급격한 점프}
// ✅ 해결: 보간(Interpolation) 사용public void UpdatePhysicsTransformWithInterpolation(Vector3 networkPosition){ StartCoroutine(InterpolatePosition(networkPosition, 0.1f));}
private IEnumerator InterpolatePosition(Vector3 targetPos, float duration){ Vector3 startPos = rb.position; float elapsed = 0f;
while (elapsed < duration) { elapsed += Time.deltaTime; rb.position = Vector3.Lerp(startPos, targetPos, elapsed / duration); yield return null; }
rb.position = targetPos;}2.2 고정 업데이트 프레임 불일치
Section titled “2.2 고정 업데이트 프레임 불일치”FixedUpdate와 네트워크 티크의 주기가 다를 때 발생하는 동기화 오류:
[ServerRpc(RequireOwnershipCheck = false)]public void PhysicsUpdateServerRpc(Vector3 velocity, Vector3 angularVelocity){ // 네트워크 메시지 도착 후 FixedUpdate 완료 후에 적용 // 이렇게 하면 FixedUpdate 주기를 벗어난 시점에 물리 업데이트 발생 방지
if (!IsServer) return;
rb.velocity = velocity; rb.angularVelocity = angularVelocity;}
void FixedUpdate(){ // 물리 시뮬레이션은 FixedUpdate에서만 수행 if (IsServer && IsOwner) { // 서버 권한 물리 로직 }}2.3 충돌 감지 이벤트 동기화
Section titled “2.3 충돌 감지 이벤트 동기화”물리 충돌은 로컬에서 즉시 감지되지만 네트워크 게임에서는 모든 클라이언트에 알려야 합니다:
public class CollisionHandler : NetworkBehaviour{ void OnCollisionEnter(Collision collision) { if (IsServer) { // 서버에서 충돌 감지 HandleCollisionServerRpc( collision.gameObject.name, collision.relativeVelocity, collision.contactCount ); } }
[ServerRpc(RequireOwnershipCheck = false)] private void HandleCollisionServerRpc(string otherName, Vector3 relVelocity, int contactCount) { // 모든 클라이언트에 충돌 이벤트 브로드캐스트 BroadcastCollisionClientRpc(otherName, relVelocity, contactCount); }
[ClientRpc] private void BroadcastCollisionClientRpc(string otherName, Vector3 relVelocity, int contactCount) { if (!IsServer) { // 클라이언트에서 충돌 이벤트 처리 (음향, 파티클 등) Debug.Log($"충돌 감지: {otherName}, 상대 속도: {relVelocity.magnitude}"); } }}3. 실전 구현 패턴
Section titled “3. 실전 구현 패턴”3.1 권한 기반 물리 제어
Section titled “3.1 권한 기반 물리 제어”public class NetworkPhysicsController : NetworkBehaviour{ [SerializeField] private Rigidbody rb; [SerializeField] private float moveForce = 10f;
private NetworkVariable<Vector3> networkVelocity = new NetworkVariable<Vector3>(Vector3.zero);
void Update() { if (!IsOwner) return;
// 로컬 입력 수집 float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 moveDirection = new Vector3(horizontal, 0, vertical).normalized;
if (moveDirection.magnitude > 0) { // 서버에 힘 적용 요청 ApplyMovementServerRpc(moveDirection * moveForce); } }
[ServerRpc(RequireOwnershipCheck = false)] private void ApplyMovementServerRpc(Vector3 force) { if (IsServer) { rb.AddForce(force, ForceMode.Force); networkVelocity.Value = rb.velocity; } }
void FixedUpdate() { if (!IsServer) return;
// 서버에서만 물리 시뮬레이션 실행 // NetworkRigidbody가 자동으로 위치를 동기화 }}3.2 투사체 동기화
Section titled “3.2 투사체 동기화”투사체는 물리 게임에서 중요한 요소이므로 특별히 처리해야 합니다:
public class NetworkProjectile : NetworkBehaviour{ [SerializeField] private Rigidbody rb; private NetworkVariable<Vector3> networkPosition = new NetworkVariable<Vector3>(); private NetworkVariable<Vector3> networkVelocity = new NetworkVariable<Vector3>();
[ServerRpc(RequireOwnershipCheck = false)] public void FireProjectileServerRpc(Vector3 spawnPos, Vector3 direction, float force) { if (!IsServer) return;
// 서버에서 발사 transform.position = spawnPos; rb.velocity = direction.normalized * force;
// 모든 클라이언트에 발사 알림 FireProjectileClientRpc(spawnPos, direction, force); }
[ClientRpc] private void FireProjectileClientRpc(Vector3 spawnPos, Vector3 direction, float force) { if (IsServer) return;
// 클라이언트에서 투사체 시각적 표현 업데이트 transform.position = spawnPos; }
void FixedUpdate() { if (IsServer) { // 서버에서 물리 시뮬레이션 networkPosition.Value = transform.position; networkVelocity.Value = rb.velocity; } }
void OnTriggerEnter(Collider other) { if (IsServer) { // 서버에서만 충돌 처리 HandleImpactServerRpc(other.gameObject.name); } }
[ServerRpc(RequireOwnershipCheck = false)] private void HandleImpactServerRpc(string targetName) { // 모든 클라이언트에 폭발 이벤트 알림 ExplosionClientRpc(transform.position);
// 투사체 제거 (객체 풀 사용 권장) gameObject.SetActive(false); }
[ClientRpc] private void ExplosionClientRpc(Vector3 impactPos) { // 모든 클라이언트에서 폭발 이펙트 재생 ParticleSystem particles = GetComponentInChildren<ParticleSystem>(); particles.Play(); }}3.3 클라이언트 측 예측 (Client-Side Prediction)
Section titled “3.3 클라이언트 측 예측 (Client-Side Prediction)”지연 시간을 숨기기 위해 클라이언트에서 사용자의 입력을 즉시 반영하되, 서버의 결과로 교정합니다:
public class PredictiveMovement : NetworkBehaviour{ [SerializeField] private Rigidbody rb; [SerializeField] private float moveForce = 10f; [SerializeField] private float reconciliationDistance = 0.5f;
private Vector3 lastAppliedForce; private Vector3 predictedPosition; private Vector3 serverPosition;
void Update() { if (!IsOwner) return;
float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 moveDirection = new Vector3(horizontal, 0, vertical).normalized;
if (moveDirection.magnitude > 0) { lastAppliedForce = moveDirection * moveForce;
// 클라이언트에서 즉시 반영 (예측) rb.AddForce(lastAppliedForce, ForceMode.Force);
// 서버에 명령 전송 ApplyMovementServerRpc(lastAppliedForce); } }
[ServerRpc(RequireOwnershipCheck = false)] private void ApplyMovementServerRpc(Vector3 force) { if (IsServer) { rb.AddForce(force, ForceMode.Force); } }
void FixedUpdate() { if (IsOwner && !IsServer) { // 클라이언트: 예측된 위치 저장 predictedPosition = rb.position; }
if (IsServer) { // 서버: 실제 위치를 클라이언트로 전송 SyncPositionClientRpc(rb.position); } }
[ClientRpc] private void SyncPositionClientRpc(Vector3 actualPosition) { if (IsOwner) { serverPosition = actualPosition;
// 예측과 서버 결과의 차이가 크면 교정 if (Vector3.Distance(predictedPosition, serverPosition) > reconciliationDistance) { rb.position = serverPosition; } } }}4. 일반적인 문제 및 해결 방법
Section titled “4. 일반적인 문제 및 해결 방법”| 문제 | 원인 | 해결 방법 |
|---|---|---|
| 물리 객체가 흔들리거나 떨림 | 위치 업데이트의 부자연스러운 변화 | 보간(Linear Interpolation) 적용 |
| 충돌이 일부 클라이언트에서 감지되지 않음 | 비권한 객체의 Rigidbody가 Dynamic 상태 | NetworkRigidbody 사용하여 자동 Kinematic 변환 |
| 플레이어 간 위치가 크게 다름 | 동기화 빈도 부족 | NetworkTick 주기 단축 또는 보간 개선 |
| 서버에서 물리 연산 후 위치가 클라이언트와 다름 | 결정론적이지 않은 물리 시뮬레이션 | 물리 설정(중력, 마찰계수) 모든 클라이언트에서 동일하게 설정 |
| RPC 메시지가 느림 | 네트워크 대역폭 문제 | 동기화 빈도 조절 및 데이터 압축 |
5. 성능 최적화 팁
Section titled “5. 성능 최적화 팁”5.1 동기화 빈도 조절
Section titled “5.1 동기화 빈도 조절”모든 프레임마다 동기화하지 않아 네트워크 부하 감소:
[SerializeField] private float syncInterval = 0.1f; // 100ms마다 동기화private float timeSinceLastSync = 0f;
void FixedUpdate(){ timeSinceLastSync += Time.fixedDeltaTime;
if (timeSinceLastSync >= syncInterval) { SyncPhysicsStateServerRpc(rb.position, rb.velocity); timeSinceLastSync = 0f; }}5.2 중요하지 않은 객체는 물리 동기화 생략
Section titled “5.2 중요하지 않은 객체는 물리 동기화 생략”배경 객체나 NPC의 물리는 모두 동기화할 필요 없음:
public class SelectivePhysicsSync : NetworkBehaviour{ [SerializeField] private bool isCritical = true;
void FixedUpdate() { if (!isCritical) return; // 중요하지 않으면 동기화 생략
// 동기화 로직 }}5.3 객체 풀 사용
Section titled “5.3 객체 풀 사용”투사체나 이펙트 객체는 파괴/생성 오버헤드를 줄이기 위해 객체 풀 사용:
public class ProjectilePool : MonoBehaviour{ private Queue<NetworkProjectile> availableProjectiles = new();
public NetworkProjectile GetProjectile() { if (availableProjectiles.Count > 0) { return availableProjectiles.Dequeue(); } return Instantiate(projectilePrefab); }
public void ReturnProjectile(NetworkProjectile projectile) { projectile.gameObject.SetActive(false); availableProjectiles.Enqueue(projectile); }}6. 테스트 및 디버깅
Section titled “6. 테스트 및 디버깅”6.1 로컬 네트워크 테스트
Section titled “6.1 로컬 네트워크 테스트”개발 초기에는 로컬 호스트로 클라이언트-서버를 실행:
public class LocalNetworkDebug : MonoBehaviour{ void Start() { #if UNITY_EDITOR // 에디터에서만 로컬 호스트 사용 var networkManager = GetComponent<NetworkManager>(); networkManager.GetComponent<UnityTransport>().SetDebugSimulatorParameters( packetDropRate: 0.05f, // 5% 패킷 손실 maxPacketDelay: 0.2f // 최대 200ms 지연 ); #endif }}6.2 동기화 상태 로깅
Section titled “6.2 동기화 상태 로깅”void OnGUI(){ GUILayout.Label($"Local Position: {transform.position}"); GUILayout.Label($"Network Position: {networkPosition.Value}"); GUILayout.Label($"Velocity: {rb.velocity}"); GUILayout.Label($"Is Server: {IsServer}"); GUILayout.Label($"Is Owner: {IsOwner}");}Netcode for GameObjects를 사용한 물리 기반 멀티플레이 게임은 다음 원칙을 따를 때 성공합니다:
- 서버 권한 유지: 모든 물리 계산은 서버에서만 수행
- 효율적인 동기화: 필요한 데이터만 필요한 빈도로 전송
- 클라이언트 측 예측: 지연을 숨기기 위해 로컬에서 즉시 반영
- 철저한 테스트: 다양한 네트워크 조건에서 검증
이러한 기초 위에 게임의 특성에 맞는 최적화를 더하면, 플레이어들이 즐기는 공정하고 반응성 좋은 멀티플레이 게임을 만들 수 있습니다.