Rollback Networking in INVERSUS

javawork·2021년 9월 18일
0

원문

http://blog.hypersect.com/rollback-networking-in-inversus/

INVERSUS 게임 플레이

https://www.youtube.com/watch?v=dej5QSnfNnk

INVERSUS는 4명까지 플레이 할 수 있는 빠르게 진행되는 멀티플레이어 게임 입니다. 전형적인 로컬 멀티플레이어 유형의 게임입니다. 레이턴시 문제로 온라인으로 만들기에는 적합하지 않다고 생각했습니다. 개발 후반부에 그래도 없는 것보다는 낫겠지라는 생각으로 멀티플레이를 개발했는데, 싱글 플레이와의 차이점을 찾기 힘들정도로 잘 마무리 되었습니다. 네트워킹은 매치메이킹, 맵 선택, 게임 플레이와 같이 다양한 주제가 있지만 여기서는 게임 플레이에 대해서만 이야기 하겠습니다. 어떻게 화면 분할 싱글 게임을 온라인 게임으로 개발하였는지 분석해 보도록 하겠습니다.

Overview

롤백 네트워킹 시스템은 모든 플레이어가 인풋을 주고 받는 동기화 방식 네트워크 게임의 혁명입니다. 이러한 시스템에서는 deterministic한 게임 시뮬레이션이 (다른 플레이어로부터)커맨드를 받는 시점보다 한 프레임 앞서 이루어져야 합니다. 리모트 플레이어의 인풋을 받기전에 로컬 플레이어의 인풋을 미리 반영하면 안됩니다.

롤백은 리모트의 인풋을 예측하여 진행함으로써 레이턴시 문제를 해결합니다. 실제 원격 인풋이 도착하면 혹시 잘못 예측한 것이 있는지 체크합니다. 잘못된 부분을 발견하면 해당 프레임으로 롤백하여 수정된 실제 인풋을 반영하여 다시 시뮬레이션 합니다. 이러한 수정 프로세스는 단 한 프레임에 이루어지기 때문에 플레이어는 미세하게 튀는 현상을 볼 수 있습니다.

네트워크 레이턴시가 증가하면서 튀는 현상 또한 증가할 것 입니다. 이것은 리모트 플레이어의 인풋과 관련이 있습니다. 예를 들면, 발사체가 발사되는 초기 몇 프레임은 스킵 할 수 있습니다. 하지만 발사체가 날아가는 중에는 싱글 게임처럼 즉각 반응 할 수 있습니다.

롤백 네트워크는 제로 레이턴시 경험을 주기위해 디자인 되었습니다. 이동 인풋을 주면 바로 이동하고, 특정 프레임에 공격하기 위해 버튼을 부르면 바로 공격 합니다. 이 아키텍처는 또한 게임과 네트워크 코드를 분리합니다.게임에 기능을 추가할 때 네트워킹에 대한 고려할 필요가 없습니다. 모든 것이 제대로 작동하며 이는 엔지니어에게 자유를 줍니다.

롤백 네트워크의 단점은 중간에 난입하는 플레이어를 지원하는게 까다롭고, 많은 플레이어가 동시에 플레이하는 게임으로 확장이 어렵다는 데 있습니다. 또한 가변 프레임 시뮬레이션을 지원하지 않지만, 가변 프레임 렌더링을 계속 지원할 수 있는 방법에 대해서는 나중에 설명하겠습니다.

롤백은 빠르고, 인풋에 바로 반응해서 정확한 프레임에 동작해야하고, 짧은 게임 플레이를 하는 게임에 적합합니다. 최신 격투게임과 짧은 게임시간의 라운드 베이스 게임의 표준이 되었습니다.

Preparing the Simulation

이 모델이 동작하기 위해서는 deterministic한 시뮬레이션을 지원해야 하고 빠르게 상태를 저장하고 복원해야 합니다. 이러한 기능은 디버그 및 리플레이 기능 등을 추가하는데 많은 도움이 됩니다.

Determinism

Deterministic한 시뮬레이션을 하기 위해서는 오직 플레이어의 인풋만이 게임에 영향을 주는 요소여야 합니다. 주어진 초기값(레벨 이름, 시간, 랜덤시드)으로 플레이어의 인풋과 조합하여 전체 게임을 다시 재생할 수 있어야 합니다. 주어진 가이드라인을 따르고 글로벌 시스템에 접근하지 않도록 디자인하면 그렇게 어려운 일은 아닙니다. 아래의 사항은 피하시면 됩니다.

  • 실제 월드의 시간을 참조하지 마세요
    • 게임이 시작된 이래 계산된 duration 을 사용하세요.
    • 새로 생성되거나 주어진 랜덤 시드만 사용
  • 글로벌 시스템에 있는 정보(deterministic하지않은)를 참조하지 마세요.
  • 동적 메모리의 주소가 로직에 영향을 주게하지 마세요.
  • 초기화되지 않은 메모리가 로직에 영향을 주게하지 마세요.
  • 로컬 시스템 인포메이션을 체크하지 마세요
    • 파일의 절대 경로는 사용하지 않음
    • 로컬 계정이나 사용자 이름을 사용하지 않음
  • 머신 아키텍쳐(x86, x64)와 연관된 코드를 사용하지 마세요
  • 병렬 스레드에서 계산된 데이터를 병합하는데 주의하세요
  • 부동 소수점 연산에 주의 하세요.

State Restoration

시간을 되돌리는 기능은 시뮬레이션 상태를 저장하고 복원하는 기능을 요구 합니다. 게임에서 데이터를 다음의 두 카테고리로 나누어 추적합니다. 다음 프레임의 시뮬레이션에 영향을 주는 데이터(gamestate), 영향을 주지 않는 데이터(non-gamestate). 게임스테이트는 객체의 속도, 플레이어의 체력 같은 것이고, 논게임스테이트는 메뉴의 위치, 로딩된 텍스쳐 같은 것 입니다. 백업된 게임스테이트를 저장하기위해 추가적인 메모리가 필요합니다. 저장/복원 수행 속도는 빨라야 합니다.

Simulation Performance

시뮬레이션 스텝은 빨라야 합니다. 롤백을 수행하고 해당 프레임에서 현재 프레임까지 하나씩 프레임을 이동시키는 시뮬레이션은 하나의 렌더링 프레임에 수행되어야 합니다. 이 말의 의미는 하나의 프레임 시뮬레이션이 인풋을 처리하는 것보다 훨씬 빨라야 한다는 뜻 입니다. 이 비율은 시뮬레이션 타임 스텝과 지원해야 하는 네트워크 레이턴시에 따라 달라집니다.

구현


롤백 시스템의 이론적인 배경에 대한 이해를 뒤로하고, 실제 구현해야 하는 시나리오에 대한 이야기를 하고자 합니다.

The Game

이러한 기능들이 INVERSUS에 어떻게 동작 하는지 이야기 하기 위한 롤백 시스템의 요구사항 입니다.

  • 이 게임의 인풋과 시뮬레이션은 60fps로 동작합니다. 30fps게임과 비교하면 인풋데이터를 2배로 전송해야 합니다.
  • 4명의 플레이어가 한번에 플레이 할 수 있고, 다수의 플레이어가 하나의 머신에서 플레이 할 수도 있습니다.
  • 플레이어는 아날로그 스틱으로 이동하고 개별 버튼으로 4개의 방향으로 발사할 수 있습니다. 한번에 하나의 방향으로만 발사 할 수 있고, 버튼을 누르고 있으면 차지샷을 날릴 수 있습니다.
  • 인디 게임으로서 플레이어 수가 적을 것을 고려하여 매치메이킹은 단순하게 하고자 합니다. 플레이어간에 서버가 패킷을 중계할 것 입니다. 20프레임의 인풋을 예측하고 롤백 할 수 있습니다. 따라서 단방향으로 300ms 레이턴시를 지원합니다.

상태분리

상태를 저장하고 복원하는 것은 프로그래밍 언어와 게임 엔진에 크게 영향을 받습니다. INVERSUS는 C++로 작성된 자체 엔진을 사용하기 때문에 모든 것을 컨트롤 할 수 있습니다.
게임 상태를 나타내는 데이터는 연속적인 메모리 블럭에 저장되기때문에, 상태를 저장하는 것은 적당한 크기의 메모리 복사에 지나지 않습니다. 복원하는 것도 마찬가지 입니다. 이 작업을 안전하게 수행하기 위해 게임 시뮬레이션을 다른 코드에서 분리했습니다.

  • 논게임스테이트는 동적인 메모리를 가리키지 않도록 합니다. 예를 들면, 외부 코드가 전체 라운드동안 존재하는 게임매니저를 가리키는 것은 괜찮지만, 언제든지 나타나고 사라질 수 있는 발사체를 참조하는 것은 안됩니다.
  • 게임스테이트는 라운드 안에 사라질 수 있는 논게임스테이트를 참조하지 않습니다. 예를 들면, 텍스쳐는 라운드 중간에 unload되지 않기 때문에, 텍스쳐를 참조하는 것에 대해 주의할 필요는 없습니다.

처음에 어플리케이션의 메모리풀에서 1MB의 버퍼를 할당받습니다. 이 메모리는 저장하고 복원하는데 사용됩니다. 이 버퍼를 sub allocator(game allocator)로 감싸서 필요한 게임스테이트를 할당하는데 사용합니다. game scene은 버퍼의 윗부분입니다. 이것은 모든 게임스테이트의 root레벨 wrapper입니다.

struct tGameScene
{
	tEntitySimMgr m_entitySimMgr;
	tParticleMgr m_particleMgr;
	tSoundScene m_soundScene;
	tTieredPoolAllocator m_soundHeap;
	tSizedPoolAllocator m_soundPools[4];
}

위 예제에서 보다시피 game scene은 entities, particles, sounds 세 파트로 이루어져 있습니다. 각 파트는 게임 allocator로 초기화 되었고, 할당 과정에서 1MB버퍼만을 알고 있습니다.

Entity Data: 엔티티 시뮬레이션 매니져 게임 object를 할당하기 위한 메모리 풀의 wrapper입니다. 게임 object는 발사체, 플레이어의 배(ship), pickups, game board 같은 것들 입니다.

Particle Data: 파티클 매니져는 폭발이나 맵에서 튕겨다니는 조각들과 같은 비주얼 이펙트를 추적합니다. 이것들은 게임을 꾸미는 목적만 있고 게임 플레이에 영향을 주지 않기 때문에 게임스테이트 바깥쪽에 두었습니만 이렇게 하면 더 많은 문제를 만들어냅니다.

예를들면, 배가 100프레임에 폭발하고 시뮬레이션은 현재 103 프레임이 있다고 가정합니다. 네트워크 시스템이 98프레임으로 롤백하기로 결정하고 100프레임까지 폭발을 재 실행하면 논게임스테이트인 파티클 매니져는 두번의 폭발을 실행하게 됩니다. ID를 발행하게되고 두 폭발이 같은 것인지 체크하게 됩니다. 당신은 이 정보를 중복을 피하기 위해 사용하게되고, 이른 종료가 필요한지 검출하게 됩니다. INVERSUS에서는 파티클 로직을 게임스테이트에 두었습니다.

Sound Data: 사운드 scene은 게임플레이와 결합되어 있는 모든 사운드를 추적합니다. 사운드 asset 데이터가 아니라, 볼륨이나 커서 타임과 같은 런타임 데이터 입니다.

정확한 게임스테이트의 재생성을 위해 많은 데이터가 이 1MB안에서 낭비됩니다만, 단순함을 위해 낭비할 가치가 있습니다. 백업을 위한 메모리 복사는 안정적입니다. 게임 스테이트에서는 함께, 추가, 제거, 순서조정을 할 수 있고 아무 문제도 일으키지 않습니다.

Debug State

릴리즈빌드에서는 문제가 없었지만, 디버그 빌드에서는 해결해야 할 문제가 있었습니다. 게임내의 메모리 풀을 볼 수 있는 메모리 검사 시스템이 있습니다. 하이 워터마크를 보여주고 메모리 릭을 체크합니다. 모든 allocator(게임스테이트에 있는 것도)는 시스템에 등록되고 추적됩니다. 이것은 디버그 allocation 데이터가 잘못되었기 때문에 이전 게임스테이트를 복원할때 문제가 발생합니다.

이 문제를 해결하려면 모든 게임 상태 할당 데이터를 버퍼에 직렬화/역직렬화할 수 있는 일부 기능을 메모리 시스템에 추가해야 했습니다. 메모리 검사는 할당자와 하위 할당자의 트리로 추적되므로 게임 할당자를 직렬화할 루트 요소로 사용할 수 있습니다.

Deterministic Input

인풋은 UI와 같은 것들과는 다르게 분리되어서 게임 시뮬레이션에 제공됩니다. 인풋데이터가 게임에 제공되기 전에 네트워크로 전송되는 데이터와 같이 불필요한 데이터를 제거하고 표준화 합니다. 로컬머신과 원격머신이 시뮬레이션하는 방식이 모두 deterministic하게 유지됩니다. 온라인과 로컬이 동일한 수준의 인풋 정확도로 느껴지게 됩니다.
각 플레이어의 인풋은 아래 구조체로 관리되고 추적됩니다. 인풋 히스토리는 롤백을 위해 배열형태로 저장됩니다.

enum tShipFireDir
{
    ShipFireDir_None,
    ShipFireDir_Left,
    ShipFireDir_Right,
    ShipFireDir_Up,
    ShipFireDir_Down,

    ShipFireDir_MAX,
};

struct tPlayerInput
{
    tU8 m_fireDir;   // set to tShipFireDir enumeration
    tU8 m_thrustAng; // quantized representation of angle [0,255] -> [0, 2pi-2pi/256]
    tU8 m_thrustMag; // quantized representation of magnitude [0,255] -> [0,1]
};

Input 예측

원격 머신에서 원격 플레이어의 인풋을 기다리고 있는 동안에도, 시뮬레이션은 로컬 플레이어의 인풋으로 돌아가고 있습니다. 기다리는 동안 롤백 프로세스의 비주얼 pop이 줄어들기를 바라면서 원격 플레이어 예측을 수행합니다. INVESUS에서 예측은 각 플레이어의 마지막 인풋을 반복해서 적용하는 것 입니다. 발사 인풋에 대해서는 괜찮은 방법이지만, 이동 인풋에 대해서는 더 좋은 방법이 있습니다. 최근의 히스토리에서 스플라인을 생성해 내면 가속에 도움이 될 수 있습니다.

Input 통신

INVERSUS는 UDP를 사용하기 때문에, 패킷 유실에 대비 해야 합니다. 인풋 패킷은 송신자가 받아야 할 다음 프레임도 알려줍니다. 이런 과거 인풋에 대한 확인 작업은 인풋을 더 보내는데 기준점이 됩니다. 기준점에서 현재의 프레임까지의 모든 인풋 데이터를 항상 보내는 것은 패킷 유실의 문제를 줄여 줍니다. 이것은 조금 오버가 될 수도 있지만(특히 먼 거리와의 통신에서), 패킷 유실 시에 별도의 로직을 타는 것보다는 구현을 더 간단하게 해줍니다.
레이턴시가 큰 상황에서 20 프레임의 데이터를 보낼 수도 있기 때문에, 대역폭을 절약하기 위해 데이터는 압축되어서 보내집니다. 보통은 델타 압축을 사용하게 됩니다. 인풋 패킷은 아래와 같습니다.

24 bits: 베이스 프레임(기준점)
 8 bits: 받아야 하는 프레임(기준점에서 상대적 숫자)
 8 bits: 보내는 첫번째 프레임(기준점에서 상대적 숫자)
 8 bits: 보내는 마지막 프레임(기준점에서 상대적 숫자)

모든 프레임에 대해
  로컬 플레이어에 대해
    If 발사 방향이 변경되면:
      1 bit: one
      3 bits: 발사방향
    Else:
      1 bit: zero
    
    If 이동 각도가 변경되면:
      1 bit: one
      8 bits: 변경된 각도
    Else:
      1 bit: zero

    If 이동량(속도?)이 변경되면:
      1 bit: one
      8 bits: 변경된 크기
    Else:
      1 bit: zero

프레임은 더 압축될 수도 있지만(8비트가 필요하지는 않고, 셋중 하나는 언제나 0) 이정도면 잘 동작합니다.

메세지를 60hz로 보내지만 peer로 부터 패킷을 한동안 받지 못했을때를 위한 스로틀링 시스템이 있습니다. 이것은 네트워크 지연이 있거나 데이터를 따라잡지 못해 메세지가 범람하는 것을 막아줍니다. 또한 스로틀링 시스템은 네트워크 지연이 있어서 모두에게 메세지를 그만 받고 싶을때도 알려줍니다. 이것은 디버깅시에 유용합니다. 실제 구현은 1초동안 메세지를 받지 못하면, 메세지 전송을 1초당 4번으로 떨어뜨립니다.

Rolling State Buffers

인풋 예측이 잘못되었을때, 해당 프레임의 이전 프레임으로 롤백을 수행해야 합니다. 게임의 종류에따라 다양한 방법이 있습니다. 링버퍼를 만들어서 백업을 할 수도 있습니다. 이 방식의 가장 큰 문제점은 오래된 잘못된 예측으로 인해 많은 프레임을 다시 시뮬레이션해야 하는 경우, 수정된 중간 프레임도 모두 백업해야 한다는 점입니다.

INVERSUS에서는 두개의 백업된 게임스테이트만을 추적하고, 20프레임이 지나면 새 프레임으로 가장 오래된 프레임을 덮어씁니다. 인풋 수정이 필요할 경우에 가장 가까운 이전 백업을 복원하고 수정된 프레임까지 시뮬레이션을 진행 합니다. 시뮬레이션이 진행되는 동안 가장 최근의 프레임에서 수신된 인풋들을 체크합니다. 이 위치 이전까지 다시 복원하고 오래된 백업을 업데이트하지 않아도 됩니다. 이렇게 하면 향후 수정에 대해 계산해야 하는 프레임수가 줄어듭니다.

이 구현은 대규모의 잘못된 예측이 발생하면 CPU 스파이크가 발생합니다. 일반적으로 각 프레임마다 최악의 시나리오를 실행하고 예측가능한 부분을 최적화 함으로써 이런 현상을 회피합니다. 예를들면, 간단한 시스템은 20프레임이 지난 하나의 백업만을 저장합니다. 해당 프레임으로 복원하는 모든 프레임은 백업을 1 프레임 진행하고 19프레임을 시뮬레이션 합니다. 왜 이것을 하지 않았는가? INVERSUS의 네트워크화를 나중에 결정했고 항상 안전한 결정을 하려고 했습니다. 구형의 PC도 지원해야 하기 때문에 퍼포먼스를 우려했습니다. 최적화를 빡세게 수행하면 어떤 머신에서도 잘 돌아갈거라고 생각하지만 그 당시에는 리스크가 큰 잘못된 결정을 했습니다. 돌이켜보면 거의 잘못된 결정이었고 가능한 간단한 구현을 시도했어야 했습니다.

Frame Advantage

어떤 클라이언트는 한 프레임을 수행하는데 더 오랜 시간이 걸릴 수 있습니다. 이는 PC가 느려서 오래된 잘못된 예측을 수정해야 해서 일 수도 있고, OS에서 무언가를 수행하고 있어서 일 수도 있습니다. 이런 일이 발생하면 60hz 프레임을 충족하지 못해서 다른 peer에 비해 1 tick이상 뒤쳐질 수 있습니다. 이것은 경쟁 우위를 만듭니다.

느린 머신을 사라라고 부르고 빠른 머신을 프레드라고 합시다. 프레드가 사라보다 몇 프레임 앞서가면, 프레드는 인풋 예측을 해서 델타를 덮게 됩니다. 지연이 계속되는 한 프레드는 사라의 진짜 인풋을 볼 수 없습니다. 사라 입장에서는 프레드는 현재 프레임을 앞서나가고 있기때문에 모든 인풋이 제때에 도착합니다. 결론적으로 사라는, 프레드가 사라의 인풋에 대응하기 전에, 프레드의 모든 인풋에 대응할 수 있습니다. 사라가 게임에서 유리하기 때문에 수정이 필요합니다.

우리는 인풋 메세지를 기반으로 원격으로 처리될 다음 프레임(마지막 인풋 프레임의 바로 전 프레임)과 그들이 우리에게 요구하는 다음 프레임을 알고 있습니다. 인풋 메세지를 받을때마다 원격 머신에서 예측될 프레임 수를 계산할 수 있습니다.

remoteFrameLag = nextRemoteFrameToReceive - nextLocalFrameRequestedByPeer;

우리가 인풋 메세지를 보낼때마다, 우리의 머신에서 예측할 프레임 수를 계산할 수 있습니다.

localFrameLag = nextLocalFrameToSimulate - nextRemoteFrameToReceive;

완벽한 월드를 가정한다면 이 두 숫자는 같고, 프레임 어드밴테이지는 없습니다. 같지 않다면, 더 빠른 머신에게 의도적으로 멈추고 간격을 줄이도록 지시할 수 있습니다. 최종 목표는 우리가 프레임을 드랍하면 다른 사람들도 프레임을 드랍하는 것 입니다.

다른 피어가 가지고 있는 인풋 프레임 어드밴테이지는 remote lag 과 local lag의 차이 입니다. 예를 들어 어떤 피어가 나를 1 프레임 lag으로 보고, 나는 그를 5프레임 lag으로 보고 있으면 그 피어가 4 프레임의 어드밴테이지를 가지는 것 입니다(그 피어가 나보다 유리한 상황). 빠른 머신이 의도적으로 1 프레임 멈추면 인풋 lag을 1 줄이고, 느린 머신의 인풋 lag이 1 늘어납니다. 즉, 시뮬레이션 프레임 어드밴테이지를 1조정하면 인풋 프레임 어드밴테이지가 2만큼 조정됩니다.

얼마나 많은 프레임이 수정되야하는지 판단하기 위해서는 모든 원격 플레이어가 고려되어야 합니다. INVERSUS에서는 각각의 원격 플레이어가 로컬 플레이어에 대해 가지는 어드밴테이지를 계산해서 가장 큰값을 씁니다.

bestInputFrameAdvantage = 0

for (each player)
{
    if (player is remote)
    {
        inputFrameAdvantage = localFrameLag - remoteFrameLag; // advantage they have over us
        bestInputFrameAdvantage = Max(bestInputFrameAdvantage, inputFrameAdvantage);
    }
}

Advantage Smoothing

네트워크 상황이 요동치면 프레임 어드밴테이지 계산도 요동칩니다. 어드밴테이지에 바로바로 반응하면 전체 게임이 느려지고 프레임 드랍도 심해지는데, 장기적으로 보았을때 불필요합니다. 이에 대응하기 위해, 매 100 프레임(경험적으로 선택한 숫자)의 평균 어드밴테이지 프레임을 구합니다. 100 프레임 동안 원격과 로컬 lag 숫자를 누적합니다. 100번째 프레임에서 적당한 숫자로 나누어서 평균을 구한 후, 빼기연산으로 float형의 프레임 어드밴테이지를 구합니다.

Sub-Frame Advantages

대략 1 프레임 어드밴테이지 수정은 어렵다는 이야기

어플리케이션 지연

이제 프레임도 구했으니 그냥 기다리면 될까요? 그렇지 않습니다. 프로세스를 숨기기위해 멈출 프레임을 저장하고 시간을 나누어서 분산시켜야 합니다. 멈추어야하는 프레임이 증가할수록, 케이던스도 증가 합니다. 이렇게 하면 일반적인 경우에 프레임 드랍이 눈에 띄지 않게 유지하면서 큰 수정이 필요할 때 응답성을 유지하는 데 도움이 됩니다.

제 경험에서 나온 다음과 같은 방식으로 매 프레임 케이던스를 조정합니다. 케이던스가 지속적으로 변경되기 때문에 지연된 프레임이 더 처리될 수록 확산됩니다. 코드를 더 간단하게 만들수도 있기는 하지만,이렇게 함으로써 다른 설정을 시도해 보기가 더 쉬워집니다(항상 선형적이지는 않습니다).

// 스킵될 프레임수를 기반으로 게임을 얼마나 자주 멈출것인가를 계산
tU32 distributeFramePeriod = 1;
switch (m_stallFrameQueue)
{
	case 1: distributeFramePeriod = 10; break;
	case 2: distributeFramePeriod = 9; break;
	case 3: distributeFramePeriod = 8; break;
	case 4: distributeFramePeriod = 7; break;
	case 5: distributeFramePeriod = 6; break;
	case 6: distributeFramePeriod = 5; break;
	case 7: distributeFramePeriod = 4; break;
	case 8: distributeFramePeriod = 3; break;
	case 9: distributeFramePeriod = 2; break;
	default: distributeFramePeriod = 1; break;
}

Startup Advantage

프레임 어드밴테이지는 게임의 시작시점에서도 나타날 수 있습니다. 호스트 머신이 게임을 시작하기로 결정하고 다른 머신에게 시작하라고 보내면, 호스트는 다른 머신에 비해 앞서있습니다. 그대로 두면 모든 게임이 프레임 어드밴테이지를 가지고 시작하게되고 호스트는 수정하기 위해 프레임 지연을 시켜야 합니다.

이런 문제를 최소화하기위해 INVERSUS는 모든 구성원 간에 핑타임을 교환합니다. 모든 피어가 준비가 되었다는 알림을 받으면, 핑타임을 사용해서 모든 멤버가 준비완료 신호를 받는데 얼마나 걸릴지 알 수 있습니다.

// 게임 시작까지 걸릴 시간을 계산
tS32 memberDelays[ RJ_ARRAY_LENGTH(m_members) ] = {};
for (tU32 targetIdx = 0; targetIdx < m_activeGameSettings.m_memberCount; ++targetIdx)
{
    for (tU32 peerIdx = 0; peerIdx < m_activeGameSettings.m_memberCount; ++peerIdx)
    {
        if (peerIdx != targetIdx)
        {
            tGameMember* pPeerMember = m_members+peerIdx;

            // time for message to be sent from owner to peer and then to target
            // note: we divide by 2 because the ping value is a round trip time
            tS32 delayThroughPeer 
		= (pOwnerMember->m_peerPings[peerIdx] + pPeerMember->m_peerPings[targetIdx])/2;
            memberDelays[targetIdx] = Max(memberDelays[targetIdx], delayThroughPeer);
        }
    }
}

가장 지연된 피어가 언제 게임을 시작할 수 있을지를 알아내고, 로컬 머신과의 차이를 계산하면, 얼마나 기다려야 하는지 알아낼 수 있습니다.
// wait for the delta of time we expect it to take for all peers to start the game after us
tS32 startGameDelay = 0;
for (tU32 i = 0; i < m_activeGameSettings.m_memberCount; ++i)
{
    startGameDelay = Max( startGameDelay, memberDelays[i] - memberDelays[localMemberIdx] );
}

게임 시작전에 짧은 시간을 둠으로써 인풋이 네트워크로 연결되고 프레임 어드밴테이지를 부드럽게 해소 할 수도 있지만 그렇게까지는 하지 않았습니다. 추정값만으로도 충분한 효과가 있었고, 게임 시작시에 어드밴테이지 수정을 알아차리는 경우는 거의 없습니다.

Dynamic Input Latency

레이턴시가 높은 환경을 개선하는데 쓸수 있는 팁이 하나 더 있습니다. 레이턴시가 증가함에 따라 원격 플레이어는 rollback correction을 보게됩니다. 총알이 좀더 해당 플레이어 쪽으로 전진되어 스폰되고 방향전환도 휙휙 나타납니다. 증상이 점점 심해지면, 로컬 컨트롤을 느리게하는 방식으로 완화할 수 있습니다.

로컬인풋 몇개를 시뮬레이션에 반영하기 전에 버퍼링한다고 가정해봅시다. 하지만 네트워크로 전송은 바로바로 합니다. 다른 피어의 관점에서는 달라진게 없습니다만, 로컬에서는 두가지 변경이 있습니다. 단점으로는 게임이 더 답답하게 느껴지고, 장점으로는 인식되는 네트워크 레이턴시가 감소했습니다. 네트워크 전송시간을 감출수 있다면, 네트워크 게임이 로컬 게임처럼 매끄럽게 느껴질 것 입니다.

이것이 가능하려면 게임에 최소 입력 대기 시간이 있어야 합니다. INVERSUS 프레임 로직의 순서는 이것을 위해 정의되어 있습니다. 그래픽 드라이버가 커맨드를 버퍼링 하지 않는다는 가정도 있어야 합니다.

이 로컬 프레임 지연을 옵션으로 제공할 수 도 있지만, OS의 제한을 완화하거나, 장애가 있는 플레이어를 지원하는 목적이 아니면 제공하지 않으려고 합니다. 다른 것은 의도한 경험에 대한 비전 부족이나 솔루션 자동화 실패를 나타내는 경우가 많습니다. 기술적인 설정을 노출하는 대신 컨트롤과 시각적 충실도의 균형 잡힌 경험을 만드는 값을 주기적으로 다시 계산합니다.

네트워크 상황이 좋으면 모든 로컬 입력 지연을 제거하고 몇 프레임의 remote lag을 허용합니다. 네트워크가 느려지면 로컬 입력 지연을 추가함으로써 remote lag이 늘어나는 것을 제한 합니다. 로컬 입력 지연이 4프레임을 넘으면 플레이어 경험이 나빠질 수 있기 때문에 더이상은 늘리지 않습니다. 4도 많은 것이지만, 장거리 연결의 경우에만 더 높입니다.

제 경험에서 온 조정된 스테이트 머신은 다음과 같이 동작합니다. 현재 local lag 설정과 피어중에 가장 나쁜 remote lag(100프레임 평균)으로 새 설정을 구할 수 있습니다.

//******************************************************************************
// Compute the new input lag to use based on the current lag. This is
// implemented as state machine to prevent quick toggling back and forth.
//******************************************************************************
static tU32 CalcInputLagFrames(tU32 curInputLagFrames, tF32 worstPeerFrameLag)
{
    tU32 newInputLagFrames = curInputLagFrames;
    if (!RJ_GET_DEBUG_BOOL(DisableNetInputLag))
    {
        // increase lag
        if (curInputLagFrames <= 0 && worstPeerFrameLag >= 2.0f)
            newInputLagFrames = 1;

        if (curInputLagFrames <= 1 && worstPeerFrameLag >= 3.0f)
            newInputLagFrames = 2;

        if (curInputLagFrames <= 2 && worstPeerFrameLag >= 6.0f)
            newInputLagFrames = 3;

        if (curInputLagFrames <= 3 && worstPeerFrameLag >= 8.0f)
            newInputLagFrames = 4;

        // reduce lag
        if (curInputLagFrames >= 4 && worstPeerFrameLag <= 7.0f)
            newInputLagFrames = 3;

        if (curInputLagFrames >= 3 && worstPeerFrameLag <= 5.0f)
            newInputLagFrames = 2;

        if (curInputLagFrames >= 2 && worstPeerFrameLag <= 2.0f)
            newInputLagFrames = 1;

        if (curInputLagFrames >= 1 && worstPeerFrameLag <= 1.0f)
            newInputLagFrames = 0;
    }
    else
    {
        newInputLagFrames = 0;
    }

    RJ_ASSERT( newInputLagFrames <= c_MaxInputLagFrames );
    return newInputLagFrames;
}

local lag 설정이 감소하면 시뮬레이션은 대기 중인 인풋을 즉시 처리해야 합니다. 증가하면 lag를 늘려서 프레임 어드밴테이지를 처리하는 것처럼 지연 시켜야 합니다.

사용자 핑 값을 기반으로 "게임 시작 지연"을 예측하는 것과 유사하게 초기 입력 지연을 예측할 수 있습니다. 다음 예측 코드에서 m_logicInterval은 시뮬레이션 프레임의 지속 시간(ms)입니다(예: 60fps의 경우 16667).

tU32 worstPingMsecs = 0;

for (tU32 i = 0; i < m_activeGameSettings.m_memberCount; ++i)
    worstPingMsecs = Max(worstPingMsecs, m_members[i].m_localPing);

tF32 roundTripLagFrames = (tF32)(worstPingMsecs*c_Usecs_Per_Msec) / (tF32)m_logicInterval;

m_inputLagFrames = CalcInputLagFrames(0, 0.5f*roundTripLagFrames); // divides by two to only consider a one way trip

Network Starvation

네트워크 상황이 정말 나빠지거나 원격 클라이언트의 연결이 끊어지면 시뮬레이션을 중지하고 입력을 기다려야 합니다. 여기서 얼마나 기다려야 하는지는 허용된 복원 프레임 수과 연관이 있습니다. INVERSUS는 최소 20프레임 백업이 있기때문에, 20프레임 동안 원격 입력을 받지 못하면, 화면에 로딩 스피너를 띄우고 입력을 기다리거나, 연결을 끊도록 했습니다.

Audio Segregation

롤백 수정의 시각적 증상은 대부분 무시할 수 있을 정도이지만, 오디오 증상은 더 좋지 않습니다.

Desync Detection

최적의 경우 게임은 모든 클라이언트 간에 완벽한 동기화를 유지하겠지만, 동기화를 깨뜨리는 버그가 있는 경우에는 어떻게 해야 할까요? 클라이언트 중 하나에 하드웨어 문제가 있어서 non-deterministic을 초래할 수 있는, 무효화된 메모리를 읽기를 시도 할 수도 있습니다. INVERSUS는 이러한 시나리오를 감지하면 자동으로 게임을 종료합니다. 이 기능은 개발 중 버그를 감지하는 데 매우 유용했습니다. 시뮬레이션에 문제를 발견한 적은 없었지만, 롤백 시스템에 오류를 수정한 적은 있습니다.

500프레임마다 모든 클라이언트에서 플레이어의 위치(부동 소수점)가 잘 동기화되어있는지 체크를 합니다. 실제 인풋을 받아야 동기화를 체크할 수 있기때문에, 이 프로세스는 생각보다는 복잡했습니다. 예를 들어, 2497 이후의 프레임을 아직 보지 못했는데, 피어가 2500 프레임의 데이터를 보낼 수 있습니다. 이를 관리하기위해, 각 피어의 최근 validation 데이터와 로컬 머신의 최근 데이터를 추적합니다. 모든 프레임의 로컬 머신 validation 데이터가 사용 가능한지 체크합니다(애초에 맞게 예측되었거나 롤백을 통해 수정됨). 데이터가 사용가능 해지면, 피어들에게 보냅니다. 모든 프레임에 대해 사용 가능 한것으로 보이면 수신된 피어 데이터와 비교합니다. 데이터가 일치하지 않으면 게임을 종료합니다.

Variable Frame Rates

이러한 규칙들이 동작하려면 게임이 일정한 프레임 rate로 시뮬레이션을 수행해야 합니다. INVERSUS는 콘솔에서는 60fps로 잘 동작하지만, PC에서는 보장할 수 없습니다. 모니터의 refresh rate가 업데이트 duration의 딱 떨어지는 배수가 아니면, 사용자는 프레임이 드랍될때 애니메이션이 끊기거나, 두번 렌더되는 것을 보게됩니다.

가변 refresh rate는 다양한 방법으로 해결할 수 있지만, 딱히 아주 좋은 해결책은 없습니다.

  • 가변 업데이트 스텝으로 시뮬레이션
    • 안정적이고 일관된 사용자 경험을 방해하는 많은 문제를 만들어 냅니다.
  • 부분 렌더링 업데이트
    • refresh rate와 입력 레이턴시가 서로 달라서 입력 대기 시간이 0과 1 프레임 사이에서 순환하는 공통 문제가 있습니다.
    • 어떤 게임은 이전 프레임과 새 프레임 사이의 결과를 보간하려고 합니다. 이 방법의 가장 나쁜 점은 허용할 수 없는 입력 대기 시간의 추가 프레임을 도입한다는 것입니다. 또한 모션의 불연속성을 표시하는 데 문제가 있으며 엔지니어에게 번거로운 제약을 추가하는 혼합 가능한 표현으로 게임 시뮬레이션을 출력해야 합니다.
    • 또 어떤 게임은 렌더링 전에 서브프레임 모션을 extrapolate하려고 합니다. 플레이어가 폭발하기 전에 프레임에 대해 벽을 통해 extrapolate하는 총알과 같은 아티팩트를 볼 수도 있습니다. 이 문제를 해결하면 엔지니어가 걱정해야 하는 복잡한 extrapolate코드와 더 성가신 종속성 및 제약 조건이 생성됩니다.
    • 또 다른 옵션은 입력이 폴링되는 것보다 더 빠른 간격으로 시뮬레이션하는 것입니다. 예를 들어 16.666밀리초(60fps)마다 입력을 폴링할 수 있지만 2.083밀리초(480fps)마다 시뮬레이션할 수 있습니다. 우리는 여전히 네트워크 입력의 60hz 케이던스를 기반으로 하는 결정적 출력을 가지고 있지만 시뮬레이션 속도에 매핑되는 훨씬 더 큰 재생 빈도 집합도 있습니다. 또한 새로 고침 빈도가 완벽하게 일치하지 않을 경우 시간 오차가 낮아 시각적인 끊김 현상이 최소화됩니다. 이 방법은 충분히 빠르게 시뮬레이션할 수 있다면 다소 매력적이지만 모든 잠재적 재생 빈도를 지원할 수는 없습니다.

INVERSUS는 게임 상태 롤백 시스템을 활용하는 부분 업데이트 방식을 사용하여 이 문제를 해결합니다. 게임 로직이나 렌더 상태에 추가적인 복잡성을 추가하지 않고도 정확한 서브프레임 extrapolation을 생성합니다. 게임이 임의의 시간 델타로 업데이트되도록 지시할 수 있다고 가정하면 refresh rate가 시뮬레이션 rate와 다를 때 다음을 수행할 수 있습니다.
1. 현재 60hz 시뮬레이션 상태를 백업
2. extrapolation된 프레임에서 오디오를 생성할 수 없게 모든 오디오 요청을 억제하도록 사운드 scene에 지시
3. 가장 최근의 60hz 프레임과 실제 렌더링 시간 사이에 얼마나 많은 시간이 경과했는지 시뮬레이션을 진행합니다. 이것은 가장 최근의 시뮬레이션 프레임의 인풋 데이터를 사용합니다.
4. 사운드 씬에 억제를 중지하라고 지시
5. 현재 extrapolation 상태를 렌더링
6. 60hz 시뮬레이션 상태 복원

이 결과는 완벽하지는 않지만 충분합니다. 네트워크 예측과 마찬가지로 extrapolate 중에 실제로 발생하지 않아 롤백되는 일이 발생할 수 있습니다. 모든 소리를 억제하면 충분히 잘 작동하지만 추가 개선을 위해 폭발과 같은 큰 시각적 이벤트를 억제하는 것도 가능합니다.

Debug Inspection

네트워크 시스템을 디버깅하기위해, 실시간 디스플레이와 방대한 로깅을 사용합니다.

화면에 텍스트를 표시되는 것은 변수들을 조정하는데 유용합니다. 각 플레이어에 전송되는 데이터의 양에 대한 정보와 함께 지연 및 입력 프레임 어드밴테이지의 평균과 값 범위를 표시합니다.. 또한 로컬 입력 지연이 현재 네트워킹 상황에 어떻게 반응하는지도 보여줍니다.

롤백 시스템과 deterministic 시스템을 디버그해야 할 때, 네트워크 상세 로깅을 사용했습니다. 로깅은 전송 및 수신된 입력, 프레임이 백업 및 복원된 시기와 이유, 각 프레임에서 사용된 입력에 대한 거대한 출력 파일을 생성합니다. 동기화 문제가 감지되면 먼저 감지율을 500프레임마다에서 10프레임마다로 높여 정확히 언제 발생했는지 더 쉽게 확인합니다. 그런 다음 모든 클라이언트에서 로깅을 활성화하고 디버그 파일을 생성합니다. 파일을 나란히 로드하면서 달라진 부분을 찾을 수 있었습니다. 다행히 이 재미없는 작업을 자주 할 필요는 없었습니다.

결론

INVERSUS는 게임 플레이 네트워킹에 문제없이 출시했으며, 플레이어가 "netcode"가 얼마나 원활한지 자주 언급하는 게임입니다. 넷플레이가 게임에 늦게 추가된것을 고려하면, 큰 성공이라고 생각합니다. 게임을 계속 업데이트하면서도 제가 구현한 가장 간단한 네트워크 아키텍처이기도 했습니다. 게임 로직과의 강력한 분리로 인해 네트워크 기능을 추가하는 것은 간단합니다. 유일한 고려 사항은 어떤 오디오 방식을 사용해야 하는지 입니다. 온라인 해킹을 대비하기에도 좋은 시스템 입니다. 모든 결과는 피어의 검증을 받으며, 부정 행위를 할 수 있는 유일한 방법은 규칙에 따라 실행되는 게임용 봇을 작성하는 것입니다.

네트워킹 구현은 INVERSUS가 구축된 맞춤형 기술에 대한 검증의 강력한 점이기도 합니다. 시스템을 단순하게 유지하고, 메모리에 대한 모든 제어를 함으로써, 백업 및 복원 프로세스는 간단하고 강력합니다. 확인해보진 않았지만 요즘 상용 엔진에서는 이 프로세스가 더 어려울 것이라고 생각합니다.

짧은 라운드로 구성된 멀티플레이어 게임을 만들고자 한다고 롤백 네트워크를 사용하는 것을 강력하게 추천합니다.

profile
게임 개발자

0개의 댓글