Character Movement Component In-Depth
강의 시리즈를 공부하면서 한글로 정리한 포스트입니다. 의역과 오역이 난무하니 주의해주세요!
https://youtu.be/dOkuIvKCvpg?si=yRzIaylvUKSvZIze
이해해야 하는 세 가지 네트워크 사례가 있다. 이는 게임의 모든 복제된 개체에 해당된다. 이 원리는 우리가 CMC를 필요로 하는 이유이기 때문에, 반드시 이해해야 한다.
게임에는 동일한 캐릭터의 사본 3개가 존재한다. (소유 클라이언트 / 서버 / 해당 캐릭터를 보기만 하고 제어하지 않는 모든 원격 클라이언트에 대해 하나씩)
소유 클라이언트가 실제로 입력을 받는 클라이언트가 되고, 이동을 수행한 다음, 확인을 위해 서버에 데이터를 보내고 가져오며, 서버에 의해 수정된다.
서버는 클라이언트 데이터를 수신하고, 이 데이터로 이동을 수행하며, 이를 다른 클라이언트로 보내고 필요한 경우 소유 클라이언트를 수정한다.
원격 클라이언트는 서버로부터 상태를 수신하고 해당 위치와 회전을 보간하기만 하면 된다. 하지만, 우리는 원격 클라이언트에 대해서는 신경 쓸 필요가 없다. CMC에 의해 완전히 처리되기 때문이다.
우리는 소유 클라이언트와 서버와의 관계에 대해서만 신경쓰면 되기 때문에, 이 강의에서 언급하는 클라이언트는 그 캐릭터의 소유 클라이언트만을 의미한다.
클라이언트측 예측은 본질적으로 캐릭터 이동 구성 요소의 주요 기능이며, 커스텀 CMC를 사용하는 이유이다.
이것은 네트워크 그래프이다. 수직의 선은 시간을 의미하고, 틱 단위로 표현되고 있다. 각 틱은 10밀리초라고 생각하면 된다. (t5 = 50ms / t10 = 100ms) 왼쪽에는 클라이언트가 있고, 오른쪽에는 서버가 있다. 이 그래프는 캐릭터 위치에 대한 클라이언트와 서버의 뷰이다.
t0에서 클라이언트와 서버 모두 0에서 시작하고, 클라이언트는 자신이 원하는 동작을 가지고 있다. 앞으로 한 칸 이동하기 위해 키보드에서 w를 눌러 이 데이터를 서버로 전송하는데, 이 선이 대각선이라는 것을 볼 수 있다. 레이턴시나 핑이 0이 아니기 때문에, t5에서 서버가 이 이동 요청을 받고 이를 적용한다. 클라이언트 캐릭터는 자신의 위치에 대한 권한이 없기 때문에 아직 위치가 업데이트 되지 않은 것을 확인하자. 그 후, 서버는 클라이언트에게 클라이언트가 있어야 할 새로운 위치를 보내고, 클라이언트는 t10에서 받아 이 위치로 이동한다. 따라서, 클라이언트가 키보드에서 w를 누르고, 실제로 캐릭터의 움직임을 볼 수 있는 것은 100ms 지연이 발생한 이후가 된다. 입력에 레이턴시가 있으면 게임이 정말 느리다고 느껴지며, 요즘 게임에서는 이런 현상을 거의 볼 수 없다.
어떻게 해야 올바른 방식으로 이동을 수행할 수 있을까? 클라이언트측 예측이 그걸 가능하게 한다. 클라이언트는 서버에 이동 요청을 하고, 즉시 예측한 이동을 적용시킨다. 하지만, 이것은 단지 예측된 값이기 때문에 권한이 없는 이동이다. 여전히 서버는 0의 위치에 있다.
아무튼, 클라이언트가 이동하고, 이 이동은 서버가 실제로 이동을 적용할 때 일어나는 일과 일치하므로 서버가 이동을 적용하고 정확한 위치를 다시 보냈을 때, 클라이언트와 서버의 위치는 동일하다. 이는 서버가 요청에 따라 수행할 움직임을 성공적으로 클라이언트가 예측했음을 의미한다.
// 번역 확인 필요..
이 그림에서 볼 수 있는 것은 2D 캐릭터를 제어할때의 모습과 같다. 클라이언트가 실제로 서버보다 앞서 있다는 것이다. 클라이언트가 매 틱마다 이동을 보내고 있었을 수도 있다는 것도 볼 수 있다. 이러한 움직임은 간헐적으로 발생하는 상태로, 서버에서 아직 확인되지 않은 보류중인 움직임이므로 서버가 이러한 움직임 중 하나를 받고 위치와 모든 작업이 완료되면 클라이언트의 움직임을 승인하고 이를 수행한다.
캐릭터가 움직일 때마다 움직임을 만드는데, 이것을 Saved Move라고 한다. 기본적으로, 클라이언트는 정확히 같은 움직임을 재현한 모든 상태 데이터를 저장하고 서버가 정확히 동일한 움직임을 다시 생성할 수 있도록 압축된 버전을 서버에 전송한다. Saved Move는 이동을 복제하는데 필요한 모든 데이터가 포함되어 있으며, 여기서 타임스탬프는 움직임의 ID를 식별한다.
클라이언트가 서버가 수행할 이동을 성공적으로 예측하려면, 서버와 클라이언트가 동일하게 호출하는 함수는 정확히 같은 데이터로 호출되어야 하고, 명확해야 한다. 따라서 그 안에 난수나 외부 상태 데이터를 가질 수 없다. 만약 이동 함수가 랜덤 벡터를 호출하고, 클라이언트가 랜덤 벡터로 이동을 수행하지만 서버는 다른 랜덤 벡터를 얻게 되면 서버와 클라이언트가 같은 위치에 있을 수 없다는 것을 알 수 있다. 이로 인해 서버가 클라이언트를 수정해야 하고, 예측이 실패하게 된다.
캐릭터가 매 프레임마다 움직여야 하므로, 가장 최근의 입력 벡터를 소비한다. 기본적으로, 모든 키 입력은 클라이언트가 해당 프레임에서 누른 모든 키를 의미한다. 그리고, 캐릭터의 이동 함수 해당 인풋 벡터를 보낸다.
해당 벡터를 가져와 가속도에 적용한다.
Controlled Character Move
는 Perform Move
를 호출하고, Perform Move
는 모든 일이 일어나는 곳이다. 캐릭터의 루트 구성 요소에 이동을 적용하는 함수이다. Force와 Impulse를 적용하고, 루트 모션을 업데이트 한다.
다양한 이동 모드에 따라 캐릭터를 움직인다.
Controlled Character Move
- Perform Move
- Start New Physics
에 걸쳐 한 체인이 끝이 나면, 소유 클라이언트는 이 작업을 서버에 전송한다. 여기서 중요한점은 Saved Move
를 만드는 것이다. 이동의 입력과 출력의 결과를 바탕으로 압축된 버전의 데이터 구조(FCharacterNetworkMoveData)를 만들어 서버 RPC 로 보내게 된다.
서버는 데이터를 전송받고, 클라이언트가 수행한 이동을 재현하게 된다.
우리는 Replicate Move To Server
/ Server Move
에 관련해서는 신경을 쓰지 않아도 된다. Saved Move만 올바르게 설정해주면, 내부적으로 거의 완벽하게 처리될 것이다.
따라서, 우리는 Perform Move
, Start New Physics
함수와 같은 움직임 로직만 집중하면 된다. 하지만, 우리가 이것들을 작성하는 방법과 이 모든 것을 서버에서 동기화하기 위해서는 무슨 일들이 일어나는지 이해해야만 한다.
클라이언트로부터 RPC로 호출된 Server Move는 들어오는 이동에 대해 준비하는 상태이다. 클라이언트가 작업했던 것과 동일한 입력 매개변수로 작동하도록 서버를 준비시켜야 한다. 동일한 입력에 대해 작업한 것과 동일한 결과를 얻는 것이 매우 중요하다.
더 많은 준비를 수행하고, 상태를 완전히 준비한 후에는 Perform Move
를 호출한다.
클라이언트가 한 것처럼 이동 수행을 호출하기만 하면, 동일한 결과를 얻을 수 있다.
Server Move에서 클라이언트가 보내는 데이터에는, 클라이언트가 Perform move를 호출한 후에 얻은 결과도 들어있다. 여기서는 각각 Perform move를 수행한 후, 서버가 얻은 결과와 클라이언트가 얻은 결과가 동일한지 확인하고, 그렇지 않다면 Client Adjust Position
을 수행하게 된다.
클라이언트의 예측이 틀렸다고 말한 뒤, 실제로 권한이 있는 상태를 보낼 것이다. 모든 것은 서버가 권한을 가지고 있고, 클라이언트는 서버가 보낸 조정 위치를 적용해야 한다.
캐릭터를 서버의 상태로 재설정하고, 보류 중인 움직임들을 다시 시뮬레이션 한다. (추후에 다시 언급)
Start New Physics
가 호출되었을 때, switch문을 사용하여 무브먼트 모드에 따라 함수를 호출하게 된다. 언리얼은 기본적으로 몇 가지 무브먼트 모드를 제공하며, advanced 한 움직임을 원하지 않을때 사용하거나, 커스텀 무브먼트 모드를 사용할 때의 좋은 예시로써 사용된다. 각 모드에는 입력 매개변수를 사용하여 이동을 계산하는 각각의 물리 함수가 존재한다. 또한 우리는 스위치 문에서 들어갈 수 있는 커스텀 무브먼트와 물리 함수를 직접 구현할 수 있다.
이것들은 모두 물리 함수가 작동하기 위해 필요하다. 그리고 이것들은 위치, 회전, 속도를 변경하고 CMC의 상태 데이터를 변경시킨다. 또한 언리얼 엔진이 제공하는 도우미 함수도 있다.
델타타임은 tick component에서 나온다. 하지만 이것은 프레임 종속적이며, 동일한 게임을 실행하는 컴퓨터에 따라 다르다. 우리는 이 델타 타임을 더 작은 시뮬레이션 시간 단계로 나눠야 하고, 이것을 서브 스테핑이라고 부른다.
중요한 것은, 시뮬레이션 중간에 이동 모드를 변경할 수 있다는 것이다. 캐릭터가 걷고있다고 해보자. 이 시뮬레이션 중간에 물 볼륨으로 건너가고, 수영을 시작해야 한다. 우리는 이 물리함수 중간에 새로운 물리를 시작(StartNewPhysics
)할 수 있다. 이 함수에 원래 델타 시간 절반에 해당하는 남은 시간을 주면, 이터레이션이 증가한다. 이렇게 하면 정밀한 서브 스테핑을 알 수 있고, 훨씬 더 정확한 시뮬레이션을 얻을 수 있다. 그런 다음 남은 델타시간 동안 우리는 수영 물리 모드를 시뮬레이션 하고, 이터레이션을 증가시킬 것이다.
클라이언트측 예측에 대해 다시 이야기 해야 한다. 이것이 작동하려면 두가지 일이 일어나야 한다. 클라이언트는 움직임을 예측해야 하지만, 클라이언트가 실수를 하면 움직임을 수정해야 한다. 서버는 클라이언트가 서버와 일치하는 움직임으로 수정하기 위해 필요한 데이터를 모두 포함한 큰 종류의 봉투를 보내게 된다. 타임 스탬프는 어떤 움직임이 벗어났는지를 식별한다. 여기서는 12.5초가 잘못되었다고 하고 있으므로, 이 위치와 속도를 사용해야 하며, 다른 사항도 알고있어야 한다. 이것이 정확한 움직임이므로, 클라이언트에는 승인되지 않은 보류중인 Saved Move가 있다. 마지막 Saved Move가 14.05초라는 것을 알 수 있는데, 이것은 과장된 것이므로 실제로는 그렇게 큰 차이가 나지는 않을 것이다. 어쨌든, 먼저 발생하는 것은 서버 상태를 캐릭터에 직접 적용하는 것이다.
클라이언트는 서버에 의해 수정될 때마다 서버의 위치로 다시 돌아간다. 알다싶이 14.05초의 시간 단계에서 우리는 잘못된 예측을 했고, 초록색의 원 위치에 있어야 했지만 현재 노란색의 위치에 있다. 하지만 서버가 업데이트된 위치를 보낼 때 우리는 이미 미래에 대해 시뮬레이션 했으며, 14.42 단계인 빨간색 원의 위치에 있다. 우리가 잘못된 예측을 했을 때보다 약 400ms 앞서 있다는 것을 알 수 있다. 클라이언트를 다시 초록색 원의 위치로 보내야 하므로, 꽤 거슬리는 행동이 될 것이다. 빨간색 원과 노란색 원의 사이에 있는 분홍색 원들은 보류 중인 동작을 나타내는데, 이 보류 중인 동작들은 잘못되지 않았다. 단지 노란색 원의 동작이 잘못됐을 뿐이다.
그래서 그 대신에, 잘못된 상태를 초록색 원의 상태로 옮긴 다음에, 보류중인 움직임을 우리가 있어야 할 위치로 다시 시뮬레이션 할 것이다. 이렇게 하면 위의 사례보다 훨씬 덜하게 차이가 발생한다는 것을 알 수 있으며, 차이가 특정된 허용범위 내에 있으면 언리얼 엔진이 클라이언트를 새로운 위치에 블렌딩하기에 스냅 현상이 일어나지 않는다.
다시 돌아가서, 우리는 상태를 먼저 적용하고, 보류된 움직임에 대해 다시 적용하고 다시 Perform Move를 호출하면 우리를 수정된 새로운 위치로 데려가게 된다.
상태 보존과 안전한 이동에 대해 얘기할 것인데, 이 주제는 좀 어려울 수 있어서 코딩을 시작하고 나서야 이해하기가 훨씬 쉬울 것이다.
클라이언트에서 perform move가 호출되는 곳은 두곳이 있다. 실제로 캐릭터를 이동할 때 틱 컴포넌트의 일반적인 이동이 있고, 서버가 클라이언트를 수정할 때 모든 Saved Move에 대한 이동이 있다. 이전에 말했던 것처럼, 이것이 작동하려면 우리는 동일한 입력 상태에서 작동하도록 해야한다. 입력이 동일하게 유지되면, 동일한 결과를 생성할 것이다.
하지만, 상태 데이터가 있으면 까다로워진다. 예를 들면 대시가 될 수 있다. 대시에 쿨다운이 있고, 플레이어가 빨간색 원의 방향으로 돌진했고, 플레이어가 빨간색의 위치에 있다고 해보자. 물리 함수에서 대시 쿨다운에 델타 시간을 추가하고 대시 쿨다운이 대시 지속 시간을 초과할 때마다 다시 대시할 수 있다.
조정을 마친 후, Saved Moves를 재생해야 한다. 각 동작이 Perform move를 다시 호출하고, 물리 함수를 다시 호출한 뒤 다시 대시 쿨다운을 증가시킨다.
겉보기에는 문제가 없어보이지만, 위의 단계에선 실시간으로 여러 프레임에 걸쳐 발생하는데 반면 여기서는 저장된 모든 동작을 하나의 프레임에서 다시 시뮬레이션 한다. 이것이 끔찍한 비동기화의 이유이다. 이 문제를 어떻게 해결해야 할까?
공식적인 용어는 아니지만, 여기서는 Movement Safety
라는 용어를 사용한다. 이것은 어떤 변수를 사용할 수 있고 없는지에 대한 아이디어와 물리 함수가 비상태 데이터에 대해 오작동하는 것을 피할 수 있는 방법을 제공한다.
Saved Move는 오버라이드가 가능하고, 여기에 대시 쿨다운을 추가할 수 있는 상태 데이터를 추가할 수 있다. 이렇게 하면 Perform move에 대한 입력이 준비되어 처음으로 perform move를 호출했을 때와 정확히 동일해진다.
Saved Move에 상태를 적절하게 저장하지 않으면 이동이 안전하지 않다. 또한 클라이언트와 서버가 동기화되지 않는 비동기화 현상이 발생하며 수많은 서버의 보정이 적용된다는 것을 알 수 있다.