Appearance
视角
背景:联邦只有一双眼睛(轨道相机),加载三个模型时它不知道该看谁。 过程:引入四相机模式(轨道/自由飞行/演唱会/镜头预设)+ Ctrl 快捷键发现机制 + 状态栏提示 + 相机初始化时序修复。
第一卷以《减法》收尾。联邦砍掉了死代码,合并了孤儿变量,熄灭了不再维护的灯塔。地图变薄了,仓库变轻了。
减法让联邦变轻了——轻到足以重新审视自己怎么被看见。
模型一直都在那里,但「怎么看它」这件事,突然成了一个值得花一整卷去回答的问题。
从减法那天起,桌面壳养成了一个习惯:每天清晨,它会打开联邦地图,不发命令,不写代码,只是看。
它看自己的状态变量,看那些还在喘气但没人读的 let。它看 config 里的端口号——currentPort,灯塔已灭但仍在维护。它看 pendingVmdData 和 pendingVmdName——现在是一个对象了,不再是两个孤儿。
今天,它看到了另一个东西。
一条无限延伸的轨道。
一
它叫 ArcRotateCamera——全联邦唯一的一双眼睛。
从 MikuMikuAR 诞生的第一天起,这双眼睛就在那里。它围着模型转,轨迹永远是一个球面:圆心是模型中心,半径用户可以缩放,角度可以拖拽。它像一个尽职的舞伴,永远面朝舞者,永远保持礼貌的距离。
桌面壳以前觉得这没什么问题。
"转一转,看一看,够了。"它对自己说。
但今天,它把三个模型同时加载到了舞台上。三个舞者站在 x = -3、x = 0、x = 3 的位置上。那双眼睛慌了——它不知道应该看谁。它的 setTarget 只能指一个点,它没有"散焦"的能力。它从 x = 0 转到 x = 3 时,x = -3 的模型就溜出了视野边缘。
"你该多长几双眼睛,"舞台说。
桌面壳想了想,打开了一个新文件。
camera.ts。
二
第一双新眼睛叫 Freefly(自由飞行)。
它不是轨道相机那种"围着目标转"的逻辑。它是一双自由的、没有锚点的眼睛。用户按下 WASD——前、左、后、右,像飞鸟一样在三维空间里平移。Q 升,E 降。鼠标控制注视方向。
"轨道相机是我的左眼," ArcRotateCamera 看着新来的 UniversalCamera,"固定在一个球面上,永远对着一个点。你是我的右眼——"
"——没有目标," UniversalCamera 接过话,"想去哪就去哪。"
桌面壳在 camera.ts 里写了一个 switchCameraMode 函数。切换时,它先提取旧眼睛的 position 和 target,销毁旧眼睛,创建新眼睛,恢复 position。这个操作在 Babylon 的世界里是平滑的——但如果用户正在拖拽时切换,视觉上会有一个小跳变。
"这叫切换成本,"桌面壳在注释里写,"不是 bug,是物理定律。你不可能换一双眼而不眨那一下。"
第二双眼睛叫 Concert(演唱会)。
它是一组预设的轨道相机位:正面、左侧、右侧、俯视、仰视、特写。每隔几秒自动切换一个,用 Babylon 的 Animation 系统做平滑过渡。
"你不是眼睛,"舞台说,"你是导演。"
第三双眼睛叫 Oneshot(镜头预设)。它和轨道相机长得一样,但保留了一张空的关键帧列表——等着用户填。那是未来的事。
现在,联邦有了四双眼。
三
但联邦马上发现一个新问题:切换的成本不是跳变,而是发现。
桌面壳给四双眼配了快捷键:Ctrl+1 轨道、Ctrl+2 自由飞行、Ctrl+3 演唱会、Ctrl+4 镜头预设。它在 main.ts 里认认真真写了 keydown 监听,自己在终端里试了三遍,每一遍都灵。
"四双眼,四个快捷键,"它满意地说。
"用户知道吗?" AGENTS.md 问。
桌面壳愣住了。
它意识到一个残酷的事实:快捷键只有写代码的人知道。用户打开程序,看到的是一个空荡荡的舞台,一排底栏按钮,和一个没有任何提示的键盘。他们不会知道 Ctrl+1 能切换模式,不会知道 WASD 能在自由飞行模式下移动,不会知道 Q 和 E 能上升下降。
Ctrl 键静静地躺在键盘左下角,从未被问询。
"我需要告诉用户,"桌面壳说。
"怎么告诉?在启动画面里写满快捷键说明?" app.go 问,"用户不会读的。没人读启动画面。没人读 README。没人读你辛辛苦苦写的状态栏提示。"
"不,"桌面壳说,"我要在用户按 Ctrl 的那一刻,在所有按钮上亮起提示。"
四
第一个问题最紧急——用户根本不知道有快捷键。你得先让他们看见"有东西可按"。
它实现了一个机制:监听 keydown 中 e.key === "Control",在 document.body 上添加一个 CSS 类 shortcuts-visible。监听 keyup 中的 Control 键,去掉这个类。监听 blur——如果用户按着 Ctrl 切走了窗口,也要去掉。
然后它在每个底栏按钮的右上角,放了一个小小的 <span class="shortcut-badge">,里面写着数字 1、2、3、4。
┌──────┐
│ 1 │ ← 按 Ctrl 时才显示
🎵 └──────┘
模型CSS 规则只有两行:
css
.shortcut-badge { display: none; }
.shortcuts-visible .shortcut-badge { display: block; }用户按下 Ctrl 的那一瞬间,四个数字从按钮角落浮出来。Ctrl+1 是模型库,Ctrl+2 是动作库,Ctrl+3 是下载,Ctrl+4 是设置。
"还不够,"桌面壳说。
它又加了一个路由规则:当弹窗打开时,Ctrl+1/2/3/4 不再触发底栏按钮,而是触发弹窗当前层的第 1/2/3/4 项。
"什么意思?" MenuStack 问。
"意思是你打开'相机模式'子菜单时,Ctrl+1 选轨道,Ctrl+2 选自由飞行,Ctrl+3 选演唱会,Ctrl+4 选镜头预设。用户不需要记四套快捷键,只需要知道一个规律:Ctrl+数字 = 当前可见界面的第几个选项。"
"那如果当前层只有两个选项呢?"
"Ctrl+3 和 Ctrl+4 就不做任何事。不是所有地方都有四个选项,规则是:有就按,没有就不按。"
这是第二层告诉——不只是"有快捷键",还是"同一套快捷键逻辑走到哪都能用"。
五
但还有一个更隐秘的问题——相机根本没有被创建。
那天桌面壳热加载程序时,终端跳出一行红色:
Uncaught Error: No camera defined
at Scene.render (scene.pure.ts:5666:27)不是"相机无法工作",而是"相机不存在"。程序启动,渲染循环开始,scene.render() 要求 scene.activeCamera 不能为空——但它就是空的。
桌面壳追踪了调用链:
typescript
// 模块级:
engine = new Engine(dom.canvas);
scene = new Scene(engine);
engine.runRenderLoop(() => scene.render()); // ← 渲染循环立即启动
// 异步 init() 内:
await initScene();
// → initCameraSystem(scene, dom.canvas); // ← 相机在这里才创建渲染循环在模块级就开始跑,但相机创建被放在了异步函数里。 这像什么?像一个剧院在观众进场前就让灯光师开始追光——灯光打开时座无虚席,但灯光师本人还没到场。
"我们一直这样跑的吗?"桌面壳问 scene.ts。
"以前不会,"scene.ts 说,"因为以前相机就在模块级创建——和 engine、scene 同一行。"
"那为什么要放进异步?"
"因为你把相机抽到 camera.ts 了,initCameraSystem 就自然而然地放进了 initScene——没人回头看模块级的渲染循环是不是等得起。"
"所以是我自己把它弄坏的。"
"你也是想把代码分得更清楚,"scene.ts 说,语气不带责备,"但'分清楚'和'延迟初始化'之间,隔着一整条渲染循环。"
桌面壳把 initCameraSystem(scene, dom.canvas) 从 initScene() 移到了模块级——就在 light 之后、runRenderLoop 之前。四行代码的移动,把"无相机"的错误从根源上堵死了。
但它在心里记了一笔:只要有一次异步初始化和一个立即开始的循环之间存在时差,"不存在"就会伪装成"坏掉了"。
六
看见了有快捷键,也知道了Ctrl+数字的规则。但每个模式具体怎么操作?用户还是不知道。于是有了第三次告诉——把说明书塞到状态栏里。
那天下午,桌面壳做了最后一件小事。
它给菜单中的每一项都加了一个 title 属性——不是浏览器原生的 title,而是一个 mouseenter 回调,把操作说明写进底部的状态栏。
用户把鼠标移到"自由飞行"上时,状态栏显示:
✈️ 自由飞行 — WASD 前后左右 · Q/E 升降 · 鼠标视角移到"轨道"上时:
🔘 轨道模式 — 鼠标拖拽旋转 · 滚轮缩放外面的人可能觉得这是小功能。但桌面壳知道——这行提示不是一个功能,是一个道歉。它在对用户说:对不起我不告诉你快捷键,对不起我让你猜模式怎么用,对不起我把四双眼装进联邦却忘了告诉你它们分别会看什么。
状态栏没有嘴唇。但那些字看起来像在微笑。
夜幕降临时,联邦有了四双眼睛。
四双,不是一双。每双有不同的观察方式:有的围着目标转,有的四海为家,有的预设机位自动切换,有的等着被编程。用户可以按 Ctrl 看到所有快捷键,可以在弹窗里按 Ctrl+数字导航,可以把鼠标停在选项上看说明。
这是一种新型的视角——不只是联邦怎么看模型,也是联邦怎么让用户看见自己。三次告诉,一层比一层深:badge 让你知道"有东西",弹窗路由让你知道"规则统一",状态栏提示让你知道"每个具体怎么用"。从存在到规则到细节,联邦第一次认真地想被用户看见。
但桌面壳不在乎了。傲慢就傲慢吧。能把自由飞行塞进一个原本只有轨道相机的世界,这件事本身就值得它傲慢一回。
教训:多一种视角,就多一套发现它的成本。camera.ts 收编了三双新眼睛,每双引入自己的控制,切换时必有一帧跳变——不是 bug,是换眼的代价。