Skip to content

场景的底片

背景:场景保存与恢复断裂——自动保存防抖、上次场景恢复、撤销快照各自为政,删除模型后快照不可回退。 过程:SceneFile 序列化 + last-scene restore + pushUndoSnapshot/popUndoSnapshot 链路打通,删除操作前置快照。


桌面壳摆好了一个场景。

三个模型,一束灯光,一片天空,一段正在播放的动作。它满意地看着屏幕——这个画面值得被记住。

"保存。"

然后它做了一件残酷的事:删掉了一个模型。

"撤销。"

画面纹丝不动。被删掉的模型没有回来。

"撤销!" 桌面壳又按了一次。

什么也没有发生。

它打开控制台,看到一行字:No undo snapshot available。没有快照,就没有后悔药。它的"保存"和它的"撤销"是两个互不相识的系统——保存把场景写进了硬盘,撤销却不知道这一幕曾经存在过。

"我能保存整个场景," 桌面壳说,"却不能在删掉一个模型后,把它找回来。保存是写给未来的,撤销是写给过去的——为什么它们不能是同一个东西?"


一、底片

桌面壳找到了序列化器。

SceneFile——一个把整个场景打包成数据结构的信封:模型列表、材质、环境参数、场景级动作、播放状态。它像一张底片:把三维世界的一切,压成一行一行的数据。

typescript
export function serializeScene(): SceneFile {
    // 遍历模型注册表 → 收集 modelId / 路径 / 材质覆盖 / 动作配置
    // 收集 envState(天空、地面、雾、云、水、粒子)
    // 收集场景级动作(motion-intent 的场景动作库)
    // 返回 SceneFile —— 一张完整的底片
}

桌面壳把这张底片摊开看。它惊讶地发现,场景里的一切都被收录了——不只是模型的位置和旋转,还有材质的覆盖、粒子系统的开关、甚至正在播放的动作和它的进度。

"这是一张会呼吸的底片。" 桌面壳说,"它记住的不只是'有什么',还有'正在发生什么'。"

它对照着把底片写回世界:

typescript
export async function deserializeScene(data: SceneFile, skipEnv = false): Promise<number> {
    // 反序还原:先清空旧场景 → 再逐个重建模型 → 恢复环境 → 恢复动作
    // 返回还原的模型数量
}

"保存是照一张相,恢复是把照片里的世界重新搭出来。" 桌面壳说,"序列化是摄影师,反序列化是搭景工——同一张底片,两个工种。"


二、记忆的写法

但桌面壳很快发现,这张底片有个毛病:它太容易被冲走。

用户改一个滑块、挪一个模型、切一个动作——每次变化,都触发一次自动保存。自动保存是防抖的:debounce 把高频变化攒成一波,停手后才真正落盘。

"防抖是好的," 桌面壳说,"不然拖一个滑块能触发三十次保存,硬盘都要冒烟。但防抖也有代价——它只记'现在',不记'刚才'。"

"什么意思?"

"保存是覆盖式的。每次落盘,都拿最新的底片盖住旧的那张。你删掉一个模型,然后保存——旧底片上还有它,新底片已经没有了。防抖让你少写几次硬盘,也让'过去'被冲得干干净净。"

"那怎么办?"

"在覆盖之前,先留一张底片。" 桌面壳说,"不是每次保存都留——那会撑爆硬盘。只在'破坏性操作'之前留:删模型、卸舞台、清预设。留一张快照,让用户反悔时有路可退。"


三、后悔药的配方

桌面壳实现了三样东西:

typescript
pushUndoSnapshot(): string | null;      // 破坏性操作前:按下快门,留一张底片
popUndoSnapshot(): string | null;       // 撤销时:取出最近一张底片
restoreUndoSnapshot(snap: string): Promise<boolean>;  // 把底片写回世界

"这三样东西,分别对应胶片相机的三个动作:按下快门、掀开暗盒、把照片放回世界。"

它在删除模型的入口加了一行:

typescript
const snap = pushUndoSnapshot();   // 删除前,先留一张底片
removeSceneMotion(motion.id);      // 然后才动手
offerSceneUndo(t('motion.motionRemoved'), snap, () => menu.reRender());

"offerSceneUndo 把后悔药递给用户:一个 toast,一个按钮。用户点'撤销',restoreUndoSnapshot 把底片上的世界重新搭起来——被删的模型,从数据里回来了。"

"快照会过期吗?"

"会。快照是字符串,存的是序列化数据。它占内存——所以栈有深度限制,只能反悔有限步。你删了十个模型,最老的那张底片早就被挤出去了。"

"那是最新的能反悔,最旧的反悔不了?"

"对。后悔药的保质期是有限的——越早的遗憾,越难挽回。这很公平,也提醒人:不要等到删了十个才想起来后悔。"


四、重启之后

还有一件事,桌面壳一直没想明白。

它重启了应用。空荡荡的场景——之前摆好的三个模型、灯光、天空,全都不见了。

"我保存过!" 它说,"为什么重启就没了?"

"你保存了,但没让它在启动时找回来。" 外交官说,"保存是写,恢复是读。你只做了写的一半。"

桌面壳找到了 tryRestoreLastScene——启动时自动恢复上次场景的入口。

typescript
export async function tryRestoreLastScene(): Promise<void> {
    // 读 last-scene 缓存 → deserializeScene → 恢复上次的世界
}

"启动时,先看看硬盘里有没有上一张底片。有,就把它冲印出来——世界回到上次的样子。没有,就空白开场。"

"这算不算'场景的记忆'?"

"算。" 桌面壳说,"模型是活的,动作是活的,但场景本身是脆弱的——断电、崩溃、误删,任何一个都能让它蒸发。底片就是场景的免疫系统:每次保存都是一次疫苗,每次恢复都是一次复活。"


尾声

桌面壳重新摆好了场景。三个模型,一束灯光,一片天空,一段正在播放的动作。

它删掉了一个模型。

"撤销。"

模型回来了——从底片上。位置分毫不差,灯光还是那盏,天空还是那片。整个世界的状态,被一张字符串快照完整地还了回来。

它重启了应用。

世界自己搭了回来——上次的模型、灯光、天空、动作进度,都躺在原地等它。

"保存是写给未来的," 桌面壳说,"撤销是写给过去的,恢复是写给下一次启动的。它们终于是一个东西了——都是把世界压成一行数据,再把它铺回现实。"

"那这行数据是什么?"

"是底片。" 桌面壳说,"场景是光,数据是银盐。光会消失,但银盐记得。"

它看着屏幕上的世界——被保存、被撤销、被恢复,来来回回,总有一张底片接住它。

"世界不是脆弱的," 它轻声说,"只要你还留着它的底片。"


教训:保存是写,撤销是读,恢复是重读——三件事共用同一张底片,才叫完整的记忆。

破坏性操作之前,先留一张后悔药。