资源预载完成不等于场景可切换:用原子激活屏障保住旧世界

港口场景仍在屏幕上,后台地牢的资源进度已经显示 100%。切换发生后,地牢缺少碰撞数据,角色落出世界;若激活回调再抛出异常,港口却已经卸载,只能用黑屏兜底。进度条没有撒谎:它只证明一组加载任务结束了,没有证明候选场景具备接管可见世界的资格。

本文用一个固定切换说明这条边界。当前活动场景是 Harbor@40,候选是 Dungeon@41;地牢必须同时持有资源 101102103。前三个数字可以分别理解为场景数据、碰撞数据和首帧材质集合,但探针不依赖具体引擎 API。真正要回答的是:哪个对象拥有当前可见场景,以及何时允许修改它。

港口保持活动,地牢候选被激活边界隔离

预载结果只能成为候选

SceneActivationBarrier 持有唯一公开状态 ActiveStage 只替换私有 _candidate。加载器即使收到了所有异步完成回调,也不能直接卸载港口;它只能提交一个带场景名、代际和驻留资源集合的候选快照。这样“下载完成”“资源驻留”和“世界已激活”不会被压成同一个布尔值。

核心实现位于 SceneActivationBarrier.csTryActivate 依次检查票据是否仍指向当前候选、必需资源是否为驻留集合的子集,再调用真正的引擎激活入口。只有三项全部成功,最后两行才交换 Active 并清空候选:

public readonly record struct SceneSnapshot(
    string Name,
    long Generation,
    IReadOnlySet<int> ResidentResources);

public readonly record struct SceneTicket(long Generation, string Name);

public enum ActivationResult
{
    Activated,
    MissingResource,
    StaleTicket,
    ActivationFailed
}

public sealed class SceneActivationBarrier(SceneSnapshot initial)
{
    private SceneSnapshot? _candidate;

    public SceneSnapshot Active { get; private set; } = initial;

    public SceneTicket Stage(
        long generation,
        string name,
        IEnumerable<int> loadedResources)
    {
        if (generation <= Active.Generation)
            throw new ArgumentOutOfRangeException(nameof(generation));

        _candidate = new SceneSnapshot(
            name, generation, loadedResources.ToHashSet());
        return new SceneTicket(generation, name);
    }

    public ActivationResult TryActivate(
        SceneTicket ticket,
        IReadOnlySet<int> requiredResources,
        Action<SceneSnapshot> activate)
    {
        if (_candidate is not { } candidate ||
            candidate.Generation != ticket.Generation ||
            candidate.Name != ticket.Name)
            return ActivationResult.StaleTicket;

        if (!requiredResources.IsSubsetOf(candidate.ResidentResources))
            return ActivationResult.MissingResource;

        try
        {
            activate(candidate);
        }
        catch
        {
            return ActivationResult.ActivationFailed;
        }

        Active = candidate;
        _candidate = null;
        return ActivationResult.Activated;
    }
}

activate 是系统边界:在 Unity 中可以包住允许场景激活后的首帧准备,在 Godot 中可以对应实例化并挂入活动树,在自研引擎中可以提交渲染世界、物理世界与玩法入口。探针没有假定这些动作可回滚,因此要求回调在修改 Active 前完成。若引擎激活本身会产生外部副作用,还需要在回调内部使用同样的候选提交方式,不能靠这里的 catch 撤销已经公开的半个世界。

三种失败不应产生三种残缺画面

资源集合不完整是最直观的失败:候选只有 {101,102},缺少 103,结果为 MissingResource,活动场景保持 Harbor@40。较隐蔽的是陈旧完成。地牢票据 41 发出后,玩家又请求竞技场票据 42;即使地牢稍后才完成全部加载,它也只能得到 StaleTicket,不能覆盖更新的意图。

第三条回归让激活回调主动抛出异常。此时结果为 ActivationFailedActive 仍然指向港口。三种失败共享一个外部结果:旧世界继续可见。区别保留在结构化返回值中,供加载 UI、重试策略和遥测分别处理,而不是把所有错误吞成“仍在加载”。

活动场景、候选快照与三项激活检查的数据流

成功路径使用完整集合 {101,102,103} 与当前票据 41,激活回调返回后才得到 Dungeon@41。决定性测试在 Program.cs 中同时断言结果枚举和 Active.Name,避免只验证函数返回值,却漏掉活动状态已经被提前修改的错误:

var barrier = new SceneActivationBarrier(
    new("Harbor", 40, new HashSet<int> { 1, 2 }));
var ticket = barrier.Stage(41, "Dungeon", [101, 102]);

var result = barrier.TryActivate(
    ticket,
    new HashSet<int> { 101, 102, 103 },
    _ => { });

Equal(ActivationResult.MissingResource, result,
    "missing resource rejected");
Equal("Harbor", barrier.Active.Name,
    "missing resource preserves active scene");

当天以 Release 配置执行探针:

$ dotnet build $WORK_DIR/probe/SceneActivationProbe.csproj -c Release --no-restore
Build succeeded.
    0 Warning(s)
    0 Error(s)
Time Elapsed 00:00:06.11

$ dotnet run --project $WORK_DIR/probe/SceneActivationProbe.csproj -c Release --no-build --no-restore
missing: ticket=41 loaded=101,102 required=101,102,103 result=MissingResource active=Harbor@40
success: ticket=41 loaded=101,102,103 result=Activated active=Dungeon@41
stale: ticket=41 latest=42 result=StaleTicket active=Harbor@40
failure: ticket=41 result=ActivationFailed active=Harbor@40
tests: passed=8 failed=0 skipped=0 elapsedMs=6.0207

这里的毫秒数只表示小型进程内探针耗时,不是场景加载性能数据。它能证明的是四条状态迁移及其固定输出,不足以推断磁盘、网络、GPU 上传或引擎首帧的延迟。

屏障必须覆盖真正影响首帧的依赖

把提交点写对以后,剩下的风险转化为资源清单是否完整。若导航网格、碰撞形状、Shader 变体或出生点数据会影响切换后的第一帧,它们就必须进入必需集合,或由激活回调在提交前验证。只统计文件下载任务,会重新制造“100% 但不可用”的语义漏洞。

当前集合检查的时间复杂度与必需资源数 R 成正比,候选快照占用与其驻留条目数 L 成正比。本文没有测量大场景资源清单的吞吐。当 R 大到逐项哈希检查影响主线程预算时,可以在资源构建阶段生成版本化清单摘要,再由加载器验证摘要与少量关键依赖;但摘要版本、候选代际和实际激活结果仍须在同一提交判定中相遇。

多场景叠加会把单一 Active 扩展为分区集合,例如常驻系统层、当前玩法分块和邻接预取分块。此时原子单位不必是整个世界,却必须与可见性和玩法一致性相匹配:一个分块若要求地形、碰撞和导航同时生效,就不能分别公开三个子状态。观察到长时间停留在候选态时,应报告缺失资源、票据被替代或激活错误;扩大等待时间不能修复错误所有权。

港口何时卸载现在有了明确答案:不是进度到 100%,也不是最后一个加载回调返回,而是候选资源完整、票据仍代表当前切换意图、引擎激活成功,并且 Active 已一次性指向地牢之后。这个切片没有覆盖异步取消、跨分区依赖和失败后的资源回收;它保住的是更基础的可恢复性——候选世界没有取得完整接管资格以前,玩家仍拥有一个一致且可继续运行的旧世界。