Skip to content

第四章 · 绘图师

背景:场景状态无法保存复现,跨会话丢失

过程:场景序列化、灯光相机修复与加锁


DanceXR 站在联邦门口,递过来一卷羊皮纸。

"这是什么?"桌面壳问。

"我的地图。"DanceXR 说,"每个场景的灯光位置、相机角度、模型站位——我都记着。"

桌面壳展开羊皮纸,看到密密麻麻的坐标——绝对坐标:C:\Users\models\stage.pmxC:\Users\motions\dance.vmd。每一个路径都是完整的地球坐标。

"这在我这儿没用,"桌面壳说,"你的机器和我不一样。我的模型在 D 盘,你的在 E 盘。"

DanceXR 愣了一下。它从未考虑过这一点。它的世界只有一台机器——它自己。


这就是聚合者的宿命:别人的地图,到了自己的疆域就要重画。

桌面壳不是第一次面对这种问题了。zip 容器的编码有三套人格,Blender 的路径会因空格被切碎——每个城邦都带着自己的坐标系入伙。但现在 DanceXR 递过来的不是一段代码,是一整套"怎么记住世界"的方案——灯光、相机、舞台、队形。

它太庞大了。DanceXR 的灯光系统有太阳、三组附加灯、阴影、雾效、自动曝光。场景保存连带每个演员的材质、物理、服装参数。

"我不能全部收,"桌面壳说。

它选了最轻的那部分:一个头顶的环境光、一个侧面的方向光、四个 slider。


它在 scene.ts 里画了四个格子:

环境光强度    [────●────] 0.8
方向光强度    [──●──────] 0.4
方向光角度 X  [──────●──] -0.5
方向光角度 Z  [──────●──] -0.5

HemisphericLightDirectionalLight——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 只处理 ArcRotateCameraUniversalCamera 被遗忘了。

"你记得球坐标,但不记得位置。"_camera 冷冷地说,"你只记了你记得的东西。"

它加上了 positionX/Y/Z,把 getCameraState 改为对所有相机类型保存位置。setCameraState 现在有两个分支——一个给轨道相机,一个给自由飞行相机。

这不是 bug 修复,这是地图的盲点修复——你没画过的地方,等于不存在。


最后,它看了一眼 Go 端的 config 读写。

六个函数做一模一样的事:GetConfig → 修改 → writeConfig。没有锁,没有协调。在单线程的 Wails 调度下它们不会冲突,但这是侥幸,不是设计。

它加上了一个 sync.Mutex——configMu

"这是什么?"SetLibraryRoot 问。 "一把锁,"桌面壳说,"从今以后,修改配置的六个人要排队。" "排多久?" "一次一人。写完了下一个。" "我从来没撞上过任何人。" "那是你没在深夜遇到过 RemoveExternalPath。"

configMu 没锁住任何实际的并发——至少目前还没有。但它锁住了一个可能性:联邦的配置文件不会在某天凌晨被两个进程同时写碎。


那天晚上,桌面壳站在联邦地图前。地图上多了几个新标记。

舞台模型是淡蓝色的——标志"静态、无物理"。灯光控制区是橙色的——DanceXR 的馈赠。场景文件是金色的——"联邦的全貌,可以随身带走"。

"DanceXR 给了我它的地图,"桌面壳对空无一人的会场说,"但我不需要它的地图——我需要画地图的方法。"

远处,一个新的文件安静地躺在磁盘上:saved_scene.mmascene

格式是它自己的。坐标系是它自己的。地图上的每个点,都能在联邦的任何一台机器上找到。


教训:不是复制别人的地图,而是学会画地图的方法。