CD Code Digest Hub /CS:GO

客户端预测内部机制:预测字段、中间数据与误差处理

21 分钟阅读 8 个章节 1 张图

输入命令与客户端预测 讲了“回滚到服务器状态、重放未确认命令”的整体流程。本页往下一层:哪些字段参与预测、它们被存到哪里、收到快照时怎样把“服务器给的值”和“本地预测的值”拼在一起、怎样判断预测错了,以及预测错误如何在画面上被平滑掉。这些细节决定了一个新加的实体字段会不会抖动或回弹。

相关页面:输入命令与客户端预测 · 实体复制 · 实体句柄与列表

相关源码文件#

  • 预测数据表宏:game/shared/predictable_entity.h、public/datamap.h
  • 拷贝/比较引擎:game/shared/predictioncopy.h、game/shared/predictioncopy.cpp
  • 实体侧存储:game/client/c_baseentity.h、game/client/c_baseentity.cpp
  • 预测驱动:game/client/prediction.cpp、game/client/prediction_private.h
  • 引擎调用点:engine/cl_ents_parse.cpp、engine/servermsghandler.cpp
  • 误差平滑:game/client/c_baseplayer.cpp、game/shared/baseplayer_shared.cpp

1. 预测数据表(prediction datamap)#

可预测类用 DECLARE_PREDICTABLE() 声明静态 m_PredMap,BEGIN_PREDICTION_DATA(className) 把它链到 BaseClass::m_PredMap,表项写在 BEGIN_PREDICTION_DATA ... END_PREDICTION_DATA 之间:game/shared/predictable_entity.h:60-111。这些宏只在 CLIENT_DLL 下有内容,服务端展开为空(注释“only client has a prediction system”):game/shared/predictable_entity.h:58、game/shared/predictable_entity.h:132-139。

宏 / 标志含义证据
DEFINE_PRED_FIELD(name, type, flags)预测字段,flags 通常含 FTYPEDESC_INSENDTABLEpublic/datamap.h:192
DEFINE_PRED_FIELD_TOL(name, type, flags, tol)同上,带浮点误差容差public/datamap.h:196
DEFINE_FIELD(name, type)在预测表中表示不联网的预测状态(标志只有 FTYPEDESC_SAVE)public/datamap.h:140
FTYPEDESC_INSENDTABLE字段也在 SendTable 中,属于“联网”集合public/datamap.h:228
FTYPEDESC_PRIVATE仅本地,不参与预测拷贝public/datamap.h:229
FTYPEDESC_NOERRORCHECK参与拷贝但不做误差比较public/datamap.h:230
FTYPEDESC_OVERRIDE覆盖基类同名字段public/datamap.h:225

示例:C_BaseEntity 的 m_vecVelocity(容差 0.5)、m_vecNetworkOrigin(坐标容差)为联网预测字段,m_angNetworkAngles 不做误差检查,m_vecAbsOrigin 等是不联网的预测状态:game/client/c_baseentity.cpp:643-684。C_BasePlayer 预测 m_nTickBase 等:game/client/c_baseplayer.cpp:389、game/client/c_baseplayer.cpp:449;玩家本地数据表 CPlayerLocalData 预测 m_aimPunchAngle(容差 0.125)等:game/client/c_baseplayer.cpp:352、game/client/c_baseplayer.cpp:365-367;CS:GO 玩家额外预测 m_flStamina(容差 0.1):game/client/cstrike15/c_cs_player.cpp:529、game/client/cstrike15/c_cs_player.cpp:533。

2. CPredictionCopy:扁平化、分组与按段拷贝#

PrepareDataMap 首次使用时为每张表构建 optimized_datamap_t:按“非联网 / 联网”两种拷贝类型各生成一份扁平字段列表(去掉嵌入结构、PRIVATE 与被覆盖字段,并按偏移排序),再把偏移连续的字段合并成可一次 memcpy 的 run:game/shared/predictioncopy.h:41-100、game/shared/predictioncopy.cpp:1010-1035、game/shared/predictioncopy.cpp:1155-1175。数据在实体内用原始偏移(TD_OFFSET_NORMAL),在快照缓冲中用紧凑偏移(TD_OFFSET_PACKED):public/datamap.h:266-273。

拷贝类型 PC_NON_NETWORKED_ONLY、PC_NETWORKED_ONLY、PC_EVERYTHING 通过“类型值 + 1”换算成位掩码:public/datamap.h:257-264、game/shared/predictioncopy.cpp:1600-1607。TransferData 的操作:

操作行为证据
TRANSFERDATA_COPYONLY按 run 拷贝game/shared/predictioncopy.cpp:1635-1645、game/shared/predictioncopy.cpp:1387-1410
TRANSFERDATA_ERRORCHECK_NOSPEW逐字段比较,遇到第一个差异即停game/shared/predictioncopy.cpp:1649-1659、game/shared/predictioncopy.cpp:1451-1500
TRANSFERDATA_ERRORCHECK_SPEW比较全部字段并打印差异game/shared/predictioncopy.cpp:1661-1671
TRANSFERDATA_ERRORCHECK_DESCRIBE供 cl_pdump 等调试面板描述字段game/shared/predictioncopy.cpp:1673-1687

比较时跳过 FTYPEDESC_NOERRORCHECK 字段:game/shared/predictioncopy.cpp:1477-1479。浮点字段若声明了容差,差值不超过容差记为 WITHINTOLERANCE(不算错误),否则 DIFFERS;未声明容差则要求完全相等:game/shared/predictioncopy.cpp:384-416、game/shared/predictioncopy.h:105-110。TransferData 返回错误计数:game/shared/predictioncopy.cpp:1689-1730。

3. 每个可预测实体的存储槽位#

存储大小用途证据
m_pOriginalData(SLOT_ORIGINALDATA = -1)1 份最近一次网络更新后的“原始状态”game/client/c_baseentity.h:1140-1143、game/client/c_baseentity.h:2063
m_pIntermediateData[MULTIPLAYER_BACKUP]150 份每条已预测命令执行后的结果game/client/c_baseentity.h:2061
m_pIntermediateData_FirstPredicted[MULTIPLAYER_BACKUP+1]151 份首次预测的结果;仅当物理环境为预测模式或尚未创建时分配game/client/c_baseentity.h:2062、game/client/c_baseentity.cpp:5742-5745
  • 每份大小等于预测表的紧凑尺寸:game/client/c_baseentity.cpp:5726-5740、game/client/c_baseentity.cpp:6049-6058。槽位号按 framenumber % 150 取模:game/client/c_baseentity.cpp:5977-5991。
  • SaveData(slot, type) 把实体字段拷进槽位并记录写入的最高槽位;RestoreData 反向拷贝,但保留 EFL_DIRTY_SHADOWUPDATE | EFL_DIRTY_SPATIAL_PARTITION 两个标志不被回滚:game/client/c_baseentity.cpp:6278-6326。恢复后 OnPostRestoreData 强制重算位置:game/client/c_baseentity.cpp:6329-6332。
  • 实体在 ProcessOnDataChangedEvents 中经 CheckInitPredictable 判定(开启 cl_predict、有本地玩家、具备预测资格且有预测所有者)后 InitPredictable:分配缓冲、加入该分屏槽位的可预测列表,并把当前状态写入全部 150 个槽位,避免初始值被误报为预测错误:game/client/cdll_client_int.cpp:3148、game/client/c_baseentity.cpp:3305-3357、game/client/c_baseentity.cpp:5088-5117。

4. 一次网络更新中的数据流#

证据:

  • 引擎在解析实体前以“本次确认的命令数、当前序号、经过的服务器 tick”调用 PreEntityPacketReceived:engine/cl_ents_parse.cpp:735-755;CPrediction 为每个可预测实体转调,并对“按服务器 tick 模拟”的实体做槽位平移:game/client/prediction.cpp:332-385。
  • 实体侧:确认数大于 0 时,非联网字段取自槽位 acked-1(即服务器刚确认的那条命令的本地预测结果),联网字段取自原始状态;否则全部取自原始状态:game/client/c_baseentity.cpp:5145-5172。
  • 随后引擎按 RecvTable 把服务器值写入实体字段(CL_CopyExistingEntity → RecvTable_Decode,详见 实体复制):engine/cl_ents_parse.cpp:442-476。
  • FRAME_NET_UPDATE_POSTDATAUPDATE_END 阶段 PostEntityPacketReceived 把新收到的联网字段存为原始状态:game/client/prediction.cpp:390-432、game/client/c_baseentity.cpp:5180-5189。
  • 包处理结束时 CClientState::PacketEnd 计算本帧确认数并调用 PostNetworkDataReceived:engine/servermsghandler.cpp:165-198;CPrediction 累加确认数,逐实体检查误差,有误差的实体可在 HandlePredictionError 中修正非联网字段并重新存为原始状态:game/client/prediction.cpp:600-704。实体侧比较的是“槽位 acked-1”与“原始状态”的联网字段:game/client/c_baseentity.cpp:5196-5239。

5. 从哪条命令开始重放#

PerformPrediction 把命令 incoming_acknowledged + i 的结果存入槽位 i-1:game/client/prediction.cpp:1837-1846、game/client/prediction.cpp:1913-1916。起点 i 由 ComputeFirstCommandToExecute 决定:

情况处理证据
没有新世界更新,或更新没有确认任何命令跳到上次已预测的最后一条之后,从对应槽位恢复后只预测新命令game/client/prediction.cpp:1648-1673
有确认、上次无误差且 cl_pred_optimize >= 2恢复到上次预测的最后槽位,把槽位指针前移“确认数”,只预测新命令(省去重放)game/client/prediction.cpp:1676-1703、game/client/c_baseentity.cpp:5792-5830
其他(含发生了预测误差)从确认点后第一条完整重放;有误差时按 cl_pred_doresetlatch 清除插值历史game/client/prediction.cpp:1704-1767

“没有新世界更新”由 Update 在起始帧未变化且 cl_pred_optimize 开启时判定:game/client/prediction.cpp:1956-1971。cl_pred_optimize 默认 2:game/client/prediction.cpp:52。只有收到新世界更新时,_Update 才先把实体恢复到原始状态:game/client/prediction.cpp:2042-2046、game/client/prediction.cpp:1335-1355。

这意味着“回滚 + 全量重放”只在服务器结果与本地预测不一致时才真正发生;无误差的常态路径只预测新产生的命令(由上表第二行推出)。

6. 误差检测与视角平滑#

CheckError 专门比较本地玩家的 m_vecNetworkOrigin:取槽位 acked-1 中的预测原点与网络原点之差,超过 MAX_PREDICTION_ERROR(64 单位)视为传送而忽略,超过 MIN_PREDICTION_EPSILON(0.5)则调用 NotePredictionError 并在 cl_showerror >= 1 时打印:game/client/prediction.cpp:121-230、game/client/prediction_private.h:24-31。

平滑:NotePredictionError 把新误差与尚未衰减完的旧误差相加并记下时间(玩家死亡时忽略):game/client/c_baseplayer.cpp:3312-3331;GetPredictionErrorSmoothingVector 在 cl_smoothtime 秒内线性衰减该偏移,回放 demo、暂停、关闭 cl_smooth 或 cl_predict 时返回零:game/client/c_baseplayer.cpp:3379-3402、game/client/c_baseplayer.cpp:104-112。偏移只加到摄像机的眼睛位置上(CalcPlayerView),不改实体真实位置:game/shared/baseplayer_shared.cpp:2092-2098。

诊断用 ConVar:

ConVar默认作用证据
cl_showerror01 打印原点误差,2 额外打印字段差异;负值只看指定实体game/client/prediction.cpp:45、game/client/c_baseentity.cpp:5207-5220
cl_pred_error_verbose0打印误差时显示更多字段信息game/shared/predictioncopy.cpp:207
cl_prediction_error_timestamps0有误差时输出 tick 与时间game/client/prediction.cpp:594、game/client/prediction.cpp:745-748
cl_pdump / cl_predictionlist-1 / 0调试面板与可预测实体列表(作弊保护)game/client/prediction.cpp:48-51

仓库中没有名为 cl_pred_error 的 ConVar;与“预测误差”相关的是上表各项(检索 game/ 与 engine/ 得出)。

约束与边界#

  • 只有客户端有预测表;服务端同名宏为空,服务端执行命令不经过 CPredictionCopy(game/shared/predictable_entity.h:132-139)。
  • 新增字段的规则(由上文机制推出):需要预测且服务器会发送的字段应使用 DEFINE_PRED_FIELD(..., FTYPEDESC_INSENDTABLE),否则不会参与误差检测;只在客户端模拟的状态用 DEFINE_FIELD,它会在收到确认时从槽位 acked-1 恢复;带浮点噪声的字段应给容差,避免每帧误报。
  • 槽位上限:命令超过 150 条未确认时放弃预测(见 输入命令与客户端预测),槽位也按 150 取模复用。
  • 条件编译:NO_ENTITY_PREDICTION 会把上述大部分函数编译为空;PREDICT_ORIGIN_SPLIT 改变 CheckError 读取原点的方式:game/client/prediction.cpp:153-181。
  • 未展开:可预测实体的客户端创建(USE_PREDICTABLEID、RemoveStalePredictedEntities)、预测物理 VPhysicsCompensateForPredictionErrors、hud_pdump 面板实现。