Skip to content

被遗忘的记忆

背景:重启后环境设置(天空、地面、水面等)全部恢复默认值,用户保存的 env 状态丢失。三个独立问题叠加:级联 auto-save 覆盖场景文件、restoreEnvState 绕过中央入口、场景文件 env 被 skipEnv 跳过。 过程:加日志排查三轮,定位到 config.json 写入时机不可靠(beforeunload 时 SetEnvState binding 未完成)+ 场景文件 env 被 skipEnv=true 跳过 → 修复:场景恢复后强制用场景文件 env 覆盖 config.json 的默认值。


序、失忆的联邦

联邦有一个秘密——它记不住自己昨天长什么样。

每次重启,天空都会恢复成那片标准的蓝色,地面变回灰白的网格,水面消失得无影无踪。用户调好的夕阳、暖色光、薄雾——全都不见了。

不是没人保存。每次修改环境参数,setEnvState 都会把当天的状态写在两块石板上:一块藏在 config.json 里,一块刻在 last_scene.json 上。可每次重启,联邦总是只读第一块,而第一块上写的永远是默认值。

第二块石板上有正确的记录,但联邦从不翻开它。


一、两块石板

联邦的保存机制,从诞生的第一天起就埋下了隐患。

当用户调整天空颜色、开启水面、修改粒子参数时,setEnvState 会做两件事:

  1. _envPersistTimer 上挂一个 500ms 的定时器,到期后调用 SetEnvState 写入 config.json
  2. 调用 triggerAutoSave(),在另一个 500ms 定时器到期后调用 SaveLastScene 写入 last_scene.json

两个定时器,两个目标,两套写入路径。

但它们的可靠性完全不同。

SetEnvState 的写入路径是:Go updateConfigwriteConfig → JSON 序列化 → tmp 文件写入 → 重命名 → 再写一份到 setting/ 目录 → 再重命名。每一步都可能失败,每一步都可能被中断。

SaveLastScene 的写入路径是:Go os.CreateWriteStringSync。三行代码,没有中间状态。

当用户关闭窗口时,beforeunload 事件触发 cleanupAndFlushSave()

typescript
function cleanupAndFlushSave(): void {
    flushEnvState();       // 调用 SetEnvState — 异步,fire-and-forget
    flushUIState();        // 调用 SetUIState — 异步,fire-and-forget
    saveSceneImmediate(true); // 调用 SaveLastScene — 异步,fire-and-forget
}

三个请求同时发出,窗口关闭。

SaveLastScene 的请求像一支轻箭,眨眼就飞到了终点。SetEnvState 的请求像一辆满载的马车,在山路上颠簸前行,还没到目的地,城门就关上了。

所以 last_scene.json 总是写成功的,config.json 里的 env 状态却经常丢失。


二、值班的守门人

重启时,联邦的初始化流程是这样的:

restoreEnvState()       ← 读 config.json 的 env
                         ← config.json 里是默认值!
tryRestoreLastScene()   ← 读 last_scene.json
                         ← 调用 deserializeScene(data, true)
                         ← skipEnv=true,环境状态跳过!

restoreEnvState 是一位尽职的守门人。它推开 config.json 的大门,取出里面的 env 状态。但门里只有默认值——因为上一次关闭时,那辆满载的马车没有赶到。

然后 tryRestoreLastScene 出场了。它打开 last_scene.json,里面保存着完整的场景快照——模型、相机、灯光、甚至环境状态。它调用 deserializeScene 来恢复场景。

deserializeScene 被传了一个参数:skipEnv = true

"跳过环境,"它说。"环境状态已经由 restoreEnvState 恢复了。"

可是 restoreEnvState 没有恢复任何东西,因为 config.json 里空空如也。

于是,两块石板都没有被正确读取。第一块是空的,第二块没有被翻开。


三、信使的呐喊

联邦的日志里,有一段被忽略的呐喊。

[auto-save] triggerAutoSaveImpl() called — debounce scheduled (500ms)
[auto-save] triggerAutoSaveImpl() called — debounce scheduled (500ms)
[auto-save] triggerAutoSaveImpl() called — debounce scheduled (500ms)

这是 deserializeScene 在恢复场景时触发的自动保存。

deserializeScene 的流程中,它调用 setCameraStatesetLightStatesetRenderState 来恢复相机、灯光和渲染参数。而这三个函数,各自在完成工作后都会调用 triggerAutoSave()

每条消息都触发一次 500ms 的防抖保存。防抖到期后,saveSceneImmediate 被调用,把当前场景——一个刚刚恢复的、还没有任何模型的场景——写回 last_scene.json

场景恢复触发了场景保存,保存的内容是空场景,空场景覆盖了之前正确的场景文件。

这就是为什么有时候重启后,连模型都不见了。


四、三把手术刀

修复分三步,每一步都切中一个独立的问题。

第一刀:沉默的恢复

scene-serialize.ts 中,加了一个 _suppressAutoSave 标志。

typescript
let _suppressAutoSave = false;

export function triggerAutoSaveImpl(): void {
    if (_suppressAutoSave) {
        return;  // 恢复期间,一切保存请求都沉默
    }
    _autoSaveDebounced();
}

deserializeScene 入口设 true,出口恢复 false。期间所有的 triggerAutoSave 调用都被静默吞掉——不再有级联保存,不再有覆盖场景文件。

第二刀:统一的门户

restoreEnvState 原本用 Object.assign(envState, loaded) + applyEnvState() 来恢复环境状态。但 applyEnvStateenv.ts 里的全量无条件重建,不走 setEnvState 的中央入口。

这就像守门人自己挖了一条地道进仓库,绕过了正门的登记处。

修复:改用 setEnvState(loaded, true),走中央入口。setSuppressAutoSave(true/false) 包裹,确保恢复期间不触发保存。

第三刀:翻开第二块石板

最关键的修复在 tryRestoreLastScene 中:

typescript
await deserializeScene(data as unknown as SceneFile, true);

// 场景文件中有 env 状态时,覆盖 config.json 中的 env 状态
const envFromScene = (data as Record<string, unknown>).env;
if (envFromScene && typeof envFromScene === 'object') {
    setSuppressAutoSave(true);
    setEnvState(envFromScene as Partial<EnvState>, true);
    setSuppressAutoSave(false);
}

deserializeScene 的参数仍然是 skipEnv = true——这是为了保持 deserializeScene 的通用性,不破坏其他调用方。但在此之后,联邦主动翻开 last_scene.json,取出里面的环境状态,强制覆盖。

第二块石板终于被正确读取了。


尾章、记忆的回归

修复后第一次重启,联邦的日志里出现了新的信息:

[auto-load] 场景文件中包含 env 状态,覆盖 config.json 的 env 状态
[env-persist] setEnvState() called: skyMode='procedural', skyColorTop=[0.9, 0.45, 0.2], ...

用户打开环境菜单,天空是暖橙色的夕阳,地面是柔和的沙色,水面泛着微光。

联邦终于记起了自己昨天的样子。

两块石板的故事留下了一个教训:当一个状态有两个存储入口时,不要假设某个入口"一定是最新的"。应该总是比较两个入口,择优使用。或者更简单——场景恢复后,强制用场景文件中的状态覆盖。

因为 config.json 的马车可能会迟到,但 last_scene.json 的轻箭从不失约。