CD Code Digest Hub /CS:GO

实体句柄、实体列表与空间分区

31 分钟阅读 9 个章节 3 张图

游戏代码几乎从不长期保存裸指针去引用别的实体,而是保存 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_Allocpublic/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 索引一一对应,其余给不联网实体CBaseEntityListpublic/const.h:84-85、game/shared/entitylist_base.cpp:153-157
句柄值 CBaseHandle::m_Index槽位 + 序列号打包成 32 位CBaseEntityList::AddEntityAtSlotpublic/basehandle.h:78-80、game/shared/entitylist_base.cpp:206-234

2. CBaseHandle 的编码与失效检测#

常量值作用证据
NUM_SERIAL_NUM_SHIFT_BITS / ENT_ENTRY_MASK16 / 0xFFFF低 16 位是槽位,高 16 位是序列号public/const.h:88-90
SERIAL_MASK0x7fff槽位序列号回绕上限game/shared/entitylist_base.cpp:16
INVALID_EHANDLE_INDEX0xFFFFFFFF空句柄public/const.h:86
NUM_NETWORKED_EHANDLE_SERIAL_NUMBER_BITS10网络上只携带序列号低 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 或已占用则返回 NULLengine/pr_edict.cpp:73-91
常规分配从 maxclients+1 与“最低空闲”中较大者开始,找空闲且已释放超过 EDICT_FREETIME(1 秒)的 edictengine/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_Errorengine/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_EDICTSSOLID_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_SolidMovedPARTITION_ENGINE_TRIGGER_EDICTS(有上一位置时沿扫掠射线)实心实体移动后触碰触发器engine/world.cpp:539-563
SV_TriggerMovedPARTITION_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 → CEntitySphereQueryPARTITION_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_PUSHABLEMOVETYPE_PUSH 实体移动game/server/physics_main.cpp:896-898
Bot 导航 CheckForEntitiesAlongSegmentPARTITION_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(渲染叶子系统)。