同图传送「加载超时」的业界兜底方案调研

起因:副本 20100301 max→典狱长传送门传送后场景异常回退 · 日志 Client - 副本 (13).log · 2026-08-12
标注说明: 实证有公开源码 / 官方文档佐证   观察基于游戏内可见行为推断   推断本文的分析结论,非外部来源

一、先看我们现在的行为

16:41 那次传送的实测时间线(同图,无缝路径):

16:41:16.011 传送-OnTeleportSelfSCNotify · 注册「15s」兜底
16:41:16.011 传送-本地玩家移动锁定(关重力 + MOVE_None)
16:41:28.126 传送-无缝过渡遮罩关闭 [reason: timeout] ← 玩家开始看见半成品世界
16:41:34.995 15s 兜底触发 → 上报 LoadDone [version:3] ← 地形没好却报「加载完成」
16:41:34.996 传送-本地玩家移动解锁 ← 被上一行逼出来的
16:41:35.050 传送-朝向包到达,补一次相机重置(第 1 次)
16:41:36.421 等待WP场景流送完成(结束) ← 真正完成,耗时 20.4s
16:41:36.437 SafeMoveTeleport 拽回落点 + 相机重置(第 2 次)

QA 报的「场景异常回退」,就是 34.996 → 36.437 这 1.44 秒:玩家被放出来自己动了一下,然后 20 秒前的旧落点把他拉回去。

19日志内同图流送次数
~18s耗时中位数
38.4s最慢一次
10 / 19超过 15s 阈值
推断 15s 阈值低于中位数,意味着一半以上的同图传送必然走兜底路径。 这不是偶发边缘 case,而是常态路径。所以这条路径的正确性格外要紧。

补充:TimerSystem 是帧驱动的。流送期间只有 10~12 fps,还夹着 2.1s / 1.5s 的巨型卡帧, 所以「15s」实际在 19.0s 和 23.8s 才触发,「8s」遮罩实际 12.1s 才关。阈值调参不能按墙钟算。

前提 这份日志含 1743 条 UEDPIE_0_,是编辑器 PIE,带 DDC SectionInfoMap 回填和 3719 条 Waiting on static mesh 阻塞。打包版大概率快很多,但快多少需实测。 下文所有阈值建议都应在打包版复测后再定稿。

二、业界三种做法

模式 1 · 显式 Loading + ACK 握手(WoW / FF14 / ESO)

实证 即使同图远距离传送也照样黑屏 loading。协议是标准三段握手:

服务端 → 客户端:SMSG_NEW_WORLD(传送指令)
                同时置位 IsBeingTeleportedFar() 信标,此后拒收该玩家移动包
客户端:黑屏 → 加载 → 完成
客户端 → 服务端:MSG_MOVE_WORLDPORT_ACK
服务端:SetSemaphoreTeleportFar(false),恢复收包

从 TrinityCore MovementHandler.cpp 的 HandleMoveWorldportAck() 能看到几个关键设计:

设计点做法
幂等 / 防伪 if (!player->IsBeingTeleportedFar()) return; —— 非预期的 ACK 直接丢弃,防重复和伪造
ACK 超时 原版没有专门的 worldport ACK 计时器。玩家就一直挂在 IsBeingTeleportedFar() 状态里等, 由会话层的连接超时兜底
落点非法 IsValidMapCoord() 不过就 LogoutPlayer() —— 踢下线,而不是把人放到坏地形上
其它 ACK 的超时 移动类 ACK 有 CONFIG_PENDING_MOVE_CHANGES_TIMEOUT,超时行为是 KickPlayer(...) —— 同样是踢,不是放行
这一派的核心态度:加载超时是异常,异常的处理是断开,不是「假装成功」。 玩家宁可看到「World server is down」重连,也不会被扔进未加载的地形。

模式 2 · 可伸缩过场表演(原神 / 崩铁 / Star Citizen)

观察 这类是 UE / 自研 + 大世界流送 + 传送锚点,和我们最像。做法是用一段表演把加载时间藏起来,且表演时长跟随实际加载。

原神有一个很能说明问题的细节:

传送到已经渲染过的近处锚点(便携锚点)→ 只走黑屏淡入淡出
传送到尚未渲染的位置 → 完整 loading 界面,带元素图标 + 可点击切换的提示文字

推断 「可点击切换的提示文字」是为吸收时长不定的加载而设计的典型手法 —— 设计上就假定了这个界面可能要停留很久,而不是播固定秒数后撤掉。

Star Citizen 的量子跳跃同理:把玩家扔进一条不需要加载的隧道场景,加载完了才推出去。隧道能开多久开多久。

关键不是「有表演」,而是「表演时长可伸缩」。 不是播 8 秒然后不管加载好没好都撤屏。

模式 3 · UE 官方推荐:预热 Streaming Source

实证 Epic 在 World Partition 文档里给的标准传送流程是:

1. 在目标点激活一个 World Partition Streaming Source Component
2. 轮询 IsStreamingCompleted()
3. 返回 true 后才把玩家传过去
4. 传完 Deactivate 该 Source

也就是先让地形加载,再移动玩家。我们现在是反过来的:先 SafeMoveTeleport 把人挪过去,由玩家自身隐式产生 streaming source,然后等。

另外 Epic 文档明确提到一个陷阱:IsStreamingCompleted 只表示格子进入 Activated, 不等同于物理 collision 已 build。这正是我们之前踩过的坑(3x3 贴地预检就是为它加的)。

UE Seamless Travel 的一个前车之鉴

实证 UE5 有个已知 bug:seamless travel 时如果客户端比服务器先加载完, 服务端永远看不到该客户端「已加载」,PlayerController 不切换,服务器和所有客户端一起挂死。

设计教训:加载完成 ACK 必须是「有锁存的状态」,不能是「边沿触发的事件」。 早到的 ACK 不能被丢弃。我们的 HasSentSameSceneLoadDone 是客户端侧的锁存,方向对, 但下面会讲它带来的一个副作用。

三、三条没有例外的铁律

三种模式路子不同,但下面三条是共通的:

#铁律理由我们
1 加载没完成,玩家绝不可控 放到未加载地形上必然穿地、掉出世界、触发反作弊。而且因为网络延迟, 服务器纠正要一个 RTT 之后才到,这期间玩家能在虚空里跑出很远 违反
2 服务端超时 ≠ 客户端解锁 服务端超时的含义是「我不等你了,你自己看着办」,不是「你可以自由移动了」。 这是两个独立状态机 违反
3 位置校正带事务号,晚到即丢弃 TrinityCore 的 IsBeingTeleportedFar() 前置判断就是干这个的。 没有它,旧指令会「拽人回去」 违反

四、逐条对照我们的代码

违反铁律 2 —— 一个超时做了两件语义相反的事

TeleportController.OnTeleportSelfTimeout()(约 1012 行):

private OnTeleportSelfTimeout(): void {
    Log.Error(..., `15s 兜底触发:超时仍未完成,强制结束并 Done`);
    //                          ↑ 日志自己都写着「仍未完成」
    const snap = ModelManager.TeleportModel!.GetLocalPlayerServerMoveSnapshot();
    this.SendSameSceneLoadDoneIfNeeded(snap.ServerMoveVersion);  // ← 对:服务端该放行
    this.UnlockLocalPlayerMovementForTeleport();                 // ← 错:地形还没好
    this.CleanupAfterTeleport();                                 // ← 里面还把 IsStreamingLevel 置 false
}

协议名字里的 LoadDone 就是「加载完成」。日志文本承认「仍未完成」,下一行就发这个包。 16:39 那次更极端:23.8 秒发出「我加载完了」,真正完成在 38.4 秒 —— 提前 14.6 秒。

而且注释里写得很清楚:

// 即便没收到 ServerMove,也用快照里现有的 version 回报;服务器对落后 version 会丢弃

「即便没收到 ServerMove」 —— 连服务器让我传送到哪儿都还不知道,就先回一句「我到了」。

然后为了自圆其说,只好把玩家也放开 —— 都跟服务器说「我到了」了,总不能还锁着。 一句谎话逼出了第二个错误动作。

违反铁律 3 —— 迟到的回调无人拦截

流送 executor 的 Success 回调(约 1299 行起)完全不知道超时已经发生过,无条件执行:

if (result === ECommandExecutorResult.Success) {
    moveSyncComp?.SafeMoveTeleport(targetPos, targetRot, ...);   // 约 1320 行 ← 拽回去
    ...
    this.ResetPlayerCameraRotation();                            // 约 1326 行 ← 第 2 次扭相机

targetPos / targetRot 是 20 秒前闭包捕获的。没有版本号校验,没有「这次事务还有效吗」的判断。 对比 TrinityCore 那句 if (!player->IsBeingTeleportedFar()) return; —— 我们缺的就是这一句。

违反铁律 1 —— 遮罩早于流送完成 8.3 秒撤掉

超时配置值16:41 实测触发说明
无缝遮罩兜底8s12.1s撤屏
传送流程兜底15s19.0s发 LoadDone + 解锁
流送真完成—20.4s

三者顺序是错的。遮罩比传送兜底早 7 秒撤,比流送完成早 8.3 秒撤。 遮罩超时的注释写的是「防止流送回调没触发把屏幕卡死」, 但它现在的实际效果是在流送正常进行中提前撤屏。

一个副作用:真·完成的上报会被吞掉

SendSameSceneLoadDoneIfNeeded 有 HasSentSameSceneLoadDone 幂等保护。 所以 15s 撒谎发完之后,流送真完成时那次诚实的上报会被直接吞掉。 服务器从头到尾只收到过那句谎话,永远收不到更正。

五、方案

核心思路:把「通知服务端」和「放开玩家」拆成两件独立的事。
服务端确实需要一个了断 —— 一个玩家卡加载不能无限期拖住 AOI、主控分配、组队同步,这个设计是对的。 错的是我们把它和「客户端可以放人了」绑在了同一个定时器上。

方案 A · 客户端单方面修(推荐先做,不依赖服务器排期)

15s 超时到了:
  ├─ 照常 SendSameSceneLoadDone        ← 服务端该放行放行,不拖累别人
  └─ 玩家怎么办?看 IsStreamingLevel:
       ├─ true  → 不解锁。起一个宽限定时器继续等
       │           等 finalize 正常收尾时放人(顺便取消宽限)
       │           宽限也耗尽 → 才强制解锁,交坠落重生兜底
       └─ false → 快照缺失等异常,没有后续回调会来放人,就地收尾

+ 加 TeleportFinalizedByFallback 标志(对应 TrinityCore 的 IsBeingTeleportedFar 判断)
  超时置位、每次新传送清零
  回调 Success 分支先查标志,置位就只走 finalize,跳过:
      SafeMoveTeleport / ResetPlayerCameraRotation / 贴地预检

+ OnClear 补清 TeleportSelfTimeoutTimerId(现在漏了)

finalize 里这四件事必须继续跑:

动作为什么不能跳
IsStreamingLevel = false 不复位就复现「跨图传送必卡」那个老坑
CloseSeamlessTransitionMask幂等,兜底不能省
EndCache同上
SendSameSceneLoadDoneIfNeeded有幂等保护,天然安全

宽限期建议 45s(覆盖实测最慢 38.4s,且考虑帧卡会让超时更晚触发)。 注意 TimerSystemConstants.MaxTime = 90000,CheckInterval 超上限会 静默返回 undefined、定时器根本不注册 —— 若哪天要调到 90s 以上, 必须显式传 needCheckMaxInterval = false,否则玩家会被永久锁死。

代价:最坏情况玩家被锁 45 秒。
但对比现状 —— 锁 19 秒 → 放出来在虚空里跑 1.4 秒 → 被拽回去 —— 锁着比放出来体验好,而且服务器侧已经通过 LoadDone 解除了 loading 态,不影响其他玩家。 这也正是模式 1 和模式 2 的共同选择。

方案 B · 加「加载失败」协议(要服务器配合,建议同步推进)

方案 A 本质上是客户端撒谎换服务器放行,终究是补丁。干净的做法是让客户端能如实上报:

ClientSameSceneLoadFailedCSReq { serverMoveVersion, reason }

服务器收到后自己决定怎么处置这种玩家:踢下线(WoW 做法)、强制回上一个安全点、 或者继续等但不阻塞其他玩家。「我加载完了」和「我放弃了,别等我」是两件事,应该有两个表达。

方案 C · 遮罩不要定时撤(对应铁律 1)

方案 D · 治「慢」本身(超出本次范围,列出备查)

手段说明
预测性流送 按 UE 官方模式 3:传送锚点是有限已知集合,在传送开始前就在落点插一个 StreamingSource 预热,而不是等 SafeMoveTeleport 把人挪过去才隐式产生
清脏资产 日志里 6 个唯一材质缺失(MI_ISL_01_A_Roc03d_01_LOD1/2/3 等, 全在 Scene/Assets/Temp/ 下),被 16 个 WP cell 引用,共 60 条警告
确认 PIE 水分 打包版实测同图流送耗时分布,再回头定 15s / 45s 这两个阈值
UE 5.6+ 调优 s.UseUnifiedTimeBudgetForStreaming:统一 ProcessAsyncLoading 与 UpdateLevelStreaming 的帧预算,闲置时间互相让渡

六、建议范围与验收

先做方案 A(一个文件、约 60 行,不依赖任何外部排期),立刻止住 QA 报的回退 bug。
同步把方案 B 提给服务器,作为下一版的干净解。
方案 C 待定:走保守版还是完整版需要拍板。
方案 D 不在本次范围。

复测验收点

  1. 15s 兜底触发 之后不应该紧跟 传送-本地玩家移动解锁
  2. 传送-解锁前贴地预检完成 应出现在 等待WP场景流送完成(结束) 之后 100ms 内
  3. 整个传送过程只有一次 传送-重置相机旋转-目标位置
  4. 在打包版跑同样用例,记录同图流送耗时分布,用于回调阈值

参考来源