Skip to content

多相机之眼

背景: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 要知道相机的实时值,功能存在要能被看见,能暂停要能被控制,环境变了所有相关系统都要知道。做到这些不是锦上添花,是基本功。


教训:状态是孤岛,每个模块只知道自己的状态——好的设计要让同步成为默认,而不是靠手工补救。