Appearance
多相机之眼
背景:Freefly 切回后滑条显示默认值而非相机实际值——UI 与状态不同步。 过程:状态同步修复(UI 问相机要实时值)+ One-shot 入口补全 + Concert 暂停功能 + 四项逻辑裂缝修复(水下后处理/水面风向/waveSpeed/天空旋转溢出)。
序
相机是观众的眼睛。
MikuMikuAR 有三种眼睛:Freefly(自由漫游)、One-shot(单拍构图)、Concert(演唱会)。Riku 切到 Freefly,把距离调到 15,试了几个角度。满意了。切到 One-shot 看看效果。看完切回来——
滑条显示 5。
他盯着那个数字。相机的实际距离是 15,他刚刚走了一圈回来,滑条却显示成默认值 5。这不是渲染延迟,这是状态丢失:相机记得自己在哪里,但菜单不知道。
问题的本质:状态是孤岛。
Freefly 的滑条
Riku 打开 renderFreeflyParams() 看。
滑条的值来自一个局部变量。初始化时这个变量等于默认值,之后没再更新过。切换模式再切回来,菜单重建,局部变量复位,但相机的实际参数已经变了——没人去问相机现在是多少。
修法很直接:让菜单每次渲染时去问相机要实时值。
camera.ts 里加一个导出函数:
typescript
export function getFreeflyParams(): FreeflyParams {
return { ...freeflyParams };
}renderFreeflyParams() 改成:
typescript
function renderFreeflyParams(container: HTMLElement) {
container.innerHTML = "";
const params = getFreeflyParams(); // 问相机要实时值
addSliderRow(container, "距离", params.distance, 1, 30, 0.5, (v) => {
setFreeflyParams({ distance: v });
});
// ...
}改完再试。切到 One-shot 再切回来——滑条显示 15。和相机一致。
这不是 bug,这是状态同步的缺失:相机有自己的状态,UI 有自己的状态,它们之间没有桥梁。
One-shot 的入口
One-shot 模式早就实现了。八个预设构图——全身、半身、特写、低角度、高角度、侧面、背面、俯视——全部在代码里。Riku 打开 camera.ts 确认了一遍,功能完整,逻辑清晰。
但他翻了半天菜单,找不到入口。
buildCameraLevel() 里只有 Freefly 的参数面板。One-shot 存在,像一个没有门的房间。
他加了一个文件夹入口:
typescript
{ kind: "folder", label: "单拍构图", icon: "lucide:camera", target: "scene:camera:oneshot" }在 onFolderEnter 路由里加 case:
typescript
case "scene:camera:oneshot": return buildOneShotLevel();buildOneShotLevel() 里列出八个按钮,每个对应一个构图 preset。这件事本身没有难度,十分钟写完。真正的问题是——这个功能在代码里存在了多久,才终于被人看见?
Concert 的暂停
Concert 模式启动后,相机沿着预设路径循环移动,像演唱会现场的导播切机位。
Riku 启动了一个路径,站定在一个好看的角度。想多看两秒——没有暂停。没有停留。路径继续走,下一圈才回来。
他把这叫做「开车没有刹车」。
在 camera.ts 里加状态标志和切换函数:
typescript
let _concertPaused = false;
export function toggleConcertPause(): void {
_concertPaused = !_concertPaused;
if (_concertPaused) {
_concertPathAnimator?.pause();
} else {
_concertPathAnimator?.resume();
}
}在 Concert 子菜单里加 toggle:
typescript
addToggleRow(container, "暂停", _concertPaused, (v) => {
toggleConcertPause();
sceneStack?.reRender();
}, "lucide:pause");暂停时相机定格在当前位置。恢复时从定格处继续。状态是可控的。
逻辑的裂缝
三件事做完,Riku 做了一遍逻辑审查。不是测试,是盯着代码看——那些「能跑但不对」的地方。
他发现了四个。
水下后处理的空判断。 scene.ts 里的 chromaticAberration 是可选后处理,直接写 pipeline.chromaticAberration.aberrationAmount = v 会抛空指针。修法:先判断存在性。
typescript
if (pipeline.chromaticAberration) {
pipeline.chromaticAberration.aberrationAmount = v;
}水面风向的初始化。 _createWater() 里用了 windDirection,但只在 setEnvState 的 observer 里更新。如果用户先改风向、后创建水体,水体的风向是默认值。修法:在 _createWater() 末尾用 envState.windDirection 初始化。
waveSpeed 的联动。 waterAnimSpeed 调了,只更新了 windForce,没更新 waveSpeed。结果是风大了,浪没大。修法:同时设置 waveSpeed = waterAnimSpeed * 0.8。
typescript
waterMaterial.windForce = envState.waterAnimSpeed;
waterMaterial.waveSpeed = envState.waterAnimSpeed * 0.8;天空旋转的溢出。 rotation.y 每帧累加,时间长了浮点数精度掉,天空开始抖。修法:加模运算。
typescript
skyBox.rotation.y = (skyBox.rotation.y + delta) % (Math.PI * 2);四个问题,四个位置,四个根因各不相同。但它们共享一个结构:某个状态改变了,系统里其他部分不知道。 环境风向变了,水体不知道。动画速度变了,波浪不知道。时间久了,精度不知道。
构建与提交
npx vite build —— 通过。
git commit -m "fix: 相机状态同步与逻辑审查修复" —— 提交。
Riku 关掉终端,盯着屏幕上的相机在 Concert 路径上走着。点一下暂停,相机停了。再点,相机从原地继续。没有重置,没有跳帧。
状态同步,不是特例,是原则。 UI 要知道相机的实时值,功能存在要能被看见,能暂停要能被控制,环境变了所有相关系统都要知道。做到这些不是锦上添花,是基本功。
教训:状态是孤岛,每个模块只知道自己的状态——好的设计要让同步成为默认,而不是靠手工补救。