实体句柄、实体列表与空间分区
游戏代码几乎从不长期保存裸指针去引用别的实体,而是保存 EHANDLE(CHandle<T>);按名字/类名找实体走全局实体列表;按“空间位置”找实体(触发器、射线、爆炸范围)走引擎的空间分区。本页解释这三套基础设施的数据结构、它们与 edict(服务端网络槽位)的关系,以及句柄如何跨网络保持一致。理解它们才能判断“这个指针什么时候会失效”“这次查询为什么查不到刚移动的实体”。
相关页面:实体系统 · 实体复制 · 地图加载与碰撞追踪 · 延迟补偿
相关源码文件#
- 句柄编码:
public/const.h、public/basehandle.h、game/shared/ehandle.h - 列表基类:
game/shared/entitylist_base.h、game/shared/entitylist_base.cpp - 服务端列表与 edict:
game/server/entitylist.cpp、game/server/baseentity.cpp、game/server/ServerNetworkProperty.cpp、engine/pr_edict.cpp - 句柄网络化:
game/server/sendproxy.cpp、game/client/recvproxy.cpp、engine/sv_ents_write.cpp、engine/cl_ents_parse.cpp - 客户端列表:
game/client/cliententitylist.h、game/client/cliententitylist.cpp - 空间分区:
public/ispatialpartition.h、engine/spatialpartition.cpp、game/shared/collisionproperty.cpp、engine/world.cpp
1. 三种“编号”#
| 概念 | 范围 | 谁分配 | 证据 |
|---|---|---|---|
edict 索引(实体网络索引 entindex()) | 0 .. MAX_EDICTS-1(2048);0 是世界,1..maxclients 固定留给玩家 | 引擎 ED_Alloc | public/const.h:72-74、engine/sv_main.cpp:3058-3068、engine/pr_edict.cpp:93-107 |
| 实体列表槽位(句柄低位) | 0 .. NUM_ENT_ENTRIES-1(8192);< MAX_EDICTS 的槽位与 edict 索引一一对应,其余给不联网实体 | CBaseEntityList | public/const.h:84-85、game/shared/entitylist_base.cpp:153-157 |
句柄值 CBaseHandle::m_Index | 槽位 + 序列号打包成 32 位 | CBaseEntityList::AddEntityAtSlot | public/basehandle.h:78-80、game/shared/entitylist_base.cpp:206-234 |
2. CBaseHandle 的编码与失效检测#
| 常量 | 值 | 作用 | 证据 |
|---|---|---|---|
NUM_SERIAL_NUM_SHIFT_BITS / ENT_ENTRY_MASK | 16 / 0xFFFF | 低 16 位是槽位,高 16 位是序列号 | public/const.h:88-90 |
SERIAL_MASK | 0x7fff | 槽位序列号回绕上限 | game/shared/entitylist_base.cpp:16 |
INVALID_EHANDLE_INDEX | 0xFFFFFFFF | 空句柄 | public/const.h:86 |
NUM_NETWORKED_EHANDLE_SERIAL_NUMBER_BITS | 10 | 网络上只携带序列号低 10 位,网络句柄共 21 位 | public/const.h:94-96 |
Init(entry, serial)把两者拼成entry | (serial << 16);IsValid()只判断“是否被赋过值”,不代表实体仍存活:public/basehandle.h:119-135。GetEntryIndex()对无效句柄返回NUM_ENT_ENTRIES-1,注释说明这是为兼容旧 bug 而保留的行为(该槽位预期为空,从而查得NULL):public/basehandle.h:137-154。Set(pEntity)直接复制实体自身保存的GetRefEHandle():public/basehandle.h:202-214。
观察:
const.h中NUM_SERIAL_NUM_BITS 16后的注释写作(32 - NUM_ENT_ENTRY_BITS),按当前常量计算应为 19,注释已过时:public/const.h:84-88。
解引用路径与失效判定:
证据:CHandle<T>::Get 转调 CBaseHandle::Get:game/shared/ehandle.h:128-132;后者查询全局 g_pEntityList(服务端指向 gEntList,客户端指向 s_EntityList):game/shared/ehandle.h:32-36、game/server/entitylist.cpp:38-39、game/client/cliententitylist.cpp:23-24;LookupEntity 的空句柄与序列号比较分支:game/shared/entitylist_base.h:144-163。
失效靠“删除时序列号自增”实现:RemoveEntityAtSlot 先把实体的引用句柄置为 INVALID_EHANDLE、通知派生类,再清空指针并 m_SerialNumber = (m_SerialNumber+1) & SERIAL_MASK;不联网槽位回到空闲链表:game/shared/entitylist_base.cpp:237-262。构造时每个槽位的初始序列号取随机值:game/shared/entitylist_base.cpp:140-149。
推断:序列号 15 位回绕,同一槽位被反复分配 32768 次后旧句柄会重新“命中”新实体;源码中未见额外防护。
静态道具也使用 CBaseHandle:引擎用固定序列号 STATICPROP_EHANDLE_MASK >> 16(0x4000)标识静态道具,IsStaticProp 只比较序列号:engine/staticpropmgr.cpp:86-90、engine/staticpropmgr.cpp:494-496、engine/staticpropmgr.cpp:2017-2020。LookupEntity 中检测“把静态道具句柄当普通实体解析”的断言被注释掉了:game/shared/entitylist_base.h:149-156。
3. CBaseEntityList:两端共用的槽位表#
- 每个槽位是
CEntInfo(实体指针、序列号、双向链表指针;服务端额外缓存名字与类名):game/shared/entitylist_base.h:20-33。 AddNetworkableEntity(pEnt, index, forcedSerial)要求index < MAX_EDICTS;AddNonNetworkableEntity从空闲链表(MAX_EDICTS+1起)取槽,耗尽时告警并返回无效句柄:game/shared/entitylist_base.cpp:174-197。AddEntityAtSlot只允许客户端强制序列号(服务端传入会断言),把槽位挂到活动链表,调用SetRefEHandle让实体记住自己的句柄,再调用虚函数OnAddEntity:game/shared/entitylist_base.cpp:206-234。头文件注释说明强制序列号的目的是“让服务器下发的句柄在客户端也能用”:game/shared/entitylist_base.h:42-47。LookupEntityByNetworkIndex按 edict 索引直接取槽位,不比较序列号:game/shared/entitylist_base.h:166-174。
4. 服务端:CGlobalEntityList 与 edict#
创建与登记#
CBaseEntity::PostConstructor 决定实体是否联网:带 EFL_SERVER_ONLY 的实体只进不联网槽位;否则(除非 EFL_NO_AUTO_EDICT_ATTACH)先 AttachEdict 取得 edict,再以 entindex() 作为槽位加入列表,并把 IServerNetworkable 缓存到 edict 上:game/server/baseentity.cpp:824-865。
AttachEdict 在没有指定 edict 时调用 engine->CreateEdict():game/server/ServerNetworkProperty.cpp:81-93;引擎侧转到 ED_Alloc:engine/vengineserver_impl.cpp:684-692。CreateEntityByName 的“强制 edict 索引”参数正是通过预先 CreateEdict(index) 实现的:game/server/mapentities.cpp:39-49。
ED_Alloc 的规则:
| 情况 | 行为 | 证据 |
|---|---|---|
| 指定索引 | 超出 num_edicts 或已占用则返回 NULL | engine/pr_edict.cpp:73-91 |
| 常规分配 | 从 maxclients+1 与“最低空闲”中较大者开始,找空闲且已释放超过 EDICT_FREETIME(1 秒)的 edict | engine/pr_edict.cpp:26-27、engine/pr_edict.cpp:93-148 |
AllowImmediateReuse 标记过的槽位 | 可立即复用,并登记一次显式删除,让客户端删除旧实体 | engine/pr_edict.cpp:135-141、engine/pr_edict.cpp:188-200 |
达到 max_edicts(MAX_EDICTS) | 退而复用释放不足 1 秒的 edict;仍没有则 Sys_Error | engine/pr_edict.cpp:150-177、engine/sv_main.cpp:3031 |
ED_Free 不释放玩家 edict,记录释放时间并把 edict 的 m_NetworkSerialNumber 加 1:engine/pr_edict.cpp:211-249。游戏侧 SetRefEHandle 也会把句柄序列号的低 10 位写到 edict:game/server/baseentity.cpp:8298-8305。
列表回调、查找与删除#
OnAddEntity/OnRemoveEntity维护实体数与 edict 数,并通知IEntityListener:game/server/entitylist.cpp:1399-1446。NotifyCreateEntity另外把实体挂进“按类名”哈希链表并再次回调OnEntityCreated:game/server/entitylist.cpp:1448-1477。 > 观察:经由CreateEntityByName(..., bNotify=true)创建的实体,监听者会收到两次OnEntityCreated(一次来自OnAddEntity,一次来自NotifyCreateEntity):game/server/mapentities.cpp:56-59。是否所有监听者都能容忍重复回调未核实。FindEntityByClassname、FindEntityByName、FindEntityInSphere都沿活动链表线性遍历(FindEntityByName遇到!开头的过程名如!activator时改走FindEntityProcedural):game/server/entitylist.cpp:607-625、game/server/entitylist.cpp:752-789、game/server/entitylist.cpp:929-955;FindEntityByClassnameFast使用类名哈希链表:game/server/entitylist.cpp:627-642。名字缓存由UpdateName写入CEntInfo:game/server/entitylist.cpp:395-402。- 删除:
AddToDeleteList只接受仍有有效句柄的实体,CleanupDeleteList统一Release(延迟删除的调用时机见 实体系统):game/server/entitylist.cpp:411-438。析构函数最后才gEntList.RemoveEntity,注释明确“此后 EHANDLE 变为 NULL”;调试版会在析构期间访问句柄时告警:game/server/baseentity.cpp:795-821、game/shared/ehandle.cpp:17-26。CServerNetworkProperty析构时DetachEdict→RemoveEdict→ED_Free:game/server/ServerNetworkProperty.cpp:50-53、game/server/ServerNetworkProperty.cpp:95-103、engine/vengineserver_impl.cpp:695-702。
5. 句柄如何跨网络保持一致#
证据:
- 快照条目的序列号取自 edict 的
m_NetworkSerialNumber:engine/sv_framesnapshot.cpp:191;写EnterPVS时附带其低 10 位:engine/sv_ents_write.cpp:621-624;客户端读取后交给CL_CopyNewEntity:engine/client.cpp:1517-1523。 CL_CopyNewEntity发现同槽实体序列号不同就先删除再重建:engine/cl_ents_parse.cpp:323-344;客户端实体Init以服务器给的序列号加入列表:game/client/c_baseentity.cpp:1290-1299。- 发送代理只发
GetEntryIndex() | (低 10 位序列号 << MAX_EDICT_BITS),实体不存在时发无效值;SendPropEHandle固定使用NUM_NETWORKED_EHANDLE_BITS位:game/server/sendproxy.cpp:24-37、game/server/sendproxy.cpp:63-71。 - 接收代理拆出索引与序列号重新
Init:game/client/recvproxy.cpp:45-60。
因此客户端联网实体的序列号只有 10 位,而服务端槽位序列号是 15 位(推断:客户端与服务端的句柄 m_Index 原始值一般不相等,只能在各自进程内比较)。
6. 客户端:CClientEntityList#
CClientEntityList 同时继承 CBaseEntityList 与 IClientEntityList,以 VCLIENTENTITYLIST_INTERFACE_VERSION 暴露给引擎;头文件注释说明它一方面把服务器实体索引转换成 IClientNetworkable,另一方面给静态道具等对象发放句柄以便空间分区回调反查:game/client/cliententitylist.h:84-95、game/client/cliententitylist.cpp:23-31。引擎解析快照时就通过它按索引取实体:engine/cl_ents_parse.cpp:312。
OnAddEntity:联网槽位计数并缓存IClientNetworkable、初始标记为休眠;所有实体都尝试登记 PVS 通知并加入m_BaseEntities快速遍历链表,再通知监听者:game/client/cliententitylist.cpp:320-370。OnRemoveEntity做反向清理,移除最高索引时重新计算m_iMaxUsedServerIndex:game/client/cliententitylist.cpp:373-416。- 纯客户端实体(临时实体、客户端特效等)走
AddNonNetworkableEntity,并同样创建空间分区句柄:game/client/c_baseentity.cpp:1343-1356。
7. 空间分区 ISpatialPartition#
结构#
引擎只有一个实现 CSpatialPartition,以 SpatialPartition001 导出;server.dll 与 client.dll 在初始化时都从 AppSystem 工厂取同一个接口:public/ispatialpartition.h:23、engine/spatialpartition.cpp:2692、game/server/gameinterface.cpp:758、game/client/cdll_client_int.cpp:1484。内部按掩码选择两棵体素树:只要查询掩码含任意客户端列表就用 CLIENT_TREE,否则用 SERVER_TREE:engine/spatialpartition.cpp:60-65、engine/spatialpartition.cpp:467-471。体素树在世界模型加载后以世界包围盒初始化:engine/spatialpartition.cpp:2723-2734、engine/world.cpp:73、engine/gl_rmisc.cpp:281。
列表(SpatialPartitionListMask_t 位) | 含义(据头文件注释) | 证据 |
|---|---|---|
PARTITION_ENGINE_SOLID_EDICTS | 非 trigger、非 not-solid 的 edict(及静态道具) | public/ispatialpartition.h:32 |
PARTITION_ENGINE_TRIGGER_EDICTS | SOLID_TRIGGER 实体 | public/ispatialpartition.h:33 |
PARTITION_ENGINE_NON_STATIC_EDICTS | 实心+触发器(含特定 not-solid),不含静态道具 | public/ispatialpartition.h:36 |
PARTITION_ENGINE_STATIC_PROPS / PARTITION_CLIENT_STATIC_PROPS | 静态道具 | public/ispatialpartition.h:37-38 |
PARTITION_ENGINE_PUSHABLE | 可被 MOVETYPE_PUSH 推动的实体 | public/ispatialpartition.h:42 |
PARTITION_CLIENT_* | 客户端实心、响应、非静态、预测触发器、IK 附着 | public/ispatialpartition.h:34-41 |
接口操作:创建/销毁句柄、按掩码插入/移除、ElementMoved 更新包围盒、安装查询回调,以及按盒/球/射线/点枚举(IPartitionEnumerator::EnumElement 返回 ITERATION_STOP 可提前终止):public/ispatialpartition.h:86-101、public/ispatialpartition.h:113-185。ElementMoved 与 InsertIntoTree 按列表掩码把元素放进一棵或两棵树:engine/spatialpartition.cpp:2993-3018、engine/spatialpartition.cpp:3072-3096;每个 Enumerate* 在查询前后各调用一次已安装的查询回调:engine/spatialpartition.cpp:3023-3066。
谁插入、何时更新#
证据:
- 位置改变时
InvalidatePhysicsRecursive标脏(世界实体除外),包围盒变化时MarkSurroundingBoundsDirty标脏:game/shared/baseentity_shared.cpp:1655-1667、game/shared/collisionproperty.cpp:1289-1296;MarkPartitionHandleDirty只在首次标脏时把句柄推入s_DirtyKDTree:game/shared/collisionproperty.cpp:1445-1452、game/shared/collisionproperty.cpp:155-158。 CDirtySpatialPartitionEntityList是自动游戏系统,Init时把自己装成查询回调:game/shared/collisionproperty.cpp:101、game/shared/collisionproperty.cpp:140-149。OnPreQuery只对本 DLL 的游戏实体掩码生效,持写锁逐个UpdatePartition,正在做骨骼设置的实体留到下次:game/shared/collisionproperty.cpp:173-250。UpdatePartition:服务端没有句柄时惰性创建并计算掩码;实心、触发器或带EFL_USE_PARTITION_WHEN_NOT_SOLID的实体用外扩 1 单位的包围盒调用ElementMoved:game/shared/collisionproperty.cpp:1458-1503。- 服务端掩码由
ComputeServerPartitionMask按 solid 类型/标志与移动类型计算(world 与无 edict 实体不入表),SetSolid、SetSolidFlags改变相关位时调用UpdateServerPartitionMask重新归表:game/shared/collisionproperty.cpp:1385-1439、game/shared/collisionproperty.cpp:572-583、game/shared/collisionproperty.cpp:609-619。 - 客户端实体在
Init时就创建句柄,并由UpdatePartitionListEntry按碰撞类型选择客户端列表:game/client/c_baseentity.cpp:1299、game/client/c_baseentity.cpp:2651-2670。句柄在CCollisionProperty析构时销毁:game/shared/collisionproperty.cpp:412-415、game/shared/collisionproperty.cpp:1375-1382。
典型查询者#
| 调用者 | 列表 | 用途 | 证据 |
|---|---|---|---|
CEngineTrace::TraceRay | 服务端 PARTITION_ENGINE_SOLID_EDICTS / 客户端 PARTITION_CLIENT_SOLID_EDICTS(触发器追踪另用 trigger 掩码) | 射线收集候选实体(见 碰撞追踪) | engine/enginetrace.cpp:290-310、engine/enginetrace.cpp:2853-2892 |
SV_SolidMoved | PARTITION_ENGINE_TRIGGER_EDICTS(有上一位置时沿扫掠射线) | 实心实体移动后触碰触发器 | engine/world.cpp:539-563 |
SV_TriggerMoved | PARTITION_ENGINE_SOLID_EDICTS | 触发器移动后找被它覆盖的实体 | engine/world.cpp:317-334、engine/world.cpp:524-529 |
CBaseEntity::PhysicsTouchTriggers | — | 根据自身是实心还是触发器调用上面两者 | game/shared/baseentity_shared.cpp:2799-2842 |
UTIL_EntitiesInBox/Sphere → CEntitySphereQuery | PARTITION_ENGINE_NON_STATIC_EDICTS | 区域查询,结果最多 MAX_SPHERE_QUERY(512)个 | game/server/util.cpp:244-260、game/server/util.h:295-310 |
CCSGameRules::RadiusDamage | 经 CEntitySphereQuery | 爆炸范围伤害(HE、C4) | game/shared/cstrike15/cs_gamerules.cpp:5342、game/shared/cstrike15/cs_gamerules.cpp:5358-5360、game/shared/cstrike15/cs_gamerules.cpp:5378 |
| 推动者阻挡检测 | PARTITION_ENGINE_PUSHABLE | MOVETYPE_PUSH 实体移动 | game/server/physics_main.cpp:896-898 |
Bot 导航 CheckForEntitiesAlongSegment | PARTITION_ENGINE_SOLID_EDICTS | 沿路线检测可破坏物等 | game/server/cstrike15/bot/cs_bot_nav.cpp:45-52 |
对比:gEntList.FindEntityInSphere 不走空间分区,而是线性遍历全部实体并逐个做盒-球相交:game/server/entitylist.cpp:929-955。
约束与边界#
- 句柄有效 ≠ 实体存活:
IsValid()只看是否赋值;存活必须以Get() != NULL判断:public/basehandle.h:53-55。 - 删除是延迟的:句柄在实体析构(
CleanupDeleteList)时才失效,同一帧内UTIL_Remove之后句柄仍能解析到实体(与 实体系统 的延迟删除一致)。 - edict 复用冷却:普通情况下 edict 释放 1 秒内不复用,避免客户端把新实体误当旧实体;满载时会打破这一规则:
engine/pr_edict.cpp:150-171。 - 空间分区是惰性的:位置变化后树并不立刻更新,而是在下一次查询的
OnPreQuery中批量刷新;同一线程正在写分区时的重入查询会跳过刷新:game/shared/collisionproperty.cpp:184-187。 - 条件编译:服务端掩码计算与惰性建句柄只在非
CLIENT_DLL编译;主机平台的静态道具判断改用成员标志:game/shared/collisionproperty.cpp:1388-1420、engine/staticpropmgr.cpp:379-386。 - 未展开:体素树
CVoxelTree的分层哈希与射线遍历算法、IPVSNotify的客户端 PVS 通知细节、客户端ClientLeafSystem(渲染叶子系统)。