UE5.8 Lag Compensation 与命中验证

📑 目录

老 K 端着狙击枪趴在仓库二楼,准星稳稳套住对面屋顶敌人的脑袋。他屏住呼吸,扣下扳机。客户端立刻播放枪焰与命中特效,但击杀提示没有弹出。右上角弹出一条冰冷的提示:命中 0。他的 ping 只有 85 ms,帧率稳定,准星也确实在目标头上。这枪为什么没中?

答案藏在时间里。在服务器看来,扣下扳机这个事件发生在 85 ms 之前。那名敌人当时并不在准星所指的位置,而是已经向前跑出了小半步。服务器只认可它自己眼中的世界,于是这枪被判定为未命中。没有延迟补偿的射击游戏,低延迟玩家天然占便宜,高延迟玩家必须打提前量,体验很糟。Lag Compensation 就是为了让服务器在判定命中时,临时回到射手开火瞬间所看到的状态。

延迟补偿的必要性

在多人在线射击游戏里,服务器拥有最终裁决权。玩家输入要经过网络才能到达服务器,这个过程至少需要半个往返时间。服务器处理完输入后,再把结果同步回客户端。客户端收到的新状态已经又落后了半个往返时间。如果服务器只按当前世界状态做命中检测,高延迟玩家看到的目标位置和服务器认定的位置永远对不上。

客户端显示的画面还不是服务器最新状态。为了平滑角色移动,网络同步通常采用插值:客户端收到两个历史快照后,在它们之间做混合。这意味着玩家看到的画面还要再老一截。设往返延迟为 RR,单向延迟约为 R/2R/2;插值缓冲为 tit_i;抖动缓冲为 tbt_b。那么玩家看到的画面相对于服务器当前时刻的年龄大约是:

trewind=R2+ti+tbt_{\text{rewind}} = \frac{R}{2} + t_i + t_b

服务器要把命中检测回退到这个时间点,才能与玩家开火时看到的情景一致。这个回退操作就是 Rewind。

以 60 Hz 服务器 Tick 为例,每帧约 16.7 ms。插值通常取前后两个快照,额外增加 16 ms 到 33 ms。再加上 85 ms 单向延迟和 10 ms 抖动缓冲,总的视在延迟轻松超过 100 ms。一名奔跑中的敌人速度约为 5 m/s,100 ms 内会移动 0.5 m。人物头部碰撞半径大约 0.15 m,所以如果不做延迟补偿,瞄准头部的子弹几乎一定从脖子后面穿过。

有人可能会想,直接去掉插值不就能让玩家看到最新状态吗?实际上去掉插值后,角色会在每个快照之间跳跃,移动会变得非常生硬。插值是用时间换平滑,延迟补偿则是用历史数据换公平。两者结合在一起,才是现代射击游戏网络同步的标准答案。

服务器端 Rewind

Unreal Engine 5.8 的服务器端 Rewind 通常依赖一套位置历史缓冲区。每个受影响的角色在每个 Tick 结束时,把自己的根变换、胶囊体姿态以及关键骨骼信息存进环形缓冲区。收到射击 RPC 时,服务器根据射手的网络延迟和插值设置,计算应该回退到哪个时刻,然后从历史中取出对应的快照。

flowchart TD
    A["Client fires"] -->|"Fire RPC"| B(Server receives)
    B --> C{Ping within limit?}
    C -->|No| D["Reject shot"]
    C -->|Yes| E["Compute rewind timestamp"]
    E --> F["Restore hitboxes from history"]
    F --> G["Run line trace"]
    G --> H{Hit?}
    H -->|Yes| I["Apply damage"]
    H -->|No| J["Notify miss"]
    I --> K["Restore current transforms"]
    J --> K
    D --> L["Return result"]
    K --> L

恢复命中框后,服务器用客户端提供的射线起点和方向做检测。检测完成后,必须立刻把角色状态恢复成当前时刻,避免影响后续物理、移动和技能判定。Rewind 只影响命中检测这一瞬间,不会真正改变游戏世界的时间线。

sequenceDiagram
    participant C as Client
    participant S as Server
    participant P as PositionHistory
    participant H as HitboxDB
    C->>S: Fire(origin, dir, clientTime)
    S->>S: rewind = now - latency - interp
    S->>P: GetSnapshot(rewind)
    P-->>S: actor transforms
    S->>H: Build transient capsules
    H-->>S: hitbox set
    S->>S: LineTrace(origin, dir)
    S->>S: Restore current transforms
    alt hit confirmed
        S->>C: ApplyDamage(target)
    else miss
        S->>C: ReconcileMiss
    end

这里的关键是只回退与命中相关的数据,例如胶囊体位置和朝向。完整的世界状态不会回退,物理堆叠、破坏效果、道具掉落都保留在服务器当前时刻。这样既能保证射击手感,又不会引发因果倒置。

位置历史缓冲区一般用环形数组实现,按服务器 Tick 序号索引。每个条目只保存最小必要的数据:位置、旋转、胶囊体半高和半径,以及若干关键骨骼的变换。保存全部骨骼太贵,通常只对头部、脊柱和四肢做采样。查询时先用目标时间换算成 Tick 号,再在相邻两个快照之间线性插值,得到更平滑的中间姿态。

Rewind 的范围也不宜扩展到整张地图。只需要恢复射手前方、武器射程内的目标。可以用空间划分或者简单的距离判断来筛选候选角色。这样做能显著降低 CPU 开销,尤其在 64 人大战场或者百人吃鸡模式里尤为重要。

客户端射击验证

客户端在开火时不能干等服务器回应。玩家需要立即看到枪焰、曳光弹和命中反馈。这些效果可以本地预测,但真正的伤害只能由服务器下发。服务器在验证射击时,会检查客户端传来的起点、方向和射击时间是否合理。

设客户端上报的射线起点为 oc\vec{o}_c,服务器根据射手当前位置反推的合理起点为 os\vec{o}_s。两者的偏差为:

d=ocosd = |\vec{o}_c - \vec{o}_s|

方向偏差用夹角 θ\theta 表示,满足 cosθ=d^cd^s\cos\theta = \hat{d}_c \cdot \hat{d}_s。服务器会设置一个容差窗口。正常网络抖动带来的偏差应该落在窗口内,超出窗口的射击会被拒绝。这样既允许延迟补偿发挥作用,也防止客户端随意伪造射线。

客户端验证还要考虑本地预测误差。Network Prediction 会让玩家看到自己立刻移动,但服务器可能在几帧后校正位置。如果服务器用校正后的位置反推起点,而客户端用预测位置上报起点,两者之间会出现误差。这个误差通常很小,容差要覆盖它。过严会导致正常玩家被误判为作弊,过松则会给外挂留出空间。

射击时间也不能完全信任客户端。服务器应该记录收到 RPC 的服务器时间,并基于连接的平均延迟估算开火时刻。客户端传来的 clientTime 只能作为参考和校验,不能成为唯一依据。否则外挂可以任意提前或延后射击时间,绕过 Rewind 窗口的限制。

命中框与射线检测同步

命中框是简化过的碰撞体,通常绑定在骨骼或 socket 上。服务器不需要精确到每一根手指,常见的做法是为头部、躯干、四肢分别设置胶囊体或球体。这些胶囊体跟随骨骼动画移动,但在 Rewind 时要用历史快照里的姿态重新摆放。

graph LR
    A["Full animation pose"]
    B["Simplified capsules"]
    C["Root motion only"]
    D["High precision
high memory"] E[Balanced] F["Low precision
low memory"] G["Exact hit feel"] H["Forgiving feel"] D --> A --> G E --> B --> G F --> C --> H

最精确的方案是保存每 Tick 的完整骨骼变换,命中检测时恢复整副骨架。内存和 CPU 开销都很高。折中方案是保存根变换加几个关键骨骼,命中框用胶囊体近似。最省的做法只保存根位置和朝向,命中框跟随根移动,适合载具或移动较慢的目标。

射线检测的碰撞通道也要和玩法对齐。人物胶囊体通常有独立的 ECC_GameTraceChannel,只响应武器射线。如果 visual mesh 和碰撞体不同步,玩家会看到子弹打中身体却没伤害,或者明明擦肩而过却被判爆头。保持美术表现与命中框一致,是手感可信的基础。

头部爆头框的大小尤其敏感。太大容易被玩家抱怨打中空气也算爆头,太小则会让高延迟玩家永远打不到头。很多游戏会把头部胶囊体做得比视觉头部稍大一点,同时在 Rewind 时做子 Tick 插值,用来补偿快照间隔带来的位置离散。

Rewind 的精度与范围

Rewind 不是无限回退。服务器会限制最大回退时长 TmaxT_{\text{max}},并限制每个玩家保存的历史帧数。设快照间隔为 Δt\Delta t,目标最大速度为 vv,那么位置误差约为:

Δx=vΔt\Delta x = v \cdot \Delta t

步行速度 5 m/s、Tick 16.7 ms 时,误差大约 8 cm,小于头部半径。载具速度 30 m/s 时,误差会达到 50 cm,可能需要更短的快照间隔或者在快照之间做线性插值。

最大回退时长直接决定了服务器支持的最高 ping。把它设得太长,内存占用线性增长,同时也会给作弊者更大的作弊窗口。一般竞技游戏会把 Rewind 上限控制在 150 ms 到 250 ms 之间,超过这个阈值的玩家会被强制 kick 到边缘服务器或者只能匹配高延迟房间。

Unreal Engine 5.8 中可以通过 CVar 调整这些参数,例如 net.MaxRewindHistory 控制历史长度,net.MaxRewindTimeMs 控制最大回退毫秒数。不同玩法需要不同的平衡:快节奏近战可以把精度调高、回退窗口调小;大战场载具战则可以放宽窗口、降低骨骼精度。

内存开销可以用一个简单公式估算。假设每个玩家每 Tick 保存 10 个关键骨骼变换,每个变换 64 字节,60 Hz 下保存 3 秒历史:

M=N×60×3×10×64M = N \times 60 \times 3 \times 10 \times 64

一百名玩家大约需要 115 MB 纯历史数据。实际实现中会用量化压缩把位置从 float 压成 16 bit 或更低,旋转用压缩四元数,通常能把数据量降到 30 MB 以下。

与 Network Prediction 的关系

UE5.8 的 Network Prediction Plugin 负责角色移动预测、服务器校正和本地和解。Lag Compensation 并不替代 Network Prediction,而是建立在它之上的另一层机制。

Network Prediction 解决的是玩家自己的移动问题:客户端先预测移动,服务器验证并下发校正,客户端再和解。Lag Compensation 解决的是射击其他玩家时的命中问题:服务器根据射手开火瞬间的视角,回退其他玩家的位置。

flowchart TD
    subgraph client ["Client"]
        input["Input"]
        renderer["Renderer"]
    end
    subgraph server ["Authoritative Server"]
        netpred["Network Prediction"]
        weapon["Weapon System"]
        history["Position History"]
        trace["Trace & Hitbox"]
    end

本地玩家不会被 Rewind。服务器只用 shooter 的延迟去回退目标,而 shooter 自己的位置由 Network Prediction 校正后决定。这样移动和射击两套系统各司其职,避免互相干扰。

当服务器对 shooter 的位置做校正时,已经发生的命中判定不会因此被撤销。因为判定是基于历史目标状态,而 shooter 位置只影响射线起点和方向。校正后的起点如果仍然落在合理容差内,结果保持有效。如果校正导致起点偏差超出容差,那说明这次射击本身就是在本地预测严重失步时发生的,拒绝它反而更公平。

反作弊考虑

延迟补偿本身不是反作弊机制。它让高延迟玩家也能获得公平判定,但无法阻止自瞄或透视。真正抵御外挂的是服务器端的验证逻辑。

服务器必须严格限制 Rewind 窗口,不能允许客户端声称十年前开了一枪。射击时间应该单调递增,并且不能超出最大 ping 容差。每发子弹都要检查射速、弹匣容量和后坐力模式。射线起点和方向要在合理范围内,不能穿过墙体命中目标。所有命中框数据都保存在服务器,客户端只接收伤害结果和特效触发。

速度挂会改变玩家移动速度,但位置验证会暴露异常起点。自瞄挂只是让瞄准更准,只要命中结果仍在 Rewind 后的合理命中框内,它造成的伤害在系统层面是合法的。透视挂配合自瞄最难缠,但服务器可以做视线遮挡检测,要求子弹路径上不能有不透明的固体。

延迟欺骗是另一种攻击面。外挂可以故意延迟发送输入,让服务器觉得玩家 ping 很高,从而扩大 Rewind 窗口。服务器应该以连接的实测延迟为准,而不是客户端声称的时间。当延迟抖动异常大时,可以降低该玩家的 Rewind 上限或者直接断开连接。

调试与可视化

延迟补偿的问题往往很难用肉眼复现。同一个场景,低延迟测试机和真实高延迟玩家看到的结果可能完全不同。UE5.8 提供了多种调试手段来观察 Rewind 行为。

最常用的方式是在 Rewind 期间绘制临时命中框和射线。DrawDebugCapsule 可以在服务器上显示回退后的胶囊体,DrawDebugLine 可以显示检测射线。把这些调试信息同步给客户端观战或开发者模式,就能直观看到子弹到底有没有穿过目标。

void UMyWeaponComponent::ServerFire_Implementation(
    FVector Origin,
    FVector_NetQuantize Dir,
    float ClientTime)
{
    const float Now = GetWorld()->GetTimeSeconds();
    const float Rewind = ComputeRewindSeconds(ClientTime, Now);
    const float TargetTime = Now - Rewind;

    if (ULagCompensationComponent* LC = GetLagCompensation())
    {
        LC->EnableRewind(TargetTime);
        FHitResult Hit;
        FCollisionQueryParams Params;
        Params.bReturnPhysicalMaterial = true;
        GetWorld()->LineTraceSingleByChannel(
            Hit, Origin, Origin + Dir * 10000.f,
            ECC_GameTraceChannel1, Params);
        LC->DisableRewind();
        ApplyDamage(Hit);
    }
}

控制台变量也能帮上忙。show collision 可以查看当前碰撞体,p.NetShowCorrections 会显示 Network Prediction 的校正信息。自定义 CVar 例如 lagcomp.Visualize 1 可以高亮 Rewind 命中框。录制专用服务器回放,再和客户端本地回放对比,是定位不同步问题的黄金组合。

调试时建议用不同颜色区分当前命中框和 Rewind 命中框。绿色表示当前服务器状态,红色表示 Rewind 后用于检测的状态。如果两条射线分别穿过红色和绿色胶囊体,就能立刻看出延迟补偿是否生效,以及补偿量是否准确。

写在最后

延迟补偿让服务器学会了站在玩家的视角看问题。它不改变权威服务器的本质,只是在命中判定那一刹那,把目标轻轻推回玩家开火时看到的位置。实现这套机制需要精确的历史快照、合理的 Rewind 窗口、严格的客户端验证,以及和 Network Prediction 的良好协作。

老 K 那枪没中的问题,根因不是他的网络,也不是他的瞄准,而是服务器和客户端对时间的理解不一致。把这一点对齐之后,狙击枪才能真正命中玩家心目中的目标。

实现时需要留意的细节

快照的保存时机必须稳定。如果在 Tick 末尾保存,就要保证所有移动和动画都已经更新完毕。如果在中间保存,命中框和实际位置会出现半帧偏差。这个偏差在慢速移动时不明显,在快速转身或跳跃时会被放大。

Restore 操作要做好异常保护。如果某个目标在 Rewind 时间点还没有加入游戏,或者已经销毁,必须跳过而不是崩溃。可以在历史缓冲区里用一个特殊标记表示该 Tick 没有有效数据,查询时自动回退到最近的有效快照。

命中检测和伤害应用要分开。先确定命中目标,再计算伤害倍率和部位加成。这样做的好处是 Rewind 只参与几何判定,伤害公式仍然可以使用当前时刻的buff、护盾和技能状态,避免状态回退带来的复杂性。

最后要区分瞬时射线武器和弹道武器。Lag Compensation 主要解决 hitscan 类武器的命中判定。抛射物有自己的飞行时间,通常用预测轨迹或者服务器端物理模拟来处理,不需要把整个目标 Rewind 到发射时刻。

不同项目对精度和性能的取舍不同,没必要一开始就把所有骨骼都存下来。先保证根位置和头部胶囊体准确,再逐步扩展到四肢和载具,这样的推进方式更稳健。等基础命中判定稳定后,再把资源投入到子 Tick 插值、压缩和网络抖动优化上。