运行时驻留:Actor 已生成不等于画面已就绪

自动关联目录:运行时驻留:Actor 已生成不等于画面已就绪

核心判断:UE 运行时不能用一个 Loaded 集合同时表达“空间上需要”“Actor 已生成”“依赖已驻留”和“画面可见”。AActor 存活只证明对象已经在游戏线程物化,不证明 UPrimitiveComponent 已注册、Mesh 与 Material 已绑定,更不证明下一帧能稳定呈现。

一、业务冲突:对象已经 Loaded,玩家为什么仍看到空白

创作者可以在场景中放置远超单机常驻预算的对象。客户端必须按观察点、对象包围盒和组合关系决定哪些对象进入世界,同时保证出生区域、脚本主动 Pin 的对象和 LOD 切换不会出现空洞。

实践中的流式链路曾允许下面这条失败序列:

  1. 对象进入加载半径,空间决策得到 Desired = true;
  2. 调度器调用 Spawn,但接口没有返回“Actor 已创建”的结果,随后便把对象加入 Loaded 集合;
  3. Spawn 可能因 World、脚本环境或实例尚未就绪而暂时无动作;也可能只创建出 Actor,网格仍在异步加载;
  4. 出生区域或 Pin 完成条件只检查 Desired 和 Loaded 集合,于是可以提前发出完成信号;
  5. 稍后网格回调才在游戏线程设置组件,期间对象逻辑上存在,画面上却是空的。

工程里其实已经存在更强的判断:LOD 从代理切回源对象时,会逐个确认 Actor 属于当前 UWorld、未进入销毁、网格类型不为空,或至少有已注册的 UPrimitiveComponent。也就是说,系统已经知道 Actor Live 不等于 Visual Ready,只是这个知识没有上升成统一契约。

最终加载遮罩另有独立的首帧屏障,会等待本地角色首个有效姿态和关键数据,因此不能简单断言“出生区域信号一定会直接关闭遮罩”。真正的问题是:同一个“已加载”词在空间流式、Pin、会话推进和可见切换中代表了不同保证,调用方必须靠隐含知识避免误用。

二、实际实现:空间决策、Actor 生命周期和可见依赖是三条链

当前实践已经把大部分关键机制做对,只是三条链最终汇入了语义过载的集合。

flowchart LR
    A[紧凑拓扑与世界包围盒] --> B[观察点距离 / 原子组 / 常驻策略]
    B --> C[Desired]
    C --> D[资源优先级授权]
    C --> E[请求 Spawn]
    E --> F[Actor Live 回调]

    D --> G[异步加载依赖闭包]
    G --> H[强引用完成队列]
    H --> I[游戏线程分帧绑定组件]
    F --> J{Presentation Ready?}
    I --> J
    J -->|是| K[源对象可见]
    J -->|否| L[保留代理或占位]

    E -. 当前过早写入 .-> M[Loaded 集合]
    M -. 驱动 .-> N[区域 / Pin 完成信号]

空间层不遍历 Actor 来做决策,而是维护紧凑的拓扑和包围盒描述;距离使用旋转对象投影到世界空间后的 AABB,组合对象可以按原子组整体进出。进入和退出使用不同阈值,内存压力策略还有高低水位、冷却时间与重复加载保护,避免观察点在边界附近抖动。

Actor 层必须服从 UE 生命周期。SpawnActor、组件注册、设置 Static Mesh 或 Skeletal Mesh、创建动态材质都应回到游戏线程。流出也不能只调用 AActor::Destroy:某些对象有一次性初始化闸门,销毁后必须重置生命周期状态,否则下次进入范围时 Spawn 请求会被旧状态挡住。DS 是否启用空间流出,则应以 UWorld::GetNetMode() 为真相,而不是等待更晚建立的脚本身份;权威世界需要保留碰撞与玩法对象,但不需要加载客户端视觉依赖。

资产层使用异步加载,再把完成结果送入有帧预算的游戏线程队列。队列中的 TStrongObjectPtr<UObject> 保证“等待应用”的资产不会被 GC;TWeakObjectPtr 只负责判断 World、Scene 和 Actor 是否还活着。回调还会复核场景代次、对象请求代次和“逻辑 ID 当前是否仍映射到同一个 Actor”,因此旧场景或旧 Actor 的迟到结果不会覆盖新对象。

三、值得保留:这些设计已经击中了 UE 的真实约束

  • 逻辑拓扑与 Actor 分离。 海量对象不应先生成 Actor 再决定是否需要;空间决策使用轻量描述,Actor 只是决策结果。
  • 以 UWorld 和 NetMode 判断执行角色。 这比脚本层身份更早、更稳定,也明确了客户端视觉驻留与 DS 玩法驻留的不同契约。
  • 资产调度先于重资源绑定。 先确认对象仍在需求集合、优先级已经获准,再释放延迟的网格和材质工作,避免后台请求绕过空间优先级。
  • 异步回调同时校验 Weak Pointer、World、Actor Identity 和 Generation。 仅检查 IsValid() 不够;同一逻辑 ID 可能已经对应新 Actor。
  • 强引用只跨越必要窗口。 完成队列用强引用保护待应用的 UObject,执行后由组件或其他明确所有者接管,不让弱引用承担驻留职责。
  • 表示切换采用两阶段提交。 新代理未准备好时保留源对象;源对象重新就绪前保留代理,并恢复源 Actor 原有隐藏状态,而不是无条件显示。
  • 主线程工作有时间与数量双预算。 失败回调也可能触发脚本或组件修改,同样不能无界排空;重入任务延迟到下一帧,避免本帧队列被回调继续放大。
  • 首帧屏障是命名需求集合。 角色首姿态、关键数据等分别报告 Ready 或 Failed,比把“加载进度到 100%”当成事实更可靠。

四、结构性缺陷:Loaded 集合混合了意图、事实和呈现承诺

当前集合在缓存重建时按真实 Actor 存活初始化,Actor 晚到后也会补登记;流出发现 Actor 尚未生成时会保留记录并重试。这些修复解决了“异步 Actor 晚到后永久脱离管理”的真实问题。但流入路径仍在发出 Spawn 请求后立即写入同一集合,所以集合的语义取决于它从哪条路径被写入。

至少五个状态不能合并:

状态UE 中可验证的事实不能推出什么
Desired空间与玩法策略当前需要它Actor 已经存在
MaterializingSpawn 或资源请求已提交请求一定成功
ActorLive当前 UWorld 中存在匹配 Actor组件和视觉依赖已就绪
DependenciesResident必要 UObject 有明确强所有者组件已注册、渲染状态已更新
PresentationReady必要组件已注册并绑定有效资源,可安全替换旧表示资源已经可以从内存回收

这里还有一个容易忽略的 UE 特性:磁盘缓存、UObject 驻留、Actor 存活和 GPU 资源不是同一层内存。 AActor::Destroy() 只结束 World 中的实体生命周期;清除组件和强引用后,资产才变为可被 GC 回收,GPU 资源的释放还可能继续滞后。因此“本帧 Stream Out 了多少 Actor”不能直接解释为“本帧释放了多少物理内存”。现有的资源估算、进程内存采样和高低水位有价值,但它们衡量的是不同信号,不能合并成一个精确回收量。

同理,TWeakObjectPtr 是取消和迟到保护,不是驻留句柄。若异步完成到组件接管之间没有 FStreamableHandle、TStrongObjectPtr 或 UPROPERTY 强引用,GC 窗口仍可能让“加载成功”变成不可用对象。

五、目标设计:用驻留票据连接 Demand、UObject 与可见提交

目标不是增加更多互相推导的布尔值,而是让每个阶段只有一个提交者。空间系统发布 Demand;物化器提交 Actor Live;资产系统持有 Residency Ticket;呈现层在组件满足条件后提交 Presentation Ready。所有异步完成都携带 WorldEpoch + ObjectGeneration + DemandEpoch。

struct FResidencyRecord {
    uint64 WorldEpoch;
    uint64 ObjectGeneration;
    uint64 DemandEpoch;
    EPhase Phase; // Absent, Requested, ActorLive, DependenciesResident, PresentationReady

    TWeakObjectPtr<AActor> Actor;          // 只判断当前实体是否仍匹配
    TSharedPtr<FStreamableHandle> Handle;  // 保持依赖闭包驻留
    TStrongObjectPtr<UObject> PendingAsset;// 保护等待游戏线程应用的结果
};

void OnDemandChanged(FObjectKey Key, bool bDesired) {
    FResidencyRecord& R = Records.FindOrAdd(Key);
    ++R.DemandEpoch;

    if (bDesired) {
        R.Phase = EPhase::Requested;
        RequestDependencyClosure(Key, R.DemandEpoch);
        RequestActorMaterialization(Key, R.DemandEpoch);
    } else {
        KeepOldRepresentationUntilReplacementOrFallback(Key);
        CancelRequestsOlderThan(R.DemandEpoch);
        DestroyActorOnGameThread(R.Actor);
        R.Handle.Reset();       // 变为 GC 可回收,不承诺立即释放物理内存
        R.PendingAsset.Reset();
        R.Phase = EPhase::Absent;
    }
}

void TryCommitVisible(FObjectKey Key, const FCallbackStamp& Stamp) {
    FResidencyRecord* R = FindIfCurrent(Key, Stamp); // 校验三个 epoch
    if (!R || !R->Actor.IsValid()) return;
    if (!RequiredComponentsAreRegistered(*R->Actor)) return;
    if (!RequiredMeshesAndMaterialsAreBound(*R->Actor)) return;

    R->Phase = EPhase::PresentationReady;
    SwapFromProxyOrPlaceholder(Key);       // 新表示 ready 后再隐藏旧表示
    NotifyVisibleReady(Key, R->DemandEpoch);
}

Spawn 接口也应从 void 改成可观察协议:同步返回 AlreadyLive / Accepted / Rejected,异步再通过带代次的完成事件提交 ActorLive。区域和 Pin 完成条件必须显式选择所需保证:脚本只依赖实体时等 ActorLive;玩家即将看见时等 PresentationReady;DS 则使用 GameplayReady,确认碰撞、权威组件和必要脚本已就绪,不加载 Mesh、Material 与纹理。

内存回收同样以票据为边界。离开需求范围时先撤销可见承诺,再销毁 Actor、清组件引用、释放 FStreamableHandle;此时只能记录 ReclaimEligibleBytes。实际内存下降应通过后续 GC 和平台内存采样确认,不应为了每个对象流出而强制 GC。高低水位、退出半径、冷却时间和重复加载率共同决定何时释放票据,从而避免省下少量内存却反复支付下载、反序列化、创建渲染资源和主线程绑定成本。

六、取舍与验证:快不是提前报 Ready,而是让关键阶段可预算

模型优点代价与风险适用边界
单一 Loaded 集合实现最简单,集合差分很快无法表达失败、迟到、空 Actor 和视觉未就绪仅适合同步、内置、无异步依赖的对象
Desired + ActorLive能正确管理 Actor 进出与 WaitForChild首帧和 LOD 仍可能看到空组件无视觉承诺的玩法实体
分阶段状态 + 驻留票据可定义完成语义、拒绝旧回调、控制 GC 窗口状态与诊断成本更高,必须统一所有完成信号动态内容、大世界、多表示和跨平台客户端

现有测试已经覆盖距离与原子组迟滞、内存水位、重复加载保护、Actor 晚到、角色保护、旧代次回调、LOD 失败回退和主线程队列预算。下一阶段应补四个跨层故障注入:

  1. Spawn 被接受但 Actor 未创建,区域完成信号不得触发;
  2. Actor 存活但网格为空或组件未注册,代理必须继续可见;
  3. 对象流出后以同一逻辑 ID 重生,旧资源回调不得写入新 Actor;
  4. 释放驻留票据并经历 GC 后,仍在 Demand 内的对象必须保持资源,退出 Demand 的对象则可稳定回收且不在边界反复加载。

指标也应按阶段拆开:TimeToActorLive、TimeToPresentationReady、完成队列等待与应用耗时、每帧物化峰值、代理空洞帧数、重复加载率、可回收估算和 GC 后实际内存变化。只有这样,问题才能被定位为网络、UE Async Loading、游戏线程绑定、渲染提交还是回收迟滞。

我的反思是:早期用集合差分管理 Actor 很合理,因为主要矛盾是“几十万逻辑对象不能都生成 Actor”;当网格、材质、代理 LOD、首帧屏障和内存压力都进入异步链路后,核心问题已经变成“当前阶段究竟承诺了什么”。继续给 Loaded 打补丁,只会让更多调用方依赖未写出的约定。

最终决策规则:UE 中任何 Ready 信号都必须写清层级;旧表示只能在新表示 PresentationReady 后退出,内存也只能在最后一个 Residency Ticket 释放后进入可回收状态。

前置的持久化安装见:缓存安装:下载完成不等于资产可用。