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


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

이전에 번역한 Source Multiplayer Networking 문서의 관련 문서들을 번역하고 있다.
이번에도 우선 번역을 먼저하고 문장을 다듬거나 부연 설명이 필요한 것은 서서히 추가할 예정이다.


소스 엔진은 가상의 세계에 최대 255명의 플레이어를 동시에 수용할 수 있다. 플레이어 간의 월드 업데이트와 유저의 입력을 동기화하기 위해서 UDP/IP 네트워크 패킷 통신으로 소통하는 서버-클라이언트 아키텍쳐를 사용하고 서버는 그 중심에서 유저의 입력이나 게임과 물리 법칙에 따른 월드 업데이트를 서버에 연결된 모든 클라이언트에게 반복적으로 중계한다.

게임에서 논리적 또는 물리적인 개체를 ‘Entity'(엔티티)라고 부르는데 이는 소스코드에서 공유된 base entity class를 상속한 클래스들이다. 몇몇 엔티티는 서버에서만 존재(서버-사이드 엔티티)하고 또는 클라이언트에만 존재(클라이언트-사이드 엔티티)할 수 있다. 대부분의 경우는 양쪽에 존재한다. 엔진의 엔티티 네트워킹 시스템은 이런 오브젝트들을 동기화하여 모든 플레이어에게 같은 상태로 남을 수 있도록 한다.

네트워킹 코드는 아래와 같은 행동을 수행해야 한다.

  1. 서버 측 모든 엔티티의 변경을 감지할 것
  2. 이런 변경사항을 비트-스트림으로 변환(직렬화)하여
  3. 네트워크 패킷으로 전송
  4. 서버에서 변환된 비트-스트림을 패킷으로 받은 클라이언트에서 정보를 읽을 수 있도록 이를 다시 변환하여 나온 변경 정보를 바탕으로 클라이언트 측의 엔티티들을 업데이트
    Note: 클라이언트 측에 해당 엔티티가 없었으면 클라이언트 측에서 자동으로 새로 생성함

데이터 패킷은 오브젝트에 변경점이 생길 때마다 보내지는 것이 아니라 마지막 업데이트를 기점으로 이후에 생긴 모든 엔티티들의 변경점을 담은 스냅샷을 통해 보내진다. (보통 초당 20개씩 보낸다.) 그리고 네트워크 대역폭을 최소화하기 위해서 모든 엔티티들의 변경점을 클라이언트들에게 같은 때에게 보내지 않고 클라이언트가 흥미로워 할 부분(보거나 들을 수 있는 것들)을 높은 빈도로 갱신한다.

소스엔진의 서버는 최대 2048개의 네트워크 엔티티들을 처리할 수 있으며 각 엔티티에는 1024개의 각 다른 멤버 변수(배열의 각 원소를 포함)와 각 업데이트에 최대 (2048개의 아스키 문자를 담는)2KB의 직렬화된 비트-스트림 데이터를 담을 수 있다.

예제

아마 어렵고 복잡하게 처리해야겠다고 생각되겠지만 엔진 상에서 작업의 대부분 처리하여 모드 제작자가 네트워크 엔티티를 만드는 것은 꽤 쉽다. (단순한 엔티티라면 아마 아무것도 할 필요가 없을 것이다.)

SDK 내의 예를 보자면 m_iPing를 검색하면 네트워크에 연결되고 있는 배열이 보일거고 이게 코드에서 단 12곳 밖에 등장하지 않아 모든 부분을 찾는 것이 쉽다.

이 예제에서는 이게 얼마나 쉽게 이뤄지는지 확인할 수 있는데 여기서 대역폭 사용량을 줄인다고 최적화를 한다면 상당히 복잡해질 수 있다.

서버 사이드
class CMyEntity : public CBaseEntity
{
public:
	DECLARE_CLASS(CMyEntity, CBaseEntity );	// 범용 엔티티 클래스 매크로 
	DECLARE_SERVERCLASS();  // 서버-사이드 엔티티 선언: 이 엔티티가 서버와 네트워킹 될 수 있도록 함

	int UpdateTransmitState()	// 항상 모든 클라이언트에게 전송되도록 함
	{
		return SetTransmitState( FL_EDICT_ALWAYS );
	}

public:
	// 공용 네트워크 변수들:
	CNetworkVar( int, m_nMyInteger ); // 정수
	CNetworkVar( float, m_fMyFloat ); // 부동소수점
};

// 전역 엔티티 이름을 이 클래스에 연결 (해머 에디터 등에서 사용됨)
LINK_ENTITY_TO_CLASS( myentity, CMyEntity );

// 서버 데이터 테이블에서 네트워크 변수들을 명시하도록 함 (SendProps)
// 헤더에 작성하지 말고 메인 CPP 파일에 작성할 것!
IMPLEMENT_SERVERCLASS_ST( CMyEntity, DT_MyEntity )
	SendPropInt(	SENDINFO( m_nMyInteger ), 8, SPROP_UNSIGNED ),
	SendPropFloat( SENDINFO( m_fMyFloat ), 0, SPROP_NOSCALE),
END_SEND_TABLE()

// 게임 코드 어딘가
void SomewhereInYourGameCode()
{
	CreateEntityByName( "myentity" ); // 위에 적은 클래스의 엔티티를 생성
}
클라이언트 사이드
class C_MyEntity : public C_BaseEntity
{
public:
	DECLARE_CLASS( C_MyEntity, C_BaseEntity ); // 범용 엔티티 클래스 매크로 
	DECLARE_CLIENTCLASS(); // 클라이언트-사이드 엔티티 선언

public:
	// 서버 사이드 코드에서 선언된 네트워크 변수들
	int	m_nMyInteger;
	float	m_fMyFloat;
};

// 전역 엔티티 이름을 이 클래스에 연결 (해머 에디터 등에서 사용됨)
LINK_ENTITY_TO_CLASS( myentity, C_MyEntity );

// 앞서 선언했던 데이터 테이블 DT_MyEntity를 클라이언트 클래스와 선언한 변수에 연결 (RecvProps)
// 헤더에 작성하지 말고 메인 CPP 파일에 작성할 것!
IMPLEMENT_CLIENTCLASS_DT( C_MyEntity, DT_MyEntity, CMyEntity )
	RecvPropInt( RECVINFO( m_nMyInteger ) ),
	RecvPropFloat( RECVINFO( m_fMyFloat )),
END_RECV_TABLE()
네트워크에 엔티티 연결

서버 측의 엔티티와 클라이언트 측의 엔티티를 연결하는 것에는 몇 가지 단계가 있다. 첫 번째는 양쪽 C++ 클래스에 LINK_ENTITY_TO_CLASS() 매크로로 연결하는 것이다.

Note: 서버-사이드에서 구현한 엔티티의 이름은 CMyEntity, 클라이언트-사이드에서 구현한 엔티티의 이름은 C_MyEntity란 형식으로 이름을 설정해야 한다. 이론적으로는 어떤 이름을 사용하던 상관없지만 일부 Valve의 코드에서는 이런 관례에 따르고 있다고 가정하고 있다.

그런 다음, 서버에 해당 엔티티가 네트워크에 연결되어야 하고 클라이언트-사이드에 같은 클래스가 존재한다고 알리는 매크로 DECLARE_SERVERCLASS()를 명시해야 한다. 이 매크로는 전역 서버 클래스 목록에 해당 엔티티의 클래스를 등록하고 고유의 클래스 ID를 예약한다.
이것을 클래스 선언문(헤더 파일)에 넣었다면 그 다음에 클래스 구현문(CPP 파일) 안에 IMPLEMENT_SERVERCLASS_STEND_SEND_TABLE로 서버-사이드 엔티티 클래스와 SendTable을 등록한다. (다음 섹션에서 설명)
마지막으로 클라이언트-사이드에서도 선언문에 DECLARE_CLIENTCLASS(), 구현문에 IMPLEMENT_CLIENTCLASS_DT, END_RECV_TABLE()를 넣어 똑같이 한다.

클라이언트가 서버에 접속하면 서로 클래스 목록을 교환하고 만약 클라이언트 측에서 모든 서버-사이드 클래스를 구현하지 않았다면 “Client missing DT class <클래스 이름>" 메세지를 보내고 연결을 끊는다.

네트워크 변수

엔티티 클래스도 다른 클래스처럼 멤버 변수들을 가지지만 몇몇 멤버 변수는 서버-사이드에서만 다룰 수 있다. 더욱 흥미로운건 멤버변수들 중에 클라이언트에 값을 복사해야 하는 부류인데 근본적으로 위치나 각도, 체력 등 현재 상태를 표시해야만 하는 변수들은 반드시 네트워크에 연결되어 있어야 한다.

연결된 변수가 변경되었다면 그 항목을 다음 스냅샷 업데이트에 반영하기 위해 엔진에서 반드시 알아야 하는데 그러기 위해선 연결된 변수가 변경된 것에 알아차리기 위한 신호로써 내부 플래그가 FL_EDICT_CHANGED로 설정된 엔티티의 NetworkStateChanged() 함수가 호출되어야 한다. 그 후에 엔진에서 업데이트를 수행하고 나면 해당 플래그는 다시 지워진다. 그러면 멤버 변수마다 변경될 때마다 NetworkStateChanged() 호출해야 하는가? 그건 아니다. 이걸 위한 특별한 helper 매크로인 CNetworkVar가 원래 변수의 타입(int, float, bool 등)을 교체하고 엔티티에서 상태를 바꾸면서 해당 변수가 바뀌게 되면 자동으로 신호를 내보내도록 한다. 이 매크로는 VectorQAngle 클래스와 배열과 EHANDLES에도 있다. CNetworkVar를 사용하는 변수들의 쓰임은 변하지 않으므로 원래 사용하던 변수 타입대로 사용할 수 있다. (배열의 경우는 제외: 원소 변경 시 Set()GetForModify()를 사용해야 함) 아래의 예제는 멤버 변수 선언 시 타입별 CNetwork* 매크로를 사용하는 방법을 보여준다. 연결되지 않은 형태의 변수는 주석에 있다.

CNetworkVar( int, m_iMyInt );			// int m_iMyInt;
CNetworkVar( float, m_fMyFloat );		// float m_fMyFloat;
CNetworkVector( m_vecMyVector );  		// Vector m_vecMyVector;
CNetworkQAngle( m_angMyAngle );  		// QAngle m_angMyAngle;
CNetworkArray( int, m_iMyArray, 64 ); 		// int m_iMyArray[64];
CNetworkHandle( CBaseEntity, m_hMyEntity );	// EHANDLE m_hMyEntity;
CNetworkString( m_szMyString );  		// const char *m_szMyString;

Warning: CNetworkArray는 일반적인 구문처럼 할당할 수 없다. Set(slot,value)을 대신 사용해야 하고 추후 호환을 염두하여 반환에는 Get(slot)를 사용하는 것을 염두하라.

네트워크 데이터 테이블

엔티티가 변경된 것에 신호를 보내고 엔진이 이에 반응하여 스냅샷 업데이트를 만들게 되면 변경되는 변수들을 어떻게 비트-스트림으로 변환해야 하는지 알아야 한다. 물론 그저 엔티티 내의 멤버변수를 그대로 보낼 순 있겠지만 너무나 많은 데이터와 대역폭을 사용해야 해서 비효율적이다. 그러므로 각 엔티티 클래스에서는 데이터 테이블에 멤버 변수를 어떻게 인코딩 할 것인지 설명해야 하는데 이것을 ‘Send Tables’이라 부르고 고유한 이름(보통 DT_EntityClassName)을 부여해야 한다.

테이블 내의 항목인 SendProp은 한 멤버 변수의 인코딩 종류를 가진다. 소스 엔진에서 흔히 사용되는 자료형인 정수, 소수, Vector, 문자열 등의 다양한 데이터 인코딩을 제공하는데 SendProp은 여기서 사용되야 하는 비트의 수, 최소/최댓값, 인코딩 플래그와 Send 프록시 함수(추후 설명)를 저장한다.

SendProp는 직접 추가할 필요 없이 SendProp* helper 함수들(SendPropInt(),SendPropFloat() 등)을 통해 중요한 인코딩 속성을 한 라인에 추가할 수 있고 그 안에 쓰이는 SENDINFO 매크로는 멤버 변수의 사이즈와 해당 엔티티의 주소와의 상대적인 오프셋을 연산하는데 도움을 준다. 아래에 앞선 예제에서 선언한 네트워크에 연결한 변수들을 SendTable에 추가하는 예제가 있다.

IMPLEMENT_SERVERCLASS_ST(CMyClass, DT_MyClass)
   SendPropInt( SENDINFO(m_iMyInt), 4, SPROP_UNSIGNED ),
   SendPropFloat( SENDINFO(m_fMyFloat), -1, SPROP_COORD),
   SendPropVector( SENDINFO(m_vecMyVector), -1, SPROP_COORD ),		
   SendPropQAngles( SENDINFO(m_angMyAngle), 13, SPROP_CHANGES_OFTEN ),
   SendPropArray3( SENDINFO_ARRAY3(m_iMyArray), SendPropInt( SENDINFO_ARRAY(m_iMyArray), 10, SPROP_UNSIGNED ) ),
   SendPropEHandle( SENDINFO(m_hMyEntity)),
   SendPropString( SENDINFO(m_szMyString) ),
END_SEND_TABLE()

IMPLEMENT_SERVERCLASS_ST 매크로는 해당 엔티티가 상속한 클래스의 Send Table에 연결한다. 따라서 부모 클래스의 속성들은 이미 추가되어있다.

Tip: 부모 클래스의 속성을 물려받고 싶지 않다면 IMPLEMENT_SERVERCLASS_ST_NOBASE() 매크로를 대신 사용하거나 하나의 속성을 제거하고 싶다면 새로운 SendProp을 추가하는 대신 부모 클래스에서 물려받은 속성을 지우는 SendPropExclude()을 사용할 수 있다.

비트-스트림을 최적화 할 때 먼저 바라봐야 할 것은 전송에 사용해야 할 비트의 수(기본값: -1)일텐데 우선 변수가 사용될 범위를 파악해보자. 만약 정수가 0 ~ 15 사이의 숫자만 사용한다면 정수의 기본 크기인 32비트(플래그 SPROP_UNSIGNED를 사용하여 지정)가 아니라 4개의 비트만을 사용할 수 있다. 이 외에도 적절한 SendProps 플래그를 사용하여 최적화에 사용할 수 있다:

SPROP_UNSIGNED

  • 정수를 부호가 없는 정수로 인코딩 (부호 비트를 보내지 않음)

SPROP_COORD

  • 소수나 Vector 컴포넌트를 좌표로써 인코딩
    (데이터를 압축함, 0.0은 2개의 비트만 사용하고 그 외의 값은 21개까지 사용 가능)

SPROP_NOSCALE

  • 소수와 Vector의 비트 32개를 모두 사용하여 압축으로 인한 데이터 손실이 없게 함

SPROP_ROUNDDOWN

  • 높은 소수 값의 범위에서 비트를 하나를 빼 제한함

SPROP_ROUNDUP

  • 낮은 소수 값의 범위에서 비트를 하나를 빼 제한함

SPROP_NORMAL

  • 소수 범위를 -1 ~ +1 사이에서만 사용함
    (인코딩에 비트 12개 사용)

SPROP_EXCLUDE

  • 부모 클래스의 Send Table에서 추가된 SendProp을 제외

Note: 수동으로 설정하지 말고 SendPropExclude()를 사용할 것

SPROP_CHANGES_OFTEN

  • 몇몇 속성은 플레이어 위치나 시야 각도와 같이 꽤 자주 바뀔 수 있을텐데 (거의 매 스냅샷) 자주 변경되는 SendProp에 이 플래그를 추가하면 엔진이 Send Table의 인덱스 색인을 최적화하여 네트워크 부하를 줄일 수 있게 된다.

클라이언트-사이드에서는 앞처럼 비슷하게 Receive Table를 선언하여 클라이언트가 전달받은 엔티티의 속성을 어디에 저장해야 하는지 알 수 있게 한다. 만약 클라이언트-사이드에서 변수 이름이 서버-사이드와 똑같이 유지되었다면 Receive Table은 그저 단순히 전달받을 변수의 목록이 된다. (Send Table의 순서처럼 맞출 필요도 없다.) IMPLEMENT_CLIENTCLASS_DT 매크로는 이 Receive Table를 선언하고 클라이언트와 서버의 클래스 그리고 그 곳의 Send Table 이름에 연결한다.

IMPLEMENT_CLIENTCLASS_DT(C_MyClass, DT_MyClass, CMyClass )
	RecvPropInt( RECVINFO ( m_iMyInt ) ),
	RecvPropFloat( RECVINFO ( m_fMyFloat ) ),
	RecvPropVector( RECVINFO ( m_vecMyVector ) ),		
	RecvPropQAngles( RECVINFO ( m_angMyAngle ) ),
	RecvPropArray3( RECVINFO_ARRAY(m_iMyArray), RecvPropInt( RECVINFO(m_iMyArray [0]))),
	RecvPropEHandle( RECVINFO (m_hMyEntity) ),
	RecvPropString( RECVINFO (m_szMyString) ),
END_RECV_TABLE()

RECVINFO 매크로는 멤버 변수의 상대적인 오프셋과 크기를 연산한다.
만약 서버-사이드와 클라이언트-사이드의 변수 이름이 다르다면 RECVINFO_NAME를 사용해야 한다.

전송 필터들

Note: 엔티티가 전송될 때, 부모 엔티티까지 모두 전송된다.

플레이어가 보통 월드의 작은 일부분을 보는 만큼, 모든 엔티티의 업데이트를 전송하는건 대역폭에 불필요한 낭비가 된다. 일반적으로 서버는 플레이어 부근의 엔티티를 업데이트해야 하지만 플레이어의 특정 팀이나 컴뱃 클래스에만 관심이 있는 엔티티가 있을 수 있다.

플레이어 위치에서 보이는 영역이나 방들을 PVS(Potential Visiblity Set)라고 하여 각 플레이어의 PVS는 보통 엔티티들이 플레이어에게 전송되기 전에 필터링을 하는 역할을 수행하는데 이때, 엔티티 내의 UpdateTransmitState(), ShouldTransmit() 가상 함수를 통해서 더욱 복잡한 규칙을 적용할 수 있게 한다.

UpdateTransmitState()

아래 플래그 중 하나를 정하여 엔티티 자체의 전역 전송 상태를 정할 수 있다.

FL_EDICT_ALWAYS

  • 항상 전송함.

FL_EDICT_DONTSEND

  • 전송하지 않음.

FL_EDICT_PVSCHECK

  • 엔진에서 플레이어의 PVS를 확인하여 전송 (수동으로 처리할 수도 있음)

FL_EDICT_FULLCHECK

  • ShouldTransmit()를 호출하여 전송 여부를 결정하게 함
    꽤 많은 함수 호출을 하게 하므로 필요할 때만 사용할 것.

ShouldTransmit()

몇몇 엔티티의 복잡한 전송 규칙과 ShouldTransmit()의 성능 상의 영향은 피할 수 없다. CBaseEntity::ShouldTransmit(const CCheckTransmitInfo *pInfo)에서 파생된 구현은 아래의 플래그 중 하나를 반환해야 한다.

FL_EDICT_ALWAYS

  • 이번 때에 전송함.

FL_EDICT_DONTSEND

  • 이번 때에 전송하지 않음.

FL_EDICT_PVSCHECK

  • 이번 때에 PVS 내에 있는지 확인하고 있는 경우에 전송함.

매개변수로 전달 받은 CCheckTransmitInfo는 정보를 받을 클라이언트와 현재 PVS 그리고 전송될 다른 엔티티들을 담고 있다.

Send & Receive 프록시들

Send와 Receive 프록시들은 Send/ReceiveProps의 콜백 함수로 구현되어있다. 연결된 변수가 전송되거나 받았을 때 무조건 실행되고 하나의 값을 여러 장소에 보내거나 연결된 값을 감지하면 그 값을 압축하기 위해서 쓰인다. (이 경우에는 각 끝에서 처리해야 한다.)

Warning: 프록시에서 절대 엔티티 로직을 실행하지 말라. 예상하지 못한 때에 실행되거나 (예: 예측 검증) 아예 실행하지 못할 수 있다. (예: 풀-업데이트) 대신 PostDataUpdate()를 사용하라.

데이터 테이블의 프록시들

SendPropDataTable()로 상위 테이블에 데이터 테이블을 추가하고 SendProxy에 적용하여 무슨 플레이어가 정보를 받을 것 인지 필터링 할 수 있다.
이런 경우에는 아래의 SendProxy의 매개변수인 DVariant* pOut를 전달받을 클라이언트 정보를 담은 CSendProxyRecipients* pRecipients로 교체할 수도 있다. 여기에는 클라이언트들의 인덱스가 담겨있고 첫번째 플레이어는 0이다.

아래에 흔히 로컬 플레이어가 예측에 사용할 높은 정확도의 데이터를 전송하는 상황에 쓰일 2개의 프록시가 이미 세워져 있다.

  • SendProxy_SendLocalDataTable
  • SendProxy_SendNonLocalDataTable

매개변수들

SendProxy (Server)

SendProp* pProp

  • 이 프록시를 사용하는 SendProxy

void* pStructBase / pStruct

  • SendProp를 소유하는 엔티티
    (요구에 맞춰 조정할 것)

void* pData

  • 조작해야 할 가공되지 않은 데이터
    (이것도 요구에 맞춰 조정할 것)

DVariant* pOut

  • 클라이언트에게 보내질 출력 데이터 오브젝트
    클래스 내부의 함수를 사용해 할당

int iElement

  • 배열의 원소 인덱스, 0이면 배열이 아님

int objectID

  • 엔티티의 인덱스

RecvProxy (Client)

CRecvProxyData* pData

  • SendProp의 데이터가 포함된 오브젝트
    • RecvProp* m_pRecvProp
    • DVariant* m_Value
    • int m_iElement
    • int m_ObjectID
      왼쪽(SendProxy) 열에서 뭐였는지 확인할 수 있다.

void* pStruct

  • 이걸 처리하고 있는 클라이언트 측의 엔티티
    (요구에 맞춰 조정할 것)

void* pOut

  • 프록시가 끝나는 시점에 클라이언트 측의 엔티티에 적용될 값
    (요구에 맞춰 타입을 바꾸거나 void *로 바꿔서 보낼 것)

예제

이 예제에서는 대역폭을 아끼기 위해 정수에서 비트 2개를 버린다. 대신 이 경우에는 정확도를 잃는다.

void SendProxy_MyProxy( const SendProp* pProp, const void* pStruct, 
	const void* pData, DVariant* pOut, int iElement, int objectID )
{
	// 가공되지 않은 데이터 얻기
	int value = *(int*)pData;

	// 전송할 데이터 준비 (정확도를 잃음)
	*((unsigned int*)&pOut->m_Int) = value >> 2;
}

IMPLEMENT_SERVERCLASS_ST(CMyClass, DT_MyClass)
	SendPropInt( SENDINFO(m_iMyInt ), 4, SPROP_UNSIGNED, SendProxy_MyProxy ),
END_SEND_TABLE()
void RecvProxy_MyProxy( const CRecvProxyData* pData, void* pStruct, void* pOut )
{
	// 전송된 데이터를 받음
	int value = *((unsigned long*)&pData->m_Value.m_Int);

	// 데이터를 원래 크기로 복구
	*((unsigned long*)pOut) = value << 2;
}

IMPLEMENT_CLIENTCLASS_DT(C_MyClass, DT_MyClass, CMyClass )
	RecvPropInt( RECVINFO ( m_iMyInt ), 0, RecvProxy_MyProxy ),
END_RECV_TABLE()

아래의 예제는 프롭에 연결된 엔티티에 전달받은 Hue 값을 두 개의 색상 변수로 변환한다.

Tip: 프록시 함수가 대상 엔티티의 클래스 내에 속하지 않으면 private, protected 멤버에 접근하지 못할 수 있으므로 클래스 내에서 friend 키워드를 붙여 선언하라. (예: friend void RecyProxy_MyProxy(args))

void RecvProxy_PlayerHue( const CRecvProxyData* pData, void* pStruct, void* pOut )
{
	HSVtoRGB( Vector(pData->m_Value.m_Int,.9,.6), static_cast<C_DeathmatchPlayer*>(pStruct)->m_vPlayerColour_Dark );
	HSVtoRGB( Vector(pData->m_Value.m_Int,.4,.9), static_cast<C_DeathmatchPlayer*>(pStruct)->m_vPlayerColour_Light );
}

IMPLEMENT_CLIENTCLASS_DT(C_DeathmatchPlayer, DT_DeathmatchPlayer, CDeathmatchPlayer )
	RecvPropInt( RECVINFO(m_PlayerHue),0, RecvProxy_PlayerHue ),
END_NETWORK_TABLE()
대역폭 최적화

데이터 테이블과 거기에 연결될 변수들을 설정했고 모든 엔티티가 정상적으로 작동한다면 이제 네트워크 코딩에서 재밌을 부분인 최적화를 할 차례다. 최적화의 목표는 평균적인 대역폭 사용량을 줄이고 엄청나게 큰 패킷으로 인해 트래픽이 튀는 현상을 피하는 것이다. 이에 소스 엔진에서는 네트워크 트래픽을 모니터하고 분석할 수 있는 도구들을 제공한다.

Netgraph

개발자 콘솔에서 일반적으로 잘 알려진 net_graph 2로 킬 수 있는 네트워크 그래프다. 가장 중요한 네트워킹 데이터를 실시간으로 간결하게 보여준다. 다가오는 모든 패킷들을 색을 입힌 줄로 오른쪽에서 왼쪽으로 이동하여 표시한다. (줄의 높이는 지연시간이 아닌, 패킷의 사이즈를 의미한다.) 줄의 색에 따라 아래와 같은 데이터 그룹으로 보여진다.

fps

  • 현재 초당 프레임 수 (화면이 새로 고쳐지는 횟수)

ping

  • 네트워크 패킷이 서버와 클라이언트 사이를 지난 지연시간 (millisecond)

in

  • 마지막으로 전달받은 패킷의 바이트 크기와 평균적으로 받은 데이터 크기 (kb/second) 그리고 실제로 받은 초당 패킷의 수와 그 위에 cl_updaterate 값이 있다.

out

  • 마지막으로 보낸 패킷의 사이즈, 평균적으로 보낸 데이터의 크기 (kb/second) 그리고 실제로 보낸 초당 패킷의 수와 아래에 cl_cmdrate 값이 있다.

lerp

  • 클라이언트의 보정을 위해 설정한 최대 대기 시간, 기본값은 100ms이다.

네트워크 그래프의 위치는 net_graphheight pixelsnet_graphpos 1|2|3로 변경할 수 있다.

cl_entityreport

네트워크에 연결된 엔티티의 데이터를 실시간으로 보여주는 비주얼 도구다. cl_entityreport startindex로 킬 수 있으며 네트워크에 연결된 엔티티의 인덱스와 클래스 이름 그리고 트래픽 표시를 셀 형태로 볼 수 있게 된다. 화면 해상도에 따라 수백 개의 엔티티가 동시에 표시될 수 있고 트래픽 표시는 작은 바에 마지막으로 받은 패킷에서 받은 비트의 수를 보여준다. 빨간 선은 최근의 최고점을 보여주고 엔티티 텍스트의 색상은 현재 전송 상태를 보인다.

상태 없음

  • 엔티티가 아예 쓰이지 않거나 전송되지 않음

깜빡임

  • 엔티티의 PVS 상태가 바뀜

초록

  • 엔티티가 PVS 내에 있지만 최근에 업데이트하지 않음

파랑

  • 엔티티가 PVS 내에 있고 보낼 트래픽을 생성 중임

빨강

  • 엔티티가 PVS가 밖에 있고 아무런 업데이트도 하지 않음

dtwatchent

한 엔티티가 지속적으로 트래픽 생성하거나 최고점을 찍는다면 그 엔티티를 dtwatchent entityindex로 지켜볼 수 있는데 각 전달받은 업데이트에서 바뀐 멤버 변수들의 정보인 변수의 이름과 타입, SendTable 인덱스, 전송에 사용한 비트의 수와 새로운 값을 나열하여 출력한다. 예를 들어 로컬 플레이어 엔티티를 확인해보면 이렇다. dtwatchent 1

ample output for the local player entity dtwatchent 1:

delta entity: 1
+ m_flSimulationTime, DPT_Int, index 0, bits 8, value 17
+ m_vecOrigin, DPT_Vector, index 1, bits 52, value (171.156,-83.656,0.063)
+ m_nTickBase, DPT_Int, index 7, bits 32, value 5018
+ m_vecVelocity[0], DPT_Float, index 8, bits 20, value 11.865
+ m_vecVelocity[1], DPT_Float, index 9, bits 20, value -50.936
= 146 bits (19 bytes)

DTI

모든 엔티티의 클래스와 Data Table 사용으로 생기는 평균적인 대역폭 사용량을 더욱 깊이 분석하고자 한다면 Data Table Instrumentation (DTI)을 사용해볼 수 있다. DTI를 활성화하기 위해 클라이언트를 -dti 명령어 매개변수 (예: hl2.exe -game mymod -dti)를 추가하고 실행하면 DTI가 백그라운드에서 모든 전송 활동을 수집한다. 여기서 좋은 샘플들을 얻기 위해선 그냥 게임 서버에 접속해서 조금 플레이하다가 나가서 콘솔 명령어 dti_flush를 입력하면 엔진에서 모아진 모든 데이터를 파일들에 써넣고 모드 폴더에 저장한다. 만들어진 파일의 데이터는 표로 구분되는 텍스트 형식으로 되어 MS Excel과 같은 데이터 분석 도구에 불러올 수 있는데 여기서 가장 흥미로울 dti_client.txt에선 아래의 필드를 포함하는 데이터 레코드들이 있다.

  • 엔티티 클래스
  • 변수의 이름
  • 복호화 된 수
  • 모든 비트의 수
  • 평균적인 비트의 수
  • 모든 인덱스 비트의 수
  • 평균적인 인덱스 비트의 수

어떤 엔티티 속성이 비용이 큰 것인지 확인하려면 ‘모든 비트의 수’나 ‘복호화 된 수’로 정렬하면 보기 편하다.

일치하지 않는 클래스 테이블

서버와 클라이언트에서 데이터를 보내고 받는 방법을 서로 알아야 하므로 같은 클래스 테이블을 사용해야 한다.

만약 서버가 업그레이드하여 클래스 테이블이 바뀌었다면 이후에 접속하는 클라이언트에게 “Server uses different class tables” 에러 메세지와 함께 프로그램을 강제로 종료시킨다. 현재로서는 HL2.exe 코어가 다른 메세지를 표시하거나 초보 플레이어들이 서버 업그레이드를 보다 쉽게 할 수 있도록 하는 방법이 알려져 있지 않다.

, , ,

답글 남기기

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