联网实体

From Valve Developer Community
Jump to: navigation, search
English (en)português do Brasil (pt-br)中文 (zh)
... Icon-Important.png
Info content.png
This page needs to be translated.

This page either contains information that is only partially or incorrectly translated, or there isn't a translation yet.
If this page cannot be translated for some reason, or is left untranslated for an extended period of time after this notice is posted, the page should be requested to be deleted.

Also, please make sure the article tries to comply with the alternate languages guide.

Source引擎允许最多255个玩家同时在一个游戏世界里游玩。为了同步玩家输入与玩家周遭世界的变化,Source引擎使用通过UDP/IP网络包来交换信息的C/S架构。服务器作为中央权威,处理玩家输入并根据游戏以及物理规则更新游戏世界,并频繁向所有连接的客户端发送世界更新的信息。

游戏中的逻辑与物理对象被称为 'entities'(实体),它们在源代码中作为类而存在,并且都继承自同一个基础实体类。有些物体仅仅存在于服务端,有些仅仅存在于客户端,但是大多数同时存在客户端和服务端上。引擎的网络实体系统确保这些物体对所有玩家保持同步。

联网代码必须实现以下功能:

  1. 检测服务端物体的变化
  2. 序列化这些变化(转化为位流)
  3. 把得到的位流作为网络包发送到客户端
  4. 客户端对位流进行反序列化得到数据并据此更新相应物体。
    Note.pngNote:在客户端中,如果这个物体不存在,那么客户端会自动创建它。

每次变化发生后发生的事情不是数据包立刻发送出去,而是系统通常按每秒20张的速度生成快照,其中包含了从上一次更新到现在的所有实体变化,还有就是并非所有实体变化都会一直发送到所有客户端:为了尽可能减少网络宽带消耗,仅有那些客户端可能感兴趣的实体(可以看见或者可以听见的实体等等)会经常更新。

Source引擎的服务器可以同时处理最多2048个网络实体,每个实体可能有1024个不同的成员变量 that are networked to clients including individual members of an array,并且每个实体每次更新可以通过网络传输最多2KB的序列化数据(即2048个ASCII字符)。

例子

上面所说的对你来说可能听起来很复杂,但是,大多数的工作是由引擎在幕后完成的,所以对于一个MOD作者而言,创建一个新的联网实体类非常简单(实际上对于简单的实体来说,你并不需要做什么)。

举一个SDK中的例子,搜索m_iPing。下面展示了将一个 array(数组)联网的过程, 并且m_iPing变量在代码中仅仅出现了十几次,所以很容易把它们找全。

一旦你为了降低服务器(对玩家的)带宽要求而开始进行优化,尽管下面这些东西可以变得非常复杂,但是这个例子还是能够向你展示这个过程可以是多么的简单。

服务端

class CMyEntity : public CBaseEntity
{
public:
	DECLARE_CLASS(CMyEntity, CBaseEntity );	// 初始化的一些宏
	DECLARE_SERVERCLASS();  // 令此实体为“可联网的”

	int UpdateTransmitState()	// 总是发送给所有客户端
	{
		return SetTransmitState( FL_EDICT_ALWAYS );
	}

public:
	// public网络成员变量:
	CNetworkVar( int, m_nMyInteger ); // 整型
	CNetworkVar( float, m_fMyFloat ); // 浮点数
};

// 链接一个全局实体名到这个类(名字在Hammer等里面使用)
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 ); // generic entity class macro
	DECLARE_CLIENTCLASS(); // this is a client representation of a server class 

public:
	// networked variables as defined in server class
	int	m_nMyInteger;
	float	m_fMyFloat;
};

//Link a global entity name to this class (name used in Hammer etc.)
LINK_ENTITY_TO_CLASS( myentity, C_MyEntity );

// Link data table DT_MyEntity to client class and map variables (RecvProps)
// DO NOT create this in the header! Put it in the main CPP file.
IMPLEMENT_CLIENTCLASS_DT( C_MyEntity, DT_MyEntity, CMyEntity )
	RecvPropInt( RECVINFO( m_nMyInteger ) ),
	RecvPropFloat( RECVINFO( m_fMyFloat )),
END_RECV_TABLE()

联网实体

把服务端的A实体和客户端的B实体连接起来有几个阶段,第一个便是把两端的C++类通过LINK_ENTITY_TO_CLASS()和同一个"Hammer类"连接起来。

Note.pngNote:实体的服务端实现应该命名为CMyEntity,而客户端的则应该命名为C_MyEntity。虽然理论上代码文件可以随意命名,但是Valve公司的某些代码假定你是遵从前面所说的命名约定的。

你必须通过DECLARE_SERVERCLASS()宏来告诉服务器,这个实体应该联网并且对应的客户端类是存在的,这个宏会在全局服务器类列表里注册该实体并并占用一个唯一的类标识符。把它放到类定义(即头文件)中去,相应的宏IMPLEMENT_SERVERCLASS_STEND_SEND_TABLE()必须按顺序放在该类的实现(即CPP文件)中去,由此注册该服务端类还有它的SendTable(下一节会讲到)。

最终你必须在客户端做同样的事情,这次在头文件用DECLARE_CLIENTCLASS(),在CPP文件用IMPLEMENT_CLIENTCLASS_DT以及END_RECV_TABLE()

当一个客户端连接到服务器,它们会交换一个已知类的列表,然后如果客户端存在没有实现的服务端类,那么它们之间的连接将会被中断,并弹出"Client missing DT class <whatever>"的提示。

联网变量

就像其他类一样,实体类也有成员变量,这些成员变量中有些可能只存在于服务端,也就是说它们不会复制到客户端上,而更有意思的是需要复制到客户端的成员变量。联网变量(通常)是诸如位置、角度、生命值这样的重要实体属性。任何需要在客户端上按其当前状态展现出来的实体都必须联网。

每当一个联网变量发生变化,Source引擎必须能觉察到这一变化,从而在下一次传输快照时把一条更新信息加入到快照中去。为了表示一个联网变量改变了,必须调用该实体的NetworkStateChanged()函数,以便设置内部标志FL_EDICT_CHANGED。一旦引擎发送了下一个更新,该标志会被再次清除。往后每次你改变了一个成员变量,对你来说调用NetworkStateChanged()函数就不是很方便了,所以联网变量使用一个特殊的辅助宏CNetworkVar,这个宏自动 signals 一个状态改变 to 它的父实体。VectorQAngle类以及数组、EHANDLES要使用特殊的宏。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;
CNetworkString( m_szMyString );  		// const char *m_szMyString;
Warning.pngWarning:CNetworkArray不能用平常的语法来赋值,要使用Set(slot,value),同时,为了向前兼容,需要获取成员变量时则考虑使用Get(slot)

联网数据表

When an entity signals a change and the engine is building the snapshot update, the engine needs to know how to convert a variable value into a bit stream. Of course it could just transfer the memory footprint of the member variable but that would be way too much data in most cases and not very efficient in terms of bandwidth usage. Therefore each entity class keeps a data table that describes how to encode each of its member variables. These tables are called Send Tables and must have a unique name, usually like DT_EntityClassName.

该表的入口是一个个记录有对成员变量编码的描述的SendProp的对象。Source引擎提供许多不同的通用数据类型编码程序,这些数据类型包括整型,浮点数,向量以及字符串文本等。SendProp同样存储着应该用掉多少位,最大最小值,特殊编码标志的信息以及send proxy函数(后面会解释)。

通常你不用自己创建并且填写SendProp,而是使用 SendProp* 辅助函数中的其中一个(如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.pngTip:If you for some reason don't want to include base class properties, use IMPLEMENT_SERVERCLASS_ST_NOBASE(). Otherwise single properties of the base class can be excluded by using SendPropExclude(). Instead of adding a new SendProp, it removes an existing one from an inherited Send Table.

The first place to start optimizing the bit-stream size is of course the number of bits that should be used for transmission (-1=default). When you know that an integer value can only be a number between 0 and 15, you just need 4 bits instead of 32 (set also flag SPROP_UNSIGNED). Other optimizations can be archived by using proper SendProps flags:

SPROP_UNSIGNED
Encodes an integer as an unsigned integer, don't send a sign bit.
SPROP_COORD
Encodes float or Vector components as a world coordinate. The data is compressed, a 0.0 just needs 2 bits and other values may use up to 21 bits.
SPROP_NOSCALE
Write float or Vector components as full 32-bit value to make sure no data loss occurs because of compression.
SPROP_ROUNDDOWN
Limit high float value to range minus one bit unit.
SPROP_ROUNDUP
Limit low float value to range minus one bit unit.
SPROP_NORMAL
Float value is a normal in range between -1 to +1, uses 12 bits to encode.
SPROP_EXCLUDE
Exclude a SendProp again that was added by a base class Send Table.
Note.pngNote:Don't set this flag manually; use SendPropExclude() instead.
SPROP_CHANGES_OFTEN
Some properties change very often like player position and view angle (almost with every snapshot). Add this flag for frequently changing SendProps, so the engine can optimize the Send Tables indices to reduce networking overhead.

On the client side you must declare a Receive Table similar to the Send Table, so the client knows where to store the transmitted entity properties. If the variables names remain the same in the client-side class, Receive Tables are just a simple list of received properties (property order doesn't have to match Send Table order). The macro IMPLEMENT_CLIENTCLASS_DT is used to define the Receive Table and also links client to the server classes and their Send Table names.

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()

The relative offset and size of a member variable is calculated by the RECVINFO macro. If the server and client variable name is different, the RECVINFO_NAME must be used.

Transmission Filters

Note.pngNote:If an entity transmits, it will force all of its parents to transmit as well.

Transmitting all entity updates to all clients would be an unnecessary waste of bandwidth, as a player usually sees only a small subset of the world. In general a server needs to update only entities that are in the local vicinity of a player, but there are also cases where an entity is only of interest to players on a certain team or of a certain combat class.

Areas or rooms visible from a player's position are called the Potential Visiblity Set. A player's PVS is usually used to filter entities before transmitted to the client, but more complex filter rules can also be defined in an entity's UpdateTransmitState() and ShouldTransmit() virtual functions.

UpdateTransmitState()

An entity sets its global transmission state in UpdateTransmitState(), where it can choose one of the following states:

FL_EDICT_ALWAYS
Always transmit.
FL_EDICT_DONTSEND
Don't ever transmit.
FL_EDICT_PVSCHECK
Have the engine check against the PVS (this could also be done manually).
FL_EDICT_FULLCHECK
Call ShouldTransmit() to decide whether or not to transmit. This creates lots of extra function calls, so only use it when needed.

If an entity changes its state so the transmission state would change too (e.g. becomes invisible etc), UpdateTransmitState() will be called.

ShouldTransmit()

Some entities have complex transmission rules and the performance impact of calling ShouldTransmit() is unavoidable. A derived implementation of CBaseEntity::ShouldTransmit(const CCheckTransmitInfo *pInfo) must return one of the following transmission flags:

FL_EDICT_ALWAYS
Transmit this time.
FL_EDICT_DONTSEND
Don't transmit this time.
FL_EDICT_PVSCHECK
Transmit this time if the entity is inside the PVS.

The argument structure passed to CCheckTransmitInfo gives information about the receiving client, its current PVS and what other entities are already marked for transmission.

Send & Receive Proxies

Send and receive proxies are callback functions implemented in Send/ReceiveProps. They are executed whenever the property is transmitted, and are commonly used to compress a value for transmission (in which case one is required at each end), to send one value to many locations, or simply to detect when a networked variable changes.

Warning.pngWarning:Do not run entity logic from proxies under any circumstances. They can be run at unexpected times (e.g. during prediction validation) or not run at all (e.g. full updates). Use PostDataUpdate() instead.

Proxies on datatables

You can filter which players receive a datatable by including it in a parent table using SendPropDataTable(), then applying a SendProxy.

In this case replace DVariant* pOut from the SendProxy argument list below with CSendProxyRecipients* pRecipients, an object which holds a list of clients who will receive the table. It takes the index of the client(s), with the first player at 0.

There are two proxies already set up for the most common scenario of sending high-precision data to the local player for use in prediction:

  • SendProxy_SendLocalDataTable
  • SendProxy_SendNonLocalDataTable

Arguments

SendProxy (Server) RecvProxy (Client)
SendProp* pProp
The SendProp that is using this proxy.
void* pStructBase / pStruct
The entity that owns the SendProp that is using this proxy. Cast it to fit your requirements.
void* pData
The raw data that is to be manipulated. Again, cast as required.
DVariant* pOut
Object that receives the output data to be sent to clients. Assign using one of its function.
int iElement
The array element index, or 0 if this isn't an array.
int objectID
The entity index of the object being referred to.
CRecvProxyData* pData
An object containing data from the SendProp:
  • RecvProp* m_pRecvProp
  • DVariant* m_Value
  • int m_iElement
  • int m_ObjectID
See the left column for details on what these are.
void* pStruct
The client-side entity that is currently being dealt with. Cast it to fit your requirements.
void* pOut
The value that will be applied to the client entity when the proxy has finished running. Cast it to the type you require, or alternatively cast values you assign to it to void*.

Example

This example strips the lower two bits of an integer, which saves bandwidth but causes a loss of precision.

void SendProxy_MyProxy( const SendProp* pProp, const void* pStruct, 
	const void* pData, DVariant* pOut, int iElement, int objectID )
{
	// get raw value
	int value = *(int*)pData;

	// prepare value for transmission, will lose precision
	*((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 to original magnitude
	*((unsigned long*)pOut) = value << 2;
}

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

This example converts a received hue value into two separate colour variables on the prop's attached entity.

Tip.pngTip:Since the proxy function is not a part of the target entity's class, it may be unable to access private or protected members. Declare it in the class with the friend keyword, e.g. friend void RecyProxy_MyProxy(args), to grant permission.
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()

Optimizing Bandwidth

Once the network variable and data tables are set up and all entities are working properly, the fun part of network coding begins: optimization. The source engine provides a set of tools to monitor and analyze network traffic. The goal of your optimizations is to lower the average bandwidth usage and avoid network traffic spikes (single, extremely large packets).

Netgraph

A well-known tool is Netgraph, which can be enabled by executing net_graph 2 in the developer console. Netgraph shows the most important networking data in a compact form in real-time. Every incoming packet is displayed as color-coded line travelling from right to left, where the line hight represents packet size (NOT latency!). The line colors represent different data groups as shown in this diagram:

net_graph
fps
Current frames per seconds at which the screen is refreshing.
ping
AKA latency. Network packet travel time between server and client in milliseconds.
in
Size of last received packet in bytes, average incoming kB/second and (on the far right), the value of cl_updaterate above the actual average of packets/second received.
out
Size of last-sent packet, average outgoing kB/second and (on the far right) average packets/second sent above the value of cl_cmdrate.
lerp
The largest amount of latency the client is configured to compensate for. Default is usually 100ms.

Netgraph's position on-screen can be altered with the convars net_graphheight pixels and net_graphpos 1|2|3.

Tip.pngTip:Use net_graphshowlatency to display latency on the netgraph.

cl_entityreport

Another visual tool to show entity networking data in real-time is cl_entityreport startindex. When enabling this console variable, a cell will be shown for each networked entity, containing the entity index, class name and a traffic indicator. Depending on your screen resolution hundreds of entities and their activities can be displayed at the same time. The traffic indicator is a small bar showing the transferred bits arrived with the last packet. The red line above the bar shows recent peaks. The entity text is color-coded representing the current transmission status:

cl_entityreport

none
Entity index never used/transmitted.
flashing
Entity PVS state change.
green
Entity in PVS, but not updated recently.
blue
Entity in PVS generating ongoing traffic.
red
Entity still exists outside PVS, but not updated anymore.

dtwatchent

Once you've identified a single entity constantly generating traffic or peaks you can watch that entity a bit closer using the console tool dtwatchent entityindex. This will generate console output with each received update showing all changed member variables. For each changed property the variable name, type, SendTable index, bits used for transmission and new value is listed. Here an example 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

An even deeper analysis of all entity classes and average bandwidth usage of their properties is possible by using the Data Table Instrumentation (DTI). To enable DTI, the Source engine (client) must be started with a special -dti command line parameter, e.g hl2.exe -game mymod -dti. Then DTI runs automatically in the background collecting data about all transmission activities. To gather a good sample of data, just connect to a game server of your mod and play for a couple of minutes. Then quit the game or run the console command dti_flush and the engine will write all collected data to files in your mod directory. The created files contain the data in a tabulator separated text format, which can be imported by data processing tools like MS Excel for further analysis. The most interesting file here is dti_client.txt where each data record contains the following fields:

  • Entity class
  • Property Name
  • Decode Count
  • Total Bits
  • Average Bits
  • Total Index Bits
  • Average Index Bits

A good way to search for most expensive entity properties is to sort data by "Total Bits" or "Decode Count".

Mismatched Class Tables

To work, the server and client must use the same class tables, so that each knows how to send and receive data.

If a server is upgraded with a class table change, any connecting client will receive a "Server uses different class tables" error message and be immediately dumped out. It is not currently known whether there is any way to tell the HL2.exe core to display some other message, or in any way make a server upgrade more seamless for a novice player.

Other References

Source Multiplayer Networking