Сетевые события и сообщения

Обзор

Движок Source допускает к игре до 255 игроков одновременно в общем виртуальном мире реального времени. Для синхронизации ввода пользователя и изменений мира между игроками, Source использует клиент-серверную архитектуру которая соединяется посредством сетевых пакетов UDP/IP. Сервер это центральный источник обрабатывающий ввод пользователя и обновляющий мир в соответствии правилам игры и физики. Сервер с с высокой частотой рассылает изменения мира ко всем присоединенным клиентам. Логические и физические объекты в игровом мире называются 'энтитями' и представлены в исходном коде классами порожденными от общего базового класса. Некоторые объекты существуют только на сервере (серверные энтити) и некоторые объекты существуют только на клиенте (энтити клиента), но большинство объектов существуют на сервереи как соответствующая копия на клиенте. Общий сетевой код энтитей позволяет удостовериться что эти объекты синхронизируются для всех игроков.

Поэтому сетевой код должен определить изменения в серверном объекте, затем сериализировать (преобразовать в битовый поток) измененых переменных-членов, отослать это как пакет данных через сеть и десериализировать данные на клиенте (обновить соответствующие переменные-члены на серверном объекте). Пакеты данных не отсылаются с каждым отдельным изменении произошедшем в определенном объекте; производятся очень частые фреймы (обычно 20/раз в секунду) которые содержат все изменения энтитей за время последнего обновления. Также не все изменеия энтитей отправляются всем клиентам одновременно. Только энтити которые вызывают интерес для клиента (видимые, слышимые и т. д.) обновляются часто, это все служит для понижения сетевого траффика как можно ниже.

Пример

Этот вопрос вполне комплексный и сложно управляемый, но большая часть работы выполняется на фоне движком Source. Для автора мода довольно просто создать новый сетевой класс энтити. Следующий пример может служить идеей как это проще всего реализовать. Псоле того как вся сложная струтура объекта будет готова можно начать оптимизировать загрузку сетевого битового потока для разгрузки сети. Серверная энтить (server.dll):

class CMyEntity : public CBaseEntity
{
public:
	DECLARE_CLASS(CMyEntity, CBaseEntity );	// установочный макрос
	DECLARE_SERVERCLASS();  // создать эту энтить сетевой

	int UpdateTransmitState()	// установить передающий фильтр на постоянную передачу
	{
		return SetTransmitState( FL_EDICT_ALWAYS );
	}

public:
	// публичные сетевые переменные-члены:
	CNetworkVar( int, m_nMyInteger ); // целые значения от 0..255
	CNetworkVar( float, m_fMyFloat ); // любые вещественные значения
}

//Привязать имя глобальной энтити к этому классу (имя используется в Хаммере и т.д.)
LINK_ENTITY_TO_CLASS( myentity, CMyEntity );

//Описание сетевых переменных-членов для серверной таблицы данных (SendProps)
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( void )
{
	CreateEntityByName( "myentity" ); // создать объект энтити этого класса
}

Соединение энтитей

Как было описано выше каждый игровой объект это энтить, экземпляр класса энтити. Класс энтити имеет помимо уникального имени класса, глобальное имя которе связано с этим классом энтити. Связывание производится макросом:
LINK_ENTITY_TO_CLASS( globalname, CEntityClass ); 
Фабрика энтитей которая создает энтити мира размещенные на карте использует эти глобальные имена для нахождения соответствующего класса энтити. Серверные имплементации классов энтитей обычно называют по подобию CName, а клиентовские имплементации называют подобно C_Name с соответствующим символом подчеркивания.

Сервер движка Source может обрабатывать до 2048 энтитей одновременно (смотрите MAX_EDICT_BITS в \public\const.h), где каждая энтить может иметь до 1024 различных переменных-членов которые соединяются с клиентами (MAX_PACKEDENTITY_PROPS). Общий путь адресовать определенную энтить — по ее индексу (CBaseEntity::entindex ()). С каждым новым созданием экземпляра энтити, движок просматривает неиспользующийся индекс энтити и приравнивает его новой энтити. Как только объект энтити уничтожается, его индекс энтити становится свободным и может повторно использоваться другой энтитью. В связи с этим индекс энтити не лучший способ привязываться к определенной энтити на длительное время. Более безопасно использовать EHANDLE-ры (или CBaseHandle) для сохранение связи с экземпляром энтити. EHANDLE инкапсулирует 32-битный ID который уникален для экземпляра энтити на протяжении всей игры и может быть использован на клиенте и на сервере для ссылки на объект энтити (EHANDLE-ры являются комбинацией индекса энтити и увеличенного серийного номера). EHANDLE может быить преобразован в CBaseEntity и обратно просто используя его перегруженные операторы. Если EHANDLE равен NULL, объект энтити не действителен.
// Ищет игрока, если нет доступного игрока pPlayer и hPlayer равны NULL
EHANDLE hPlayer = gEntList.FindEntityByClassname( NULL, "player" );
CBaseEnity *pPlayer = hPlayer; // преобразует EHANDLE в указатель на объект энтити
m_hPlayer = pPlayer; // преобразует указатель на энтить в EHANDLE
Для сообщения движку сервера что класс энтити будет работать по сети и существует соответствующий класс на клиенте, макрос DECLARE_SERVERCLASS должен быть помещен в определении энтити. Этот макрос регистрирует класс в глобальном списке классо в сервера и резервирует уникальный ID класса. Макрос DECLARE_SERVERCLASS может быть добавлен для каждой порождаемой сетевой энтити заново. Также макрос IMPLEMENT_SERVERCLASS должен быть размещен в имплементации для регистрации серверного класса и его таблицы отправки данных. Это обычно выполняется во времени объявления серверной таблицы данных (смотрите ниже).

На стороне клиента должен быть создан соответствующий класс что требует разместить макрос DECLARE_CLIENTCLASS в его определении класса и IMPLEMENT_CLIENTCLASS_DT в его имплементации. Этот макрос связывает класс клиента к указанному серверному имени класса. Когда клиент соединяется с сервером, они обмениваются списком известных классов и если клиент не имплементирует все серверные классы, соединение останавливается с сообщением «Client missing DT class CName».

Сетевые переменные

Классы энтитей содержат переменные-члены подобно остальным классам. Некоторые из этих переменных-членов могут быть сугубо серверными, что означает что они не воссоздаются на клиенте. Более интересны переменные-члены, которые нуждаются в воссоздании энтити которая находится на стороне клиента. Сетевые переменные являются важнейшими свойствами например позиция, угол или здоровье. Все что нуждается в отображении энтити в ее текущем состоянии на клиенте должно быть соединено через сеть.

Каждый раз кодга сетевая переменная изменяется, движок Source должен знать об этом для включения и обновления сообщения для этой энтити в следующем фрейме соединения. Для сигнализирования об изменении сетевой переменной, должна быть вызвана функция NetworkStateChanged () этой энтити для установки внутреннего флага FL_EDICT_CHANGED. Как только движок отправит следующее обновление, этот флаг сборсится снова. Не обязательно нужно использовать вызов NetworkStateChanged () каждый раз во время изменения переменной-члена, поэтому сетевые переменные используют специальный вспомогательный макрос NetworkVar который заменяет настоящий тип переменной (int, float, bool и т. д.) на измененный тип который автоматически сигнализирует о изменении его родительскую энтить. Существует специальный макрос для классов Vector и QAngle, также само как для массивов и EHANDLE-ров. Практическое использование этих CNetworkVars не изменяется, так что их можно использовать подобно оригинальным типам данных (за исключением сетевых массивов, требуется использовать 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;

Сетевые таблицы данных

Когда энтить сигнализирует о изменениях и движок обновляет фрейм, движок требует знать как конвертировать значение переменной в битовый поток. Естественно он может просто отправить дамп памяти переменной-члена, но она может содержать слишком много данных в разных значениях и это не эффективно по отношению использования пропускной способности сети. В связи с этим каждый класс энтитей хранит таблицу данных которая описывает как кодировать каждую переменную-член. Эти таблицы носят название SendTables и должны иметь уникальное имя, обычно чтото похожее на DT_EntityClassName.

Записи этой таблицы являются объектами SendProp, которые хранят кодированное значение описания переменной-члена. Движое Source предоставляет несколько различных кодеров для часто используемых типов данных таких как integer, float, vector и текстовые строки. SendProp-ы также хранят информацию о том как много бит будет использоваться, минимальные и максимальные значения, специальные кодирующие флаги и прокси функции отправки (будут рассмотрены ниже).

Обычно не нужно создавать и заполнять SendProp-ы собственноручно вместо этого используется вспомогательные функции SendProp* (SendPropInt (…), SendPropFloat (…) и т. д.). Эти функции помогают установить все важные кодирующие свойства с помощью одной строки кода. Макрос SENDIFNO помогает определить размер функции-члена и относительное смещение от адреса энтити. Ниже приводится пример SendTable для сетевых переменных определенных выше.
SendProp* ( SENDINFO(name), bits, flags ),

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)),
END_SEND_TABLE()
Макрос IMPLEMENT_SERVERCLASS_ST автоматически связывает SendTable базового класса энтити от которого порождена энтить, так что все наследуемые свойства включены. Если вы по определенным причинам не желаете включать свойства базового класса используйте макрос IMPLEMENT_SERVERCLASS_ST_NOBASE. В ином случае отдельные свойства базового класса могут быть исключены используя вспомогательную функцию SendPropExclude (…). Вместо добавления новой SendProp, она удаляет существующую из SendTable.

Первое место в начале оптимизации размера битового потока это естественно число бит которое может быть использовано для передачи (-1 означает по умолчанию). Затем, вы знаете что целым значением может быть число от 0 до 15, поэтому требуется 4 бита вместо 32 (смотрите также флаг SPROP_UNSIGNED). Другие оптимизации могут быть с использованием флагов SendProp:
SPROP_UNSIGNED
 Кодировать целые как беззнаковое целые, не отправлять знаковый бит.
 
SPROP_COORD
 Кодировать float или Vector компоненты как мировые координаты. при этом данные сжимаются, 0.0 потребует всего 2 бита и другие значения могут занять до 21 бит.
 
SPROP_NOSCALE
 Писать компоненты float или Vector как полные 32-битные значения для уверенности что не произойдет потеря данных в резуьтате компрессии. 
 
SPROP_ROUNDDOWN
 Ограничить старшее значение float на один бит меньше
 
SPROP_ROUNDUP
 Ограничить младшее значение float на один бит меньше
 
SPROP_NORMAL
 Значение float нормальное в диапазоне от -1 до +1, используя 12 бит для кодирования.
 
SPROP_EXCLUDE
 Исключить SendProp заново, это добавляется базовым классом SendTable. Не устанавливайте этот флаг вручную, используйте фместо этого функцию SendPropExclude(...).
 
SPROP_INSIDEARRAY
 Внутренний флаг массива, не используется.
 
SPROP_PROXY_ALWAYS_YES
 Внутренний флаг массива, не используется.
 
SPROP_CHANGES_OFTEN
 Некоторые свойства изменяются очень часто например положение игрока и угол взгляда (возможно с каждым фреймом). Добавте этот флаг дял часто изменяемых SendProp-ов, так движок может оптимизировать индексы SendTable для уменьшения нагрузки на сеть.
На стороне клиента вы должны определить ReceiveTable подбно SendTable, так чтоб клиент знал где хранить переданные параметры энтитей. Если имена переменных остаются такими же самыми в серверном классе, ReceiveTable-ы представляют собой список принятых свойств (порядок свойств не обязательно должен совпадать с порядком в SendTable). Макрос IMPLEMENT_CLIENTCLASS_DT используется для описания ReceiveTable, а также привязывает классы клиента к серверным и их SendTable имена.
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) ),
END_RECV_TABLE()
Относительное смещение и размер переменной-члена вычисляется макрососм RECVINFO. Если имя переменной сервера и клиента отличаются, должен быть использован макрос RECVINFO_NAME

Фильтры передачи

Передача изменений всех энтитей для всех клиентов излишняя трата пропускной способности ведь игрок видит только небольшой набор всех объектов мира. В основном сервер требует только обновлять энтити которые в непосредственной близости с игроком. Пространства помещений видимые из позиции игрока называется «Potential Visible Set» (PVS) (потенциальный видимый состав, ПВС) игрока. PVS игрока обычно используется для фильтрации физических энтитей перед передачей клиенту, эти энтити могут описывать более комплексные правила фильтрации в их виртуальных функциях UpdateTransmitState () и ShouldTransmit (…). Особеннно, логические энтити используют эти фильтры с тех пор к они не имеют 'позиции' и часто инересны только для игроков определенной команды или игрокам определенного игрового класса. Энтити устанавливают их глобальное состояние передачи в функции UpdateTransmitState () где оно может быть выбрано оним из следующих состояний:

FL_EDICT_ALWAYS
 всегда передавать эту энтить
 
FL_EDICT_DONTSEND 
 не передавать эту энтить
 
FL_EDICT_PVSCHECK
 всегда передавать эту энтить, но исходя из PVS
 
FL_EDICT_FULLCHECK
 вызывать ShouldTransmit(...) для каждого обновления клиента (затратно)
 
Если энтить изменяет ее состояние так что сотояние передачи также должно измениться (например становится невидимым и т. д.), должна быть вызывана функция UpdateTransmitState (). Вообще можно отметить все энтити как FL_EDICT_FULLCHECK и поместить все правила передачи ShouldTransmit (…), но это будет очень затратно так как движок должен будет вызывать ShouldTransmit (…) для кажой энтити для кажого клиента около 30 раз в секунду (смотрите CServerGameEnts::CheckTransmit (…)). Наилучший путь сьесть всю мощность процессора на сервере. Поэтому там где это возможно выбирайте одно из состояний передачи.

Кроме того некоторые энтити имеют вполне сложные правила передачи (энтити игрока, оружия и т. д.) и вызов ShouldTransmit () неизбежен. Произведенная имплементация от CBaseEntity::ShouldTransmit (const CCheckTransmitInfo *pInfo) должна возвращать один из флагов передачи за исключением FL_EDICT_FULLCHECK (который является рекурсивным). В связи с этим флаги передачи имеют следующие значения:

FL_EDICT_ALWAYS
 передавать энтить в этот раз
 
FL_EDICT_DONTSEND
 не передавать энтить в этот раз
 
FL_EDICT_PVSCHECK
 передавать энтить в этот раз, но исходя из PVS
Передаваемый аргумент, структура CCheckTransmitInfo дает информацию о принимаемом клиенте, его текущий PVS и какие другие энтити готовы к передаче.

Прокси приема и передачи Отправляющие и принимающие прокси являются функциями обратного вызова привязанные к определенным Send/ReceiveProp. Если установлена для свойства энтити, эта функция обратного вызова выполняется как только она будет отправлена или принята. Прокси функции устанавливаются когда объявляются свойства энтитей в SendTable или ReceiveTable. Эти прокси функции позволяют модифицировать исходящие и входящие данные так что вы можите добавить произвольные имплементации кодировщиков и декодировщиков. Принимающий прокси может также использоваться для детектирования этих изменений в свойстве энтити без изменения входных данных. Но будьте уверенны что принимающий прокси не всегда вызывается если значени свойства действительно изменилось, например когда сервер отправляет полныйl, не измененный, сжатый фрейм обновлений. Следующий пример отнимает нижние 2 бита у integer перед передачей, что сохраняет пропускную способность, но вызывает потерю точности данных.

Установка прокси сервера отправки:
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 )
{
	// get the transmitted value
	int value =  *((unsigned long*)&pData->m_Value.m_Int);

	// restore value and write to destination address
	*((unsigned long*)pOut) = value << 2;
}

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

Оптимизация пропускной способности

После того как установлена таблица данных и все энтити работают корректно, веселая часть сетевого кодирования началась: оптимизация. Движок Source предоставляет набор инструментов для отображения и анализа сетевого траффика. Цель оптимизаций — понизить высокий уровень использования пропускной способности и избежать частых резких всплесков (другими словами, экстримально больших пакетов).

Хорошо известным инструментом является Netgraph, который может быть включен выполнением «net_graph 2» в консоли разработчика. Netgraph показывает наиболее важные сетевые данные в сжатой форме в реальном времени. Каждый входящий пакет отображается как цветная линия тянущаяся справа налево, где высота линий представляет размер пакета. Разные цвета линий представляют разные группы данных как показано в данной диаграмме:
fps:
 текущее отображаемое количество кадров в секунду.
 
ping:
 время путешествия пакета от сервера до клиента в милисекундах.
 
in:
 размер последнего полученного пакета в байтах, средние входящие kB/сек. и средние принятые пакеты/сек.
 
out:
 размер последнего отправленного пакета в байтах, средние исходящие kB/сек. и средние отправленные пакеты/сек.
Позиция netgraph на экране может быть настроена используя консольную переменную «net_graphheight pixels» и «net_graphpos 1|2|3».

Другой визуальный инструмент для отображения сообщения энтитей по сети в реальном времени «cl_entityreport startindex». Когда включается эта консольная переменная, для кажой сетевой энтити показывается клетка, содержащая индекс энтити, имя класса и индикатор траффика. В зависимости от разрешения экрана сотни энтитей и их активность может отображатся одновременно. Индикатор траффика — это небольшая полоска показывающая переданные биты с последним пакетом. красная линия над ней показывает частоту пиков. Цвет энтити представляет текущий статус передачи:
none
 индекс энтити никогда не использовался/передавался
 
flashing
 изменилось состояние PVS энтити
 
green
 энтить в PVS, но не часто обновляется
 
blue
 энтить в PVS генерирует исходящий трафик
 
red
 энтить все еще существует за пределами PVS, но ничего не изменила
 
Как только будет замечена одна из энтитей постоянно генерирующая пики трафика можно рассмотреть энтить более детально используя консольную утилиту «dtwatchent entityindex». Она выдаст на консоль вывод который покажет каждое полученное обновление переменных. Для кажого измененного свойства показывается имя, тип, индекс SendTable, биты использованные для передачи нового значения и новое значение. Ниже приводится пример вывода локальной переменной игрока «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)
Возможен более глубокий анализ всех энтитей классов и среднее использование пропускной способности для их свойств с помощью инструментария таблицы данных Data Table Instrumentation (DTI). Для включения DTI, движок Source (клиент) должен быть запущен с специальным параметром «-dti» в коммандной строке, например «hl2.exe -game mymod -dti». Затем DTI запускается автоматически на фоне собирая данные о всякой активности передачи. Для накопления хорошего примера таких данных, нужно соединиться с игровым сервером вашего мода и поиграть несколько минут. Затем выйти из игры или запустить консольную команду «dti_flush» и движок запишет все накопленные данные в файлы в директории вашего мода. Созданные файлы содержат данные в текстовом формате с символами табуляции в качестве разделителей, эти данные могут импортироваться утилитами обработки данных подобные MS Excel для дольнейшей работы. Наиболее интересный файл сдесь — «dti_client.txt» где каждая запись данных составляет:
Класс энтити; Имя свойства; Стетчик декодирования; Всего бит; Средне бит; Всего индеков бит; Средне индексов бит
Хороший путь искать наиболее затратные параметры энтитей это сортировать по «Всего бит» или «Стетчик декодирования».
Автор: DarkLight.
20 января 2005, 17:05