客户端预测内部机制:预测字段、中间数据与误差处理
输入命令与客户端预测 讲了“回滚到服务器状态、重放未确认命令”的整体流程。本页往下一层:哪些字段参与预测、它们被存到哪里、收到快照时怎样把“服务器给的值”和“本地预测的值”拼在一起、怎样判断预测错了,以及预测错误如何在画面上被平滑掉。这些细节决定了一个新加的实体字段会不会抖动或回弹。
相关页面:输入命令与客户端预测 · 实体复制 · 实体句柄与列表
相关源码文件#
- 预测数据表宏:
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_INSENDTABLE | public/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_showerror | 0 | 1 打印原点误差,2 额外打印字段差异;负值只看指定实体 | game/client/prediction.cpp:45、game/client/c_baseentity.cpp:5207-5220 |
cl_pred_error_verbose | 0 | 打印误差时显示更多字段信息 | game/shared/predictioncopy.cpp:207 |
cl_prediction_error_timestamps | 0 | 有误差时输出 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面板实现。