캐릭터의 이동과 교정/복제

로컬에서 캐릭터 이동패킷을 서버로 보내고 서버에서 위치오류를 판별하여 교정하는 과정에 대해 설명한다.

흐름

캐릭터를 이동 시키고 그것을 서버에 알리는 주체는 로컬유저 이다.

즉 기본적으로 서버의 허락을 받고 이동하는 방식이 아닌 먼저 움직이고 그것을 서버에 알리는 방식이다.

로컬머신(클라이언트)으로부터 이동정보를 받은 서버는 그 정보가 나(서버)의 것과 비슷한지 확인해보고 오류 허용수치를 넘어가면 로컬머신에게 교정신호를 보내준다.

그리고 그 로컬을 제외한 나머지 클라이언트들에게는 단방향 복제1)를 수행한다.

크게 보면 다음의 순서대로 진행된다.

  1. 로컬유저 (ROLE_Autonomous 또는 그 이상) 는 자신의 캐릭터 이동을 서버에게 알린다.
  2. 서버는 로컬로부터 전달된 이동 정보 (Acceleration/Location/Rotation) 를 자신의 것에 적용하고 이 정보 중 위치정보(Location) 가 자신의 것과 차이가 일정수준 이상 난다면 주체에게 교정신호를 보낸다.
  3. 그리고 서버는 주체를 제외한 3자 클라이언트들에게는 단순 복제한다.
    1. 특정 값 (Location,Rotation,Velocity) 을 복제받은 제 3자 클라이언트들은 그 정보를 토대로 캐릭터의 움직임을 시뮬레이션한다.

이제 자세히 살펴보자.

로컬유저 -> 서버

이동의 시작

로컬유저는 자신의 캐릭터 이동을 관리하는데 그 시작은 PlayerController::PlayerTick 에서부터다. 이 함수가 매 프레임 호출되는 조건은 PlayerInput 이 유효해야 한다. 즉 서버라 할지라도 자신의 캐릭터 PlayerController::PlayerTick 만이 호출될 뿐이란 얘기다.

이 함수는 크게 두가지 역할을 하는데

  1. 서버로부터 받은 이동 보정값을 적용하고
  2. PlayerMove 를 수행한다.

첫번째 항목인 보정값 적용은 추후 설명될 것이므로 나중에 보기로 하고 일단 PlayerMove 를 보자.

PlayerMove

PlayerController::PlayerMove 는 각 상태별 이동을 처리하는 엔트리 함수이다. 각 상태별이란 단어를 썼듯이 다양한 state 에서 PlayerMove 함수가 존재한다.

PlayerController.uc
state PlayerWalking
{
    function PlayerMove( float DeltaTime )
    {
        ...
    }
}
 
state PlayerClimbing
{
    function PlayerMove( float DeltaTime )
    {
        ...
    }
}
 
state PlayerDriving
{
    function PlayerMove( float DeltaTime )
    {
        ...
    }
}
 
// 아래 상태들도 마찬가지
state PlayerSwimming
state PlayerFlying
state PlayerClimbing
state BaseSpectating
state RoundEnded
state Dead

PlayerMove 함수의 내용은 다음과 같다. (모든 상태의 함수 내용이 대략 아래와 비슷하다.)

  1. Accel 과 Rotation 을 구한다.
  2. 싱글플레이 또는 멀티플레이 여부에 따라 ProcessMove 또는 ReplicateMove 를 호출한다. 이 두 함수 또한 위와 마찬가지로 상태별로 다르게 구현되어 있다. 그리고 ReplicateMove 는 내부적으로 ProcessMove 를 호출한다.

ProcessMove 는 Pawn 에 Acceleration 을 적용하고 더블클릭을 처리하는 등의 사소한 역할을 하므로 따로 언급하지 않고 ReplicateMove 를 살펴본다.

ReplicateMove

로컬유저의 움직임을 서버로 보내는 함수이다. 그만큼 중요한 내용이므로 눈에 힘을주고 봐주자.

설명하기 앞서 움직임 복제에 어떠한 자료구조가 사용되는지 먼저 소개한다.

PlayerController.uc
var SavedMove       SavedMoves;          // 움직임 데이터를 저장해 놓을 리스트 (SavedMove 타입은 내부적으로 단방향 링크드리스트 이다.)
var SavedMove       FreeMoves;           // 사용하지 않는 빈 버퍼 리스트. 이곳으로부터 하나 빼서 움직임 정보를 저장한 후 SavedMoves 리스트에 달아놓게 된다.
var SavedMove       PendingMove;         // 서버측으로 전송할 주기가 아직 돌아오지 않을 경우엔 일단 SavedMoves 리스트 에 저장한 후, 이 변수로 캐싱해 둠으로써 아직 서버측으로 전송하지 않았다는 것을 알린다.

요놈은 ROLE_Autonomous 일 경우에만 호출되고2) 다음의 역할을 수행한다.

  1. SavedMoves 리스트를 순회하며 OldMove(처음으로 발견된 꼭 업데이트되어야 하는 중요한 움직임), LastMove(제일 최근 움직임. 즉 리스트의 마지막), AlmostLastMove(LastMove 이전) 을 찾는다.
    5948902281_c292a3376d.jpg
  2. FreeMoves 리스트에서 빈 버퍼를 하나 빼낸 후, 현재 움직임을 저장한다. NewMove
  3. ProcessMove 호출
  4. PendingMove 가 있으며 NewMove 와 합쳐질 수 있다면 합친 후 PendingMove 는 폐기한다.이때 PendingMove 가 LastMove(SaveMoves리스트) 라면 LastMove 가 SavedMoves 리스트에서 제거되고 FreeMoves 리스트에 추가된다.
    5949485822_155b082f0a_z.jpg
  5. Pawn::AutonomousPhysics 호출
  6. NewMove 를 SavedMoves 리스트에 추가한다.
    5949486230_1203b1dd01_z.jpg
  7. PendingMove 가 없고 만약 아직 움직임을 서버로 복제할 주기3)가 안되었다면 NewMove 를 PendingMove 에 캐싱해둔다.
  8. CallServerMove 호출.

CallServerMove

상황에 맞는 적절한 버전의 ServerMove 함수를 호출

  1. OldMove 가 있다면 Acceleration 을 압축 (1/20 + 0.5(반올림) 하여 byte 로 변환) 하여 OldServerMove 함수로 서버에 전송. 서버측은 이 함수를 받음으로써 앞으로 전송받을 NewMove 를 받기 전에 빠뜨리면 안되는 중요한 Move (OldMove) 가 전송되었음을 알고 미리 대처할 수 있다.
  2. PendingMove 가 있다면 DuralServerMove 로 PendingMove 와 NewMove 를 동시에 서버로 전송. 서버는 이 함수복제를 내부적으로 ServerMove 를 두번 호출해 줌으로써 순차적으로 처리한다.
  3. 그것도 아니면 일반 ServerMove

서버 -> 로컬유저

ServerMove

server 함수이며, 클라이언트 (주체자) 로부터 넘어온 이동 정보를 토대로 서버의 Pawn 을 이동시킨다.

  1. 서버측에서 이 함수로 넘어온 파라메터 (Acceleration, Location, Rotation) 로 서버측 Pawn 의 Acceleration 과 Rotation delta 를 구하여 셋업
  2. MoveAutonomous 를 호출하여 Pawn 의 물리운동을 수행. ProcessMove 및 AutonomousPhysics 호출
  3. ServerMoveHandleClientError 호출

ServerMoveHandleClientError

클라로부터 받은 정보 Accel 과 Loc 을 서버의 것과 비교하여 오차가 있는지 확인한다.

  1. 특수한 경우는 에러검출을 제끼고4)
  2. 서버의 Loc 과 클라가 보내온 Loc 의 오차가 허용치 이상이라면 클라에 보정신호를 보내기 위해 PlayerController.PendingAdjustment 에 서버의 Phsycs, Loc, Vel 을 기록
  3. 그리고 서버측 틱 마지막에 PendingAdjustment 에 뭔가 있으면 PlayerController::SendClientAdjustment 가 호출5)된다.

SendClientAdjustment

상황에 맞는 버전의 ClientAdjustPosition 함수를 호출

  1. 이전 ServerMoveHandleClientError 단계에서 오차가 발견되지 않았다면 클라측에 ClientAckGoodMove( TimeStamp) 를 보내고 클라는…
    1. 보냈던 패킷의 TimeStamp 와 현재 시간의 간격을 토대로 ping 을 계산하여 서버에 알리고
    2. SavedMoves 배열에서 TimeStamp 이전의 요소들을 클리어한다. 오차가 발견되지 않았다면 '이동과 교정/복제' 의 흐름은 여기서 끝난다.
  2. 이전 단계에서 오차가 발견되었다면 현재 상황에 가장 알맞는 버전의 ClientAdjustPosition 함수6)를 클라로 보낸다.

LongClientAdjustPosition

client 함수이며, 서버측이 클라이언트에게 보내는 위치보정 함수복제이다.

  1. 서버로부터 받은 State, Physics, Loc, Vel, Base, Floor 등을 세팅하고 bUpdatePosition 플래그를 TRUE 로 설정한다.
  2. 다음번 PlayerTick 이 돌아오면 bUpdatePosition 이 TRUE 인지 체크하고 PlayerMove 가 호출되기 전에 ClientUpdatePosition 이 호출7)된다.

ClientUpdatePosition

여기가 중요하다.

현재 상황은 서버의 것과 위치 차이가 있으니 내가 시키는 대로 수정하라는 신호 (이하 correction정보라 칭하겠다.) 를 서버로부터 받았고, 클라에는 서버로 전송하고 아직 Acked 받지 못한 SavedMoves 들이 대기중인 상태다. 물론 대기중인 SavedMoves 들의 TimeStamp 는 이 오류보정 패킷 이 후의 것들이다.

한편 서버는 correction정보를 클라로 보낸 후, 클라에서 이후에 보내온 (클라 입장에선 SavedMoves 리스트에서 서버의 Ack 를 기다리고 있는) 이동 정보들을 계속 적용할 것이고 8) 클라에서도 서버로부터 받은 correction정보에 SavedMoves 들을 똑같이 적용해 주어야 비로소 현재 위치가 서버의 위치과 얼추 비슷해졌을 것이라 기대할 수 있다.

:!: 이래야만 앞으로 보낼 ServerMove 들에 문제가 발생하지 않는다.

만약 클라에서 위와 같이 Ack 를 기다리는 SavedMoves 들을 correction정보를 받은 직후 적용해주지 않는다면 이후 서버로 보내지는 ServerMove 들은 십중팔구 이와 같은 오류교정 프로세스를 지속적으로 유발할 것이라 예상할 수 있고 상황은 아비규환으로..

따라서 이 함수의 내용은 이러하다.

  1. ClearAckedMoves 를 호출하여 correction정보 이전의 SavedMoves 의 요소들은 모두 없앤다. 필요없다.
  2. 남아있는 SavedMoves 리스트들을 모두 움직임에 적용해준다.

See also

1) 어차피 제 3자들이기에
2) 코드에는 Role < ROLE_Authority 가 만족할 경우 호출되지만 어차피 이 내용이 있는 PlayerMove 는 PlayerInput 이 있어야 호출되기 때문에 ROLE_Simulated 이하는 호출될 수 없다. 따라서 해당되는 놈은 ROLE_Autonomous 뿐이다.
3) 넷스피드 및 인원수 등을 고려하여 적당한 복제주기가 계산된다.
4) RM 중이거나, DualServerMove 의 첫번째 Move 이거나, 보정주기가 아직 안되었거나 등.. 기본은 이렇고 요거 상속받아 구현하기 나름
5) UWorld::ServerTickClients 에서 호출
6) VeryShortClientAdjustPosition, ShortClientAdjustPosition, ClientAdjustPosition, LongClientAdjustPosition 이 있다. 보면 알겠지만 결국 모든 함수는 LongClientAdjustPosition 함수를 호출하게 된다.
7) PlayerController::PlayerTick 에서 호출
8) 물론 ServerMove 함수가 unreliable 이라 패킷이 유실될 수도 있겠지만 여기선 고려하지 않는다.