Appearance
第四章 · 绘图师
背景:场景状态无法保存复现,跨会话丢失
过程:场景序列化、灯光相机修复与加锁
DanceXR 站在联邦门口,递过来一卷羊皮纸。
"这是什么?"桌面壳问。
"我的地图。"DanceXR 说,"每个场景的灯光位置、相机角度、模型站位——我都记着。"
桌面壳展开羊皮纸,看到密密麻麻的坐标——绝对坐标:C:\Users\models\stage.pmx、C:\Users\motions\dance.vmd。每一个路径都是完整的地球坐标。
"这在我这儿没用,"桌面壳说,"你的机器和我不一样。我的模型在 D 盘,你的在 E 盘。"
DanceXR 愣了一下。它从未考虑过这一点。它的世界只有一台机器——它自己。
这就是聚合者的宿命:别人的地图,到了自己的疆域就要重画。
桌面壳不是第一次面对这种问题了。zip 容器的编码有三套人格,Blender 的路径会因空格被切碎——每个城邦都带着自己的坐标系入伙。但现在 DanceXR 递过来的不是一段代码,是一整套"怎么记住世界"的方案——灯光、相机、舞台、队形。
它太庞大了。DanceXR 的灯光系统有太阳、三组附加灯、阴影、雾效、自动曝光。场景保存连带每个演员的材质、物理、服装参数。
"我不能全部收,"桌面壳说。
它选了最轻的那部分:一个头顶的环境光、一个侧面的方向光、四个 slider。
它在 scene.ts 里画了四个格子:
环境光强度 [────●────] 0.8
方向光强度 [──●──────] 0.4
方向光角度 X [──────●──] -0.5
方向光角度 Z [──────●──] -0.5HemisphericLight 和 DirectionalLight——Babylon.js 自带的基本光照,两个就够了。
不是 DanceXR 的方案不好。是桌面壳不需要那么亮。它只需要"能看到模型"。
舞台模型是另一个类似的权力博弈。
DanceXR 的舞台/场景类型也走物理引擎——它一视同仁。但桌面壳知道,舞台不需要跌倒,不需要弹跳,不需要被风吹动裙摆。
所以它加了一个参数:asStage。
ts
export async function loadPMXFile(filePath: string, asStage?: boolean): Promise<void>为 true 时,加载的是一个不会说话的角色——没有 MMD 运行时,没有物理引擎,没有 VMD 绑定,只有网格和纹理。一个纯粹的布景。
"你这是在制造二等公民,"wasmBullet 有些不满,"所有模型都应该有物理。" "舞台不需要知道重力,"桌面壳说,"它只是站在那里。"
它给舞台模型的场景标签换了一个图标——不是 🎭,是 🏛️。一个小小的区别,但足够让用户知道:这个不会跳舞。
真正的挑战来自"记住全貌"。
灯光、相机、模型位置、VMD 路径——每个子系统都能单独工作,但没有一个地方能把它们一起冻住。
桌面壳决定设计一个场景文件格式。
它翻遍了之前所有场景保存的设计——DanceXR 的完整序列化太胖,自己随手记的笔记太散。它最终写下的结构只有五段:
json
{
"version": 1,
"models": [{ "filePath": "...", "name": "...", "kind": "actor", ... }],
"camera": { "mode": "orbit", "alpha": -1.57, ... },
"lights": { "hemiIntensity": 0.8, "dirIntensity": 0.4, ... }
}.version 是给未来自己留的后路——当格式演进到 v2,v1 还能被识别。
这不是 DanceXR 的地图。这是桌面壳自己的地图。它记不住材质参数,记不住物理属性,但它记住了:什么模型站在哪里、什么灯光照着它、谁的眼睛在看。
但有一个问题桌面壳没预料到。
它存的是绝对路径——C:\Users\zhujieling11\MikuMikuAR\models\miku.pmx。
当它试着把自己保存的场景在另一台机器上加载时,路径碎了。不存在 C 盘的用户目录,不存在那个精确的文件夹结构。地图上的坐标,在另一个世界毫无意义。
它想到了码头。港口怎样面对来自不同城邦的货物——不看来源地,只看货物编号。libraryRoot 就是联邦的港口根目录。
它写了一个函数叫 computeLibraryRef:
ts
// 外部库: "ExternalLib:actors/miku.pmx"
// 主库: "stages/stage.pmx"
// 未知: null → 走 filePath 兜底这不是 GPS 坐标,这是"从港口出发怎么走"的导航指令。
它又写了对应的 resolveLibraryRef:给定一个导航指令,在当前配置的港口下找到实际路径。配置变了?港口变了?导航指令仍然有效——只要联邦的港口结构没变。
"以前我用经纬度记位置,"它自言自语,"现在我记'下了码头左转第三个仓库'。经纬度会变,码头不会。"
它在场景面板上放了两颗按钮,不是图标——是文字。
"保存场景","加载场景"。
文字按钮是两个新手也能看懂的词。之前有人提议用 💾 📂,但被否决了——"图标对新手不够友好。"这是桌面壳第一次不是为了城邦的方便,而是为了用户的方便做设计。
"这不是什么了不起的功能,"MenuStack 评价,"但方向对了——你把'记住世界'的权力交给了用户。"
相机是另一个发现。
在测试场景恢复时,桌面壳发现 freefly 模式保存后恢复回来,相机不在原来的位置。它查了序列化代码——setCameraState 只处理 ArcRotateCamera,UniversalCamera 被遗忘了。
"你记得球坐标,但不记得位置。"_camera 冷冷地说,"你只记了你记得的东西。"
它加上了 positionX/Y/Z,把 getCameraState 改为对所有相机类型保存位置。setCameraState 现在有两个分支——一个给轨道相机,一个给自由飞行相机。
这不是 bug 修复,这是地图的盲点修复——你没画过的地方,等于不存在。
最后,它看了一眼 Go 端的 config 读写。
六个函数做一模一样的事:GetConfig → 修改 → writeConfig。没有锁,没有协调。在单线程的 Wails 调度下它们不会冲突,但这是侥幸,不是设计。
它加上了一个 sync.Mutex——configMu。
"这是什么?"SetLibraryRoot 问。 "一把锁,"桌面壳说,"从今以后,修改配置的六个人要排队。" "排多久?" "一次一人。写完了下一个。" "我从来没撞上过任何人。" "那是你没在深夜遇到过 RemoveExternalPath。"
configMu 没锁住任何实际的并发——至少目前还没有。但它锁住了一个可能性:联邦的配置文件不会在某天凌晨被两个进程同时写碎。
那天晚上,桌面壳站在联邦地图前。地图上多了几个新标记。
舞台模型是淡蓝色的——标志"静态、无物理"。灯光控制区是橙色的——DanceXR 的馈赠。场景文件是金色的——"联邦的全貌,可以随身带走"。
"DanceXR 给了我它的地图,"桌面壳对空无一人的会场说,"但我不需要它的地图——我需要画地图的方法。"
远处,一个新的文件安静地躺在磁盘上:saved_scene.mmascene。
格式是它自己的。坐标系是它自己的。地图上的每个点,都能在联邦的任何一台机器上找到。
教训:不是复制别人的地图,而是学会画地图的方法。