NAMI · 2026-07-09 · 传送门跨图卡 Loading

流送源是怎么丢的?

玩家用传送门从岛屿跨图回主城,Loading 卡死 90 秒。
根子在一个不起眼的地方:流送源 actor 被生在了"要被销毁的旧关卡"里。

一句话:流送源 actor 被 spawn 进了「旧关卡的 PersistentLevel」,跨图切换时随旧关卡一起被 UE 销毁;而 TS 侧的全局单例还攥着这个已死句柄、且从不检查它死没死,下次跨图复用它取组件 → access a null object → 命令卡死 90 秒。

1先搞清:流送源是"谁的东西"

一个概念错配,是全部问题的源头。

TS 里有个 全局单例 GameModeModel.TargetStreamingSourceInfo,它跨关卡不重建,一直活着。
但它管理的那个 流送源 actor,是靠 ActorSystem.Get → SpawnActorForEntity(当前World) 生出来的——actor 归属"当时所在的那个关卡",关卡没了它就没了。

单例 (永生) ──持有──▶ actor (关卡级 · 会随关卡死)

✨ 永生对象持有短命对象的句柄 —— 经典的 悬垂引用 (dangling reference) 温床。

2一步步看它怎么丢的

点「下一步」,跟着流送源 actor 的一生走一遍。

🏝️ Island001(旧关卡)当前所在

玩家出发地 · WP PersistentLevel

🏙️ MainCity001(新关卡)未加载

传送目标 · LevelId 10100101
🔒 TargetStreamingSourceInfo(全局单例 · 永生)
StreamingSourceInternal = undefined
0 / 5
点「下一步」开始。玩家正站在岛屿 Island001 里,靠近了一个传送门。

3日志实锤

不是猜的 —— 被销毁的流送源,路径和状态都写在日志里。
✅ 决定性证据(nami__20260709_3.log,21:57:20)

旧场景销毁命令 SceneLoadingDestroyOldSceneCmd 尝试回收流送源时打出:

[ActorSystem][W] 对象不存在 [Target: { "JsUObject_PathName":"/Game/Aki/Map/WorldPartition/Scene/Island001/Island001_WP.Island001_WP:PersistentLevel.BP_StreamingSource_C_2147367576", "JsUObject_Destroyed":true }] [ActorSystem][W] 对象不存在 [Target: { "...Island001_WP:PersistentLevel.BP_StreamingSource_C_2147369141", "JsUObject_Destroyed":true }]

① 路径带 Island001_WP:PersistentLevel → 流送源确实生在旧关卡里。
② Destroyed:true → 这俩 actor 已经被回收了。
这两个 被回收的流送源 就是你说的"几个人的 StreamingSource 都没了"。

4完整卡死时间线

log3 · clientId 130698 → 130884
21:56:33.617
靠近传送门,触发预流送 Start Preload WP DataLayer
此刻在 Island001 → 流送源 actor 被 spawn 进 Island001 PersistentLevel
21:57:09.007
按 F 交互传送门,服务器下发进入主城 10100101
出生点数据完整(X=44.68 Y=13714 Z=48391)—— 传送本身没问题
21:57:10.2
CleanupWorld for Island001_WP / WorldPartition Uninitialize
💀 Island001 里的流送源 actor 被 UE 一并销毁 → 单例句柄变悬垂
21:57:14.408
SceneLoadingNewSceneCmd 抛 access a null object
EnsureStreamingSource 见句柄"非 undefined"就跳过重建 → 对已死 actor 取组件 → 崩
21:57:20.827
下一轮销毁命令 Put 这俩 actor → 打出「对象不存在 / Destroyed:true」(上方证据)
21:58:41 / 22:00:10
executor 90 秒超时保底 + GameMode InLoading 保底
命令抛异常后没置终止态,只能死等超时 → 你看到的"卡 Loading"

5崩在哪几行

StreamingSourceInfo.ts —— 悬垂句柄一路畅通无阻。
崩点①:句柄非 undefined 就跳过重建StreamingSourceInfo.ts:188
public EnsureStreamingSource(): void {
    if (!this.StreamingSourceInternal) {   // ← 只判 undefined,不判 IsValid
        this.CreateStreamingSourceDisabled();      // 悬垂句柄 → 跳过 → 留住死 actor
    }
}
崩点②:对(可能已死的)actor 直接取组件StreamingSourceInfo.ts:142 UpdateStreamingState
if (this.StreamingSourceInternal) {          // truthy 但指向已销毁 actor
    const component = this.StreamingSourceInternal.GetComponentByClass(...);
    component.EnableStreamingSource();          // 💥 access a null object
}
崩点③:Get 结果非空断言,不判空StreamingSourceInfo.ts:114 / 180
    this.StreamingSourceInternal = ActorSystem.Get(BP_StreamingSource_C, transform)!;  // Get 可能返回 undefined
⚠️ 放大器:命令抛异常后不置终止态

CommandBase 的 try/catch 只打日志、既不 Fail 也不 Succeed,命令永远停在 Executing → 只能等 90 秒超时。
这把"1 帧崩溃"放大成了"90 秒黑屏卡死"。

6是我改传送改出来的吗?

半对半不对。

根因是历史遗留:StreamingSourceInfo(2026-04-28)"永生单例持关卡级 actor + 不校验有效性"的写法,早就在那了,不是同步瞬移改造新写的。

但传送改造是放大器:改造在 BaseMoveSyncComponent、SceneLoadingController 等多处新增了 TargetStreamingSourceInfo.EnsureStreamingSource() 调用,让同一个全局单例在关卡内被反复启用/锁定,于是更容易在关卡销毁后把悬垂句柄留给下次跨图复用 —— 把这个老坑踩响了。

代码注释里本就有多处"污染全局单例 TargetStreamingSourceInfo 会卡死保底"的警告,说明这个单例的生命周期一直很脆。

7怎么修

两条治本 + 一条护栏。
治本 · 用前校验

句柄用前先判 IsValid

EnsureStreamingSource 把 !StreamingSourceInternal 改成 !ObjectUtils.IsValid(...);UpdateStreamingState 取组件前也校验,失效就先重建。去掉 ActorSystem.Get(...)! 的非空断言。

治本 · 关卡销毁清句柄

关卡卸载时把成员置 undefined

DestroyStreamingSource 无论 ActorSystem.Put 成败,都要把 StreamingSourceInternal 清成 undefined,不给下次跨图留悬垂句柄。

护栏 · 全局收益

命令 catch 里补 this.Fail()

CommandBase 异常分支置终止态,杜绝任何命令抛异常后死等 90 秒。独立于流送源问题,所有命令受益。