非对称 PVP 竞技手游 · 双端预测与回滚系统
2023 年 12 月
目录
00项目背景与问题
项目背景
本项目是一款非对称 PVP 竞技手游,采用 2V4 对抗模式:2 名追逐方对阵 4 名逃跑方。追逐方拥有高伤害技能和控制能力(眩晕、击飞、钩子拉拽),逃跑方依靠跑位、伪装、格挡弹反等生存技能周旋。双方的技能体系完全不对称——追逐方和逃跑方的技能设计、Buff 机制、运动模式差异极大。
这种非对称设计带来了丰富的操作深度:追逐方需要精确释放眩晕和钩子抓住逃跑方的失误窗口,逃跑方需要在被追击时利用格挡弹反、翻越障碍、变身伪装等手段化解追击。追逃双方的技能交互是帧级别的——格挡有 4 帧前摇、眩晕有延迟触发帧、弹反窗口仅持续数帧——这些精密的帧窗口机制意味着,即使 100ms 的操作延迟也会让"明明按了格挡却没挡住"成为常态,直接摧毁竞技公平性和操作手感。
问题:网络延迟吞噬操作体验
游戏采用服务器权威架构——所有操作必须经过服务器确认才能生效。玩家按下方向键到角色响应,中间隔着一个 RTT(网络往返延迟),通常 60~200ms。这 0.1 秒意味着:
- 移动:追逃场景中,逃跑方推摇杆的瞬间角色不动,0.1 秒后才启动——感觉"粘手"、"不跟手"。
- 技能:守方看到攻击立刻按格挡,但指令到达服务器时攻击判定早已结束——"明明按了,没挡住"。
优化目标
让玩家几乎感受不到网络延迟——推摇杆角色立刻动,按技能技能立刻出,体验趋近于单机。同时不牺牲服务器权威性(防作弊)。
解决思路
不等服务器确认,客户端拿到操作后立即在本地执行,同时把操作发给服务器。服务器独立计算结果后回传。两边结果一致——完美;不一致——客户端悄悄回退到服务器确认的状态,用保存的操作重走一遍,玩家无感知。
优化前:推摇杆 → 等 60~200ms → 角色才动;按技能 → 等 60~200ms → 技能才释放。追逃靠网速不靠操作,格挡靠运气不靠反应。
优化后:推摇杆 → 角色立刻动;按技能 → 技能立刻释放。攻守对抗回归操作本身,体验接近单机水准。
技术挑战
思路只有两句话,但要真正做到"双端各自模拟结果一致"和"不一致时无感知修正",背后的工程复杂度远超预期:
- 预测回滚机制本身理解和实现成本高:不同于常规的状态同步或帧同步,预测回滚要求客户端在"过去、现在、未来"三条时间线上同时工作——当前帧做预测、收到服务器回包时回退到历史帧重新模拟、再快进到当前帧追平。逻辑天然是"反直觉"的:一个玩家的当前位置可能在同一帧内被计算多次,每次的输入还不同。整个系统的状态管理、帧号对齐、推演与正常帧的区分,都比常规网络同步复杂一个量级。
- 运行环境完全不同:客户端和服务器是两套独立运行时——不同平台的浮点精度、不同的帧调度时机。要让两端独立运行产出一致结果,需要从引擎底层逐层对齐。
- 双端物理引擎存在一致性问题:角色移动依赖物理引擎的 CCT(Character Controller,角色控制器)碰撞检测、场景碰撞体、射线/扫描查询等能力。客户端使用 Messiah、服务器使用 MessiahServer,两套引擎的物理 API、CCT 移动接口、物理步进策略存在差异,需要在 C++ 引擎层做改造对齐,使双端物理引擎对相同输入产出一致结果。
- 服务器动画能力缺失:技能位移由动画系统驱动(RootMotion),且技能之间存在打断关系——A 技能播到一半被 B 打断,位移轨迹完全改变。但 MessiahServer 引擎不具备动画播放能力。服务器不运行动画,既无法算出技能位移,也无法知道打断发生时动画处于什么状态,双端结果必然不一致。项目组从零自研了服务器端 Graph 动画引擎来填补这一能力缺失。
- 模拟维度多、耦合深:角色位置不仅受摇杆驱动,还受技能位移、Buff 速度、动画曲线、物理碰撞、击退击飞等多种机制叠加影响,每种机制都必须双端对齐,任何一个维度出错都会导致预测偏移。
- 技能逻辑复杂度高、类型多样:游戏技能不是简单的"释放→生效",而是各有独特的时序和交互逻辑——延迟触发(眩晕 33 帧后才生效)、帧级前摇窗口(格挡 4 帧前摇期间不免疫)、多技能双向联动(格挡与眩晕互相影响对方的判定结果)。每种技能的预测策略、回滚方式、副作用清理都不同,不存在一套通用逻辑适配所有技能。
- 纠错要无感知:双端偶尔不一致时,客户端需要回滚到历史帧重新模拟——回滚过程在一帧内完成 10+ 帧的重计算,且修正后的位移差异要小到玩家完全看不出来。
- 对抗真实网络波动:手游网络环境恶劣——丢包、延迟突增、抖动是常态而非异常。预测系统不能只考虑理想网络:丢包时服务器收不到输入怎么办?延迟突然飙升导致预测窗口不够用怎么办?网络恢复后堆积的输入如何消化?系统必须在丢包、抖动、延迟突变等各种网络状况下都能维持稳定输出,而不是一遇到网络波动就崩溃回退。
下图展示了"操作手感打磨"这一目标背后所涉及的完整技术范围——从 C++ 引擎底层到网络传输层再到每个具体玩法的适配:
上述"本地立刻执行 → 服务器后台确认 → 偏差自动修正"的完整技术实现,就是本文档所阐述的双端预测与回滚系统。后续章节将逐一展开其架构设计、核心难点和各子系统实现。
01技术方案概述
核心思想
相同输入 → 相同模拟算法 → 相同输出。
- 双端跑相同的模拟:客户端和服务器各自拿到输入,各自独立执行完全相同的模拟算法(物理碰撞、动画位移、技能、Buff),各自产出结果。客户端在操作瞬间立即模拟并表现,不等服务器。
- 算法自身保证稳定:即使双端输入有时差导致结果不一致,客户端回滚到服务器确认的状态,用保存的输入重走一遍相同的模拟——同平台上算法是确定性的,重模拟的输出会趋向收敛(跨平台浮点微差由容错层兜底,详见 05A 节)。
- 所有玩法共享同一框架:基础移动、技能位移、Buff 速度补偿——本质上都是这个输入 → 模拟 → 输出框架在不同玩法维度上的延伸和实践。
框架背后的工程复杂性
"相同模拟"四个字,拆开来是六层工程对齐和多个子系统的协同设计。上一节列出了八项技术挑战,这些挑战最终归结为六个对齐层——每层解决一个维度的双端差异:
| 对齐层 | 核心手段 | 实现层 | 详见 |
|---|---|---|---|
| 物理引擎对齐 | 统一 CCT 参数 + 新增独立预测 API + 物理场景数据同源 | C++ / 资源管线 | 05A 节 |
| 动画引擎对齐 | 自研服务器 Graph 动画引擎,复现 RootMotion 位移 + 实时模拟 Graph 栈状态(处理技能打断) | C++(从零自研) | 05A 节 |
| 运动公式对齐 | 速度因子预推送 + MoveStrategy 抽象 + motion_type 双模式切换 | Python | 06 节 |
| 外部状态对齐 | 服务器预推送未来 17 帧速度因子、禁移集合同步、技能 Snapshot 对比 | Python | 06–07 节 |
| 帧对齐 | 固定 dt=0.033s、按帧号对齐、物理帧与逻辑帧 1:1 绑定 | C++ / Python | 05A 节 |
| 容错兜底 | 跨平台浮点误差不可消除 → 阈值过滤 + Filter 平滑 + 回滚重模拟 | Python | 05B 节 |
后续章节将从架构(02)、流水线(04)、核心难点(05)到各子系统实现逐层展开这些对齐层的具体设计。
02系统架构总览
架构设计决策:采用 Client-Side Prediction + Server Reconciliation 模式,而非帧同步 (Lockstep) 或纯状态同步。原因:非对称竞技以追逃和伪装为核心玩法,追逐方与逃跑方的技能、Buff、视野机制差异大,帧同步一致性成本高;而追逃场景下移动手感至关重要,纯状态同步的延迟感会严重影响追逃体验。预测回滚方案在保持服务器权威的同时,将玩家感知延迟降至接近零。
03核心参数
| 参数 | 说明 |
|---|---|
| 30 FPS | 预测主循环帧率 — dt=33ms,兼顾 CCT 精度与服务器负载 |
| ~0ms | 玩家感知输入延迟 — 本帧采集即预测,不等服务器回包 |
| < 200ms | RTT 开启阈值 — 超过则回滚代价 > 收益,动态关闭预测 |
| 33 帧 | 客户端最大缓冲 — 覆盖 ~1s 延迟,回滚耗时极短(详见 05B 节) |
| 60 帧 | 服务器输入缓冲 — 比客户端大,容纳网络抖动突发到达 |
| < 150B | 单帧包体大小 — Protobuf + zlib,典型单帧 < 150B(含冗余 < 400B,超限自动降级为单帧) |
04预测与回滚流水线
4.1 预测-确认-修正 时序
以一帧输入(F100)为例,展示它在双端系统中的完整生命周期。典型 RTT ≈ 150ms(约 5 帧):
4.2 回滚与重模拟流程
05核心技术难点
本节较长,分为两个难点:A. 双端模拟一致性 和 B. 回滚与重模拟。
场景还原:追逃对局中,逃跑方玩家被追击时按下前进键急需脱离。在传统状态同步中,客户端需要等待服务器确认才移动,延迟 = 1 个 RTT(约 60~200ms),角色动作发"粘"——追逃场景下这种迟滞直接影响生死。
预测回滚系统让客户端先行模拟——按下的瞬间角色就动了。但"提前算"带来两个必须解决的工程难题:
- 难点 A:双端模拟一致 — 相同输入 → 相同模拟 → 相同输出。双端对同一帧、同一输入,执行相同的模拟算法(物理碰撞和动画位移),必须算出几乎相同的结果。否则每次对比都要修正,角色反复回弹。
- 难点 B:算法自纠错 — 帧状态必须可回滚、可推演。模拟不可能 100% 正确(输入时差、Buff 延迟等),算法自身能回到出错帧,用正确输入重新模拟到当前帧,输出自动收敛稳定,玩家无感知。
两者共同保证算法输出稳定:A 让双端模拟结果一致率极高(减少修正频率),B 让不一致时算法自动收敛(保证修正质量)。
难点 A — 双端帧计算结果的对齐、修正与兜底
类比:想象两个人在两个房间里,各自拿到一道一模一样的物理题(同一帧输入),必须独立算出完全一样的答案(角色位置)。听起来简单?但两个人用的计算器精度不一样(浮点硬件不同)、用的公式版本不一样(引擎 API 不同)、桌上摆的参考物不同(碰撞场景参数差异)、甚至一个人有动画教具而另一个人没有(服务器原本没有动画引擎)。这就是双端一致性面临的真实挑战。
为什么难:四个维度的差异
客户端和服务器是两个完全独立的运行环境,从硬件到引擎到运行时,每一层都可能产生结果差异:
| 差异维度 | 双端差异 | 影响 | 应对 |
|---|---|---|---|
| 硬件平台 | 客户端 ARM/x86 vs 服务器 x86_64 | FPU 精度不同、SIMD 指令集不同(NEON vs SSE)、编译器浮点优化策略不同 | 不可消除,只能容忍 |
| 物理引擎 | 客户端 Messiah vs 服务器 MessiahServer | CCT 移动 API 完全不同、物理步进策略不同、碰撞检测调用链不同 | 引擎层 C++ 改造对齐 |
| 动画引擎 | 客户端内置动画系统 vs 服务器原本完全没有动画能力 | 技能位移由 RootMotion 驱动,服务器不运行动画就无法算出技能位移;技能打断影响 Graph 状态 | 项目组从零自研服务器 Graph 动画引擎 |
| 运行时状态 | 客户端先于服务器执行 | Buff 状态有时间差(RTT)、禁移集合可能不同步、帧率/帧间隔可能不同 | 脚本层预同步设计 |
如果做不好会怎样?——"橡皮筋效应"
当双端计算不一致时,玩家看到的不是"角色平稳移动",而是角色走两步就被拉回来,像被一根橡皮筋拽住——这就是网络游戏开发中常说的橡皮筋效应(rubber-banding):客户端预测位置不断被服务器修正拉回,角色一会前进一会后退,操控感极差,对比纯状态同步延迟感反而更差——"预测做了不如不做"。
这就是为什么双端一致性是预测系统的生死线:一致性不够,回滚修正就频繁,手感反而比不预测更差。而要实现一致性,光调 Python 脚本层的参数远远不够——最终决定角色位置的是 C++ 引擎层的 CCT 碰撞检测。
解法核心:引擎层 C++ 改造
为什么脚本层调参数解决不了? 角色每帧的位置由两种模式之一决定:基础移动时,位置 = 当前位置 + 速度 × dt,然后经过 CCT 碰撞检测后得到最终位置;技能期间,位置由动画 RootMotion 驱动。前者碰撞检测发生在 C++ 引擎内部的 PhysX 物理引擎中,Python 无法介入;后者由动画系统的 Graph 节点提取动画关键帧位移,也在 C++ 层完成。
更关键的是:客户端引擎(Messiah)原本没有"在任意时刻、任意位置发起一次独立的 CCT 移动并取回结果"的能力——它的 CCT 移动与渲染帧绑定;服务器引擎(MessiahServer)原本完全没有动画能力——传统服务器不需要播动画。要支持预测系统,前者必须新增独立 CCT API,后者必须从零搭建动画引擎。
物理引擎对齐的三个维度
物理引擎对齐不是"参数对上就行"。CCT 碰撞检测的输出由三个维度共同决定——任何一个维度不一致,同样的输入就会算出不同的位置:
| 维度 | 含义 | 双端现状 | 对齐方式 |
|---|---|---|---|
| ① 参数 | 胶囊体半径/高度、StepOffset、碰撞层编号等 CCT 配置 | 客户端 CreateCapsuleCharCtrl(0.3, 0.65);服务器 add_controller(CCT_HEIGHT, CCT_RADIUS)——两端取同一组常量 |
硬编码写死,不走导表 |
| ② API 与调用链 | 发起 CCT 移动的函数签名、物理步进策略 | 客户端新增 MoveManual(pos, vel, dt);服务器用 cct.move(disp, minDist, dt)——语义对齐但入口不同 |
引擎层 C++ 改造(见下表) |
| ③ 物理场景数据 | 碰撞检测依赖的场景几何体:地形 Mesh、障碍物刚体、高度图 | 服务器从 physx_scene_data/{map_id}.xml 加载;客户端预测 CCT 进入引擎已加载的 ActiveWorld.DefaultLevel.RootArea——两端碰撞的是同一份地图导出的物理数据 |
共用同一份资源产出 + 高度图双端同源 |
前两个维度(参数 + API)通过工程手段可以精确对齐。第三个维度(物理场景数据)的关键保障是资源管线统一:场景美术在编辑器中摆放的碰撞体导出后,同一份 .xml 被服务器直接加载,客户端也从同一份场景资源构建物理世界——如果两端读到的碰撞几何体不同,即使参数和 API 完全一致,CCT 的碰撞结果也会不同(墙壁偏了 0.01m 就能让角色一端通过另一端被挡住)。
高度图也属于物理场景数据。双端各有 SpaceMapHeightInfoSystem:客户端基于 PhysicsSpace.AllOverlap 采样生成,服务器从预构建的 map_height_data/ 加载——两者本质上源自同一份场景几何。当 CCT_USE_HEIGHT_MAP = True 时,服务器 CCT 移动直接查高度图跳过物理碰撞,此时高度图数据的一致性就等价于碰撞结果的一致性。
引擎改造清单
以下每一项都需要修改 C++ 引擎代码或引擎配置,不是 Python 脚本层能完成的:
| 改造项 | 做了什么 | 解决什么问题 | 不做的后果 |
|---|---|---|---|
| 新增 MoveManual API | 在 Messiah 的 CharCtrlComponent 中新增 C++ 方法,接受 位置+速度+dt,返回碰撞后位置,不触发渲染/动画 |
原 API 与渲染帧绑定,无法在预测循环中独立调用 | 无法做回滚推演(一帧内需多次调用) |
| 物理帧率锁定 | 通过 SetSyncWithFrameTick 关闭物理子步进,物理帧与逻辑帧 1:1 对齐 | 默认物理引擎会在一帧内做多次子步进以追求精度,但子步进次数双端不同 | 同一 dt 两端物理模拟次数不同,位置发散 |
| 碰撞参数硬编码 | 胶囊体 r=0.25 h=1.3、StepOffset=0.3、碰撞层=31,双端完全一致 | 碰撞检测对参数极度敏感 | 斜坡能否上去、台阶能否跨过双端结果不同 |
| 独立 CCT 实例 | 客户端创建专用预测 CCT,与渲染角色的 CCT 完全分离 | 预测循环独立于渲染帧运行 | 复用渲染 CCT 会互相污染位置 |
| 场景碰撞数据同源 | 服务器从 physx_scene_data/{map_id}.xml 加载场景碰撞体;客户端预测 CCT 进入引擎已加载的同一份场景(ActiveWorld.RootArea);碰撞层配置统一使用 Collision.xml |
CCT 碰撞结果由场景几何体决定——参数一致但场景不一致等于没对齐 | 墙面/斜坡/台阶碰撞结果双端不同,回滚频繁 |
| 版本门控 | 检测引擎版本 ≥ 20231127,缺少 MoveManual 的旧引擎自动关闭预测 | 渐进上线,兼容旧版本 | 旧引擎调用不存在的 API 直接崩溃 |
| 自研服务器动画 Graph 引擎 | 从零自研 C++ Graph 动画引擎,包含 ActorComponent(Graph 栈管理)、ActionNode(RootMotion 位移提取)、Cue/Event(技能流程驱动)等完整模块 | 技能位移由动画 RootMotion 驱动(近战突进、翻滚、击飞等),服务器不运行动画 Graph 就根本无法算出技能期间的角色位移 | 技能位移双端不一致,回滚频繁;技能打断时机不同步 |
| 动画 Graph 数据同源 | 服务器专用 *_server.graph 文件与客户端 Graph 共享同一份骨骼和动画关键帧数据(position_keys / yaw_keys),仅剔除渲染相关节点(骨骼蒙皮、材质等服务器不需要的部分) |
动画位移采样(sample_delta_motion)依赖关键帧数据;同一段动画在双端必须产生同样的 deltaPos/deltaYaw | 近战打击、翻滚等技能位移不一致 |
动画系统对齐:从零自研服务器动画引擎
物理引擎决定了"走路能不能撞墙",而动画系统决定了"放技能往哪飞"。游戏中大量技能(近战突进、翻滚、击退、抓取等)的角色位移不是由玩家摇杆控制,而是由动画数据中的 RootMotion(根骨骼位移)驱动——一个突进技能冲多远、翻滚落在哪个点,全部由动画关键帧数据决定。
问题在于:MessiahServer 作为服务器引擎,原本完全没有动画能力。传统服务器不需要播动画——它只关心逻辑判定,不关心角色表演。但预测系统要求服务器能独立算出技能期间每一帧的角色位置,而这些位移恰恰来自动画数据。服务器不运行动画 Graph,就无法知道一个突进技能到底冲了多远、一个翻滚到底落在哪里。
为此,项目组从零自研了服务器端 C++ 动画 Graph 引擎。它不做骨骼蒙皮、不做渲染,核心解决两个问题:一是精确复现 RootMotion 位移输出,使服务器能算出技能期间每帧角色位置;二是实时维护 Graph 栈状态,使服务器能正确处理技能打断——当 A 技能播到一半被 B 技能打断时,服务器必须知道 A 动画当前播到哪一帧、B 动画从哪个状态开始接管,才能得出与客户端一致的打断后位移轨迹。
Graph 动画引擎架构:图节点驱动的动画状态机
Graph 动画引擎采用与客户端相同的图节点架构(Animation Graph),而非传统的线性状态机。每个 Graph 是一棵节点树,包含 ActionNode(播放动画 + 提取位移)、BlendNode(混合)、EventNode(事件触发)等节点类型。多个 Graph 通过栈结构层叠管理——推入(push)一个技能 Graph 开始播放技能动画,弹出(pop)时恢复到基础移动状态。
预测系统如何切换两种位移模式
预测系统通过 motion_type 标志在两种位移模式间切换:
| motion_type | 位移来源 | 触发时机 | 预测帧处理 |
|---|---|---|---|
| 0(CCT) | 物理引擎 CCT 碰撞 | 默认状态:基础移动、无技能时 | simulate_pos() → CCT MoveManual → 客户端自行预测 |
| 1(Graph) | 动画 Graph RootMotion | push_graph() 时设为 1;pop_graph() 时恢复为 0 |
位置由动画引擎 tick 直接写入 → pd_pos_now = position(信任引擎输出) |
当 motion_type == 1 时,预测系统不再调用 simulate_pos(),而是直接采用引擎动画 tick 输出的 area.position——因为此时角色位移由动画关键帧决定,CCT 碰撞只负责物理约束(可选开启 set_graph_cct)。
动画对齐面临的挑战
| 挑战 | 具体表现 | 应对 |
|---|---|---|
| 动画数据一致性 | 动画关键帧(position_keys、yaw_keys)双端必须完全相同。同一段突进动画,双端采样出不同的 deltaPos 就会导致位置分叉 | 服务器使用 *_server.graph 文件,仅剔除渲染节点,保留全部运动数据和关键帧 |
| Graph 栈状态同步 | 技能的 push/pop 顺序、时机必须一致——先 push A 再 push B 和先 push B 再 push A 的混合结果不同(MotionMode 叠加 vs 独占) | Graph 的 push/pop 通过预测帧的 cmd 队列同步(pd_process_cmd_frame),确保帧号对齐 |
| 技能打断 | A 技能播到一半被 B 技能打断 → Graph 被 pop 再 push 新 Graph → 位移轨迹完全改变。打断判定在服务器,客户端有延迟 | 服务器权威判定打断 → 通知客户端 → 客户端回滚到打断帧重新模拟 |
| Cue 事件时序 | 动画播放到特定进度触发 Cue(如命中判定、位移阶段切换),进度基于 playedTime / duration 计算 |
双端使用相同的 graph 配置和固定 dt=0.033s → 触发进度一致 |
不可消除的误差:跨平台浮点数
类比:同一道除法 10 ÷ 3,一个计算器显示 3.3333333,另一个显示 3.33333334——精度不同,最后一位就是不一样。CPU 的浮点运算也是如此:ARM 芯片(手机)和 x64 芯片(服务器)的浮点单元精度不同,算出来的结果在最低几位上必然有差异。
IEEE 754 浮点误差 —— 三个不可控因素
| 差异源 | 具体表现 | 为什么无法消除 |
|---|---|---|
| FPU 精度模式 | x87 用 80-bit 扩展精度,SSE 用 64-bit,ARM NEON 用 64-bit 但指令行为不同 | 由 CPU 硬件决定,软件无法统一 |
| 编译器优化 | a*b+c 可能被优化为 fma(a,b,c)(fused multiply-add),结果不同 |
不同平台的编译器和优化级别不同 |
| PhysX 碰撞累积 | 三角形碰撞检测涉及大量浮点运算,单步误差 ~1e-7 会在碰撞体边缘被放大 | 物理引擎内部实现,无法干预 |
实测表明,同一输入 pos=(3.0, 0, 5.0) vel=(0.1, -0.03, 0.2) 在 ARM 和 x64 上输出差值约 ~2.38e-7/帧——肉眼不可见,但 30 帧/秒会累积。设计哲学:尽力对齐以减少误差 + 容错层处理不可避免的微小偏差。
完整的六层对齐体系
前面分析了四个差异维度(硬件平台、物理引擎、动画引擎、运行时状态),但解法不是一对一的——硬件差异无法从根源消除,只能在帧对齐和容错层兜底。最终形成的是从引擎底层到脚本逻辑的六层对齐体系:
| # | 层 | 实现层 | 具体手段 |
|---|---|---|---|
| 1 | 物理引擎对齐 | C++ / 资源管线 | 三维度对齐:① 参数——统一胶囊体 r/h、StepOffset、碰撞层;② API——新增 MoveManual + 关闭物理子步进 + 创建独立预测 CCT;③ 场景数据——碰撞几何体同源(physx_scene_data)+ 高度图双端同源 |
| 2 | 动画引擎对齐 | C++(从零自研) | 自研服务器 Graph 动画引擎(见上文):Graph 栈 + ActionNode RootMotion 位移提取 + Cue/Event 驱动 + *_server.graph 数据同源;技能期间 motion_type=1,位移由动画 tick 输出 |
| 3 | 运动公式对齐 | Python | 速度 = 导表查询 · 重力 = -2.94×dt · 合成 = move_dir × SPEED × factor × dt,双端完全相同的代码路径 |
| 4 | 外部状态对齐 | Python | Buff 速度因子服务器预推送未来 17 帧 · 禁移集合双端同步检查 · 运动模式属性自动同步 |
| 5 | 帧对齐 | C++ / Python | 固定 dt=0.033s(调频仅改触发时机不改 dt)· 按帧号对齐而非时间戳 · 物理帧与逻辑帧 1:1 绑定 |
| 6 | 容错兜底 | Python | 浮点误差不可消除 → 偏差 <0.00001 直接忽略 → <0.01 Filter 平滑过渡 → 大偏差服务器强制拉回 |
第 1、2 层需要引擎 C++ 改造 + 资源管线保障,第 5 层需要引擎配合,第 3、4、6 层在 Python 脚本层实现。六层协同把偏差控制在不可感知范围内。
偏差来源、对策与残余影响
| 偏差源 | 根因 | 对策 | 残余影响 |
|---|---|---|---|
| 跨平台浮点精度 | ARM vs x64 FPU 精度/指令差异,PhysX 碰撞运算累积 | 每帧双端对比 + 阈值过滤 | 极小 (~1e-7/帧) |
| CCT API 差异 | MoveManual vs cct.move 调用链路不同 | 统一输入输出语义 + 参数硬编码对齐 | 极小 |
| 场景碰撞数据差异 | UGC 地图或热更后场景资源版本不一致 | 碰撞几何体同源管线 + 高度图从同一物理世界生成 | 正常流程为 0;UGC/热更需额外校验 |
| 动画 Graph 状态差异 | 技能打断时机不同步(服务器权威判定 vs 客户端延迟感知),导致 Graph 栈状态不一致 | 服务器通知打断 → 客户端回滚到打断帧重新模拟 | 打断帧前后 1~2 帧窗口 |
| Buff 生效时差 | Buff 由服务器判定,客户端有 RTT 延迟 | 服务器预推送未来 17 帧速度因子 | 1~2 帧窗口 |
| 物理子步进不同步 | 默认物理引擎一帧内子步进次数不确定 | SetSyncWithFrameTick(True) 关闭子步进 | 0(彻底消除) |
| 帧间隔漂移 | 客户端自适应调频 ±10% | 模拟固定 dt=0.033 · 按帧号对齐 | 0(彻底消除) |
| 丢包导致输入缺失 | 服务器饥饿时只能复用旧输入 | 8 帧冗余发包 + 13 帧滑动窗口 | 高丢包时偏差增大 |
难点 A 小结:双端模拟一致性跨越 C++ 引擎层(物理 + 动画)、资源管线和 Python 脚本层。物理层面需要三维度对齐——参数、API、场景数据;动画层面需要自研服务器 Graph 动画引擎并保证数据同源、栈状态同步、打断时机一致。脚本层面需要预同步外部状态(速度因子、禁移集合等)。六层协同把双端模拟偏差控制在肉眼不可感知的范围。而跨平台浮点误差是IEEE 754 浮点标准决定的天花板,只能容忍不能消除——这直接引出了难点 B 的必要性:算法必须能自纠错。
难点 B — 算法自纠错:帧状态可回滚、可推演
具体场景:逃跑方推摇杆右跑,客户端立即预测并持续向右跑了约 0.3 秒。此时收到服务器回包,告知 0.3 秒前那一刻的权威位置比客户端预测的偏左了 0.3m(因为服务器判定那一刻角色踩到了一个减速 Buff 区域,而客户端尚未收到这个 Buff 信息)。此时客户端已经基于错误的起点向前算了 0.3 秒,后续每帧都累积了偏差。系统需要做的是:把位置回退到服务器确认的那一刻(回滚),然后用保存的这 0.3 秒内的输入重新模拟一遍(推演),得到修正后的当前位置——整个过程在一帧(33ms)内完成,玩家看到的角色轨迹只有极微小的平滑修正,完全无感知。
为什么回滚不可避免
因为难点 A 无法做到 100% 一致——跨平台浮点误差是 IEEE 754 浮点标准决定的,Buff 时序差是网络延迟决定的。只要有差异存在,服务器回包就可能告诉客户端"你算错了"。此时:
如果没有回滚:客户端只能瞬间跳到服务器的正确位置。玩家看到角色突然闪现/瞬移,体验极差——相当于每隔几帧角色就"闪"一下。有了回滚推演:客户端回到出错的那一帧,用正确值重新模拟到现在。如果后续帧的输入没变,重新算出来的位置和原来差别极小,角色只需微调,玩家完全无感知。
回滚推演的三大技术挑战
回滚推演听起来简单——"保存一下、改一下、重算一遍"——但在实际工程中有三个硬约束:
- 存什么:不只是坐标——每帧还有输入、速度因子、技能状态、禁移集合等,都要完整快照。
- 怎么算:推演 10 帧 = 10 次完整物理模拟 + 技能状态更新,且必须在 1 帧(33ms)内完成。
- 视觉不能闪:推演中不能重播动画、创建特效,否则玩家会看到技能"闪烁"或"重放"。
挑战 1 解法:每帧快照 —— 像游戏存档一样
客户端每帧往帧缓冲区里存一个完整快照。Buffer 就像一个环形录像带,最多保存 33 帧(约 1 秒),收到服务器确认后才丢弃旧帧:
每个快照保存的不只是一个坐标,而是一个完整的帧状态:帧号、当时的位置、当时的输入、物理模拟结果、技能系统快照。这些信息让推演时能精确"重放"每一帧的计算过程。
挑战 2 解法:回滚 + 推演 —— 一帧内重算 10 帧
当客户端在第 110 帧收到服务器对第 100 帧的确认,发现位置有偏差时,整个修正过程分 4 步:
- 查找 — 在 Buffer 中定位服务器确认的 Frame 100,对比预测值 (3.01, 0, 5.02) vs 服务器值 (3.00, 0, 5.00),偏差 0.02 > 阈值 0.00001,需要修正
- 回滚 — 将角色位置重置为服务器权威位置 (3.00, 0, 5.00),通知技能系统将状态也回退到确认帧
- 推演(前滚) — 从服务器位置出发,逐帧重新模拟 101→110。每帧:取出保存的输入 → 查该帧速度因子 → 计算速度向量 → 调用 CCT 物理模拟 → 更新技能状态
tick(dt, roll_front_state=1)。最末帧 110 用roll_front_state=2,标记为推演最终帧以修正视觉表现 - 完成 — 恢复帧号到真实值 110,丢弃已确认的旧帧,重新模拟的位置经 Filter 平滑过渡到渲染模型
关键区分:推演不是"近似估计"或"插值"——每一帧都是完整的物理模拟(调用 CCT 做真实碰撞检测),和正常预测帧走的是完全相同的计算路径。10 帧推演 = 10 次 CCT 物理模拟,全部在 1 帧内完成,耗时 <1ms。区别只在于 roll_front_state 参数告诉技能系统"这帧是推演,跳过视觉表现"。
挑战 3 解法:推演状态三态设计 —— 分离逻辑与视觉
移动回滚只涉及一个坐标,相对简单。但技能系统有生命周期、视觉特效、状态机,回滚时绝不能"重新放一遍技能特效"——否则玩家会看到技能闪烁或重放。
解决办法是通过一个推演状态参数,让技能系统知道"当前是正常运行还是回滚推演",从而精确控制行为:
| 值 | 含义 | 技能行为 | 为什么这样设计 |
|---|---|---|---|
0 |
正常预测帧 | 完整执行:播放动画、创建特效、修改状态 | 正常游戏流程,所有表现都需要 |
1 |
推演中间帧 | 只更新逻辑(帧计数器、禁移集合、状态转移),跳过所有视觉表现 | 中间帧的视觉无意义(马上就会被下一帧覆盖),跳过能大幅提升推演速度 |
2 |
推演最末帧 | 更新逻辑 + 修正视觉最终状态(特效播放进度、动画时间点) | 只有最后一帧对玩家可见,需要把视觉"调到正确的进度" |
效果:回滚推演 10 帧耗时极短(纯数学计算 + 物理模拟,不涉及 GPU)。玩家看到的动画始终平滑连续,没有闪烁或重放。如果没有这个设计,每次回滚都重新创建 10 帧的特效 + 播放 10 次动画,一帧内 GPU 提交量暴增,掉帧卡顿 + 视觉闪烁。
技能预测的双端对比:快照比对
客户端每帧为技能拍"快照",服务器也拍。回包时两者比对——如果客户端预测的技能开始帧和服务器一致,说明预测正确;否则需要修正:
客户端快照记录"我认为这个技能从第 100 帧开始执行,当前已运行到第 110 帧";服务器快照记录"服务器确认该技能从第 100 帧开始,服务器当前处理到第 105 帧"。
比对逻辑:若服务器确认的技能开始帧 == 客户端记录的技能开始帧 → 预测正确,技能继续运行;否则 → 预测失败,用服务器的值修正技能状态(可能需要回滚或取消技能)。
实战示例:眩晕预测的回滚
以眩晕被动技能为例:F100 客户端预测眩晕命中(添加禁移 + 播放眩晕特效)→ F105 服务器判定目标有霸体,眩晕无效 → F110 客户端收到 NACK,回滚移除禁移 + 清除特效 → F111 恢复正常移动。预测错误的体验代价是玩家看到眩晕特效闪了一下就消失——仍然比"没有预测但延迟高"的体验更好。
推演细节:历史速度因子的精确回放
推演每帧时,必须使用该帧当时对应的速度因子,而非当前帧的值。服务器会预推送未来 17 帧的速度因子序列,推演时按帧号精确匹配:
推演循环中的速度因子查找过程:
- 在服务器预推送的速度因子序列中,按帧号精确匹配查找当前推演帧对应的服务器速度因子
- 将服务器因子与客户端预测因子逐项相乘,得到最终合成速度因子
- 用合成后的速度因子参与该帧的速度计算和物理模拟
反例:如果错误地使用了"当前帧"的因子来推演历史帧 → 加速 Buff 结束后推演旧帧时,速度因子从 1.5 变成 1.0 → 推演出来的位置比原始预测短了 30% → 角色突然"缩回去"。
难点 B 小结:算法自纠错机制要做到"快、准、静"——快:10 帧完整模拟在极短时间内完成;准:用正确输入重走完全相同的模拟算法,确定性保证输出收敛;静:推演状态三态设计分离逻辑与视觉,修正对玩家完全透明。这就是"输入 → 确定性模拟 → 输出"框架的自稳定性——即使输入有时差,算法重新拿到正确输入后必然收敛到正确输出。
两大难点的协同
难点 A(双端模拟一致)决定了双端输出的一致率。一致性越高 → 回滚修正越少 → 手感越平滑。六层对齐体系(物理引擎 + 动画引擎 + 运动公式 + 外部状态 + 帧对齐 + 误差收敛)把双端模拟偏差压到肉眼不可感知。
难点 B(算法自纠错)决定了不一致时的收敛速度。回滚重模拟越快越精确 → 输出收敛越快 → 即使双端短暂不一致也不影响体验。
缺一不可:只有 A 没有 B → 偶尔不一致就导致角色瞬移,算法脆弱;只有 B 没有 A → 每帧都在回滚重模拟,CPU 负载高,且频繁修正让角色微抖。A 把"需要修正"的概率降到极低,B 把"修正的代价"降到极低——两者共同保证了算法在任何情况下输出都是稳定的。
06基础移动预测系统
框架层面的两大难点讲完了,下面逐个展开各子系统的实现,从最基础的移动预测开始。
6.0 角色位置的多源驱动
在预测系统中,角色位置不是单一运动公式决定的。除了基础摇杆移动外,还有多种机制会改变角色位置,每种机制在预测系统中的处理方式不同:
| 位置源 | 输入来源 | 预测系统中的处理方式 |
|---|---|---|
| 基础摇杆移动 | WASD → 速度 → CCT 物理 | 客户端完整预测(CCT 物理模拟) |
| Buff 速度因子 | 加速 / 减速 / 叠层计算 | 服务器预推送 17 帧速度因子,客户端乘入速度公式 |
| 技能位移 | 冲刺 / 击退 / 钩子拉拽 | 通过 MoveStrategy 替换基础速度计算(冲刺=匀加速 / 钩子=目标点牵引) |
| 动画驱动位移 | RootMotion / 击飞动画 | 切换 motion_type=1,模拟输入源从摇杆变为动画曲线,双端仍独立运行 |
| 恒定重力 | −2.94×dt Y 轴下压 | 每帧固定施加,保证角色贴地 + 高处自然下落 |
| 禁移集合 | 眩晕 / 技能前摇 | 集合非空时跳过整个速度计算和 CCT 模拟,直接返回原位置 |
CCT 物理模式下,各位置源汇聚为速度向量,经碰撞检测输出最终位置;动画驱动模式下(motion_type=1),位置由双端各自运行的动画引擎计算输出。两条路径的结果均经 Filter 平滑到渲染模型。
为什么不能只考虑基础移动? 在追逃玩法中,角色大部分时间都在奔跑(基础移动),但关键时刻的位置变化往往来自非基础移动:被钩子拉回、被击退撞墙、冲刺加速追击、加速 Buff 拉开距离——这些场景恰恰是延迟感最强、最需要预测的时刻。预测系统必须覆盖所有位置源的模拟逻辑,才能保证输出稳定。
这些位置源虽然输入不同,但都遵循同一个核心框架——输入 → 确定性模拟 → 输出。它们的区别仅在于"输入从哪来"和"模拟算法用哪条路径":
- 输入 = 摇杆 + 速度因子:基础摇杆移动双端各自拿到摇杆输入,独立计算速度 + CCT 碰撞。Buff 速度因子由服务器预推送,客户端乘入速度公式后走相同 CCT 路径。禁移集合非空时输入被屏蔽,模拟直接返回原位置。
- 输入 = 技能参数:冲刺/突进由 MoveStrategy 替换速度计算,输入变为技能配置的方向、最大速度、加速度、距离上限,模拟输出仍走 CCT 碰撞。钩子拉拽输入变为目标点坐标 + 牵引加速度。
- 输入 = 动画曲线:击退/击飞切换为动画驱动模式(motion_type=1),位移由引擎动画系统计算,双端仍各自独立运行相同动画。传送/闪现为瞬时位置跳变。翻越障碍的起点/终点由双端各自地形查询确定。
下面 6.1 节展开基础摇杆移动的模拟流水线——这是最核心的路径,也是其他位置源(Buff 速度、MoveStrategy)的计算基础。
6.1 基础移动:从摇杆输入到角色移动
玩家推动摇杆到角色在屏幕上移动,中间经过5 个阶段的模拟计算。客户端和服务器各自独立执行相同流程,客户端先于服务器完成并立即表现:
技能位移时的流水线变化:当角色处于冲刺、突进、钩子拉拽等技能位移状态时,流水线的第 2~3 步被 MoveStrategy 替换:
- 冲刺/突进(RMMoveStrategy):速度不再由摇杆+查表决定,而是按"匀加速→最大速度→距离上限停止"的策略计算,方向和参数由技能配置决定。
- 钩子拉拽(HookMoveStrategy):速度方向指向钩子施放者,按"加速牵引→接近时减速"计算。
这些 MoveStrategy 的输出仍然是速度向量,最终仍送入第 4 步 CCT 碰撞检测——因此冲刺撞墙会停下,被钩子拉到障碍物前也会停住。物理碰撞保证位移结果的合理性。
6.2 独立预测 CCT 实例
客户端创建一个完全独立的 IEntity + CharCtrlComponent,专门用于预测物理模拟,与渲染角色的 CCT 完全隔离:
- 创建一个独立的物理实体(IEntity)和角色控制器组件(CharCtrlComponent)
- 设置碰撞参数——必须与服务器完全一致:碰撞层 = 31、台阶高度 = 0.3、胶囊体半径 = 0.25、半高 = 0.65(总高 1.3m)
- 将实体放入当前场景的根区域,使其能参与碰撞检测
为什么需要独立实例:预测循环需要在一帧内反复调用 CCT 模拟(回滚推演时一帧模拟 10+ 次)。如果复用渲染角色的 CCT,会污染渲染位置导致角色闪烁。独立实例让预测计算和渲染完全解耦。为什么要放入场景:CCT 碰撞检测需要知道周围的墙壁、地形、障碍物,独立 CCT 必须进入场景区域才能获得正确的碰撞信息。
6.3 双端 CCT 物理模拟对比
客户端和服务器的 CCT 调用方式不同(不同引擎 API),但输入输出语义完全对齐:
客户端 (Messiah C++)
新增的 MoveManual 接口:
输入:当前位置(x,y,z) + 速度向量(vx,vy,vz) + dt(0.033)
输出:碰撞检测后的新位置
一次调用完成"设位置 + 移动 + 取结果"
服务器 (MessiahServer C++)
原生 CCT API,分步调用:
Step 1:设置脚底位置为当前坐标
Step 2:调用 move(速度向量, 最小距离0.01, dt=0.033)
输出:从脚底位置读取碰撞后的新位置
两个 API 的调用形式不同,但内部都走 PhysX 的 CCT::move() 碰撞检测管线,输入语义(位置+速度+dt)和输出语义(碰撞后位置)完全一致。
6.4 速度计算全流程
每帧速度合成 5 步流程:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 查表获取基础速度 | 根据 4 个维度查配置表:英雄 ID、阵营(追逐方/逃跑方)、姿态(站立/蹲伏/趴下)、行走方式(步行/奔跑)。不同组合对应不同的基础速度值。 |
| 2 | 叠加 Buff 速度因子 | 最终速度 = 基础速度 × 速度因子。速度因子的三个来源与预推送机制见下方 6.5 节。 |
| 3 | 合成水平速度向量 | X 轴速度 = 方向X × 最终速度 × dt;Z 轴速度 = 方向Z × 最终速度 × dt |
| 4 | 施加固定重力 | Y 轴速度 = −2.94 × dt ≈ −0.097 m/帧,恒定下压,保证角色贴地 + 从高处自然下落 |
| 5 | 送入 CCT 碰撞检测 | 将当前位置 + 速度向量 + dt 传入角色控制器,得到碰撞处理后的新位置 |
pose_type 与动画的关系:姿态类型决定了角色当前的姿态(站立、蹲伏等),它同时影响速度(蹲伏时基础速度降低)和动画(蹲伏时播放蹲伏移动动画)。预测系统只关心速度数值,不直接控制动画播放。动画由渲染侧的状态机根据 pose_type 和速度大小自动匹配。
6.5 速度因子:三源合成与预推送
上述第 2 步中的速度因子不是一个简单数值——它由三个独立来源叠乘合成。在追逃玩法中,加减速 Buff 直接改变速度因子输入——如果双端的速度因子不一致,即使摇杆输入完全相同,模拟输出也会发散。
| 来源 | 计算方式 | 说明 |
|---|---|---|
| Buff 速度因子 | 遍历所有生效中的速度 Buff,同 ID 层数叠加、不同 ID 效果叠乘 | 由 PredictSpeedBuffSystem 每帧计算 |
| Motion 系统因子 | 从 Motion 系统获取当前运动状态的速度修正 | 每 0.5 秒刷新一次,中间帧使用队列缓存值 |
| Gameplay 层因子 | 技能层面的临时加减速,按 skill_hash 索引,逐项叠乘 | 双端各自维护 pd_speed_predict_dict |
最终速度因子 = Buff 因子 × Motion 因子 × ∏(Gameplay 因子),再乘以基础速度得到每帧实际移速。
预推送机制
客户端无法自行计算 Buff 速度因子(Buff 的添加、移除、过期均由服务器控制)。为了让客户端在预测和回滚重模拟时使用正确的速度因子,服务器每帧预推送未来约半秒(17 帧 ≈ 0.56s)的合成速度因子序列——该序列已包含 Buff 因子与 Motion 因子的乘积:
客户端收到后按帧号存储,回滚重模拟时按当前推演帧号查找对应的速度因子。找到则使用服务器预推值,找不到则使用默认值 1.0(无修正)。
Buff 叠加规则
- 相同 Buff ID:层数叠加,每层独立结算生效/结束时间
- 不同 Buff ID:效果逐项叠乘(最终 Buff 因子 = 各 Buff 因子连乘)
- 支持 Buff 临时禁用:临时抑制指定 Buff,禁用期满自动恢复
- 过期 Buff 自动 GC,每帧清理 dirty 列表
以下为部分速度相关 Buff 示例,用于展示不同类型 Buff 对速度因子的影响方式:
| Buff ID | 名称 | 效果 |
|---|---|---|
| 100000 | 眩晕 | 禁止移动 |
| 132011 | 睦邻友好 QTE | 眩晕控制 |
| 132027~29 | 狩猎余韵加速 1~3 级 | 移速提升 |
| 100141 | 惊吓魔盒 | 移速 -50%,0.7s 延迟生效 |
6.6 运动模式切换:物理驱动 vs 动画驱动
游戏中角色的移动有两种驱动方式,通过运动模式属性动态切换:
| 模式 | 值 | 驱动方式 | 预测系统行为 | 典型场景 |
|---|---|---|---|---|
| CCT 物理模式 | 0 |
Python 计算速度 → CCT 碰撞检测 → 物理决定位置 | 客户端独立预测,完整模拟 | 正常移动、奔跑 |
| 动画驱动模式 | 1 |
动画曲线驱动位置(RootMotion),引擎自动应用位移 | 模拟输入源切换为动画曲线,双端各自运行相同动画 | 被击飞、技能位移、过场动画 |
切换时机由服务器通过属性同步触发(如技能释放导致击飞动画)。切换后,模拟的输入源从摇杆变为动画曲线——本质上仍然是双端各自运行相同的模拟,只是模拟内容从"物理驱动位移"变为"动画驱动位移"。动画驱动模式能够正常工作的前提,是服务器也拥有完整的动画 Graph 执行能力(详见 05A 节「动画系统对齐」)。动画驱动模式下 Filter 关闭物理位移应用、摇杆输入清零、yaw 取模型当前朝向。
CCT 模式下的精细控制:在 CCT 物理模式下,还有一个子开关控制客户端的模拟时机——独立模拟(默认,客户端立即执行 CCT 物理模拟)或等待服务器模拟结果(牺牲即时性换取更高一致性)。用途:某些特殊场景下(如服务器需要精确控制位置),可以临时让客户端从"先模拟后对比"切换为"等结果再应用"。
6.7 禁移集合
角色在某些状态下不能移动(如被眩晕、释放技能前摇中)。预测系统通过一个集合管理所有禁移来源:
- 检查:每帧物理模拟开始前,先检查禁移集合是否为空。只要集合非空,直接返回原位置,跳过整个速度计算和 CCT 模拟。
- 添加:技能触发禁移时(如眩晕生效),将自身标识加入集合。
- 移除:技能结束禁移时(如眩晕恢复),将自身标识从集合中移除。
使用集合而非布尔值是因为:多个技能可能同时禁移(眩晕 + QTE),任一个解除后如果用布尔值就会误开移动。集合保证"所有禁移来源都解除后才恢复移动"。这个集合在双端都维护,推演时也参与计算。
6.8 预测位置到屏幕渲染:Filter 平滑
预测系统的输出是一个逻辑位置(CCT 物理模拟后的精确坐标),直接赋给角色会导致微小抖动。Filter 组件在两者之间做平滑过渡:预测逻辑位置 → Filter(输入:累计时间, x, y, z, 朝向角)→ 渲染模型位置。累计时间每帧递增、每 10s 重置一次以防浮点溢出。Filter 在引擎 C++ 层完成从当前渲染位置到预测位置的平滑过渡——玩家操控角色使用 SPLerp 滤波算法(球面线性插值,对方向变化更平滑),其他实体使用 Smooth 滤波算法。回滚推演后的新位置也通过 Filter 输入,保证修正对玩家透明。动画驱动模式下(motion_type=1),Filter 的物理位移应用被关闭,位置完全由动画引擎输出。
两个位置的区别:预测逻辑位置是物理层的精确预测坐标(用于逻辑判断、Buffer 存储、发送给服务器对比);模型渲染位置是经过 Filter 平滑后的位置(用于屏幕显示)。两者在大部分时间几乎一致,只有回滚修正时会短暂出现微小差异,Filter 负责在几帧内消除这个差异。
07技能预测系统
7.1 技能预测:核心算法的延伸
技能预测和移动预测遵循相同的核心框架(输入 → 模拟 → 输出),但在输入完备性上存在本质差异:
移动模拟输入完备——摇杆方向、速度因子等在客户端本地完全可知,双端拿到相同输入执行相同模拟,结果确定性高。技能模拟输入不完备——技能结果依赖对方状态(目标有没有霸体、敌方有没有攻击),这些输入客户端不完全知道,因此双端模拟可能产生不同结果。
输入不完备意味着技能模拟比移动模拟更容易出现双端不一致。但核心算法的回滚重模拟机制天然处理了这种情况:客户端先按已知信息乐观模拟,服务器用完整信息独立模拟后回传结果,客户端收到后通过相同的回滚重模拟流程修正。关键是修正要快、要无感知。
7.2 技能预测通用流水线
7.3 Snapshot 快照与纠错闭环
每个预测技能每帧会生成一份快照(Snapshot),记录该技能当前的预测状态。快照随帧数据在双端传递,用于对比预测是否正确。客户端快照记录"客户端认为的技能开始帧号 + 当前运行帧号 + 各技能自定义字段",服务器快照记录"服务器确认帧号 + 服务器当前帧号 + 模拟结果字段"。快照内容由各技能子类自定义,基类只负责帧号对比。核心逻辑:如果双端的"技能开始帧号"一致,说明预测正确;不一致则修正为服务器的值。这种设计让每个技能可以传递自己需要的额外对比数据(如格挡成功标记、眩晕失败标记等)。
纠错实战:以冲刺技能为例——客户端预测释放后,服务端因碰撞环境不同判定技能在不同位置停下:
pd_identifier 随机标识,立即执行预测表现pd_ack_server_package() 对比双端结果纠错难点:技能纠错不像位置纠错——一个技能的预测效果可能同时涉及:位置变化(冲刺位移)、状态变化(禁移集合 pd_gp_forbid_move_set)、视觉效果(动画、特效、屏幕特效)、UI 状态(技能按钮禁用)。纠错时必须逐一回退所有副作用,且不能让玩家看到"动画重播"或"特效闪烁"。推演状态(roll_front_state)通过 0/1/2 三种模式区分正常模拟、推演中、推演末帧,确保推演时只跑逻辑不跑视觉。
7.4 技能预测架构
所有预测技能通过统一的技能管理系统调度。系统同时管理无需快照对比的技能(如嘲讽,不影响游戏状态)和需要双端快照对比的技能(如眩晕、格挡,影响移动和免疫状态),根据技能类型自动选择是否走快照对比流程。所有已激活的预测技能每帧按优先级排序执行:
| 优先级 | 权重 | 技能类型 | 为什么要排序 |
|---|---|---|---|
| 高 | 1 | 控制类(眩晕、格挡) | 禁移、免疫状态要先算出来,影响后续移动和其他技能的计算 |
| 中 | 0 | 位移类(冲刺、钩子) | 默认优先级,依赖控制类的禁移判定结果 |
| 低 | -1 | 表现类(嘲讽) | 不影响任何游戏状态,最后执行即可 |
1. 将所有已激活的预测技能按优先级从高到低排序
2. 依次执行每个技能的帧更新逻辑,传入当前的推演状态(正常 / 推演中 / 推演末帧)
3. 推演时所有技能都会被重新执行——逻辑照算(禁移、免疫),视觉跳过(特效、动画)
这种统一调度体现了核心算法的一致性:回滚重模拟时,技能系统和移动系统走的是完全相同的模拟路径——禁移集合、免疫 Buff 字典在重模拟中被正确更新,确保移动模拟拿到的输入状态与首次模拟一致,算法输出自然收敛。
7.5 技能系统的工程复杂度
在讨论具体适配案例之前,先理解这个技能系统有多复杂——它不是"播个动画扣个血"的简单模型:
一个技能的执行不是状态机,而是时间轴驱动的嵌套 Action 序列——动画、位移、Buff 添加/移除、碰撞检测、打断判定、AI 暂停……全部混编在一条 timeline 上。技能之间还有打断级联和组互斥。
在这样的复杂度下做预测适配,不现实——"全量复制"整个技能系统到客户端模拟。实际策略是精选可预测子集——目前支持 3 个主动技能 + 8 个被动行为,只同步这些技能的核心输入参数,在双端跑确定性模拟。用最小的适配面覆盖最高频的玩法场景。
7.6 适配案例
以下案例分为两个层级:
- 机制级案例(案例 1–3):聚焦预测系统中某个特定子问题——一种运动模型、一类 Buff 叠加——双端如何保持一致。回答的是“这个机制怎么做预测”。
- 技能级案例(案例 4–6):展示一个完整技能从玩家按键 → 客户端预测 → 服务器确认 → 纠错回滚的端到端流水线。回答的是“这个技能整体怎么走预测流程”。
机制级案例 —— 聚焦单一预测子问题
每个案例拆解一种具体预测机制(运动模型、Buff 叠加等),说明双端如何保持一致。
案例 1:冲刺/突进位移预测
复杂度:中 —— 加速度运动模型 + 碰撞停止一致性
场景:绫(英雄 2010)释放 2 技能冲刺,角色沿面朝方向加速突进一段距离后停下。
不是匀速位移,而是线性加速曲线:speed = start_speed + (max_speed - start_speed) / duration × t,从起始速度匀加速至最大速度后匀速运动。
三重判定:① 达到最大距离 → 减速停止;② 碰撞检测(delta < expected × 0.7 持续 0.1s)→ 撞墙停止;③ 达到最大时间 → 强制停止。
预测难点:加速度模型的每一帧速度都不同,双端必须用完全相同的加速参数(start_speed、max_speed、duration)模拟,否则终点位置发散。碰撞停止判定也必须一致——客户端和服务端的 CCT 碰撞环境可能有微小差异,导致一端认为"撞墙了"而另一端认为"没撞",停止时机不同直接导致终点位置偏差。此外,冲刺中还支持方向微调(角速度转向),方向翻转时角速度归零以抑制抖动。
案例 2:钩子拉拽位移预测
复杂度:中高 —— 目标坐标同步 + 减速梯度逐帧放大误差
场景:加布(英雄 2020)释放 2 技能钩子命中后,将目标拉向自身位置。
每帧重新计算朝目标坐标的方向向量,接近时触发减速梯度:distance < 5m → speed × (distance / 5.0),到达阈值距离后触发 EVENT_CLOSELY_TARGET_POSITION。
冲刺的输入是方向(面朝角度),钩子的输入是目标坐标。这意味着钩子的输入完备性要求更高——双端必须同步目标点的精确坐标,而非仅仅同步摇杆方向。
预测难点:目标坐标来自技能释放时对方的位置——但由于网络延迟,客户端和服务端看到的"对方位置"可能有偏差。如果目标坐标不一致,整条拉拽轨迹都会偏移。加上减速梯度是基于实时距离计算的(每帧重算),微小的目标点差异会在逐帧计算中被放大。
案例 3:多 Buff 速度因子叠加与回滚重放
复杂度:中 —— 多源叠加 + 回滚逐帧重算复合因子
场景:逃跑方同时携带狩猎余韵加速(132027,+30%)和惊吓魔盒减速(100141,-50%),两个 Buff 叠加作用于移速。
预测难点:回滚重模拟时,每一帧必须精确还原当时的复合速度因子。Buff 的添加/移除时机跨越网络延迟——客户端可能在 tick 50 才收到"tick 48 加了减速 Buff"的通知,回滚后 tick 48~50 的速度全部变化,位置连锁修正。服务器通过预推送未来 17 帧速度因子序列缓解此问题,但 Buff 突然添加/移除仍会导致 1~2 帧的预测窗口。
技能级案例 —— 展示完整技能的预测流水线
每个案例展示一个完整技能从玩家按键 → 客户端预测 → 服务器确认 → 纠错回滚的端到端流程。
案例 4:嘲讽
复杂度:低 —— 纯客户端表现,不影响对方状态
技能效果:逃跑方角色做出嘲讽动作,不影响任何人的状态,只是一个视觉表现。
为什么要预测:玩家按下嘲讽键后,如果等服务器确认再播放动作,会有明显延迟——嘲讽动作"发不出来"的感觉。
预测实现方案
- 不需要快照同步——这个技能不影响任何游戏状态,不需要双端对比
- 客户端收到按键后立即播放嘲讽动画
- 如果嘲讽期间玩家推动摇杆移动,自动打断嘲讽(优先级:移动 > 嘲讽)
- 最大持续时间 60 秒,防止异常状态卡死
1. 检测当前是否有摇杆输入(移动方向非零)→ 有则立即结束嘲讽
2. 检测技能已持续帧数是否超过上限 → 超过则强制结束
3. 其余情况:继续播放嘲讽动画,不做任何状态修改
这类技能的特点:预测逻辑极简,不需要服务器确认,不涉及状态回滚。是最容易接入预测系统的技能类型。
案例 5:眩晕被动
复杂度:高 —— 影响移动状态,需要双端对比,有不一致回滚
技能效果:追逐方命中逃跑方后触发眩晕,被眩晕的角色被禁止移动一段时间,并显示眩晕特效(特效 ID: 21543)。在追逃玩法中,眩晕是追逐方的核心控制手段。
为什么预测困难:客户端判断"命中"但服务器可能判断"目标有霸体,眩晕无效"——预测结果依赖客户端不知道的对方状态。
延迟触发 + 帧精确同步:不是立即生效,而是 33 帧后才执行。客户端和服务端必须从同一个起始帧号开始倒计时——起始帧号来自服务器确认,确保双端在同一 tick 触发眩晕。
与格挡的交叉判定:眩晕触发前会检查免疫 Buff 字典——如果目标正在格挡,客户端认为"被格挡了",不执行眩晕。但格挡的生效时机本身也有延迟(4 帧前摇),双端可能在不同 tick 判定格挡是否生效。
多层副作用回滚:眩晕预测成功时产生大量副作用——禁移集合、动画图、眩晕特效、群体眩晕特效、屏幕特效、技能按钮禁用。如果服务器判定眩晕失败,客户端必须逐一清除所有副作用。
预测生命周期
双端模拟不一致时的回滚
当服务器判定目标有霸体、眩晕无效时,会在快照中标记"眩晕失败"。客户端收到后执行回撤:
1. 清除所有预测产生的视觉副作用(移除眩晕特效、停止受击动画)
2. 从禁移集合中移除本技能 ID → 角色恢复移动能力
玩家感受:眩晕特效闪了一下就消失,角色恢复移动。比"延迟 100ms 才开始眩晕"体验更好——至少反馈是即时的。
这类技能的关键点:通过禁移集合与移动预测联动——眩晕期间禁移,回滚时解禁。所有视觉表现受推演状态控制,推演时不重复播放。
案例 6:格挡/弹反
复杂度:最高 —— 有前摇、免疫窗口、格挡成功判定、动态时长扩展,多种双端对比结果
技能效果:逃跑方角色举盾格挡,前摇 4 帧后进入免疫窗口。如果在免疫期间被追逐方攻击,触发"弹反"效果(成功格挡),免疫持续 3 帧后结束;如果没有被攻击,持续到技能总时长 35 帧后自然结束。格挡是逃跑方在被追击时的关键自保技能。
为什么预测困难:
眩晕 ↔ 格挡 双向联动:眩晕被动在触发时检查免疫 Buff 字典,如果格挡存在则 dispatch 免疫事件。格挡技能监听此事件,标记格挡成功并延长技能持续时间用于播放弹反动画。两个技能在预测时间线上互相影响。
4 帧前摇精确控制:格挡释放后的前 4 帧,免疫状态尚未生效。如果眩晕恰好在这 4 帧内触发,格挡无效——但客户端和服务端可能因为网络延迟在不同 tick 判定"是否过了前摇",导致结果分歧。
格挡成功后免疫窗口消耗:格挡成功后免疫 Buff 字典仅保留 3 帧(播放弹反动画期间),之后移除。这意味着格挡是一次性消耗品——成功一次后即失效,不会重复触发。
时间线与状态机
每帧预测逻辑
1. 技能是否结束?
已持续帧数 ≥ 总时长 → 清除特效,结束技能
2. 技能进行中 → 是否过了前摇?
已持续帧数 ≤ 4(前摇中)→ 不免疫
已持续帧数 > 4(过了前摇)→ 进入免疫判定:
2a. 是否已经格挡成功?
尚未成功 → 持续免疫(等待被攻击触发格挡)
已经成功 → 判断距离成功帧的时间:
≤ 3 帧 → 免疫有效(格挡后的短暂保护)
> 3 帧 → 移除免疫,结束技能
服务器快照携带的三个关键信息
| 信息 | 含义 | 客户端如何修正 |
|---|---|---|
| 技能结束帧 | 格挡成功后技能时长被缩短(从 35 帧变为成功帧+3) | 用服务器的值更新客户端的技能结束时间 |
| 格挡是否成功 | 服务器判定:在免疫窗口内是否受到了攻击 | 如果客户端没预测到格挡成功,补发免疫效果 |
| 格挡成功帧号 | 格挡成功时对应的客户端帧号 | 对齐免疫窗口的起始时间,确保"成功后 3 帧"双端一致 |
双端对比的四种结果
这类技能的关键点:预测逻辑必须双端完全一致(服务器也运行同样的 pd_tick),快照字段精确传递格挡成功帧号以对齐免疫窗口,四种对比结果都有专门的修正路径。这是预测系统中技术复杂度最高的技能类型。
两个预测技能在同一条预测时间线上相互影响:眩晕检查格挡的免疫字典,格挡的免疫事件被眩晕 dispatch 触发。预测失败时两个技能都需要独立回滚到正确状态。设计原则:以服务器为准,目前不对格挡做延迟补偿——格挡和眩晕各自修正为服务器状态即可。
适配案例汇总
| # | 层级 | 案例 | 核心难点 | 一致性策略 |
|---|---|---|---|---|
| 1 | 机制级 | 冲刺位移 | 加速度模型 + 碰撞停止一致 | 参数对齐 + CCT 统一 |
| 2 | 机制级 | 钩子拉拽 | 目标坐标同步 + 减速梯度 | 技能参数完整传递 |
| 3 | 机制级 | 速度因子叠加 | 多源叠加 + 回滚逐帧重算 | 预推送 17 帧因子序列 |
| 4 | 技能级 | 嘲讽 | 无状态影响,最简模型 | 纯客户端预测,无需快照 |
| 5 | 技能级 | 眩晕被动 | 延迟触发 + 格挡交叉 + 多层副作用 | 快照帧号对齐 + 禁移集合联动 |
| 6 | 技能级 | 格挡/弹反 | 双向事件信号 + 4 帧前摇窗口 | 四种对比路径 + 免疫窗口对齐 |
08对抗网络波动
以上章节介绍了预测系统的框架、子系统和技能适配。接下来关注预测系统面对真实网络环境时的核心挑战——网络波动对抗。
为什么网络波动对预测系统尤其致命
传统状态同步下,网络波动的后果只是"延迟变大"——玩家能接受。但预测系统不同:它的整个逻辑建立在"客户端先行、服务器后验"的时差模型上。一旦网络波动打破这个模型的假设,后果不是"慢一点",而是系统性崩溃:
- 丢包:客户端发出的输入丢失 → 服务器收不到输入、无法模拟 → 双端状态开始分叉 → 回包后触发大幅回滚修正 → 角色瞬移
- 延迟突增:RTT 从 60ms 突然跳到 300ms → 预测窗口(33 帧缓冲)不够用 → 客户端输入堆积、服务器确认追不上 → 预测失效
- 抖动:延迟在 50~200ms 之间频繁跳变 → 服务器输入到达节奏不稳 → 缓冲区忽满忽空 → 模拟节奏被打乱
因此,预测系统必须内建一套完整的网络波动对抗机制,在丢包、延迟突变、抖动等各种状况下维持稳定输出。以下是四个层面的应对设计:
| 网络问题 | 对预测系统的影响 | 应对机制 | 详见 |
|---|---|---|---|
| 丢包 | 服务器输入缺失,被迫停顿或盲猜 | 冗余发包(每帧携带 8 帧历史输入) | 8.3 冗余发包与丢包恢复 |
| 连续丢包 / 断连 | 服务器输入缓冲区耗空 | 服务器饥饿预测(复用上一输入继续模拟) | 8.1 服务器饥饿预测 |
| 延迟突增 | 预测窗口不够用,系统失效 | 延迟检测 + 动态开关(RTT > 200ms 关闭预测) | 8.5 延迟检测与动态开关 |
| 抖动 | 输入到达节奏不稳,缓冲区忽满忽空 | 自适应发送频率(根据缓冲区水位动态调节) | 8.4 自适应发送频率 |
8.1 服务器饥饿预测
当网络丢包导致服务器输入缓冲区为空时,服务器不会停止模拟,而是进入饥饿预测模式:
设计原则:服务器绝不预测技能释放。服务器饥饿预测仅复用移动方向,技能指令一律清空。技能释放涉及游戏结果判定,错误预测的代价远高于延迟。若饥饿期间客户端实际发出了技能指令,待收到后通过指令追加队列立即补发。
8.2 协议栈设计
| 层级 | 客户端 → 服务器 | 服务器 → 客户端 |
|---|---|---|
| 应用层 | ClientFrameDataList (多帧打包) | ServerFrameData (单帧) |
| 序列化 | Protobuf 二进制 | Protobuf + JSON (skill_delta) |
| 压缩 | zlib 压缩 | RLE (speed_factor_list) |
| 传输 | 裸 UDP(最低延迟,冗余替代重传) | UDP + KCP 可靠传输(保证每帧送达) |
上行使用裸 UDP 是一个有针对性的取舍。常规游戏网络开发中,裸 UDP 通常被认为"不可用"——没有重传、没有确认、没有顺序保证,丢了就是丢了。但预测系统的上行数据有一个关键特征:旧帧没有价值——一个迟到 100ms 的操作输入对预测毫无意义,KCP 的 ARQ 重传反而会引入不必要的延迟和包体膨胀。因此我们做了一个精准的取舍:放弃传输层可靠性,用应用层冗余发包(每包携带最近 8 帧历史输入)替代重传,以 FEC(前向纠错)的思路,在最低传输延迟下实现等效的可靠性。下行方向则相反——服务器回包包含权威状态,每帧结果不可替代,必须可靠送达,因此使用 KCP。
8.3 冗余发包与丢包恢复
滑动窗口机制:
- 客户端每帧发送时,附带最近 8 帧的历史输入,实现前向纠错
- 服务器连续 3 帧未收到输入时,开启滑动窗口(大小 13 帧 ≈ 400ms),通知客户端加速发包
- 客户端根据服务器反馈的缓冲区保障大小动态调节发送频率
- 带宽保护:如果单帧压缩后包体超过 400B,放弃冗余发包,仅发送当前帧,避免超过 UDP MTU(~1400B)导致 IP 分片
8.4 自适应发送频率
- 服务器缓冲区积压(> 2 帧)→ 客户端发送间隔 × 1.1(降频,减少积压)
- 服务器缓冲区即将耗尽(≤ 1 帧)→ 客户端发送间隔 × 0.9(加速,防止饥饿)
8.5 延迟检测与动态开关
| 参数 | 值 | 说明 |
|---|---|---|
| 预测开启条件 | 0 < RTT < 200ms | RTT 未测出(=0)或超过 200ms 均不开启预测 |
| Ping 算法 | KCP RPC ping-pong | 取最近 5 包均值,包含双端逻辑循环开销 |
| 预测动态开关 | 动态属性 | 按英雄、网络条件、回放模式动态控制 |
09调试与可视化
开启 PREDICT_VISUAL_ENABLE=True 后,场景中会渲染三个球体:红球(客户端预测位置,r=0.3)、紫球(服务器权威位置,r=0.1)、绿球(回滚重模拟位置,r=0.2)。三球重合说明预测准确;分离距离直观反映偏差大小。
实时监控面板
| 指标 | 说明 |
|---|---|
| 当前帧号 | 客户端预测系统当前处理到的帧序号 |
| 待确认帧数 | 缓冲区中等待服务器确认的帧数量(反映网络延迟) |
| 预测误差距离 | 客户端预测位置与服务器权威位置之间的偏差(米) |
| 当前移动速度 | 经 Buff 因子调整后实际应用的速度值 |
| 双向延迟 | 客户端到服务器的往返延迟 (ms) |
| 上/下行包体大小 | 经 Protobuf + zlib 压缩后的发送/接收字节数 |
| 回滚推演帧数 | 单次回滚时需要重新模拟的帧数量 |
10技术亮点总结
| 技术要点 | 方案设计 | 效果 |
|---|---|---|
| 输入延迟消除 | 客户端预测 + 服务器权威 + 异步对比修正 | 感知延迟从 RTT (~150ms) 降至 ~0ms |
| 双端模拟一致性 | 6 层对齐模型(物理引擎 + 动画引擎 + 运动公式 + 外部状态 + 帧对齐 + 误差收敛) | 回滚修正幅度极小,玩家无感知纠错 |
| 物理引擎对齐 | CCT 参数统一、碰撞层对齐、物理场景数据同源、PhysX 胶囊体一致 | 双端 CCT 碰撞输出一致,消除基础移动偏差 |
| 服务器 Graph 动画引擎 | 自研服务器端 Graph 动画引擎,做运动提取(RootMotion)和动画状态模拟(技能打断),不做骨骼蒙皮渲染 | 服务器具备动画驱动位移能力,技能位移、击飞等场景双端输出一致 |
| 回滚确定性 | 三态分离(逻辑 / 推演 / 渲染)+ 固定帧率 + 帧号精确匹配速度因子 | 回滚后重模拟结果可复现,推演帧不产生视觉副作用 |
| 多源位置驱动 | motion_type 双模式切换(CCT 物理 / 动画驱动),统一 MoveStrategy 抽象 | 摇杆移动、技能冲刺、击飞、钩子拉拽等全部位置源纳入预测 |
| 技能预测 | Snapshot 双端对比 + Reject 回撤 + 副作用清理机制 | 技能释放即时响应,错误预测平滑回撤 |
| Buff 速度补偿 | 服务器预推送 17 帧速度因子 + RLE 压缩 | 加减速场景下预测误差大幅降低 |
| Filter 平滑 | 引擎 C++ 层滤波(玩家角色 SPLerp / 其他实体 Smooth),修正幅度在 1~2 帧内消化 | 回滚修正对玩家完全透明,无跳变感知 |
| 传输策略分离 | 上行裸 UDP(冗余替代重传)+ 下行 KCP 可靠传输 | 上行最低延迟保操作响应,下行保证权威结果不丢 |
| 带宽优化 | Protobuf + zlib + RLE 多级压缩 | 典型单帧包体 < 150B,适配移动网络 |
| 网络波动对抗 | 冗余发包 (8帧) + 服务器饥饿预测 + 自适应发送频率 + 延迟检测动态开关 | 丢包、抖动、延迟突变下仍保持稳定输出 |
11Q & A
Q1:预测系统的性能开销大吗?
首先要明确一个前提:每个客户端只预测自己操控的角色。6 人对局(2V4)中,其他 5 个玩家的位置、动画、轨迹都由服务器状态同步驱动,不走预测系统。预测系统的计算量 = 1 个角色的模拟开销,不会随对局人数增长。
- 客户端计算:常规帧只做 1 次速度合成 + 1 次 CCT 碰撞 + 1 次 Filter 输入,计算量远小于渲染帧。独立 CCT 实例不带渲染组件,纯物理模拟。回滚帧(偶发)最坏重模拟 10+ 帧,实测总耗时极短(详见 05B 节)。
- 服务器计算:每个预测玩家每秒 30 次模拟 tick。6 人对局 = 每秒 180 次 tick。服务器每 5 秒采样"饥饿预测率",正常网络下 < 5%,无额外开销。
- 带宽:典型单帧上行 < 150B(Protobuf + zlib),含 8 帧冗余 < 400B(超限自动降级为单帧),30 FPS 上行带宽远低于手游移动网络预算。
Q2:表现良好的核心要点是什么?
预测系统"表现好"不是指"预测总是对的",而是"对的时候立即响应,错的时候玩家无感知"。实现这一点靠三个层面:
- 一致率要高:六层对齐(物理引擎 + 动画引擎 + 运动公式 + 外部状态 + 帧对齐 + 误差收敛)把双端模拟偏差控制在修正阈值(0.00001m)以下,绝大多数帧不触发回滚。一致率越高,玩家越感受不到预测的存在。
- 修正要无感:回滚重模拟走完全相同的模拟路径,输出与原始预测差异极小。Filter 组件在引擎层做位置平滑过渡,修正幅度在 1~2 帧内消化完毕,玩家肉眼不可见。
- 输入要同步:影响模拟结果的所有输入(速度因子、禁移状态、运动模式)都有对应的同步机制——预推送、属性同步、集合同步。输入一致了,输出自然一致。
反过来说,如果一致率低(每帧都修正)或修正不平滑(直接跳到新位置),玩家会感到角色"一会前进一会后退",手感比不预测更差。
Q3:这是竞技游戏,公平性怎么保证?时间差带来的逻辑冲突怎么解决?
核心原则:预测只是客户端的视觉提前量,不影响服务器的判定结果。
所有玩家的输入最终都汇集到服务器,由服务器按统一的帧序逐帧处理。服务器不关心输入是早到还是晚到——它只看自己当前帧该消费哪个输入,按到达顺序放入缓冲区,逐帧取出执行。每个玩家的模拟在服务器上是独立串行的,不存在并发冲突。
时间差带来的典型冲突与解法:
| 冲突场景 | 服务器处理方式 |
|---|---|
| A 的客户端预测"眩晕命中 B",但服务器判定 B 有霸体 | 服务器 Reject,A 的客户端回撤眩晕表现。以服务器判定为准 |
| A 和 B 同时对对方释放技能,谁先生效? | 取决于服务器收到谁的输入先。服务器按帧序消费,先到先处理,结果确定性 |
| 高延迟玩家的输入晚到,服务器已经跳过了该帧 | 服务器检测到帧号过期,丢弃该输入。但技能释放命令会追加到当前帧执行,避免操作丢失 |
| 丢包导致服务器没收到输入 | 服务器用上一帧输入预测(保持当前方向),标记为"预测帧"。后续收到迟到的真实输入时,补发其中的技能命令 |
本质:预测系统不改变游戏的判定公平性——它只改变了"玩家什么时候看到结果",不改变"结果是什么"。所有判定由服务器统一执行,所有客户端最终收敛到同一结果。
Q4:为什么不统一用 KCP?
常见疑问:KCP 已经是可靠 UDP,性能不差,为什么不上下行统一用 KCP 省事?
因为上行和下行的数据价值模型完全不同(详见 8.2 节协议栈设计)。上行帧数据是"易腐品"——一个迟到 100ms 的旧帧对预测毫无价值,KCP 的重传反而浪费带宽和时间;下行权威结果是"必达件"——丢一帧意味着客户端无法修正,偏差累积。统一用 KCP 看似简单,实际上让上行为不需要的可靠性付出了延迟代价——这和预测系统"消除延迟"的目标直接矛盾。
一句话:上行丢了可以补(冗余 8 帧),下行丢了不能忍(权威结果不可替代)。数据特征不同,传输策略必须不同。