首页 / 横向专题 / 地图流送与 DataLayer

地图流送与 DataLayer

World Partition 是这个项目大世界的地基。「传送完掉下去」「地形突然消失」「实体没出来」「卡在等流送」 这几类问题的根都在这里。 本页把 WP / DataLayer / 流送源 / 子关卡这四组概念讲清, 再回答一个最容易被当成引擎 bug 的问题:cell 已 Activated,为什么碰撞体还没好?

World Partition DataLayer 流送源 假阳性 调试工具

1. 三组概念,先分清

1.1 World Partition 是什么

UE5 把一整张大地图切成网格 cell,按玩家所在位置动态加载/卸载附近的 cell, 远处的用 HLOD 代理顶着。这样一张几平方公里的图不需要一次性载进内存。

一张 WP 地图(本项目里叫 WPMap)内部又通过 DataLayer 划分出可独立开关的内容层。 所以「换场景」在这个项目里有两种量级:

量级做什么代价
换 WPMap引擎级别的换图,整张地图卸载重载最重,必然有加载界面
同 WPMap 换 DataLayer只开关内容层,地形骨架不动轻,有机会做成无缝
这就是无缝判定的物理基础 无缝之所以可能,正是因为同一张 WPMap 内换 DataLayer 时, 玩家脚下的地形 cell 根本没被卸载过。
反过来,一旦 newMapPath !== oldMapPath,无缝就无从谈起——地形要整体重来。

1.2 DataLayer:本项目的六种前缀

DataLayer 的命名前缀就是它的类型,运行时靠 startsWith 分派处理。 这是一个用命名约定代替枚举的设计。

前缀用途运行时默认
DataLayerRuntime_EntityLevel_{levelId}一个 ServerLevel 对应一层。玩法实体、关卡专属内容只开当前关卡那一层,其余全关
DataLayerRuntime_SubMap_{name}子关卡(LayerTasks)。任务、活动这类可动态开关的内容默认全关,按 子关卡列表逐个开
DataLayerRuntime_*(其它)其它运行时层默认开
DataLayerEditor_*编辑器专用,如 DataLayerEditor_BaimoDesigner(策划白盒)一律关
DataLayer_Layer_*实体编辑器使用一律关
DataLayerPlatform_{Desktop|Mobile}按渲染等级二选一GlobalData.IsSm5 → Desktop;IsEs3 → Mobile;都不满足则全关
加新 DataLayer 时必须遵守前缀约定 用一个不在上表里的名字(比如 MyLayer_Test), 它不会被任何一条分支命中——既不会被重置也不会被开启, 表现是「有时在有时不在」,取决于上一张图留下的状态。
排查「某个 DataLayer 状态莫名其妙」,第一件事是看它的名字有没有落在上表里。

1.3 两个重置函数,管的不是同一批

函数管哪些前缀什么时候调
ResetDefaultWpDataLayerSettingDataLayerEditor_ / DataLayer_Layer_ / DataLayerPlatform_换 WPMap 之后。管「美术场景」这一批
ResetDefaultRuntimeDataLayerSetting只管 DataLayerRuntime_SceneLoadingResetDataLayerCmd(主链 ④)。管「关卡 Id 场景」这一批,末尾还会 ClientLayerTasks.clear() + ServerLayerTasks.clear()
名字太像,容易调错 两者处理的前缀完全不重叠,互不覆盖。
把 Runtime 那个当成「重置全部 DataLayer」来用,会导致编辑器层和平台层保持上一张图的状态; 反之则关卡层不会切换,表现为「进了新关卡还看得到旧关卡的实体内容」。

2. 流送源:谁决定加载哪些 cell

2.1 流送源就是一个「灯」

WP 不知道玩家在哪,它只认流送源(streaming source)—— 场景里一个带 WorldPartitionStreamingSourceComponent 的 Actor (本项目用 BP_StreamingSource_C)。引擎加载它周围的 cell。

可以把它想成一盏灯:灯照到哪,那块地形就存在;灯灭了或移开,地形被回收。

本项目枚举对应引擎 TargetState含义
EStreamingSourceState.UnloadDisableStreamingSource()灯灭,不再持有任何 cell
LoadAndHideEStreamingSourceTargetState.Loaded加载但不显示。碰撞在、看不见——用于预加载
LoadAndVisibleEStreamingSourceTargetState.Activated加载并显示。正常玩法状态

2.2 两盏灯:跟随玩家的 + 锁定落点的

StreamingSourceInfoTargetStreamingSourceInfo
位置跟随玩家,OnPrePhysicsTick 每帧同步 Global.BaseCharacter 坐标锁定在传送落点,不动
存续常驻整局只在加载期间存在,落位完成后关灯
作用保证玩家脚下永远有地形让落点地形提前加载好,玩家到达时不悬空

为什么要两盏:玩家还没到落点,跟随玩家的那盏灯照不到落点。 如果等玩家搬过去再加载,那一瞬间脚下是空的,直接穿地。 所以先在落点点一盏灯把地形烤好,再把玩家搬过去。

2.3 灯交接:主链 ⑪ 真正在做的事

// SceneLoadingMoveCmd.MoveToNewScene()
gameModeModel.StreamingSourceInfo.EnsureStreamingSource();
StreamingSourceInfo.CopyStreamingSourceInfo(
    gameModeModel.StreamingSourceInfo,        // to:跟随玩家那盏
    gameModeModel.TargetStreamingSourceInfo,  // from:落点那盏
);
// 移动完成,关闭TargetStreamingSource流送
gameModeModel.TargetStreamingSourceInfo.SetStreamingSourceState(EStreamingSourceState.Unload);

CopyStreamingSourceInfo 拷三样东西:位置、LevelLayerName、流送状态。 交接完成后落点那盏灯立刻关掉——两盏灯照同一片地,留着是浪费。

这一步没跑到 = 一分钟后玩家坠落 交接是「落点地形的持有者从临时灯换成常驻灯」这一动作。
如果它没执行(比如上游 SceneWaitWpStreamingCmd 卡住了、整条链停在 ⑩), 落点地形就只由那盏临时灯持有。临时灯的生命周期一到, 没有任何源持有那些 cell,地形被回收——玩家站在空气上,开始下坠。
这就是「传送后过一会儿突然掉下去」的完整因果链: 流送假死 → 链停在 ⑩ → ⑪ 从未执行 → 灯没交接 → 临时灯到期 → 地形卸载 → 坠落。 症状出现在一分钟后,根因发生在第一秒。案例详情
注意 ⑪ 的跳过条件是 NeedsTerrainStreaming(),不是 IsSeamlessLoading() 「无缝换落点」这种情况也要做灯交接——它虽然是无缝,但落点变了。 代码注释写得很直白:「无缝换落点也需交接流送源,使新位置地形持续保留,否则落点灯关掉后地形被卸载又坠落」。
这是踩过之后补上的判断:如果这里用 IsSeamlessLoading(),无缝换落点就会跳过交接, 重现同一个坠落。

2.4 锁定流送

SetStreamingLockStateAndPosition(isLock: boolean, lockPosition?: Vector)

锁定后 Tick() 直接 return,灯不再跟随玩家,停在 lockPosition。 用于「玩家位置暂时不可信」的时刻——比如传送过程中玩家 Actor 已经被挪走但地形还没准备好。

锁定是一个必须配对的状态 锁上之后忘了解,灯就永久钉在那个位置:玩家走出流送半径, 脚下地形被卸载。所有 SetStreamingLockStateAndPosition(true, ...) 都必须有对应的解锁路径,包括早 return 分支和异常分支。

2.5 第三种灯:临时流送源

TransientStreamingSourceManager 提供按需创建的额外流送源, 典型用途是过场镜头飞到远处——镜头看得到的地方必须加载,但玩家并没有过去。

设计点做法解决什么
句柄而非对象对外只给 number,InvalidHandle = -1调用方拿不到内部对象,不可能持有已销毁实例的野引用
Outer 绑定生命周期创建时传一个 Actor 作为 Outer,Tick 里发现它失效就自毁并从表里移除忘记 Destroy 也不会泄漏。用「借主还在不在」代替「有没有人记得释放」
初始化失败即标记已销毁创建流送源失败时立刻 DestroyedInternal = true注释写的是「防止半死不活状态」——不留下「句柄有效但底层没有」的对象
重入保护TickAll / DestroyAll 都判 IsTickingInternal,重入直接报 Error 并忽略遍历中修改容器
先收集后删除TickAll 把要删的 handle 推进 HandlesToRemove,遍历结束后统一删同上,遍历中删除元素
句柄不回绕DestroyAll 明确不重置 NextHandle注释:「避免旧 handle 引用与新分配的 handle 冲突」——否则旧代码拿着过期 handle 操作到了新实例
这套「句柄 + Outer 绑定」值得抄 它把「资源释放」从调用方的纪律问题变成了结构上的必然: 只要 Outer Actor 销毁,流送源一定跟着走。
凡是「创建了必须记得销毁」的东西(定时器、订阅、临时 Actor),都可以照这个套。

3. 等待流送完成

3.1 查询口径:五个字段决定「等什么」

const querySource = new UE.WorldPartitionStreamingQuerySource();
querySource.Location = location;                                  // 检查位置 = 落点
querySource.Radius = ResourceSystemConstants.StreamingSourceRadius;
querySource.bUseGridLoadingRange = false;
querySource.DataLayers = dataLayers;
querySource.bDataLayersOnly = false;
querySource.bSpatialQuery = true;
querySource.bClampedByGridLoadingRange = true;
字段值含义与后果
Location落点坐标以落点为圆心查,不是以玩家当前位置
Radius7000
= 70 米
只等这个半径内的 cell。来自 ResourceSystemConstants.StreamingSourceRadius, 注释标了单位是厘米。70 米外的地形不在等待范围内
bUseGridLoadingRangefalse用上面的 Radius,不用网格自己配的加载范围
bClampedByGridLoadingRangetrue再被网格加载范围裁一次上限。代码注释:「超过范围会有多余查询,卡住加载」。 即:查得太大反而会等一堆根本不会加载的 cell,永远等不完
DataLayers目标层 + 子关卡层只等这些层里的 cell。不在列表里的层,加载状态不影响判定
bDataLayersOnlyfalse不限定「只查 DataLayer 内的」
bSpatialQuerytrue只查空间哈希的 cell,不含 always-loaded。always-loaded 内容本来就一直在,不需要等

等待层的组装:目标关卡的 EntityLevel 层 + Client 与 Server 两套子关卡层, 用 Set 去重后逐个转成 FName:

dataLayerLabels.add(gameModeModel.TargetStreamingSourceInfo.GetLevelLayerName()!.toString());
for (const label of ControllerHolder.WorldController.ClientLayerTasks) {
    dataLayerLabels.add(`DataLayerRuntime_SubMap_${label}`);
}
for (const label of ControllerHolder.WorldController.ServerLayerTasks) {
    dataLayerLabels.add(`DataLayerRuntime_SubMap_${label}`);
}

3.2 轮询而不是回调

const timerId = TimerSystem.Forever(() => {
    const completed = worldPartitionSubsystem.IsStreamingCompleted(
        UE.EWorldPartitionRuntimeCellState.Activated, querySources, true);
    if (!completed) { return; }
    TimerSystem.Remove(timerId);
    completedPromise.SetResult(true);
}, ResourceSystemConstants.CheckStreamingInterval)!;
常量值计量的是什么
CheckStreamingInterval100
= 0.1 秒
轮询间隔。够密以免白等,够疏以免每帧压测引擎
StreamingSourceRadius7000
= 70 米
流送检测半径,单位厘米(UE 世界单位)
AsyncLoadTimeoutMs60000
= 60 秒
资源系统异步加载的超时。注意它管的是资源加载,不管 WP 流送
进度条是「已匹配 cell 数 / 总 cell 数」 progress = matchCellNum / max(cellNum, 1)。 分母兜底成 1 是为了防除零——但也意味着 cellNum 为 0 时进度直接是 0/1, 进度条不动。看到进度条一直 0% 不一定是没在加载,可能是 cell 计数没拿到。

4. 核心问题:cell 已 Activated,为什么碰撞体还没好

这是本项目最典型的一个「引擎说完成了,实际没完成」。 表现是:流送等待返回 true,解锁放人,玩家直接穿过地面持续下坠,直到坠落重生兜底。

4.1 三个原因叠在一起

原因一:Activated 断言的是流送状态,不是物理状态

EWorldPartitionRuntimeCellState.Activated 的语义是 「这个 cell 的 Level 已经完成 AddToWorld、进入可见状态」。 而 Actor 的组件注册和物理刚体创建是引擎分帧摊平的增量工作—— Level 变可见和「里面所有 collision 都 build 完」不是同一个时刻。

一句话 IsStreamingCompleted(Activated, ...) 是一个流送状态断言, 从来不承诺物理已就绪。把它当成「地形能站人了」用,是调用方读错了语义,不是引擎的 bug。

原因二:查询口径本身有边界

由 3.1 的字段直接推出来的三条盲区:

盲区成因
70 米外的地形不等Radius = 7000 + bClampedByGridLoadingRange
always-loaded 的内容不等bSpatialQuery = true 只查空间哈希的 cell
不在 DataLayers 列表里的层不等列表只装了 EntityLevel 层 + 子关卡层。落点脚下的地形若属于别的层,它加载没加载完,判定完全不看

原因三:HLOD 代理会让「看起来有地」

远处 cell 未加载时由 HLOD 代理顶着显示。代理是给眼睛看的,不是给物理用的。 所以画面上地形完好、脚下却没有碰撞,这个组合是正常的引擎行为。

4.2 实测结论

map 9999 触发器路径的实测 在这条路径上,流送等待会稳定假阳性:判定返回完成, 解锁后玩家穿过占位 Cube 持续下坠,直到坠落重生兜底把人捞回来。
代码注释里的原话:「SceneWaitWpStreamingCmd 的 IsStreamingCompleted 只判 WP cell 是否进入 Activated, 不等同于 UE 物理 collision 已 build」。

4.3 解法:不信标志位,直接问物理系统

既然「流送完成」不能证明「能站人」,那就换一个能直接证明它的检查: 以落点为中心做 3×3 网格向下射线,九条全部命中才算脚下有地。

private static readonly GroundReadyTraceHorizontalDist = 132;  // 相邻射线水平间距,约等于角色胶囊体直径
private static readonly GroundReadyTraceVerticalDist = 200;    // 向下探测深度

for (let i = -1; i <= 1; i++) {
    for (let j = -1; j <= 1; j++) {
        trace.SetStartLocation(targetPos.X + h * i, targetPos.Y + h * j, targetPos.Z);
        trace.SetEndLocation  (targetPos.X + h * i, targetPos.Y + h * j, targetPos.Z - v);
        if (!TraceElementCommon.LineTrace(trace, '[传送-解锁前贴地预检]')) { return false; }
    }
}
参数值为什么是这个值
GroundReadyTraceHorizontalDist132
= 1.32 米
约等于角色胶囊体直径。九条射线正好铺满玩家落脚会踩到的那片面积,不多也不漏
GroundReadyTraceVerticalDist200
= 2 米
往下探两米。再深就会命中悬崖底部,把「悬空但下方远处有地」误判为安全
为什么必须是 3×3,一条不行 单点命中可能打在临时占位 Actor 上(map 9999 的那个 Cube 就是), 或者刚好命中一小块已加载的碎片。九点全中才能排除这两种巧合。
这个多点策略是从坠落重生的地面检测借过来的——同一个项目里,同类判断用同一套手法, 比各写一套更不容易出错。
预检现在是准入门槛,不再放行(2026-09-21 变更) 早期实现给预检设了重试上限(20 × 50ms ≈ 1 秒),用完就放行,把兜底交给坠落重生。 但放行的实际后果是:地形没好就解锁移动、撤掉遮罩、并发 LoadDone—— 玩家当着镜头穿地下坠,同时骗服务器说「本端就绪」。
所以改成持续轮询到就绪为止,没有次数上限,并且把「发 LoadDone + 解锁移动 + 撤遮罩」整体后移到地面确认之后。 代价是地形真的永远好不了时没有放行兜底,玩家会一直停在遮罩里,只能重登; 可发现性由每 100 秒一条红色告警提供。详见 传送 · 同图安全等待。

4.4 另一面:流送假死而不是假阳性

假阳性是「说完成了其实没完成」,还有一种相反的失效:永远不说完成。

const timerId = TimerSystem.Forever(() => { ... });   // Forever:没有次数上限
这条等待链上没有任何一层会超时 轮询用的是 TimerSystem.Forever; SceneWaitWpStreamingCmd 只在 catch 里 FailCritical, 而「一直返回 false」不会抛异常; 外层 executor 又把过期时间设成了 TimerSystemConstants.MaxTime。
三层都没有兜底 ⇒ 流送一旦永久不完成,整条场景加载链永久停住, 连带 灯交接 从未执行,一分钟后地形卸载,玩家坠落。
治本方向是给这个等待加超时放行:等到上限就往下走, 再配合贴地预检通过后主动做一次灯交接作为双保险。

5. 子关卡 LayerTasks:两套集合求差

子关卡是「同一张图上可动态开关的一片内容」,比如某个任务专属的建筑。 它由两个来源各维护一套集合:

集合来源触发时机
ClientLayerTasksLevelPlayConfig.ClientLayerTaskSettings.Slots: 遍历 slot,第一个 VisibleCondition 通过的 slot 胜出(break), 没配条件视为直接通过客户端条件变化时 UpdateClientLayerTasks()
ServerLayerTasks协议 WorldSetLayerTaskSCNotify 的 layerTaskNameList服务器推送
private SetLayerTasks(type: ESetLayerTasksType, newLayerTasks: Set<string>): void {
    const oldLayerTasks = type === Client ? this.ClientLayerTasks : this.ServerLayerTasks;
    // 卸载旧的:在 old 不在 new
    for (const dl of oldLayerTasks) {
        if (!newLayerTasks.has(dl)) { this.SetWorldPartitionDataLayerState(`DataLayerRuntime_SubMap_${dl}`, false); }
    }
    // 加载新的:在 new 不在 old
    for (const dl of newLayerTasks) {
        if (!oldLayerTasks.has(dl)) { this.SetWorldPartitionDataLayerState(`DataLayerRuntime_SubMap_${dl}`, true); }
    }
    ...
}
求差而不是「全关再全开」 两个集合的交集部分不动。 如果图省事写成「先全部关掉再按新列表全部打开」, 交集里那些层会经历一次 关→开,视觉上是内容闪一下, 而且白白触发一轮流送。
凡是「用新状态替换旧状态」的地方都适用这条:算差集,只动真正变化的部分。
两套集合各自独立,同名会互相覆盖 Client 和 Server 两套各调各的 SetLayerTasks, 但最终操作的是同一批 DataLayer。 同一个名字同时出现在两套里时,后一次调用的状态生效。
所以 流送等待里把两套都加进等待列表——不能只等一套。

每次 SetWorldPartitionDataLayerState 都会 Emit OnLayerTasksChanged, 表现层据此刷新。注意这个事件是逐个 DataLayer 发的,一次批量变更会发多条。

6. 预加载与「移交」

SceneLoadingController 提供一组 DataLayer 预加载接口, 让目标关卡的内容在正式切场景之前就开始加载。

接口作用
PreloadWpDataLayers(position, labels, useVisibleState?)核心实现。useVisibleState 决定目标状态是 Activated 还是 Loaded(默认后者:加载但不显示)
PreloadWpLevel(position, levelId, useVisibleState?)便捷封装,等价于预加载 DataLayerRuntime_EntityLevel_{levelId}
CancelPreloadWpDataLayer(transferLevelId?, reason?)取消。带 transferLevelId 时语义变成「移交」,见下

6.1 「移交」而不是「取消再重来」

预加载和正式加载可能要的是同一批 DataLayer。 如果正式加载开始时把预加载单纯取消掉(还原 DataLayer 状态), 那些刚加载好的内容会被卸载,然后立刻又被加载一遍——预加载完全白做。

所以取消时判断:

1
请求里是否包含 DataLayerRuntime_EntityLevel_{transferLevelId}
包含才可能移交
2
若当前目标状态不是 Activated,把它提升到 Activated
日志 Promote WP DataLayer For Scene Loading。预加载时是 Loaded(不可见),正式加载要可见
3
移交路径不还原这一层的 DataLayer 状态
preserveDataLayerLabel 把它排除在还原之外,日志 Preserve WP DataLayer State。 其它层照常还原
还原时的双重检查 RestorePreloadDataLayers 只在当前状态仍等于预加载时设的目标状态时才还原, 否则跳过并打 Restore WP DataLayer Skipped。
理由:如果状态已经被别人改过,说明有另一条逻辑正在管这一层, 这时候还原成「预加载之前的样子」就是踩别人。 「只回滚自己造成的改变」是回滚逻辑的通用要求。

7. 调试工具清单

7.1 GM 命令

命令参数作用
DebugPrintDataLayerInfo无输出全部 DataLayer 的开关状态(分成已激活/未激活两段打)
DebugSetDataLayerStateDataLayer 名, 0/1手动开关任意 DataLayer,之后自动打一遍全量状态
DebugSetBaimoDesignerDataLayerState0/1开关策划白盒层 DataLayerEditor_BaimoDesigner
WhereAmI无排查流送问题的第一条命令:先打全量 DataLayer 状态, 再输出地图 Id / 完整地图路径 / 配置出生点 / 关联流程 Id / 玩家 Actor 的位置与朝向(含欧拉角)

7.2 代码内调试接口

接口输出
WorldController.DebugPrintAllDataLayerStatus()全量 DataLayer 状态,用分隔线包住便于日志里定位
TransientStreamingSourceManager.DebugDump()活跃实例数、NextHandle,以及每个句柄的 Valid / 位置 / 流送是否完成
TransientStreamingSourceManager.GetActiveCount()活跃临时流送源数量。怀疑泄漏时看这个——数字只增不减就是没释放

7.3 引擎控制台

wp.Runtime.StopLoadingLevelStreamingCells 1   // 冻结 cell 加载
wp.Runtime.StopLoadingLevelStreamingCells 0   // 恢复

这两行在 SetStreamingLockStateAndPosition 里以注释形式保留着—— 曾经用它配合流送锁定来复现「地形不加载」,现在没有启用。 需要手动构造流送假死场景时,这是最直接的手段。

7.4 日志关键字

要查什么grep 什么
流送等待的起止等待WP场景流送完成(有「开始」「结束」「失败」三种)
等待的查询参数[CheckTargetStreamingCompleted] 检测参数——能看到实际等的 DataLayer
灯交接开始移动到新场景 / 移动到新场景完成
流送源创建创建streaming source
DataLayer 预加载WP DataLayers Preload(Start / Cancel / Transferred)
子关卡变化ClientLayerTasks / ServerLayerTasks
贴地预检传送-解锁前贴地预检

8. 排查速查表

现象先查什么
传送后立刻穿地下坠流送假阳性。grep 贴地预检 看九点检测的结果
传送后一分钟左右突然掉下去灯交接没执行。grep 移动到新场景完成, 没有这条就往上找卡在哪一步
卡在「等待WP场景流送完成(开始)」只有「开始」没有「结束」= 流送永不完成。 看 检测参数 里等的 DataLayer 对不对
某片内容该出现没出现WhereAmI 看那一层 DataLayer 的开关状态; 是子关卡就看它在不在 Client/Server 两套集合里
内容闪一下又出现差集逻辑被绕过,变成了「全关再全开」
玩家走远后脚下地形消失流送锁定没解,灯还钉在旧位置
某个 DataLayer 状态时有时无名字没落在六种前缀里,两个重置函数都不管它
过场镜头飞过去一片空白没建临时流送源,或者 Outer 提前失效被自动销毁了
怀疑流送源泄漏GetActiveCount() / DebugDump()

9. 面试怎么讲

World Partition 的流送在你们项目里是怎么控制的概览
引擎不认玩家,只认流送源——一个带 WorldPartitionStreamingSourceComponent 的 Actor, 可以理解成一盏灯,照到哪块地形那块就存在。
我们维护两盏常驻灯:一盏每帧跟随玩家,保证脚下永远有地形; 一盏在传送时锁定落点,让落点地形提前烤好。玩家搬过去之后做一次灯交接, 把落点地形的持有权从临时灯移给跟随灯,然后关掉临时灯。
第三种是句柄式的临时流送源,用于过场镜头飞到玩家没去过的地方, 生命周期绑在一个 Outer Actor 上,Outer 销毁就自动回收。
讲一个你们踩过的、比较难定位的流送问题难点
传送完玩家直接穿过地面持续下坠。
表面看像是「等流送」这一步没生效,但日志显示它确实返回了完成。 根因是读错了引擎 API 的语义: IsStreamingCompleted(Activated, ...) 断言的是「cell 的 Level 已经 AddToWorld、进入可见状态」, 而组件注册和物理刚体创建是引擎分帧摊平的增量工作——可见 ≠ 碰撞已 build 完。 再叠上查询口径的边界(只等 70 米半径内、只等空间哈希的 cell、只等列进去的那几个 DataLayer) 和 HLOD 代理「看着有地其实没碰撞」,三者一起就导致稳定假阳性。
解法不是去修等待逻辑,而是换一个能直接证明目标的检查: 以落点为中心做 3×3 向下射线,九条全中才解锁。间距取角色胶囊体直径, 让九条射线正好铺满落脚面积;用九点而不是一点,是为了排除命中临时占位 Actor 的巧合。
还有一个取舍上的反转:早期是预检失败也要放行(预检只能提前发现问题、不能解决问题, 卡着不放人比穿地更糟)。2026-09-21 起改掉了这个判断——放行等于地形没好就解锁移动、撤遮罩、并发 LoadDone, 穿地骗的是玩家,假 LoadDone 骗的是服务器。现在预检持续轮询到就绪,没有次数上限, 并作为解锁与上报的准入门槛;卡住时靠每 100 秒一条红色告警暴露。
「传送完过一会儿才掉下去」和「传送完立刻掉下去」是同一个问题吗设计
不是,方向正好相反。
立刻掉是流送判定假阳性——说完成了其实没完成。
过一会儿掉是流送永远不说完成,导致整条加载链停在等待那一步, 后面的灯交接从未执行。落点地形只由那盏临时灯持有,临时灯到期,没有源持有那些 cell, 地形被回收,玩家开始下坠。症状在一分钟后,根因在第一秒。
后者能发生是因为这条等待链上三层都没有超时: 轮询用 TimerSystem.Forever;等待命令只在 catch 里失败,而「一直返回 false」不抛异常; 外层 executor 的过期时间被设成了最大值。
治本是给等待加超时放行,再拿贴地预检通过后主动交接一次灯作为双保险。
临时流送源为什么用句柄,不直接返回对象细节
为了让「资源释放」从调用方的纪律问题变成结构上的必然。
对外只给一个 number,调用方拿不到内部实例,就不可能持有已销毁对象的野引用; 所有操作都要过管理器,无效句柄只是打一条 Warn,不会崩。
更关键的是生命周期绑在 Outer Actor 上:Tick 里发现 Outer 失效就自毁并从表里移除。 调用方忘记 Destroy 也不会泄漏——判断依据从「有没有人记得释放」换成了「借主还在不在」。
配套还有几个细节:初始化失败立刻标记已销毁,不留「句柄有效但底层没有」的半死状态; TickAll 先收集待删句柄再统一删,避免遍历中改容器; DestroyAll 明确不重置句柄计数器,防止旧句柄撞上新分配的实例。