场景流送的激活屏障:加载完成不等于可以切换
开放世界客户端从主城切入副本时,地形、灯光与玩法分区可以并行加载,但不能分别决定何时对玩家可见。本文把“加载完成”定义为单个资源句柄可用,把“场景激活”定义为全部必需句柄已登记且唯一状态所有者提交新场景;两者之间必须存在激活屏障。
// .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 必须蕴含全部必需句柄存在;失败返回前必须令句柄表为空且 activeScene 为 null。把激活权交给任一分区回调虽能缩短代码,却会让完成顺序决定世界状态。
// .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 → ACTIVE。Promise.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 拒绝,同时允许 terrain 与 gameplay 成功。断言不仅检查异常,还检查两个成功句柄均被释放、激活场景为空、句柄表归零。并发切换测试则证明第二个调用不能窃取第一个调用的状态所有权。
// .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,只有仍持有所有权的事务才能登记句柄或提交场景。