-
[UE5 | FPS] 서버와 클라이언트의 동기화를 위한 시행착오언리얼엔진/FPS 프로젝트 2024. 8. 13. 18:50
이전 글의 마지막에 무기 드롭과 교체에 관한 내용을 다루겠다고 했다.
관련 내용 중 서버와 클라이언트의 무기 슬롯 동기화에서 발생한 문제는 무엇인지, 이 문제를 어떻게 해결했는지, 이 방식의 아쉬운점은 무엇이고 어떻게 하면 개선할 수 있을지를 한번 정리할 필요가 있어서 글로 작성하게 됐다.
상황 정리
이 프로젝트에서 무기는 액터라서 월드에 독립적으로 존재할 수 있고, 플레이어 캐릭터가 줍거나 떨어뜨릴 수 있다.
캐릭터가 무기를 주웠다면 무기는 캐릭터에 부착된다.
캐릭터가 무기를 들고있는경우 캐릭터 메시의 손에 있는 소켓에 부착되고, 넣은 상태면 등쪽에 있는 다른 소켓에 부착된다.
등쪽의 소켓은 여러개 존재하며, 하나의 소켓에는 하나의 무기만 부착된다.
등쪽의 소켓을 관리하기 위해 해시맵을 사용하고있고, 캐릭터가 소유하고있는 무기들의 슬롯은 배열로 관리하고있다.
클라이언트와 서버의 동기화를 위해 무기들의 배열과 현재 들고있는 무기의 슬롯 번호(배열의 인덱스)는 레플리케이트 된다.
소켓을 관리하는 해시맵은 동기화될 필요가 없다.캐릭터의 등에 부착된 무기
현재 무기 슬롯을 스왑하는 기능은 구현되어있다.
게임플레이 어빌리티를 통해 클라이언트와 서버에서 작동되지만, 여기에서는 별도로 존재하는 무기 스왑 함수를 호출하는 작업만을 하고있다.
이 무기 스왑 함수를 클라이언트에서 단독으로 실행하면 클라이언트에서만 작동해서 동기화되지 않는다.
서버에서 작동하면 결과가 레플리케이트되고, 시뮬레이티드 프록시는 그 결과를 보고 거기에 맞는 행동을 하면 된다.
무기 드롭, 교체의 처리 과정
무기 슬롯은 크기가 정해져있다.
무기 슬롯이 가득 찬 상황에 새로운 무기를 줍는다면, 현재 들고있는 무기를 떨어뜨린 후에 새로운 무기를 집는 교체 작업을 한다.
반대로 무기 슬롯이 하나라도 비어있으면 비어있는 슬롯에 새로운 무기를 넣으면 된다.무기 교체와 줍는 상황의 툴팁 차이
새로운 무기를 주웠다면, 항상 그 무기를 든다.
무기를 교체했다면 이전과 동일한 슬롯의 무기를 든다.
빈 슬롯에 무기를 추가했다면 무기가 새로 추가된 슬롯의 무기를 들어야한다.
무기를 들때에는 다음과 같은 작업이 진행되어야한다.- 무기에 맞는 애니메이션 재생.
- 애니메이션이 재생되는 동안에는 무기의 어빌리티를 모두 취소하고 활성화 불가능하게 설정
- 애니메이션이 끝나면 무기의 어빌리티를 활성화 가능하게 설정
- 무기 액터가 3인칭 메시에 부착되는 소켓을 변경(손에 있던 무기는 등으로, 등에 있던 무기는 손으로)
이러한 작업은 무기 슬롯을 스왑하는 함수에서 하고있으므로 이 함수를 이용한다.
무기 교체 무기 줍기
무기는 모든 플레이어가 주울 수 있다.
그리고 하나의 무기를 여러 플레이어가 주우려고 시도하는 상황은 충분히 발생할 수 있다.
무기는 한명의 플레이어에게만 주어져야하므로 한명을 제외한 나머지 다른 플레이어들은 무기 줍기를 시도했어도 실패하게된다.
만약 무기를 줍는 작업을 클라이언트에서 예측으로 처리하게된다면, 무기 줍기를 실패한 플레이어 입장에서도 캐릭터가 무기를 들어올리는 애니메이션이 재생되는걸 확인할 수 있지만, 서버와 동기화되어 보정된 이후에는 그 무기가 사라지게된다.
이렇게되면 플레이어에게 혼란을 주게되고, 특히 무기가 주요 요소인 FPS에서는 무기와 관련한 불확실성에 민감할 수 밖에 없다.
따라서 클라이언트에서는 어떠한 무기를 줍겠다는 요청을 서버에 보내기만 한 뒤에 예측은 하지 않고, 서버에서는 검증 후에 하나의 플레이어에게만 무기를 지급하는 방식을 사용하기로 했다.
일반적으로 예측과 보정을 사용하는게 플레이어에게 피드백이 바로 전달되는 좋은 방법이지만, 이 경우에는 예외다.
무기를 플레이어에게 지급했음을 서버에서 클라이언트로 알리는 방법
사실 이 부분은 깊게 고민하지 않았다.
권한이 있는 로컬 플레이어도 무기를 주울 수 있어야하기 때문에 서버에서 무기를 교체하거나 줍는 작업을 처리해야하며, 그 작업의 결과물이 이미 클라이언트로 레플리케이트되도록 설정되어있기 때문이다.
정리하면 상황에 따라 새로운 무기를 어떻게 주울지, 어느 슬롯으로 스왑해야하는지 등을 서버에서 결정하고 처리하면 그 결과물이 자동으로 클라이언트에 레플리케이트되는 상황이므로 깊게 고민할 필요가 없었다.
이렇게 생각하면 쉬워보였지만, 클라이언트 입장에서 생각해보니 쉽지 않았다.
어려움이 있었던 부분을 정리하면 다음과 같다.- 레플리케이트를 활용하려면 레플리케이트 되었을때 호출되게 지정하는 OnRep함수를 사용해야한다. 이 함수에서 레플리케이트 받은 결과물을 보고 서버에서 어떠한 작업이 있었는지를 추측해야한다. 그리고 이러한 추측 과정은 오토노머스 프록시 입장에서도, 시뮬레티드 프록시 입장에서도 처리해야 한다. 게다가 OnRep함수는 무기를 주웠을 경우만을 처리하는 함수가 아니므로, 무기를 줍는것 뿐만 아니라 다른 경우도 고려해야할 필요가 있다.
- 무기를 줍는 작업은 무기 슬롯을 스왑하는 경우에도 발생할 수 있다. 무기 슬롯을 스왑하기 위해 들고있던 무기를 넣는 중에도, 스왑된 슬롯의 무기를 꺼내는 중에도 새로운 무기를 주울 수도 있다. 이런식으로 다른 액션이 진행중인 상황과 동시에 발생할수도 있는데 이런 세세한 부분까지 추측을 하기에는 따져야할 조건이 너무 많다.
- 서버에서 사용하는 로직을 클라이언트에서도 사용하면 되지 않을까 싶었다. 서버에서 사용되는 로직을 일부분 사용하고 있긴 하지만, 발생한 이벤트를 끝내면서 상황을 정리하는 로직일 뿐이다. 작업된 결과를 레플리케이트 받는 방법으로는 이벤트가 발생한 시점에 대한 정보가 부족하기 때문에 같은 로직을 사용할 수 없다. 예를 들면 주우려고 하는 무기가 어떤 무기인지에 관한 정보는 클라이언트에서 서버로 전달해주긴 하지만, 클라이언트에서는 그 정보를 기억해두지 않는다. 서버에서 허락을 해줄지는 서버의 응답을 받기 전까지는 모르는 것이고, 예측을 사용하지도 않는 상황이다. 게다가 오토노머스 프록시 뿐만 아니라 시뮬레이티드 프록시에서도 작동해야한다. 그리고 시뮬레이티드 프록시에서는 무기 스왑이 작동하지 않는다는 점도 고려해야한다. 무기 스왑은 1인칭 시점을 기준으로 구현되었고, 시뮬레이티드 프록시에서는 무기 스왑의 결과물을 보고 3인칭 메시를 다뤄야하기 때문이다.
- 시뮬레이티드 프록시에서 무기 스왑이 작동하지 않는다는 것은 위에서 언급했던 캐릭터의 등에 있는 무기 소켓을 다루는 해시맵이 정상적으로 관리되지 않는다는 뜻이다. 이건 구현 부족의 문제이지만, 이 문제로 인해서 드롭했던 무기를 참조 해제하지 않게되고, 무기를 어태치할수있는 소켓이 부족해져서 새로 주운 무기가 어태치되지 않는 문제가 발생했다. 이렇게되면 시뮬레이티드 프록시가 무기를 주워도 어태치되지 않아서 무기는 월드에 떨어진 상태로 유지된다. 그렇다고 이 해시맵을 레플리케이트 되게 하면 대역폭 낭비이므로 이 방법은 절대 사용하지 않기로 했다. 이 해시맵은 절차적으로 처리하기만 하면 문제가 되지 않을 것이고, 굳이 동기화되어서 같은 상태를 유지할 필요도 없다. 다른 캐릭터의 등에 붙어있는 무기들이 어떤 순서로 어느 소켓에 붙어있는지는 세세하게 따질만한 주요 관심사가 아니므로 동기화 될 필요가 전혀 없다.
이러한 복합적인 문제를 한번에 해결해야하는 상황이었다.
특히 마지막에 적은 해시맵이 제일 문제였다.
이전에 적은 문제들은 어떻게든 해결할 수 있었지만, 마지막에 발견한 이 문제는 상황을 더 꼬이게 만들었다.
해결 방법
이 문제에 처음 직면했을때는 레플리케이트 된 결과를 보고 서버에서 어떠한 상황이 있었는지를 정확하게 추측해야한다는 생각에 사로잡혀서 이 문제를 지엽적으로 해결하고 있었다.
그러다보니 조건이 복잡해지고 코드도 알아보기 어려운 또 다른 문제가 발생했다.
사실 이 문제는 이렇게 상황을 정리해놓고 보거나, 조금 다른 방식으로 생각하면 간단하고 깔끔한 해결 방법을 찾을 수 있다.
제일 큰 문제는 드롭했던 무기가 해시맵에 남아있는 문제이므로, 일단 드롭된 무기들을 해시맵에서 모두 제거하면 어떻게든 빈 소켓이 생기게 된다.
드롭된 무기들을 제거했으니 다음으로는 추가된 무기를 해시맵에 추가하고 캐릭터 등쪽의 소켓에 어태치한다.
새로 추가된 무기가 어느 슬롯에 있는지, 그 무기를 들어야 하는지는 상관없이 추가된 모든 무기를 등에 어태치한다.
그 다음 레플리케이트 된 무기 슬롯 인덱스로 무기 스왑을 하면된다.
무기 스왑을 하는 조건을 다음과 같다.
만약 현재 슬롯 인덱스가 바뀐 경우, 그렇지 않지만 현재 슬롯의 무기가 바뀐 경우다.
첫번째 조건은 빈 슬롯에 새로운 무기가 추가되었거나 그저 무기 스왑만 한 경우, 두번째 조건은 들고있던 무기를 새로운 무기로 교체한 경우라고 볼 수 있다.
이 조건만 확인하면 어떤 무기를 어떤 방식으로 어떤 상황에 주웠는지, 어느 슬롯으로 무기를 스왑을 했는지를 포함해서 플레이어는 무기를 줍지 않았지만 갑자기 무기가 추가되거나 제거되는 예상치 못한 상황들을 고려할 필요가 전혀 없다.
그뿐만 아니라 다수의 무기를 드롭하거나 줍는 경우에도 서버에서 작동했다면 클라이언트에서도 의도대로 작동할 것이다.
서버에서 어떤일이 있었는지 정확하게 추측할 필요는 없었다.
검증된 결과물을 받았으니 그 결과물에 충실하게 따르면 되는 문제였다.
아래의 더보기를 누르면 위 과정을 처리한 코드가 나온다.더보기// .h USTRUCT(BlueprintType) struct FWeaponSlot { GENERATED_BODY() UPROPERTY(EditDefaultsOnly, BlueprintReadOnly) uint8 MaxSlotSize = 3; UPROPERTY(BlueprintReadOnly) uint8 CurrentSlot = 0; UPROPERTY(VisibleAnywhere, BlueprintReadOnly) TArray<AWeaponActor*> WeaponActorSlot; }; // .cpp void UNLCharacterComponent::OnRep_WeaponSlot(const FWeaponSlot& OldSlot) { if (!bStartupWeaponInitFinished) { ValidateStartupWeapons(); return; } TSet<AWeaponActor*> OldSet(OldSlot.WeaponActorSlot); TSet<AWeaponActor*> NewSet(WeaponSlot.WeaponActorSlot); TSet<AWeaponActor*> RemovedWeapons; for (AWeaponActor* Wpn : OldSet) { if (Wpn && !NewSet.Contains(Wpn)) { RemovedWeapons.Add(Wpn); Wpn->Dropped(); } } // 새로운 무기를 추가하기 전에 제거된 무기를 소켓 맵에서도 제거해서 빈 공간을 만들어야 함. for (TPair<FName, AWeaponActor*>& Item : WeaponSlotSocketMap) { if (Item.Value && RemovedWeapons.Contains(Item.Value)) { Item.Value = nullptr; } } // Added weapons for (AWeaponActor* Wpn : NewSet) { if (Wpn && !OldSet.Contains(Wpn)) { AttachWeaponToSocket(Wpn); Wpn->SetOwner(GetOwner()); // WeaponActor의 Owner는 아직 레플리케이트되지 않았을수도 있음. Wpn->PickedUp(GetOwningCharacter()); } } UpdateWeaponTagSlot(); bool bShouldSwapSlot = false; if (WeaponSlot.CurrentSlot < WeaponSlot.MaxSlotSize) { const bool bCurrentSlotChanged = OldSlot.CurrentSlot != WeaponSlot.CurrentSlot; // CurrentSlot은 동일하다는 전제조건 const bool bCurrentWeaponChanged = OldSlot.WeaponActorSlot[WeaponSlot.CurrentSlot] != WeaponSlot.WeaponActorSlot[WeaponSlot.CurrentSlot]; if (bCurrentSlotChanged || bCurrentWeaponChanged) { bShouldSwapSlot = true; } } const bool bIsSimulated = GetOwnerRole() == ROLE_SimulatedProxy; UpdateMeshes(nullptr, bIsSimulated); if (bShouldSwapSlot) { for (AWeaponActor* Wpn : WeaponSlot.WeaponActorSlot) { AttachWeaponToSocket(Wpn); } const uint8 RealCurrentSlot = WeaponSlot.CurrentSlot; if (!bIsSimulated) { /** * OnCurrentWeaponDropped 함수 호출로 인해서 current slot이 변경될 수 있음. * 따라서 레플리케이트 된 current slot을 따로 저장해놓음. * e.g. holster중인데 무기 교체가 된 경우, 교체된 무기가 있는 슬롯으로 스왑하는게 맞음. * 하지만 holster중인 무기를 버리는걸 우선으로 처리해야하는 정해진 절차가 있으므로 * swap pending 슬롯으로 스왑하는 과정을 먼저 하게 됨. * (무기를 버리는 경우에는 pending 슬롯으로 스왑하는게 맞는 행동이기 때문.) * pending 슬롯으로 스왑하게 되면 current slot이 변경되는 문제가 발생하게 되고, * 그렇게 로컬에서 변경된 current slot 값으로 무기 스왑을 하게되면 서버와 클라이언트가 * 서로 다른 슬롯의 무기를 들고있게 됨. * 서버에서는 모든 작업을 끝낸 결과를 레플리케이트 시켰는데 클라이언트에서는 그 값을 바꾼 상황. * 이 함수에서는 클라이언트의 입장에서, 서버에서 발생했던 사건의 결과물만 보고 판단해야하므로, * 서버에서 모든것이 의도대로 작동했다고 생각해야하고 그 결과를 그대로 따라야함. */ OnCurrentWeaponDropped(); // This function is not const TrySwapWeaponSlot(RealCurrentSlot, false, true); } else if (MontageTemp && GetOwningCharacter() && GetOwningCharacter()->GetMesh() && GetOwningCharacter()->GetMesh()->GetAnimInstance()) { AttachWeaponToHand(GetWeaponActorAtSlot(RealCurrentSlot)); // TODO: temp GetOwningCharacter()->GetMesh()->GetAnimInstance()->Montage_Play(MontageTemp); } } }
마지막에 몽타주를 재생하게 하는 코드는 임시로 해놓았다.
3인칭 메시의 애니메이션 작업은 우선 순위가 낮아서 아직 정해진 구조가 없다보니 일단 작동을 확인하기 위한 임시 코드다.
시뮬레이티드 프록시의 무기 줍기, 교체, 드롭
결과만 보면 간단한 방식으로 해결했지만, 문제를 찾고 해결하는데에 오랜 시간이 걸렸다.
앞으로도 이와 비슷한 다른 문제들이 있을텐데 모든 문제가 이렇게 간단한 방식으로 해결될거라고 기대할수는 없을듯하다.
이번에는 괜찮았지만 이 방법은 별도의 로직을 구현해야하며, 로직이 복잡해질 수 있다는 단점이 있는 방식인듯하다.
이런 복잡성을 줄이려면 평소에 함수를 잘 모듈화 해두는게 좋은 방법일듯하다.
아니면 다른 방식을 사용할수도 있다.
한가지 예시로 클라이언트와 서버가 같은 로직을 수행하지만, 클라이언트에서는 서버에서 허락을 해주면 그때 처리하는 방식을 생각해볼 수 있다.
그러기 위해서는 서버에 요청을 보냈을때의 상황을 클라이언트에서 기억해두거나, 서버에서 허락을 해줄때 상황과 관련된 정보를 같이 전달해줄 필요가 있을듯하다.
만약 해당 로직의 결과물이 클라이언트로 레플리케이트 되지 않거나, 레플리케이트 되어도 클라이언트의 플레이어 시점에 크게 영향을 주지 않는다면 서버에서는 허락 신호를 보내준 후에 바로 해당 로직을 수행해도 된다.
그런데 반대로 로직의 결과물이 레플리케이트 되며 클라이언트에 크게 영향을 주는 상황이라면, 결과물이 먼저 레플리케이트 되지 않도록 서버에서는 클라이언트보다 이 로직을 늦게 수행해야할수도 있다.
그런 경우라면 확실하게 하기 위해서는 [클라이언트에서 서버로 요청 → 서버에서 클라이언트로 허락 → 클라이언트에서 로직을 수행하고 로직을 수행했음을 서버로 알림 → 서버에서 로직을 수행] 이런 순서로 진행되어야 할텐데 이러면 지연이 너무 길어지는 문제가 있으므로 실제로 사용하기엔 어렵지 않을까 싶다.
'언리얼엔진 > FPS 프로젝트' 카테고리의 다른 글
[UE5 | FPS] 진행 사항 정리 (0) 2024.08.12 [UE5 | FPS] 멀티플레이에서 작동하는 달리기 구현 과정 (0) 2024.06.14 [UE5 | FPS] FGameplayEffectContext의 자식 구조체로 데미지 관련 정보 전달 (2) 2024.06.05 [UE5 | FPS] 히트 박스를 관리하기 위한 에디터 유틸리티 (1) 2024.06.02 [UE5 | FPS] 수평 FOV와 수직 FOV, 그리고 뷰모델 (0) 2024.05.08