Skip to content

ADR-162: 感知层 per-model 实例化 — Phase 1(pinned 模型支持)

状态: 已完成(2026-07-21;per-model 真实隔离由 ADR-166 收口;独立审核 frontend 1821 测试 0 失败)⚠️ §6 验收标准已过时 — pin 功能已随 ADR-164/166 整合入 tier 系统(见 §六 勘误) 关联: ADR-071(程序化与感知边界)、ADR-079(感知层扩展)、ADR-147(显式管线调度器) 来源: 2026-07-20 感知层审核 P2 — perceptionState 单例导致多模型场景仅焦点模型有感知 日期: 2026-07-21


一、问题陈述

1.1 现状

感知层采用单例状态 + 单 observer + 单焦点模型架构:

限制位置后果
单例 perceptionState[perception.ts:53](file:///c:/Users/zhujieling11/MikuMikuAR/frontend/src/scene/motion/perception.ts#L53)全场景只有一份呼吸/眨眼/gaze 配置
单值 perceptionModelId[perception.ts:54](file:///c:/Users/zhujieling11/MikuMikuAR/frontend/src/scene/motion/perception.ts#L54)仅焦点模型激活感知
单 observer[perception.ts:95-177](file:///c:/Users/zhujieling11/MikuMikuAR/frontend/src/scene/motion/perception.ts#L95-L177)observer 仅对 perceptionModelId 模型应用

用户感知:多角色场景中,非焦点角色无呼吸/眨眼/视线跟随,呈"木偶"状态。ADR-079 标题"角色永远活着"在多模型场景下不成立。

1.2 为什么不直接做全员感知

全员感知(N 模型 × 6 感知项/帧)有 3 个未验证的风险:

风险未解问题
性能100 模型 × 6 项 = 600 次/帧,未基准测试;对象池容量 [perception-shared.ts:83-85](file:///c:/Users/zhujieling11/MikuMikuAR/frontend/src/scene/motion/perception-shared.ts#L83-L85) 按 28 设计
冲突面模块层 claimBones 已 per-model,感知层 per-model 后 N×M 冲突可能爆炸
UX100 模型各自配置呼吸/眨眼/gaze 不现实,需要"全员默认 + 个别 pin"模式

决策:本 ADR 仅做 Phase 1(pinned 模型支持,≤5 个),Phase 2(全员感知 + 性能降级)立项为 ADR-164,需先做性能基准。


二、设计方案

2.1 核心概念:PerceptionContext

抽象单例为 per-model 上下文:

typescript
interface PerceptionContext {
    modelId: string;
    state: PerceptionState;
    isActive: boolean;
    isPinned: boolean;  // 用户手动 pin,不受焦点切换影响
    lastOffsets: {
        breath: number;
        balance: BalanceSwayState;
        emotion: string | null;
    };
}

// 替换原单例
let _contexts = new Map<string, PerceptionContext>();
let _focusedContextId: string | null = null;

2.2 激活策略

模型类型激活行为
焦点模型自动激活,切换焦点时旧焦点 deactivate(除非 pinned)
Pinned 模型用户手动 pin,始终激活,≤5 个上限
普通模型不激活(Phase 1 不支持全员感知)

2.3 API 变更

typescript
// 既有 API(保留兼容)
export function activatePerception(modelId?: string): void;  // 焦点模型
export function deactivatePerception(): void;                // 注销焦点
export function getPerceptionState(): PerceptionState;       // 焦点 context 状态

// 新增 API
export function pinPerception(modelId: string): void;        // pin 模型(≤5)
export function unpinPerception(modelId: string): void;
export function getPinnedModelIds(): string[];
export function getPerceptionStateFor(modelId: string): PerceptionState;  // 指定模型
export function setPerceptionStateFor(modelId: string, s: Partial<PerceptionState>): void;

2.4 observer 改造

typescript
// 原:单 observer 处理单模型
// 新:单 observer 遍历所有激活 context(焦点 + pinned)
perceptionObserver = getMotionPipeline().register({
    id: 'perception',
    stage: 'perception',
    order: 0,
    run: () => {
        const scene = getScene();
        if (!scene || scene.isDisposed) return;
        const time = performance.now() / 1000;
        const dt = scene.getEngine().getDeltaTime() / 1000;

        // 遍历所有激活 context
        for (const ctx of _contexts.values()) {
            if (!ctx.isActive) continue;
            const inst = modelManager.get(ctx.modelId);
            if (!inst?.mmdModel || inst.mmdModel.mesh?.isDisposed()) {
                _deactivateContext(ctx.modelId);
                continue;
            }
            _applyPerceptionForContext(ctx, inst.mmdModel, time, dt);
        }
    },
});

2.5 序列化

typescript
// 原:perception: { ...getPerceptionState() }
// 新:perception: {
//   focused: PerceptionState,
//   pinned: Array<{ modelId: string, state: PerceptionState }>,
// }

迁移:旧存档 perception: PerceptionState{ focused: oldState, pinned: [] }


三、改动范围

文件改动风险
[perception-shared.ts](file:///c:/Users/zhujieling11/MikuMikuAR/frontend/src/scene/motion/perception-shared.ts)新增 PerceptionContext 类型
[perception.ts](file:///c:/Users/zhujieling11/MikuMikuAR/frontend/src/scene/motion/perception.ts)单例 → Map<modelId, Context>🔴 高(核心重构)
[perception-breathing/balance/expression.ts](file:///c:/Users/zhujieling11/MikuMikuAR/frontend/src/scene/motion)reset/lastOffset 状态移入 Context🟠 中
[scene-serialize.ts](file:///c:/Users/zhujieling11/MikuMikuAR/frontend/src/scene/scene-serialize.ts)序列化 schema 变更🟠 中
[scene-migrate.ts](file:///c:/Users/zhujieling11/MikuMikuAR/frontend/src/scene/scene-migrate.ts)旧存档迁移🟡 低
[menu-schema.ts](file:///c:/Users/zhujieling11/MikuMikuAR/frontend/src/menus/menu-schema.ts)getPerceptionState()[key] → 指定 modelId🟠 中
[motion-gaze-levels.ts](file:///c:/Users/zhujieling11/MikuMikuMikuAR/frontend/src/menus/motion-gaze-levels.ts)UI 加「pin 当前模型」按钮🟡 低
[ar-scene.ts](file:///c:/Users/zhujieling11/MikuMikuAR/frontend/src/scene/ar/ar-scene.ts)读焦点 context 而非单例🟡 低
测试perception.test.ts 大改🟠 中

四、风险评估

风险级别缓解措施
核心重构破坏现有 57 项 perception 测试🔴 高分步迁移:先抽 Context,单例行为不变;再加 pin
序列化 schema 破坏旧存档🟠 中迁移函数兜底:旧格式 → 新格式
UI「当前编辑模型」概念引入🟠 中焦点模型即默认编辑对象,pin 模型在独立面板编辑
对象池容量不足(5 模型 × 28 = 140 > 32)🟠 中池扩容或改 per-context 池
循环依赖🟡 低Context 类型放 perception-shared.ts,无新依赖

五、实施计划

阶段内容风险点
Phase 1PerceptionContext 类型;单例包装为 _contexts.get(focusedId)保持行为不变
Phase 2observer 改遍历;_applyPerceptionForContext 抽取现有测试需调整
Phase 3pinPerception / unpinPerception API;UI 加 pin 按钮新功能
Phase 4序列化 schema + 迁移;ar-scene 适配旧存档兼容
Phase 5测试重构 + 新增 pin 测试全量回归

六、验收标准

标准验证方法
焦点模型感知行为不变加载单模型,呼吸/眨眼/gaze 正常
Pinned 模型在焦点切换时仍激活pin 模型 A,切焦点到 B,A 仍呼吸
Pinned 上限已移除(受 ADR-164 tier 控制)pin 第 6+ 个时正常运作,不 warn 不拒绝
旧存档加载后使用默认值加载旧场景,无 NaN
57 项 perception 测试全绿npm run test -- perception.test.ts
新增 pin 相关测试通过npm run test -- perception.test.ts

⚠️ ADR-166 返工同步(2026-07-21): §6 验收标准已过时。Phase 1 pin 功能已随 ADR-164/166 整合入 tier 系统。当前测试基线 1833 项全绿,pinned 模型在 low 档仍保留感知,由 ADR-164 tier 统一控制。旧"57 项"基线已升级为全量回归。


七、与 ADR-164(Phase 2)的边界

Phase 1(本 ADR)Phase 2(ADR-164)
激活模型数焦点 + pinned(上限由 ADR-164 tier 控制:high=全员、medium=焦点+pinned+前 10 个、low=仅焦点+pinned)全员
性能降级不需要三档(high/medium/low)
UIpin 按钮全员默认开关
性能基准不需要必须先做

八、开放问题

  1. Pinned 模型的感知参数是否独立? 当前设计是独立(per-context state),但 UI 编辑 pinned 模型参数需切换"编辑对象"。可考虑「继承焦点参数 + 个别覆盖」模式。
  2. Pinned 上限 5 是否合适? 需实测性能后决定。若 5 模型 × 28 = 140 对象池消费无压力,可上调。 → ADR-166 返工后 pinned 上限已移除,由 ADR-164 tier 系统统一控制。
  3. AR 模式下 pin 行为? AR 中通常仅 1 个模型,pin 意义不大,可隐藏 UI。