场景流送的激活屏障:加载完成不等于可以切换

开放世界客户端从主城切入副本时,地形、灯光与玩法分区可以并行加载,但不能分别决定何时对玩家可见。本文把“加载完成”定义为单个资源句柄可用,把“场景激活”定义为全部必需句柄已登记且唯一状态所有者提交新场景;两者之间必须存在激活屏障。

// .tmp/daily-labs/2026-07-24-1930/scene-transition.js
// symbols: TransitionState, SceneTransition
export const TransitionState = Object.freeze({
  IDLE: "idle",
  LOADING: "loading",
  READY: "ready",
  ACTIVE: "active",
  ROLLED_BACK: "rolled_back",
});

export class SceneTransition {
  constructor(loader) {
    this.loader = loader;
    this.state = TransitionState.IDLE;
    this.loaded = new Map();
    this.activeScene = null;
  }
}

三个流送分区穿过激活屏障后组成完整副本场景

所有权边界

SceneTransition 独占 state、已加载句柄表与 activeScene;加载器只创建和释放句柄,不得修改玩法可见状态。由此得到两个不变量:ACTIVE 必须蕴含全部必需句柄存在;失败返回前必须令句柄表为空且 activeScenenull。把激活权交给任一分区回调虽能缩短代码,却会让完成顺序决定世界状态。

// .tmp/daily-labs/2026-07-24-1930/scene-transition.js
// symbol: SceneTransition.enter
async enter(scene) {
  if (this.state === TransitionState.LOADING) {
    throw new Error("transition already loading");
  }
  this.state = TransitionState.LOADING;
  const required = [...scene.requiredChunks];
  try {
    const results = await Promise.allSettled(
      required.map(async (id) => ({
        id,
        handle: await this.loader.load(id),
      })),
    );
    for (const result of results) {
      if (result.status === "fulfilled") {
        this.loaded.set(result.value.id, result.value.handle);
      }
    }
    const failed = results.find((r) => r.status === "rejected");
    if (failed) throw failed.reason;
    if (this.loaded.size !== required.length) {
      throw new Error("activation barrier incomplete");
    }
    this.state = TransitionState.READY;
    this.activeScene = scene.id;
    this.state = TransitionState.ACTIVE;
    return { scene: scene.id, chunks: [...this.loaded.keys()] };
  } catch (error) {
    await this.rollback();
    throw error;
  }
}

激活屏障

正常时序是 LOADING → READY → ACTIVEPromise.allSettled 的作用不是容错,而是等所有并行请求终止后统一登记成功句柄,再作一次完整性判断。这样灯光先完成、地形后完成只影响等待时间,不影响提交语义。当前探针要求三个分区,屏障条件可复算为 loaded.size === required.length === 3

输入 屏障 场景状态 句柄数
terrain、lighting、gameplay 全成功 3/3 ACTIVE 3
lighting 失败 2/3 ROLLED_BACK 0

失败回滚

并行加载发生部分成功时,直接让 Promise.all 抛出会丢失成功结果,回滚便不知道该释放什么。实现先收集 settled 结果,再把已成功句柄纳入事务;发现拒绝后逆序释放。逆序不是本例的必要条件,却为存在依赖的真实资源栈保留“后取得、先释放”的安全语义。

// .tmp/daily-labs/2026-07-24-1930/scene-transition.js
// symbol: SceneTransition.rollback
async rollback() {
  for (const handle of [...this.loaded.values()].reverse()) {
    await this.loader.release(handle);
  }
  this.loaded.clear();
  this.activeScene = null;
  this.state = TransitionState.ROLLED_BACK;
}

正常激活与灯光分区失败后的回滚时序

回归约束

失败回归固定令 lighting 拒绝,同时允许 terraingameplay 成功。断言不仅检查异常,还检查两个成功句柄均被释放、激活场景为空、句柄表归零。并发切换测试则证明第二个调用不能窃取第一个调用的状态所有权。

// .tmp/daily-labs/2026-07-24-1930/scene-transition.test.js
// symbols: failed chunk..., concurrent transition...
test("failed chunk rolls back loaded handles and blocks activation", async () => {
  const resources = loader("lighting");
  const transition = new SceneTransition(resources);
  await assert.rejects(
    transition.enter({
      id: "dungeon",
      requiredChunks: ["terrain", "lighting", "gameplay"],
    }),
    /load failed: lighting/,
  );
  assert.equal(transition.state, TransitionState.ROLLED_BACK);
  assert.equal(transition.activeScene, null);
  assert.equal(transition.loaded.size, 0);
  assert.deepEqual(resources.released, [
    "handle:gameplay",
    "handle:terrain",
  ]);
});

test("a concurrent transition cannot steal state ownership", async () => {
  const first = transition.enter({
    id: "a", requiredChunks: ["terrain"],
  });
  await assert.rejects(
    transition.enter({ id: "b", requiredChunks: ["terrain"] }),
    /already loading/,
  );
  resume();
  await first;
  assert.equal(transition.activeScene, "a");
});

运行证据

探针运行于 Node.js v26.5.0,使用内置测试运行器,无新增依赖。测试统计为通过 3、失败 0、跳过 0;测试运行器报告 48.204 毫秒,进程墙钟 0.18 秒。该数据只证明状态合同,不用于推断生产流送性能。

$ cd $WORK_DIR/.tmp/daily-labs/2026-07-24-1930
$ npm test
✔ activates only after every required chunk is ready (1.36825ms)
✔ failed chunk rolls back loaded handles and blocks activation (0.307ms)
✔ a concurrent transition cannot steal state ownership (0.669042ms)
tests 3
pass 3
fail 0
skipped 0
duration_ms 48.203583
exit_code=0
real 0.18

$ npm run probe
{"input":{"scene":"dungeon","required":3},
"output":{"scene":"dungeon","chunks":["terrain","lighting","gameplay"]},
"state":"active",
"events":["load:terrain","load:lighting","load:gameplay"]}
exit_code=0

容量边界

当前实现的句柄登记与回滚空间复杂度均为 O(n),其中 n 是单次切换的必需分区数;没有进行吞吐或延迟基准。若 n 增长到需要分批流送,仍不能放松激活不变量,而应把分区划为“首帧必需集”和“后台可见集”,分别设置屏障。若需要取消、超时或跨帧重试,则演进触发条件是出现可观察的长尾加载或玩家重复切换,此时应引入 transition id 与取消令牌,拒绝迟到结果污染新事务。

架构结论

场景切换不是若干资源回调的集合,而是一笔拥有明确提交点的状态事务。单一所有者加完整性屏障牺牲了“某块先到就先显示”的局部响应速度,换取玩法、渲染与资源句柄同时跨越边界;失败时保留旧世界或空激活态,也优于暴露无法交互的半场景。当前接口向下一阶段交付的合同是:任何异步结果必须携带所属 transition id,只有仍持有所有权的事务才能登记句柄或提交场景。