Appearance
被遗忘的记忆
背景:重启后环境设置(天空、地面、水面等)全部恢复默认值,用户保存的 env 状态丢失。三个独立问题叠加:级联 auto-save 覆盖场景文件、restoreEnvState 绕过中央入口、场景文件 env 被 skipEnv 跳过。 过程:加日志排查三轮,定位到 config.json 写入时机不可靠(beforeunload 时 SetEnvState binding 未完成)+ 场景文件 env 被 skipEnv=true 跳过 → 修复:场景恢复后强制用场景文件 env 覆盖 config.json 的默认值。
序、失忆的联邦
联邦有一个秘密——它记不住自己昨天长什么样。
每次重启,天空都会恢复成那片标准的蓝色,地面变回灰白的网格,水面消失得无影无踪。用户调好的夕阳、暖色光、薄雾——全都不见了。
不是没人保存。每次修改环境参数,setEnvState 都会把当天的状态写在两块石板上:一块藏在 config.json 里,一块刻在 last_scene.json 上。可每次重启,联邦总是只读第一块,而第一块上写的永远是默认值。
第二块石板上有正确的记录,但联邦从不翻开它。
一、两块石板
联邦的保存机制,从诞生的第一天起就埋下了隐患。
当用户调整天空颜色、开启水面、修改粒子参数时,setEnvState 会做两件事:
- 在
_envPersistTimer上挂一个 500ms 的定时器,到期后调用SetEnvState写入config.json - 调用
triggerAutoSave(),在另一个 500ms 定时器到期后调用SaveLastScene写入last_scene.json
两个定时器,两个目标,两套写入路径。
但它们的可靠性完全不同。
SetEnvState 的写入路径是:Go updateConfig → writeConfig → JSON 序列化 → tmp 文件写入 → 重命名 → 再写一份到 setting/ 目录 → 再重命名。每一步都可能失败,每一步都可能被中断。
而 SaveLastScene 的写入路径是:Go os.Create → WriteString → Sync。三行代码,没有中间状态。
当用户关闭窗口时,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 的流程中,它调用 setCameraState、setLightState、setRenderState 来恢复相机、灯光和渲染参数。而这三个函数,各自在完成工作后都会调用 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() 来恢复环境状态。但 applyEnvState 是 env.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 的轻箭从不失约。