본문 바로가기

진리는어디에

[Unity] Physics.Simulate를 이용한 네트워크 동기화

들어가며

네트워크 게임에서 서버와 클라이언트간의 네트워크 전송 지연(transmission delay)은 이 세상에 물리 법칙이 적용되는한 피할 수 없는 사실이다. 플레이어가 액션을 취한 뒤 서버의 시뮬레이션을 거쳐 다른 플레이어들에게 전파되기 까지는 항상 지연이 수반 된다.

그림1

위 그림에서 클라이언트 A에서 전송한 패킷이 서버에서 처리를 거쳐 클라이언트 B에게 전달 되기 까지 100ms가 필요했다. 따라서 클라이언트 B에서 볼 수 있는 클라이언트 A의 최근 모습은 아무리 빨라도 100ms 이전의 모습 밖에는 볼 수 없다.

다시 한번 말하지만 네트워크 지연은 절대 피할 수 없는 물리적 한계이고, 길다면 길고 짧다면 짦은 온라인 게임의 역사속에 이 문제를 해결하기 위한 많은 방법들이 제시되었다. 이번 포스트에서는 이런 전송 지연을 '클라이언트의 예측'으로 해결하는 이론적 방법에 대해서 알아 보고, 이를 유니티의 Physics.Simulate를 이용해 쉽게 구현 해보도록 하겠다.

클라이언트 측 예측

요즘 일반적인 게임은 초당 60프레임을 제공한다. 계산을 편하게 하기위해 서버도 동일한게 초당 60프레임으로 시뮬레이션을 돌릴 수 있다고 가정하고 아래 그림 처럼 초당 60프레임에서 서버가 플레이어의 점프를 시뮬레이션 한다고 상상해보자. 

그림2

위 [그림2]에서 사각형 박스 하나는 프레임 하나를 나타내고, 박스 안의 숫자는 점프한 플레이어의 높이 픽셀 좌표다. 서버는 한 프레임마다 플레이어를 1픽셀씩 움직이지만 4프레임마다 한번만 상태 패킷을 보내고 있다. 클라이언트가 상태 패킷을 수신하면 플레이어의 높이를 갱신하는데 한번 갱신한 이후 다음번 상태 패킷이 오는 4프레임 동안 같은 위치의 픽셀에 플레이어를 그리고 있기 때문에 클라이언트의 GPU는 초당 60프레임으로 쉴새 없이 일하고 있지만 유저가 보는 화면은 고작 초당 15프레임으로 돌아가는 셈이다. 비싼 그래픽 카드를 샀지만 겨우 15프레임이라니 허탈하지 않을 수 없다.

이런 문제는 빠른 조작감과 정확한 판정을 요구하는 FPS 게임에서 특히 문제가 된다. 전송 레이턴시 때문에 조작감이 떨어지며 다른 플레이어를 조준하기도 어려워 진다. 정확한 현재 시점의 상대 플레이어 위치가 확보되지 않으면 조준 사격의 의미가 없다. 나는 분명히 정확히 상대 방의 머리에 조준해 사격 했지만 사실 지금 나에게 랜더링 되고 있는 상대의 모습은 100ms 이전의 상태로 서버에서는 이미 이동한 것으로 시뮬레이션 되어 허공에 총을 쏜것 처럼 판정한다.

이런 문제를 해결하기 위해 사용할 수 있는 좋은 방법 중에 하나가 '클라이언트 측 예측'이다. 예측이란 서버에서 조금 오래 된 상태를 받아 그것을 기반으로 현재 상태라고 추측한 상태와 가장 가깝게 맞춘 뒤 플레이어게 보여주는 것이다. 이를 다른 개발 서적에서는 외삽(extrapolation)법이라고 하고 외삽을 활용하는 기법을 클라이언트 측 예측이라 한다.

보외법(補外法) 또는 외삽(外揷, extrapolation)은 수학에서 원래의 관찰 범위를 넘어서서 다른 변수와의 관계에 기초하여 변수의 값을 추정하는 과정이다. 
출처 : https://ko.wikipedia.org/wiki/%EB%B3%B4%EC%99%B8%EB%B2%95

과거의 상태를 이용해 현재 상태를 예측하려면 아래 두가지 요소가 필수적이다.

  1. 클라이언트는 최종 갱신 내역이 얼마나 오래전 것인지 알 수 있어야 한다.(전송 지연이 얼마인지 알아야 한다)
  2. 클라이언트와 서버는 동일한 시뮬레이션 코드를 실행할 수 있어야 한다.

전송 지연(transmission delay) 측정

클라이언트 예측을 위해서 가장 먼저 필요한 정보는 전송이 얼마나 지연이 되고 있는가 하는 것이다. 위의 [그림1]에서 클라이언트 A는 자신이 패킷을 보내고 다시 응답을 수신하기 전까지 100ms의 시간이 걸렸다. 이렇게 패킷을 보내고 받을 때 까지 걸리는 시간을 Round Trip Time(이하 RTT)라고 하며, 네트워크에서 한쪽 방향으로 패킷이 전송되는데 걸리는 시간은 왕복 시간의 절반만큼 걸린다고 가정하면 아래와 같이 지연 시간을 구할 수 있다. (물론 왕복 시간의 절반을 취한다는 것은 어디까지나 추정일 뿐이다. 보내고 받는 양쪽 방향의 네트워크 속도가 꼭 같다고 할 수는 없지만 1/2정도면 대부분 실시간 게임 용도로 충분하다 하겠다)

클라이언트는 최종 갱신 내역이 얼마나 오래전 것인지 알 수 있어야 한다.
전송 지연 시간 = 1/2 * RTT

RTT를 측정하기 위해 단순히 서버에서 타임 스탬프를 찍어 클라이언트에게 보내고, 클라이언트는 로컬 타임 스탬프와 비교하여 경과시간을 측정하는 방식은 원격에 있는 장비들의 시계를 동일하게 동기화하는 것은 불가능한 이유 때문에 허용 범위 이상의 오차를 발생 시킬 수 있는 정확하지 못한 방법이다. 보다 정확하게 RTT를 측정하기 위해서는 클라이언트에서 서버로 타임 스탬프를 찍어 전송하고 전송한 타임스탬프 그대로 서버로 부터 돌려 받아 두 시간의 차를 이용해 왕복 시간을 측정해야 한다.

클라이언트 예측

서버의 시뮬레이션이 얼마나 늦게 클라이언트에서 도착하는지 알았다면 이제 본격적으로 클라이언트 예측에 대해 알아 볼 시간이다. 현실적인 전송 지연을 고려 했을 때 플레이어가 서버의 시뮬레이션 결과를 원격에서 랜더링 할 수 있는 시간은 최소한 서버의 진짜 상태 보다 1/2 RTT 시간만큰 뒤쳐지게 된다. 이렇게 클라이언트는 상태 갱신 패킷을 받은 시점에서 그 갱신 내역이 1/2 RTT만큼 오래 되었다는 것을 알고 있다.

이제 이 문제를 어떻게 해결해야 할까? 간단하다. 과거의 상태를 현재 상태로 당기려면 클라이언트가 과거의 상태로 부터 시뮬레이션을 1/2 RTT 시간만큼 더 진행 시킨뒤 화면에 보여주면 된다. 이런 방식으로 서버의 진짜 현재 게임 상태에 훨씬 근접한 상태로 예측하여 플레이어에게 보여 줄 수 있다. 이 때 필요한 것이 우리가 위에서 살펴 봤던 클라이언트 예측의 두번째 조건이다.

클라이언트와 서버는 동일한 시뮬레이션 코드를 실행할 수 있어야 한다.
과거의 상태로 부터 시뮬레이션을 1/2 RTT 시간만큼 더 진행 한다.

게임 시뮬레이션의 구현은 여러 부분에서 결정론적이므로 서버와 클라이언트가 같은 시뮬레이션 로직을 실행한다면 대체로 같은 시뮬레이션 결과를 얻는다. 총알이 발사되어 공기 중에 날아갈 때 서버와 클라이언트에서 같은 방식으로 날아 갈 것이고, 공이 벽이나 바닥에 튕길 때도 같은 물리 법칙으로 동작할 것이다. 클라이언트와 서버가 AI 로직도 같이 공유한다면 AI가 제어하는 객체 또한 서버와 동기화를 맞추어 시뮬레이션 할 수 있다. 심지어 랜덤 조차도 클라이언트와 서버가 같은 난수를 생성 하도록할 수 있다.

데드 레커닝(Dead Reckoning)

앞에서 대부분의 게임 시뮬레이션의 구현은 결정론적이기 때문에 예측이 가능하다고 이야기 했다. 하지만 단 한 종류의 객체는 전적으로 비결정론적이며 완벽한 예측 시뮬레이션이 절대 불가능한것이 있다. 바로 플레이어다. 클라이언트 프로그램이 원격지의 플레이어가 무슨 돌발 행동을 할지 갑자기 어디로 이동할지 예측하는 것은 불가능 하다. 이때문에 플레이어가 조작하는 객체에 대해서의 예측은 항상 오차를 수반한다. 클라이언트가 취할 수 있는 최선책은 기존 정보를 토대로 추정하고 그 추정치를 서버로 부터 받은 신규 정보로 보정해 나가는 것이다.

플레이어의 돌발행동은 예측이 불가능 하다 = 예측의 오차가 발생한다

네트워크 게임에서 데드 레커닝(dead reckoning)이란 대상체가 현재 하는 행동을 지속할 것이란 가정하에 대상체의 다음 행동을 예측하는 기법이다. 플레이어가 지금 뛰고 있다면 계속 같은 방향으로 뛸것을 가정하며, 오른쪽으로 회전하고 있다면 같은 방향으로 계속 회전할 것으로 가정한다.

데드 레커닝 : 현재 행동을 지속할 것이란 가정하에 다음 행동을 예측하는 기법

원격 플레이어가 예상치 못한 동작을 하면 클라이언트 쪽 시뮬레이션이 서버의 상태와 조금 씩 달라지는데, 이렇게 달라진 부분들은 최대한 빠른 시간 내에 수정되어야 한다. 

예를 들어 1/2 RTT가 50ms이고 프레임레이트가 초당 60프레임이며 10프레임당 한번씩 상태 패킷을 보낸다고 가정하자. 플레이어가 오른쪽으로 1프레임당 1픽셀씩 이동하다 동기화 패킷을 보내고 15프레임 때 갑자기 위쪽으로 이동 방향을 바꾼다면 이것을 시뮬레이션 하는 다른 플레이어는 새로운 상태 패킷이 도착하기 전 5프레임 동안 추가로 오른쪽으로 이동을 더 하게 되는 오차가 발생하게 된다.

데드 레커닝은 서버에서 모든 정버를 완벽하게 확보한뒤 수행되는 방식이 아니다. 따라서 보수적 알고리즘으로 분류하지 않으며 낙관적 알고리즘(optimistic algorithm)으로 분류한다. 이런 방식은 대부분 비슷한 추정 상태를 얻을 수 있지만, 가끔은 완전히 틀린 결과를 도출하게 되어 수정이 필요하게 된다.

클라이언트가 자신의 시뮬레이션이 부정확하다는 것을 탐지하게 되면 아래 세가지 방법중에 하나를 선택하여 상태를 동기화할 수 있다.

  • 즉시 상태 갱신 : 그냥 새 상태를 즉시 반영해 버린다. 플레이어는 객체들이 갑자기 텔레포트하는 것을 보게 될것이지만 그래도 부정확한것 보다는 낫다고 판단할 때 사용하는 방법이다. 하지만 상태는 1/2 RTT만큼 뒤쳐져 있으므로 클라이언트는 반영한 시점에서 다시 1/2 RTT만큼 시뮬레이션을 진행해야 한다.
  • 보간 : 클라이언트 측 보간 법을 이용하여 잘못 예측한 상태에서 출발해 몇 프레임에 걸쳐 새 상태로 보간해 나간다. 이를 위해 위치, 회전등 부정확한 각 상태 변수에 대한 보정용 델타 값을 계산해 저장해 두었다가 매 프레임마다 점진적으로 적용해 나간다. 
  • 상태 변수의 도함수를 유도하여 적용 : 멈추어 있던 상태의 객체가 갑자기 속력을 내는 경우, 보간하더라도 어색해 보일 수 있다. 플레이어가 눈치채지 못하게 하려면 속력에 대해 도함수를 유도하여 가속도를 구하는 식으로 시뮬레이션을 섬세하게 제어해야 한다. 수학적으로 복잡하고 골치 아프지만 자연스럽게 보이게 하는데 가장 좋은 방법이다.

Unity를 이용해 구현해보기

기본적인 이론들을 간단하게나마 살펴 보았으니 이제 실제 유니티 엔진 위에서 구현하는 예제를 살펴 보도록하자. 프로젝트의 전체 소스 코드는 [여기]에 업로드 되어있다. 

앞에서 예측을 하기 위해서 필요한 조건은 '전송 지연 시간 측정'과 '동일 시뮬레이션'이었다. 이제 부터 유니티 엔진에서 제공하는 기능을 이용해 손쉽게 구현하는 방법에 대해 살펴 보도록 하자.

UnityEngine.Ping - 전송 지연 시간

전송 지연 시간을 측정하기 위해 클라이언트에서 타임 스탬프를 찍어 서버로 전송하고 서버로 부터 그대로 돌려 받은 타임 스탬프를 이용해 RTT를 구하는 방법도 있지만 더 간단하게 Ping 클래스를 이용하는 방법도 있다. Ping 객체는 생성자의 인자로 주어진 dot 표현 방식의 ip 주소에 ping 메시지를 보내 응답 시간을 저장한다. 비동기로 동작하며 isDone 변수를 이용해 완료 여부를 판단 할 수 있다.

아래는 Ping 클래스의 사용법을 간단히 표현한 샘플 코드이다. 전체 코드는 [Assets/Gamnet/Script/Client/Session.cs]의 132라인에서 부터 살펴 보면 된다.

using UnityEngine;

class Session 
{
    private Ping ping;
    public int ping_time { get; private set; }
    
    // 일정 주기마다 지속적으로 호출
    void PingTimer()
    {
        if (null == ping)
        {
            // ping time 측정 시작
            ping = new Ping(connector.endpoint.Address.ToString());
        }
        else
        {
            // ping time 측정 완료
            if (true == ping.isDone)
            {
                ping_time = ping.time;
                ping = null;
            }
        }
    }
}
요즘에는 ping 테스트를 할 수 있는 ICMP를 차단하는 방화벽도 늘어나고 있는 추세다. 만일 Ping이 정상적으로 동작하지 않는다면 어쩔 수 없이 타임 스탬프를 찍어서 주고 받는 방식을 이용해야 한다. 위에서 공유된 소스코드에 직접 RTT를 측정하는 코드 샘플도 추가 되어 있으므로 살펴 보도록 하자.

Physics.Simulate - 동일 시뮬레이션

지연 시간 측정 외 나머지 필요 조건은 서버와 클라이언트가 같은 시뮬레이션 로직을 돌릴수 있어야 한다는 것이었다. 이는 클라이언트 뿐만 아니라 서버도 유니티 엔진 기반으로 만드는 것으로 해결하도록 하겠다. 유니티를 이용해 서버 빌드를 하는 방법에 관한 방법은 [여기]에서 찾아 볼 수 있다.

서버와 클라이언트가 동일한 시뮬레이션을 돌리는 것에 추가하여 클라이언트가 받은 서버의 상태는 현재로 부터 1/2 RTT 만큼 이전의 상태라고 했었다. 클라이언트는 그만큼 더 서버와 동일한 시뮬레이션을 진행한 후 플레이어게게 보여주어야 한다. 실제 우리가 이런 시뮬레이션을 코드로 직접 만들게 된다면 방향과 현재 속도, 그리고 가속도, 충돌에 따른 반사, 심지어 중력과 물체의 무게, 탄력에 따른 반사 정도를 모두 만들어 줘야 할 것이다. 하지만 유니티에서는 이런 모든 문제를 Physics.Simulate가 해결해 준다.

public static void Simulate(float step /* 시뮬레이션을 앞으로 진행 시키는 시간 */);

씬(scene)에서 step에 지정된 시간만큼 물리 운동을 시뮬레이션한다. Physics.autoSimulation이 꺼져 있을 때 수동으로 물리 운동을 시뮬레이션하려면 이것을 호출하면 된다. 시뮬레이션에는 충돌 감지, 리지드바디조인트 통합, 물리적 콜백(접촉, 트리거 및 조인트) 처리의 모든것을 포함한다. 참고로 Physics.Simulate를 호출해도 FixedUpdate는 호출 되지 않는다. MonoBehaviour.FixedUpdate 는 자동 시뮬레이션의 활성화 여부, Physics.Simulate를 호출과 관계 없이, Time.fixedDeltaTime 에서 정의한 속도로 계속 호출된다.

NOTE 1 : 만일 프레임 레이트에 종속적인 값(예: Time.deltaTime )을 물리 엔진에 전달하면 프레임 속도 변동으로 인해 예측 할 수 없는 시뮬레이션 결과를 얻게 된다. 결정론적(고정적인) 물리 시뮬레이션 결과를 얻기 위해서는 호출 할 때 마다 고정 값을 Physics.Simulate에 전달해야 한다.

NOTE 2 : 일반적으로 step작은 양수여야 한다. 만일 0.03보다 큰 값을 사용하면 부정확한 결과가 생성될 수 있다.

아래 코드 샘플은 예제 프로젝트에서 사용된 Simulate 코드를  단순하게 편집한 내용이다. 전체 소스코드는 [여기]에서 확인 할 수 있다.

public class Main : MonoBehaviour
{
    // 서버로 부터 상태 메시지를 받음
    public void OnRecv_SyncBall_Ntf(Packet.MsgSvrCli_SyncBall_Ntf ntf)
    {
        // ...코드 생략...
        Ball ball = go.GetComponent<Ball>();
        if (null == ball)
        {
            return;
        }
        
        // 예측 시뮬레이션을 하기 위한 서버의 상태값 동기화
        ball.transform.localPosition = ntf.ball.localPosition;
        ball.transform.rotation = ntf.ball.rotation;
        ball.rigidBody.velocity = ntf.ball.velocity;
        
        // Network.NetworkDelay : 네트워크 지연 시간(초)
        deltaTime += (float)Network.NetworkDelay / 1000;
        while (deltaTime >= Time.fixedDeltaTime)
        {
            deltaTime -= Time.fixedDeltaTime;
            physicsScene.Simulate(Time.fixedDeltaTime);
        }
    }
}

위 코드에서 핵심은 먼저 14라인에서 클라이언트 시뮬레이션 예측을 위해 서버로 부터 받은 상태 값들을 로컬 객체에 저장(동기화)하는 부분이다. 이 예제 프로젝트에서는 예측을 위해 현재 위치, 회전, 이동 방향과 속도를 사용하고 있다. 다음으로 눈여겨 보아야 할 부분은 19라인에서 부터 시작하는 예측 시뮬레이션이다. 클라이언트는 상태 동기화 패킷을 수신 후 네트워크 지연 시간만큼 시뮬레이션을 강제 진행 시킨다.

Tip. Physics.Simulate는 씬에 포함된 모든 객체들에 대해 물리 시뮬레이션을 진행하므로 만일 특정 객체에 대해서만 물리 시뮬레이션을 사용하고 싶다면 별도의 씬을 생성하여 대상 객체를 해당 씬에서 시뮬레이션 하도록 만들어야 한다.

예를 들어 본 포스트에서 사용된 예제 프로젝트는 편의를 위해 서버와 클라이언트를 하나의 프로세스에서 구동했다. 때문에 클라이언트 예측을 위한 시뮬레이션을 강제 진행하면 서버도 그만큼 빨라지는 버그가 있었다. 이런 경우 서버에서 계산하는 오브젝트와 클라이언트에서 계산 하는 오브젝트를 별도의 씬에서 구동하도록 만들어했다

// https://docs.unity3d.com/2019.1/Documentation/ScriptReference/PhysicsScene.html
private PhysicsScene physicsScene; // 물리 씬..이라는데 공식 문서에서도 설명이 부족함

void Start()
{
    CreateSceneParameters csp = new CreateSceneParameters(LocalPhysicsMode.Physics3D);
    Scene scene = SceneManager.CreateScene("LocalPhysicsScene", csp); // 동적으로 씬 생성
    physicsScene = scene.GetPhysicsScene();
    SceneManager.MoveGameObjectToScene(gameObject, scene); // 객체를 신규 씬으로 이동
    
    // 코드 생략...

참고로 PhysicsScene.Simulate는 Update함수 내에서 명시적으로 Simulate를 호출해주어 한다.

private void Update()
{
    // ... 생략 ...
    
    deltaTime += Time.deltaTime;
    while (deltaTime >= Time.fixedDeltaTime)
    {
        deltaTime -= Time.fixedDeltaTime;
        physicsScene.Simulate(Time.fixedDeltaTime);
    }
    
    // ... 생략 ...
}

위 예제의 전체 코드는 [여기]에서 확인할 수 있다.

결과

결과는 아래 동영상과 같다. 왼쪽의 검은 배경은 클라이언트 측 예측이고 오른쪽 밝은 배경은 서버측 시뮬레이션이다. 서버와 클라이언트는 500ms의 전송 지연 시간을 가지며 동영상의 처음 부분 부터 7초까지는 지연된 화면을 그대로 플레이하고 있다. 동영상의 7초 부터 서버에서 받은 상태 값으로 부터 500ms 더 시뮬레이션을 진행해 보여주고 있다. 서버와 클라이언트의 공이 동일하게 움직이는 것을 볼 수 있을 것이다. 블록이 파괴 이벤트 패킷에는 예측을 진행하지 않으므로 왼쪽 화면에서는 공이 지나가고 500ms 뒤에 블록이 사라지는 것을 확인 할 수 있다.

만일 직접 플레이를 해보고 싶다면 [여기]에서 프로젝트를 다운 받아 유니티로 구동 시켜 보면 된다.

부록 1. 같이 읽으면 좋은 글

 

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!