Appearance
第 4 章 · 双轴分野
对应真实事件:ADR-100 相机控制方案 × 运动行为 双轴拆分(P1 契约+shim、P2 运行时接线;提交 7e2c7d5 / f336ea1) 背景:相机 7 模式塞进单枚举,自动运镜成游离第四态 过程:拆控制×行为两轴,beatcut 互斥派生修饥饿
一、惊醒
我重启了联邦的窗口。模型在舞台上站定,灯光照常亮起,可镜头却像一座有七道门、却没有地图的城邦——orbit、freefly、surround、concert、oneshot、vmd、ar,七个名字挤在同一个枚举里,谁也分不清哪道门通的是"怎么看",哪道门通的是"自己会动"。
更诡异的是,还有一扇不在户籍上的偏门。它不叫任何名字,不在枚举里:自动运镜 是一个游离在七门之外的开关,被硬塞进 orbit 与 concert 的生效条件里,像一扇只在别人家里借住的门。我翻出上一次存档,曾勾选过自动运镜,重新载入——镜头安静地转着,却一次也没切过。节拍在响,镜头却聋了。
那是我的第一句证词:存档恢复后,自动运镜亮着灯,却再没切过一次。
二、追查
我把 camera.ts 摊在光下,一页页读。
ts
export type CameraMode = 'orbit' | 'freefly' | 'surround'
| 'concert' | 'oneshot' | 'vmd' | 'ar'; // 七个值,一座拥挤的城七个值。可真正决定"镜头怎么动"的,只有三类:绕着看的(orbit)、自由飞的(freefly)、贴脸的(ar)。其余四个——surround 是 orbit 加转台,concert 是 orbit 加演唱会摆动,vmd/oneshot 是 orbit 加脚本轨迹。控制与行为,从一开始就被缝在同一块布里。
再往下,是那扇偏门的真身:
ts
export function restoreAutoCameraState(): void {
const s = uiState;
if (s.autoCameraEnabled) {
_autoCameraEnabled = true; // ← 灯亮了
// …却没有人去订阅节拍
}
}我顺着 _onAutoCameraBeat 往下读,发现它的门禁写在函数最末尾,而 restoreAutoCameraState 点亮开关后,压根没把这根线接到节拍源上。于是 procBeatDetector 每帧都在 scene.ts:191 出生、在 playback.ts:67 被喂拍——可这扇门后的耳朵,从没被唤醒。饿死,不是因为没饭,是因为没人把碗递过去。
我去找外交官。
"七道门里,三道是路,四道是路上跑的车。"我把枚举摊给他,"自动运镜甚至不在门里,是个寄生开关。存档一恢复,它亮灯却不接拍——镜头聋了。"
外交官没翻文档,只点了四个数:"行为互斥,单活一个;oneshot 直接并进 scripted;别名留着别急着删;先 P1–P2 验组合。"——这正是后来写进 ADR-100 §九 的四道裁定。
三、真相
我们动刀。先立两轴,不删一门。
ts
export type CameraControl = 'orbit' | 'freefly' | 'ar'; // 3 条路
export type CameraBehavior = 'none' | 'turntable' | 'concert'
| 'beatcut' | 'scripted'; // 5 种跑法
export type ScriptedSubMode = 'loop' | 'oneshot'; // 脚本的两副面孔旧的七门没有消失,只是搬进了 LEGACY_MODE_MAP,像一本户籍簿:
ts
orbit: { control: 'orbit', behavior: 'none' },
surround:{ control: 'orbit', behavior: 'turntable' },
concert: { control: 'orbit', behavior: 'concert' },
vmd: { control: 'orbit', behavior: 'scripted', scripted: 'loop' },
oneshot: { control: 'orbit', behavior: 'scripted', scripted: 'oneshot' },
freefly: { control: 'freefly', behavior: 'none' },
ar: { control: 'ar', behavior: 'none' },P1 只是把 switchCameraMode 改成读这本簿子——签名不动,调用点零改,旧测试全绿。一座城换了户籍系统,居民毫无察觉。
真正的变局在 P2。那扇偏门,不再是寄生开关,而成了派生行为:
ts
function _resolveBehavior(mode: CameraMode): CameraBehavior {
const m = LEGACY_MODE_MAP[mode];
if (_autoCameraEnabled && m.control === 'orbit' && m.behavior === 'none') {
return 'beatcut'; // 只有 orbit + 静止 时,节拍切镜才降生
}
return m.behavior;
}互斥,从此不是靠 if/else 去掐对方,而是自然发生:你选了 concert 或 turntable 或 scripted,基底行为就不再是 none,beatcut 便无从派生——自动运镜被优雅地"挂起",开关还亮着,切回 orbit 又自动复活。
而那桩饿死案,被一句话了结。订阅收归相机内部,toggle 与 restore 两条路共用同一根线:
ts
function _subscribeAutoCameraBeat(detector?) {
_unsubscribeAutoCameraBeat();
const bd = detector ?? getProcBeatDetector();
if (bd) _autoCameraUnsub = bd.onBeat(_onAutoCameraBeat);
}
// restore 里:_autoCameraEnabled = true; … _subscribeAutoCameraBeat(); // 碗,递过去了门禁也挪到了 _onAutoCameraBeat 头顶(camera.ts:1294):
ts
function _onAutoCameraBeat(): void {
if (_cameraBehavior !== 'beatcut') return; // 被挂起时不消耗任何一拍
// …
}我重新载入那次存档。勾选过自动运镜——灯亮,线接,第一拍落下,镜头稳稳切了过去。聋了许久的耳朵,终于听见了节拍。
四、教训
一座城把"路"和"车"写进同一本户籍,门就会越开越多,直到第七道门后藏着一扇没有名字的偏门。ADR-100 做的,不是多开一道门,而是把户籍重新分作两栏:一栏写"怎么走"(控制轴),一栏写"路上跑什么"(行为轴)。偏门不再是寄生者,而成了一张合法出生证——beatcut,只在 orbit 静立、且自动运镜亮起时,才被签发。
聚合之思:联邦收编的每一个城邦,都带着自己的脾气;可当脾气和道路被缝在同一块布里,联邦自己也会迷失方向。分轴,不是分裂,是让每一束能力,各有其合法的国境。
教训:拥挤的枚举是一座无区划的城;分轴,给每束能力以国境。