소스엔진 멀티플레이어 네트워크 : 예측


https://developer.valvesoftware.com/wiki/Prediction


예측은 서버에서 확인 받을 필요 없이 클라이언트가 로컬 플레이어의 행동에 대한 영향을 예측하는 개념이다. 엔티티의 예측된 상태는 서버와 일치하거나 불일치가 감지될 때까지 서버 명령어를 테스트한다.

대다수의 경우는 클라이언트의 예측에서 서버와 일치하고 지연시간이 없는 것처럼 행복하게 플레이 할 수 있게 된다. 불일치가 있다면? 예측 코드가 정확하게 작성되었어도 희박하게 발생될 수 있는데 이때는 클라이언트를 틀린 지점으로 다시 옮겨 모든 명령어를 다시 시뮬레이트 한다. 물론 에러가 심각했다면 플레이어의 위치나 상태, 어쩌면 월드 상태까지 홱 움직이는 것 같은 눈에 띄는 문제가 발생될 수 있다.

예측은 렉 보정과 긴밀히 연결되지만 클라이언트에만 존재하므로 보간과는 별개로 취급된다.

Warning: 예측에서 항상 ‘두 값이 항상 동기화 될 것’이라고 생각해선 안된다. 패킷 손실로 인한 가능성이 있다!

Note: 이는 플레이어만 렉 보정을 거치므로 플레이어가 다른 엔티티를 때리고자 했다면 예측을 하지 않게 되어 지연시간으로 인해 조준에 방해받을 수 있다. 이런 이슈가 있다면 NPC에게 렉 보정 하는 것을 고려할 것. (Left 4 Dead 2에서는 선택된 prop_physics이 렉 보정 될 수 있음.)

구현

엔티티 예측을 위해선:

  1. 로컬 플레이어에서 조작할 수 있는 엔티티여야 한다. 그렇지 않고서야 굳이 예측할 필요가 있는가?
  2. 예측될 함수가 서버와 클라이언트에 서로 동일하게 존재해야 한다. 이는 공유 코드를 만들어 해결할 수 있다.
  3. 해당 엔티티에서 함수 ‘SetPredictionEligible(true)‘를 호출해야 한다. 정석적으로는 생성자에서 호출하는 것이 좋다.
  4. 클라이언트에 함수 ‘bool ShouldPredict()‘가 있어야 한다.
    로컬 플레이어가 이 엔티티를 들고 있는지 등을 여기서 확인할 것.
    Note: 무기도 클라이언트에 함수 bool IsPredicted()를 구현해야 하고 언제나 true를 반환하도록 해야 한다.
  5. 엔티티의 예측 테이블 내에 있는 변수들은 모두 네트워크에 연결하여 동기화 되어야 한다.

예측 테이블

예측된 플레이어 입력으로 인해 변경되는 모든 클라이언트 측의 변수는 예측 테이블에 있어야 한다. 여기의 3가지 동작 중 하나를 정할 수 있다.

  • FTYPEDESC_INSENDTABLE
    변수가 네트워크에 연결되었음을 의미, 클라이언트에서 예측한 값을 서버에서 전달받은 값에 테스트하고 동기화가 되지 않는다면 예측 에러가 생기게 된다.
  • FTYPEDESC_NOERRORCHECK
    변수의 네트워크 연결에 관계 없이, 예측된 값이 동기화 되지 않을 가능성이 있지만 예측 에러가 생기지 않도록 함.
  • FTYPEDESC_PRIVATE
    네트워크에 연결되지 않고 예측도 되지 않지만 여전히 cl_pdump로 관찰될 수 있음.

    Note: 이 타입의 변수들은 예측 시스템에서 새로운 서버 명령어를 테스트하고자 시간을 돌릴 때에 저장되거나 복원되지 않는다. 만약 예측 함수가 이 값을 증가시킨다면 새로운 테스트마다 계속 값이 증가된다는 말이다.

이런 행동들은 매크로 DEFINE_PRED_FIELD()와 DEFINE_PRED_FIELD_TOL()로 구현될 수 있다. 뒤에 TOL(TOLerance)가 붙은 매크로는 정수나 소수 자료형에 오차 범위를 부여하여 예측 에러가 생기지 않도록 하는데 주로 값이 전송되면서 반올림 되는 경우에 사용된다. 예를 들어, 소수의 경우는 1ms 해상도 (0.001)로 잘린다. (특히 소수의 경우는 이런 상황을 위한 #define TD_MSECTOLERANCE 상수가 있다. 이 외에는 정수로만 전달해야 한다.)

이런 상황에서 반올림된 변수를 보외할 때, 이런 전송된 값의 작은 차이로 연산 이후의 결과에 큰 차이가 생길 수 있음을 염두에 둬야 한다.

To do: DEFINE_PRED_TYPEDESCRIPTION

예제

#ifdef CLIENT_DLL
BEGIN_PREDICTION_DATA( CBaseCombatWeapon )
	DEFINE_PRED_FIELD( m_nNextThinkTick, FIELD_INTEGER, FTYPEDESC_INSENDTABLE ),
	DEFINE_PRED_FIELD( m_hOwner, FIELD_EHANDLE, FTYPEDESC_INSENDTABLE ),
	DEFINE_PRED_FIELD_TOL( m_flNextPrimaryAttack, FIELD_FLOAT, FTYPEDESC_INSENDTABLE, TD_MSECTOLERANCE ),	
END_PREDICTION_DATA()
#endif

예측되는 엔티티 만들기

표준 예측 시스템에서 이미 존재하는 엔티티의 상태를 유지할 수 있지만 새로운 엔티티의 경우는 CreatePredictedEntityByName()를 통해 새로 만들 수 있다. 클라이언트 상에서 엔티티를 만들고 서버에 도착하면 실제 엔티티랑 교체한다. 당연하게도 이는 공유 코드에서 호출해야 한다.

CBaseEntity::CreatePredictedEntityByName( char* classname, char* class_file, int line, bool persist = false )

여기서 나와있듯 클래스가 선언된 실제 코드 모듈의 파일 이름과 해당 라인을 쓸 수 있다.

확인 필요: 엔티티 클래스가 꼭 공유 코드에 선언되어야 하는가?

이 함수는 엔티티의 흔적을 남기는데 사용되며 변수 persist는 예측하는 동안 테스트하게 한다.

만약 예측되는 엔티티의 데이터 중에 서버로 보내지 않은 것이 있다면 실제 엔티티의 C_BaseEntity::OnPredictedEntityRemove()에 복사해야 한다.

도구 및 꼼수들

IsFirstTimePredicted()
prediction->IsFirstTimePredicted() 함수로 처음 예측할 때만 코드를 실행하도록 할 수 있다. 접근을 위해선 #include "prediction.h"를 선언할 것.

SharedRandom()
무작위 수를 생성하는데 사용할 수 있다. 시드는 usercmd의 수를 기반으로 사용하여 결과가 클라이언트와 서버 모두 동일하도록 한다.

CDisablePredictionFiltering
To Do

네트워크 데이터 억제
무기 이펙트 같은 중요하지 않은 이벤트는 전체적으로 클라이언트에서 수행될 수 있다. 이런 데이터를 억제하여 대역폭 사용량을 줄이는 것도 좋다.

IPredictionSystem::SuppressHostEvents()를 호출하여 인자의 플레이어에 대한 모든 네트워크 데이터 전송을 중지한다. 인자를 NULL로 하고 다시 호출하면 데이터 전송이 다시 시작된다. 예시:

if ( pPlayer->IsPredictingWeapons() )
	IPredictionSystem::SuppressHostEvents( pPlayer );

pWeapon->CreateEffects();

IPredictionSystem::SuppressHostEvents( NULL );

위의 코드대로 작성하고자 한다면 다른 곳에 플레이어의 클라이언트 이펙트를 생성하는 코드를 작성해야 한다.

샘플

예측된 정보를 콘솔에 출력하는 단순한 무기 예제가 있다. net_fakelag 200(또는 이상)을 설정하고 빠르게 발사하는 보조 발사를 사용하여 어떻게 엔진에서 여러 개의 예측된 탄환들을 다루는지 볼 수 있다.

Note: 플레이어 엔티티에서 뷰모델을 예측하지 않으면 렉 걸릴 때, 총이 불안하게 움직일 수 있다.

문제 해결

만약 무기에 기능을 추가했는데 위에 언급한 예측을 안넣었다면 무기가 홱 움직여버리거나 애니메이션이 이상하게 보일 수 있다. cl_predictionlistcl_pdump로 디버깅 할 수 있고 cl_pred_optimize를 변경하는 것도 도움될 수 있다.

이제 cl_pdump의 패널에서 가끔 붉게 되는 변수를 보면 이 변수의 클라이언트와 서버와 서로 다른 값을 가졌다는 것을 알 수 있다. 이 문제는 아래와 같은 문제 중 하나로 추적할 수 있다.

  1. 클라이언트와 서버의 코드 일부분이 서로 동일하지 않은 경우
    코드 내에 #ifdef GAME_DLL#ifndef CLIENT_DLL가 있다면 주변에 변수가 달라지게 되는 것이 있을 수 있다.
  2. 또는 데이터 테이블을 거치지 않고 전송되어 변수가 달라질 수 있다. (이러면 서버에서 애초에 전송하지도 않게 되므로) 이 경우에는 항상 값이 달라지게 된다.
  3. 또는 DEFINE_PRED_FIELD_TOL 매크로를 이용한 적절한 오차를 허용하는 것을 까먹어서 추가하지 않았어도 값이 달라질 수 있다. 만약 0.0 ~ 255.0의 범위를 가진 소수를 전송하고자 하고 정확도를 위해 4개의 비트만 사용했다면 이 경우에는 오차 범위를 17.0만큼 허용해야 한다. 허용하지 않은 경우에는 예측 시스템에서 압축된 4개의 비트 값으로만 확인하게 되어 데이터가 잘못되었다고 판단하게 된다.

이렇게 예측 문제들을 추적하는 것은 코드를 뒤져보며 붉게 되는 변수에 영향을 미치는 코드와 그 값에 영향을 미치는 변수를 확인하는 과정으로 처음에는 지루할 수 있으나 요령을 터득하고 나면 문제를 빠르게 추적할 수 있게 된다.

, ,

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다