ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

UE5服务端倒带(Server-Side Rewind)

UE5服务端倒带(Server-Side Rewind) 3.3 帧查找双链表二分查找// 第368-423行 FFramePackage GetFrameToCheck(ABlasterCharacter* HitCharacter, float HitTime) { const TDoubleLinkedListFFramePackage History HitCharacter-GetLagCompensation()-FrameHistory; // 边界检查 if (History.GetTail()-GetValue().Time HitTime) return FFramePackage(); // 延迟太大 if (History.GetTail()-GetValue().Time HitTime) return History.GetTail()-GetValue(); if (History.GetHead()-GetValue().Time HitTime) return History.GetHead()-GetValue(); // ═══ 双指针二分查找 ═══ auto* Younger History.GetHead(); // 较新帧 auto* Older Younger; // 较旧帧 while (Older-GetValue().Time HitTime) { if (Older-GetNextNode() nullptr) break; Older Older-GetNextNode(); if (Older-GetValue().Time HitTime) Younger Older; } // 此时: Older.Time HitTime Younger.Time if (Older-GetValue().Time HitTime) return Older-GetValue(); // 精确匹配 return InterpBetweenFrames(Older-GetValue(), Younger-GetValue(), HitTime); }Server-Side Rewind服务端倒带实现机制实现服务端倒带Server-Side Rewind命中判定系统服务端以TDoubleLinkedList按 60Hz 录制角色全部碰撞盒位姿快照4 秒滑动窗口自动裁剪。收到客户端ServerScoreRequestRPC 后通过双链表回退检索命中时刻两侧帧、线性插值InterpBetweenFrames还原精确位姿执行两阶段射线验证头部优先检测 → 全身遍历适配 Hitscan 射线、PredictProjectilePath弹道预判、霰弹散射三种武器类型。ConfirmHit2集成 AOI 空间哈希快速拒绝层将宏观检索从 O(N) 压至 O(1)。一、为什么需要客户端Ping 100ms看到敌人 → 开枪 → 命中 ↓ RPC 延迟 100ms 服务器收到请求时 → 敌人已经移动了 → 碰撞体不在原地 → 判定 MISS ​ SSR 解决服务器倒带回客户端开枪时刻的碰撞体位置重新判定。二、数据结构① 帧快照FFramePackageLagCompensationComponent.h:27struct FFramePackage { float Time; // 服务器时间戳 TMapFName, FBoxInformation HitBoxInfo; // head/pelvis/spine... 碰撞盒位姿 ABlasterCharacter* Character; // 哪个角色 }; ​ struct FBoxInformation { FVector Location; // 世界坐标 FRotator Rotation; // 朝向 FVector BoxExtent; // 碰撞盒半尺寸 };② 双链表帧历史—TDoubleLinkedListFFramePackage FrameHistory每帧在SaveFramePackage()中插入新帧到头部超出MaxRecordTime的旧帧从尾部移除。三、记录阶段每帧保存快照// LagCompensationComponent.cpp:573 void SaveFramePackage() { // 仅在服务器执行 if (!HasAuthority()) return; ​ // 采集当前帧所有碰撞盒的 Location/Rotation/Extent FFramePackage ThisFrame; SaveFramePackage(ThisFrame); ​ // 插入双链表头部最新帧在前 FrameHistory.AddHead(ThisFrame); ​ // 尾部裁剪超过 MaxRecordTime(默认4s) 的帧删除 while (HistoryLength MaxRecordTime) FrameHistory.RemoveNode(FrameHistory.GetTail()); }四、查询阶段GetFrameToCheck— 二分查找历史帧// LagCompensationComponent.cpp:440 FFramePackage GetFrameToCheck(HitCharacter, HitTime) { // ① 边界保护 if (HitTime 最旧帧时间) return FFramePackage(); // 太迟了放弃SSR ​ // ② 特殊情况优化a if (HitTime 最旧帧时间) 直接返回尾帧精确命中 if (HitTime 最新帧时间) 直接返回头帧精确命中 ​ // ③ 双链表向后遍历找 Older.Time HitTime Younger.Time 这一对 Older Younger Head; while (Older-Time HitTime) { Older Older-NextNode; // 向尾部回退时间递减 if (Older-Time HitTime) Younger Older; } ​ // ④ 帧间插值 if (没精确命中) { return InterpBetweenFrames(Older, Younger, HitTime); } }InterpBetweenFrames— 线性插值还原精确位姿LagCompensationComponent.cpp:26InterpFraction (HitTime - Older.Time) / (Younger.Time - Older.Time); ​ 对每个碰撞盒 Location Lerp(Older.Location, Younger.Location, InterpFraction) Rotation Lerp(Older.Rotation, Younger.Rotation, InterpFraction) BoxExtent Younger.BoxExtent // 尺寸不变五、命中确认阶段两阶段射线检测// LagCompensationComponent.cpp:54 (ConfirmHit) / :116 (ConfirmHit2 AOI) FServerSideRewindResult ConfirmHit(Package, HitCharacter, TraceStart, HitLocation) { // ① 先关掉角色 Mesh 碰撞避免自己挡自己 EnableCharacterMeshCollision(HitCharacter, NoCollision); ​ // ② 把碰撞盒倒带移到历史 Package 记录的位置 MoveBoxes(HitCharacter, Package); ​ // ③ 阶段一头部检测优先级高 Enable ONLY head box → LineTrace → 命中 → return {HitConfirmedtrue, HeadShottrue} ​ // ④ 阶段二全身检测 Enable ALL boxes → LineTrace → 命中 → return {HitConfirmedtrue, HeadShotfalse} ​ // ⑤ 恢复碰撞盒到当前帧 ResetHitBoxes(HitCharacter, CurrentFrame); EnableCharacterMeshCollision(HitCharacter, QueryAndPhysics); }ConfirmHit2AOI 加速版LagCompensationComponent.cpp:116在 ③ 之前加了AOI 空间哈希快速拒绝— 目标不在命中位置周围 9 格内 → 直接返回 false跳过后续昂贵的盒位移RayTrace。六、完整网络流程客户端 服务器 │ │ ├─ 射线命中 Enemy │ ├─ PostHit true, HitTime Now │ │ │ ├─ ServerScoreRequest ──────────→ ServerScoreRequest_Implementation │ (Enemy, TraceStart, │ → ServerSideRewind(Enemy, Start, Hit, Time) │ HitLocation, HitTime) │ → GetFrameToCheck(Enemy, HitTime) // 倒带 │ │ → ConfirmHit2(Package, Enemy, ...) // AOIRay │ │ ├─ 命中 → ApplyDamage() │ │ └─ 未命中 → 日志记录 │ │ ←─ ApplyDamage RPC ──────────────┤ │ 生效 │三种武器各自独立Hitscan→ServerScoreRequest→ConfirmHitProjectile→ProjectileServerScoreRequest→ProjectileConfirmHit弹道预测验证Shotgun→ShotgunServerScoreRequest→ShotgunConfirmHit多弹丸累加七、关键设计决策决策原因TDoubleLinkedList非 TArray头插/尾删 O(1)历史窗口滚动无需整体搬移MaxRecordTime 4s超过 4s 的延迟视为断线SSR 放弃头部优先检测单独一轮爆头判定优先级高于身体一次命中即返回InterpFraction线性插值服务器 60Hz 快照间隔 16ms线性够用AOI 快速拒绝ConfirmHit2跳过不属于命中区域的目标O(1) 宏观过滤2、高ping就要关掉SSR这是一个非常深刻的问题触及了多人射击游戏网络同步的核心矛盾。直觉上SSRServer Side Rewind / 服务端回滚 / 延迟补偿就是为了拯救高 Ping 玩家而生的。Ping 越高越需要补偿为什么要关掉它答案在于SSR 不是魔法它有物理上限和公平性代价。当 Ping 超过某个阈值后继续开启 SSR 带来的“恶果”将远超“收益”。以下是深层原理拆解1. 技术原理历史帧缓冲区的硬限制SSR 的本质是服务器在内存中维护一个环形缓冲区Ring Buffer存储过去 N 帧的所有 Actor 位置快照。时间轴: [t-20] [t-19] ... [t-5] [t-4] [t-3] [t-2] [t-1] [当前帧] ↑ ↑ 缓冲区起点 最新快照 (最多回溯20帧)正常 Ping50ms客户端发送的命中请求对应t-3时刻服务器轻松找到该帧快照精确验证命中。✅超高 Ping300ms客户端请求对应t-25时刻但缓冲区只存了 20 帧。该帧已被覆盖销毁。❌此时服务器面临两个选择A. 扩大缓冲区存更多帧 → 内存占用暴增遍历查找耗时线性增长拖垮所有玩家的服务器性能。B. 钳制到最旧帧用t-20代替t-25验证 → 命中判定基于5帧前的错误位置产生严重的幽灵命中。⚠️关键认知SSR 的有效窗口 缓冲区大小 × 服务器帧间隔。例如 64 Tick 20帧缓冲 最大补偿约 312ms。超过这个值SSR 在数学上就不再准确。2. 公平性原理“时光机悖论”即使缓冲区足够大超高 Ping 下的 SSR 也会破坏其他玩家的体验场景低 Ping 玩家视角高 Ping 玩家视角SSR开启问题躲入掩体我已经进掩体 200ms 了我的画面里你还没进掩体开枪命中低Ping玩家被穿越时空打死对枪我先击中对方并看到血花我晚200ms才开枪但SSR判定我赢了低Ping玩家的先发优势被抹除本质矛盾SSR 把高 Ping 玩家的延迟代价转嫁给了低 Ping 玩家。当延迟较小时这种转嫁是可接受的误差但当延迟过大时它就变成了系统性的不公平。3. 安全原理攻击面暴露SSR 要求服务器信任客户端上报的时间戳来定位历史帧。超高 Ping 环境下时间戳与真实服务器时间的偏差极大校验窗口被迫放宽作弊者可以伪造更大的延迟迫使服务器回溯到异常久远的帧利用宽松的校验窗口实现回溯瞄准Rewind Aimbot合法地击中已经不在那个位置的敌人关闭 SSR 等于收窄了攻击面让作弊者无法利用延迟补偿机制作为掩护。4. 设计哲学分级降级策略成熟的竞技游戏不会简单地“开/关”SSR而是采用分级降级Ping 80ms → 完整 SSR 插值平滑 最佳体验 80-150ms → SSR 减少插值 可接受误差 150-250ms → SSR 钳制到缓冲区边界 UI警告 体验下降但可用 250ms → 关闭 SSR 纯客户端预测 自担延迟惩罚 400ms → 踢出/限制匹配 保护其他人你代码中的bUseServerSideRewind !bPingTooHigh就是这个分级策略中最关键的熔断开关——当补偿机制本身成为问题时果断放弃补偿回归最基础的权威校验模型。 一句话总结SSR 是用“对其他玩家的微小不公”换取“高 Ping 玩家的可玩性”。当 Ping 高到这种不公变成“明显荒谬”时关掉 SSR 是对整体游戏生态的最优解。这也是为什么你在简历中写这个功能时应该强调“公平性与体验的权衡”而非单纯的“技术实现”——面试官想看到的正是你对这种设计 trade-off 的理解深度。
返回列表