异步场景切换:加载完成事件不能直接激活子场景

地形子场景先加载完成,画面立刻显示地表;光照还没切到新版本,玩法对象也仍属于旧区域。几帧后其余内容陆续出现,最终状态看似正确,切换过程却曾暴露一个从未被设计过的混合世界。把淡入遮罩盖在画面上只能隐藏视觉结果,碰撞、寻路、脚本和网络兴趣区仍可能在这段窗口里读取不一致状态。

这个问题不在异步加载能否成功,而在完成事件有没有权力修改当前世界。本文用三个固定依赖验证一条更窄的合同:TerrainLightingGameplay 可以并行准备,但只有场景切换协调器能交付激活批次。

三个场景层在激活屏障后合并为同一可见世界

单个完成回调只报告事实

最直接的实现会在每个加载回调里调用激活。探针中的 ImmediateSceneActivator.ReportReady 刻意保留了这个失败样本:只收到 Terrain 后,活动集合立即变成单元素集合。它没有崩溃,也没有丢事件,却已经允许消费者观察到半提交状态。

屏障实现把“资源已经准备好”与“可以替换当前世界”拆成两个动作。LoadTicket 同时携带场景名和代次;协调器只接收当前代次、当前事务和已声明依赖的回调。就绪集合完整之前,Commit 始终拒绝执行。

public enum TransitionState
{
    Idle,
    Loading,
    ReadyToActivate,
    Failed
}

public readonly record struct LoadTicket(long Generation, string Scene);

public readonly record struct ActivationBatch(
    long Generation,
    IReadOnlyList<string> Scenes);

public sealed class SceneTransitionCoordinator
{
    private readonly HashSet<string> required;
    private readonly HashSet<string> ready = new(StringComparer.Ordinal);
    private long generation;

    public SceneTransitionCoordinator(IEnumerable<string> requiredScenes)
    {
        required = new HashSet<string>(requiredScenes, StringComparer.Ordinal);
        if (required.Count == 0 || required.Any(string.IsNullOrWhiteSpace))
        {
            throw new ArgumentException(
                "At least one named scene is required.",
                nameof(requiredScenes));
        }
    }

    public TransitionState State { get; private set; } = TransitionState.Idle;
    public string? Failure { get; private set; }

    public IReadOnlyList<LoadTicket> Begin()
    {
        generation++;
        ready.Clear();
        Failure = null;
        State = TransitionState.Loading;
        return required.Order()
            .Select(scene => new LoadTicket(generation, scene))
            .ToArray();
    }

    public bool ReportReady(LoadTicket ticket)
    {
        if (!Accepts(ticket)) return false;

        ready.Add(ticket.Scene);
        if (ready.SetEquals(required))
        {
            State = TransitionState.ReadyToActivate;
        }
        return true;
    }

    public bool ReportFailed(LoadTicket ticket, string reason)
    {
        if (!Accepts(ticket) || string.IsNullOrWhiteSpace(reason))
        {
            return false;
        }

        Failure = reason;
        State = TransitionState.Failed;
        return true;
    }

    public ActivationBatch Commit()
    {
        if (State != TransitionState.ReadyToActivate)
        {
            throw new InvalidOperationException(
                "Every required scene must be ready before activation.");
        }

        State = TransitionState.Idle;
        return new ActivationBatch(generation, required.Order().ToArray());
    }

    private bool Accepts(LoadTicket ticket)
    {
        return State == TransitionState.Loading
            && ticket.Generation == generation
            && required.Contains(ticket.Scene);
    }
}

排序不是正确性的必要条件,但让激活批次、日志和回放断言保持确定。真正关键的是 ActivationBatch 只能从 ReadyToActivate 产生;加载器不能绕过协调器直接写活动世界。

失败与重试属于同一个事务边界

如果 Lighting 失败,已经就绪的 Terrain 不应留在新世界中等待补齐。探针先报告一个成功,再报告另一个失败,状态进入 Failed,随后调用 Commit 会抛出异常。协调器没有返回半份批次,因此当前活动世界仍由原来的拥有者保留。实际引擎可以在这个分支释放预载句柄、展示错误或重试,但不需要从已公开的新旧混合状态中回滚。

重试还带来另一类竞态:第一轮取消后,旧的加载回调可能晚于第二轮返回。只清空集合并不足够,因为场景名在两轮中相同。generation 让旧票据失效;测试分别把旧 Ready 和旧 Failed 送入新事务,两者均返回 false,新事务仍保持 Loading

协调器收集同一代次的完成事件并一次提交

这里的代次不是内容版本。它只标识一次切换事务,防止异步完成事件跨事务写入。资源包版本、场景内容哈希或服务器地图版本仍应由更上层的发布合同校验,不能复用这个递增整数替代。

固定输入把半提交窗口变成断言

当天运行使用 .NET 9 Release 构建。构建结果为零警告、零错误,耗时 6.26 秒;测试段记录 12 个用例全部通过,无失败和跳过,耗时 70.7343 毫秒。该耗时只覆盖进程内断言,不代表引擎加载性能。

$ dotnet build -c Release --nologo -p:NuGetAudit=false
Build succeeded.
    0 Warning(s)
    0 Error(s)
Time Elapsed 00:00:06.26

$ dotnet run -c Release --no-build --no-restore
exitCode=0
passed=12 failed=0 skipped=0 elapsedMs=70.7343
input=Terrain,Lighting,Gameplay
states=Loading,Loading,ReadyToActivate
activationGeneration=1
activated=Gameplay,Lighting,Terrain

前两个完成事件之后仍是 Loading,第三个完成后才变为 ReadyToActivate。失败样本还覆盖了局部成功后失败、失败后提交、重复完成、未知场景、空依赖以及两种迟到回调。参照断言检查外部可观察的状态与批次,没有复制协调器内部的集合判断。

激活批次仍不是引擎级原子指令

这份实现证明的是 CPU 协调边界,不声称多个引擎场景能在底层以单条指令原子激活。接入 Unity、Godot 或自研运行时时,消费端仍要定义一帧内的提交顺序:暂停世界读取,绑定新场景对象与服务,切换活动集合,再恢复更新。若任一步可能失败,提交阶段还需要预验证或可回滚的旧句柄;不能因为上游返回一个批次,就假定下游天然没有中间状态。

同样,流式大世界通常不会等待所有远处分块。此时屏障范围应缩到“本次必须一致的最小集合”,例如玩家出生格、碰撞、导航和玩法脚本;远景装饰可以属于另一条不阻塞提交的流。依赖集合过大只会把可用性绑到最慢资源,过小则重新暴露混合世界,范围必须由哪些消费者会在激活后立即读取来决定。

开头的地形不再凭一次完成回调提前出现。加载器负责准备和报告,协调器负责事务状态,世界拥有者只消费完整批次。当前探针没有覆盖资源卸载、跨帧脚本初始化和联网玩家迁移;但它已经把最危险的权限边界固定下来:任何单个异步结果都不能独自决定新世界何时生效。